Skip to content

Improve Replay Tools [hackweek] - #1256

Closed
mrduncan wants to merge 26 commits into
mainfrom
feat/replay-review-fixtures
Closed

mrduncan wants to merge 26 commits into
mainfrom
feat/replay-review-fixtures

Conversation

@mrduncan

Copy link
Copy Markdown
Member

No description provided.

mrduncan and others added 26 commits August 17, 2026 13:33
Replay recording fixtures used `tag: "ui.click"`, a shape the browser SDK
never emits. Real user actions arrive as rrweb custom events with
`tag: "breadcrumb"`, where the meaning lives in `payload.category`. Because
the fixtures matched what the classifier expected rather than what Sentry
sends, snapshots looked correct while real output degraded.

Rebuild the segment fixtures from the recording spec and the
`@sentry-internal/replay` frame types: breadcrumbs carry millisecond
timestamps, performance spans carry seconds, and the set covers ui.click, a
console error, a navigation breadcrumb, a navigation span, fetches at 200 and
500, options, a script resource, and two ui.slowClickDetected events that
differ only by clickCount so rage and dead clicks are separable behaviorally.

Reconcile the replay metadata counts with the recording. Upstream sets
click_is_dead for both DEAD_CLICK and RAGE_CLICK, and count_dead_clicks sums
that column, so one rage click plus one dead click is 2 dead and 1 rage.

Add fixtures and handlers for endpoints the tools do not read yet: a
multi-page recording served through a Link header cursor, replays-events-meta,
and the experimental Seer summarize endpoint. Cover them with contract tests
so the shapes cannot drift before the tools arrive.

The get_replay_details snapshots are re-baselined against these fixtures and
now record known-wrong output: every user action is labeled `breadcrumb`,
four of six lines are session-boot noise, and the console error, failed
checkout request, rage click, and dead click are dropped by the event cap.
The snapshots are annotated as such so the taxonomy fix lands as a visible
diff rather than a rewrite.

Refs docs/specs/replay-review.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Document the map-then-zoom restructuring of replay retrieval: get_replay_details
returns session shape instead of a fixed six-event prose sample, a new
catalog-only get_replay_activity returns signals for a requested window and
grain, and classification follows Sentry's own replay event taxonomy so MCP
and Seer agree on what a session contains.

Also records the adjacent correctness fixes the work depends on: segment
pagination through the Link header cursor, per-event-type timestamp units,
truncation reporting, replay sort reconciliation, capability gating on the
replays search path, and distinguishing replay-count rate limiting from
absence.

Section 1 of tasks.md is checked off by the preceding commit, which rebuilt
the fixtures from real SDK event shapes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replay events were classified by their rrweb `data.tag`, but the SDK emits
every user action with `tag: "breadcrumb"` and puts the meaning in
`payload.category`. Clicks, console errors, and rage clicks therefore all
rendered under the literal label `breadcrumb`, with their real content
demoted into a details blob.

Add a shared internal module that ports Sentry's own taxonomy from
`replays/usecases/ingest/event_parser.py` and `summarize.py`, so MCP output
and Seer's summarizer agree about what a session contains:

- Classify breadcrumbs by `payload.category` and spans by `op`, matching
  `which()`. Unrecognized events are `unknown` rather than guessed at.
- Resolve timestamps by event type. Spans and web vitals are seconds; clicks,
  console, and navigation are milliseconds. The previous magnitude heuristic
  held only by coincidence of present-day epochs.
- Drop the noise upstream refuses to narrate, and skip 2xx requests from the
  rendered set while still counting them, so `network 58 (2 failed)` means 58
  requests of which 2 were narrated.
- Prefer the navigation span on web and the navigation breadcrumb on mobile,
  where no span exists.
- Separate dead, rage, and slow clicks behaviorally: all three arrive as
  `ui.slowClickDetected` and differ only by end reason, target element, stall
  duration, and click count.
- Measure offsets from the replay's `started_at` rather than the first
  recorded event, so they align with replay metadata and error timestamps.

Render at three grains, and label unavailable payload rather than passing over
it in silence: bodies the SDK never captured are marked not captured, and only
Relay's `[Filtered]` marker is reported as redacted. Client-side SDK masking
leaves no marker, so masked values render as delivered instead of asserting a
redaction we cannot detect.

Widen the recording payload schema to carry the fields classification needs.
Parsing stays permissive: recordings span many SDK versions and pass through
PII scrubbing, which can replace a number with a marker string, so a malformed
field degrades that field rather than dropping the event.

Against the rebuilt fixtures this surfaces the failed checkout request, the
console error, the rage click, and the dead click — all four of which the
previous six-event cap dropped before reaching them.

Refs docs/specs/replay-review.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The recording segments index was requested with `?download=true`, a parameter
that does not exist on that endpoint, while the `Link` header's cursor was
ignored. Sentry serves at most 100 segments per page — `per_page` cannot raise
that, since its default and maximum are both 100 — so any replay longer than
100 segments was silently truncated to its first page.

Follow the cursor, and bound the read twice over. Segments are capped at 150,
matching the clamp Sentry's own summarize endpoint applies, and raw JSON at
10MB measured before parsing. The byte ceiling is the real guard: segment
sizes vary by orders of magnitude, parsed rrweb objects expand well beyond
their serialized size, and mcp-cloudflare runs under a 128MB Workers limit.
Both bounds are provisional pending QA against real replays. The caller is
told which bound stopped the read, and `get_replay_details` now says so rather
than presenting a partial recording as a whole session.

Add two endpoints the map will need:

- `getReplayErrorEvents` resolves a replay's error ids through
  `replays-events-meta` in one batched call. Error ids are event ids, and
  resolving them through `listIssues` returns issues, which carry no event
  timestamp; this returns issue identity and a millisecond-precision timestamp
  together, so it also replaces the per-error issue lookups. The endpoint is
  PRIVATE, so its response is parsed defensively.
- `getReplaySummary` reads Seer's replay summary state. Deliberately one GET:
  no start request, no retry loop. Seer's start route enqueues background work
  and returns an empty body, so starting would spend an LLM run per call and
  still report `processing` on the immediate read.

Send an explicit `field` allow-list on the replays index, drawn from
`VALID_FIELD_SET`. Omitting it returns Sentry's default column set, which is
wider than anything rendered. `replay_type` and `ota_updates` appear in
responses but are absent from that set and would 400, so they are excluded.

Refs docs/specs/replay-review.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replay details returned a six-event prose sample, which either truncates a
long session or floods a short one, and in neither case tells the reader where
to look. The cap was routinely spent on session-boot noise before reaching the
failure the user cared about.

Return the shape of the session instead: signal counts and time span, the page
flow, and a per-kind breakdown carrying error, rage, and dead click counts.
Counts come from every classified event including ones that are never
rendered, so `network 2 (1 failed)` describes two requests of which one is
shown. Truncation is stated rather than implied.

Add a suggested next call windowed on the replay's own first resolvable error,
so the reader gets a concrete zoom target instead of guessing. Error
timestamps come from one batched `replays-events-meta` lookup, which also
replaces the per-error `listIssues` calls behind the Related section — up to
three sequential requests become one. Related entries are still driven by
`replay.error_ids`, so an id that private endpoint cannot resolve is listed by
id rather than disappearing. When no timestamp resolves, the suggestion falls
back to a whole-session digest and the map is unaffected.

Render Sentry's AI summary as an optional Chapters section, read once. It is
strictly additive: a 403, a server failure, a still-running or not-started
task, an unparseable body, or a non-completed status carrying stale chapter
data all omit the section and leave the map intact. Status decides, not the
presence of data, so a superseded run cannot present itself as current.

Report how many related issues and traces were omitted; both previously
stopped at their display limit in silence.

Delete the old classifier, which keyed on a `data.tag` shape the SDK never
emits. Its snapshots recorded that defect deliberately; they now record
correct output, and two tests that pinned the old rendering are rewritten to
assert what replaced it.

Refs docs/specs/replay-review.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The replay map tells an agent where to look but gives it no way to look
there. Add a catalog-only tool that returns the signals in a time window at a
requested level of detail, completing the map-then-zoom pair: the map's
suggested call now resolves.

Windowing is by `startMs`/`endMs` measured from the replay's `started_at`,
defaulting to the whole session. Bounds are inclusive. A signal whose
timestamp could not be resolved is excluded from a windowed read rather than
guessed into one, and stays available in a whole-session read.

`grain` controls rendering and `kinds` controls inclusion, kept orthogonal so
a digest of a filtered window stays consistent with the same window read at
standard grain.

Paging is by `limit`/`cursor`. Sentry paginates recording segments rather than
signals, so there is no server-side signal cursor to pass through: each page
re-reads the recording and skips a signal offset. The cursor is therefore
synthetic and encodes the window and kind filter alongside that offset, so a
continuation resumes the same query rather than a differently-filtered one. It
also overrides any window or filter passed beside it, since honouring both
would silently change what the caller is paging through. Truncation always
reports the cursor needed to continue, and a partial recording read is
reported separately from a paged result.

Extract replay parameter resolution and the project-constraint check into a
shared helper so the two replay tools cannot disagree about what a valid
replay reference is, or about which replays a constrained session may read.

The direct tool surface is unchanged: this is catalog-only, and
`measure-tokens` still reports 5,598 tokens across 9 direct tools.

Refs docs/specs/replay-review.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ailures

Four correctness fixes on the replay paths outside the two replay tools.

Replay sorting drifted from Sentry's `sort_config`, which is the authority for
both the scalar and aggregated query paths and raises a ParseError for
anything absent from it. Add the four sorts it supports that we omitted:
`count_screens` and the aliases `browser`, `os`, and `os_name`. A test now
asserts exact parity with upstream's 29 keys, in both directions — a sort we
advertise but Sentry rejects fails at query time, and one Sentry supports but
we omit is simply unreachable.

Field discovery had no notion of sortability at all, so it presented every
searchable replay field as if a sort on it would work. Most replay fields are
filterable but not sortable, which meant an agent could take a sort straight
from discovery output and get a UserInputError. Replay fields now carry a
`sortable` flag derived from the same allow-list, and the routing prompt tells
the agent to respect it.

`search_events` served the replays dataset regardless of whether the
constrained project had Session Replay, so replay search and replay details
disagreed about availability. Reject it at handler time, checking the resolved
dataset so an agent-chosen route is caught as well as an explicit request, and
drop `replays` from the advertised dataset options so the router cannot pick
what would be rejected. The schema narrowing goes through a new general
`refineInputSchema` hook rather than putting replay logic in shared
infrastructure: `requiredCapabilities` gates a tool as a whole, and
search_events serves six datasets of which only one needs replays.

`listReplayIdsForIssue` swallowed every failure with `.catch(() => undefined)`.
Because `replay-count` is rate limited per organization and `get_issue_details`
calls it on every lookup, parallel issue triage silently lost the Session
Replay section — a throttled request was indistinguishable from an issue with
no replays. Report the lookup as unavailable instead, both when nothing else
is known and when an attached replay was found but related ones could not be.

Refs docs/specs/replay-review.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Section 7 of the replay review change. The gaps that remained were the
ones mocks make easy to skip: paths that render nothing, where a passing
test and a silently broken one look identical.

Cover the two truncation paths end to end. A recording spanning three
segment pages is only complete if both `Link` cursors are followed, and
the byte budget is tripped with a real oversized page rather than by
injecting a bound, so the "Truncated" line is driven by the same code
that runs in production. Silence here would present a partial recording
as a whole one, turning "no errors in this replay" into a false negative.

