Skip to content

Commit ded27f0

Browse files
committed
ref(seer): Type 10 issue/event/agent/PR seer RPC responses
Replaces the `dict[str, Any]` return annotations on the following seer RPC methods with explicit Pydantic models so the seer-side SDK consumer sees declared field shapes: - `get_issue_details` → `IssueDetailsResponse | None` - `get_event_details` → `EventDetailsResponse | None` - `get_issue_and_event_details_v2` → `IssueAndEventDetailsResponse | None` (single model whose issue-side fields use `exclude_unset` so they're absent from the wire when `include_issue=False` or the group lookup failed — matches the pre-typed conditional spread) - `get_transactions_for_project` → `TransactionsForProjectResponse` - `get_trace_for_transaction` → `TraceData | EmptyResponse` - `get_profiles_for_trace` → `TraceProfiles | EmptyResponse` - `get_issues_for_transaction` → `TransactionIssues | EmptyResponse` (the four project-scoped RPC wrappers above already built the response by calling `.dict()` on existing Pydantic models — just return them directly and use `EmptyResponse` for the `{}` not-found case) - `bulk_get_project_preferences` → `BulkProjectPreferencesResponse` (bare `{project_id_str: pref_dict}` map via `__root__` passthrough) - `record_pr_attribution` → `PrAttributionResponse` - `update_pr_metrics` → `UpdatePrMetricsSuccessResponse | UpdatePrMetricsErrorResponse` (discriminated by `success: Literal[True|False]`) Wire-identical across all paths. The response models that need to be read like dicts by seer or existing test sites carry a small `_DictProxyMixin` (`__getitem__`/`__contains__`/`.get`) so the typed return doesn't force a rewrite of every consumer in the same change.
1 parent 7a23d35 commit ded27f0

8 files changed

Lines changed: 283 additions & 137 deletions

File tree

src/sentry/pr_metrics/judge.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@
3939
from sentry.pr_metrics.utils import iso_or_none, resolved_group_ids
4040
from sentry.seer.code_review.models import SeerCodeReviewRepoDefinition
4141
from sentry.seer.code_review.utils import build_repo_definition
42+
from sentry.seer.sentry_data_models import (
43+
UpdatePrMetricsErrorResponse,
44+
UpdatePrMetricsSuccessResponse,
45+
)
4246
from sentry.seer.signed_seer_api import SeerViewerContext, make_signed_seer_api_request
4347
from sentry.utils import metrics
4448

@@ -245,7 +249,7 @@ def update_pr_metrics(
245249
repository_id: int,
246250
verdict: str | None = None,
247251
attributions: Sequence[Mapping[str, Any]] | None = None,
248-
) -> dict[str, Any]:
252+
) -> UpdatePrMetricsSuccessResponse | UpdatePrMetricsErrorResponse:
249253
"""Persist Seer's judge result for a PR and emit the enriched metrics row.
250254
251255
Inbound Seer RPC (Seer → Sentry), invoked once Seer has judged a forwarded
@@ -278,14 +282,14 @@ def update_pr_metrics(
278282
if verdict is None or verdict not in RESULT_VERDICTS:
279283
logger.warning("pr_metrics.update.invalid_verdict", extra={**log_extra, "verdict": verdict})
280284
metrics.incr("pr_metrics.update.skipped", tags={"reason": "invalid_verdict"})
281-
return {"success": False, "error": "invalid_verdict"}
285+
return UpdatePrMetricsErrorResponse(error="invalid_verdict")
282286

283287
try:
284288
parsed_attributions = _parse_attributions(attributions or ())
285289
except (KeyError, TypeError, ValueError):
286290
logger.warning("pr_metrics.update.invalid_attribution", extra=log_extra)
287291
metrics.incr("pr_metrics.update.skipped", tags={"reason": "invalid_attribution"})
288-
return {"success": False, "error": "invalid_attribution"}
292+
return UpdatePrMetricsErrorResponse(error="invalid_attribution")
289293

290294
# Scope the lookup to the reported org+repo: the id alone is attacker-influenced
291295
# (it round-trips through Seer), so trusting it unscoped would be an IDOR.
@@ -298,15 +302,15 @@ def update_pr_metrics(
298302
except PullRequest.DoesNotExist:
299303
logger.warning("pr_metrics.update.pull_request_not_found", extra=log_extra)
300304
metrics.incr("pr_metrics.update.skipped", tags={"reason": "pr_not_found"})
301-
return {"success": False, "error": "pull_request_not_found"}
305+
return UpdatePrMetricsErrorResponse(error="pull_request_not_found")
302306

303307
# Emit needs a terminal PR (closed_at + head_commit_sha). Validate it before
304308
# writing so a non-terminal PR is rejected up front rather than committing the
305309
# verdict and then failing in emit — i.e. no committed-but-errored state.
306310
if pull_request.closed_at is None or pull_request.head_commit_sha is None:
307311
logger.warning("pr_metrics.update.not_terminal", extra=log_extra)
308312
metrics.incr("pr_metrics.update.skipped", tags={"reason": "not_terminal"})
309-
return {"success": False, "error": "pull_request_not_terminal"}
313+
return UpdatePrMetricsErrorResponse(error="pull_request_not_terminal")
310314

311315
# Only the verdict is written here; the webhook keeps the activity counters
312316
# current, so this partial update must not clobber them.
@@ -328,7 +332,7 @@ def update_pr_metrics(
328332
"pr_metrics.update.already_settled", extra={**log_extra, "verdict": verdict}
329333
)
330334
metrics.incr("pr_metrics.update.skipped", tags={"reason": "already_settled"})
331-
return {"success": True}
335+
return UpdatePrMetricsSuccessResponse()
332336
for signal_type, source, signal_details in parsed_attributions:
333337
record_attribution_signal(
334338
pull_request=pull_request,
@@ -341,4 +345,4 @@ def update_pr_metrics(
341345

342346
metrics.incr("pr_metrics.update.recorded", tags={"verdict": verdict})
343347
logger.info("pr_metrics.update.recorded", extra={**log_extra, "verdict": verdict})
344-
return {"success": True}
348+
return UpdatePrMetricsSuccessResponse()

src/sentry/seer/agent/index_data.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,15 @@
2020
normalize_description,
2121
)
2222
from sentry.seer.sentry_data_models import (
23+
EmptyResponse,
2324
IssueDetails,
2425
ProfileData,
2526
Span,
2627
TraceData,
2728
TraceProfiles,
2829
Transaction,
2930
TransactionIssues,
31+
TransactionsForProjectResponse,
3032
)
3133
from sentry.services.eventstore import backend as eventstore
3234
from sentry.services.eventstore.models import Event, GroupEvent
@@ -540,22 +542,25 @@ def get_issues_for_transaction(transaction_name: str, project_id: int) -> Transa
540542
# RPC wrappers
541543

542544

543-
def rpc_get_transactions_for_project(project_id: int) -> dict[str, Any]:
545+
def rpc_get_transactions_for_project(project_id: int) -> TransactionsForProjectResponse:
544546
transactions = get_transactions_for_project(project_id)
545-
transaction_dicts = [transaction.dict() for transaction in transactions]
546-
return {"transactions": transaction_dicts}
547+
return TransactionsForProjectResponse(transactions=list(transactions))
547548

548549

549-
def rpc_get_trace_for_transaction(transaction_name: str, project_id: int) -> dict[str, Any]:
550+
def rpc_get_trace_for_transaction(
551+
transaction_name: str, project_id: int
552+
) -> TraceData | EmptyResponse:
550553
trace = get_trace_for_transaction(transaction_name, project_id)
551-
return trace.dict() if trace else {}
554+
return trace if trace is not None else EmptyResponse()
552555

553556

554-
def rpc_get_profiles_for_trace(trace_id: str, project_id: int) -> dict[str, Any]:
557+
def rpc_get_profiles_for_trace(trace_id: str, project_id: int) -> TraceProfiles | EmptyResponse:
555558
profiles = get_profiles_for_trace(trace_id, project_id)
556-
return profiles.dict() if profiles else {}
559+
return profiles if profiles is not None else EmptyResponse()
557560

558561

559-
def rpc_get_issues_for_transaction(transaction_name: str, project_id: int) -> dict[str, Any]:
562+
def rpc_get_issues_for_transaction(
563+
transaction_name: str, project_id: int
564+
) -> TransactionIssues | EmptyResponse:
560565
issues = get_issues_for_transaction(transaction_name, project_id)
561-
return issues.dict() if issues else {}
566+
return issues if issues is not None else EmptyResponse()

src/sentry/seer/agent/tools.py

Lines changed: 95 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,12 @@
6161
from sentry.seer.sentry_data_models import (
6262
EAPTrace,
6363
EmptyResponse,
64+
EventDetailsResponse,
6465
ExecuteQueryErrorResponse,
6566
ExecuteQuerySuccessResponse,
6667
GetDsnResponse,
68+
IssueAndEventDetailsResponse,
69+
IssueDetailsResponse,
6770
RepositoryDefinitionResponse,
6871
TraceItemAttributesResponse,
6972
TraceItemEventsResponse,
@@ -1245,100 +1248,100 @@ def get_issue_and_event_response(
12451248
organization: Organization,
12461249
start: datetime | None = None,
12471250
end: datetime | None = None,
1248-
) -> dict[str, Any]:
1251+
) -> IssueAndEventDetailsResponse:
12491252
serialized_event = dict(serialize(event, user=None, serializer=EventSerializer()))
12501253
serialized_event.update(_get_event_troubleshooting_context(event))
12511254

1252-
result = {
1255+
event_fields: dict[str, Any] = {
12531256
"event": serialized_event,
12541257
"event_id": event.event_id,
12551258
"event_trace_id": event.trace_id,
12561259
"project_id": event.project_id,
12571260
"project_slug": event.project.slug,
12581261
}
12591262

1260-
if group is not None:
1261-
# Get the issue metadata, tags overview, and event count timeseries.
1262-
serialized_group = dict(serialize(group, user=None, serializer=GroupSerializer()))
1263-
# Add issueTypeDescription as it provides better context for LLMs. Note the initial type should be BaseGroupSerializerResponse.
1264-
serialized_group["issueTypeDescription"] = group.issue_type.description
1263+
if group is None:
1264+
return IssueAndEventDetailsResponse(**event_fields)
12651265

1266-
logger.info(
1267-
"get_issue_and_event_details_v2: Querying for tags overview",
1266+
# Get the issue metadata, tags overview, and event count timeseries.
1267+
serialized_group = dict(serialize(group, user=None, serializer=GroupSerializer()))
1268+
# Add issueTypeDescription as it provides better context for LLMs. Note the initial type should be BaseGroupSerializerResponse.
1269+
serialized_group["issueTypeDescription"] = group.issue_type.description
1270+
1271+
logger.info(
1272+
"get_issue_and_event_details_v2: Querying for tags overview",
1273+
extra={
1274+
"organization_id": organization.id,
1275+
"issue_id": group.id,
1276+
"timedelta": (end - start) if start and end else None,
1277+
"start": start,
1278+
"end": end,
1279+
},
1280+
)
1281+
1282+
try:
1283+
tags_overview = get_all_tags_overview(group, start, end)
1284+
except Exception:
1285+
logger.exception(
1286+
"Failed to get tags overview for issue",
12681287
extra={
12691288
"organization_id": organization.id,
12701289
"issue_id": group.id,
1271-
"timedelta": (end - start) if start and end else None,
12721290
"start": start,
12731291
"end": end,
12741292
},
12751293
)
1294+
tags_overview = None
12761295

1277-
try:
1278-
tags_overview = get_all_tags_overview(group, start, end)
1279-
except Exception:
1280-
logger.exception(
1281-
"Failed to get tags overview for issue",
1282-
extra={
1283-
"organization_id": organization.id,
1284-
"issue_id": group.id,
1285-
"start": start,
1286-
"end": end,
1287-
},
1288-
)
1289-
tags_overview = None
1290-
1291-
try:
1292-
ts_result = _get_issue_event_timeseries(
1293-
group=group,
1294-
organization=organization,
1295-
start=start,
1296-
end=end,
1297-
)
1298-
except Exception:
1299-
logger.exception(
1300-
"Failed to get issue event timeseries",
1301-
extra={
1302-
"organization_id": organization.id,
1303-
"issue_id": group.id,
1304-
"start": start,
1305-
"end": end,
1306-
},
1307-
)
1308-
ts_result = None
1296+
try:
1297+
ts_result = _get_issue_event_timeseries(
1298+
group=group,
1299+
organization=organization,
1300+
start=start,
1301+
end=end,
1302+
)
1303+
except Exception:
1304+
logger.exception(
1305+
"Failed to get issue event timeseries",
1306+
extra={
1307+
"organization_id": organization.id,
1308+
"issue_id": group.id,
1309+
"start": start,
1310+
"end": end,
1311+
},
1312+
)
1313+
ts_result = None
13091314

1310-
if ts_result:
1311-
timeseries, timeseries_stats_period, timeseries_interval = ts_result
1312-
else:
1313-
timeseries, timeseries_stats_period, timeseries_interval = None, None, None
1315+
if ts_result:
1316+
timeseries, timeseries_stats_period, timeseries_interval = ts_result
1317+
else:
1318+
timeseries, timeseries_stats_period, timeseries_interval = None, None, None
13141319

1315-
# Fetch user activity (comments, status changes, etc.)
1316-
try:
1317-
activities = Activity.objects.filter(
1318-
group=group,
1319-
type__in=_SEER_EXPLORER_ACTIVITY_TYPES,
1320-
).order_by("-datetime")[:50]
1321-
serialized_activities = serialize(
1322-
list(activities), user=None, serializer=ActivitySerializer()
1323-
)
1324-
except Exception:
1325-
logger.exception(
1326-
"Failed to get user activity for issue",
1327-
extra={"organization_id": organization.id, "issue_id": group.id},
1328-
)
1329-
serialized_activities = []
1330-
1331-
result = {
1332-
**result,
1333-
"issue": serialized_group,
1334-
"event_timeseries": timeseries,
1335-
"timeseries_stats_period": timeseries_stats_period,
1336-
"timeseries_interval": timeseries_interval,
1337-
"tags_overview": tags_overview,
1338-
"user_activity": serialized_activities,
1339-
}
1320+
# Fetch user activity (comments, status changes, etc.)
1321+
try:
1322+
activities = Activity.objects.filter(
1323+
group=group,
1324+
type__in=_SEER_EXPLORER_ACTIVITY_TYPES,
1325+
).order_by("-datetime")[:50]
1326+
serialized_activities = serialize(
1327+
list(activities), user=None, serializer=ActivitySerializer()
1328+
)
1329+
except Exception:
1330+
logger.exception(
1331+
"Failed to get user activity for issue",
1332+
extra={"organization_id": organization.id, "issue_id": group.id},
1333+
)
1334+
serialized_activities = []
13401335

1341-
return result
1336+
return IssueAndEventDetailsResponse(
1337+
**event_fields,
1338+
issue=serialized_group,
1339+
event_timeseries=timeseries,
1340+
timeseries_stats_period=timeseries_stats_period,
1341+
timeseries_interval=timeseries_interval,
1342+
tags_overview=tags_overview,
1343+
user_activity=serialized_activities,
1344+
)
13421345

13431346

13441347
def get_issue_details(
@@ -1348,7 +1351,7 @@ def get_issue_details(
13481351
start: str | None = None,
13491352
end: str | None = None,
13501353
project_slug: str | None = None,
1351-
) -> dict[str, Any] | None:
1354+
) -> IssueDetailsResponse | None:
13521355
"""
13531356
Get issue-level details for an issue, optionally scoped by time range.
13541357
@@ -1433,16 +1436,16 @@ def get_issue_details(
14331436
)
14341437
serialized_activities = []
14351438

