Skip to content

Commit c49cc32

Browse files
giovanni-guidiniclaudegetsantry[bot]
authored
ref(pr-metrics): Type signal_details for delegated-agent attributions; add run_id (#117519)
## What this does Introduces `DelegatedAgentSignalDetails`, a Pydantic model that replaces the ad-hoc `dict` used for `signal_details` on `SEER_DELEGATED_*` attribution signals: ```python class DelegatedAgentSignalDetails(BaseModel): agent_id: str | None = None pr_url: str run_id: int | None = None ``` `run_id` is threaded through both paths that write these signals: - **Polling path** (`poll_github_copilot_agents`, `poll_claude_code_agents` → `poll_claude_agent`): reads `autofix_state.run_id` and passes it to `attribute_delegated_agent_pull_request`. - **Seer RPC callback** (`record_pr_attribution`): validates the incoming `signal_details` dict through `DelegatedAgentSignalDetails` when the signal type is any `SEER_DELEGATED_*` value. Missing `pr_url` raises a `ParseError`; nullable fields default to `None`. The Cursor webhook has no autofix state at webhook time, so `run_id` remains `None` there. ## Why `run_id` is the natural join key back to Seer's run state and to `SeerRun` in Sentry's DB — having it in `signal_details` makes post-hoc attribution debugging much easier. The typed model enforces the contract explicitly so future callers can't silently omit fields. Refs CW-1493 --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
1 parent 4dc7114 commit c49cc32

7 files changed

Lines changed: 108 additions & 9 deletions

File tree

src/sentry/pr_metrics/attribution.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from typing import Any
1616

1717
from django.db.models import Q
18+
from pydantic import BaseModel
1819

1920
from sentry import features
2021
from sentry.constants import ObjectStatus
@@ -60,6 +61,25 @@
6061
}
6162

6263

