Skip to content

fix(investigations): Preserve query context through refinement - #124607

Draft
arslnb wants to merge 20 commits into
masterfrom
sentry/investigation-refinement-snapshots
Draft

arslnb wants to merge 20 commits into
masterfrom
sentry/investigation-refinement-snapshots

Conversation

@arslnb

@arslnb arslnb commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Query refinements retain the original query links when reusing saved data, and carry forward the source, filters, and parameters through subsequent refinements. Relative query windows become absolute dates anchored to the producing execution; existing absolute bounds are preserved. Agent-generated report cells also save their source context so refinement can recover the investigation window when no query link exists.

The query instructions require presentation changes to reuse saved measurements and query changes to preserve the historical window unless the user explicitly requests another period. Regression coverage checks refinement ten days later and a subsequent refinement after that. Links without exact query bounds use the original execution time as their anchor.

Based on #124334. The frontend counterpart is #124516, which reveals ready cells and removes Rerun.

billyvg and others added 20 commits September 10, 2026 14:22
Renders the hypotheses an agentic investigation is weighing as a row of
cards: the statement, where it landed, the confidence behind that, and the
checks the agent ran to get there.

The data already exists. Seer pushes the whole live state of a run as one
projection blob on every orchestration event, Sentry stores it on
InvestigationOrchestrationRun.projection, and
`/investigations/$id/orchestration/` serves the latest one. So the agent
decides what these cards say purely by what it writes into
`projection.hypotheses` -- there is no separate signal telling the frontend
to render a hypothesis, and no new block kind. Decisions travel back as
versioned commands fenced on workflowVersion, so one made against a stale
view is rejected rather than applied to a run that has moved on.

The row reflows on its container rather than the viewport: auto-fit over
minmax tracks drops columns whenever the available space stops fitting
another readable card. That lets the same component sit in a full-width
detail view and in a narrow drawer without a breakpoint prop, and it is
why this does not draw the connecting edges the flow-graph mock has.

