Flattening Nested Error cause Chains for Logging
You have wrapped errors correctly at every boundary, but the log line that reaches your aggregator shows only the outermost message and a [cause]: [Object] stub — the three layers of context underneath, each with its own stack, never made it into the record. This how-to walks a nested Error.cause chain and serializes every level into a single structured log entry, so one failure produces one log line that carries the origin message, the origin stack, and every wrapper in between. It is a focused technique within the parent guide Async Stack Traces & Error Cause Chains and the broader Core JavaScript Error Handling & Boundaries reference. The goal is narrow: get the whole chain, with stacks intact, into the shape your logger writes as JSON.
Symptom / Trigger
A wrapped error looks perfect in a local Node.js REPL because util.inspect expands cause recursively. The moment the same error passes through a structured logger — Pino, Winston, Bunyan, or a hand-rolled logger.error(msg, { err }) — the chain collapses. The logger calls JSON.stringify on the object, and cause disappears along with every stack below the top.
# What you expected in the log aggregator (all four layers, with stacks):
Unable to build dashboard for u_42
caused by: repository.loadUserRecord failed for u_42
caused by: Upstream responded 503 for user u_42
caused by: TypeError: fetch failed (ECONNREFUSED 10.0.0.4:443)
# What the structured logger actually wrote:
{"level":50,"msg":"Unable to build dashboard for u_42","err":{"type":"Error","message":"Unable to build dashboard for u_42","stack":"Error: Unable to build dashboard for u_42\n at buildDashboard (/app/service.js:9:11)"}}
# cause is gone. The origin TypeError, its ECONNREFUSED, and its stack are nowhere in the record.
The origin stack — the one that names the exact socket call that failed — is the single most useful field for triage, and it is exactly the one the logger dropped.
Root Cause Explanation
Error.cause is a normal own property, but it is the relationship between error objects that serializers do not follow the way you expect. JSON.stringify traverses own enumerable properties; message, stack, and name are non-enumerable, so a bare error serializes to {} and most logger error serializers special-case only those three fields. They read err.message and err.stack explicitly and stop — they never recurse into err.cause. The chain exists in memory as a linked list of Error objects, but nothing walks it on the way out.
// The broken pattern — the logger sees only the outermost error
const origin = new TypeError('fetch failed (ECONNREFUSED)');
const wrapped = new Error('Unable to build dashboard for u_42', { cause: origin });
logger.error({ err: wrapped }, wrapped.message);
// The logger's err serializer reads err.message and err.stack, then returns.
// err.cause (the origin TypeError and its stack) is never traversed → lost.
So the fix is not a logger configuration flag. You must flatten the chain into an enumerable array of plain objects before the logger touches it, capturing each level’s stack string as data. Below, a single origin failure is wrapped at three layers; each cause link is a pointer the logger will not follow on its own.
Step-by-Step Fix
Step 1 — Walk the cause pointers into an ordered array, guarding against cycles
Start by turning the linked list of errors into a flat array you can iterate. Begin at the top error and follow .cause until it is undefined or no longer an Error. Two guards keep the walk safe in production: a Set of already-seen objects stops an infinite loop if some layer accidentally set a cause back onto an ancestor, and a depth cap bounds the work even if the guard is somehow defeated. The array comes out outermost-first, which is the order you will render.
// collect.js — outermost-first list of every Error in the chain
export function collectChain(err, maxDepth = 12) {
const links = [];
const seen = new Set(); // cycle guard: a cause that loops back to an ancestor
let node = err;
while (node instanceof Error && !seen.has(node) && links.length < maxDepth) {
seen.add(node);
links.push(node);
node = node.cause; // undefined terminates the walk
}
return links; // [top, service, repository, origin]
}
Step 2 — Normalize non-Error causes so every link carries a real stack
The walk stops the instant it hits a link that is not an Error, because a thrown string or a rejected plain object has no .stack to preserve. That is usually the origin — the very frame you most want. Wrapping such a value in an Error captures a stack at the point of normalization, which is better than nothing, and tagging the name as NonError records in the log that the deepest link never carried a stack of its own.
// normalize.js — a rejected string or object has no .stack; wrap it to capture one
export function toError(value) {
if (value instanceof Error) return value;
const e = new Error(typeof value === 'string' ? value : JSON.stringify(value));
e.name = 'NonError'; // marks a link that had no stack at its own throw site
return e;
}
Step 3 — Serialize each link to a plain object, keeping its own stack string
Now convert each Error into a plain object whose fields are all enumerable, so the logger’s JSON.stringify cannot skip them. The crucial field is stack: read it into a string here, at the layer that owns it, rather than hoping a downstream serializer will. Capturing depth, name, and any transport code such as Node’s err.code gives each entry enough identity to search on later without re-parsing the raw stack.
// serialize-level.js — one enumerable object per layer; stack is preserved as data
export function levelToObject(err, depth) {
return {
depth, // 0 = outermost wrapper, last = origin
name: err.name, // e.g. "TypeError", "LayeredError", "NonError"
message: err.message, // this layer's own message, not the top one
stack: err.stack ?? null, // raw multi-line stack; backend symbolicates later
code: err.code ?? null, // Node system errors carry ECONNREFUSED etc.
};
}
Step 4 — Assemble one flat log record with the whole chain inside it
Combine the pieces into the object your logger will actually write. Keep a top-level message for the human scanning the stream, a small origin handle so triage tooling can group and search on the true root cause without indexing into the array, a depth count that makes a suspiciously deep chain obvious at a glance, and the full chain array carrying every stack in order. One failure becomes one self-contained record.
// to-log-record.js — combine the steps into a single structured entry
import { collectChain } from './collect.js';
import { toError } from './normalize.js';
import { levelToObject } from './serialize-level.js';
export function toLogRecord(topErr) {
const links = collectChain(toError(topErr));
const origin = links[links.length - 1]; // deepest link = true root cause
return {
message: topErr.message, // human-readable top line
origin: { name: origin.name, message: origin.message }, // fast triage handle
depth: links.length, // how many layers deep it went
chain: links.map((e, i) => levelToObject(e, i)),// every stack, in order
};
}
Step 5 — Log the record exactly once, at the top handler, as one entry
Wire the flattening into the single place that owns the request outcome — the route handler, queue consumer, or global rejection hook. Flatten first, then pass the resulting plain object to the logger. Because the record is already enumerable data, the logger has nothing left to drop, and because you log only here, the incident maps to exactly one line in the aggregator.
// handler.js — flatten first, then hand the enumerable object to the logger
import { toLogRecord } from './to-log-record.js';
export async function dashboardRoute(req, res) {
try {
res.json(await buildDashboard(req.params.userId));
} catch (topErr) {
// One failure -> one log line that contains all stacks as an array.
logger.error({ err: toLogRecord(topErr) }, topErr.message);
res.status(500).json({ error: 'internal_error' });
}
}
The flattening runs before the logger’s own serializer, so it never gets the chance to drop cause — by the time the logger sees err, it is already a plain object whose chain array is fully enumerable. The same discipline of logging once, at the top, that keeps telemetry clean is covered from the rejection angle in how to log custom error properties without blooming payloads.
Verification
Prove two things: the record contains one object per layer in origin order, and every layer kept a non-empty stack string. Assert on the structure directly rather than reading console output.
// to-log-record.test.js — the flat record must carry every level and its stack
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { toLogRecord } from './to-log-record.js';
test('record flattens the full chain with stacks preserved', () => {
const origin = new TypeError('fetch failed (ECONNREFUSED)');
const repo = new Error('repository.loadUserRecord failed', { cause: origin });
const service = new Error('Unable to build dashboard', { cause: repo });
const record = toLogRecord(service);
assert.equal(record.depth, 3); // three layers, no truncation
assert.equal(record.chain[0].message, 'Unable to build dashboard'); // outermost first
assert.equal(record.chain[2].name, 'TypeError'); // origin at the end
assert.equal(record.origin.message, 'fetch failed (ECONNREFUSED)');
// The origin stack — the field the plain logger dropped — is present.
assert.ok(record.chain[2].stack.includes('fetch failed'));
// And it survives a JSON round-trip, unlike a live cause link.
assert.equal(JSON.parse(JSON.stringify(record)).chain.length, 3);
});
The before/after below is the whole point: a default logger emits one message and one stack; the flattened record emits every layer as an addressable array element.
Edge Cases & Gotchas
- A cause that is not an Error dead-ends the walk with no stack.
throw 'boom'orPromise.reject({ code: 502 })gives a link with no.stackto preserve. Route the top error throughtoErrorbefore flattening, and normalize inner causes at the wrap site so the deepest link always carries a real stack. - Full stacks per layer can bloat the payload. A twelve-deep chain with fifty-frame stacks is large. Cap depth (already
maxDepth = 12) and, if your sink enforces a size limit, keep the full stack only on the origin and the top, storing justnameandmessagefor the middle links. - Do not log mid-chain to “help” — you will duplicate the incident. Each intermediate log writes a partial record and multiplies your alert count. Flatten and log exactly once at the top handler; the array already contains what the middle layers knew.
- Async frames inside each stack live only in-process. The
stackstrings you capture are the durable artifact once the record crosses a network or worker boundary; the engine’s async stitching does not survive serialization, so never rely on reconstructing it downstream from the flat record.
FAQ
Why does my logger drop cause when console.error shows it fine?
console.error and util.inspect recurse into cause because Node.js taught its inspector about the relationship. Structured loggers serialize with JSON.stringify (or a fast error serializer that reads only message, stack, and name), and neither follows the cause link. That is why you flatten into an enumerable chain array before handing the object to the logger — you are turning a relationship the serializer ignores into plain data it cannot miss.
Should I flatten outermost-first or origin-first?
Outermost-first (depth: 0 is the top wrapper) reads top-down like the request flow and matches how the top handler encountered the error. Keep a separate origin handle on the record for fast triage and grouping so you never have to index chain[chain.length - 1] in a query. The order is a convention — pick one and keep every service consistent, because dashboards and alerts will assume it.
How is this different from the telemetry serialization in the parent guide?
The parent guide’s serialization targets an observability backend and derives a grouping fingerprint from the origin so unrelated incidents do not merge. This how-to targets your log stream: one human-readable top message plus a chain array that a person scans in the aggregator. The two share the walk-and-normalize core but differ in output shape and audience — you can emit both from the same collected chain. For the process-exit path where a top handler flattens a fatal chain, see implementing graceful shutdown on uncaughtException.
Related
- Async Stack Traces & Error Cause Chains — parent guide on building and reading the chain this page flattens
- Core JavaScript Error Handling & Boundaries — the reference covering every interception and boundary pattern
- How to Log Custom Error Properties Without Blooming Payloads — bounding the size of the record you emit here
- Implementing Graceful Shutdown on uncaughtException — flushing a flattened fatal chain before the process exits