Structured Error Logging & Breadcrumbs
An error that reaches your log sink as a bare Error: undefined is not a function string is nearly worthless for triage — you cannot tell which user hit it, which request triggered it, or what the code was doing in the seconds before the throw. This guide shows how to wrap every captured error in a stable JSON envelope, attach a rolling breadcrumb trail recorded before the exception occurred, thread a request or trace correlation ID through the whole chain, and redact sensitive fields so nothing private leaves the process. It builds on the foundations in Core JavaScript Error Handling & Boundaries, and pairs closely with Mastering window.onerror and Global Event Listeners for the browser capture surface and Integrating Observability SDKs: Sentry, Datadog RUM, and OpenTelemetry for the ingestion side. Prerequisites: a working global error handler, Node.js 18+ or an evergreen browser, and a log destination that can index JSON.
After completing this guide you will be able to:
- Define a single versioned JSON log schema that every error record conforms to
- Maintain a bounded ring buffer of breadcrumbs and snapshot it at throw time
- Generate and propagate a correlation ID across async boundaries and HTTP hops
- Redact secrets, tokens, and PII with a deny-list and a bounded-depth walker
- Verify the shape with a schema assertion in CI so drift never ships
Problem Framing & Symptom Identification
The failure this guide addresses is not that errors go uncaught — it is that caught errors arrive contextless. A stack trace tells you where the code exploded; it rarely tells you why. The “why” lives in the events that led up to the throw: the route the user navigated to, the API call that returned a 403, the feature flag that flipped, the form field that was edited. None of that is present in an Error object. Unless you capture it deliberately and attach it at the moment of failure, it is gone.
The second half of the problem is shape. When each service logs errors in a slightly different JSON layout — one uses msg, another message, a third nests everything under err — your log platform cannot build a single saved search that works across all of them. Dashboards fracture, alert queries silently miss records, and on-call engineers grep raw text instead of filtering structured fields.
Symptoms that bring engineers to this page:
- Log records where the only useful field is a free-text
messageand everything else is a wall of stringified stack - The same logical error appearing under three different field names across services, defeating aggregation
- No way to answer “what did this user do right before the crash?” because no history was captured
- Access tokens,
Authorizationheaders, or email addresses showing up in plaintext inside stored logs - A frontend error and its backend
500that cannot be joined because no shared ID connects them
There is a temporal dimension to this too. An Error object is a snapshot of a single instant — the instant V8 unwound the stack. But debugging is almost always about the sequence, not the instant. The engineer reading the record needs to reconstruct a small story: the user arrived here, clicked that, the request came back with this status, and then the code threw. That story is not recoverable after the fact because the runtime discards it. It exists only if you capture it continuously and freeze a copy at the moment of failure. This is the single most valuable thing a logging layer can add, and it is the thing raw stack traces can never provide on their own.
The fix is a small, deliberate logging layer with four responsibilities — schema, breadcrumbs, correlation, and redaction — applied uniformly at every capture point. Each responsibility is independent and testable in isolation, which matters because the layer sits on the hot path of every error and must never itself throw. A logging module that crashes while handling an error turns a recoverable incident into a silent one. The diagram below shows the raw error being enriched into that envelope before it is serialized.
Prerequisites & Environment Setup
The examples target Node.js 18 LTS or newer on the server and any evergreen browser on the client. The redaction and breadcrumb modules are dependency-free plain JavaScript; the only optional package is a fast serializer for high-throughput services.
# Node.js baseline — AsyncLocalStorage and crypto.randomUUID are built in on 18+
node --version # expect v18.0.0 or higher
# Optional: a fast structured logger for high-volume services.
# The patterns here work with console.log too; pino is used only where noted.
npm install pino@9
# For TypeScript projects, install the platform types
npm install --save-dev @types/node
No server framework is required. The correlation module uses node:async_hooks, which ships with Node.js, and the browser breadcrumb buffer uses only standard DOM and fetch APIs. If you run the code in a bundler, make sure crypto.randomUUID is available (it is in every browser released after 2021 and in Node.js 16.7+); otherwise polyfill it with a UUID library of your choice.
Decide up front where the schema definition lives. Put it in one module — log-schema.js — and import it everywhere. A single source of truth for the field names is what makes cross-service aggregation possible, so resist the temptation to inline the shape at each call site.
Step-by-Step Implementation
1. Define the versioned JSON log schema
Every record shares the same top-level keys in the same order, with a schema version field so downstream consumers can evolve. Keep the envelope flat where possible — nested objects are fine for context, but the fields you filter and alert on should be top-level.
// log-schema.js — the single source of truth for the record shape
export const LOG_SCHEMA_VERSION = '1.0.0';
/** Build a normalized error record. Every field has a stable name. */
export function buildErrorRecord({ error, level = 'error', context = {}, breadcrumbs = [], correlationId }) {
return {
schema: LOG_SCHEMA_VERSION, // lets consumers handle field evolution
ts: new Date().toISOString(), // ISO-8601 UTC, sortable and unambiguous
level, // 'error' | 'fatal' | 'warn'
correlationId: correlationId ?? null, // joins this record to its request/trace
error: {
name: error?.name ?? 'Error',
message: error?.message ?? String(error),
stack: error?.stack ?? null,
// preserve a machine-readable code if the app sets one
code: error?.code ?? null,
},
breadcrumbs, // ordered oldest -> newest
context, // free-form but redacted before serialize
};
}
Because the shape is a plain function, it is trivial to unit-test and impossible to drift between call sites. The schema field is not decoration — when you rename message to msg in v2, consumers read the version and branch instead of guessing. A common mistake is to leave the version out and treat the first shipped shape as permanent; in practice every schema evolves, and a version field is the cheapest possible insurance against a breaking change taking down every dashboard at once. Keep the field set small and deliberate. Every key you add is a key that must be indexed, stored, and reasoned about across every service, so favor a handful of stable top-level fields over a sprawling record that no two teams populate the same way.
2. Build a bounded breadcrumb ring buffer
Breadcrumbs are a short history of what happened before the throw. The buffer must be bounded: an unbounded array captured on every error is a memory leak and a serialization bomb. A ring buffer keeps only the most recent N entries at O(1) cost.
// breadcrumbs.js — fixed-capacity ring buffer, newest entries win
export class BreadcrumbTrail {
constructor(capacity = 30) {
this.capacity = capacity;
this.buffer = [];
}
/** Record one event. category groups them; data is arbitrary but small. */
add(category, message, data = {}) {
this.buffer.push({
ts: Date.now(), // relative ordering; cheap to record
category, // 'navigation' | 'http' | 'ui' | 'state'
message,
data,
});
if (this.buffer.length > this.capacity) {
this.buffer.shift(); // drop the oldest to stay bounded
}
}
/** Snapshot a copy at throw time so later mutations can't corrupt it. */
snapshot() {
return this.buffer.map((b) => ({ ...b, data: { ...b.data } }));
}
}
Call add from the places that matter — a router hook, a fetch wrapper, a click handler. The snapshot returns a defensive copy so that async work happening after the throw cannot mutate the trail you already captured. The horizontal pipeline below shows the three event sources feeding one shared trail.
3. Instrument the sources that produce breadcrumbs
A trail is only useful if it is fed. Wire the recorder into the events that describe user and system behavior. Keep each entry tiny — a category, a short message, and a handful of primitive data fields.
// instrument.js — attach recorders to the surfaces that matter
import { BreadcrumbTrail } from './breadcrumbs.js';
export const trail = new BreadcrumbTrail(30);
// Navigation: record every client-side route change
window.addEventListener('popstate', () => {
trail.add('navigation', 'popstate', { to: location.pathname });
});
// HTTP: wrap fetch so every request/response leaves a crumb
const nativeFetch = window.fetch;
window.fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input.url;
trail.add('http', 'request', { url, method: init?.method ?? 'GET' });
const res = await nativeFetch(input, init);
trail.add('http', 'response', { url, status: res.status }); // status, not body
return res;
};
// UI: capture clicks by stable target label, never by input value
document.addEventListener('click', (e) => {
const el = e.target.closest('[data-track]');
if (el) trail.add('ui', 'click', { target: el.dataset.track });
});
Notice what is not recorded: no response bodies, no form input values, no header contents. Breadcrumbs describe behavior, not payloads. Recording payloads is how secrets end up in your logs — the redaction step in step 5 is a safety net, not a license to be careless here. The discipline is to record the shape of an interaction — a URL, a method, a status code, a stable element label — and never its contents. A crumb that says an HTTP request to /account returned 403 is exactly as useful for debugging as one that also captured the bearer token, and infinitely safer. When in doubt, record less: a breadcrumb trail with thirty behavioral events and zero payloads will out-debug a trail of ten events stuffed with sensitive data every time, because it survives a security review and can actually be shipped to a shared log platform.
4. Generate and propagate a correlation ID
A correlation ID is the join key between a browser error, the request it triggered, and the backend span that failed. On the server, AsyncLocalStorage carries the ID across every async boundary without threading it through function arguments. On the browser, attach it to outgoing requests so the backend inherits the same value.
// correlation.js (server) — one ID per request, available everywhere downstream
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
export const requestContext = new AsyncLocalStorage();
/** Express-style middleware: adopt an inbound ID or mint a fresh one. */
export function withCorrelation(req, res, next) {
const correlationId = req.headers['x-correlation-id'] ?? randomUUID();
res.setHeader('x-correlation-id', correlationId); // echo it back to the client
requestContext.run({ correlationId }, next); // all async work inherits it
}
/** Read the current ID anywhere in the async call tree. */
export function currentCorrelationId() {
return requestContext.getStore()?.correlationId ?? null;
}
On the browser side, send the ID you already hold on every request so the two halves of a trace share one key:
// correlation-client.js — reuse one ID per page session, inject into fetch
import { randomUUID } from './uuid.js'; // or crypto.randomUUID directly
export const sessionCorrelationId = randomUUID();
// Extend the fetch wrapper from step 3 to inject the header
const withId = (init = {}) => ({
...init,
headers: { ...init.headers, 'x-correlation-id': sessionCorrelationId },
});
// use: window.fetch(url, withId(init))
Because the browser sends x-correlation-id and the server middleware adopts it, a single value now spans the client throw and the server 500. The comparison below contrasts a contextless record with an enriched one.
5. Redact sensitive fields before serialization
Redaction is the last gate before a record leaves the process. Use a deny-list of key names, match case-insensitively, and walk the object to a bounded depth so a deeply nested or circular structure cannot hang the serializer. Replace values in place with a sentinel rather than deleting keys, so the record shape stays predictable.
// redact.js — deny-list redaction with bounded depth and cycle safety
const DENY = /^(authorization|cookie|password|token|secret|api[-_]?key|ssn)$/i;
const EMAIL = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-z]{2,}/g;
export function redact(value, depth = 0, seen = new WeakSet()) {
if (depth > 6) return '[Truncated]'; // bound recursion depth
if (typeof value === 'string') {
return value.replace(EMAIL, '[email]'); // scrub PII inside strings
}
if (value && typeof value === 'object') {
if (seen.has(value)) return '[Circular]'; // guard against cycles
seen.add(value);
const out = Array.isArray(value) ? [] : {};
for (const [k, v] of Object.entries(value)) {
out[k] = DENY.test(k) ? '[Redacted]' : redact(v, depth + 1, seen);
}
return out;
}
return value; // numbers, booleans, null pass through
}
Redaction runs over the whole record — including breadcrumb data and the context object — so even if an upstream instrument accidentally captured a token, it never reaches disk. Prefer replacing with [Redacted] over deletion: a downstream consumer expecting an authorization key will handle a redacted string gracefully but may crash on a missing field.
The two hardest failure modes here are casing and structure. A deny-list that matches authorization but not Authorization leaks under a different header casing, which is why the pattern uses the case-insensitive i flag. Structure bites when a record contains a cycle or nests dozens of levels deep — a naive recursive walk either hangs or blows the stack. The depth cap and the WeakSet of seen objects turn both of those into a harmless sentinel value. Treat the deny-list as a living document: every time a new field name for a secret appears in a code review, add it, and back the addition with a test so the rule can never silently regress.
6. Assemble the pipeline into one capture function
Wire the four pieces together behind a single captureError entry point that every catch block and global handler calls. This guarantees uniform enrichment and redaction no matter where the error originated.
// capture.js — the one function every error path calls
import { buildErrorRecord } from './log-schema.js';
import { redact } from './redact.js';
import { trail } from './instrument.js';
import { currentCorrelationId } from './correlation.js';
export function captureError(error, { level = 'error', context = {} } = {}) {
const record = buildErrorRecord({
error,
level,
context,
breadcrumbs: trail.snapshot(), // freeze history at throw time
correlationId: currentCorrelationId(), // join key from async context
});
const safe = redact(record); // scrub before it leaves the process
// one line of JSON per record — the shape log platforms index cleanly
process.stdout.write(JSON.stringify(safe) + '\n');
return safe;
}
// Global hooks funnel into the same function
process.on('uncaughtException', (err) => captureError(err, { level: 'fatal' }));
process.on('unhandledRejection', (reason) =>
captureError(reason instanceof Error ? reason : new Error(String(reason)), { level: 'fatal' }));
Every path — a try/catch, a React error boundary, a global handler — now produces an identical envelope. That uniformity is the entire point: one saved search, one alert query, one dashboard works everywhere.
Production Telemetry Integration
In production you rarely write JSON to stdout by hand — a structured logger like pino handles serialization, log levels, and transport. The record shape stays identical; only the emit line changes. Pass the enriched, redacted record as the log object so the logger’s own serializers do not re-flatten your fields:
// capture-pino.js — same envelope, emitted through a real logger
import pino from 'pino';
import { redact } from './redact.js';
const logger = pino({
// pino has native redaction too; layer it with your walker for defense in depth
redact: { paths: ['error.stack'], remove: false, censor: '[Redacted]' },
});
export function emit(record) {
const safe = redact(record);
// level drives pino's own routing; the object is the structured payload
logger[safe.level === 'fatal' ? 'fatal' : 'error'](safe, safe.error.message);
}
When you also run an observability SDK, hand it the same breadcrumb trail and correlation ID rather than maintaining a second parallel history. Most SDKs expose a breadcrumb and tag API; map your fields onto theirs so the platform’s UI and your raw logs agree. The details of wiring Sentry, Datadog RUM, and OpenTelemetry — including their own beforeSend redaction hooks — are covered in Integrating Observability SDKs; the key is that the correlation ID you mint here becomes the SDK’s tag, so a search in the platform and a query against raw logs return the same set of events.
For distributed systems, propagate the correlation ID over the wire on every hop. Adopt an inbound x-correlation-id header when present and mint one only at the true entry point, so a single ID follows a request through the gateway, the API, and any downstream services. That is what turns three disconnected 500s in three services into one traceable story.
Verification & Testing
The most valuable test asserts that the record conforms to the schema — this catches field drift before it ships. Validate the presence and type of every top-level key against a fixture built from a known error.
// capture.test.js — run with: node --test
import test from 'node:test';
import assert from 'node:assert';
import { buildErrorRecord, LOG_SCHEMA_VERSION } from './log-schema.js';
import { redact } from './redact.js';
test('record conforms to the schema and redacts secrets', () => {
const record = buildErrorRecord({
error: new Error('boom'),
correlationId: 'abc-123',
breadcrumbs: [{ ts: 1, category: 'http', message: 'request', data: {} }],
context: { authorization: 'Bearer sk_live_secret', user: '[email protected]' },
});
const safe = redact(record);
assert.equal(safe.schema, LOG_SCHEMA_VERSION); // version pinned
assert.equal(typeof safe.ts, 'string'); // ISO timestamp present
assert.equal(safe.correlationId, 'abc-123'); // join key preserved
assert.equal(safe.error.message, 'boom'); // message intact
assert.equal(safe.context.authorization, '[Redacted]'); // secret scrubbed
assert.equal(safe.context.user, '[email]'); // PII scrubbed
assert.ok(Array.isArray(safe.breadcrumbs)); // trail is an array
});
Also test the ring buffer’s bound directly: push more than the capacity and assert the length never exceeds it and the oldest entry was dropped.
# Assert the buffer stays bounded under load
node -e "
import('./breadcrumbs.js').then(({ BreadcrumbTrail }) => {
const t = new BreadcrumbTrail(5);
for (let i = 0; i < 100; i++) t.add('test', 'e' + i);
const snap = t.snapshot();
console.assert(snap.length === 5, 'buffer must stay bounded at 5');
console.assert(snap[4].message === 'e99', 'newest entry must survive');
console.log('PASS: bounded at ' + snap.length + ', newest = ' + snap[4].message);
});
"
For redaction, add adversarial fixtures: a circular object, a deeply nested one past the depth limit, and a key whose casing differs from the deny-list (AUTHORIZATION, Api-Key). All three must resolve to a sentinel rather than throwing or leaking. Run the schema test in CI so a rename of message to msg fails the build instead of silently breaking every downstream dashboard.
Failure Modes & Edge Cases
| Scenario | Root Cause | Fix |
|---|---|---|
| Breadcrumb array grows without bound and bloats every record | The recorder pushes to a plain array with no cap | Use the ring buffer’s shift() on overflow to hold a fixed capacity |
JSON.stringify throws Converting circular structure to JSON |
The context object contains a reference cycle |
Route every record through the depth- and cycle-safe redact walker before serializing |
| Secret still appears in logs despite redaction | Key casing (API_KEY) did not match a case-sensitive deny-list |
Match with a case-insensitive regex and test adversarial casings in CI |
| Frontend error and backend 500 cannot be joined | Each side minted its own correlation ID independently | Send x-correlation-id from the browser and adopt it server-side; mint only at the entry point |
Correlation ID is null inside an async callback |
Work escaped the AsyncLocalStorage.run scope |
Wrap the entire request in requestContext.run; read via getStore() and fall back to null |
| Breadcrumb captured a form value and leaked PII | An instrument recorded input contents instead of a label | Record only stable target labels and metadata; never capture input values or response bodies |
FAQ
How many breadcrumbs should I keep?
Between 20 and 50 is the practical range. Fewer than 20 and you miss the setup steps that led to the error; more than 50 inflates every record and rarely adds signal, because the events immediately before the throw carry almost all the diagnostic value. Start at 30 and tune based on how often the earliest crumb in your snapshots turns out to be relevant. Because the buffer is bounded, raising the cap costs a fixed amount of memory per trail, not unbounded growth.
Where should redaction run — at capture or at the log sink?
At capture, inside the process, before the record is serialized or transmitted. Redacting at the sink means the secret has already crossed the network and possibly been buffered on disk by an agent along the way. In-process redaction is the only point where you control the data before it leaves your trust boundary. Sink-side redaction is a useful second layer for defense in depth, but it is never the primary control.
Do breadcrumbs and correlation IDs duplicate what my observability SDK already does?
They overlap, and that is fine — the goal is that your raw logs and your SDK agree rather than tell two different stories. Mint the correlation ID yourself and feed it to the SDK as a tag, and record breadcrumbs into both your buffer and the SDK’s breadcrumb API from the same instrument. When the two share a source, a query in the platform UI and a grep over raw JSON return the same events, which is exactly what you want during an incident.
Should the correlation ID be a UUID or something shorter?
A UUID from crypto.randomUUID() is the safe default: it is collision-free without coordination, available in every modern runtime, and unambiguous in logs. If you already run distributed tracing, reuse the trace ID as the correlation ID instead of minting a separate value, so a single identifier joins logs, traces, and error records. Avoid short random strings — they collide at scale and turn a clean join into a noisy one.
Related
- Core JavaScript Error Handling & Boundaries — parent reference covering every global capture surface these records flow from
- Mastering window.onerror and Global Event Listeners — the browser hooks that feed
captureErroron the client - Integrating Observability SDKs: Sentry, Datadog RUM, and OpenTelemetry — routing the enriched record to a managed ingestion pipeline
- Node.js uncaughtException vs unhandledRejection — the server-side global handlers that call into this logging layer