Types and API signatures match the ones in the in-flight orchestration
branch (#122949) so the two collapse into one rather than conflicting.

Stories cover the row, its reflow, every status, and the connected
component running against the existing story fixture API, which now serves
the two orchestration routes and applies commands in memory so accept,
reject, and retry stay interactive on the stories page.

Claude-Session: https://claude.ai/code/session_014zh69vex76pjNTnarqVcXL
Four fixes from design review of the hypothesis row.

The evidence list is a `ul` nested inside the row's own `ul`, so browsers
gave it `list-style-type: circle` and drew a marker beside every step,
sitting in the card's padding. Both lists now clear their markers.

The card border carries the verdict rather than only marking the report's
primary hypothesis: accent for supported or user-accepted, dotted while
inconclusive, ordinary otherwise -- refuted included, since ruling
something out is a result rather than a fault. `getBorder` only ever emits
`1px solid`, so the dotted case is CSS; the colors are still border tokens.
It is driven by a data attribute, which also makes it assertable, unlike
emotion styles, which this repo's stubbed getComputedStyle cannot see.

Stories now sit inside `Storybook.Demo`. That excludes their headings from
the page's table of contents, which was listing every hypothesis statement
and, because the same three statements repeat across stories, giving
several entries the same id and marking them all active at once. The demo
also supplies the container query context these cards size against.

The three hardcoded-width reflow stories are gone; `Demo resizable` gives a
drag handle and a live breakpoint readout instead.

Claude-Session: https://claude.ai/code/session_014zh69vex76pjNTnarqVcXL
A dotted hairline is barely visible against the default border color, so
the broken edge is dashed now.

It also applies more widely. The border had three states; it has two. A
solid accent edge marks the explanation that stands -- supported by the
evidence, or accepted by a person -- and every other card is dashed.
Investigating, refuted, inconclusive, cancelled and failed all mean the
same thing to someone scanning the row: not the answer. The status line
already carries which of them it is, so the border does not need to.

Claude-Session: https://claude.ai/code/session_014zh69vex76pjNTnarqVcXL
Knip flagged fourteen exports nothing outside their own module reads.
Thirteen were module internals -- the status vocabulary behind
`HypothesisStatus`, the polling predicate behind the orchestration query,
and the projection types composed into `InvestigationOrchestration` -- so
they lose the `export` keyword and keep working. Anything that needs one
later can export it then.

`isInvestigationOrchestrationConflictError` was speculative: added to match
the in-flight orchestration branch, called by nothing here. Deleted; it
arrives with the hook that uses it.

`knip --production` separately reports the hypothesis components as
unreachable, which is accurate -- no investigation surface renders the row
yet, only stories and tests do. That is the same situation the config
already records for `autofixChatContext` and the chat blocks, so it gets
the same treatment: one entry point with a TODO. Wiring the row into the
detail view belongs with the surface work, not here, and that file is
being rewritten on #122949.

Claude-Session: https://claude.ai/code/session_014zh69vex76pjNTnarqVcXL
Measured against the mock, every value from a token.

The evidence rows sat on a grey surface; in the spec they sit on the card
and are separated by their border alone, so they move to the primary
background. The rationale was muted, which put it in the same register as
the evidence results below it; the spec reads it as body copy, so it takes
the primary content colour and only the results stay muted.

Spacing was uniformly tighter than the spec: card padding lg -> xl, card
gap md -> lg, evidence rows sm/md -> md/lg with a sm gap between them, and
the gap between cards md -> xl, which now matches the card's own padding.

Type sizes and weights were already right and are unchanged. Worth noting
the scale tops out at 500 -- there is no bolder weight token -- so the
statement is as heavy as the system goes.

Claude-Session: https://claude.ai/code/session_014zh69vex76pjNTnarqVcXL
Taken from the prototype recording, which shows the row moving through
states the card had been collapsing into one.

A hypothesis in flight is a single effectiveStatus, but the recording names
four moments inside it: Formed, Preparing checks, Checking, and Evidence
checked. The distinction only exists in the verification steps -- whether
any are planned, and whether they have produced anything -- so that is
where it is read from. Only Checking is coloured and keeps its dot moving;
the others are staging posts, not outcomes, and the old code lit all of
them accent. The heading above the steps moves with them, from "Evidence to
check" to "Evidence checked", and a step with nothing yet reads "Awaiting
evidence" rather than naming its queue position.

A step that has produced something now opens, revealing the objective and
method behind it -- fields the projection has always carried and the card
never showed. A step with no result stays a bare row, because a chevron
there would promise a finding that does not exist yet.

Accepted and rejected keep their own labels rather than folding into
supported and refuted, so a decision is never misread as a verdict.
Confidence is lowercase, matching the recording.

Claude-Session: https://claude.ai/code/session_014zh69vex76pjNTnarqVcXL
Verified in a browser this time, which caught four things reading the code
did not.

The evidence row's summary was passed as the Disclosure title's children,
so it landed inside a Button: centred, held to one line, and spilling past
the row's right edge. Moving it to `leadingItems` puts it outside the
button, where it lays out as ordinary content and, taking the row's spare
width, pushes the chevron to the edge the prototype has it on. That also
removed the doubled padding, since the Disclosure supplies its own.

The toggle had no accessible name once the summary moved out of it -- a
screen reader heard "button, collapsed" and nothing else. It now says which
check it opens.

The statement and its rationale were running together as one block, and the
story demos were clipping at Storybook.Demo's 512px maximum, so most of
each row was only reachable by scrolling inside the frame.

Claude-Session: https://claude.ai/code/session_014zh69vex76pjNTnarqVcXL
The row talked to the real endpoints already, but nothing mounted it, so it
never appeared in the product. `knip --production` had been reporting
exactly that and the config carried a TODO entry point to keep it quiet;
both are gone now because the detail view reaches it.

`orchestration` on the investigation is what gates it. The field is already
served inline on the list and detail responses and is null for manual and
template investigations, whose orchestration endpoint 404s. The proof of
concept gated on `investigation.mode === 'agentic'` instead, but no `mode`
field exists -- the summary is the only marker an investigation carries.

The detail query now also polls while a run is live. It previously stopped
as soon as no block was executing, which would freeze the gate: a run that
started after load would never show its row, and one that finished would
keep claiming to be running.
The button went grey with nothing to explain it, and the two reasons it
does so are not guessable from the page.

A missing open period is named outright: the page already lists open
periods, so saying there is none gives nothing away.

The other reason cannot be as specific. `unavailable` from the candidates
endpoint covers an issue that cannot be investigated at all, an existing
investigation in a project the viewer cannot see, and a viewer who may not
create one -- collapsed on purpose, since the resolver it comes from notes
that a caller "should not reveal whether an inaccessible or invalid issue
exists". Distinguishing those in a tooltip would leak precisely that, so
the wording covers them together and points at the cause someone can
actually act on.

Neither reason is ever "still loading": a pending query renders a
placeholder in place of the button, and a failed one renders an alert.

Claude-Session: https://claude.ai/code/session_012CtaiBZtJdz8uMRtUgvbmv
…heses

Frames 4705-4707 of the Seer Investigation design put a status block above
the hypothesis row: one line saying what the agent is doing, with a chip and
an elapsed counter, and the row beneath it inside a shared panel.

The block is one component for the whole lifecycle, because the shape never
changes -- icon, sentence, chip, time -- only the words and the colour do. It
sits in a fixed spot for the life of a run, so a reader who has learned where
to look for "what is happening" never has to relearn it. `status` picks the
variant and `phase` picks the words: every in-flight phase shares one
`processing` status, so the phase is the only thing separating "gathering
context" from "finalizing".

The design's chip turned out to be the existing `Tag`: `variant="info"`
resolves to `content.accent` on `background.transparent.accent.muted`, which
is exactly what the Figma variables name. No new styled component needed.

Two states are not from the frames and are marked as such. `cancelled` is
here because the projection can report it and falling through to `failed`
would paint a decision someone deliberately made bright red. `elapsed` is an
optional prop the wired block leaves empty -- the projection carries no
run-level start time, only per-block `startedAt` -- so it renders in the
story and nowhere else rather than counting from an invented origin.

The cards move with it. A hypothesis being verified now keeps a solid border
instead of a dashed one, because dashing it announces a verdict the agent has
not reached; dashed is reserved for a card that was checked and is not the
answer. "Checking" becomes "Verifying..." and is drawn as a spinning ring
rather than a pulsing dot, and `refuted` reads amber rather than muted -- a
hypothesis the agent tested and closed is not an error, but it is a result
worth registering as you scan the row.

Claude-Session: https://claude.ai/code/session_014zh69vex76pjNTnarqVcXL
Neither entry point produced an investigation with hypotheses. The server
decides from the request body -- a `source` with no `templateKey` builds an
agentic run, anything else builds a plain notebook -- and both buttons sent
a shape that landed elsewhere.

"Investigate" on a metric issue now sends the metric snapshot alone,
dropping the template key. The candidates endpoint already matches agentic
and template lineage keys alike, so a breach that was investigated before
still resolves to "View" rather than offering a duplicate.

"New investigation" sends a manual source. That run opens `awaiting_input`
and stays there until someone supplies a prompt, which nothing in the UI
does yet, so the investigation behaves as before -- an empty notebook --
with an idle run attached.

That idle run is why polling changed. The predicate asked whether a run had
reached a terminal status, and `awaiting_input` has not; left alone, every
newly created investigation would have polled the detail and orchestration
endpoints every two seconds forever. It now asks whether the agent is
advancing, which `awaiting_input` is not: it is blocked on a person, and
supplying input writes the new projection into the cache and starts it
again.

Note that this does not gate the behaviour behind anything beyond the
existing organizations:investigations flag -- every investigation created
in a flagged org becomes agentic.

Claude-Session: https://claude.ai/code/session_012CtaiBZtJdz8uMRtUgvbmv
The agent writes its findings in terms of what it read, so they are mostly symbols: module/file.py::function_name, dotted paths, issue short IDs. None of them carry a break opportunity, so a token wider than the column pushed its own text out through the card edge instead of wrapping inside it.

Set the scraps wordBreak prop on every agent-written string in the card, not just the evidence detail that surfaced it.

Claude-Session: https://claude.ai/code/session_014zh69vex76pjNTnarqVcXL
Drop the block count from the subheader, the status badge and the Seer mark from the header actions, and the debug-only add text/query cell composer.

The main content area now fills the page and left-aligns with the rest of the product rather than sitting in a centred 884px column. Three separate caps were producing that column: the header grid, the canvas wrapper, and the two stacks around the hypothesis row and the notebook.

Removing the composer left useAddInvestigationBlockMutation with no callers, so it goes too. The specs that reached the behaviour through it now exercise it directly: the never-run cell test uses the fixture's own unrun query block, and the fixture-API mutation test drives a rename instead.

Claude-Session: https://claude.ai/code/session_014zh69vex76pjNTnarqVcXL
Reverts the fluid-width half of 45de482. Running the notebook to the full page width stretched the prose past a comfortable measure, and body text is most of what this view renders.

The header grid, the canvas, the hypothesis row and the notebook column go back to the centred 884px column. Everything else from that commit stands: the block count, the status badge, the Seer mark and the debug-only add-cell composer stay gone.

Claude-Session: https://claude.ai/code/session_014zh69vex76pjNTnarqVcXL
Two defects the review bot found in the hypothesis row.

A settled run stops polling, but accept, reject and retry stay on the menu -- and acting on a finished run is the main reason to open it. Sentry only queues a command: the response carries the projection it already had with nothing but workflowVersion moved on, and Seer rewrites the real one later. The card therefore kept its old disposition until someone reloaded. An accepted command now reopens polling for a bounded window, long enough for Seer to apply it and short enough not to poll a stopped run forever.

Separately, verificationSteps is declared required=False with no default on the contract, so DRF omits the key rather than sending an empty list. The frontend type claimed it was always there, and two call sites read .length and .filter straight off it -- a hypothesis the agent has only just formed would crash the row it appears in. The type now says what the wire says, which is what surfaced the third unguarded caller.

Claude-Session: https://claude.ai/code/session_014zh69vex76pjNTnarqVcXL
Neither entry point produced an investigation with hypotheses. The server
decides from the request body -- a `source` with no `templateKey` builds an
agentic run, anything else builds a plain notebook -- and both buttons sent
a shape that landed elsewhere.

"Investigate" on a metric issue now sends the metric snapshot alone,
dropping the template key. The candidates endpoint already matches agentic
and template lineage keys alike, so a breach that was investigated before
still resolves to "View" rather than offering a duplicate.

"New investigation" sends a manual source. That run opens `awaiting_input`
and stays there until someone supplies a prompt, which nothing in the UI
does yet, so the investigation behaves as before -- an empty notebook --
with an idle run attached.

That idle run is why polling changed. The predicate asked whether a run had
reached a terminal status, and `awaiting_input` has not; left alone, every
newly created investigation would have polled the detail and orchestration
endpoints every two seconds forever. It now asks whether the agent is
advancing, which `awaiting_input` is not: it is blocked on a person, and
supplying input writes the new projection into the cache and starts it
again.

Note that this does not gate the behaviour behind anything beyond the
existing organizations:investigations flag -- every investigation created
in a flagged org becomes agentic.

Claude-Session: https://claude.ai/code/session_012CtaiBZtJdz8uMRtUgvbmv
A review-bot finding on the polling predicate this commit introduced.

Sentry parks a brand-new run at awaiting_input before it has finished creating it in Seer: the create is dispatched after the transaction commits, and until it lands the run carries no Seer id. Treating that status as stopped meant the queries went quiet during exactly the window where the create can still fail the run or rewrite the projection, leaving the UI on awaiting input until a reload.

The predicate now takes whether the Seer run exists, so a placeholder awaiting_input keeps being read while a genuine one -- blocked on a person -- still does not. A create that fails leaves no Seer id behind but flips the run to failed, which is terminal, so the missing id cannot restart polling on a stopped run. The detail view reads a summary that carries no run id, so it keeps the blocked-on-a-person reading.

Claude-Session: https://claude.ai/code/session_014zh69vex76pjNTnarqVcXL
@github-actions github-actions Bot added the Scope: Backend Automatically applied to PRs that change backend components label Sep 16, 2026
@arslnb
arslnb requested a review from wedamija September 16, 2026 17:47
@billyvg
billyvg force-pushed the billyvong/investigations-agentic-entry-points branch from 5a2c9ef to 248163c Compare September 16, 2026 18:52

@wedamija wedamija left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three notes on the freeze logic. The window arithmetic itself is right — reference_time is the end of the window, and each call site anchors on the execution that produced the data. The two blocking items are that the new fingerprint test does not reach the new code, and that the filters fallback can pair a current duration with an old anchor.

Review by Claude Code.

assert snapshot["context"][0]["queryContext"]["source"] == source
assert snapshot["context"][0]["result"]["queryLinks"] == [link]

def test_relative_filters_keep_the_same_request_fingerprint(self) -> None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test passes on master. create_block() makes no execution, so _query_refinement_context_execution returns None and the freeze path never runs. build_block_execution_snapshot also never calls timezone.now(), so both mocks are no-ops.

Add a completed previous execution with statsPeriod in its input_snapshot, then build the snapshot twice at different wall-clock times. That is the case that has to stay stable.

via Claude Code

previous_input = previous_execution.input_snapshot
query_context = previous_input.get("queryContext") or {
"source": previous_input.get("source", source),
"filters": previous_input.get("filters", filters),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filters default is the investigation's filters now, but reference_time is the previous execution's start. _upsert_report_block writes no filters, so refining a report query block pairs today's duration with yesterday's anchor. The resulting window looks absolute and exact, and no query used it.

Suggest omitting the window when previous_input has no filters, and letting source.timeRange / analysisWindow supply it.

via Claude Code

query_context = previous_input.get("queryContext") or {
"source": previous_input.get("source", source),
"filters": previous_input.get("filters", filters),
"parameters": previous_input.get("parameters", parameters),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parameters come from the previous execution here, but snapshot["parameters"] holds the new values, and the prompt gives queryContext precedence. A user who edits a parameter and re-runs then gets the old value. Is freezing the parameters intended, or only the window?

via Claude Code

Base automatically changed from billyvong/investigations-agentic-entry-points to master September 16, 2026 19:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Scope: Backend Automatically applied to PRs that change backend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants