Skip to content

Commit

Permalink
feat(profiling): add endpoint to generate flamegraph from span group …
Browse files Browse the repository at this point in the history
…for continuous profiling (#73843)

depends on getsentry/vroom#482
  • Loading branch information
viglia authored and priscilawebdev committed Jul 11, 2024
1 parent a3a56ba commit f5e7418
Show file tree
Hide file tree
Showing 5 changed files with 325 additions and 5 deletions.
40 changes: 37 additions & 3 deletions src/sentry/api/endpoints/organization_profiling_profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@
from sentry.exceptions import InvalidSearchQuery
from sentry.models.organization import Organization
from sentry.profiles.flamegraph import (
get_chunks_from_spans_metadata,
get_profile_ids,
get_profile_ids_with_spans,
get_profiles_with_function,
get_spans_from_group,
)
from sentry.profiles.profile_chunks import get_chunk_ids
from sentry.profiles.utils import parse_profile_filters, proxy_profiling_service
Expand Down Expand Up @@ -63,7 +65,7 @@ def get(self, request: Request, organization: Organization) -> HttpResponse:
if not features.has("organizations:profiling", organization, actor=request.user):
return Response(status=404)

params = self.get_snuba_params(request, organization, check_global_views=False)
params = self.get_snuba_params(request, organization)
project_ids = params["project_id"]
if len(project_ids) > 1:
raise ParseError(detail="You cannot get a flamegraph from multiple projects.")
Expand Down Expand Up @@ -101,10 +103,10 @@ def get(self, request: Request, organization: Organization) -> HttpResponse:
@region_silo_endpoint
class OrganizationProfilingChunksEndpoint(OrganizationProfilingBaseEndpoint):
def get(self, request: Request, organization: Organization) -> HttpResponse:
if not features.has("organizations:profiling", organization, actor=request.user):
if not features.has("organizations:continuous-profiling", organization, actor=request.user):
return Response(status=404)

params = self.get_snuba_params(request, organization, check_global_views=False)
params = self.get_snuba_params(request, organization)

project_ids = params.get("project_id")
if project_ids is None or len(project_ids) != 1:
Expand All @@ -123,3 +125,35 @@ def get(self, request: Request, organization: Organization) -> HttpResponse:
"chunk_ids": [el["chunk_id"] for el in chunk_ids],
},
)


@region_silo_endpoint
class OrganizationProfilingChunksFlamegraphEndpoint(OrganizationProfilingBaseEndpoint):
def get(self, request: Request, organization: Organization) -> HttpResponse:
if not features.has("organizations:profiling", organization, actor=request.user):
return Response(status=404)

params = self.get_snuba_params(request, organization)

project_ids = params.get("project_id")
if project_ids is None or len(project_ids) != 1:
raise ParseError(detail="one project_id must be specified.")

span_group = request.query_params.get("span_group")
if span_group is None:
raise ParseError(detail="span_group must be specified.")

spans = get_spans_from_group(
organization.id,
project_ids[0],
params,
span_group,
)

chunksMetadata = get_chunks_from_spans_metadata(organization.id, project_ids[0], spans)

