From df193e89c7b09cf74a80e399d98932ad4a1e1bbe Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Sat, 22 Nov 2025 02:09:39 -0800 Subject: [PATCH 01/35] init --- src/sentry/seer/explorer/tools.py | 101 ++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index b08e1a93a41fd9..a6fdffea027917 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -3,6 +3,9 @@ from datetime import UTC, datetime, timedelta, timezone from typing import Any, cast +from sentry_protos.snuba.v1.endpoint_get_trace_pb2 import GetTraceRequest +from sentry_protos.snuba.v1.request_common_pb2 import TraceItemType + from sentry import eventstore, features from sentry.api import client from sentry.api.endpoints.organization_events_timeseries import TOP_EVENTS_DATASETS @@ -18,6 +21,7 @@ from sentry.models.repository import Repository from sentry.replays.post_process import process_raw_response from sentry.replays.query import query_replay_id_by_prefix, query_replay_instance +from sentry.search.eap.constants import DOUBLE, INT, STRING from sentry.search.eap.types import SearchResolverConfig from sentry.search.events.types import SAMPLING_MODES, SnubaParams from sentry.seer.autofix.autofix import get_all_tags_overview @@ -26,10 +30,12 @@ from sentry.seer.sentry_data_models import EAPTrace from sentry.services.eventstore.models import Event, GroupEvent from sentry.snuba.referrer import Referrer +from sentry.snuba.rpc_dataset_common import RPCBase from sentry.snuba.spans_rpc import Spans from sentry.snuba.trace import query_trace_data from sentry.snuba.utils import get_dataset from sentry.utils.dates import parse_stats_period +from sentry.utils.snuba_rpc import get_trace_rpc logger = logging.getLogger(__name__) @@ -237,6 +243,101 @@ def execute_timeseries_query( return data +def execute_trace_query( + *, + org_id: int, + trace_id: str, + dataset: str, + fields: list[str], + query: str, + project_ids: list[int] | None = None, + stats_period: str | None = None, + start: str | None = None, + end: str | None = None, + sampling_mode: SAMPLING_MODES = "NORMAL", +) -> dict[str, Any] | None: + + _validate_date_params(stats_period=stats_period, start=start, end=end) + + try: + organization = Organization.objects.get(id=org_id) + except Organization.DoesNotExist: + logger.warning("Organization not found", extra={"org_id": org_id}) + return None + + projects = list( + Project.objects.filter( + organization=organization, + status=ObjectStatus.ACTIVE, + **({"id__in": project_ids} if project_ids else {}), + ) + ) + + if dataset == "spans": + trace_item_type = TraceItemType.TRACE_ITEM_TYPE_SPAN + elif dataset == "errors": + trace_item_type = TraceItemType.TRACE_ITEM_TYPE_ERROR + elif dataset == "issuePlatform": + trace_item_type = TraceItemType.TRACE_ITEM_TYPE_OCCURRENCE + elif dataset == "logs" or dataset == "ourlogs": + trace_item_type = TraceItemType.TRACE_ITEM_TYPE_LOG + elif dataset == "tracemetrics": + trace_item_type = TraceItemType.TRACE_ITEM_TYPE_METRIC + else: + # Might work for other item types but we choose not to support them for now. + raise NotImplementedError(f"Unsupported dataset: {dataset}") + + snuba_params = SnubaParams( + start=start, + end=end, + projects=projects, + organization=organization, + sampling_mode=sampling_mode, + ) + + resolver = RPCBase.get_resolver(params=snuba_params, config=SearchResolverConfig()) + columns, _ = resolver.resolve_attributes(fields) + meta = resolver.resolve_meta(referrer=Referrer.SEER_RPC) + request = GetTraceRequest( + meta=meta, + trace_id=trace_id, + items=[ + GetTraceRequest.TraceItem( + item_type=trace_item_type, + attributes=[col.proto_definition for col in columns], + ) + ], + ) + response = get_trace_rpc(request) + + results = [] + columns_by_name = {col.proto_definition.name: col for col in columns} + for item_group in response.item_groups: + for item in item_group.items: + item_dict: dict[str, Any] = { + "id": item.id, + } + for attribute in item.attributes: + resolved_column = columns_by_name[attribute.key.name] + if resolved_column.proto_definition.type == STRING: + item_dict[resolved_column.public_alias] = attribute.value.val_str + elif resolved_column.proto_definition.type == DOUBLE: + item_dict[resolved_column.public_alias] = attribute.value.val_double + elif resolved_column.search_type == "boolean": + item_dict[resolved_column.public_alias] = attribute.value.val_int == 1 + elif resolved_column.proto_definition.type == INT: + item_dict[resolved_column.public_alias] = attribute.value.val_int + if resolved_column.public_alias == "project.id": + item_dict["project.slug"] = resolver.params.project_id_map.get( + item_dict[resolved_column.public_alias], "Unknown" + ) + results.append(item_dict) + + break # There should only be one item group + + return {"data": results} + + def get_trace_waterfall(trace_id: str, organization_id: int) -> EAPTrace | None: """ Get the full span waterfall and connected errors for a trace. From a4d1c58cecfa9a29889695aa5f393f593d21d672 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Sat, 22 Nov 2025 02:14:07 -0800 Subject: [PATCH 02/35] fix dt --- src/sentry/seer/explorer/tools.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index a6fdffea027917..8d3e762e431843 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -287,9 +287,13 @@ def execute_trace_query( # Might work for other item types but we choose not to support them for now. raise NotImplementedError(f"Unsupported dataset: {dataset}") + start_dt = datetime.fromisoformat(start) if start else None + end_dt = datetime.fromisoformat(end) if end else None + snuba_params = SnubaParams( - start=start, - end=end, + start=start_dt, + end=end_dt, + stats_period=stats_period, projects=projects, organization=organization, sampling_mode=sampling_mode, From 5a170204e471808448b41419456eb1f9a5f8312a Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Sat, 22 Nov 2025 02:20:40 -0800 Subject: [PATCH 03/35] support short id and use query --- src/sentry/seer/explorer/tools.py | 120 +++++++++++++++++------------- 1 file changed, 67 insertions(+), 53 deletions(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index 8d3e762e431843..5261886d8045a5 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -56,6 +56,53 @@ def _validate_date_params( raise ValueError("start and end must be provided together") +def _get_full_trace_id( + short_trace_id: str, organization: Organization, projects: list[Project] +) -> str | None: + """ + Get full trace id if a short id is provided. Queries EAP for a single span. + Use sliding 14-day windows starting from most recent, up to 90 days in the past, to avoid timeouts. + TODO: This query ignores the trace_id column index and can do large scans, and is a good candidate for optimization. + This can be done with a materialized string column for the first 8 chars and a secondary index. + Alternatively we can try more consistent ways of passing the full ID to Explorer. + """ + now = datetime.now(timezone.utc) + window_days = 14 + max_days = 90 + + # Slide back in time in 14-day windows + for days_back in range(0, max_days, window_days): + window_end = now - timedelta(days=days_back) + window_start = now - timedelta(days=min(days_back + window_days, max_days)) + + snuba_params = SnubaParams( + start=window_start, + end=window_end, + projects=projects, + organization=organization, + debug=True, + ) + + subquery_result = Spans.run_table_query( + params=snuba_params, + query_string=f"trace:{short_trace_id}", + selected_columns=["trace"], + orderby=[], + offset=0, + limit=1, + referrer=Referrer.SEER_RPC, + config=SearchResolverConfig(), + sampling_mode=None, + ) + + data = subquery_result.get("data") + full_trace_id = data[0].get("trace") if data else None + if full_trace_id: + return full_trace_id + + return None + + def execute_table_query( *, org_id: int, @@ -249,7 +296,7 @@ def execute_trace_query( trace_id: str, dataset: str, fields: list[str], - query: str, + query: str | None = None, project_ids: list[int] | None = None, stats_period: str | None = None, start: str | None = None, @@ -273,6 +320,17 @@ def execute_trace_query( ) ) + if len(trace_id) < 32: + full_trace_id = _get_full_trace_id(trace_id, organization, projects) + if not full_trace_id: + logger.warning( + "execute_trace_query: No full trace id found for short trace id", + extra={"org_id": org_id, "trace_id": trace_id}, + ) + return None + else: + full_trace_id = trace_id + if dataset == "spans": trace_item_type = TraceItemType.TRACE_ITEM_TYPE_SPAN elif dataset == "errors": @@ -294,6 +352,7 @@ def execute_trace_query( start=start_dt, end=end_dt, stats_period=stats_period, + query_string=query, projects=projects, organization=organization, sampling_mode=sampling_mode, @@ -304,7 +363,7 @@ def execute_trace_query( meta = resolver.resolve_meta(referrer=Referrer.SEER_RPC) request = GetTraceRequest( meta=meta, - trace_id=trace_id, + trace_id=full_trace_id, items=[ GetTraceRequest.TraceItem( item_type=trace_item_type, @@ -365,59 +424,14 @@ def get_trace_waterfall(trace_id: str, organization_id: int) -> EAPTrace | None: projects = list(Project.objects.filter(organization=organization, status=ObjectStatus.ACTIVE)) - # Get full trace id if a short id is provided. Queries EAP for a single span. - # Use sliding 14-day windows starting from most recent, up to 90 days in the past, to avoid timeouts. - # TODO: This query ignores the trace_id column index and can do large scans, and is a good candidate for optimization. - # This can be done with a materialized string column for the first 8 chars and a secondary index. - # Alternatively we can try more consistent ways of passing the full ID to Explorer. if len(trace_id) < 32: - full_trace_id = None - now = datetime.now(timezone.utc) - window_days = 14 - max_days = 90 - - # Slide back in time in 14-day windows - for days_back in range(0, max_days, window_days): - window_end = now - timedelta(days=days_back) - window_start = now - timedelta(days=min(days_back + window_days, max_days)) - - snuba_params = SnubaParams( - start=window_start, - end=window_end, - projects=projects, - organization=organization, - debug=True, - ) - - subquery_result = Spans.run_table_query( - params=snuba_params, - query_string=f"trace:{trace_id}", - selected_columns=["trace"], - orderby=[], - offset=0, - limit=1, - referrer=Referrer.SEER_RPC, - config=SearchResolverConfig(), - sampling_mode=None, + full_trace_id = _get_full_trace_id(trace_id, organization, projects) + if not full_trace_id: + logger.warning( + "get_trace_waterfall: No full trace id found for short trace id", + extra={"organization_id": organization_id, "trace_id": trace_id}, ) - - data = subquery_result.get("data") - if not data: - # Temporary debug log - logger.warning( - "get_trace_waterfall: No data returned from short id query", - extra={ - "organization_id": organization_id, - "trace_id": trace_id, - "subquery_result": subquery_result, - "start": window_start.isoformat(), - "end": window_end.isoformat(), - }, - ) - - full_trace_id = data[0].get("trace") if data else None - if full_trace_id: - break + return None else: full_trace_id = trace_id From 4483c062ec36e9a4bce62280f959b729aec5c116 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Sat, 22 Nov 2025 20:54:44 -0800 Subject: [PATCH 04/35] try trace_query rpc --- src/sentry/seer/explorer/tools.py | 60 ++++++++++-------------- tests/sentry/seer/explorer/test_tools.py | 58 ++++++++++++++++++++++- 2 files changed, 83 insertions(+), 35 deletions(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index 5261886d8045a5..f570696b0c962b 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -1,10 +1,9 @@ import logging import uuid from datetime import UTC, datetime, timedelta, timezone -from typing import Any, cast +from typing import Any, Literal, cast from sentry_protos.snuba.v1.endpoint_get_trace_pb2 import GetTraceRequest -from sentry_protos.snuba.v1.request_common_pb2 import TraceItemType from sentry import eventstore, features from sentry.api import client @@ -29,10 +28,12 @@ from sentry.seer.explorer.utils import _convert_profile_to_execution_tree, fetch_profile_data from sentry.seer.sentry_data_models import EAPTrace from sentry.services.eventstore.models import Event, GroupEvent +from sentry.snuba.ourlogs import OurLogs from sentry.snuba.referrer import Referrer from sentry.snuba.rpc_dataset_common import RPCBase from sentry.snuba.spans_rpc import Spans from sentry.snuba.trace import query_trace_data +from sentry.snuba.trace_metrics import TraceMetrics from sentry.snuba.utils import get_dataset from sentry.utils.dates import parse_stats_period from sentry.utils.snuba_rpc import get_trace_rpc @@ -294,14 +295,15 @@ def execute_trace_query( *, org_id: int, trace_id: str, - dataset: str, - fields: list[str], + dataset: Literal["spans", "logs", "tracemetrics"], + attributes: list[str], query: str | None = None, project_ids: list[int] | None = None, stats_period: str | None = None, start: str | None = None, end: str | None = None, sampling_mode: SAMPLING_MODES = "NORMAL", + limit: int | None = None, ) -> dict[str, Any] | None: _validate_date_params(stats_period=stats_period, start=start, end=end) @@ -320,6 +322,7 @@ def execute_trace_query( ) ) + # Look up full trace id if a short id is provided. if len(trace_id) < 32: full_trace_id = _get_full_trace_id(trace_id, organization, projects) if not full_trace_id: @@ -331,19 +334,14 @@ def execute_trace_query( else: full_trace_id = trace_id + # Build the GetTraceRequest. + rpc_cls: type[RPCBase] if dataset == "spans": - trace_item_type = TraceItemType.TRACE_ITEM_TYPE_SPAN - elif dataset == "errors": - trace_item_type = TraceItemType.TRACE_ITEM_TYPE_ERROR - elif dataset == "issuePlatform": - trace_item_type = TraceItemType.TRACE_ITEM_TYPE_OCCURRENCE - elif dataset == "logs" or dataset == "ourlogs": - trace_item_type = TraceItemType.TRACE_ITEM_TYPE_LOG - elif dataset == "tracemetrics": - trace_item_type = TraceItemType.TRACE_ITEM_TYPE_METRIC + rpc_cls = Spans + elif dataset == "logs": + rpc_cls = OurLogs else: - # Might work for other item types but we choose not to support them for now. - raise NotImplementedError(f"Unsupported dataset: {dataset}") + rpc_cls = TraceMetrics start_dt = datetime.fromisoformat(start) if start else None end_dt = datetime.fromisoformat(end) if end else None @@ -352,28 +350,32 @@ def execute_trace_query( start=start_dt, end=end_dt, stats_period=stats_period, - query_string=query, projects=projects, organization=organization, sampling_mode=sampling_mode, ) - resolver = RPCBase.get_resolver(params=snuba_params, config=SearchResolverConfig()) - columns, _ = resolver.resolve_attributes(fields) - meta = resolver.resolve_meta(referrer=Referrer.SEER_RPC) + resolver = rpc_cls.get_resolver(params=snuba_params, config=SearchResolverConfig()) + columns, _ = resolver.resolve_attributes(attributes) + meta = resolver.resolve_meta(referrer=Referrer.SEER_RPC, sampling_mode=sampling_mode) request = GetTraceRequest( meta=meta, trace_id=full_trace_id, items=[ GetTraceRequest.TraceItem( - item_type=trace_item_type, + item_type=meta.trace_item_type, attributes=[col.proto_definition for col in columns], ) ], ) + if limit: + request.limit = limit + + # Query EAP EndpointGetTrace and format the response. response = get_trace_rpc(request) + # print(response) - results = [] + items: list[dict[str, Any]] = [] columns_by_name = {col.proto_definition.name: col for col in columns} for item_group in response.item_groups: for item in item_group.items: @@ -394,11 +396,11 @@ def execute_trace_query( item_dict["project.slug"] = resolver.params.project_id_map.get( item_dict[resolved_column.public_alias], "Unknown" ) - results.append(item_dict) + items.append(item_dict) - break # There should only be one item group + # break # There should only be one item group, for the requested item type. - return {"data": results} + return {"data": items} def get_trace_waterfall(trace_id: str, organization_id: int) -> EAPTrace | None: @@ -435,16 +437,6 @@ def get_trace_waterfall(trace_id: str, organization_id: int) -> EAPTrace | None: else: full_trace_id = trace_id - if not isinstance(full_trace_id, str): - logger.warning( - "get_trace_waterfall: Trace not found from short id", - extra={ - "organization_id": organization_id, - "trace_id": trace_id, - }, - ) - return None - # Get full trace data. start, end = default_start_end_dates() snuba_params = SnubaParams( diff --git a/tests/sentry/seer/explorer/test_tools.py b/tests/sentry/seer/explorer/test_tools.py index 67fad12b997976..e733a3c87f7946 100644 --- a/tests/sentry/seer/explorer/test_tools.py +++ b/tests/sentry/seer/explorer/test_tools.py @@ -17,6 +17,7 @@ EVENT_TIMESERIES_RESOLUTIONS, execute_table_query, execute_timeseries_query, + execute_trace_query, get_issue_details, get_replay_metadata, get_repository_definition, @@ -1773,7 +1774,7 @@ def test_logs_table_basic(self) -> None: result = execute_table_query( org_id=self.organization.id, - dataset="ourlogs", + dataset="logs", fields=self.default_fields, per_page=10, stats_period="1h", @@ -1787,3 +1788,58 @@ def test_logs_table_basic(self) -> None: for log in data: for field in self.default_fields: assert field in log, field + + def test_logs_trace_query(self) -> None: + + trace_id = uuid.uuid4().hex + # Create logs with various attributes + logs = [ + self.create_ourlog( + { + "body": "User authentication failed", + "severity_text": "ERROR", + "severity_number": 17, + "trace_id": trace_id, + }, + timestamp=self.ten_mins_ago, + ), + self.create_ourlog( + { + "body": "Request processed successfully", + "severity_text": "INFO", + "severity_number": 9, + "trace_id": trace_id, + }, + timestamp=self.nine_mins_ago, + ), + self.create_ourlog( + { + "body": "Database connection timeout", + "severity_text": "WARN", + "severity_number": 13, + }, + timestamp=self.nine_mins_ago, + ), + ] + self.store_ourlogs(logs) + + # print(self.organization.id, self.project.id) + + result = execute_trace_query( + org_id=self.organization.id, + trace_id=trace_id, + dataset="logs", + attributes=[ + # "id", + # "timestamp_precise", + # "project", # project slug + "severity", + "trace.parent_span_id", + "message", + ], + stats_period="1d", + sampling_mode="HIGHEST_ACCURACY", + ) + assert len(result["data"]) == 2 + + # print(result) From a43aa181a1eb0594adc94f04447ad1f2b6f56252 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Sun, 23 Nov 2025 02:40:21 -0800 Subject: [PATCH 05/35] update --- src/sentry/seer/explorer/tools.py | 6 +++--- tests/sentry/seer/explorer/test_tools.py | 21 ++++++++++++++------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index f570696b0c962b..cd3e7d6cff4a1b 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -297,7 +297,6 @@ def execute_trace_query( trace_id: str, dataset: Literal["spans", "logs", "tracemetrics"], attributes: list[str], - query: str | None = None, project_ids: list[int] | None = None, stats_period: str | None = None, start: str | None = None, @@ -373,7 +372,6 @@ def execute_trace_query( # Query EAP EndpointGetTrace and format the response. response = get_trace_rpc(request) - # print(response) items: list[dict[str, Any]] = [] columns_by_name = {col.proto_definition.name: col for col in columns} @@ -398,7 +396,9 @@ def execute_trace_query( ) items.append(item_dict) - # break # There should only be one item group, for the requested item type. + break # There should only be one item group, for the requested item type. + + # TODO: support filter on results by attribute(s)? return {"data": items} diff --git a/tests/sentry/seer/explorer/test_tools.py b/tests/sentry/seer/explorer/test_tools.py index e733a3c87f7946..f651be151d7895 100644 --- a/tests/sentry/seer/explorer/test_tools.py +++ b/tests/sentry/seer/explorer/test_tools.py @@ -1823,23 +1823,30 @@ def test_logs_trace_query(self) -> None: ] self.store_ourlogs(logs) - # print(self.organization.id, self.project.id) - result = execute_trace_query( org_id=self.organization.id, trace_id=trace_id, dataset="logs", attributes=[ - # "id", - # "timestamp_precise", - # "project", # project slug + "timestamp_precise", + "project", # project slug "severity", "trace.parent_span_id", "message", ], stats_period="1d", - sampling_mode="HIGHEST_ACCURACY", ) + assert result is not None assert len(result["data"]) == 2 - # print(result) + assert result["data"][0]["project"] == self.project.slug + assert result["data"][0]["severity"] == "ERROR" + # TODO: assert result["data"][0]["trace.parent_span_id"] is None + assert result["data"][0]["message"] == "User authentication failed" + assert result["data"][0]["timestamp_precise"] == self.ten_mins_ago.isoformat() + + assert result["data"][1]["project"] == self.project.slug + assert result["data"][1]["severity"] == "INFO" + # assert result["data"][1]["trace.parent_span_id"] is None + assert result["data"][1]["message"] == "Request processed successfully" + assert result["data"][1]["timestamp_precise"] == self.nine_mins_ago.isoformat() From 92adaf617ac24963040517d60fa2c30110f7233d Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Mon, 24 Nov 2025 16:55:32 -0800 Subject: [PATCH 06/35] get_log_attrs --- src/sentry/seer/explorer/tools.py | 84 ++++++++++++------------ tests/sentry/seer/explorer/test_tools.py | 31 +++------ 2 files changed, 51 insertions(+), 64 deletions(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index 1f8ec8515453f6..6e2975d4f6d7a5 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -1,7 +1,7 @@ import logging import uuid from datetime import UTC, datetime, timedelta, timezone -from typing import Any, Literal, cast +from typing import Any, cast from sentry_protos.snuba.v1.endpoint_get_trace_pb2 import GetTraceRequest @@ -31,10 +31,8 @@ from sentry.services.eventstore.models import Event, GroupEvent from sentry.snuba.ourlogs import OurLogs from sentry.snuba.referrer import Referrer -from sentry.snuba.rpc_dataset_common import RPCBase from sentry.snuba.spans_rpc import Spans from sentry.snuba.trace import query_trace_data -from sentry.snuba.trace_metrics import TraceMetrics from sentry.snuba.utils import get_dataset from sentry.utils.dates import parse_stats_period from sentry.utils.snuba_rpc import get_trace_rpc @@ -292,18 +290,17 @@ def execute_timeseries_query( return data -def execute_trace_query( +def get_log_attributes( *, org_id: int, trace_id: str, - dataset: Literal["spans", "logs", "tracemetrics"], - attributes: list[str], - project_ids: list[int] | None = None, + message_substring: str | None = None, stats_period: str | None = None, start: str | None = None, end: str | None = None, + project_ids: list[int] | None = None, sampling_mode: SAMPLING_MODES = "NORMAL", - limit: int | None = None, + limit: int | None = 50, ) -> dict[str, Any] | None: _validate_date_params(stats_period=stats_period, start=start, end=end) @@ -335,28 +332,16 @@ def execute_trace_query( full_trace_id = trace_id # Build the GetTraceRequest. - rpc_cls: type[RPCBase] - if dataset == "spans": - rpc_cls = Spans - elif dataset == "logs": - rpc_cls = OurLogs - else: - rpc_cls = TraceMetrics - - start_dt = datetime.fromisoformat(start) if start else None - end_dt = datetime.fromisoformat(end) if end else None - snuba_params = SnubaParams( - start=start_dt, - end=end_dt, + start=datetime.fromisoformat(start) if start else None, + end=datetime.fromisoformat(end) if end else None, stats_period=stats_period, projects=projects, organization=organization, sampling_mode=sampling_mode, ) - resolver = rpc_cls.get_resolver(params=snuba_params, config=SearchResolverConfig()) - columns, _ = resolver.resolve_attributes(attributes) + resolver = OurLogs.get_resolver(params=snuba_params, config=SearchResolverConfig()) meta = resolver.resolve_meta(referrer=Referrer.SEER_RPC, sampling_mode=sampling_mode) request = GetTraceRequest( meta=meta, @@ -364,42 +349,59 @@ def execute_trace_query( items=[ GetTraceRequest.TraceItem( item_type=meta.trace_item_type, - attributes=[col.proto_definition for col in columns], + attributes=None, # Returns all attributes. ) ], ) - if limit: + if limit and not message_substring: request.limit = limit - # Query EAP EndpointGetTrace and format the response. + # Query EAP EndpointGetTrace response = get_trace_rpc(request) + # Collect the returned attributes + attributes = [] + for item_group in response.item_groups: + for item in item_group.items: + for attribute in item.attributes: + attributes.append(attribute.key.name) + # There should only be one item group, for the requested item type. + break + + # Resolve the attributes to get the types and public aliases items: list[dict[str, Any]] = [] - columns_by_name = {col.proto_definition.name: col for col in columns} + resolved_attrs, _ = resolver.resolve_attributes(attributes) + resolved_attrs_by_name = {col.internal_name: col for col in resolved_attrs} + for item_group in response.item_groups: for item in item_group.items: item_dict: dict[str, Any] = { "id": item.id, } for attribute in item.attributes: - resolved_column = columns_by_name[attribute.key.name] - if resolved_column.proto_definition.type == STRING: - item_dict[resolved_column.public_alias] = attribute.value.val_str - elif resolved_column.proto_definition.type == DOUBLE: - item_dict[resolved_column.public_alias] = attribute.value.val_double - elif resolved_column.search_type == "boolean": - item_dict[resolved_column.public_alias] = attribute.value.val_int == 1 - elif resolved_column.proto_definition.type == INT: - item_dict[resolved_column.public_alias] = attribute.value.val_int - if resolved_column.public_alias == "project.id": - item_dict["project.slug"] = resolver.params.project_id_map.get( - item_dict[resolved_column.public_alias], "Unknown" + r = resolved_attrs_by_name[attribute.key.name] + if r.proto_definition.type == STRING: + item_dict[r.public_alias] = attribute.value.val_str + elif r.proto_definition.type == DOUBLE: + item_dict[r.public_alias] = attribute.value.val_double + elif r.search_type == "boolean": + item_dict[r.public_alias] = attribute.value.val_int == 1 + elif r.proto_definition.type == INT: + item_dict[r.public_alias] = attribute.value.val_int + if r.public_alias == "project.id": + # Enrich with project slug, alias "project" + item_dict["project"] = resolver.params.project_id_map.get( + item_dict[r.public_alias], "Unknown" ) items.append(item_dict) - break # There should only be one item group, for the requested item type. + break # Should only be one item group + + if message_substring: + items = [item for item in items if message_substring in item["message"].lower()] - # TODO: support filter on results by attribute(s)? + if limit: + items = items[:limit] return {"data": items} diff --git a/tests/sentry/seer/explorer/test_tools.py b/tests/sentry/seer/explorer/test_tools.py index 5735ee1b0ffa6b..cf681253f676b0 100644 --- a/tests/sentry/seer/explorer/test_tools.py +++ b/tests/sentry/seer/explorer/test_tools.py @@ -17,8 +17,8 @@ EVENT_TIMESERIES_RESOLUTIONS, execute_table_query, execute_timeseries_query, - execute_trace_query, get_issue_and_event_details, + get_log_attributes, get_replay_metadata, get_repository_definition, get_trace_waterfall, @@ -1834,8 +1834,7 @@ def test_logs_table_basic(self) -> None: for field in self.default_fields: assert field in log, field - def test_logs_trace_query(self) -> None: - + def test_get_log_attributes(self) -> None: trace_id = uuid.uuid4().hex # Create logs with various attributes logs = [ @@ -1868,30 +1867,16 @@ def test_logs_trace_query(self) -> None: ] self.store_ourlogs(logs) - result = execute_trace_query( + result = get_log_attributes( org_id=self.organization.id, trace_id=trace_id, - dataset="logs", - attributes=[ - "timestamp_precise", - "project", # project slug - "severity", - "trace.parent_span_id", - "message", - ], + message_substring="request", stats_period="1d", ) assert result is not None - assert len(result["data"]) == 2 + assert len(result["data"]) == 1 assert result["data"][0]["project"] == self.project.slug - assert result["data"][0]["severity"] == "ERROR" - # TODO: assert result["data"][0]["trace.parent_span_id"] is None - assert result["data"][0]["message"] == "User authentication failed" - assert result["data"][0]["timestamp_precise"] == self.ten_mins_ago.isoformat() - - assert result["data"][1]["project"] == self.project.slug - assert result["data"][1]["severity"] == "INFO" - # assert result["data"][1]["trace.parent_span_id"] is None - assert result["data"][1]["message"] == "Request processed successfully" - assert result["data"][1]["timestamp_precise"] == self.nine_mins_ago.isoformat() + assert result["data"][0]["severity"] == "INFO" + assert result["data"][0]["message"] == "Request processed successfully" + assert result["data"][0]["timestamp_precise"] == self.nine_mins_ago.isoformat() From 385f7b17dcf2ae14419239aa81e295bd9f350b39 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Mon, 24 Nov 2025 17:09:05 -0800 Subject: [PATCH 07/35] get_metric_attrs --- src/sentry/seer/explorer/tools.py | 300 ++++++++++++++++++------------ src/sentry/seer/explorer/utils.py | 41 ++++ 2 files changed, 223 insertions(+), 118 deletions(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index 6e2975d4f6d7a5..1b263e1275b929 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -20,19 +20,23 @@ from sentry.models.repository import Repository from sentry.replays.post_process import process_raw_response from sentry.replays.query import query_replay_id_by_prefix, query_replay_instance -from sentry.search.eap.constants import DOUBLE, INT, STRING from sentry.search.eap.types import SearchResolverConfig from sentry.search.events.types import SAMPLING_MODES, SnubaParams from sentry.seer.autofix.autofix import get_all_tags_overview from sentry.seer.constants import SEER_SUPPORTED_SCM_PROVIDERS from sentry.seer.explorer.index_data import UNESCAPED_QUOTE_RE -from sentry.seer.explorer.utils import _convert_profile_to_execution_tree, fetch_profile_data +from sentry.seer.explorer.utils import ( + _convert_profile_to_execution_tree, + fetch_profile_data, + parse_get_trace_rpc_response, +) from sentry.seer.sentry_data_models import EAPTrace from sentry.services.eventstore.models import Event, GroupEvent from sentry.snuba.ourlogs import OurLogs from sentry.snuba.referrer import Referrer from sentry.snuba.spans_rpc import Spans from sentry.snuba.trace import query_trace_data +from sentry.snuba.trace_metrics import TraceMetrics from sentry.snuba.utils import get_dataset from sentry.utils.dates import parse_stats_period from sentry.utils.snuba_rpc import get_trace_rpc @@ -290,122 +294,6 @@ def execute_timeseries_query( return data -def get_log_attributes( - *, - org_id: int, - trace_id: str, - message_substring: str | None = None, - stats_period: str | None = None, - start: str | None = None, - end: str | None = None, - project_ids: list[int] | None = None, - sampling_mode: SAMPLING_MODES = "NORMAL", - limit: int | None = 50, -) -> dict[str, Any] | None: - - _validate_date_params(stats_period=stats_period, start=start, end=end) - - try: - organization = Organization.objects.get(id=org_id) - except Organization.DoesNotExist: - logger.warning("Organization not found", extra={"org_id": org_id}) - return None - - projects = list( - Project.objects.filter( - organization=organization, - status=ObjectStatus.ACTIVE, - **({"id__in": project_ids} if project_ids else {}), - ) - ) - - # Look up full trace id if a short id is provided. - if len(trace_id) < 32: - full_trace_id = _get_full_trace_id(trace_id, organization, projects) - if not full_trace_id: - logger.warning( - "execute_trace_query: No full trace id found for short trace id", - extra={"org_id": org_id, "trace_id": trace_id}, - ) - return None - else: - full_trace_id = trace_id - - # Build the GetTraceRequest. - snuba_params = SnubaParams( - start=datetime.fromisoformat(start) if start else None, - end=datetime.fromisoformat(end) if end else None, - stats_period=stats_period, - projects=projects, - organization=organization, - sampling_mode=sampling_mode, - ) - - resolver = OurLogs.get_resolver(params=snuba_params, config=SearchResolverConfig()) - meta = resolver.resolve_meta(referrer=Referrer.SEER_RPC, sampling_mode=sampling_mode) - request = GetTraceRequest( - meta=meta, - trace_id=full_trace_id, - items=[ - GetTraceRequest.TraceItem( - item_type=meta.trace_item_type, - attributes=None, # Returns all attributes. - ) - ], - ) - if limit and not message_substring: - request.limit = limit - - # Query EAP EndpointGetTrace - response = get_trace_rpc(request) - - # Collect the returned attributes - attributes = [] - for item_group in response.item_groups: - for item in item_group.items: - for attribute in item.attributes: - attributes.append(attribute.key.name) - # There should only be one item group, for the requested item type. - break - - # Resolve the attributes to get the types and public aliases - items: list[dict[str, Any]] = [] - resolved_attrs, _ = resolver.resolve_attributes(attributes) - resolved_attrs_by_name = {col.internal_name: col for col in resolved_attrs} - - for item_group in response.item_groups: - for item in item_group.items: - item_dict: dict[str, Any] = { - "id": item.id, - } - for attribute in item.attributes: - r = resolved_attrs_by_name[attribute.key.name] - if r.proto_definition.type == STRING: - item_dict[r.public_alias] = attribute.value.val_str - elif r.proto_definition.type == DOUBLE: - item_dict[r.public_alias] = attribute.value.val_double - elif r.search_type == "boolean": - item_dict[r.public_alias] = attribute.value.val_int == 1 - elif r.proto_definition.type == INT: - item_dict[r.public_alias] = attribute.value.val_int - if r.public_alias == "project.id": - # Enrich with project slug, alias "project" - item_dict["project"] = resolver.params.project_id_map.get( - item_dict[r.public_alias], "Unknown" - ) - items.append(item_dict) - - break # Should only be one item group - - if message_substring: - items = [item for item in items if message_substring in item["message"].lower()] - - if limit: - items = items[:limit] - - return {"data": items} - - def get_trace_waterfall(trace_id: str, organization_id: int) -> EAPTrace | None: """ Get the full span waterfall and connected errors for a trace. @@ -1224,3 +1112,179 @@ def get_trace_item_attributes( ) return {"attributes": resp.data["attributes"]} + + +def get_log_attributes_for_trace( + *, + org_id: int, + trace_id: str, + message_substring: str | None = None, + substring_case_sensitive: bool = True, + stats_period: str | None = None, + start: str | None = None, + end: str | None = None, + project_ids: list[int] | None = None, + sampling_mode: SAMPLING_MODES = "NORMAL", + limit: int | None = 50, +) -> dict[str, Any] | None: + + _validate_date_params(stats_period=stats_period, start=start, end=end) + + try: + organization = Organization.objects.get(id=org_id) + except Organization.DoesNotExist: + logger.warning("Organization not found", extra={"org_id": org_id}) + return None + + projects = list( + Project.objects.filter( + organization=organization, + status=ObjectStatus.ACTIVE, + **({"id__in": project_ids} if project_ids else {}), + ) + ) + + # Look up full trace id if a short id is provided. + if len(trace_id) < 32: + full_trace_id = _get_full_trace_id(trace_id, organization, projects) + if not full_trace_id: + logger.warning( + "execute_trace_query: No full trace id found for short trace id", + extra={"org_id": org_id, "trace_id": trace_id}, + ) + return None + else: + full_trace_id = trace_id + + # Build the GetTraceRequest. + snuba_params = SnubaParams( + start=datetime.fromisoformat(start) if start else None, + end=datetime.fromisoformat(end) if end else None, + stats_period=stats_period, + projects=projects, + organization=organization, + sampling_mode=sampling_mode, + ) + + resolver = OurLogs.get_resolver(params=snuba_params, config=SearchResolverConfig()) + meta = resolver.resolve_meta(referrer=Referrer.SEER_RPC, sampling_mode=sampling_mode) + request = GetTraceRequest( + meta=meta, + trace_id=full_trace_id, + items=[ + GetTraceRequest.TraceItem( + item_type=meta.trace_item_type, + attributes=None, # Returns all attributes. + ) + ], + ) + if limit and not message_substring: + request.limit = limit + + # Query EAP EndpointGetTrace + response = get_trace_rpc(request) + + items: list[dict[str, Any]] + for item_group in response.item_groups: + items = parse_get_trace_rpc_response(item_group, resolver) + break # Should only be one item group, for the requested item type. + + if message_substring: + items = ( + [item for item in items if message_substring in item["message"]] + if substring_case_sensitive + else [item for item in items if message_substring.lower() in item["message"].lower()] + ) + + if limit: + items = items[:limit] + + return {"data": items} + + +def get_metric_attributes_for_trace( + *, + org_id: int, + trace_id: str, + name_substring: str | None = None, + substring_case_sensitive: bool = True, + stats_period: str | None = None, + start: str | None = None, + end: str | None = None, + project_ids: list[int] | None = None, + sampling_mode: SAMPLING_MODES = "NORMAL", + limit: int | None = 50, +) -> dict[str, Any] | None: + + _validate_date_params(stats_period=stats_period, start=start, end=end) + + try: + organization = Organization.objects.get(id=org_id) + except Organization.DoesNotExist: + logger.warning("Organization not found", extra={"org_id": org_id}) + return None + + projects = list( + Project.objects.filter( + organization=organization, + status=ObjectStatus.ACTIVE, + **({"id__in": project_ids} if project_ids else {}), + ) + ) + + # Look up full trace id if a short id is provided. + if len(trace_id) < 32: + full_trace_id = _get_full_trace_id(trace_id, organization, projects) + if not full_trace_id: + logger.warning( + "execute_trace_query: No full trace id found for short trace id", + extra={"org_id": org_id, "trace_id": trace_id}, + ) + return None + else: + full_trace_id = trace_id + + # Build the GetTraceRequest. + snuba_params = SnubaParams( + start=datetime.fromisoformat(start) if start else None, + end=datetime.fromisoformat(end) if end else None, + stats_period=stats_period, + projects=projects, + organization=organization, + sampling_mode=sampling_mode, + ) + + resolver = TraceMetrics.get_resolver(params=snuba_params, config=SearchResolverConfig()) + meta = resolver.resolve_meta(referrer=Referrer.SEER_RPC, sampling_mode=sampling_mode) + request = GetTraceRequest( + meta=meta, + trace_id=full_trace_id, + items=[ + GetTraceRequest.TraceItem( + item_type=meta.trace_item_type, + attributes=None, # Returns all attributes. + ) + ], + ) + if limit and not name_substring: + request.limit = limit + + # Query EAP EndpointGetTrace + response = get_trace_rpc(request) + + items: list[dict[str, Any]] + for item_group in response.item_groups: + items = parse_get_trace_rpc_response(item_group, resolver) + break # Should only be one item group, for the requested item type. + + if name_substring: + items = ( + [item for item in items if name_substring in item["metric.name"]] + if substring_case_sensitive + else [item for item in items if name_substring.lower() in item["metric.name"].lower()] + ) + + if limit: + items = items[:limit] + + return {"data": items} diff --git a/src/sentry/seer/explorer/utils.py b/src/sentry/seer/explorer/utils.py index 90ad8a6cf9d898..10f7dc2992018a 100644 --- a/src/sentry/seer/explorer/utils.py +++ b/src/sentry/seer/explorer/utils.py @@ -4,10 +4,13 @@ from typing import Any import orjson +from sentry_protos.snuba.v1.endpoint_get_trace_pb2 import GetTraceResponse from sentry.models.project import Project from sentry.profiles.profile_chunks import get_chunk_ids from sentry.profiles.utils import get_from_profiling_service +from sentry.search.eap.constants import DOUBLE, INT, STRING +from sentry.search.eap.resolver import SearchResolver from sentry.search.events.types import SnubaParams from sentry.seer.sentry_data_models import ExecutionTreeNode @@ -459,3 +462,41 @@ def fetch_profile_data( if response.status == 200: return orjson.loads(response.data) return None + + +def parse_get_trace_rpc_response( + item_group: GetTraceResponse.ItemGroup, resolver: SearchResolver +) -> list[dict[str, Any]]: + # Collect the returned attributes + attributes = [] + for item in item_group.items: + for attribute in item.attributes: + attributes.append(attribute.key.name) + + # Resolve the attributes to get the types and public aliases + items: list[dict[str, Any]] = [] + resolved_attrs, _ = resolver.resolve_attributes(attributes) + resolved_attrs_by_name = {col.internal_name: col for col in resolved_attrs} + + for item in item_group.items: + item_dict: dict[str, Any] = { + "id": item.id, + } + for attribute in item.attributes: + r = resolved_attrs_by_name[attribute.key.name] + if r.proto_definition.type == STRING: + item_dict[r.public_alias] = attribute.value.val_str + elif r.proto_definition.type == DOUBLE: + item_dict[r.public_alias] = attribute.value.val_double + elif r.search_type == "boolean": + item_dict[r.public_alias] = attribute.value.val_int == 1 + elif r.proto_definition.type == INT: + item_dict[r.public_alias] = attribute.value.val_int + if r.public_alias == "project.id": + # Enrich with project slug, alias "project" + item_dict["project"] = resolver.params.project_id_map.get( + item_dict[r.public_alias], "Unknown" + ) + items.append(item_dict) + + return items From c3a3454692459dcae786891987a8ebcf37beb6b0 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Mon, 24 Nov 2025 17:11:07 -0800 Subject: [PATCH 08/35] register --- src/sentry/seer/endpoints/seer_rpc.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/sentry/seer/endpoints/seer_rpc.py b/src/sentry/seer/endpoints/seer_rpc.py index aeb7edffd6a944..26ab0780b9996a 100644 --- a/src/sentry/seer/endpoints/seer_rpc.py +++ b/src/sentry/seer/endpoints/seer_rpc.py @@ -91,6 +91,8 @@ execute_timeseries_query, get_issue_and_event_details, get_issue_details, + get_log_attributes_for_trace, + get_metric_attributes_for_trace, get_replay_metadata, get_repository_definition, get_trace_item_attributes, @@ -1039,6 +1041,8 @@ def check_repository_integrations_status(*, repository_integrations: list[dict[s "get_trace_item_attributes": get_trace_item_attributes, "get_repository_definition": get_repository_definition, "call_custom_tool": call_custom_tool, + "get_log_attributes_for_trace": get_log_attributes_for_trace, + "get_metric_attributes_for_trace": get_metric_attributes_for_trace, # # Replays "get_replay_summary_logs": rpc_get_replay_summary_logs, From 6c9bfe09e8d8f5f42414e19bc4858ba45b9d21c1 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Mon, 24 Nov 2025 17:17:07 -0800 Subject: [PATCH 09/35] fix --- tests/sentry/seer/explorer/test_tools.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/sentry/seer/explorer/test_tools.py b/tests/sentry/seer/explorer/test_tools.py index cf681253f676b0..db4550e0867ce6 100644 --- a/tests/sentry/seer/explorer/test_tools.py +++ b/tests/sentry/seer/explorer/test_tools.py @@ -18,7 +18,7 @@ execute_table_query, execute_timeseries_query, get_issue_and_event_details, - get_log_attributes, + get_log_attributes_for_trace, get_replay_metadata, get_repository_definition, get_trace_waterfall, @@ -1834,7 +1834,7 @@ def test_logs_table_basic(self) -> None: for field in self.default_fields: assert field in log, field - def test_get_log_attributes(self) -> None: + def test_get_log_attributes_for_trace(self) -> None: trace_id = uuid.uuid4().hex # Create logs with various attributes logs = [ @@ -1867,7 +1867,7 @@ def test_get_log_attributes(self) -> None: ] self.store_ourlogs(logs) - result = get_log_attributes( + result = get_log_attributes_for_trace( org_id=self.organization.id, trace_id=trace_id, message_substring="request", From 527dd2c2f335f8032567cd6c54c427fcebff7c2e Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Mon, 24 Nov 2025 22:34:59 -0800 Subject: [PATCH 10/35] fix parse --- src/sentry/seer/explorer/tools.py | 4 ++-- src/sentry/seer/explorer/utils.py | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index 1b263e1275b929..b05a2813abc501 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -1187,7 +1187,7 @@ def get_log_attributes_for_trace( items: list[dict[str, Any]] for item_group in response.item_groups: items = parse_get_trace_rpc_response(item_group, resolver) - break # Should only be one item group, for the requested item type. + break # Should only be one item group for logs if message_substring: items = ( @@ -1275,7 +1275,7 @@ def get_metric_attributes_for_trace( items: list[dict[str, Any]] for item_group in response.item_groups: items = parse_get_trace_rpc_response(item_group, resolver) - break # Should only be one item group, for the requested item type. + break # Should only be one item group for metrics if name_substring: items = ( diff --git a/src/sentry/seer/explorer/utils.py b/src/sentry/seer/explorer/utils.py index 10f7dc2992018a..d58322bc6f07d7 100644 --- a/src/sentry/seer/explorer/utils.py +++ b/src/sentry/seer/explorer/utils.py @@ -467,6 +467,8 @@ def fetch_profile_data( def parse_get_trace_rpc_response( item_group: GetTraceResponse.ItemGroup, resolver: SearchResolver ) -> list[dict[str, Any]]: + # Based on spans_rpc.Spans.run_trace_query + # Collect the returned attributes attributes = [] for item in item_group.items: @@ -476,7 +478,7 @@ def parse_get_trace_rpc_response( # Resolve the attributes to get the types and public aliases items: list[dict[str, Any]] = [] resolved_attrs, _ = resolver.resolve_attributes(attributes) - resolved_attrs_by_name = {col.internal_name: col for col in resolved_attrs} + resolved_attrs_by_name = {a.proto_definition.name: a for a in resolved_attrs} for item in item_group.items: item_dict: dict[str, Any] = { From 574091bb158dec15c4ea24a5405c78333130545a Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 25 Nov 2025 01:23:26 -0800 Subject: [PATCH 11/35] substr type --- src/sentry/seer/explorer/tools.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index b05a2813abc501..f12860dd8bb3c1 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -1118,7 +1118,7 @@ def get_log_attributes_for_trace( *, org_id: int, trace_id: str, - message_substring: str | None = None, + message_substring: str = "", substring_case_sensitive: bool = True, stats_period: str | None = None, start: str | None = None, @@ -1206,7 +1206,7 @@ def get_metric_attributes_for_trace( *, org_id: int, trace_id: str, - name_substring: str | None = None, + name_substring: str = "", substring_case_sensitive: bool = True, stats_period: str | None = None, start: str | None = None, From b0e83c4b4bfb3f3e61a8c7fc74a0658692482f1a Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 25 Nov 2025 01:40:26 -0800 Subject: [PATCH 12/35] single pslug --- src/sentry/seer/explorer/tools.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index f12860dd8bb3c1..9b56ab8dba19a7 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -1123,7 +1123,7 @@ def get_log_attributes_for_trace( stats_period: str | None = None, start: str | None = None, end: str | None = None, - project_ids: list[int] | None = None, + project_slug: str | None = None, sampling_mode: SAMPLING_MODES = "NORMAL", limit: int | None = 50, ) -> dict[str, Any] | None: @@ -1140,7 +1140,7 @@ def get_log_attributes_for_trace( Project.objects.filter( organization=organization, status=ObjectStatus.ACTIVE, - **({"id__in": project_ids} if project_ids else {}), + **({"slug": project_slug} if project_slug else {}), ) ) @@ -1211,7 +1211,7 @@ def get_metric_attributes_for_trace( stats_period: str | None = None, start: str | None = None, end: str | None = None, - project_ids: list[int] | None = None, + project_slug: str | None = None, sampling_mode: SAMPLING_MODES = "NORMAL", limit: int | None = 50, ) -> dict[str, Any] | None: @@ -1228,7 +1228,7 @@ def get_metric_attributes_for_trace( Project.objects.filter( organization=organization, status=ObjectStatus.ACTIVE, - **({"id__in": project_ids} if project_ids else {}), + **({"slug": project_slug} if project_slug else {}), ) ) From 3982a9f5cfeb12b55e845bad54c49f3c51af1d28 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 25 Nov 2025 01:51:33 -0800 Subject: [PATCH 13/35] exact metric name --- src/sentry/seer/explorer/tools.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index 9b56ab8dba19a7..d5acef2fbd4f69 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -1206,8 +1206,8 @@ def get_metric_attributes_for_trace( *, org_id: int, trace_id: str, - name_substring: str = "", - substring_case_sensitive: bool = True, + metric_name: str, + metric_name_case_sensitive: bool = True, stats_period: str | None = None, start: str | None = None, end: str | None = None, @@ -1266,7 +1266,7 @@ def get_metric_attributes_for_trace( ) ], ) - if limit and not name_substring: + if limit and not metric_name: request.limit = limit # Query EAP EndpointGetTrace @@ -1277,11 +1277,11 @@ def get_metric_attributes_for_trace( items = parse_get_trace_rpc_response(item_group, resolver) break # Should only be one item group for metrics - if name_substring: + if metric_name: items = ( - [item for item in items if name_substring in item["metric.name"]] - if substring_case_sensitive - else [item for item in items if name_substring.lower() in item["metric.name"].lower()] + [item for item in items if metric_name == item["metric.name"]] + if metric_name_case_sensitive + else [item for item in items if metric_name.lower() == item["metric.name"].lower()] ) if limit: From a38e20a3f476d766f1a9e18e555597c7aa87a6a2 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 25 Nov 2025 02:04:45 -0800 Subject: [PATCH 14/35] opt metric name filter, proj id filter --- src/sentry/seer/explorer/tools.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index d5acef2fbd4f69..aa87e658167d4a 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -1123,6 +1123,7 @@ def get_log_attributes_for_trace( stats_period: str | None = None, start: str | None = None, end: str | None = None, + project_id: int | None = None, project_slug: str | None = None, sampling_mode: SAMPLING_MODES = "NORMAL", limit: int | None = 50, @@ -1140,6 +1141,7 @@ def get_log_attributes_for_trace( Project.objects.filter( organization=organization, status=ObjectStatus.ACTIVE, + **({"id": project_id} if project_id else {}), **({"slug": project_slug} if project_slug else {}), ) ) @@ -1206,11 +1208,12 @@ def get_metric_attributes_for_trace( *, org_id: int, trace_id: str, - metric_name: str, + metric_name: str = "", metric_name_case_sensitive: bool = True, stats_period: str | None = None, start: str | None = None, end: str | None = None, + project_id: int | None = None, project_slug: str | None = None, sampling_mode: SAMPLING_MODES = "NORMAL", limit: int | None = 50, @@ -1228,6 +1231,7 @@ def get_metric_attributes_for_trace( Project.objects.filter( organization=organization, status=ObjectStatus.ACTIVE, + **({"id": project_id} if project_id else {}), **({"slug": project_slug} if project_slug else {}), ) ) From f657d5f699afb652677a44454517562a0de8deb0 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 25 Nov 2025 12:30:21 -0800 Subject: [PATCH 15/35] review feedback --- src/sentry/seer/explorer/tools.py | 219 +++++++++++++++++------------- src/sentry/seer/explorer/utils.py | 43 ------ 2 files changed, 125 insertions(+), 137 deletions(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index aa87e658167d4a..49ead88c9806ce 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -4,6 +4,7 @@ from typing import Any, cast from sentry_protos.snuba.v1.endpoint_get_trace_pb2 import GetTraceRequest +from sentry_protos.snuba.v1.request_common_pb2 import TraceItemType from sentry import eventstore, features from sentry.api import client @@ -20,16 +21,14 @@ from sentry.models.repository import Repository from sentry.replays.post_process import process_raw_response from sentry.replays.query import query_replay_id_by_prefix, query_replay_instance +from sentry.search.eap.constants import DOUBLE, INT, STRING +from sentry.search.eap.resolver import SearchResolver from sentry.search.eap.types import SearchResolverConfig from sentry.search.events.types import SAMPLING_MODES, SnubaParams from sentry.seer.autofix.autofix import get_all_tags_overview from sentry.seer.constants import SEER_SUPPORTED_SCM_PROVIDERS from sentry.seer.explorer.index_data import UNESCAPED_QUOTE_RE -from sentry.seer.explorer.utils import ( - _convert_profile_to_execution_tree, - fetch_profile_data, - parse_get_trace_rpc_response, -) +from sentry.seer.explorer.utils import _convert_profile_to_execution_tree, fetch_profile_data from sentry.seer.sentry_data_models import EAPTrace from sentry.services.eventstore.models import Event, GroupEvent from sentry.snuba.ourlogs import OurLogs @@ -1114,6 +1113,83 @@ def get_trace_item_attributes( return {"attributes": resp.data["attributes"]} +def _make_get_trace_request( + trace_id: str, + organization: Organization, + projects: list[Project], + trace_item_type: TraceItemType, + resolver: SearchResolver, + limit: int | None, + sampling_mode: SAMPLING_MODES, +) -> list[dict[str, Any]]: + # Look up full trace id if a short id is provided. + if len(trace_id) < 32: + full_trace_id = _get_full_trace_id(trace_id, organization, projects) + if not full_trace_id: + logger.warning( + "No full trace id found for short trace id", + extra={"org_id": organization.id, "trace_id": trace_id}, + ) + return [] + else: + full_trace_id = trace_id + + # Build the GetTraceRequest. + meta = resolver.resolve_meta(referrer=Referrer.SEER_RPC, sampling_mode=sampling_mode) + request = GetTraceRequest( + meta=meta, + trace_id=full_trace_id, + items=[ + GetTraceRequest.TraceItem( + item_type=trace_item_type, + attributes=None, # Returns all attributes. + ) + ], + ) + if limit: + request.limit = limit + + # Query EAP EndpointGetTrace + response = get_trace_rpc(request) + + # Format the response - based on spans_rpc.Spans.run_trace_query + for item_group in response.item_groups: + # Collect the returned attributes + attributes = [] + for item in item_group.items: + for attribute in item.attributes: + attributes.append(attribute.key.name) + + # Resolve the attributes to get the types and public aliases + items: list[dict[str, Any]] = [] + resolved_attrs, _ = resolver.resolve_attributes(attributes) + resolved_attrs_by_name = {attributes[i]: resolved_attrs[i] for i in range(len(attributes))} + + for item in item_group.items: + item_dict: dict[str, Any] = { + "id": item.id, + } + for attribute in item.attributes: + r = resolved_attrs_by_name[attribute.key.name] + if r.proto_definition.type == STRING: + item_dict[r.public_alias] = attribute.value.val_str + elif r.proto_definition.type == DOUBLE: + item_dict[r.public_alias] = attribute.value.val_double + elif r.search_type == "boolean": + item_dict[r.public_alias] = attribute.value.val_int == 1 + elif r.proto_definition.type == INT: + item_dict[r.public_alias] = attribute.value.val_int + if r.public_alias == "project.id": + # Enrich with project slug, alias "project" + item_dict["project"] = resolver.params.project_id_map.get( + item_dict[r.public_alias], "Unknown" + ) + items.append(item_dict) + + # We expect only one item group in the request/response. + return items + + def get_log_attributes_for_trace( *, org_id: int, @@ -1126,7 +1202,7 @@ def get_log_attributes_for_trace( project_id: int | None = None, project_slug: str | None = None, sampling_mode: SAMPLING_MODES = "NORMAL", - limit: int | None = 50, + limit: int | None = 50, # None will default to a Snuba server default. ) -> dict[str, Any] | None: _validate_date_params(stats_period=stats_period, start=start, end=end) @@ -1146,19 +1222,6 @@ def get_log_attributes_for_trace( ) ) - # Look up full trace id if a short id is provided. - if len(trace_id) < 32: - full_trace_id = _get_full_trace_id(trace_id, organization, projects) - if not full_trace_id: - logger.warning( - "execute_trace_query: No full trace id found for short trace id", - extra={"org_id": org_id, "trace_id": trace_id}, - ) - return None - else: - full_trace_id = trace_id - - # Build the GetTraceRequest. snuba_params = SnubaParams( start=datetime.fromisoformat(start) if start else None, end=datetime.fromisoformat(end) if end else None, @@ -1167,41 +1230,33 @@ def get_log_attributes_for_trace( organization=organization, sampling_mode=sampling_mode, ) - resolver = OurLogs.get_resolver(params=snuba_params, config=SearchResolverConfig()) - meta = resolver.resolve_meta(referrer=Referrer.SEER_RPC, sampling_mode=sampling_mode) - request = GetTraceRequest( - meta=meta, - trace_id=full_trace_id, - items=[ - GetTraceRequest.TraceItem( - item_type=meta.trace_item_type, - attributes=None, # Returns all attributes. - ) - ], + + items = _make_get_trace_request( + trace_id=trace_id, + organization=organization, + projects=projects, + trace_item_type=TraceItemType.TRACE_ITEM_TYPE_LOG, + resolver=resolver, + limit=(limit if not message_substring else None), # Return all results if we're filtering. + sampling_mode=sampling_mode, ) - if limit and not message_substring: - request.limit = limit - # Query EAP EndpointGetTrace - response = get_trace_rpc(request) + if not message_substring: + # Limit is already applied by the EAP request + return {"data": items} - items: list[dict[str, Any]] - for item_group in response.item_groups: - items = parse_get_trace_rpc_response(item_group, resolver) - break # Should only be one item group for logs - - if message_substring: - items = ( - [item for item in items if message_substring in item["message"]] - if substring_case_sensitive - else [item for item in items if message_substring.lower() in item["message"].lower()] - ) - - if limit: - items = items[:limit] + # Filter on message substring. + filtered_items = [] + for item in items: + if len(filtered_items) >= limit: + break + if (substring_case_sensitive and message_substring in item["message"]) or ( + not substring_case_sensitive and message_substring.lower() in item["message"].lower() + ): + filtered_items.append(item) - return {"data": items} + return {"data": filtered_items} def get_metric_attributes_for_trace( @@ -1209,14 +1264,13 @@ def get_metric_attributes_for_trace( org_id: int, trace_id: str, metric_name: str = "", - metric_name_case_sensitive: bool = True, stats_period: str | None = None, start: str | None = None, end: str | None = None, project_id: int | None = None, project_slug: str | None = None, sampling_mode: SAMPLING_MODES = "NORMAL", - limit: int | None = 50, + limit: int | None = 50, # None will default to a Snuba server default. ) -> dict[str, Any] | None: _validate_date_params(stats_period=stats_period, start=start, end=end) @@ -1236,19 +1290,6 @@ def get_metric_attributes_for_trace( ) ) - # Look up full trace id if a short id is provided. - if len(trace_id) < 32: - full_trace_id = _get_full_trace_id(trace_id, organization, projects) - if not full_trace_id: - logger.warning( - "execute_trace_query: No full trace id found for short trace id", - extra={"org_id": org_id, "trace_id": trace_id}, - ) - return None - else: - full_trace_id = trace_id - - # Build the GetTraceRequest. snuba_params = SnubaParams( start=datetime.fromisoformat(start) if start else None, end=datetime.fromisoformat(end) if end else None, @@ -1257,38 +1298,28 @@ def get_metric_attributes_for_trace( organization=organization, sampling_mode=sampling_mode, ) - resolver = TraceMetrics.get_resolver(params=snuba_params, config=SearchResolverConfig()) - meta = resolver.resolve_meta(referrer=Referrer.SEER_RPC, sampling_mode=sampling_mode) - request = GetTraceRequest( - meta=meta, - trace_id=full_trace_id, - items=[ - GetTraceRequest.TraceItem( - item_type=meta.trace_item_type, - attributes=None, # Returns all attributes. - ) - ], - ) - if limit and not metric_name: - request.limit = limit - # Query EAP EndpointGetTrace - response = get_trace_rpc(request) + items = _make_get_trace_request( + trace_id=trace_id, + organization=organization, + projects=projects, + trace_item_type=TraceItemType.TRACE_ITEM_TYPE_METRIC, + resolver=resolver, + limit=(limit if not metric_name else None), # Return all results if we're filtering. + sampling_mode=sampling_mode, + ) - items: list[dict[str, Any]] - for item_group in response.item_groups: - items = parse_get_trace_rpc_response(item_group, resolver) - break # Should only be one item group for metrics - - if metric_name: - items = ( - [item for item in items if metric_name == item["metric.name"]] - if metric_name_case_sensitive - else [item for item in items if metric_name.lower() == item["metric.name"].lower()] - ) + if not metric_name: + # Limit is already applied by the EAP request + return {"data": items} - if limit: - items = items[:limit] + # Filter on metric name (exact case-insensitive match). + filtered_items = [] + for item in items: + if len(filtered_items) >= limit: + break + if metric_name.lower() == item["metric.name"].lower(): + filtered_items.append(item) - return {"data": items} + return {"data": filtered_items} diff --git a/src/sentry/seer/explorer/utils.py b/src/sentry/seer/explorer/utils.py index d58322bc6f07d7..90ad8a6cf9d898 100644 --- a/src/sentry/seer/explorer/utils.py +++ b/src/sentry/seer/explorer/utils.py @@ -4,13 +4,10 @@ from typing import Any import orjson -from sentry_protos.snuba.v1.endpoint_get_trace_pb2 import GetTraceResponse from sentry.models.project import Project from sentry.profiles.profile_chunks import get_chunk_ids from sentry.profiles.utils import get_from_profiling_service -from sentry.search.eap.constants import DOUBLE, INT, STRING -from sentry.search.eap.resolver import SearchResolver from sentry.search.events.types import SnubaParams from sentry.seer.sentry_data_models import ExecutionTreeNode @@ -462,43 +459,3 @@ def fetch_profile_data( if response.status == 200: return orjson.loads(response.data) return None - - -def parse_get_trace_rpc_response( - item_group: GetTraceResponse.ItemGroup, resolver: SearchResolver -) -> list[dict[str, Any]]: - # Based on spans_rpc.Spans.run_trace_query - - # Collect the returned attributes - attributes = [] - for item in item_group.items: - for attribute in item.attributes: - attributes.append(attribute.key.name) - - # Resolve the attributes to get the types and public aliases - items: list[dict[str, Any]] = [] - resolved_attrs, _ = resolver.resolve_attributes(attributes) - resolved_attrs_by_name = {a.proto_definition.name: a for a in resolved_attrs} - - for item in item_group.items: - item_dict: dict[str, Any] = { - "id": item.id, - } - for attribute in item.attributes: - r = resolved_attrs_by_name[attribute.key.name] - if r.proto_definition.type == STRING: - item_dict[r.public_alias] = attribute.value.val_str - elif r.proto_definition.type == DOUBLE: - item_dict[r.public_alias] = attribute.value.val_double - elif r.search_type == "boolean": - item_dict[r.public_alias] = attribute.value.val_int == 1 - elif r.proto_definition.type == INT: - item_dict[r.public_alias] = attribute.value.val_int - if r.public_alias == "project.id": - # Enrich with project slug, alias "project" - item_dict["project"] = resolver.params.project_id_map.get( - item_dict[r.public_alias], "Unknown" - ) - items.append(item_dict) - - return items From d777b1e4101bc80bda62c223db20796c7839e9c7 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 25 Nov 2025 12:44:40 -0800 Subject: [PATCH 16/35] update return format --- src/sentry/seer/explorer/tools.py | 51 +++++++++++++++++------- tests/sentry/seer/explorer/test_tools.py | 14 +++++-- 2 files changed, 47 insertions(+), 18 deletions(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index 49ead88c9806ce..5166df014cb256 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -1166,25 +1166,44 @@ def _make_get_trace_request( resolved_attrs_by_name = {attributes[i]: resolved_attrs[i] for i in range(len(attributes))} for item in item_group.items: - item_dict: dict[str, Any] = { - "id": item.id, - } + attr_dict = {} for attribute in item.attributes: r = resolved_attrs_by_name[attribute.key.name] if r.proto_definition.type == STRING: - item_dict[r.public_alias] = attribute.value.val_str + attr_dict[r.public_alias] = { + "value": attribute.value.val_str, + "type": "string", + } elif r.proto_definition.type == DOUBLE: - item_dict[r.public_alias] = attribute.value.val_double + attr_dict[r.public_alias] = { + "value": attribute.value.val_double, + "type": "double", + } elif r.search_type == "boolean": - item_dict[r.public_alias] = attribute.value.val_int == 1 + attr_dict[r.public_alias] = { + "value": attribute.value.val_int == 1, + "type": "boolean", + } elif r.proto_definition.type == INT: - item_dict[r.public_alias] = attribute.value.val_int + attr_dict[r.public_alias] = { + "value": attribute.value.val_int, + "type": "integer", + } if r.public_alias == "project.id": # Enrich with project slug, alias "project" - item_dict["project"] = resolver.params.project_id_map.get( - item_dict[r.public_alias], "Unknown" - ) - items.append(item_dict) + attr_dict["project"] = { + "value": resolver.params.project_id_map.get( + attribute.value.val_int, "Unknown" + ), + "type": "string", + } + + items.append( + { + "id": item.id, + "attributes": attr_dict, + } + ) # We expect only one item group in the request/response. return items @@ -1251,8 +1270,10 @@ def get_log_attributes_for_trace( for item in items: if len(filtered_items) >= limit: break - if (substring_case_sensitive and message_substring in item["message"]) or ( - not substring_case_sensitive and message_substring.lower() in item["message"].lower() + + message: str = item["attributes"].get("message", {}).get("value", "") + if (substring_case_sensitive and message_substring in message) or ( + not substring_case_sensitive and message_substring.lower() in message.lower() ): filtered_items.append(item) @@ -1319,7 +1340,9 @@ def get_metric_attributes_for_trace( for item in items: if len(filtered_items) >= limit: break - if metric_name.lower() == item["metric.name"].lower(): + + item_metric_name: str = item["attributes"].get("metric.name", {}).get("value", "") + if metric_name.lower() == item_metric_name.lower(): filtered_items.append(item) return {"data": filtered_items} diff --git a/tests/sentry/seer/explorer/test_tools.py b/tests/sentry/seer/explorer/test_tools.py index db4550e0867ce6..fc27dcffffde3e 100644 --- a/tests/sentry/seer/explorer/test_tools.py +++ b/tests/sentry/seer/explorer/test_tools.py @@ -1876,7 +1876,13 @@ def test_get_log_attributes_for_trace(self) -> None: assert result is not None assert len(result["data"]) == 1 - assert result["data"][0]["project"] == self.project.slug - assert result["data"][0]["severity"] == "INFO" - assert result["data"][0]["message"] == "Request processed successfully" - assert result["data"][0]["timestamp_precise"] == self.nine_mins_ago.isoformat() + item = result["data"][0] + assert bytes(reversed(item["id"])) == logs[1].item_id + ts = datetime.fromisoformat(item["timestamp"]).timestamp() + assert int(ts) == logs[1].timestamp.seconds + + assert isinstance(item["attributes"].get("timestamp_precise"), int) + assert item["attributes"]["project"] == self.project.slug + assert item["attributes"]["project_id"] == self.project.id + assert item["attributes"]["severity"] == "INFO" + assert item["attributes"]["message"] == "Request processed successfully" From 0b0528a70ecd04ebf95c13f314fd0ddd85017337 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 25 Nov 2025 13:00:00 -0800 Subject: [PATCH 17/35] proj slugs and docstr --- src/sentry/seer/explorer/tools.py | 67 ++++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 18 deletions(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index 5166df014cb256..0a78708261ab50 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -1115,13 +1115,33 @@ def get_trace_item_attributes( def _make_get_trace_request( trace_id: str, - organization: Organization, - projects: list[Project], trace_item_type: TraceItemType, resolver: SearchResolver, limit: int | None, sampling_mode: SAMPLING_MODES, ) -> list[dict[str, Any]]: + """ + Make a request to the EAP GetTrace endpoint to get all attributes for a given trace and item type. + Includes a short ID translation if one is provided. + + Args: + trace_id: The trace ID to query. + trace_item_type: The type of trace item to query. + resolver: The EAP search resolver, with SnubaParams set. + limit: The limit to apply to the request. Passing None will use a Snuba server default. + sampling_mode: The sampling mode to use for the request. + + Returns: + A list of dictionaries for each trace item, with the keys: + - id: The trace item ID. + - timestamp: ISO 8601 timestamp. + - attributes: A dictionary of dictionaries, where the keys are the attribute names. + - attributes[name].value: The value of the attribute (str, int, float, bool). + - attributes[name].type: The type of the attribute (str). + """ + organization = cast(Organization, resolver.params.organization) + projects = list(resolver.params.projects) + # Look up full trace id if a short id is provided. if len(trace_id) < 32: full_trace_id = _get_full_trace_id(trace_id, organization, projects) @@ -1218,11 +1238,19 @@ def get_log_attributes_for_trace( stats_period: str | None = None, start: str | None = None, end: str | None = None, - project_id: int | None = None, - project_slug: str | None = None, + project_slugs: list[str] | None = None, sampling_mode: SAMPLING_MODES = "NORMAL", - limit: int | None = 50, # None will default to a Snuba server default. + limit: int | None = 50, ) -> dict[str, Any] | None: + """ + Get all attributes for all logs in a trace. You can optionally filter by message substring and/or project slugs. + + Returns: + A list of dictionaries for each log, with the keys: + - id: The trace item ID. + - timestamp: ISO 8601 timestamp. + - attributes: A dict[str, dict[str, Any]] where the keys are the attribute names. See _make_get_trace_request for more details. + """ _validate_date_params(stats_period=stats_period, start=start, end=end) @@ -1236,8 +1264,7 @@ def get_log_attributes_for_trace( Project.objects.filter( organization=organization, status=ObjectStatus.ACTIVE, - **({"id": project_id} if project_id else {}), - **({"slug": project_slug} if project_slug else {}), + **({"slug__in": project_slugs} if bool(project_slugs) else {}), ) ) @@ -1253,15 +1280,13 @@ def get_log_attributes_for_trace( items = _make_get_trace_request( trace_id=trace_id, - organization=organization, - projects=projects, trace_item_type=TraceItemType.TRACE_ITEM_TYPE_LOG, resolver=resolver, limit=(limit if not message_substring else None), # Return all results if we're filtering. sampling_mode=sampling_mode, ) - if not message_substring: + if not message_substring or not limit: # Limit is already applied by the EAP request return {"data": items} @@ -1288,11 +1313,20 @@ def get_metric_attributes_for_trace( stats_period: str | None = None, start: str | None = None, end: str | None = None, - project_id: int | None = None, - project_slug: str | None = None, + project_slugs: list[str] | None = None, sampling_mode: SAMPLING_MODES = "NORMAL", - limit: int | None = 50, # None will default to a Snuba server default. + limit: int | None = 50, ) -> dict[str, Any] | None: + """ + Get all attributes for all metrics in a trace. You can optionally filter by metric name and/or project slugs. + The metric name is a case-insensitive exact match. + + Returns: + A list of dictionaries for each metric event, with the keys: + - id: The trace item ID. + - timestamp: ISO 8601 timestamp. + - attributes: A dict[str, dict[str, Any]] where the keys are the attribute names. See _make_get_trace_request for more details. + """ _validate_date_params(stats_period=stats_period, start=start, end=end) @@ -1306,8 +1340,7 @@ def get_metric_attributes_for_trace( Project.objects.filter( organization=organization, status=ObjectStatus.ACTIVE, - **({"id": project_id} if project_id else {}), - **({"slug": project_slug} if project_slug else {}), + **({"slug__in": project_slugs} if project_slugs else {}), ) ) @@ -1323,15 +1356,13 @@ def get_metric_attributes_for_trace( items = _make_get_trace_request( trace_id=trace_id, - organization=organization, - projects=projects, trace_item_type=TraceItemType.TRACE_ITEM_TYPE_METRIC, resolver=resolver, limit=(limit if not metric_name else None), # Return all results if we're filtering. sampling_mode=sampling_mode, ) - if not metric_name: + if not metric_name or not limit: # Limit is already applied by the EAP request return {"data": items} From 4203a7f1b1807aaa24ecbff99335ddfdafbab859 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 25 Nov 2025 13:30:53 -0800 Subject: [PATCH 18/35] mypy n limit --- src/sentry/seer/explorer/tools.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index 0a78708261ab50..6172abf8cb068c 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -1115,7 +1115,7 @@ def get_trace_item_attributes( def _make_get_trace_request( trace_id: str, - trace_item_type: TraceItemType, + trace_item_type: TraceItemType.ValueType, resolver: SearchResolver, limit: int | None, sampling_mode: SAMPLING_MODES, @@ -1186,7 +1186,7 @@ def _make_get_trace_request( resolved_attrs_by_name = {attributes[i]: resolved_attrs[i] for i in range(len(attributes))} for item in item_group.items: - attr_dict = {} + attr_dict: dict[str, dict[str, Any]] = {} for attribute in item.attributes: r = resolved_attrs_by_name[attribute.key.name] if r.proto_definition.type == STRING: @@ -1225,9 +1225,11 @@ def _make_get_trace_request( } ) - # We expect only one item group in the request/response. + # We expect exactly one item group in the request/response. return items + return [] + def get_log_attributes_for_trace( *, @@ -1291,9 +1293,9 @@ def get_log_attributes_for_trace( return {"data": items} # Filter on message substring. - filtered_items = [] + filtered_items: list[dict[str, Any]] = [] for item in items: - if len(filtered_items) >= limit: + if limit is not None and len(filtered_items) >= limit: break message: str = item["attributes"].get("message", {}).get("value", "") @@ -1362,14 +1364,14 @@ def get_metric_attributes_for_trace( sampling_mode=sampling_mode, ) - if not metric_name or not limit: + if not metric_name: # Limit is already applied by the EAP request return {"data": items} # Filter on metric name (exact case-insensitive match). - filtered_items = [] + filtered_items: list[dict[str, Any]] = [] for item in items: - if len(filtered_items) >= limit: + if limit is not None and len(filtered_items) >= limit: break item_metric_name: str = item["attributes"].get("metric.name", {}).get("value", "") From 982eb61a1e3b13f62ed3d5ea1a202ec4330a0115 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 25 Nov 2025 13:33:09 -0800 Subject: [PATCH 19/35] limit --- src/sentry/seer/explorer/tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index 6172abf8cb068c..d1673e7c5d68d0 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -1288,7 +1288,7 @@ def get_log_attributes_for_trace( sampling_mode=sampling_mode, ) - if not message_substring or not limit: + if not message_substring: # Limit is already applied by the EAP request return {"data": items} From eca5c166361568944fbca08a51ed0b43a1b6fc67 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 25 Nov 2025 13:40:05 -0800 Subject: [PATCH 20/35] limit n ts --- src/sentry/seer/explorer/tools.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index d1673e7c5d68d0..de0a5ed7354628 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -1134,7 +1134,7 @@ def _make_get_trace_request( Returns: A list of dictionaries for each trace item, with the keys: - id: The trace item ID. - - timestamp: ISO 8601 timestamp. + - timestamp: ISO 8601 timestamp, Z suffix. - attributes: A dictionary of dictionaries, where the keys are the attribute names. - attributes[name].value: The value of the attribute (str, int, float, bool). - attributes[name].type: The type of the attribute (str). @@ -1221,6 +1221,7 @@ def _make_get_trace_request( items.append( { "id": item.id, + "timestamp": item.timestamp.ToJsonString(), "attributes": attr_dict, } ) From 0024b9a52dfd7f49f9b13f21f8aa00feeace1218 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 25 Nov 2025 13:46:29 -0800 Subject: [PATCH 21/35] fix test --- tests/sentry/seer/explorer/test_tools.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/sentry/seer/explorer/test_tools.py b/tests/sentry/seer/explorer/test_tools.py index fc27dcffffde3e..65a8c55f313a34 100644 --- a/tests/sentry/seer/explorer/test_tools.py +++ b/tests/sentry/seer/explorer/test_tools.py @@ -1871,6 +1871,7 @@ def test_get_log_attributes_for_trace(self) -> None: org_id=self.organization.id, trace_id=trace_id, message_substring="request", + substring_case_sensitive=False, stats_period="1d", ) assert result is not None From 27a35ea2a6103105b82f380b13cbf313bc19d70f Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 25 Nov 2025 13:54:13 -0800 Subject: [PATCH 22/35] fix test --- tests/sentry/seer/explorer/test_tools.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/sentry/seer/explorer/test_tools.py b/tests/sentry/seer/explorer/test_tools.py index 65a8c55f313a34..9417aecf9044cb 100644 --- a/tests/sentry/seer/explorer/test_tools.py +++ b/tests/sentry/seer/explorer/test_tools.py @@ -1883,7 +1883,13 @@ def test_get_log_attributes_for_trace(self) -> None: assert int(ts) == logs[1].timestamp.seconds assert isinstance(item["attributes"].get("timestamp_precise"), int) - assert item["attributes"]["project"] == self.project.slug - assert item["attributes"]["project_id"] == self.project.id - assert item["attributes"]["severity"] == "INFO" - assert item["attributes"]["message"] == "Request processed successfully" + + for name, value, type in [ + ("project", self.project.slug, "string"), + ("project_id", self.project.id, "int"), + ("severity", "INFO", "string"), + ("message", "Request processed successfully", "string"), + # todo: boolean and double attributes + ]: + assert item["attributes"][name]["value"] == value + assert item["attributes"][name]["type"] == type From 4ba3322b144628cb9090a1a69b93280bef45f077 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 25 Nov 2025 13:58:37 -0800 Subject: [PATCH 23/35] fix type --- src/sentry/seer/explorer/tools.py | 2 +- tests/sentry/seer/explorer/test_tools.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index ed6ccb80ff769b..f8ab017b7c1ced 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -1017,7 +1017,7 @@ def _make_get_trace_request( - timestamp: ISO 8601 timestamp, Z suffix. - attributes: A dictionary of dictionaries, where the keys are the attribute names. - attributes[name].value: The value of the attribute (str, int, float, bool). - - attributes[name].type: The type of the attribute (str). + - attributes[name].type: The string type of the attribute ("string", "integer", "double", "boolean"). """ organization = cast(Organization, resolver.params.organization) projects = list(resolver.params.projects) diff --git a/tests/sentry/seer/explorer/test_tools.py b/tests/sentry/seer/explorer/test_tools.py index 9417aecf9044cb..25de25b4456a72 100644 --- a/tests/sentry/seer/explorer/test_tools.py +++ b/tests/sentry/seer/explorer/test_tools.py @@ -1886,7 +1886,7 @@ def test_get_log_attributes_for_trace(self) -> None: for name, value, type in [ ("project", self.project.slug, "string"), - ("project_id", self.project.id, "int"), + ("project_id", self.project.id, "integer"), ("severity", "INFO", "string"), ("message", "Request processed successfully", "string"), # todo: boolean and double attributes From 5a2201c77cddda2312dc0b89f008528c85b038bd Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 25 Nov 2025 16:32:48 -0800 Subject: [PATCH 24/35] fix public alias lookup --- src/sentry/seer/explorer/tools.py | 79 ++++++++++++++++--------------- 1 file changed, 41 insertions(+), 38 deletions(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index f8ab017b7c1ced..ceae30813d02fe 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -3,6 +3,7 @@ from datetime import UTC, datetime, timedelta, timezone from typing import Any, cast +from psycopg2.extensions import BOOLEAN from sentry_protos.snuba.v1.endpoint_get_trace_pb2 import GetTraceRequest from sentry_protos.snuba.v1.request_common_pb2 import TraceItemType @@ -21,6 +22,7 @@ from sentry.models.repository import Repository from sentry.replays.post_process import process_raw_response from sentry.replays.query import query_replay_id_by_prefix, query_replay_instance +from sentry.search.eap.columns import ResolvedAttribute from sentry.search.eap.constants import DOUBLE, INT, STRING from sentry.search.eap.resolver import SearchResolver from sentry.search.eap.types import SearchResolverConfig @@ -1017,7 +1019,7 @@ def _make_get_trace_request( - timestamp: ISO 8601 timestamp, Z suffix. - attributes: A dictionary of dictionaries, where the keys are the attribute names. - attributes[name].value: The value of the attribute (str, int, float, bool). - - attributes[name].type: The string type of the attribute ("string", "integer", "double", "boolean"). + - attributes[name].type: The string type of the attribute. """ organization = cast(Organization, resolver.params.organization) projects = list(resolver.params.projects) @@ -1049,56 +1051,57 @@ def _make_get_trace_request( if limit: request.limit = limit - # Query EAP EndpointGetTrace + # Query EAP EndpointGetTrace then format the response - based on spans_rpc.Spans.run_trace_query response = get_trace_rpc(request) - # Format the response - based on spans_rpc.Spans.run_trace_query - for item_group in response.item_groups: - # Collect the returned attributes - attributes = [] - for item in item_group.items: - for attribute in item.attributes: - attributes.append(attribute.key.name) + # Map internal names to attribute definitions for easy lookup + resolved_attrs_by_internal_name: dict[str, ResolvedAttribute] = {} + for r in resolver.definitions.columns.values(): + # Use the first found resolved attribute for each internal name. Avoids duplicates like project.id and project_id. + if not r.secondary_alias and r.internal_name not in resolved_attrs_by_internal_name: + resolved_attrs_by_internal_name[r.internal_name] = r - # Resolve the attributes to get the types and public aliases - items: list[dict[str, Any]] = [] - resolved_attrs, _ = resolver.resolve_attributes(attributes) - resolved_attrs_by_name = {attributes[i]: resolved_attrs[i] for i in range(len(attributes))} + # Parse response, returning the public aliases. + for item_group in response.item_groups: + item_dicts: list[dict[str, Any]] = [] for item in item_group.items: attr_dict: dict[str, dict[str, Any]] = {} - for attribute in item.attributes: - r = resolved_attrs_by_name[attribute.key.name] - if r.proto_definition.type == STRING: - attr_dict[r.public_alias] = { - "value": attribute.value.val_str, - "type": "string", + for a in item.attributes: + r = resolved_attrs_by_internal_name.get(a.key.name) + public_alias = r.public_alias if r else a.key.name + + if a.key.type == STRING: + attr_dict[public_alias] = { + "value": a.value.val_str, + "type": STRING, } - elif r.proto_definition.type == DOUBLE: - attr_dict[r.public_alias] = { - "value": attribute.value.val_double, - "type": "double", + elif a.key.type == DOUBLE: + attr_dict[public_alias] = { + "value": a.value.val_double, + "type": DOUBLE, } - elif r.search_type == "boolean": - attr_dict[r.public_alias] = { - "value": attribute.value.val_int == 1, - "type": "boolean", + elif a.key.type == BOOLEAN or ( + a.key.type == INT and r and r.search_type == "boolean" + ): + attr_dict[public_alias] = { + "value": a.value.val_int == 1, + "type": BOOLEAN, } - elif r.proto_definition.type == INT: - attr_dict[r.public_alias] = { - "value": attribute.value.val_int, - "type": "integer", + elif a.key.type == INT: + attr_dict[public_alias] = { + "value": a.value.val_int, + "type": INT, } - if r.public_alias == "project.id": + + if r and r.internal_name == "sentry.project_id": # Enrich with project slug, alias "project" attr_dict["project"] = { - "value": resolver.params.project_id_map.get( - attribute.value.val_int, "Unknown" - ), - "type": "string", + "value": resolver.params.project_id_map.get(a.value.val_int, "Unknown"), + "type": STRING, } - items.append( + item_dicts.append( { "id": item.id, "timestamp": item.timestamp.ToJsonString(), @@ -1107,7 +1110,7 @@ def _make_get_trace_request( ) # We expect exactly one item group in the request/response. - return items + return item_dicts return [] From e39e5ed6337c70c6ff682df6242e3aecc7215153 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 25 Nov 2025 17:01:31 -0800 Subject: [PATCH 25/35] workin test --- tests/sentry/seer/explorer/test_tools.py | 49 +++++++++++++++--------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/tests/sentry/seer/explorer/test_tools.py b/tests/sentry/seer/explorer/test_tools.py index 25de25b4456a72..fb4b78e1a29022 100644 --- a/tests/sentry/seer/explorer/test_tools.py +++ b/tests/sentry/seer/explorer/test_tools.py @@ -1834,16 +1834,23 @@ def test_logs_table_basic(self) -> None: for field in self.default_fields: assert field in log, field - def test_get_log_attributes_for_trace(self) -> None: - trace_id = uuid.uuid4().hex + +class TestLogsTraceQuery(APITransactionTestCase, SnubaTestCase, OurLogTestCase): + def setUp(self) -> None: + super().setUp() + self.login_as(user=self.user) + self.ten_mins_ago = before_now(minutes=10) + self.nine_mins_ago = before_now(minutes=9) + + self.trace_id = uuid.uuid4().hex # Create logs with various attributes - logs = [ + self.logs = [ self.create_ourlog( { "body": "User authentication failed", "severity_text": "ERROR", "severity_number": 17, - "trace_id": trace_id, + "trace_id": self.trace_id, }, timestamp=self.ten_mins_ago, ), @@ -1852,7 +1859,6 @@ def test_get_log_attributes_for_trace(self) -> None: "body": "Request processed successfully", "severity_text": "INFO", "severity_number": 9, - "trace_id": trace_id, }, timestamp=self.nine_mins_ago, ), @@ -1861,35 +1867,40 @@ def test_get_log_attributes_for_trace(self) -> None: "body": "Database connection timeout", "severity_text": "WARN", "severity_number": 13, + "trace_id": self.trace_id, }, timestamp=self.nine_mins_ago, ), ] - self.store_ourlogs(logs) + self.store_ourlogs(self.logs) + def test_get_log_attributes_for_trace_basic(self) -> None: result = get_log_attributes_for_trace( org_id=self.organization.id, - trace_id=trace_id, - message_substring="request", - substring_case_sensitive=False, + trace_id=self.trace_id, stats_period="1d", ) assert result is not None - assert len(result["data"]) == 1 + assert len(result["data"]) == 2 - item = result["data"][0] - assert bytes(reversed(item["id"])) == logs[1].item_id - ts = datetime.fromisoformat(item["timestamp"]).timestamp() - assert int(ts) == logs[1].timestamp.seconds + auth_log_expected = self.logs[0] + auth_log = None + for item in result["data"]: + if item["id"] == auth_log_expected.item_id.hex(): + auth_log = item - assert isinstance(item["attributes"].get("timestamp_precise"), int) + assert auth_log is not None + ts = datetime.fromisoformat(auth_log["timestamp"]).timestamp() + assert int(ts) == auth_log_expected.timestamp.seconds + + assert isinstance(auth_log["attributes"].get("timestamp_precise"), int) for name, value, type in [ + ("message", "Request processed successfully", "string"), ("project", self.project.slug, "string"), - ("project_id", self.project.id, "integer"), + ("project.id", self.project.id, "integer"), ("severity", "INFO", "string"), - ("message", "Request processed successfully", "string"), # todo: boolean and double attributes ]: - assert item["attributes"][name]["value"] == value - assert item["attributes"][name]["type"] == type + assert auth_log["attributes"][name]["value"] == value + assert auth_log["attributes"][name]["type"] == type From 3c98e5b4fecdb0369522ee2b14a459a80ccbbe65 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 25 Nov 2025 17:08:44 -0800 Subject: [PATCH 26/35] more tests --- tests/sentry/seer/explorer/test_tools.py | 68 +++++++++++++++++++++++- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/tests/sentry/seer/explorer/test_tools.py b/tests/sentry/seer/explorer/test_tools.py index fb4b78e1a29022..b1f40a573cd7b6 100644 --- a/tests/sentry/seer/explorer/test_tools.py +++ b/tests/sentry/seer/explorer/test_tools.py @@ -1871,6 +1871,15 @@ def setUp(self) -> None: }, timestamp=self.nine_mins_ago, ), + self.create_ourlog( + { + "body": "Another database connection timeout", + "severity_text": "WARN", + "severity_number": 13, + "trace_id": self.trace_id, + }, + timestamp=self.nine_mins_ago, + ), ] self.store_ourlogs(self.logs) @@ -1881,7 +1890,7 @@ def test_get_log_attributes_for_trace_basic(self) -> None: stats_period="1d", ) assert result is not None - assert len(result["data"]) == 2 + assert len(result["data"]) == 3 auth_log_expected = self.logs[0] auth_log = None @@ -1900,7 +1909,62 @@ def test_get_log_attributes_for_trace_basic(self) -> None: ("project", self.project.slug, "string"), ("project.id", self.project.id, "integer"), ("severity", "INFO", "string"), - # todo: boolean and double attributes + # todo: boolean and double custom attributes ]: assert auth_log["attributes"][name]["value"] == value assert auth_log["attributes"][name]["type"] == type + + def test_get_log_attributes_for_trace_substring_filter(self) -> None: + result = get_log_attributes_for_trace( + org_id=self.organization.id, + trace_id=self.trace_id, + stats_period="1d", + message_substring="database", + message_substring_case_sensitive=False, + ) + assert result is not None + assert len(result["data"]) == 2 + ids = [item["id"] for item in result["data"]] + assert self.logs[2].item_id.hex() in ids + assert self.logs[3].item_id.hex() in ids + + result = get_log_attributes_for_trace( + org_id=self.organization.id, + trace_id=self.trace_id, + stats_period="1d", + message_substring="database", + message_substring_case_sensitive=True, + ) + assert result is not None + assert len(result["data"]) == 1 + assert result["data"][0]["id"] == self.logs[3].item_id.hex() + + def test_get_log_attributes_for_trace_limit_no_filter(self) -> None: + result = get_log_attributes_for_trace( + org_id=self.organization.id, + trace_id=self.trace_id, + stats_period="1d", + limit=1, + ) + assert result is not None + assert len(result["data"]) == 1 + assert result["data"][0]["id"] in [ + self.logs[0].item_id.hex(), + self.logs[2].item_id.hex(), + self.logs[3].item_id.hex(), + ] + + def test_get_log_attributes_for_trace_limit_with_filter(self) -> None: + result = get_log_attributes_for_trace( + org_id=self.organization.id, + trace_id=self.trace_id, + stats_period="1d", + message_substring="database", + message_substring_case_sensitive=False, + limit=2, + ) + assert result is not None + assert len(result["data"]) == 2 + ids = [item["id"] for item in result["data"]] + assert self.logs[2].item_id.hex() in ids + assert self.logs[3].item_id.hex() in ids From 7294901656ea717259f7a3372a8fa25553555e8d Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 25 Nov 2025 18:05:48 -0800 Subject: [PATCH 27/35] cursor reveiw --- src/sentry/seer/explorer/tools.py | 3 +-- tests/sentry/seer/explorer/test_tools.py | 10 +++++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index ceae30813d02fe..122398f9d4a2a2 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -3,7 +3,6 @@ from datetime import UTC, datetime, timedelta, timezone from typing import Any, cast -from psycopg2.extensions import BOOLEAN from sentry_protos.snuba.v1.endpoint_get_trace_pb2 import GetTraceRequest from sentry_protos.snuba.v1.request_common_pb2 import TraceItemType @@ -23,7 +22,7 @@ from sentry.replays.post_process import process_raw_response from sentry.replays.query import query_replay_id_by_prefix, query_replay_instance from sentry.search.eap.columns import ResolvedAttribute -from sentry.search.eap.constants import DOUBLE, INT, STRING +from sentry.search.eap.constants import BOOLEAN, DOUBLE, INT, STRING from sentry.search.eap.resolver import SearchResolver from sentry.search.eap.types import SearchResolverConfig from sentry.search.events.types import SAMPLING_MODES, SnubaParams diff --git a/tests/sentry/seer/explorer/test_tools.py b/tests/sentry/seer/explorer/test_tools.py index b1f40a573cd7b6..0a911e8e356a03 100644 --- a/tests/sentry/seer/explorer/test_tools.py +++ b/tests/sentry/seer/explorer/test_tools.py @@ -1905,10 +1905,10 @@ def test_get_log_attributes_for_trace_basic(self) -> None: assert isinstance(auth_log["attributes"].get("timestamp_precise"), int) for name, value, type in [ - ("message", "Request processed successfully", "string"), + ("message", "User authentication failed", "string"), ("project", self.project.slug, "string"), ("project.id", self.project.id, "integer"), - ("severity", "INFO", "string"), + ("severity", "ERROR", "string"), # todo: boolean and double custom attributes ]: assert auth_log["attributes"][name]["value"] == value @@ -1920,7 +1920,7 @@ def test_get_log_attributes_for_trace_substring_filter(self) -> None: trace_id=self.trace_id, stats_period="1d", message_substring="database", - message_substring_case_sensitive=False, + substring_case_sensitive=False, ) assert result is not None assert len(result["data"]) == 2 @@ -1933,7 +1933,7 @@ def test_get_log_attributes_for_trace_substring_filter(self) -> None: trace_id=self.trace_id, stats_period="1d", message_substring="database", - message_substring_case_sensitive=True, + substring_case_sensitive=True, ) assert result is not None assert len(result["data"]) == 1 @@ -1960,7 +1960,7 @@ def test_get_log_attributes_for_trace_limit_with_filter(self) -> None: trace_id=self.trace_id, stats_period="1d", message_substring="database", - message_substring_case_sensitive=False, + substring_case_sensitive=False, limit=2, ) assert result is not None From dbe1a6c54af890b500027e18ec40157d0725d224 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 25 Nov 2025 18:26:38 -0800 Subject: [PATCH 28/35] rev --- src/sentry/seer/explorer/tools.py | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index 122398f9d4a2a2..109e18a9217fc5 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -1073,31 +1073,35 @@ def _make_get_trace_request( if a.key.type == STRING: attr_dict[public_alias] = { "value": a.value.val_str, - "type": STRING, + "type": "string", } elif a.key.type == DOUBLE: attr_dict[public_alias] = { "value": a.value.val_double, - "type": DOUBLE, + "type": "double", } - elif a.key.type == BOOLEAN or ( - a.key.type == INT and r and r.search_type == "boolean" - ): + elif a.key.type == BOOLEAN: attr_dict[public_alias] = { - "value": a.value.val_int == 1, - "type": BOOLEAN, + "value": a.value.val_bool, + "type": "boolean", } elif a.key.type == INT: - attr_dict[public_alias] = { - "value": a.value.val_int, - "type": INT, - } + if r and r.search_type == "boolean": + attr_dict[public_alias] = { + "value": a.value.val_int == 1, + "type": "boolean", + } + else: + attr_dict[public_alias] = { + "value": a.value.val_int, + "type": "integer", + } if r and r.internal_name == "sentry.project_id": # Enrich with project slug, alias "project" attr_dict["project"] = { "value": resolver.params.project_id_map.get(a.value.val_int, "Unknown"), - "type": STRING, + "type": "string", } item_dicts.append( From d34aae090a96aa24112d9aa24d8ca3a0a2d6d104 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 25 Nov 2025 20:08:09 -0800 Subject: [PATCH 29/35] fix test --- tests/sentry/seer/explorer/test_tools.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/tests/sentry/seer/explorer/test_tools.py b/tests/sentry/seer/explorer/test_tools.py index 0a911e8e356a03..34d219599ad9fa 100644 --- a/tests/sentry/seer/explorer/test_tools.py +++ b/tests/sentry/seer/explorer/test_tools.py @@ -5,6 +5,7 @@ import pytest from pydantic import BaseModel +from sentry_protos.snuba.v1.trace_item_pb2 import TraceItem from sentry.api import client from sentry.constants import ObjectStatus @@ -1883,6 +1884,9 @@ def setUp(self) -> None: ] self.store_ourlogs(self.logs) + def get_id_str(item: TraceItem) -> str: + return item.item_id[::-1].hex() + def test_get_log_attributes_for_trace_basic(self) -> None: result = get_log_attributes_for_trace( org_id=self.organization.id, @@ -1895,7 +1899,7 @@ def test_get_log_attributes_for_trace_basic(self) -> None: auth_log_expected = self.logs[0] auth_log = None for item in result["data"]: - if item["id"] == auth_log_expected.item_id.hex(): + if item["id"] == self.get_id_str(auth_log_expected): auth_log = item assert auth_log is not None @@ -1925,8 +1929,8 @@ def test_get_log_attributes_for_trace_substring_filter(self) -> None: assert result is not None assert len(result["data"]) == 2 ids = [item["id"] for item in result["data"]] - assert self.logs[2].item_id.hex() in ids - assert self.logs[3].item_id.hex() in ids + assert self.get_id_str(self.logs[2]) in ids + assert self.get_id_str(self.logs[3]) in ids result = get_log_attributes_for_trace( org_id=self.organization.id, @@ -1937,7 +1941,7 @@ def test_get_log_attributes_for_trace_substring_filter(self) -> None: ) assert result is not None assert len(result["data"]) == 1 - assert result["data"][0]["id"] == self.logs[3].item_id.hex() + assert result["data"][0]["id"] == self.get_id_str(self.logs[3]) def test_get_log_attributes_for_trace_limit_no_filter(self) -> None: result = get_log_attributes_for_trace( @@ -1949,9 +1953,9 @@ def test_get_log_attributes_for_trace_limit_no_filter(self) -> None: assert result is not None assert len(result["data"]) == 1 assert result["data"][0]["id"] in [ - self.logs[0].item_id.hex(), - self.logs[2].item_id.hex(), - self.logs[3].item_id.hex(), + self.get_id_str(self.logs[0]), + self.get_id_str(self.logs[2]), + self.get_id_str(self.logs[3]), ] def test_get_log_attributes_for_trace_limit_with_filter(self) -> None: @@ -1966,5 +1970,5 @@ def test_get_log_attributes_for_trace_limit_with_filter(self) -> None: assert result is not None assert len(result["data"]) == 2 ids = [item["id"] for item in result["data"]] - assert self.logs[2].item_id.hex() in ids - assert self.logs[3].item_id.hex() in ids + assert self.get_id_str(self.logs[2]) in ids + assert self.get_id_str(self.logs[3]) in ids From f3eeed8a1d38c0cc2e7f0657c424679c6af435c5 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 25 Nov 2025 20:24:46 -0800 Subject: [PATCH 30/35] staticmethod --- tests/sentry/seer/explorer/test_tools.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/sentry/seer/explorer/test_tools.py b/tests/sentry/seer/explorer/test_tools.py index 34d219599ad9fa..9187732bcfdebf 100644 --- a/tests/sentry/seer/explorer/test_tools.py +++ b/tests/sentry/seer/explorer/test_tools.py @@ -1884,6 +1884,7 @@ def setUp(self) -> None: ] self.store_ourlogs(self.logs) + @staticmethod def get_id_str(item: TraceItem) -> str: return item.item_id[::-1].hex() From be2f8fbabc939b204625676acd5a2e86cac00631 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 25 Nov 2025 21:22:55 -0800 Subject: [PATCH 31/35] fix --- src/sentry/seer/explorer/tools.py | 6 ++++-- tests/sentry/seer/explorer/test_tools.py | 13 ++++++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index 109e18a9217fc5..8c3681baeb43a6 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -1176,7 +1176,8 @@ def get_log_attributes_for_trace( ) if not message_substring: - # Limit is already applied by the EAP request + if limit is not None: + items = items[:limit] # Re-apply in case the endpoint didn't respect it. return {"data": items} # Filter on message substring. @@ -1252,7 +1253,8 @@ def get_metric_attributes_for_trace( ) if not metric_name: - # Limit is already applied by the EAP request + if limit is not None: + items = items[:limit] # Re-apply in case the endpoint didn't respect it. return {"data": items} # Filter on metric name (exact case-insensitive match). diff --git a/tests/sentry/seer/explorer/test_tools.py b/tests/sentry/seer/explorer/test_tools.py index 9187732bcfdebf..b2612727a7f63e 100644 --- a/tests/sentry/seer/explorer/test_tools.py +++ b/tests/sentry/seer/explorer/test_tools.py @@ -1853,6 +1853,12 @@ def setUp(self) -> None: "severity_number": 17, "trace_id": self.trace_id, }, + attributes={ + "my-string-attribute": "custom value", + "my-boolean-attribute": True, + "my-double-attribute": 1.23, + "my-integer-attribute": 123, + }, timestamp=self.ten_mins_ago, ), self.create_ourlog( @@ -1907,14 +1913,15 @@ def test_get_log_attributes_for_trace_basic(self) -> None: ts = datetime.fromisoformat(auth_log["timestamp"]).timestamp() assert int(ts) == auth_log_expected.timestamp.seconds - assert isinstance(auth_log["attributes"].get("timestamp_precise"), int) - for name, value, type in [ ("message", "User authentication failed", "string"), ("project", self.project.slug, "string"), ("project.id", self.project.id, "integer"), ("severity", "ERROR", "string"), - # todo: boolean and double custom attributes + ("my-string-attribute", "custom value", "string"), + ("my-boolean-attribute", True, "boolean"), + ("my-double-attribute", 1.23, "double"), + ("my-integer-attribute", 123, "integer"), ]: assert auth_log["attributes"][name]["value"] == value assert auth_log["attributes"][name]["type"] == type From 28a0c669cbad5531b005f3713735892c511637d7 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 25 Nov 2025 22:08:20 -0800 Subject: [PATCH 32/35] shorten typestrs and add project.id attr --- src/sentry/seer/explorer/tools.py | 20 ++++++++++---------- tests/sentry/seer/explorer/test_tools.py | 15 ++++++++------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index 8c3681baeb43a6..173198c07b85d7 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -1017,8 +1017,8 @@ def _make_get_trace_request( - id: The trace item ID. - timestamp: ISO 8601 timestamp, Z suffix. - attributes: A dictionary of dictionaries, where the keys are the attribute names. - - attributes[name].value: The value of the attribute (str, int, float, bool). - - attributes[name].type: The string type of the attribute. + - attributes[name].value: The value of the attribute (primitives only) + - attributes[name].type: The type of the attribute ("str", "int", "double", "bool") """ organization = cast(Organization, resolver.params.organization) projects = list(resolver.params.projects) @@ -1055,10 +1055,10 @@ def _make_get_trace_request( # Map internal names to attribute definitions for easy lookup resolved_attrs_by_internal_name: dict[str, ResolvedAttribute] = {} - for r in resolver.definitions.columns.values(): + for c in resolver.definitions.columns.values(): # Use the first found resolved attribute for each internal name. Avoids duplicates like project.id and project_id. - if not r.secondary_alias and r.internal_name not in resolved_attrs_by_internal_name: - resolved_attrs_by_internal_name[r.internal_name] = r + if not c.secondary_alias and c.internal_name not in resolved_attrs_by_internal_name: + resolved_attrs_by_internal_name[c.internal_name] = c # Parse response, returning the public aliases. for item_group in response.item_groups: @@ -1073,7 +1073,7 @@ def _make_get_trace_request( if a.key.type == STRING: attr_dict[public_alias] = { "value": a.value.val_str, - "type": "string", + "type": "str", } elif a.key.type == DOUBLE: attr_dict[public_alias] = { @@ -1083,25 +1083,25 @@ def _make_get_trace_request( elif a.key.type == BOOLEAN: attr_dict[public_alias] = { "value": a.value.val_bool, - "type": "boolean", + "type": "bool", } elif a.key.type == INT: if r and r.search_type == "boolean": attr_dict[public_alias] = { "value": a.value.val_int == 1, - "type": "boolean", + "type": "bool", } else: attr_dict[public_alias] = { "value": a.value.val_int, - "type": "integer", + "type": "int", } if r and r.internal_name == "sentry.project_id": # Enrich with project slug, alias "project" attr_dict["project"] = { "value": resolver.params.project_id_map.get(a.value.val_int, "Unknown"), - "type": "string", + "type": "str", } item_dicts.append( diff --git a/tests/sentry/seer/explorer/test_tools.py b/tests/sentry/seer/explorer/test_tools.py index b2612727a7f63e..a5f32092e459a8 100644 --- a/tests/sentry/seer/explorer/test_tools.py +++ b/tests/sentry/seer/explorer/test_tools.py @@ -1854,6 +1854,7 @@ def setUp(self) -> None: "trace_id": self.trace_id, }, attributes={ + "sentry.project_id": self.project.id, "my-string-attribute": "custom value", "my-boolean-attribute": True, "my-double-attribute": 1.23, @@ -1914,14 +1915,14 @@ def test_get_log_attributes_for_trace_basic(self) -> None: assert int(ts) == auth_log_expected.timestamp.seconds for name, value, type in [ - ("message", "User authentication failed", "string"), - ("project", self.project.slug, "string"), - ("project.id", self.project.id, "integer"), - ("severity", "ERROR", "string"), - ("my-string-attribute", "custom value", "string"), - ("my-boolean-attribute", True, "boolean"), + ("message", "User authentication failed", "str"), + ("project", self.project.slug, "str"), + ("project.id", self.project.id, "int"), + ("severity", "ERROR", "str"), + ("my-string-attribute", "custom value", "str"), + ("my-boolean-attribute", True, "bool"), ("my-double-attribute", 1.23, "double"), - ("my-integer-attribute", 123, "integer"), + ("my-integer-attribute", 123, "int"), ]: assert auth_log["attributes"][name]["value"] == value assert auth_log["attributes"][name]["type"] == type From 72dee605782f69c0ee0209ad5f53f39246094abc Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 25 Nov 2025 22:51:21 -0800 Subject: [PATCH 33/35] final fix --- src/sentry/seer/explorer/tools.py | 3 ++- tests/sentry/seer/explorer/test_tools.py | 10 +++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index 173198c07b85d7..23f9e7ac6a1943 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -1070,6 +1070,7 @@ def _make_get_trace_request( r = resolved_attrs_by_internal_name.get(a.key.name) public_alias = r.public_alias if r else a.key.name + # Note - custom attrs not in the definitions can only be returned as strings or doubles. if a.key.type == STRING: attr_dict[public_alias] = { "value": a.value.val_str, @@ -1097,7 +1098,7 @@ def _make_get_trace_request( "type": "int", } - if r and r.internal_name == "sentry.project_id": + if public_alias == "project.id" or public_alias == "project_id": # Enrich with project slug, alias "project" attr_dict["project"] = { "value": resolver.params.project_id_map.get(a.value.val_int, "Unknown"), diff --git a/tests/sentry/seer/explorer/test_tools.py b/tests/sentry/seer/explorer/test_tools.py index a5f32092e459a8..8e48af313e630c 100644 --- a/tests/sentry/seer/explorer/test_tools.py +++ b/tests/sentry/seer/explorer/test_tools.py @@ -1917,15 +1917,15 @@ def test_get_log_attributes_for_trace_basic(self) -> None: for name, value, type in [ ("message", "User authentication failed", "str"), ("project", self.project.slug, "str"), - ("project.id", self.project.id, "int"), + ("project.id", self.project.id, "double"), ("severity", "ERROR", "str"), ("my-string-attribute", "custom value", "str"), - ("my-boolean-attribute", True, "bool"), + ("my-boolean-attribute", True, "double"), ("my-double-attribute", 1.23, "double"), - ("my-integer-attribute", 123, "int"), + ("my-integer-attribute", 123, "double"), ]: - assert auth_log["attributes"][name]["value"] == value - assert auth_log["attributes"][name]["type"] == type + assert auth_log["attributes"][name]["value"] == value, name + assert auth_log["attributes"][name]["type"] == type, f"{name} type mismatch" def test_get_log_attributes_for_trace_substring_filter(self) -> None: result = get_log_attributes_for_trace( From cfe0b115ffa5b4059d13203ad466eabf5c3677b2 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 25 Nov 2025 22:58:58 -0800 Subject: [PATCH 34/35] metric tests --- tests/sentry/seer/explorer/test_tools.py | 158 +++++++++++++++++++++++ 1 file changed, 158 insertions(+) diff --git a/tests/sentry/seer/explorer/test_tools.py b/tests/sentry/seer/explorer/test_tools.py index 8e48af313e630c..86ab32491dbd15 100644 --- a/tests/sentry/seer/explorer/test_tools.py +++ b/tests/sentry/seer/explorer/test_tools.py @@ -20,6 +20,7 @@ execute_timeseries_query, get_issue_and_event_details, get_log_attributes_for_trace, + get_metric_attributes_for_trace, get_replay_metadata, get_repository_definition, get_trace_waterfall, @@ -33,6 +34,7 @@ ReplaysSnubaTestCase, SnubaTestCase, SpanTestCase, + TraceMetricsTestCase, ) from sentry.testutils.helpers.datetime import before_now from sentry.utils.dates import parse_stats_period @@ -1981,3 +1983,159 @@ def test_get_log_attributes_for_trace_limit_with_filter(self) -> None: ids = [item["id"] for item in result["data"]] assert self.get_id_str(self.logs[2]) in ids assert self.get_id_str(self.logs[3]) in ids + + +class TestMetricsTraceQuery(APITransactionTestCase, SnubaTestCase, TraceMetricsTestCase): + def setUp(self) -> None: + super().setUp() + self.login_as(user=self.user) + self.ten_mins_ago = before_now(minutes=10) + self.nine_mins_ago = before_now(minutes=9) + + self.trace_id = uuid.uuid4().hex + # Create metrics with various attributes + self.metrics = [ + self.create_trace_metric( + metric_name="http.request.duration", + metric_value=125.5, + metric_type="distribution", + metric_unit="millisecond", + trace_id=self.trace_id, + attributes={ + "sentry.project_id": self.project.id, + "http.method": "GET", + "http.status_code": 200, + "my-string-attribute": "custom value", + "my-boolean-attribute": True, + "my-double-attribute": 1.23, + "my-integer-attribute": 123, + }, + timestamp=self.ten_mins_ago, + ), + self.create_trace_metric( + metric_name="database.query.count", + metric_value=5.0, + metric_type="counter", + # No trace_id - should not be returned in trace queries + attributes={ + "sentry.project_id": self.project.id, + }, + timestamp=self.nine_mins_ago, + ), + self.create_trace_metric( + metric_name="http.request.duration", + metric_value=200.3, + metric_type="distribution", + metric_unit="millisecond", + trace_id=self.trace_id, + attributes={ + "sentry.project_id": self.project.id, + "http.method": "POST", + "http.status_code": 201, + }, + timestamp=self.nine_mins_ago, + ), + self.create_trace_metric( + metric_name="cache.hit.rate", + metric_value=0.85, + metric_type="gauge", + trace_id=self.trace_id, + attributes={ + "sentry.project_id": self.project.id, + "cache.type": "redis", + }, + timestamp=self.nine_mins_ago, + ), + ] + self.store_trace_metrics(self.metrics) + + @staticmethod + def get_id_str(item: TraceItem) -> str: + return item.item_id[::-1].hex() + + def test_get_metric_attributes_for_trace_basic(self) -> None: + result = get_metric_attributes_for_trace( + org_id=self.organization.id, + trace_id=self.trace_id, + stats_period="1d", + ) + assert result is not None + assert len(result["data"]) == 3 + + # Find the first http.request.duration metric + http_metric_expected = self.metrics[0] + http_metric = None + for item in result["data"]: + if item["id"] == self.get_id_str(http_metric_expected): + http_metric = item + + assert http_metric is not None + ts = datetime.fromisoformat(http_metric["timestamp"]).timestamp() + assert int(ts) == http_metric_expected.timestamp.seconds + + for name, value, type in [ + ("metric.name", "http.request.duration", "str"), + ("metric.type", "distribution", "str"), + ("value", 125.5, "double"), + ("project", self.project.slug, "str"), + ("project.id", self.project.id, "double"), + ("http.method", "GET", "str"), + ("http.status_code", 200, "double"), + ("my-string-attribute", "custom value", "str"), + ("my-boolean-attribute", True, "double"), + ("my-double-attribute", 1.23, "double"), + ("my-integer-attribute", 123, "double"), + ]: + assert http_metric["attributes"][name]["value"] == value, name + assert http_metric["attributes"][name]["type"] == type, f"{name} type mismatch" + + def test_get_metric_attributes_for_trace_name_filter(self) -> None: + # Test substring match (fails) + result = get_metric_attributes_for_trace( + org_id=self.organization.id, + trace_id=self.trace_id, + stats_period="1d", + metric_name="http.", + ) + assert result is not None + assert len(result["data"]) == 0 + + # Test an exact match (case-insensitive) + result = get_metric_attributes_for_trace( + org_id=self.organization.id, + trace_id=self.trace_id, + stats_period="1d", + metric_name="Cache.hit.rate", + ) + assert result is not None + assert len(result["data"]) == 1 + assert result["data"][0]["id"] == self.get_id_str(self.metrics[3]) + + def test_get_metric_attributes_for_trace_limit_no_filter(self) -> None: + result = get_metric_attributes_for_trace( + org_id=self.organization.id, + trace_id=self.trace_id, + stats_period="1d", + limit=1, + ) + assert result is not None + assert len(result["data"]) == 1 + assert result["data"][0]["id"] in [ + self.get_id_str(self.metrics[0]), + self.get_id_str(self.metrics[2]), + self.get_id_str(self.metrics[3]), + ] + + def test_get_metric_attributes_for_trace_limit_with_filter(self) -> None: + result = get_metric_attributes_for_trace( + org_id=self.organization.id, + trace_id=self.trace_id, + stats_period="1d", + metric_name="http.request.duration", + limit=2, + ) + assert result is not None + assert len(result["data"]) == 2 + ids = [item["id"] for item in result["data"]] + assert self.get_id_str(self.metrics[0]) in ids + assert self.get_id_str(self.metrics[2]) in ids From fcbf216e404c1dd304d9f5d4514fde1eb8c89045 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 25 Nov 2025 23:33:33 -0800 Subject: [PATCH 35/35] fix proj.id vs _id --- src/sentry/seer/explorer/tools.py | 13 ++++++------- tests/sentry/seer/explorer/test_tools.py | 11 ++--------- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index 23f9e7ac6a1943..aeab785afaf501 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -21,7 +21,6 @@ from sentry.models.repository import Repository from sentry.replays.post_process import process_raw_response from sentry.replays.query import query_replay_id_by_prefix, query_replay_instance -from sentry.search.eap.columns import ResolvedAttribute from sentry.search.eap.constants import BOOLEAN, DOUBLE, INT, STRING from sentry.search.eap.resolver import SearchResolver from sentry.search.eap.types import SearchResolverConfig @@ -1054,11 +1053,9 @@ def _make_get_trace_request( response = get_trace_rpc(request) # Map internal names to attribute definitions for easy lookup - resolved_attrs_by_internal_name: dict[str, ResolvedAttribute] = {} - for c in resolver.definitions.columns.values(): - # Use the first found resolved attribute for each internal name. Avoids duplicates like project.id and project_id. - if not c.secondary_alias and c.internal_name not in resolved_attrs_by_internal_name: - resolved_attrs_by_internal_name[c.internal_name] = c + resolved_attrs_by_internal_name = { + c.internal_name: c for c in resolver.definitions.columns.values() if not c.secondary_alias + } # Parse response, returning the public aliases. for item_group in response.item_groups: @@ -1069,6 +1066,8 @@ def _make_get_trace_request( for a in item.attributes: r = resolved_attrs_by_internal_name.get(a.key.name) public_alias = r.public_alias if r else a.key.name + if public_alias == "project_id": # Same internal name, normalize to project.id + public_alias = "project.id" # Note - custom attrs not in the definitions can only be returned as strings or doubles. if a.key.type == STRING: @@ -1098,7 +1097,7 @@ def _make_get_trace_request( "type": "int", } - if public_alias == "project.id" or public_alias == "project_id": + if public_alias == "project.id": # Enrich with project slug, alias "project" attr_dict["project"] = { "value": resolver.params.project_id_map.get(a.value.val_int, "Unknown"), diff --git a/tests/sentry/seer/explorer/test_tools.py b/tests/sentry/seer/explorer/test_tools.py index 86ab32491dbd15..6d54e7ec164db7 100644 --- a/tests/sentry/seer/explorer/test_tools.py +++ b/tests/sentry/seer/explorer/test_tools.py @@ -1856,7 +1856,6 @@ def setUp(self) -> None: "trace_id": self.trace_id, }, attributes={ - "sentry.project_id": self.project.id, "my-string-attribute": "custom value", "my-boolean-attribute": True, "my-double-attribute": 1.23, @@ -1919,7 +1918,7 @@ def test_get_log_attributes_for_trace_basic(self) -> None: for name, value, type in [ ("message", "User authentication failed", "str"), ("project", self.project.slug, "str"), - ("project.id", self.project.id, "double"), + ("project.id", self.project.id, "int"), ("severity", "ERROR", "str"), ("my-string-attribute", "custom value", "str"), ("my-boolean-attribute", True, "double"), @@ -2002,7 +2001,6 @@ def setUp(self) -> None: metric_unit="millisecond", trace_id=self.trace_id, attributes={ - "sentry.project_id": self.project.id, "http.method": "GET", "http.status_code": 200, "my-string-attribute": "custom value", @@ -2017,9 +2015,6 @@ def setUp(self) -> None: metric_value=5.0, metric_type="counter", # No trace_id - should not be returned in trace queries - attributes={ - "sentry.project_id": self.project.id, - }, timestamp=self.nine_mins_ago, ), self.create_trace_metric( @@ -2029,7 +2024,6 @@ def setUp(self) -> None: metric_unit="millisecond", trace_id=self.trace_id, attributes={ - "sentry.project_id": self.project.id, "http.method": "POST", "http.status_code": 201, }, @@ -2041,7 +2035,6 @@ def setUp(self) -> None: metric_type="gauge", trace_id=self.trace_id, attributes={ - "sentry.project_id": self.project.id, "cache.type": "redis", }, timestamp=self.nine_mins_ago, @@ -2078,7 +2071,7 @@ def test_get_metric_attributes_for_trace_basic(self) -> None: ("metric.type", "distribution", "str"), ("value", 125.5, "double"), ("project", self.project.slug, "str"), - ("project.id", self.project.id, "double"), + ("project.id", self.project.id, "int"), ("http.method", "GET", "str"), ("http.status_code", 200, "double"), ("my-string-attribute", "custom value", "str"),