Extend summary degradation to a connection failure and a non-JSON body.
Seer takes tens of seconds on a long replay, and a timeout raises a
ConfigurationError that would abort the whole tool if it escaped the
chapters lookup.

Assert redaction survives to tool output. `<not captured>` and
`<redacted>` mean different things — enable networkCaptureBodies, or
relax a scrubbing rule — so they appear on one signal where conflating
them would fail.

Cover capability gating, which no tool tested. Verified by mutation:
stubbing the check out fails the replays-absent case.

Add a replay eval for map-then-zoom. It executes against the mocks, so
the zoom can only pass by reading the window off the map.

Also reset MSW handlers between replay detail tests; overrides are not
reset automatically here, and a leaked stub changes what a later test
measures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Running the eval showed both cases scoring 0.5 while the model's answers
were correct — it found the 500, the console error, and the uncaptured
body sizes. The spec was wrong, not the behavior.

Two problems. The eval required routing to the map through
search_sentry_tools, but get_sentry_resource is a top-level tool that
delegates to get_replay_details, so a pasted replay URL reaches the map
without catalog discovery. Both routes are correct, and asserting one
scores a routing preference rather than the handoff under test.

The first case also asserted a map read with nothing to hand off to,
which get_sentry_resource already covers.

Assert only the zoom, and let allowExtras absorb whichever route the
model takes to the map. The zoom is the real subject: its offsets are
milliseconds from the start of the replay and appear nowhere in the
prompt, so passing means the map's suggested window was read and
followed.

Now scores 1.00. Mutating the expected startMs drops it to 0.5,
confirming the offsets are load-bearing rather than incidentally matched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ts spec

Section 8 of the replay review change. The spec was written before the
work and read as though it still described pending behavior, so mark it
implemented and record where the shipped code took a different path.

Keep the Motivation section in its original present tense. It is the
record of what was wrong and why the work was justified; rewriting it to
past tense would lose the evidence and leave assertions no one can check.

Correct the Map example. Offsets render in a consistent `T+` form rather
than mixing bare seconds, and the page count is gone from the Signals
line, where a count beside a time span read as something temporal.

Add a divergences section covering the seven places the implementation
differs: discovery gained a sortability flag rather than losing fields,
dataset narrowing uses a general `refineInputSchema` hook instead of
replay-specific logic in shared infrastructure, both replay tools share
extracted parameter resolution, a cursor overrides arguments passed
beside it, unresolvable error ids are listed rather than dropped,
`count_dead_clicks` includes rage clicks upstream, and the segment
bounds remain unmeasured against real replays.

Also correct the two upstream fix references. The descriptions of
#120859 and #121765 were swapped in the task notes; verified against the
commits in getsentry/sentry and confirmed both behaviors are implemented
and tested here.

No other doc describes replay tool output. Generated definitions were
already current.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Section 9's automated portion. Targeted replay and catalog tests (167
across 9 files), tsc, lint, full suite (1481 in mcp-core), and token
measurement all pass. Token cost is unchanged at 5,598 across 9 direct
tools, since get_replay_activity is catalog-only.

The remaining task, QA against a real organization, needs a Sentry token
and a long real replay; it cannot be satisfied from mocks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Out of Scope section ruled out "web visual snapshots" on the grounds
that rendering needs a browser. That is right about images and wrong as
a blanket statement: reading DOM structure needs no layout engine, and
the structure is already in segments this change downloads and discards.

Record what is actually reachable. rrweb FullSnapshot (type 2) carries
the serialized DOM, and IncrementalSnapshot (type 3, source 0) carries
the mutations to replay it forward to a timestamp. Upstream's which()
ignores both, so matching it lost nothing for behavioral summarization,
but it leaves structure on the floor.

Rooting is close to free: click breadcrumbs already carry
payload.data.node.id, and rrweb ids are stable within a recording, so
the identifier a subtree read needs is one get_replay_activity already
surfaces.

Bounding boxes and a visible lens stay out, for the same reason images
do — both are layout properties, not recorded facts. An interactive lens
is decidable from tags and attributes alone.

Two constraints are recorded because they shape any implementation:
replaying mutations to a timestamp cannot reuse the existing segment
reader without blowing the Workers memory ceiling, and a full tree
exceeds any reasonable response size, so rooting plus a lens is the
default rather than an option.

Also note that SDK masking defaults to on and leaves no marker, so tree
text must render as delivered rather than be claimed as redacted —
consistent with the redaction rule this spec already sets.

Verified against rrweb's type definitions and Sentry's pinned EventType
enum rather than from memory; both are now cited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The prior entry established that tree reads are reachable. This works
out what a correct one requires, and surfaces two problems the sketch
had glossed over.

Input values arrive as rrweb source 5 (Input), not as attribute
mutations. An implementation applying only source 0 renders every field
at its initial value — a stale tree that looks authoritative, which is
worse than no tree. Called out explicitly and given a test case, since
it would otherwise ship silently.

Reconstruction cost is not known to be bounded. rrweb re-snapshots on
checkoutEveryNms, and Sentry's SDK treats a recording's first event as a
checkout, so a read at time T may only need the nearest preceding
snapshot — or, if a recording carries exactly one, may have to apply
every mutation in the session. Those two cases have different feature
shapes, and building for the wrong one yields a tool that works on short
replays and fails on the long ones that matter. Promoted to the first
question QA should answer.

Also record that element nodes carry no textContent (a label is a child
text node, not a property), that reconstruction fidelity must be
reported rather than assumed, and that a visible lens is out for the
same reason boxes are — both need the cascade resolved.

Argue the tool surface three ways and recommend a separate catalog tool:
a point-in-time structural read has a different return shape and cost
model than a windowed signal list, and only a separate tool can refuse
on budget grounds without complicating a contract agents call routinely.

Verified against rrweb's type definitions and the Sentry SDK's recording
emit handler rather than from memory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turn the three-way comparison into a decision. get_replay_dom is a
separate catalog-only tool: a point-in-time structural read differs from
a windowed signal list in return shape, cost model, and failure modes,
and it is the only option where a read can refuse on budget grounds
without complicating a contract agents call routinely.

