Skip to content

Commit 8d2e7e0

Browse files
aliu39claude
andcommitted
feat(seer): Return _seer_error_detail on timeseries query 400s
execute_timeseries_query previously let client.ApiError propagate on any non-2xx response, so a malformed query surfaced as an unhandled exception rather than actionable feedback for the query builder agent. Catch 400s and return the error detail under a reserved _seer_error_detail key. The key is deliberately distinct from a plain "error" key because grouped timeseries responses are keyed by group_by value at the top level, so an "error" group would otherwise be indistinguishable from a failure. Non-400 errors continue to propagate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9cbb88f commit 8d2e7e0

2 files changed

Lines changed: 68 additions & 6 deletions

File tree

src/sentry/seer/agent/tools.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -299,12 +299,26 @@ def execute_timeseries_query(
299299
params = {k: v for k, v in params.items() if v is not None}
300300

301301
# Call sentry API client. This will raise API errors for non-2xx / 3xx status.
302-
resp = client.get(
303-
auth=ApiKey(organization_id=organization.id, scope_list=["org:read", "project:read"]),
304-
user=None,
305-
path=f"/organizations/{organization.slug}/events-stats/",
306-
params=params,
307-
)
302+
try:
303+
resp = client.get(
304+
auth=ApiKey(organization_id=organization.id, scope_list=["org:read", "project:read"]),
305+
user=None,
306+
path=f"/organizations/{organization.slug}/events-stats/",
307+
params=params,
308+
)
309+
except client.ApiError as e:
310+
# For 400 errors, return an error detail for the query builder agent.
311+
# Use a reserved "_seer_error_detail" key so it can't collide with a
312+
# group_by value (which becomes a top-level key in grouped responses below).
313+
if e.status_code == 400:
314+
logger.exception("execute_timeseries_query: bad request", extra={"org_id": org_id})
315+
error_detail = e.body.get("detail") if isinstance(e.body, dict) else None
316+
return {
317+
"_seer_error_detail": (
318+
str(error_detail) if error_detail is not None else str(e.body)
319+
)
320+
}
321+
raise
308322
data = resp.data
309323

310324
# Always normalize to the nested {"metric": {"data": [...]}} format for consistency

tests/sentry/seer/agent/test_tools.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,54 @@ def test_spans_table_query_error_handling(self, mock_client_get):
455455

456456
assert exc_info.value.status_code == 500
457457

458+
@patch("sentry.seer.agent.tools.client.get")
459+
def test_spans_timeseries_query_error_handling(self, mock_client_get: Mock) -> None:
460+
"""400 errors return the detail under a reserved _seer_error_detail key so it can't collide
461+
with a group_by value (which becomes a top-level key); non-400 errors are re-raised."""
462+
# 400 error with dict body containing detail.
463+
error_detail_msg = "Invalid query: field 'invalid_field' does not exist"
464+
mock_client_get.side_effect = client.ApiError(400, {"detail": error_detail_msg})
465+
466+
result = execute_timeseries_query(
467+
org_id=self.organization.id,
468+
dataset="spans",
469+
y_axes=["count()"],
470+
query="invalid_field:value",
471+
stats_period="1h",
472+
)
473+
474+
assert result == {"_seer_error_detail": error_detail_msg}
475+
# The detail is not exposed under "error", where it could shadow a group value.
476+
assert "error" not in result
477+
478+
# 400 error with a non-dict body falls back to stringifying the whole body.
479+
error_body = "Bad request: malformed query syntax"
480+
mock_client_get.side_effect = client.ApiError(400, error_body)
481+
482+
result = execute_timeseries_query(
483+
org_id=self.organization.id,
484+
dataset="spans",
485+
y_axes=["count()"],
486+
query="malformed query",
487+
stats_period="1h",
488+
)
489+
490+
assert result == {"_seer_error_detail": error_body}
491+
492+
# Non-400 errors are re-raised.
493+
mock_client_get.side_effect = client.ApiError(500, {"detail": "Internal server error"})
494+
495+
with pytest.raises(client.ApiError) as exc_info:
496+
execute_timeseries_query(
497+
org_id=self.organization.id,
498+
dataset="spans",
499+
y_axes=["count()"],
500+
query="",
501+
stats_period="1h",
502+
)
503+
504+
assert exc_info.value.status_code == 500
505+
458506
@patch("sentry.seer.agent.tools.client.get")
459507
def test_table_query_forwards_cross_event_params(self, mock_client_get: Mock) -> None:
460508
"""Cross-event filters are forwarded to /events/ as repeated spanQuery/logQuery/metricQuery params."""

0 commit comments

Comments
 (0)