Skip to content

Report each failed route load to RUM once, and offer Try Again when the server errors or can't be reached - #1809

Merged
dawsontoth merged 4 commits into
claude/1293-forbidden-not-found-error-pagefrom
claude/1695-one-report-per-route-failure
Oct 12, 2026
Merged

dawsontoth merged 4 commits into
claude/1293-forbidden-not-found-error-pagefrom
claude/1695-one-report-per-route-failure

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #1804 (claude/1293-forbidden-not-found-error-page). This PR's base is that branch, so the diff here is only the #1695 work. Merge #1804 first, then retarget this PR to stage. Don't squash-merge #1804 while this branch is built on it.

⊙ Problem

One failed route load reaches Datadog RUM as up to 14 identical error-boundary reports (#1695). 30 days showed 170 @error.source:custom events across 84 sessions, every one a data-fetch failure that reached a route error boundary. Because Datadog fingerprints by stack, the burst also inflates one Error Tracking issue roughly 5×, so "is this getting worse?" can't be answered from it. On top of that, each React root handler also console.errors the same error, which the RUM SDK files as a second, console-sourced event. And a 5xx or network failure lands on a dead-end card with no way to retry.

❓ Your call: Is a client-side dedupe warranted, rather than accepting the burst and fixing it in Datadog queries? I think so: the duplicates come from our own root handler, the fix is local, and nothing downstream can tell a re-catch from a new failure. The window (10 s) and cap (100 signatures) are judgment calls. They're constants in one file, so tightening or loosening them is trivial.

Closes #1695

💡 Solution

  • Why it repeats. TanStack Router's CatchBoundary resets whenever the match object changes (getResetKey={() => match}, @tanstack/react-router/src/Match.tsx) and MatchInner re-throws the stored match.error, so one error is caught again on each match update. Each reload (AppRouted calls router.invalidate() on every auth change) also re-runs beforeLoad and throws a fresh but identical AxiosError. Driving the real router with one org 403 plus two invalidates, the old handler called addReactError 10 times across 4 distinct error objects.
  • One report per failure. The root handlers send each caught error through reportReactError, which skips a failure whose signature was reported in the last 10 s.
  • No second console-sourced copy. Devtools logging in the root handlers and in two local boundaries moves from console.error to console.warn. The SDK only collects console.error (browser-rum-core errorCollection.mjs, ConsoleApiName.error), and warn still shows in devtools by default, unlike debug.
  • Try Again. A route error page for a 5xx, or for a request that got no answer, says so in words and offers Try Again, which reloads the route. 4xx, cancellations and code errors get no retry. The generic title becomes "Something Went Wrong".

⚠️ Look hardest: whether the signature can merge two failures a human would want counted separately. Same name, message, stack and component stack, same endpoint, within 10 s, is my definition of "the same failure".

❓ Your call: React Query's global errorHandler still console.errors every failed fetch, so a route-load failure is still one custom event plus one console event per HTTP attempt, and it also toasts over the error page. Codex kept this as a major through both review rounds. I left it out on purpose: AGENTS.md documents that console.error as the RUM channel for handled rejections, and it's shared by every query, so telling a route load apart from a background fetch needs its own decision. Filed as #1808 with the options. If you'd rather fold it in here, say so.

🔧 Changes

  • src/integrations/datadog/reportReactError.ts (new): signatureOf builds the key from name, message, stack, component stack and, for an axios error, method/baseURL/url, so two different endpoints failing the same way stay separate. A thrown non-Error has none of those fields, so contentsOf adds its serialized contents, or its keys when it can't be serialized. The key never leaves memory. remember expires and evicts oldest-first, at most 100 entries. reportReactError reports without dedupe if computing a signature throws. A throw from addReactError itself is swallowed and the signature stays unrecorded, so the root callback never throws on top of the failure it was handed and the next catch tries again. reactRootErrorHandlers holds the three root callbacks in a side-effect-free module, so a test can drive them, and logs with console.warn.
  • src/main.tsx: createRoot takes reactRootErrorHandlers instead of inline handlers that called addReactError and then console.error.
  • src/components/ErrorComponent.tsx: describeFailure adds copy for a 5xx and for ERR_NETWORK/ECONNABORTED/ETIMEDOUT. The reset prop, which TanStack passes only to a route error component, gates Try Again. RetryButton calls router.invalidate() (a reset alone re-renders a match that still holds the error) and is disabled, with its icon spinning, while the reload is pending. Route loads are reads, so a retry repeats no write. Generic title: "Something Went Wrong".
  • ClusterRouteError.tsx and OrgRouteError.tsx forward reset to the generic page (cluster, org), so their non-403 failures get the retry too.
  • PanelErrorBoundary.tsx and MetricRenderer.tsx: each componentDidCatch console.errored a failure the root onCaughtError already reports, and now uses console.warn (panel, metric). This was the planning review's catch.
  • AGENTS.md: the boundary rule sits next to the existing console.error note. src/testSetup/failOnRenderPhaseUpdate.ts: its comment no longer cites the panel log as a console.error user.

⚖️ Alternatives

❓ Your call: The planning review returned better-alternative-exists. I adopted the variant it described: the two boundary-local console.error producers I had missed, a bounded and guarded signature with endpoint identity, and no permanent identity WeakSet (a module-level singleton error would never have been reported again).

  • Dedupe in beforeSend: rejected. The duplication is produced entirely by our React handlers, and beforeSend would need the same signature state for every event type. The concurrent [RUM] error.causes[] bypasses every redaction layer in beforeSend #1665 work is also editing beforeSend.ts.
  • Stop TanStack re-catching, or don't throw for expected statuses: rejected. The re-catch is router internals, and not throwing a 403 would hide it from Error Tracking, against the deliberate policy that 403s stay visible (shouldKeepEvent.ts, [RUM] Persistent AxiosError 401 on instance operation polling endpoint #1386).
  • Only drop the console.error, or only an identity set: rejected. The probe showed 10 catches across 4 distinct objects, so either alone still reports the burst.
  • Retry only the failed match (router.invalidate({ filter })): not done. Try Again reloads every match, so it also re-fetches healthy parents (org, cluster). That costs a few reads per click and is easy to narrow later.

✅ Verification

  • routeErrorReporting.test.tsx drives the real route chain down to the org layout, with addReactError mocked. One 403 plus two router.invalidate()s yields one addReactError call and no root-handler console.error. A 503 shows Try Again; once the server answers, clicking it re-reads and renders the page. A 403 offers no Try Again.
  • Fails on base: with onCaughtError restored to the base body (addReactError + console.error), that test fails with expected "vi.fn()" to be called 1 times, but got 10 times. Restoring only the console.error fails it on the console assertion. Reverting PanelErrorBoundary to console.error fails its new test.
  • reportReactError.test.ts covers identity and fresh-identical repeats, window expiry, a different message/endpoint/name/component stack, primitives of different types, plain objects with different contents (and a cyclic one that still dedupes), a proxy whose getters throw, an addReactError that throws (swallowed, unrecorded, original still warned), and the 100-signature cap.
  • ErrorComponent.test.tsx covers 5xx and the three no-response codes with reset, no retry for 400/403/404/cancel/TypeError/string, no retry without reset, and one invalidate per click with the button disabled while pending and its icon spinning until the reload settles. ClusterRouteError.test.tsx checks reset is forwarded, OrgRouteError.test.tsx checks a 500 on the org route offers Try Again, and PanelErrorBoundary.test.tsx checks the panel logs with warn.
  • Gates: vitest run 400 files / 3,711 tests pass (pre-commit hook on dbc2bc20; the hook on 05181160 timed out in the unrelated editorApi and NewApplication suites under load, both pass alone, and that commit ran commitlint, dprint check --staged and oxlint by hand), tsc -b 0, oxlint 0, dprint check 0, vite build 0. One earlier message-only amend skipped the hook because the machine's load average hit ~590 and unrelated suites (editorApi, NewApplication, Scaling) timed out. Those pass in isolation, and the tree was identical to the one that had just passed.
  • Cross-model review: codex (graded) and gemini, two rounds against the Describe 403 and 404 route failures in words, and tell a non-member they have no access to an organization #1804 branch. The Harper domain leg failed auth both rounds, and the Cursor leg failed no-receipt on the 1Password SSH agent (round 1; pruned in round 2), so I triaged the outside findings myself. Resolved: the SDK-throw escape (codex minor / gemini major). Kept open: codex's React Query channel major (above, A failed route load is reported to RUM twice and shown twice — React Query's console.error and toast duplicate the route error page #1808). Declined: gemini's .finally() without .catch() on router.invalidate(), because a rejection there is a real router failure and should reach RUM as an unhandled rejection rather than be swallowed. Comment nits: the ticket reference came out of code; the remaining comments state why (warn not error, invalidate not reset).
  • Round 3 (full, 05181160, after the gemini-code-assist fixes below): codex (graded), gemini and the Harper domain leg, independent=true, Adjudicated-Severity: major. That major is the React Query channel above, kept and unchanged (A failed route load is reported to RUM twice and shown twice — React Query's console.error and toast duplicate the route error page #1808; Show a failed page load only on its error page, without a duplicate toast or RUM report #1878 may own it). The domain leg also kept codex's RetryButton point at minor: a rejected router.invalidate() goes unhandled, and a synchronous throw would leave the button disabled. I left both as they are. The rejection reaching RUM is the earlier deliberate call. The synchronous throw can only come from router-core's own bookkeeping, since invalidate just maps the committed matches and returns the async load() (@tanstack/router-core router.js:484,509-554). The comment nit (gemini, domain) was declined for the same reason as before: the remaining comments each state a constraint that a later edit would break.
  • gemini-code-assist feedback: fixed, plain objects with different contents collapsing into one signature (dbc2bc20) and the Try Again icon not showing the pending reload (05181160). Declined: making errorInfo optional, because React always passes it (@types/react-dom/client.d.ts) and the componentStack read is already inside the signature's try.
  • End-to-end route: real-router jsdom tests with the SDK call mocked. This proves app-to-SDK invocation, not delivery to Datadog. Confirm in RUM after deploy that @error.source:custom per affected session drops toward 1. Not checked in a browser: the shared 5173 preview belonged to another session.

🤖 Generated by Claude Code (Claude Opus 5.5); posted via @dawsontoth.

Related PRs: #1804 overlaps (this PR is stacked on it), #1878 overlaps (its route-query claims would close the React Query channel kept open here, #1808), 1 others independent
Complexity: medium

Review-Coverage: authored=claude; ran=gemini,codex; adjudicated=domain; blocked=cursor-composer(no-receipt); declined=cursor-grok,cursor-kimi,cursor-muse; rounds=3; full=1 @ 0518116

Review-Attention: study ~9m (decisions: do-less-alternative, dedup-window-semantics, dedup-layer, retry-scope, retry-gating, generic-title; raised: open major) @ 0518116

dawsontoth and others added 2 commits October 10, 2026 22:30
…in for transient ones

One failed route load reached Datadog RUM as up to 14 identical
error-boundary reports (#1695). TanStack Router re-throws a route's stored
error whenever its match object changes, and every reload of the route
throws a fresh but identical one, so React's root onCaughtError fired once
per catch, not once per failure. Driving the real router with a 403 on the
org route, the old handler reported 10 times for one failure.

The root handlers now go through reportReactError, which skips a failure
identical in name, message, stack, component stack and request endpoint
to one reported in the last 10 s (at most 100 signatures held, oldest
evicted). A value whose fields throw is still reported, just not deduped.

The handlers' devtools copy moves from console.error to console.warn: the
RUM SDK reports every console.error as an error of its own, so each
boundary catch was also landing as a second, console-sourced event.
PanelErrorBoundary and MetricErrorBoundary had the same duplicate in
componentDidCatch and move to console.warn too. AGENTS.md records the
rule next to the existing console.error note.

A route error page for a 5xx or a request that got no answer (ERR_NETWORK,
ECONNABORTED, ETIMEDOUT) now says so in words and offers Try Again, which
reloads the route via router.invalidate() and is disabled while that
reload is pending. 4xx, cancellations and code errors get no retry. The
generic title becomes "Something Went Wrong" instead of "Component Error".

Closes #1695

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…SDK does

A throw from addReactError escaped the root onCaughtError callback and
skipped the devtools line for the failure it was reporting. reportReactError
now swallows it and leaves the signature unrecorded so the next catch tries
again. The real-router test name now says what it proves: one report from
the root handlers. React Query's per-attempt console.error is still a
second channel, filed as #1808.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a deduplication mechanism for React root errors reported to Datadog RUM, preventing repeated TanStack Router route-load errors from flooding logs. It also refactors the ErrorComponent to support a 'Try Again' button for retryable failures (such as 5xx and network errors) and updates error boundaries to log via console.warn instead of console.error to avoid duplicate reporting. Feedback on these changes includes serializing custom plain objects or arrays in error signatures to prevent incorrect deduplication, making errorInfo optional in reportReactError to avoid potential runtime TypeErrors, and adding a spin animation to the retry icon for improved visual feedback.

Comment on lines +61 to +81
export function reportReactError(error: unknown, errorInfo: ErrorInfo): void {
let signature: string | undefined;
try {
signature = signatureOf(error, errorInfo.componentStack);
} catch {
signature = undefined;
}
const now = performance.now();
if (signature !== undefined) {
const last = lastReportedAt.get(signature);
if (last !== undefined && now - last < REPEAT_WINDOW_MS) { return; }
}
try {
addReactError(error as Error, errorInfo);
} catch {
// The root callback must not throw on top of the failure it was handed; an unrecorded signature
// lets the next catch try again.
return;
}
if (signature !== undefined) { remember(signature, now); }
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

To prevent potential runtime TypeErrors if reportReactError is called with an undefined or null errorInfo (e.g., in tests or custom error triggers), we should use optional chaining when accessing errorInfo.componentStack and make errorInfo optional in the function signature.

export function reportReactError(error: unknown, errorInfo?: ErrorInfo): void {
	let signature: string | undefined;
	try {
		signature = signatureOf(error, errorInfo?.componentStack);
	} catch {
		signature = undefined;
	}
	const now = performance.now();
	if (signature !== undefined) {
		const last = lastReportedAt.get(signature);
		if (last !== undefined && now - last < REPEAT_WINDOW_MS) { return; }
	}
	try {
		addReactError(error as Error, errorInfo);
	} catch {
		// The root callback must not throw on top of the failure it was handed; an unrecorded signature
		// lets the next catch try again.
		return;
	}
	if (signature !== undefined) { remember(signature, now); }
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Declined. The only production caller is React itself, through reactRootErrorHandlers (main.tsx:28), and React's root callbacks always receive an errorInfo object: @types/react-dom/client.d.ts:22-32 types it as required on onUncaughtError, onCaughtError and onRecoverableError, and the handlers are checked against that with satisfies RootOptions. Even a missing one couldn't throw here, because the errorInfo.componentStack read sits inside the try (reportReactError.ts:74-78): the signature becomes undefined and the error is still reported, just not deduplicated.

🤖 Generated by Claude Code (Claude Opus 5.5); posted via @dawsontoth.

Comment thread src/components/ErrorComponent.tsx Resolved
@github-actions

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 68.57% 10529 / 15355
🔵 Statements 68.74% 11245 / 16358
🔵 Functions 62.2% 2737 / 4400
🔵 Branches 63.95% 8092 / 12653
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/components/ErrorComponent.tsx 95.74% 93.33% 100% 100% 114, 115
src/features/cluster/components/ClusterRouteError.tsx 100% 100% 100% 100%
src/features/instance/status/analytics/primitives/MetricRenderer.tsx 65.3% 58.33% 88.88% 65.3% 24-37, 87-91, 170, 194-203, 205-215, 217-223, 234
src/features/instance/status/analytics/tabs/PanelErrorBoundary.tsx 100% 83.33% 100% 100%
src/features/organization/components/OrgRouteError.tsx 100% 100% 100% 100%
src/integrations/datadog/reportReactError.ts 90.69% 86.66% 75% 89.47% 100-101, 108-109
Generated in workflow #2166 for commit 0518116 by the Vitest Coverage Report Action

dawsontoth and others added 2 commits October 12, 2026 10:54
… deduplicating them

Two non-Error values thrown within the repeat window, such as { error: 'Unauthorized' } and { error: 'Forbidden' }, had no name, message, stack or config to differ on, so the second was dropped as a repeat of the first. A non-Error's serialized contents now join its signature, with its keys standing in when it cannot be serialized, so a cyclic object re-thrown by the router is still reported once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dawsontoth
dawsontoth marked this pull request as ready for review October 12, 2026 15:50
@dawsontoth
dawsontoth requested a review from a team as a code owner October 12, 2026 15:50
@dawsontoth
dawsontoth removed this pull request from stack #1910 October 12, 2026 15:50
@dawsontoth
dawsontoth merged commit e057144 into claude/1293-forbidden-not-found-error-page Oct 12, 2026
4 checks passed
@dawsontoth
dawsontoth deleted the claude/1695-one-report-per-route-failure branch October 12, 2026 15:51
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.

[RUM] One failed route query emits up to 14 identical error-boundary reports, and a 403 renders a raw "Component Error" dead end (84 sessions/30d)

1 participant