feat(runner): instrument the NEED-DATA Sentry issues — setUser, fetch retry diagnostics, editor trail (DEV-2859) - #329
Merged
Conversation
Adds Sentry user context, fetchVersions/fetchDocsJson retry+diagnostics,
and a bounded editor trail, each as an import-free decision module
(identity.ts, fetchDiagnostics.ts, editorTrail.ts) with wiring kept to
App.tsx/main.tsx/auth.ts/catalog.ts, which node --test cannot import.
Item 1 (Sentry.setUser): every Sentry issue in this project reports
"users: 0" because nothing ever called Sentry.setUser/setTag — that
literal absence of user context, not an actual absence of affected
users, is what misled an earlier triage into suppressing DEMOS-2X on
"0 users". Identity is sessionStorage-scoped ("hot_sid"), not
localStorage: localStorage would mint a new persistent pseudonymous
identifier on a public site, which this instrumentation deliberately
avoids. The cost is real and stated in identity.ts: a reload mints a
new id, so `users` on an anonymous issue counts sessions, not people.
A signed-in id is a truncated SHA-256 of the lowercased email, never
the raw address or username.
Item 2 (fetchVersions/fetchDocsJson diagnostics): a bounded retry
(once, after 300ms, only on a transport failure that never produced a
response) is the discriminator between a visitor's own network
dropping mid-request and a real host dip - a dip fails at both
attempts, a blip does not. `!res.ok` is never retried (retrying an
outage amplifies it) and `catalog.ts`'s `versions ${res.status}` throw
stays byte-identical so that population's grouping does not move.
App.tsx reads the attached `fetchDiagnostics` ahead of
`isOpaqueNetworkFailure` on purpose: once the sibling fix in
fix/DEV-2859-opaque-fetch-host-suffix lands, that check starts
matching production wording, and reading it first would silently turn
every exhausted two-attempt failure back into a breadcrumb.
`docs-catalog.ts`'s `fetchDocsJson` was left untouched, not wired
through the same retry helper as originally planned: it cannot import
fetchDiagnostics.ts and stay importable by
pipeline/docs-catalog.test.mjs under --experimental-strip-types, which
cannot resolve a sibling ./x.js specifier (verified empirically against
a throwaway probe file). The DEMOS-7D ruling (instrument, don't
suppress) is instead satisfied by wrapping the two existing App.tsx
reportError callsites in Sentry.withScope + diagnosticTags with a
thinner, retry-less diagnostics bundle - no re-promotion, no
fingerprint change, no new issue.
Item 3 (DEMOS-1D editor trail): replaces a rejected
one-breadcrumb-per-onEdit design. sentry.ts caps breadcrumbs at 200; a
"Maximum update depth exceeded" loop calling onEdit thousands of times
would fill every slot with identical entries and evict the preceding
context that identifies the trigger - the only evidence this change
exists to collect. A capacity-24 ring buffer with consecutive-identical
coalescing fixes that: a 3000-iteration loop occupies one slot, and
whatever happened just before it survives. Two fields from the
original ticket are deliberately absent: a version-switch never
reaches onEdit (it re-pins files via setFiles, its own "version" trail
entry) and the active editor tab is unreachable from App.tsx - it
lives inside packages/editor-shell, and adding a prop to a published
package's surface for a temporary diagnostic was out of scope. Also
fixed before landing: editorTrailTags() was reading the bare last
entry, which is always a "flush-quiet" after a Style-panel colour
drag - that hid the coalesced run of quiet writes one slot earlier,
which is the exact signal DEMOS-1D exists to read. It now walks back
past trailing flush-quiet entries.
Tests: pipeline/identity.test.mjs, pipeline/fetch-diagnostics.test.mjs,
pipeline/editor-trail.test.mjs (new), plus one added case in
pipeline/docs-catalog.test.mjs pinning that a transport failure is not
misclassified as a missing resource. All wiring in App.tsx, main.tsx,
auth.ts and catalog.ts is an honest, undecorated coverage gap - none of
it is node-importable, and no text-grep assertions were added to fake
coverage of it.
Does not touch apps/authoring/src/fetchFailure.ts or
pipeline/fetch-failure.test.mjs - owned by the parallel
fix/DEV-2859-opaque-fetch-host-suffix branch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…numerability (DEV-2859) Two review findings, both small but both undercutting what the instrumentation exists for. `withDocsFetchDiagnostics` was applied to both branches of the docs catches, so a missing-artifact failure (a 404 from a server we plainly reached) was tagged `context: "docs-fetch"` alongside a genuine transport failure. `onlineAtStart` and `apiBaseOrigin` answer "was the visitor offline / is this build pointing at localhost", and neither means anything for a missing artifact — tagging both put two different faults under one `context` and defeated the DEMOS-7D filtering the tags are for. Now gated on the transport sub-case, with `isMissingDocsResource` evaluated once per catch instead of three times. And the non-enumerability of the attached `fetchDiagnostics` property was only inferred from `name`/`message` staying intact. It is load-bearing: if it became enumerable it would surface in `JSON.stringify(error)` and in any spread, changing what downstream reporting sees — and that is exactly the kind of thing a refactor flips silently. Pinned directly via `getOwnPropertyDescriptor`, verified to go red under `enumerable: true` (13/14), and asserting the accessor still reads it so non-enumerable does not become unreachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 5350bc4. Configure here.
… test (DEV-2859) Two findings from Bugbot on #329, both confirmed against the source. The docs `context` tag was silently overwritten. `withDocsFetchDiagnostics` set `context: "docs-fetch"` on the scope, but `run()` is a `reportError` call and that captures with `{ tags: { context } }` of its own — an event-level tag beats a scope one. So the wrapper appeared to tag a population it never tagged. The key is now dropped explicitly, with a comment saying why. Nothing is lost: the population is still identifiable by `reportError`'s own `docs-example-load:fetch` value and by the `docs_fetch_*` tag names `prefixFor` derives, neither of which collides. The non-enumerability test never used its mocks. It passed `{ fetch, now, sleep, onLine }` as the second argument (`init`) instead of the third (`deps`), with the wrong key names — the real ones are `fetchFn` and `isOnline`. So it ran the real `fetch` against example.test with a real 300ms sleep and a real 5s abort, and passed anyway, because a genuine network failure also throws with diagnostics attached. It asserted the right invariant for the wrong reason and could stall or flake in CI. Fixed to the idiom the rest of the file already uses, and given a `fetchCalls` counter asserting two attempts — so the test now proves it went through the injected mock rather than the network. Verified both ways: `enumerable: true` fails it (13/14), and restoring the original wrong-slot mocks also fails it (13/14), so it would have caught its own defect. File duration dropped from seconds to 65ms, which is the same fact from the other side. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