Keep the rejected options as a short record so the choice is not
relitigated from scratch, and note why a third replay tool is
affordable: the ≤20 target governs the direct surface, which stands at
9, while catalog tools carry no per-session token overhead.

Two consequences are written down because they are easy to drop under
implementation pressure. Refusal is a supported outcome rather than a
failure, since a partial tree is indistinguishable from a complete one
at the point of use. And the tool returns structure only, so it composes
with get_replay_activity instead of drifting into a competing account of
the same moment.

Gating, parameter resolution, and the constraint check follow the
existing replay tools; internal/tool-helpers/replay.ts was extracted for
this case when the second one landed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DOM reconstruction folds a whole recording into a running node map and
keeps only the result. The existing reader accumulates every segment
before returning, so a tree read would hold the raw events and the
derived state at once — double the peak for no benefit, under a 128MB
Workers ceiling that the byte budget already exists to respect.

Add streamReplayRecordingSegments, which invokes a callback per segment
in wire order and retains nothing. Returning "stop" ends the read
immediately, so a caller that has passed the moment it cares about does
not page the rest of the session.

Stopping by choice is reported as truncatedBy: null. Conflating it with
a budget stop would make a deliberate early exit look like a lost tail.

getReplayRecordingSegments becomes a thin wrapper that pushes into an
array, so signal extraction is unchanged — its 111 existing tests pass
untouched. A test asserts the two agree on segments, count, and bytes,
since any drift between them would now be a bug in the wrapper.

The stop path is mutation-verified: forcing the branch false fails the
early-exit test rather than passing quietly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the fold behind a DOM read: a flat node map built from a
FullSnapshot (type 2) and advanced by IncrementalSnapshot (type 3)
mutations up to a target time. Semantics come from rrweb's own type
definitions rather than from the SDK, which does not re-export them.

Input values are applied from source 5, not from attribute mutations.
This is the failure worth naming: a source 0-only fold renders every
form field at the value the page shipped with, so the tree looks current
and reports stale content. The initial value still comes from the
attribute, and the two are kept separate.

A later FullSnapshot supersedes the map entirely rather than merging.
Merging would resurrect nodes the page had already discarded, and it
makes the one-snapshot and periodic-checkout cases behave identically
without branching on which a recording uses.

An add whose parentId is unknown is dropped and counted, never
reparented to the root: fabricated structure is indistinguishable from
recorded structure once rendered. Removes delete the subtree, so a later
add cannot reattach orphans under a parent that no longer exists, and a
relocation detaches before inserting so a moved node does not appear
twice.

Fidelity is reported, not assumed. Drops are counted by reason
(unknown-parent, unknown-node, malformed, duplicate-id), a missing
snapshot is distinguished from an empty page, and apply() returns
past-target so a streaming caller stops paging at the moment it cares
about.

Subtree deletion is iterative — untrusted DOM depth should not reach the
call stack.

Five mutations of the risky paths each fail a test: dropping source 5,
reparenting unknown parents, skipping subtree deletion, ignoring nextId
ordering, and merging snapshots instead of superseding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the rendering half: selector, text, state attributes, and node id
per element, with connectors showing structure.

Two lenses, and deliberately not a third. `interactive` keeps what a
user can act on — interactive tags, plus anything carrying role, href,
onclick, or tabindex — and the ancestors needed to place them, so a
button is never shown floating without a path. `full` keeps every
element. There is no `visible` lens: visibility is a computed style, and
a lens that guessed at the cascade would be confidently wrong about the
thing a reader most wants to trust.

Text comes from immediate text children only. rrweb element nodes carry
no textContent, so a renderer that ignores children shows no labels at
all, and one that walks descendants attributes a child's label to its
container.

Values render as recorded. SDK masking leaves no marker, so a masked
field is indistinguishable from a real one, and claiming <redacted>
would assert something the recording cannot support — consistent with
the rule the signal renderer already follows. The current input value is
shown rather than the value attribute, since input events supersede it.

A rootNodeId that is not in the reconstruction is reported rather than
falling back to the document root, which would answer a question the
caller did not ask.

Mutation-verified. One mutation initially survived: the test for
container text relied on directText only walking immediate children
rather than on the node-type check. Added a case with an element child
carrying textContent, which now fails without the check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verified against the `@sentry-internal/rrweb-snapshot` build Sentry ships:
`serializeNodeWithId` assigns an id to every node including the Document, so a
snapshot's root is a `nodeType: 0` node rather than an element. The renderer
walked from that root and bailed on the first non-element, so a real recording
rendered nothing at all. Resolve the render root by descending to the first
element instead, breadth-first so the doctype sibling cannot lead the search
away from `html`.

The unit fixtures had modelled an id-less document wrapper, which is what hid
this. They now carry the real shape.

Three rendering defects surfaced once the fixtures were honest, each confirmed
against rrweb's own emission code rather than assumed:

- `role` presence was treated as interactivity, so `role="alert"` pulled status
  banners into a tree labelled interactive. Match the WAI-ARIA widget roles by
  value instead.
- `isChecked` is reported on every input event, not only for checkboxes and
  radios, so text fields rendered `[checked=false]` — a claim about a property
  they do not have.
- A boolean HTML attribute serializes as the empty string, since
  `getAttribute("disabled")` returns `""`. `disabled` rendered as `[disabled=]`.

Each fix is pinned by a test that fails without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Click breadcrumbs carry `payload.data.node.id`, the rrweb node id, which is
stable within a recording. It was parsed but never rendered, so nothing in the
output named the element a structural read could focus on — "show me the DOM
around what was rage-clicked" had no handle to pass along. Report it at detail
grain.