64+
class DelegatedAgentSignalDetails(BaseModel):
65+
"""Typed signal_details for SEER_DELEGATED_* attribution signals."""
66+
67+
agent_id: str | None = None
68+
pr_url: str
69+
run_id: int | None = None
70+
71+
72+
# Signal types that use DelegatedAgentSignalDetails for their signal_details.
73+
DELEGATED_SIGNAL_TYPES = frozenset(
74+
{
75+
PullRequestAttributionSignalType.SEER_DELEGATED_CURSOR,
76+
PullRequestAttributionSignalType.SEER_DELEGATED_GITHUB_COPILOT,
77+
PullRequestAttributionSignalType.SEER_DELEGATED_CLAUDE_CODE,
78+
PullRequestAttributionSignalType.SEER_DELEGATED_UNKNOWN,
79+
}
80+
)
81+
82+
6383
def record_attribution_signal(
6484
*,
6585
pull_request: PullRequest,
@@ -260,6 +280,7 @@ def attribute_delegated_agent_pull_request(
260280
repo_provider: str,
261281
pr_url: str,
262282
agent_id: str | None = None,
283+
run_id: int | None = None,
263284
) -> None:
264285
"""Attribute a PR opened by a Seer-delegated coding agent (Cursor/Copilot/Claude).
265286
@@ -303,7 +324,11 @@ def attribute_delegated_agent_pull_request(
303324
pr_number=pr_number,
304325
signal_type=signal_type,
305326
source=PullRequestAttributionSource.SEER_DATA,
306-
signal_details={"agent_id": agent_id, "pr_url": pr_url},
327+
signal_details=DelegatedAgentSignalDetails(
328+
agent_id=agent_id,
329+
pr_url=pr_url,
330+
run_id=run_id,
331+
).dict(),
307332
log_context=log_context,
308333
)
309334

src/sentry/seer/autofix/coding_agent.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ def poll_github_copilot_agents(
148148
user_id: int = 0,
149149
coding_agents: dict[str, Any] | None = None,
150150
organization_id: int = 0,
151+
run_id: int | None = None,
151152
) -> None:
152153
agents = coding_agents or (autofix_state.coding_agents if autofix_state else None)
153154
if not agents:
@@ -158,6 +159,7 @@ def poll_github_copilot_agents(
158159
organization_id = organization_id or (
159160
autofix_state.request.organization_id if autofix_state else 0
160161
)
162+
run_id = run_id if run_id is not None else (autofix_state.run_id if autofix_state else None)
161163

162164
user_access_token: str | None = None
163165

@@ -242,6 +244,7 @@ def poll_github_copilot_agents(
242244
repo_provider="github",
243245
pr_url=pr_url,
244246
agent_id=agent_id,
247+
run_id=run_id,
245248
)
246249
except Exception:
247250
logger.exception(
@@ -280,6 +283,7 @@ def poll_claude_code_agents(
280283
autofix_state: AutofixState | None = None,
281284
organization_id: int | None = None,
282285
coding_agents: dict[str, Any] | None = None,
286+
run_id: int | None = None,
283287
) -> None:
284288
"""
285289
Poll Claude Code Agent sessions for status updates.
@@ -307,11 +311,15 @@ def poll_claude_code_agents(
307311
logger.warning("coding_agent.claude_code.no_client_class_configured")
308312
return
309313

314+
run_id = run_id if run_id is not None else (autofix_state.run_id if autofix_state else None)
315+
310316
for agent_id, agent_state in agents.items():
311-
poll_claude_agent(clients, agent_id, org_id, agent_state)
317+
poll_claude_agent(clients, agent_id, org_id, agent_state, run_id=run_id)
312318

313319

314-
def poll_claude_agent(clients, agent_id, org_id, agent_state: CodingAgentState) -> None:
320+
def poll_claude_agent(
321+
clients, agent_id, org_id, agent_state: CodingAgentState, run_id: int | None = None
322+
) -> None:
315323
if agent_state.provider != CodingAgentProviderType.CLAUDE_CODE_AGENT:
316324
return
317325

@@ -357,6 +365,7 @@ def poll_claude_agent(clients, agent_id, org_id, agent_state: CodingAgentState)
357365
repo_provider=result.repo_provider,
358366
pr_url=result.pr_url,
359367
agent_id=agent_id,
368+
run_id=run_id,
360369
)
361370
except Exception:
362371
logger.exception(

src/sentry/seer/endpoints/group_ai_autofix.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,11 +383,13 @@ def get(self, request: Request, group: Group) -> Response[AutofixStateResponse]:
383383
coding_agents=state.coding_agents,
384384
user_id=request.user.id,
385385
organization_id=group.organization.id,
386+
run_id=state.run_id,
386387
)
387388
if CodingAgentProviderType.CLAUDE_CODE_AGENT in agent_providers:
388389
poll_claude_code_agents(
389390
coding_agents=state.coding_agents,
390391
organization_id=group.organization.id,
392+
run_id=state.run_id,
391393
)
392394

393395
run = get_seer_run(state.run_id, group.organization)

src/sentry/seer/endpoints/seer_rpc.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,11 @@
5858
PullRequestAttributionSource,
5959
)
6060
from sentry.models.repository import Repository
61-
from sentry.pr_metrics.attribution import record_attribution_signal
61+
from sentry.pr_metrics.attribution import (
62+
DELEGATED_SIGNAL_TYPES,
63+
DelegatedAgentSignalDetails,
64+
record_attribution_signal,
65+
)
6266
from sentry.pr_metrics.judge import update_pr_metrics
6367
from sentry.replays.usecases.summarize import rpc_get_replay_summary_logs
6468
from sentry.search.eap.resolver import SearchResolver
@@ -954,6 +958,14 @@ def record_pr_attribution(
954958
f"PullRequest {pull_request_id} not found in org {organization_id}"
955959
)
956960