The instrumentation half of DEV-2859. Item 0 (the
Failed to fetchclassifier fix) already shipped as #327; this is what makes the remaining questions answerable.Three Sentry issues were left NEED-DATA by the DEV-2852 triage because they are not diagnosable from current telemetry. Precedent: DEV-2559's session-start tags resolved DEMOS-9, a 786-event mystery, and the decisive tag turned out to be
cf_ray's absence.Item 1 —
Sentry.setUser()was missing app-wideThere was no
Sentry.setUsercall anywhere. EverysetUserinapps/**is React local state. NosendDefaultPii, so not even anip_addressfallback identity.users: 0was therefore a literal statement that no user context exists — not that nobody was affected. That misled this very triage: DEMOS-2X was initially classified SUPPRESS partly on "0 users", and re-checking with trace ids showed 26 sampled events across 26 distinct traces on two separate days. That is an availability shape, not one visitor's blip.Two-stage, because anonymous traffic is the majority: a synchronous seed in
main.tsxbeforecreateRoot(so an early module-evaluation crash already has an identity), then an upgrade inauth.ts'scurrentUser()— the single funnel every route uses — with resets on the 401 andclearSession()paths.currentUser()is deliberately not called frommain.tsx: it round-trips the Render-hosted broker, and routes that need no identity shouldn't pay that latency.Privacy: the signed-in id is
"u_" + sha256(trimmed, lowercased email).slice(0,16). Noemail, nousername, no raw address ever set; the dev-bypass address is hashed like any other. Stated plainly: over a known internal address list a truncated hash is reversible by us — the goal is "don't ship addresses to a third party", not anonymisation.The anonymous id is
"s_" + randomin sessionStorage, not localStorage, on purpose: localStorage would be a new persistent pseudonymous identifier on a public site. The cost of that choice: a reload mints a new id, souserson an anonymous issue counts sessions, not people. It's a floor, not a headcount — and it does not by itself answer "one visitor or 26" for DEMOS-2X. Item 2's attempt count does.Plus an
auth_modetag (anonymous|google|api-token|dev-bypass), which doubles as a tripwire for the exact bundle leak thedistgrep in AGENTS.md exists to catch.Everything is behind the existing
reportingEnabledguard, so nothing runs and no storage key is written undernavigator.webdriver.Item 2 —
fetchVersionsdiagnostics, with the retry as the discriminatorfetchFailure.tsonly ever classified; it has no measurement and no remedy. A bareFailed to fetchcarries no status code because the request never completed, so nothing separated a visitor blip from a dip in ours.level: "warning", flat fingerprint!res.ok— our host answered → never retried, because retrying amplifies a real dip into a self-inflicted oneAbortController(copied fromcheckVersionExists); a timeout does not retry either, so it can't stall the picker for 10s or storm during an outageTags:
versions_fetch_attempts(the decisive one — a dip fails twice, a blip doesn't),versions_fetch_outcome,versions_fetch_online,versions_fetch_elapsed_bucket,net_effective_type, andapi_base_origin.That last one is a new candidate cause: a missing
VITE_API_BASEleaves thehttp://localhost:8787fallback in the production bundle, which fails for every visitor on that build. Alocalhostvalue there would settle DEMOS-2X outright.This can't copy DEV-2559's shape —
cf_rayis structurally unavailable when nothing arrives. Attempts + online + elapsed bucket + API-base origin are its substitutes.Critically, the rethrown error stays classifiable: on exhaustion the original last error is rethrown with
fetchDiagnosticsattached non-enumerably andname/messageuntouched, so #327'sisOpaqueNetworkFailurestill matches and DEMOS-2X does not regress.catalog.ts'sversions ${res.status}message is byte-identical to master so that population's grouping doesn't move.Item 3 — DEMOS-1D editor trail
The obvious design is wrong and is not what shipped.
sentry.tssetsmaxBreadcrumbs: 200. A "Maximum update depth exceeded" loop callsonEditthousands of times, so one breadcrumb per call would fill all 200 slots with identical entries and evict the preceding context that identifies the trigger — destroying the only evidence the change exists to collect.Instead: a bounded ring buffer (capacity 24) with consecutive-identical coalescing, so a 3000-iteration loop occupies one slot carrying
n: 3000and the entry before it survives. Flushed once at capture viaSentry.addEventProcessor, which skipssurface === "demo-runtime"so relayed preview events stay clean.Two fields from the original ticket were wrong and are not here.
version-switchnever reachesonEdit(a version change re-pins viasetFiles— its own trail entry). And the active tab is unreachable fromApp.tsx: it lives inpackages/editor-shell, and adding a prop to a published package's surface for a temporary diagnostic is the arrangementsessionDiagnostics.tsexplicitly refuses.path+seqsubstitute;packages/is untouched.Source is discriminated at the App-owned callsites, not inside
onEdit:editor,style,style-reset,ai,ai-undo.Privacy: records
size = contents.length, nevercontents—onEdit's second argument is the visitor's own source code.pathis visitor-authored too, so it's capped at 120 chars and excluded from every fingerprint. TheloadWorkspaceentry records the lineage prefix only (before the first:) so an?import=<url>lineage can't carry a URL.No
onCaughtError: Sentry.reactErrorHandler()—Sentry.ErrorBoundaryalready captures the component stack, and adding that handler would double-report every boundary crash and corrupt the 17-event baseline this instrumentation exists to read. (React 19 owner stacks are dev-only and unavailable from a production bundle, which is why breadcrumbs are the only route — a different reason than the ticket assumed.) No fingerprint change for DEMOS-1D either: it's substatus-regressed and must keep receiving events into the same issue.Two bugs found before landing
editorTrailTags()read the bare last buffer entry — but a Style-panel colour drag always ends with aflush-quietentry, which masked the coalesced run of quiet style writes underneath it. That run is exactly the DEMOS-1D signal. Now walks back past trailingflush-quiet, with two tests.visitorIdguarded throwing storage methods, but reading the globalsessionStorageaccessor can itself throw under a storage-denial policy — which would have white-screened the app. Guarded bysafeSessionStorage().Verification
pnpm test—1125 tests / 1123 pass / 0 fail / 2 todo(todos pre-existing)pnpm typecheck— clean across all four packagesidentity10,fetch-diagnostics14,editor-trail12grep -rl "localhost:8787\|VITE_DEV_USER\|dev@handsontable.com" dist→ no matchesTwo assertions are real privacy tests, not guards:
hashedUserId's output contains no@and no substring of the input local-part; and a sentinel passed ascontentsdoes not appear insnapshotEditorTrail(). The retry-storm guard asserts exactly one fetch call on!res.ok. The ring-buffer test proves 3000 pushes collapse to one entry and the pre-loop trigger survives.Review follow-ups, in the second commit
withDocsFetchDiagnosticswas applied to both branches of the docs catches, so a missing-artifact 404 got taggedcontext: "docs-fetch"next to a genuine transport failure — putting two faults under one context and defeating the DEMOS-7D filtering the tags exist for. Now gated on the transport sub-case only.And the non-enumerability of
fetchDiagnosticswas only inferred fromname/messagestaying intact. It's pinned directly now, verified to go red underenumerable: true.Known gap, stated rather than hidden
The wiring in
App.tsx,main.tsx,auth.tsandcatalog.tshas no test coverage — none is importable bynode --test(@sentry/react,import.meta.env, and a bare JSON import node can't resolve). Every decision lives in an import-free module that is tested; only wiring sits in those files. No text-grep assertions were added to fake it.Related:
docs-catalog.ts'sfetchDocsJsonwas deliberately not routed through the new helper. A sibling./x.jsimport doesn't resolve undernode --experimental-strip-types, which would have broken that file's own direct-import test seam — so DEMOS-7D is served at theApp.tsxcallsites with a thinner bundle (online + API-base classification, no attempts/outcome). Thin, but it still answers two concrete single-cause questions: visitor offline, orVITE_API_BASEmisconfigured to localhost in prod.🤖 Generated with Claude Code
Note
Medium Risk
Touches auth/session reporting, startup fetch retry timing, and every edit path; mistakes could leak PII, double-report, or mis-classify outages, though guards (reportingEnabled, non-enumerable diagnostics, no contents in trail) are explicit.
Overview
Adds DEV-2859 observability so three NEED-DATA Sentry populations become diagnosable without changing product behavior (versions still fail open; docs errors still split path vs fetch).
Identity: Seeds anonymous
Sentry.setUser+auth_modebeforecreateRoot, upgrades to a truncated email hash oncurrentUser()success, and resets on 401/clearSession()— all behindreportingEnabled, with session-scoped visitor ids insessionStorage.Versions fetch: New
fetchWithDiagnostics(one transport retry, 5s timeout, no retry on!res.ok/timeout) powersfetchVersions; exhausted failures emit a once-per-page-load warning with attempt/outcome/online/API-base tags, while retries that recover log breadcrumbs only. Docs transport failures get thinner scope tags viawithDocsFetchDiagnosticswithout changingfetchDocsJson.Editor trail: A bounded, coalescing trail records edits/workspace loads/version repins/style/chat/flush events (path redacted/capped, size only);
main.tsxattaches trail tags/extras to outgoing events (skippingdemo-runtime).onEditis split into surface-specific wrappers wired toStylePanelandChatPanel(isUndoon chat undo).Reviewed by Cursor Bugbot for commit d936082. Bugbot is set up for automated code reviews on this repo. Configure here.