return proxy_profiling_service(
method="POST",
path=f"/organizations/{organization.id}/projects/{project_ids[0]}/chunks-flamegraph",
json_data={"chunks_metadata": chunksMetadata},
)
6 changes: 6 additions & 0 deletions src/sentry/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,7 @@
from .endpoints.organization_profiling_functions import OrganizationProfilingFunctionTrendsEndpoint
from .endpoints.organization_profiling_profiles import (
OrganizationProfilingChunksEndpoint,
OrganizationProfilingChunksFlamegraphEndpoint,
OrganizationProfilingFiltersEndpoint,
OrganizationProfilingFlamegraphEndpoint,
)
Expand Down Expand Up @@ -2114,6 +2115,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
OrganizationProfilingFlamegraphEndpoint.as_view(),
name="sentry-api-0-organization-profiling-flamegraph",
),
re_path(
r"^chunks-flamegraph/$",
OrganizationProfilingChunksFlamegraphEndpoint.as_view(),
name="sentry-api-0-organization-profiling-chunks-flamegraph",
),
re_path(
r"^function-trends/$",
OrganizationProfilingFunctionTrendsEndpoint.as_view(),
Expand Down
179 changes: 177 additions & 2 deletions src/sentry/profiles/flamegraph.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
from collections import defaultdict
from datetime import datetime
from typing import Any, TypedDict

from snuba_sdk import Column, Condition, Entity, Function, Limit, Op, Query, Request
from snuba_sdk import (
And,
BooleanCondition,
Column,
Condition,
Entity,
Function,
Limit,
Op,
Or,
Query,
Request,
Storage,
)

from sentry import options
from sentry.search.events.builder.discover import DiscoverQueryBuilder
from sentry.search.events.types import ParamsType
from sentry.snuba import functions
from sentry.snuba.dataset import Dataset, EntityKey
from sentry.snuba.dataset import Dataset, EntityKey, StorageKey
from sentry.snuba.referrer import Referrer
from sentry.utils.snuba import raw_snql_query

Expand Down Expand Up @@ -176,3 +190,164 @@ def extract_profile_ids() -> list[str]:
return profile_ids

return {"profile_ids": extract_profile_ids()}


class IntervalMetadata(TypedDict):
start: str
end: str
active_thread_id: str


def get_spans_from_group(
organization_id: int,
project_id: int,
params: ParamsType,
span_group: str,
) -> dict[str, list[IntervalMetadata]]:
query = Query(
match=Entity(EntityKey.Spans.value),
select=[
Column("start_timestamp_precise"),
Column("end_timestamp_precise"),
Function(
"arrayElement",
parameters=[
Column("sentry_tags.value"),
Function(
"indexOf",
parameters=[
Column("sentry_tags.key"),
"profiler_id",
],
),
],
alias="profiler_id",
),
Function(
"arrayElement",
parameters=[
Column("sentry_tags.value"),
Function(
"indexOf",
parameters=[
Column("sentry_tags.key"),
"thread.id",
],
),
],
alias="active_thread_id",
),
],
where=[
Condition(Column("project_id"), Op.EQ, project_id),
Condition(Column("timestamp"), Op.GTE, params["start"]),
Condition(Column("timestamp"), Op.LT, params["end"]),
Condition(Column("group"), Op.EQ, span_group),
Condition(Column("profiler_id"), Op.NEQ, ""),
],
limit=Limit(100),
)
request = Request(
dataset=Dataset.SpansIndexed.value,
app_id="default",
query=query,
tenant_ids={
"referrer": Referrer.API_PROFILING_FLAMEGRAPH_SPANS_WITH_GROUP.value,
"organization_id": organization_id,
},
)
data = raw_snql_query(
request,
referrer=Referrer.API_PROFILING_FLAMEGRAPH_SPANS_WITH_GROUP.value,
)["data"]
spans: dict[str, list[IntervalMetadata]] = defaultdict(list)
for row in data:
spans[row["profiler_id"]].append(
{
"active_thread_id": row["active_thread_id"],
"start": row["start_timestamp_precise"],
"end": row["end_timestamp_precise"],
}
)

return spans


class SpanMetadata(TypedDict):
profiler_id: list[IntervalMetadata]


def get_chunk_snuba_conditions_from_spans_metadata(
spans: dict[str, list[IntervalMetadata]],
) -> list[BooleanCondition | Condition]:
cond = []
for profiler_id, intervals in spans.items():
chunk_range_cond = []
for interval in intervals:
start = interval.get("start")
end = interval.get("end")
chunk_range_cond.append(
And(
[
Condition(Column("end_timestamp"), Op.GTE, start),
Condition(Column("start_timestamp"), Op.LT, end),
],
)
)
cond.append(
And(
[
Condition(Column("profiler_id"), Op.EQ, profiler_id),
Or(chunk_range_cond) if len(chunk_range_cond) >= 2 else chunk_range_cond[0],
]
)
)
return [Or(cond)] if len(cond) >= 2 else cond


def get_chunks_from_spans_metadata(
organization_id: int,
project_id: int,
spans: dict[str, list[IntervalMetadata]],
) -> list[dict[str, Any]]:
query = Query(
match=Storage(StorageKey.ProfileChunks.value),
select=[
Column("profiler_id"),
Column("chunk_id"),
],
where=[Condition(Column("project_id"), Op.EQ, project_id)]
+ get_chunk_snuba_conditions_from_spans_metadata(spans),
limit=Limit(100),
)
request = Request(
dataset=Dataset.Profiles.value,
app_id="default",
query=query,
tenant_ids={
"referrer": Referrer.API_PROFILING_FLAMEGRAPH_CHUNKS_FROM_SPANS.value,
"organization_id": organization_id,
},
)
data = raw_snql_query(
request,
referrer=Referrer.API_PROFILING_FLAMEGRAPH_CHUNKS_FROM_SPANS.value,
)["data"]
chunks = []
for row in data:
intervals = [
{
"start": str(int(datetime.fromisoformat(el["start"]).timestamp() * 10**9)),
"end": str(int(datetime.fromisoformat(el["end"]).timestamp() * 10**9)),
"active_thread_id": el["active_thread_id"],
}
for el in spans[row["profiler_id"]]
]
chunks.append(
{
"profiler_id": row["profiler_id"],
"chunk_id": row["chunk_id"],
"span_intervals": intervals,
}
)
return chunks
2 changes: 2 additions & 0 deletions src/sentry/snuba/referrer.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,8 @@ class Referrer(Enum):
API_PROFILING_PROFILE_SUMMARY_TABLE = "api.profiling.profile-summary-table"
API_PROFILING_PROFILE_SUMMARY_FUNCTIONS_TABLE = "api.profiling.profile-summary-functions-table"
API_PROFILING_PROFILE_FLAMEGRAPH = "api.profiling.profile-flamegraph"
API_PROFILING_FLAMEGRAPH_SPANS_WITH_GROUP = "api.profiling.flamegraph-spans-with-group"
API_PROFILING_FLAMEGRAPH_CHUNKS_FROM_SPANS = "api.profiling.flamegraph-chunks-with-spans"
API_PROFILING_FUNCTION_SCOPED_FLAMEGRAPH = "api.profiling.function-scoped-flamegraph"
API_PROFILING_TRANSACTION_HOVERCARD_FUNCTIONS = "api.profiling.transaction-hovercard.functions"
API_PROFILING_TRANSACTION_HOVERCARD_LATEST = "api.profiling.transaction-hovercard.latest"
Expand Down
103 changes: 103 additions & 0 deletions tests/sentry/profiling/test_flamegraph.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import pytest
from snuba_sdk import And, Column, Condition, Op, Or

from sentry.profiles.flamegraph import get_chunk_snuba_conditions_from_spans_metadata


@pytest.mark.parametrize(
["span_metadata", "expected_condition"],
[
(
{
"0000": [
{"start": "1", "end": "2"},
{"start": "5", "end": "7"},
]
},
[
And(
[
Condition(Column("profiler_id"), Op.EQ, "0000"),
Or(
[
And(
[
Condition(Column("end_timestamp"), Op.GTE, "1"),
Condition(Column("start_timestamp"), Op.LT, "2"),
]
),
And(
[
Condition(Column("end_timestamp"), Op.GTE, "5"),
Condition(Column("start_timestamp"), Op.LT, "7"),
]
),
]
), # end Or
]
)
], # end And
),
(
{
"1111": [
{"start": "1", "end": "2"},
]
},
[
And(
[
Condition(Column("profiler_id"), Op.EQ, "1111"),
And(
[
Condition(Column("end_timestamp"), Op.GTE, "1"),
Condition(Column("start_timestamp"), Op.LT, "2"),
]
),
]
)
], # end And
),
(
{
"1111": [
{"start": "1", "end": "2"},
],
"2222": [
{"start": "1", "end": "2"},
],
},
[
Or(
[
And(
[
Condition(Column("profiler_id"), Op.EQ, "1111"),
And(
[
Condition(Column("end_timestamp"), Op.GTE, "1"),
Condition(Column("start_timestamp"), Op.LT, "2"),
]
),
]
),
And(
[
Condition(Column("profiler_id"), Op.EQ, "2222"),
And(
[
Condition(Column("end_timestamp"), Op.GTE, "1"),
Condition(Column("start_timestamp"), Op.LT, "2"),
]
),
]
),
]
)
],
),
],
)
def test_get_chunk_snuba_conditions_from_spans_metadata(span_metadata, expected_condition):
condition = get_chunk_snuba_conditions_from_spans_metadata(span_metadata)
assert condition == expected_condition

0 comments on commit f5e7418

Please sign in to comment.