961+
if signal in DELEGATED_SIGNAL_TYPES:
962+
try:
963+
signal_details = DelegatedAgentSignalDetails.parse_obj(signal_details or {}).dict()
964+
except Exception:
965+
raise ParseError(
966+
detail="signal_details does not match DelegatedAgentSignalDetails schema"
967+
)
968+
957969
attribution = record_attribution_signal(
958970
pull_request=pull_request,
959971
signal_type=signal,

tests/sentry/pr_metrics/test_attribution.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ def _attribute(
199199
repo_provider: str = "github",
200200
pr_url: str = "https://github.com/getsentry/sentry/pull/42",
201201
agent_id: str | None = "agent-1",
202+
run_id: int | None = None,
202203
organization_id: int | None = None,
203204
has_feature: bool = True,
204205
) -> None:
@@ -212,6 +213,7 @@ def _attribute(
212213
repo_provider=repo_provider,
213214
pr_url=pr_url,
214215
agent_id=agent_id,
216+
run_id=run_id,
215217
)
216218

217219
def test_records_the_given_signal_type(self) -> None:
@@ -234,8 +236,23 @@ def test_records_the_given_signal_type(self) -> None:
234236
assert attribution.signal_details == {
235237
"agent_id": "agent-1",
236238
"pr_url": f"https://github.com/getsentry/sentry/pull/{pr_number}",
239+
"run_id": None,
237240
}
238241

242+
def test_includes_run_id_in_signal_details(self) -> None:
243+
self._attribute(
244+
pr_url="https://github.com/getsentry/sentry/pull/77",
245+
run_id=9999,
246+
)
247+
248+
pull_request = PullRequest.objects.get(repository_id=self.repo.id, key="77")
249+
attribution = PullRequestAttribution.objects.get(pull_request=pull_request)
250+
assert attribution.signal_details == {
251+
"agent_id": "agent-1",
252+
"pr_url": "https://github.com/getsentry/sentry/pull/77",
253+
"run_id": 9999,
254+
}
255+
239256
def test_noop_when_feature_disabled(self) -> None:
240257
self._attribute(has_feature=False)
241258

tests/sentry/seer/autofix/test_coding_agent.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ def test_poll_attributes_pr_when_task_complete(
232232
repo_provider="github",
233233
pr_url="https://github.com/getsentry/sentry/pull/12345",
234234
agent_id="getsentry:sentry:task-123",
235+
run_id=self.run_id,
235236
)
236237

237238
@patch("sentry.seer.autofix.coding_agent.attribute_delegated_agent_pull_request")
@@ -691,6 +692,7 @@ def test_attributes_pr_on_completion(
691692
repo_provider="github",
692693
pr_url="https://github.com/getsentry/sentry/pull/999",
693694
agent_id="claude-session-123",
695+
run_id=self.run_id,
694696
)
695697

696698
@patch("sentry.seer.autofix.coding_agent.attribute_delegated_agent_pull_request")

tests/sentry/seer/endpoints/test_seer_rpc.py

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1693,11 +1693,14 @@ def setUp(self) -> None:
16931693
key="10",
16941694
)
16951695

1696+
_DEFAULT_PR_URL = "https://github.com/getsentry/sentry/pull/99"
1697+
16961698
def _call(self, **overrides: Any) -> dict[str, Any]:
16971699
kwargs: dict[str, Any] = {
16981700
"organization_id": self.organization.id,
16991701
"pull_request_id": self.pr.id,
17001702
"signal_type": PullRequestAttributionSignalType.SEER_DELEGATED_CLAUDE_CODE,
1703+
"signal_details": {"pr_url": self._DEFAULT_PR_URL},
17011704
}
17021705
kwargs.update(overrides)
17031706
return record_pr_attribution(**kwargs)
@@ -1710,14 +1713,43 @@ def test_creates_attribution(self) -> None:
17101713
assert attr.is_valid is True
17111714
assert result == {"attribution_id": attr.id}
17121715

1713-
def test_stores_signal_details(self) -> None:
1714-
self._call(signal_details={"agent_id": "agent-abc-123"})
1716+
def test_stores_typed_signal_details_for_delegated_signals(self) -> None:
1717+
self._call(
1718+
signal_details={
1719+
"agent_id": "agent-abc-123",
1720+
"pr_url": self._DEFAULT_PR_URL,
1721+
"run_id": 42,
1722+
}
1723+
)
17151724

17161725
attr = PullRequestAttribution.objects.get(pull_request=self.pr)
1717-
assert attr.signal_details == {"agent_id": "agent-abc-123"}
1726+
assert attr.signal_details == {
1727+
"agent_id": "agent-abc-123",
1728+
"pr_url": self._DEFAULT_PR_URL,
1729+
"run_id": 42,
1730+
}
17181731

1719-
def test_no_signal_details_leaves_signal_details_null(self) -> None:
1720-
self._call()
1732+
def test_delegated_signal_details_defaults_nullable_fields(self) -> None:
1733+
self._call(signal_details={"pr_url": self._DEFAULT_PR_URL})
1734+
1735+
attr = PullRequestAttribution.objects.get(pull_request=self.pr)
1736+
assert attr.signal_details == {
1737+
"agent_id": None,
1738+
"pr_url": self._DEFAULT_PR_URL,
1739+
"run_id": None,
1740+
}
1741+
1742+
def test_invalid_delegated_signal_details_raises(self) -> None:
1743+
from rest_framework.exceptions import ParseError
1744+
1745+
with pytest.raises(ParseError):
1746+
self._call(signal_details={"agent_id": "x"}) # missing required pr_url
1747+
1748+
def test_no_signal_details_for_non_delegated_type_leaves_null(self) -> None:
1749+
self._call(
1750+
signal_type=PullRequestAttributionSignalType.SENTRY_APP,
1751+
signal_details=None,
1752+
)
17211753

17221754
attr = PullRequestAttribution.objects.get(pull_request=self.pr)
17231755
assert attr.signal_details is None

0 commit comments

Comments
 (0)