1436-
return {
1437-
"issue": serialized_group,
1438-
"event_timeseries": timeseries,
1439-
"timeseries_stats_period": timeseries_stats_period,
1440-
"timeseries_interval": timeseries_interval,
1441-
"tags_overview": tags_overview,
1442-
"user_activity": serialized_activities,
1443-
"project_id": group.project_id,
1444-
"project_slug": group.project.slug,
1445-
}
1439+
return IssueDetailsResponse(
1440+
issue=serialized_group,
1441+
event_timeseries=timeseries,
1442+
timeseries_stats_period=timeseries_stats_period,
1443+
timeseries_interval=timeseries_interval,
1444+
tags_overview=tags_overview,
1445+
user_activity=serialized_activities,
1446+
project_id=group.project_id,
1447+
project_slug=group.project.slug,
1448+
)
14461449

14471450

14481451
def get_event_details(
@@ -1453,7 +1456,7 @@ def get_event_details(
14531456
start: str | None = None,
14541457
end: str | None = None,
14551458
project_slug: str | None = None,
1456-
) -> dict[str, Any] | None:
1459+
) -> EventDetailsResponse | None:
14571460
"""
14581461
Get event details by event ID, or get the recommended event for an issue, optionally scoped by time range.
14591462
Exactly one of event_id or issue_id must be provided.
@@ -1554,13 +1557,13 @@ def get_event_details(
15541557
serialized_event = dict(serialize(event, user=None, serializer=EventSerializer()))
15551558
serialized_event.update(_get_event_troubleshooting_context(event))
15561559

1557-
return {
1558-
"event": serialized_event,
1559-
"event_id": event.event_id,
1560-
"event_trace_id": event.trace_id,
1561-
"project_id": event.project_id,
1562-
"project_slug": event.project.slug,
1563-
}
1560+
return EventDetailsResponse(
1561+
event=serialized_event,
1562+
event_id=event.event_id,
1563+
event_trace_id=event.trace_id,
1564+
project_id=event.project_id,
1565+
project_slug=event.project.slug,
1566+
)
15641567

15651568

15661569
def get_issue_and_event_details_v2(
@@ -1572,7 +1575,7 @@ def get_issue_and_event_details_v2(
15721575
event_id: str | None = None,
15731576
project_slug: str | None = None,
15741577
include_issue: bool = True,
1575-
) -> dict[str, Any] | None:
1578+
) -> IssueAndEventDetailsResponse | None:
15761579
if bool(issue_id) == bool(event_id):
15771580
raise BadRequest("Either issue_id or event_id must be provided, but not both.")
15781581

0 commit comments

Comments
 (0)