Also add the DOM events the recording fixture lacked. It carried only custom
events (`type: 5`), so nothing exercised the two types that describe structure.
The login and checkout pages are now snapshotted, and the checkout segment
carries a `source: 5` input change, an ignored scroll, an added error banner, and
a text-plus-attribute mutation that disables the submit button — the last two
after the console error, so a read at the error must not show them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes the three-step replay read: the map finds the failure,
`get_replay_activity` names the element and reports its node id, and this
explains what was around it. Each step hands the next a concrete handle.

A separate catalog-only tool rather than a grain or a flag on
`get_replay_activity`. A structural read has a different return shape, cost
model, and failure mode from a windowed signal list, and it is the only surface
where a read can refuse on budget grounds without complicating a contract agents
call routinely. Catalog-only keeps the direct-surface budget unchanged at 5,598
tokens across 9 tools.

`atMs` is required. Defaulting to either end of the session would answer a
different question often enough that guessing is worse than asking. It is an
offset from the replay's start, matching the other two tools, so a replay with no
parseable `started_at` is refused rather than placed by guess.

Refusal is a supported outcome. When the segment budget runs out before the
target moment is reached, the tool says so and explains what would help. It does
not return a partial tree, because a tree assembled from a truncated mutation
history is indistinguishable from a complete one at the point of use. Reads stop
paging as soon as an event passes the target, so cost is bounded by how far in
the moment is rather than by session length.

Fidelity is reported, not assumed: which snapshot it started from, how many
mutations it applied, how many operations it dropped and why, and whether the
recording ended before the moment asked for.

Structure only. Text and input values arrive already masked by the SDK with no
marker, so they render as delivered and are never labelled redacted — the tool
cannot distinguish masked-at-capture from genuinely-this-text. It answers "was
the button disabled", not "what did the user type".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Promote the DOM tree section from Future Work into Design, replace the proposed
output with the shipped format, and add a requirement to the delta spec.

The checkout-frequency question the section called "the single largest open
question" is answered by design rather than measurement: superseding the node map
on each snapshot makes the frequent-checkout and single-snapshot cases identical,
so it no longer gates the interface. What remains unmeasured is whether the
budget is generous enough on a long real replay — and if it is not, the tool
refuses, which is a correct answer rather than a wrong tree.

Also record the divergences: ARIA roles matched by value rather than `role`
presence, node ids inline rather than in a trailing index, and two reported
conditions the spec did not name (a recording that ends before `atMs`, and a
replay with no parseable `started_at`).

The testing list gained a case it was missing, added because it caused a real
bug: a fixture's node shape must match what rrweb actually emits. A merely
plausible fixture tests the implementation against itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… walk

Found by pointing the tool at a real replay: the rendered tree stopped partway
through `head` and never reached `body` at all.

Two defects, both of which made a real page look far smaller than it was.

Depth truncation aborted the whole traversal. `truncated` was a single flag and
the walk returned as soon as it was set, so the first branch to exceed the depth
limit hid every later sibling — and on a live page `head` is routinely deep
enough to swallow `body` outright. Depth pruning is now local: the offending
branch is dropped and counted, and its siblings still render. The node budget
stays global, since once it is spent there is nothing left to spend.

The default `maxDepth` of 12 was also too shallow to be useful. The real page
measured 17 levels of elements with its interactive nodes below level 12, so the
default clipped exactly what the interactive lens exists to surface. Raise it to
40 and document depth as a poor proxy for output size — component frameworks
nest wrappers freely, so `maxNodes` is the real budget.

Report the two limits separately. They call for different fixes, and the old
single line named `maxNodes` even when depth was the cause, which sent a reader
at the wrong dial. Each message now also names the way out, including that every
rendered line carries the node id `rootNodeId` accepts.

The first test written for this passed with the bug reintroduced: the fixture was
not deep enough for one branch to hide another. Mutation testing caught that, and
the replacement fails without the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Agents investigating a replay over-rotate onto web vitals and console warnings
instead of the DOM. Three causes, all in what the output emphasizes rather than
in the model.

The chain dead-ended. `get_replay_details` prints an explicit
`get_replay_activity(...)` call to run next, but activity pointed nowhere, so the
structural read was reachable only by knowing it existed and searching the
catalog — while console and vital signals sat in the output already in hand.
Activity now prints a rooted `get_replay_dom(...)` call, so the three steps read
end to end.

The suggestion is conditional. It appears for a rage click, a dead click, or a
hydration error — cases where the user acted and the page did not answer, and the
explanation is the element rather than a response. It stays silent after an
ordinary click or a failed request, because a suggestion on every response is one
nobody reads, and silent when the DOM tool is absent from the session, because
naming an unreachable tool sends the reader after nothing.

A web vital that met its threshold no longer renders as a signal. `good`-rated
LCP beside a 500 and a rage click invites investigation of a metric that is
already fine; kind counts still include it.

The rrweb node id is now carried on the signal rather than only in its rendered
detail line, so the handoff does not depend on parsing prose it also produces.

Descriptions say structural questions exist and which tool answers them, and that
vitals and warnings are context rather than cause.

Verified against a real replay: of 16 signals, 8 carry a node id, and this
recording has no rage clicks, dead clicks, or hydration errors — so the
suggestion correctly stays silent. That means prod has not yet exercised the
trigger; the gating is covered by tests, including one that fails if the
availability guard is removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…l reads

Three findings from manual use.

Network requests never reported a duration. The code read `data.duration`, which
the SDK does not set — `NetworkRequestData` in `@sentry-internal/replay` has no
such field, and real spans carry timing in `startTimestamp`/`endTimestamp`
instead. The line was dead in every recording. Derive the elapsed time from the
span's own bounds and put it in the summary rather than the detail lines: whether
a failure was rejected or timed out is the first question asked of it, so it
belongs on the line every grain shows. An absent bound reports nothing rather
than 0ms, which would claim the request was instant.

