Skip to content

feat(runner): instrument the NEED-DATA Sentry issues — setUser, fetch retry diagnostics, editor trail (DEV-2859) - #329

Merged
demtario merged 3 commits into
masterfrom
feat/DEV-2859-need-data-instrumentation
Sep 9, 2026
Merged

feat(runner): instrument the NEED-DATA Sentry issues — setUser, fetch retry diagnostics, editor trail (DEV-2859)#329
demtario merged 3 commits into
masterfrom
feat/DEV-2859-need-data-instrumentation

Conversation

@demtario

@demtario demtario commented Sep 9, 2026

Copy link
Copy Markdown
Member

The instrumentation half of DEV-2859. Item 0 (the Failed to fetch classifier 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-wide

There was no Sentry.setUser call anywhere. Every setUser in apps/** is React local state. No sendDefaultPii, so not even an ip_address fallback identity.

users: 0 was 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.tsx before createRoot (so an early module-evaluation crash already has an identity), then an upgrade in auth.ts's currentUser() — the single funnel every route uses — with resets on the 401 and clearSession() paths. currentUser() is deliberately not called from main.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). No email, no username, 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_" + random in 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, so users on 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_mode tag (anonymous | google | api-token | dev-bypass), which doubles as a tripwire for the exact bundle leak the dist grep in AGENTS.md exists to catch.

Everything is behind the existing reportingEnabled guard, so nothing runs and no storage key is written under navigator.webdriver.

Item 2 — fetchVersions diagnostics, with the retry as the discriminator

fetchFailure.ts only ever classified; it has no measurement and no remedy. A bare Failed to fetch carries no status code because the request never completed, so nothing separated a visitor blip from a dip in ours.

  • attempt 1 fails → retry once after 300ms → succeeds: breadcrumb only (the blip population stays silent)
  • both fail → one synthesized event per page load (module latch), level: "warning", flat fingerprint
  • !res.ok — our host answered → never retried, because retrying amplifies a real dip into a self-inflicted one
  • per-attempt 5s AbortController (copied from checkVersionExists); a timeout does not retry either, so it can't stall the picker for 10s or storm during an outage

Tags: 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, and api_base_origin.

That last one is a new candidate cause: a missing VITE_API_BASE leaves the http://localhost:8787 fallback in the production bundle, which fails for every visitor on that build. A localhost value there would settle DEMOS-2X outright.

This can't copy DEV-2559's shape — cf_ray is 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 fetchDiagnostics attached non-enumerably and name/message untouched, so #327's isOpaqueNetworkFailure still matches and DEMOS-2X does not regress. catalog.ts's versions ${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.ts sets maxBreadcrumbs: 200. A "Maximum update depth exceeded" loop calls onEdit thousands 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: 3000 and the entry before it survives. Flushed once at capture via Sentry.addEventProcessor, which skips surface === "demo-runtime" so relayed preview events stay clean.

Two fields from the original ticket were wrong and are not here. version-switch never reaches onEdit (a version change re-pins via setFiles — its own trail entry). And the active tab is unreachable from App.tsx: it lives in packages/editor-shell, and adding a prop to a published package's surface for a temporary diagnostic is the arrangement sessionDiagnostics.ts explicitly refuses. path + seq substitute; 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, never contentsonEdit's second argument is the visitor's own source code. path is visitor-authored too, so it's capped at 120 chars and excluded from every fingerprint. The loadWorkspace entry records the lineage prefix only (before the first :) so an ?import=<url> lineage can't carry a URL.

No onCaughtError: Sentry.reactErrorHandler()Sentry.ErrorBoundary already 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 a flush-quiet entry, which masked the coalesced run of quiet style writes underneath it. That run is exactly the DEMOS-1D signal. Now walks back past trailing flush-quiet, with two tests.

visitorId guarded throwing storage methods, but reading the global sessionStorage accessor can itself throw under a storage-denial policy — which would have white-screened the app. Guarded by safeSessionStorage().

Verification

  • pnpm test1125 tests / 1123 pass / 0 fail / 2 todo (todos pre-existing)
  • pnpm typecheck — clean across all four packages
  • New specs: identity 10, fetch-diagnostics 14, editor-trail 12
  • Bundle hygiene: production build, grep -rl "localhost:8787\|VITE_DEV_USER\|dev@handsontable.com" dist → no matches

Two assertions are real privacy tests, not guards: hashedUserId's output contains no @ and no substring of the input local-part; and a sentinel passed as contents does not appear in snapshotEditorTrail(). 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

withDocsFetchDiagnostics was applied to both branches of the docs catches, so a missing-artifact 404 got tagged context: "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 fetchDiagnostics was only inferred from name/message staying intact. It's pinned directly now, verified to go red under enumerable: true.

Known gap, stated rather than hidden

The wiring in App.tsx, main.tsx, auth.ts and catalog.ts has no test coverage — none is importable by node --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's fetchDocsJson was deliberately not routed through the new helper. A sibling ./x.js import doesn't resolve under node --experimental-strip-types, which would have broken that file's own direct-import test seam — so DEMOS-7D is served at the App.tsx callsites with a thinner bundle (online + API-base classification, no attempts/outcome). Thin, but it still answers two concrete single-cause questions: visitor offline, or VITE_API_BASE misconfigured 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_mode before createRoot, upgrades to a truncated email hash on currentUser() success, and resets on 401/clearSession() — all behind reportingEnabled, with session-scoped visitor ids in sessionStorage.

Versions fetch: New fetchWithDiagnostics (one transport retry, 5s timeout, no retry on !res.ok/timeout) powers fetchVersions; 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 via withDocsFetchDiagnostics without changing fetchDocsJson.

Editor trail: A bounded, coalescing trail records edits/workspace loads/version repins/style/chat/flush events (path redacted/capped, size only); main.tsx attaches trail tags/extras to outgoing events (skipping demo-runtime). onEdit is split into surface-specific wrappers wired to StylePanel and ChatPanel (isUndo on chat undo).

Reviewed by Cursor Bugbot for commit d936082. Bugbot is set up for automated code reviews on this repo. Configure here.

demtario and others added 2 commits September 9, 2026 12:06
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>
@demtario demtario self-assigned this Sep 9, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ 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.

Comment thread runner/apps/authoring/src/App.tsx
Comment thread runner/pipeline/fetch-diagnostics.test.mjs
… 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>
@demtario
demtario merged commit 4aff516 into master Sep 9, 2026
6 checks passed
@demtario
demtario deleted the feat/DEV-2859-need-data-instrumentation branch September 9, 2026 10:34
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.

2 participants