This page details the payload contract summarised in Error Telemetry & Crash Reporting, which covers the surrounding pipeline — fingerprinting, sampling and delivery.
The exact problem #
A crash dashboard fills up with entries that read TypeError: Cannot read properties of undefined (reading 'toFixed') at chunk-4f2a.js:1:88213, four hundred times, from an unknown route. Every one of them is technically a correct report and none of them is actionable. The engineer on call cannot tell which feature broke, whether users recovered, or whether this is one loop or four hundred people.
The payload is where triage is won or lost, and the design rule is simple: enumerate the questions someone will ask at 2am, then include exactly the fields that answer them.
| Question in the first two minutes | Field that answers it |
|---|---|
| What broke, in product terms? | scope — the boundary’s name |
| Where was the user? | route — the matched pattern, not the URL |
| Is this new? | release plus fingerprint |
| How bad is it for users? | recovery outcome, and unique session.id count |
| Is it looping? | session.sequence and occurrence counts |
| Can I reproduce it? | componentStack, breadcrumbs, viewport, online state |
Zero-to-working payload #
// payload.ts
export const PAYLOAD_SCHEMA = 3;
export type Recovery = 'fallback-shown' | 'retry-succeeded' | 'retry-failed' | 'reloaded';
export interface Breadcrumb {
t: number; // ms since session start, not wall clock
kind: 'nav' | 'request' | 'interaction' | 'connectivity';
label: string; // '/orders/:id', 'POST /api/cart 500'
}
export interface CrashPayload {
schema: number;
fingerprint: string;
scope: string;
route: string;
release: string;
error: {
name: string;
message: string;
stack?: string;
componentStack?: string;
/** Present when the failure came from a rejection, not a render throw. */
async: boolean;
};
recovery: Recovery;
session: { id: string; user?: string; sequence: number; startedAt: number };
context: {
viewport: string;
online: boolean;
agent: string;
/** Bounded ring buffer — most recent last. */
breadcrumbs: Breadcrumb[];
};
ts: number;
}
const MAX_STACK_FRAMES = 20;
const MAX_COMPONENT_FRAMES = 12;
const MAX_BREADCRUMBS = 20;
const MAX_MESSAGE = 300;
export function buildPayload(input: {
error: Error;
scope: string;
route: string;
componentStack?: string;
async?: boolean;
recovery?: Recovery;
}): CrashPayload {
const { error, scope, route } = input;
return {
schema: PAYLOAD_SCHEMA,
fingerprint: fingerprintOf(error, scope),
scope,
route, // '/orders/:id' — never the resolved path
release: __RELEASE__,
error: {
name: error.name || 'Error',
message: scrub(error.message).slice(0, MAX_MESSAGE),
stack: trimLines(error.stack, MAX_STACK_FRAMES),
componentStack: trimLines(input.componentStack, MAX_COMPONENT_FRAMES),
async: input.async ?? false,
},
recovery: input.recovery ?? 'fallback-shown',
session: sessionInfo(),
context: {
viewport: `${window.innerWidth}x${window.innerHeight}`,
online: navigator.onLine,
agent: coarseAgent(), // 'chrome/126 · android'
breadcrumbs: recentBreadcrumbs(MAX_BREADCRUMBS),
},
ts: Date.now(),
};
}
function trimLines(text: string | undefined, max: number): string | undefined {
if (!text) return undefined;
return text.split('\n').slice(0, max).join('\n');
}
Step-by-step #
- Version the schema.
PAYLOAD_SCHEMAlets the backend reject or migrate reports from bundles that are weeks old — which will exist, because users keep tabs open. Without it, a renamed field silently becomesundefinedin your queries. - Report the route pattern.
/orders/:idgroups;/orders/8814does not, and it exports a customer identifier off-device. Read the pattern from your router’s matched route, not fromlocation.pathname. - Separate
error.async. A rejection routed into a boundary and a render throw need different fixes. One boolean saves a triage step every time. - Record recovery, then update it. Send
fallback-shownat catch time; if the user retries successfully, send a small follow-up event keyed by fingerprint and session. Severity should track “users who never got the feature back”, not raw error counts. - Bound everything. Twenty stack frames, twelve component frames, twenty breadcrumbs, 300-character message. These caps are what keep the payload inside
sendBeacon’s queue budget. - Scrub free text at the boundary of serialisation. Not in the reporter, not on the server — in the builder, so no code path can bypass it.
Scrubbing rules that survive review #
// scrub.ts
const RULES: Array<[RegExp, string]> = [
[/[\w.+-]+@[\w-]+\.[\w.]+/g, '[email]'],
[/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, '[uuid]'],
[/\b(?:\d[ -]?){13,19}\b/g, '[pan]'], // card-shaped digits
[/\b(?:eyJ[\w-]+\.){2}[\w-]+\b/g, '[jwt]'],
[/(token|key|secret|password|authorization)=[^&\s]+/gi, '$1=[redacted]'],
[/\b\d{6,}\b/g, '[id]'],
];
export function scrub(text: string): string {
return RULES.reduce((acc, [pattern, replacement]) => acc.replace(pattern, replacement), text);
}
/** Breadcrumb labels get the same treatment plus URL normalisation. */
export function scrubLabel(label: string): string {
return scrub(label.replace(/\/[0-9a-f-]{6,}/gi, '/:id').replace(/\?.*$/, '?…'));
}
Two rules matter more than the rest. Card-shaped digit runs get caught before anything else can log them, and query strings are dropped wholesale rather than filtered key by key — an allowlist is the only safe way to keep a query parameter, and in practice nobody maintains one.
| Field | Safe to send | Reason |
|---|---|---|
scope, route pattern, release |
Yes | Stable, non-identifying, essential for grouping |
| Hashed user id | Yes, with a rotating salt | Answers “one user or many” without storing identity |
Raw location.href |
No | Carries identifiers, tokens and search terms |
| Component props or form values | No | Arbitrary user content, unbounded size |
localStorage contents |
No | Often holds tokens and drafts |
| Full user-agent string | Only coarsened | High-entropy fingerprinting surface for little triage value |
Verification #
- Serialise a worst-case payload and measure it. Build one with a maximal stack, a full breadcrumb buffer and a long message;
new Blob([JSON.stringify(p)]).sizemust sit under your 8 KB target. - Feed the scrubber a hostile string. Assert an email, a UUID, a card-shaped number, a JWT and a
token=parameter are all replaced. Keep that test next to the rules so a future contributor sees it. - Check the route field on a dynamic page. Load
/orders/8814, force a crash, and confirm the payload reads/orders/:id. This is the field that most often regresses when routing changes. - Query your own dashboard. Group the last week by
fingerprintand confirm one product bug is one row. If a single bug spreads across many rows, the fingerprint inputs — not the payload — need attention; see Deduplicating Error Noise with Fingerprinting and Sampling. - Assert the payload in a test. The reporter assertion in Unit Testing an Error Boundary with React Testing Library is where a dropped
componentStackgets caught before release.
Frequently asked questions #
How large should a crash payload be?
Target under 8 KB, hard-cap at 32 KB. sendBeacon can refuse payloads near the browser’s roughly 64 KB queue budget, and a refused report is a lost report. Twenty stack frames and twenty breadcrumbs usually land well inside.
Should I include the user’s id in a crash report?
Include a salted hash, not the raw id or an email. It answers “one user or many” and correlates with support tickets without turning the dashboard into a personal-data store.
Do I need breadcrumbs if I already have a stack trace?
Yes, for what the stack cannot explain: the previous route, a request that failed just before, a connectivity change. Keep them as a bounded ring buffer of typed events, not free-form log lines.
Related #
- Error Telemetry & Crash Reporting — the pipeline this payload travels through
- Deduplicating Error Noise with Fingerprinting and Sampling — turning many reports into one actionable issue
- Testing & Fault Injection for Error Boundaries — asserting the payload never silently loses a field