Skip to content

Commit cc92108

Browse files
committed
feat(investigations): Improve breached metric analysis
Rewrite the breached metric template into evidence-backed sections and expose resolved source context to Code Mode. Persist fenced completion metadata and expose its generation status to list consumers.
1 parent 50277e9 commit cc92108

8 files changed

Lines changed: 316 additions & 43 deletions

File tree

src/sentry/investigations/agent.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ class TitleGenerationStatus(StrEnum):
8686
import type helpers. Time-axis
8787
x values must be offset-bearing ISO 8601 timestamps. If the question cannot be answered with telemetry, ask the user an
8888
inline clarification. Finish by returning exactly one raw JSON object in your final response.
89+
The source object in investigation_context is authoritative resolved source context, not a
90+
template parameter. Use source.snapshot for supplied monitor, project, threshold, condition,
91+
dataset, and analysis-window facts; do not report them missing merely because parameters is empty.
8992
When notebookContext contains an item with currentBlock=true, it is the last successful result for
9093
the block being refined. Reuse its table and chart data for presentation-only requests such as
9194
changing line, area, or bar visualization; do not claim the data is unavailable or query it again.
@@ -119,6 +122,8 @@ def build_agent_prompt(execution: InvestigationBlockExecution) -> str:
119122
)
120123
context = {
121124
"request": snapshot.get("prompt"),
125+
"organizationSlug": snapshot.get("organizationSlug"),
126+
"source": snapshot.get("source", {}),
122127
"projectSlugs": snapshot.get("projectSlugs", []),
123128
"filters": snapshot.get("filters", {}),
124129
"parameters": snapshot.get("parameters", {}),
@@ -889,6 +894,7 @@ def _completion_block_context(block: InvestigationBlock) -> str:
889894

890895

891896
def _parse_completion_metadata(content: str) -> dict[str, str] | None:
897+
content = _strip_json_code_fence(content)
892898
try:
893899
payload = json.loads(content)
894900
except ValueError:
@@ -924,6 +930,18 @@ def _parse_completion_metadata(content: str) -> dict[str, str] | None:
924930
}
925931

926932

933+
def _strip_json_code_fence(content: str) -> str:
934+
stripped = content.strip()
935+
lines = stripped.splitlines()
936+
if (
937+
len(lines) >= 3
938+
and lines[0].strip().lower() in {"```", "```json"}
939+
and lines[-1].strip() == "```"
940+
):
941+
return "\n".join(lines[1:-1]).strip()
942+
return stripped
943+
944+
927945
def title_generation_preview(content: str | None) -> str | None:
928946
if not content:
929947
return None

src/sentry/investigations/endpoints/serializers/investigation.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ class InvestigationSerializerResponse(TypedDict):
5959
version: int
6060
blockCount: int
6161
isFavorited: bool
62+
titleGeneration: InvestigationTitleGenerationSerializerResponse
6263

6364

6465
class InvestigationDetailsSerializerResponse(InvestigationSerializerResponse):
@@ -68,7 +69,6 @@ class InvestigationDetailsSerializerResponse(InvestigationSerializerResponse):
6869
projectIds: list[int]
6970
parameters: list[InvestigationParameterSerializerResponse]
7071
blocks: list[InvestigationBlockSerializerResponse]
71-
titleGeneration: InvestigationTitleGenerationSerializerResponse
7272

7373

7474
@register(Investigation)
@@ -124,6 +124,7 @@ def serialize(
124124
"version": obj.version,
125125
"blockCount": attrs["block_count"],
126126
"isFavorited": attrs["is_favorited"],
127+
"titleGeneration": {"status": obj.title_generation_status},
127128
}
128129

129130

