Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions src/sentry/seer/assisted_query/metrics_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,13 @@ def get_metric_metadata(

params: dict[str, Any] = {
"dataset": "tracemetrics",
# Selecting metric.name/type/unit plus count() groups by the selected
# Selecting metric.name/type/unit plus count(value) groups by the selected
# non-aggregate fields, giving us distinct tuples with event counts.
"field": ["metric.name", "metric.type", "metric.unit", "count()"],
# tracemetrics requires count() to take an attribute argument — zero-arg
# count() parse-fails at the events layer.
"field": ["metric.name", "metric.type", "metric.unit", "count(value)"],
"query": query,
"sort": "-count()",
"sort": "-count(value)",
"per_page": per_page,
"statsPeriod": stats_period,
"project": project_ids or [ALL_ACCESS_PROJECT_ID],
Expand All @@ -114,10 +116,17 @@ def get_metric_metadata(
path=f"/organizations/{organization.slug}/events/",
params=params,
)
except client.ApiError:
except client.ApiError as e:
# Surface status + body prefix in log extras so prod flakes are debuggable
# without a new deploy. Keep the return `error` code stable for callers.
logger.exception(
"get_metric_metadata: events query failed",
extra={"org_id": org_id, "project_ids": project_ids},
extra={
"org_id": org_id,
"project_ids": project_ids,
"status_code": getattr(e, "status_code", None),
"body_prefix": str(getattr(e, "body", None))[:500],
},
)
return {"candidates": [], "has_more": False, "error": "events_query_failed"}

Expand All @@ -137,8 +146,9 @@ def get_metric_metadata(
munit = row.get("metric.unit") or "none"
if not name or not mtype:
continue
# count() may come back as "count()" or "count" depending on the dataset shape.
count = row.get("count()")
# count(value) may come back under the full function key or the bare name
# depending on the dataset shape.
count = row.get("count(value)")
if count is None:
count = row.get("count", 0)
candidates.append(
Expand Down
83 changes: 73 additions & 10 deletions tests/sentry/seer/assisted_query/test_metrics_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@
_build_or_query,
get_metric_metadata,
)
from sentry.testutils.cases import TestCase
from sentry.testutils.cases import (
APITransactionTestCase,
SnubaTestCase,
TestCase,
TraceMetricsTestCase,
)
from sentry.testutils.helpers.datetime import before_now


class TestBuildOrQuery(TestCase):
Expand Down Expand Up @@ -39,13 +45,13 @@ def test_returns_distinct_tuples_with_count(self, mock_client: MagicMock) -> Non
"metric.name": "http.request.duration",
"metric.type": "distribution",
"metric.unit": "millisecond",
"count()": 1200,
"count(value)": 1200,
},
{
"metric.name": "api.request.count",
"metric.type": "counter",
"metric.unit": "none",
"count()": 800,
"count(value)": 800,
},
]
}
Expand Down Expand Up @@ -75,9 +81,9 @@ def test_returns_distinct_tuples_with_count(self, mock_client: MagicMock) -> Non
"metric.name",
"metric.type",
"metric.unit",
"count()",
"count(value)",
]
assert params["sort"] == "-count()"
assert params["sort"] == "-count(value)"
assert params["query"] == '(metric.name:"*http*" OR metric.name:"*api*")'
# over-fetch by 1 to detect has_more
assert params["per_page"] == 11
Expand All @@ -102,7 +108,7 @@ def test_has_more_when_result_exceeds_limit(self, mock_client: MagicMock) -> Non
"metric.name": f"m.{i}",
"metric.type": "counter",
"metric.unit": "none",
"count()": 100 - i,
"count(value)": 100 - i,
}
for i in range(3)
]
Expand Down Expand Up @@ -134,15 +140,15 @@ def test_has_more_uses_raw_row_count_not_filtered_count(self, mock_client: Magic
"metric.name": "a",
"metric.type": "counter",
"metric.unit": "none",
"count()": 30,
"count(value)": 30,
},
# Malformed — will be filtered out locally.
{"metric.name": None, "metric.type": "counter", "metric.unit": "none"},
{
"metric.name": "b",
"metric.type": "counter",
"metric.unit": "none",
"count()": 20,
"count(value)": 20,
},
]
}
Expand All @@ -167,7 +173,7 @@ def test_skips_rows_missing_name_or_type(self, mock_client: MagicMock) -> None:
"metric.name": "good",
"metric.type": "counter",
"metric.unit": "none",
"count()": 10,
"count(value)": 10,
},
{"metric.name": "", "metric.type": "counter", "metric.unit": "none"},
{"metric.name": "no-type", "metric.type": None, "metric.unit": "none"},
Expand All @@ -192,7 +198,7 @@ def test_missing_unit_defaults_to_none(self, mock_client: MagicMock) -> None:
"metric.name": "foo",
"metric.type": "counter",
"metric.unit": None,
"count()": 5,
"count(value)": 5,
}
]
}
Expand Down Expand Up @@ -244,3 +250,60 @@ def test_events_query_failure_returns_error(self, mock_client: MagicMock) -> Non
"error": "events_query_failed",
}
mock_client.get.assert_called_once()


class TestGetMetricMetadataIntegration(APITransactionTestCase, SnubaTestCase, TraceMetricsTestCase):
"""End-to-end test against the real tracemetrics parser.

The mock-based tests above never exercised the events-layer query parser,
which masked that zero-arg count() parse-fails on tracemetrics. This class
persists real TraceItems and lets the handler's in-process client.get call
hit the actual parser, so a regression in the aggregate shape surfaces here.
"""

def setUp(self) -> None:
super().setUp()
ts = before_now(minutes=5)
self.store_eap_items(
[
self.create_trace_metric(
metric_name="http.request.duration",
metric_value=100.0,
metric_type="distribution",
metric_unit="millisecond",
timestamp=ts,
),
self.create_trace_metric(
metric_name="http.request.duration",
metric_value=200.0,
metric_type="distribution",
metric_unit="millisecond",
timestamp=ts,
),
self.create_trace_metric(
metric_name="api.request.count",
metric_value=1.0,
metric_type="counter",
timestamp=ts,
),
]
)

def test_returns_candidates_matching_substring(self) -> None:
result = get_metric_metadata(
org_id=self.organization.id,
project_ids=[self.project.id],
name_substrings=["http"],
stats_period="1h",
)

# A broken aggregate would short-circuit into events_query_failed.
assert "error" not in result, result
names = {c["name"] for c in result["candidates"]}
assert "http.request.duration" in names
assert "api.request.count" not in names

http_row = next(c for c in result["candidates"] if c["name"] == "http.request.duration")
assert http_row["type"] == "distribution"
assert http_row["unit"] == "millisecond"
assert http_row["count"] == 2
Loading