A truncated digest was actively misleading. `navigation x2 / click x1` reads as a
whole-session rollup while the rage click, dead click, failed request and console
error sit on later pages — the counts look authoritative and describe a healthier
session than the real one. Say so above the numbers, since the numbers are what
gets believed. Continuation is now a callable `get_replay_activity(...)` rather
than a bare `cursor='...'` the reader has to rebuild a call around, and it names
raising `limit` or windowing as the cheaper alternatives to paging.

The structural read went unused on client-side state questions. The handoff only
fired for a rage click, dead click, or hydration error, but a message that
flashed or a control that never rendered produces no such signal — and an agent
with no pointer goes looking for the explanation in application source instead of
reconstructing the page. State the capability whenever there is a timeline to aim
into, anchored on a failure rather than the first signal, since a leading
navigation is a useless moment to reconstruct. A specific signal still earns a
specific rooted call; this is the fallback, not a replacement.

Two tests here passed against shapes that do not occur: one asserted the
nonexistent `data.duration`, and one could not tell a failure-anchored offset
from a first-signal one. Both were caught by mutation testing and now fail
without the behaviour they cover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An agent asked to confirm the wording shown during checkout read the map,
concluded "the replay tooling only exposes metadata/breadcrumbs — not rendered
DOM text", and went to read application source at a release SHA. Three separate
causes, none of them discovery: `get_replay_dom` already ranks first or second in
tool search for phrasings like "rendered DOM text replay".

The map named only `get_replay_activity`. The chain runs map to activity to DOM,
and the handoff added for the third step lived in activity — which this agent
never called, because the map looked sufficient. The map is where a reader stops,
so it now names the structural read too, anchored on the same moment it suggests
zooming into.

The description understated what a tree contains, in the exact direction that
mattered. It claimed text and form values are masked, so the tool "answers
structural questions, not what a user typed" — which reads as "rendered text is
unavailable" and is wrong. `maskAllText` targets user-entered content: on a real
recording, 22 of 24 text nodes are intact, including product copy like "Session
replay" and "Watch real user sessions to see what went wrong". Only the user's own
details were masked. Reading the wording a user was shown is now stated as a
primary use, with its own example, and the masking note scoped to what it actually
covers.

The agent's conclusion was a positive claim about capability, and nothing in the
map contradicted it. That is the failure mode being fixed: silence about a
capability reads as its absence.

Verified against the SDK rather than assumed — `maskAllText` defaults to true at
the integration level, and the masking function targets text nodes by selector,
which is why static copy survives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 80-character cap was deliberate when the tree was a structural artifact and
text was only a label to identify an element by. It became wrong once confirming
the exact wording a user was shown was a documented use of the tool, and nothing
flagged the contradiction.

Measured against a real onboarding page: product copy runs 60 to 100 characters,
so 80 cuts through the middle of the distribution. The longest line there —
"Catch breaking changes, automatically root cause issues in production, and fix
what you missed." at 95 characters — lost its last two words, which is the worst
available outcome. It looks complete enough to quote and is not.

Rendered text now allows 400 characters. The node budget is what bounds response
size; this only bounds a single pathological node, so it can afford to be
generous.

Form values keep a tighter 80. Values are usually masked to `***`, and an
unmasked one long enough to hit the cap is a payload — a token or a pasted blob —
where the length is the informative part rather than the content.

The cap had no test at all, which is why the number survived a change in what the
tool is for. Both limits are now covered, each verified by a mutation that fails
the new tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mrduncan mrduncan changed the title Improve Replay Tools Improve Replay Tools [hackweek] Aug 19, 2026
- [ ] 9.6 QA against a real organization with the `mcp-qa` skill, since mocks cannot prove the SDK-shape fix. Confirm on a long real replay that the 150-segment and 10MB bounds hold under the Workers memory ceiling, and adjust them if not. `get_replay_dom` is the read most exposed to those bounds, since it must page to the requested moment rather than sampling; check how often a reconstruction refuses on real sessions. Two of the three DOM observations originally filed here no longer gate anything: snapshot frequency is handled by superseding the node map wholesale, so both cases take one path. Still worth capturing: whether real click breadcrumbs populate `payload.data.node.id` consistently, which decides whether rooting is a reliable entry point or best-effort, and how large a real `FullSnapshot` is. None of these block this change.

## 10. DOM Tree Reads

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

DomReconstructor leaks superseded drop counts into final fidelity report

DomReconstructor does not reset dropped counts when a later snapshot supersedes an earlier one, so malformed nodes or unknown-parent drops from the superseded state leak into the final fidelity report and falsely warn that the current tree is incomplete.

Evidence
  • Task 10.2 states a later snapshot supersedes wholesale, matching the ingestSnapshot comment that pre-snapshot mutations described a state the snapshot already supersedes.
  • ingestSnapshot() in packages/mcp-core/src/internal/replay-dom.ts resets nodes, rootId, and mutationsApplied to enforce this, but does not reset this.dropped.
  • result() returns { ...this.dropped }, and formatDomOutput() prints a warning like Dropped 2 operations (2 malformed); the structure below may be incomplete whenever countDropped(dropped) > 0.
  • On recordings with checkoutEveryNms, a clean later snapshot still reports earlier drops in its final output even though those drops do not affect the tree it produced.
  • The existing test "supersedes an earlier snapshot entirely" asserts mutationsApplied is reset but does not assert dropped is reset, leaving the gap uncovered.
Also found at 1 additional location
  • packages/mcp-core/src/internal/replay-dom.ts:231

Identified by Warden · code-review · HDK-H8D

Comment on lines +498 to +510
}
if (typeof data.isChecked === "boolean") {
node.inputChecked = data.isChecked;
}
}