src/sentry/investigations/services/executions.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,8 @@ def build_block_execution_snapshot(
321321
)
322322
snapshot: dict[str, Any] = {
323323
"prompt": prompt,
324+
"organizationSlug": block.investigation.organization.slug,
325+
"source": block.investigation.source,
324326
"filters": block.investigation.filters,
325327
"parameters": parameters,
326328
"dependencies": dependencies,
Lines changed: 138 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,177 @@
11
from sentry.investigations.models import InvestigationBlockKind, InvestigationSourceType
22
from sentry.investigations.templates.types import InvestigationTemplateSpec, TemplateBlockSpec
33

4+
SHORT_LINKED_SUMMARY_INSTRUCTIONS = (
5+
"Use at most three short sentences or three compact bullets total. Preserve useful Markdown "
6+
"links from the evidence. Link concrete Sentry issues, releases, transactions, or other pages "
7+
"when the evidence contains a valid URL or identifier; never invent a URL or identifier."
8+
)
9+
LINKED_EVIDENCE_INSTRUCTIONS = (
10+
"Include concise Markdown links to relevant Sentry issues, releases, transactions, or other "
11+
"pages when telemetry returns a concrete URL or identifier. Use organizationSlug from the "
12+
"investigation context for relative Sentry paths when needed; never guess a URL or identifier."
13+
)
14+
415
BREACHED_METRIC_TEMPLATE = InvestigationTemplateSpec(
516
key="breached_metric",
617
version=1,
718
source_type=InvestigationSourceType.METRIC_OPEN_PERIOD,
819
parameters=(),
920
blocks=(
1021
TemplateBlockSpec(
11-
key="metric_chart",
22+
key="monitor_summary",
23+
kind=InvestigationBlockKind.TEXT,
24+
title="Monitor context",
25+
generation_prompt=(
26+
"Summarize only the Monitor details evidence for a human responder. State the "
27+
"monitor name and query, affected project and environment when known, threshold "
28+
"and breach direction, and supplied open-period time range. Do not speculate about "
29+
"the cause. " + SHORT_LINKED_SUMMARY_INSTRUCTIONS
30+
),
31+
config={"autoRun": True},
32+
display={"type": "markdown"},
33+
dependencies=("monitor_evidence",),
34+
),
35+
TemplateBlockSpec(
36+
key="monitor_evidence",
1237
kind=InvestigationBlockKind.QUERY,
13-
title="Breached metric",
38+
title="Monitor details",
39+
generation_prompt=(
40+
"Use the supplied metric issue, monitor definition, project, open-period window, "
41+
"threshold, and direction. Query the exact supplied monitor definition over the "
42+
"open period and an equal pre-breach baseline. Return a compact table of the "
43+
"monitor facts and the most useful baseline and open-period values. Preserve the "
44+
"exact query and time range; do not infer missing monitor configuration. "
45+
+ LINKED_EVIDENCE_INSTRUCTIONS
46+
),
47+
config={"autoRun": True},
48+
display={"version": 1, "type": "table", "defaultView": "table"},
49+
),
50+
TemplateBlockSpec(
51+
key="spike_summary",
52+
kind=InvestigationBlockKind.TEXT,
53+
title="What happened",
1454
generation_prompt=(
15-
"Query the exact supplied monitor definition over the supplied analysis window. "
16-
"Make the breach immediately visible in a time-series chart spanning the equal "
17-
"pre-breach baseline and open-period portions. Plot the observed metric and the "
18-
"supplied threshold or comparison as separate series so the crossing is clear. "
19-
"Use the monitor time window for the chart interval when supported."
55+
"Summarize only the Metric spike evidence. Describe when the ramp-up began, the "
56+
"peak value and time, how long the breach lasted, and whether the signal recovered "
57+
"or remained elevated. State uncertainty when sparse telemetry does not establish "
58+
"one of those facts. " + SHORT_LINKED_SUMMARY_INSTRUCTIONS
59+
),
60+
config={"autoRun": True},
61+
display={"type": "markdown"},
62+
dependencies=("spike_evidence",),
63+
),
64+
TemplateBlockSpec(
65+
key="spike_evidence",
66+
kind=InvestigationBlockKind.QUERY,
67+
title="Metric spike",
68+
generation_prompt=(
69+
"Query the exact supplied monitor definition over the supplied open period and an "
70+
"equal pre-breach baseline. Make the ramp-up, peak, and ramp-down or recovery "
71+
"visible in a time-series chart. Plot the observed metric and supplied threshold "
72+
"or comparison as separate series so the crossing is clear. Use the monitor time "
73+
"window for the chart interval when supported, and do not invent missing points. "
74+
+ LINKED_EVIDENCE_INSTRUCTIONS
2075
),
2176
config={"autoRun": True, "preferChart": True},
2277
display={"version": 1, "type": "table", "defaultView": "chart"},
78+
dependencies=("monitor_evidence",),
2379
),
2480
TemplateBlockSpec(
25-
key="overview",
81+
key="issues_summary",
2682
kind=InvestigationBlockKind.TEXT,
27-
title="Overview",
83+
title="What caused the spike",
2884
generation_prompt=(
29-
"Give the reader a useful overview of this breached metric using the supplied "
30-
"monitor, open-period, project, threshold, direction, and analysis-window facts. "
31-
"Accurately describe whether this is an upward or downward breach. Do not claim a "
32-
"cause before examining the telemetry. Keep the overview to two short paragraphs."
85+
"Summarize only the Contributing issues evidence. Identify the issue groups that "
86+
"best account for the spike, including their counts, change from baseline, and "
87+
"relative contribution when available. If no issue groups convincingly explain "
88+
"the metric change, say that clearly. " + SHORT_LINKED_SUMMARY_INSTRUCTIONS
3389
),
3490
config={"autoRun": True},
3591
display={"type": "markdown"},
92+
dependencies=("issues_evidence",),
3693
),
3794
TemplateBlockSpec(
38-
key="synthesis",
95+
key="issues_evidence",
96+
kind=InvestigationBlockKind.QUERY,
97+
title="Contributing issues",
98+
generation_prompt=(
99+
"Using the Metric spike evidence to preserve the spike window, compare issue-group "
100+
"telemetry during the open period with the equal pre-breach baseline. Rank issue "
101+
"groups by the strongest supported contribution using absolute counts, change "
102+
"from baseline, and share of the spike. Prefer a bar chart with a compact table "
103+
"when useful, and include issue links only when the telemetry provides valid ones. "
104+
"Treat no supporting issue groups as a valid result; never fabricate attribution. "
105+
+ LINKED_EVIDENCE_INSTRUCTIONS
106+
),
107+
config={"autoRun": True, "preferChart": True},
108+
display={"version": 1, "type": "table", "defaultView": "chart"},
109+
dependencies=("spike_evidence",),
110+
),
111+
TemplateBlockSpec(
112+
key="tags_summary",
39113
kind=InvestigationBlockKind.TEXT,
40-
title="What explains the change",
114+
title="Why those issues spiked",
41115
generation_prompt=(
42-
"Explain what the breached-metric result above and contributor result below show "
43-
"together. Focus on evidence, distinguish correlation from causation, and state "
44-
"uncertainty when the telemetry does not establish a convincing explanation. Keep "
45-
"the answer to two or three short paragraphs unless a tiny table is essential."
116+
"Summarize only the Issue tag changes evidence. Explain which dimensions changed "
117+
"most during the spike and what explanation, if any, those distribution changes "
118+
"support. Distinguish evidence from hypotheses and say when the slices do not "
119+
"establish a convincing explanation. " + SHORT_LINKED_SUMMARY_INSTRUCTIONS
46120
),
47121
config={"autoRun": True},
48122
display={"type": "markdown"},
49-
dependencies=("metric_chart", "contributors"),
123+
dependencies=("tags_evidence",),
50124
),
51125
TemplateBlockSpec(
52-
key="contributors",
126+
key="tags_evidence",
53127
kind=InvestigationBlockKind.QUERY,
54-
title="Likely contributors",
128+
title="Issue tag changes",
55129
generation_prompt=(
56-
"Compare telemetry during the supplied open-period window with its equal baseline. "
57-
"Use as many supported telemetry calls and local transformations as useful. Let "
58-
"the evidence determine whether issue groups, tags, or other metadata best explain "
59-
"the change, then chart the strongest available evidence. If no convincing "
60-
"contributor exists, show the most useful evidence and say so in the result."
130+
"Slice the contributing issue groups by supported dimensions that can explain the "
131+
"change, such as release, transaction, environment, region, browser, device, or "
132+
"other useful tags. Compare each distribution during the open period with the "
133+
"equal pre-breach baseline; do not rank raw spike volume without that comparison. "
134+
"Chart the strongest supported distribution changes and retain a compact table "
135+
"when useful. Do not invent unavailable tags or claim causation from correlation. "
136+
+ LINKED_EVIDENCE_INSTRUCTIONS
61137
),
62138
config={"autoRun": True, "preferChart": True},
63139
display={"version": 1, "type": "table", "defaultView": "chart"},
140+
dependencies=("issues_evidence",),
141+
),
142+
TemplateBlockSpec(
143+
key="trigger_summary",
144+
kind=InvestigationBlockKind.TEXT,
145+
title="Likely trigger and next steps",
146+
generation_prompt=(
147+
"Summarize only the Release and infrastructure signals evidence. Classify the "
148+
"result as code-related, infrastructure-related, mixed, or insufficient evidence. "
149+
"Distinguish correlation from causation, name the strongest supported finding, and "
150+
"give concrete next steps a human can take. Present external observability checks "
151+
"as suggestions rather than findings. " + SHORT_LINKED_SUMMARY_INSTRUCTIONS
152+
),
153+
config={"autoRun": True},
154+
display={"type": "markdown"},
155+
dependencies=("trigger_evidence",),
156+
),
157+
TemplateBlockSpec(
158+
key="trigger_evidence",
159+
kind=InvestigationBlockKind.QUERY,
160+
title="Release and infrastructure signals",
161+
generation_prompt=(
162+
"Use the supplied spike window together with the contributing issue and tag "
163+
"evidence. Check supported Sentry telemetry for releases, deployments, affected "
164+
"services or transactions, and infrastructure-like patterns correlated with the "
165+
"change. Return a compact evidence table containing only observed signals and "
166+
"their timing. If Sentry cannot inspect relevant infrastructure directly, include "
167+
"specific suggested external checks such as affected pods or services in Datadog, "
168+
"clearly labeled as follow-up checks rather than findings. Never fabricate release, "
169+
"deployment, infrastructure, or external-observability data. "
170+
+ LINKED_EVIDENCE_INSTRUCTIONS
171+
),
172+
config={"autoRun": True},
173+
display={"version": 1, "type": "table", "defaultView": "table"},
174+
dependencies=("spike_evidence", "issues_evidence", "tags_evidence"),
64175
),
65176
),
66177
)

tests/sentry/investigations/endpoints/serializers/test_investigation.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ def test_serializes_the_list_representation(self) -> None:
3838
"version": 1,
3939
"blockCount": 0,
4040
"isFavorited": False,
41+
"titleGeneration": {"status": None},
4142
}
4243

4344
def test_counts_only_active_blocks(self) -> None:

tests/sentry/investigations/services/test_executions.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,33 @@ def test_canonicalizes_project_scope(self) -> None:
202202
assert execution.input_snapshot["projectIds"] == [project.id for project in expected]
203203
assert execution.input_snapshot["projectSlugs"] == [project.slug for project in expected]
204204

205+
def test_snapshots_the_resolved_investigation_source(self) -> None:
206+
source = {
207+
"type": "metric_open_period",
208+
"ref": {"groupId": "30", "openPeriodId": "85"},
209+
"snapshot": {
210+
"analysisWindow": {
211+
"baselineStart": "2026-08-11T01:21:15+00:00",
212+
"breachStart": "2026-08-14T23:56:02+00:00",
213+
"end": "2026-08-18T22:30:49+00:00",
214+
},
215+
"monitor": {
216+
"name": "Mobile API error volume",
217+
"query": "fixture_metric:mobile-api-errors",
218+
"aggregate": "count()",
219+
"direction": "above",
220+
},
221+
},
222+
}
223+
self.investigation.update(source=source)
224+
block = self.create_block()
225+
226+
execution, created = self.run_block(block)
227+
228+
assert created
229+
assert execution.input_snapshot["organizationSlug"] == self.organization.slug
230+
assert execution.input_snapshot["source"] == source
231+
205232
def test_revalidates_project_parameter_access(self) -> None:
206233
cases: list[tuple[InvestigationParameterType, Callable[[int], Any]]] = [
207234
(InvestigationParameterType.PROJECT, lambda project_id: project_id),

0 commit comments

Comments
 (0)