/** Unlinks a node from its parent without deleting it. */
private detach(node: DomNode): void {
if (node.parentId === null) {
return;
}
const parent = this.nodes.get(node.parentId);
if (!parent) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unchecked checkbox still renders [checked] because applyInput leaves stale attributes.checked

When a checkbox that started with checked: true receives an input event with isChecked: false, renderDomTree still emits [checked] from the unchanged attributes.checked.

Evidence
  • applyInput updates node.inputChecked but never touches node.attributes.checked.
  • renderDomTree emits [checked] when either node.inputChecked === true or "checked" in node.attributes.
  • A snapshot with <input checked> sets attributes.checked = true. A subsequent uncheck input event sets inputChecked = false while attributes.checked remains true.
  • The resulting line therefore contains [checked] even though the control was unchecked by the user.

Identified by Warden · code-review · CAG-TCD

Comment on lines +892 to +925
startId: number,
): Set<number> {
const keep = new Set<number>([startId]);

const markAncestors = (id: number) => {
let current = nodes.get(id)?.parentId ?? null;
while (current !== null && !keep.has(current)) {
keep.add(current);
current = nodes.get(current)?.parentId ?? null;
}
};

// Walk the subtree under startId rather than the whole map, so a rooted
// render is not influenced by interactive elements elsewhere in the page.
const stack = [startId];
while (stack.length > 0) {
const id = stack.pop();
if (id === undefined) {
continue;
}
const node = nodes.get(id);
if (!node) {
continue;
}
if (isInteractive(node)) {
keep.add(id);
markAncestors(id);
}
stack.push(...node.childIds);
}

return keep;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Interactive lens renders bare root when no interactive elements exist

The interactive lens always includes the render root in keep, so get_replay_dom outputs a single bare root element instead of the "No interactive elements are present here" message when a page has no interactive controls.

Evidence
  • collectInteractiveAncestry initializes keep with [startId] unconditionally, even when the subtree has no interactive descendants.
  • When no interactive elements exist, keep remains {startId}, so walk renders exactly the root element and skips all children.
  • renderDomTree returns lines.length === 1, which prevents the caller in get-replay-dom.ts (line 336) from triggering the tree.lines.length === 0 branch that prints 'No interactive elements are present here. Try \lens: "full"`.'`
  • The DomLens contract says interactive keeps "interactive elements, plus every ancestor up to the render root so each one has a path"; when there are no interactive elements there are no paths to anchor, so the root should not be kept.

Identified by Warden · code-review · NMJ-VPV

Comment on lines +196 to +216

/** Breadcrumb `payload.category` values, mapped to their event type. */
const CATEGORY_TO_TYPE: Record<string, ReplayEventType> = {
"ui.click": "click",
"ui.multiClick": "multi-click",
navigation: "navigation",
console: "console",
"ui.blur": "ui-blur",
"ui.focus": "ui-focus",
"replay.hydrate-error": "hydration-error",
"replay.mutations": "mutations",
"sentry.feedback": "feedback",
"ui.tap": "tap",
"device.battery": "device-battery",
"device.orientation": "device-orientation",
"device.connectivity": "device-connectivity",
"ui.scroll": "scroll",
"ui.swipe": "swipe",
"app.background": "background",
"app.foreground": "foreground",
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

countReplayKinds misses network errors without a numeric >= 400 statusCode

isErrorEvent counts only numeric status codes >= 400 as network failures, but describeNetworkRequest renders any non-2xx request—including CORS failures with no statusCode—as an error. This causes the replay kind breakdown to undercount network errors compared to the rendered signals.

Evidence
  • TYPE_TO_KIND maps resource-fetch and resource-xhr to network so both reach countReplayKinds and extractReplaySignals.
  • describeNetworkRequest sets isError: true for every request that does not have a numeric 2xx statusCode, including CORS failures that carry no statusCode at all (explicitly tested as "failed with no response").
  • isErrorEvent checks typeof status === "number" && status >= 400, so requests with missing or non-numeric statusCode return false and are excluded from the error count.
  • formatKindBreakdown surfaces network.errors from countReplayKinds, making the undercount visible in the replay Map output.
Also found at 3 additional locations
  • packages/mcp-core/src/internal/replay-events.ts:435
  • packages/mcp-core/src/internal/replay-events.ts:582-584
  • packages/mcp-core/src/tools/catalog/get-replay-details.ts:487-489

Identified by Warden · code-review · VJE-EE3

// A dead or rage click is a failure of the page, not of the request.
isError: verb !== "Clicked",
nodeId,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Dead and rage clicks set isError but are not counted by isErrorEvent

describeClick now returns isError: true for dead and rage clicks, but the unchanged isErrorEvent (used by countReplayKinds) does not recognize any click type as an error. This causes renderDigest and countReplayKinds to disagree on the number of click errors.

Evidence
  • describeClick returns isError: verb !== "Clicked", so dead and rage clicks are marked as errors (line ~799).
  • isErrorEvent at line 575 was unchanged by the hunk; it only handles console, resource-fetch/xhr, and hydration-error, falling through to false for clicks.
  • renderDigest counts errors via signal.isError, while countReplayKinds counts them via isErrorEvent.
  • The fixture already includes dead and rage clicks (tested at line 657 of replay-events.test.ts), so the mismatch is reachable immediately.

Identified by Warden · code-review · AXQ-7KK

Comment on lines +737 to +757
const startMs = Math.max(0, anchor.offsetMs - ERROR_WINDOW_PADDING_MS);
const endMs = anchor.offsetMs + ERROR_WINDOW_PADDING_MS;

return [
`${anchor.label} occurred at ${formatReplayOffset(anchor.offsetMs)}. ${instruction}:`,
formatToolCall({
toolName: "get_replay_activity",
arguments: {
organizationSlug,
replayId,
startMs,
endMs,
grain: "detail",
},
}),
...structuralReadLines({
organizationSlug,
replayId,
atMs: anchor.offsetMs,
context,
}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

buildNextStepLines generates invalid get_replay_activity and get_replay_dom parameters for errors before session start

When findErrorAnchor returns a negative offsetMs (error timestamp before startedAt), the generated tool suggestions pass unclamped negative values into downstream tools. endMs can become negative if the offset exceeds -ERROR_WINDOW_PADDING_MS, and atMs is always passed raw. Both tools enforce min(0) in their Zod schemas and will reject the generated calls.

Evidence
  • buildNextStepLines computes startMs with Math.max(0, anchor.offsetMs - ERROR_WINDOW_PADDING_MS) but leaves endMs as anchor.offsetMs + ERROR_WINDOW_PADDING_MS unclamped.
  • findErrorAnchor computes offsetMs = timestampMs - originMs with no guard that timestampMs >= originMs; an error before session start yields a negative value.
  • If anchor.offsetMs < -ERROR_WINDOW_PADDING_MS, endMs is negative, violating get_replay_activity's schema (endMs: z.number().min(0)) and its explicit validation endMs >= startMs.
  • atMs: anchor.offsetMs is passed directly to structuralReadLines; any negative value violates get_replay_dom's schema (atMs: z.number().min(0)).
  • formatReplayOffset already clamps to Math.max(0, offsetMs) for display, showing the codebase expects and defends against negative offsets, but the generated tool parameters were not given the same treatment.

Identified by Warden · code-review · BUH-Y58

maxNodes: 200,
...params,
} as never,
context,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

callTool test helper overrides maxDepth, defeating the default-depth regression guard

The test that claims to verify the default depth passes maxDepth: 12 via the callTool helper, so it never exercises the handler’s actual default of 40. If the default ever regresses, this test will still pass and hide the regression.

Evidence
  • get-replay-dom.ts defines maxDepth: z.number().min(1).max(200).default(40) as the tool default.
  • The callTool helper introduced in the same file hard-codes maxDepth: 12, overriding the handler default for every invocation.
  • The test "renders a deeply nested real-world page at the default depth" invokes callTool({ atMs: AT_CHECKOUT_ERROR }), so it actually runs at depth 12 instead of 40.
  • Because the fixture tree is shallower than 12, the assertion not.toContain("deeper than \maxDepth`")` passes even though the test does not validate the intended default.
  • On a real page whose tree is 17 levels deep, a default of 12 would clip exactly the interactive elements the lens exists to surface, yet this test would still pass and mask that breakage.

Identified by Warden · code-review · D5S-VDQ

for (const event of segment) {
if (reconstructor.apply(event) === "past-target") {
reachedTarget = true;
return "stop";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Malformed event timestamps bypass the target-time gate and corrupt DOM reconstruction

Malformed replay events whose timestamp is not a number bypass the target-time gate in DomReconstructor.apply and are folded into the DOM even after atMs, producing an incorrect reconstruction.

Evidence
  • ReplayRecordingEventSchema in packages/mcp-core/src/api-client/schema.ts defines timestamp with z.number().optional().catch(undefined), so a non-numeric timestamp (e.g., a scrubbed marker string) parses successfully with timestamp: undefined.
  • DomReconstructor.apply in packages/mcp-core/src/internal/replay-dom.ts checks typeof event.timestamp === "number" before comparing to this.atMs; when the field is undefined the time gate is skipped and the event is processed.
  • Because the streamReplayRecordingSegments callback only returns "stop" on "past-target", a post-target mutation or FullSnapshot with a missing timestamp is applied instead of ending the read, silently advancing the DOM state past the requested moment.
  • replay-dom.test.ts only exercises numeric timestamps, and the fixture segments use well-formed timestamps, so no test guards this path.

Identified by Warden · code-review · N2J-AMX

if (event.type === RRWEB_FULL_SNAPSHOT) {
// A later snapshot supersedes everything applied so far: it is a
// complete state, so replaying earlier mutations onto it would be wrong.
this.ingestSnapshot(event, timestamp);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

FullSnapshot with missing timestamp is ingested but reported as missing

A FullSnapshot whose timestamp is missing or not a number is ingested and populates the node map, yet result() sets missingSnapshot: true. This causes get_replay_dom to refuse rendering and falsely tell the user no snapshot exists.

Evidence
  • apply at lines 167-181 parses event.timestamp as null when it is missing or non-numeric, then calls ingestSnapshot without guarding against null.
  • ingestSnapshot at line 232 overwrites snapshotTimestampMs with null, erasing any earlier valid timestamp.
  • result() at line 207 computes missingSnapshot: this.snapshotTimestampMs === null, so a snapshot that lacked a timestamp is reported as missing.
  • get-replay-dom.ts at line 220 uses missingSnapshot to refuse rendering with the message "No full DOM snapshot appears…", even though reconstruction.nodes is populated.
  • ReplayRecordingEventSchema at line 498 makes timestamp optional, so a FullSnapshot with a missing timestamp can flow through streamReplayRecordingSegments unfiltered.

Identified by Warden · code-review · MDD-M2U

Comment on lines +496 to +498
`console ${consoleCount.total}${consoleCount.errors > 0 ? ` (${consoleCount.errors} error)` : ""}`,
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

formatKindBreakdown uses singular "error" for any console error count

formatKindBreakdown prints console X (N error) even when N > 1, producing ungrammatical user-facing output.

Evidence
  • formatKindBreakdown line 496 appends `(${consoleCount.errors} error)` unconditionally.
  • consoleCount.errors comes from countReplayKinds, which increments for every console event with level === "error", so any replay with multiple console errors triggers the bug.
  • The same file already pluralizes error correctly for omittedIssues (line 351: error${omittedIssues === 1 ? "" : "s"}), confirming the intended pattern exists nearby.

Identified by Warden · code-review · WGT-BCH

@mrduncan mrduncan closed this Aug 26, 2026
@linear-code

linear-code Bot commented Aug 26, 2026

Copy link
Copy Markdown

AIML-3376

This branch was previously deployed

1 inactive deployment
Actions — 242ab6ca Deployed Aug 19, 2026 by mrduncan via eval #1089
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant