Skip to content
JavaScript

6 JSON Error Serialization Tests Before Production Logging

·11 min read
6 JSON Error Serialization Tests Before Production Logging - JavaScript guide from JSON Prettifier

Quick answer: Before production logging, test six boundaries: the empty-object default, explicit core fields, safe custom metadata, non-Error thrown values, nested causes and aggregate errors, and a bounded redacted wire contract. These tests catch lost diagnostics, serialization crashes, secret leakage, and unstable event shapes before an incident depends on them.

An Error can look rich in a console yet become an empty object in a JSON log. The mismatch is easy to miss because console inspection and JSON serialization use different rules. The six tests below define a small, reviewable logging contract instead of trusting whatever properties happen to be visible in one runtime.

For background, exception handling separates exceptional control flow from ordinary return values. This guide addresses the narrower boundary where a caught JavaScript value must become JSON for an internal log. It is not a public API error format; use the JSON API error response guide for that separate contract.

1. Does a native Error become an empty JSON object?

A fresh Error commonly stringifies as an empty object because JSON.stringify visits enumerable own string-keyed properties, while the useful built-in Error details are not ordinary enumerable fields. Test the exact runtime behavior and its property descriptors so a later logger or framework change cannot silently alter what reaches production.

MDN documents JSON.stringify in terms of serializable properties, and its property enumerability guide explains why Object.keys sees a narrower set than direct property access. The first test makes that distinction concrete:

import assert from "node:assert/strict";

const error = new Error("database unavailable");

assert.equal(error.message, "database unavailable");
assert.equal(JSON.stringify(error), "{}");
assert.deepEqual(Object.keys(error), []);
assert.equal(
  Object.getOwnPropertyDescriptor(error, "message").enumerable,
  false
);

Do not conclude that the Error contains no information. Direct access still finds message and usually stack, while name is available through the prototype. A console may render those properties specially. JSON.stringify has no obligation to copy the console view.

This is also why spreading an Error is not a complete fix. The expression {...error} copies enumerable own properties only, so a plain Error still produces an empty object. A custom property assigned later may appear, creating an inconsistent record that contains a request ID but omits the actual message.

Test the assumption in every supported runtime rather than encoding a console screenshot into documentation. Browser developer tools, Node's inspector, and logging libraries may each present an Error differently. The language-level test above is intentionally small: it proves what the JSON boundary receives, independently of presentation. If a logger provides a documented Error serializer, test the logger's final output too, because passing an Error under the wrong key can bypass library-specific handling.

2. Does the serializer copy an explicit set of core fields?

Build a plain record from an explicit allowlist such as name, message, code, and stack policy. Test values and types rather than copying every own property. An allowlist makes the log schema reviewable, captures non-enumerable Error details deliberately, and avoids treating engine-specific or library-added properties as a stable public contract.

Node's current Error documentation says stack records where the Error was instantiated and notes that its details depend on the V8 stack trace API. Therefore assert that a stack is either a string or intentionally absent; do not snapshot exact file names and line numbers across runtimes.

import assert from "node:assert/strict";

function coreErrorRecord(error, { includeStack = false } = {}) {
  const record = {
    name: typeof error.name === "string" ? error.name : "Error",
    message: typeof error.message === "string" ? error.message : String(error),
  };
  if (typeof error.code === "string") record.code = error.code;
  if (includeStack && typeof error.stack === "string") record.stack = error.stack;
  return record;
}

const error = new TypeError("quantity must be numeric");
error.code = "INVALID_QUANTITY";

assert.deepEqual(coreErrorRecord(error), {
  name: "TypeError",
  message: "quantity must be numeric",
  code: "INVALID_QUANTITY",
});
assert.equal(typeof coreErrorRecord(error, { includeStack: true }).stack, "string");

Decide whether your organization uses name or type and whether code is a string, then keep that choice consistent with the structured JSON logging guide. Avoid copying line, column, fileName, errno, syscall, status, or response automatically. Some are useful in a specific service, but each deserves an explicit contract and privacy review.

Keep the conversion local to the boundary rather than adding Error.prototype.toJSON globally. A prototype modification changes every serialization site in the process, including analytics, caches, tests, and third-party code. A named function is easier to review, version, and invoke only where the output is appropriate. It also lets an API response deliberately use a safer format than an access-controlled diagnostic log.

3. Does custom metadata survive without leaking secrets?

Allow only named diagnostic metadata and reject or redact credentials, headers, tokens, cookies, and unbounded request bodies. Test both an approved field and a tempting secret on the same Error. A successful JSON.stringify call is not evidence of safety; enumerable custom properties are precisely the values most likely to pass through accidentally.

Simple assignment creates enumerable properties by default. That means error.requestId may survive even while error.message disappears, and error.authorization may survive too. Use an allowlist close to the logging boundary:

import assert from "node:assert/strict";

const SAFE_METADATA = ["requestId", "operation", "retryable"];

function safeMetadata(error) {
  return Object.fromEntries(
    SAFE_METADATA.flatMap((key) =>
      Object.hasOwn(error, key) ? [[key, error[key]]] : []
    )
  );
}

const error = new Error("upstream rejected request");
error.requestId = "req_204";
error.operation = "createInvoice";
error.retryable = false;
error.authorization = "Bearer secret-value";

const record = { ...coreErrorRecord(error), ...safeMetadata(error) };
assert.equal(record.requestId, "req_204");
assert.equal(record.authorization, undefined);
assert.equal(JSON.stringify(record).includes("secret-value"), false);

The allowlist is deliberately small. If metadata can contain nested user-controlled objects, validate or normalize those objects before logging. Merely removing a key named password misses apiKey, cookie, session, accessToken, and secrets embedded inside URLs or messages. The JSON security practices guide covers broader disclosure and injection boundaries.

Redaction tests should use unmistakable sentinel values, then search the complete emitted text for those sentinels. That catches leakage through both expected keys and surprising nested paths. Do not use a real credential as a fixture. Pair negative assertions with positive ones so a serializer that drops all metadata cannot pass merely because it also dropped the secret.

4. Does the logger normalize values that are not Error instances?

JavaScript permits throwing any value, so a catch binding may hold a string, null, number, plain object, or Error. Test each category and return a safe uniform record. Never read caught.message unconditionally or blindly spread an unknown object, because either choice can lose the event or expose arbitrary application data.

MDN's Error cause reference makes the same warning about cause values: they can be any type. The rule also applies to the value received by catch. A conservative normalizer can preserve a classification and short message without walking unknown objects:

import assert from "node:assert/strict";

function normalizeThrown(value, options) {
  if (value instanceof Error) return coreErrorRecord(value, options);
  if (typeof value === "string") {
    return { name: "NonErrorThrown", message: value };
  }
  if (value === null) {
    return { name: "NonErrorThrown", message: "null" };
  }
  return {
    name: "NonErrorThrown",
    message: Object.prototype.toString.call(value),
  };
}

assert.deepEqual(normalizeThrown("connection closed"), {
  name: "NonErrorThrown",
  message: "connection closed",
});
assert.deepEqual(normalizeThrown(null), {
  name: "NonErrorThrown",
  message: "null",
});
assert.equal(normalizeThrown({ token: "secret" }).message, "[object Object]");

Do not use JSON.stringify(value) as the fallback. A thrown object can contain cycles, BigInt values, throwing getters, large buffers, or secrets. Prefer Error instances in application code, but keep the boundary defensive because dependencies, browser events, and legacy code may violate that convention.

If a plain object carries an approved error code, extract that one property only after checking ownership and type. Resist the urge to preserve everything for later investigation. Logging is a data-export operation: once the record leaves the process, it may be copied into indexes, archives, alerts, and support tools with different access controls and retention periods.

5. Are cause chains and AggregateError children preserved safely?

Serialize causes and AggregateError children recursively, but cap depth, child count, and repeated references. Test an ordinary cause, a non-Error cause, an aggregate containing mixed values, and a cycle. Without bounds, a diagnostic helper can throw while handling the original failure or create an oversized log event during an incident.

The cause property is non-enumerable according to MDN's Error cause reference, so it needs explicit handling. AggregateError groups multiple failures, and its errors property contains the collected values. Those values are not guaranteed to be Error instances.

import assert from "node:assert/strict";

function serializeFailure(value, options = {}, state = {}) {
  const { includeStack = false, maxDepth = 3, maxChildren = 5 } = options;
  const depth = state.depth ?? 0;
  const seen = state.seen ?? new WeakSet();

  if (!(value instanceof Error)) return normalizeThrown(value, { includeStack });
  if (seen.has(value)) return { name: "CircularError", message: "[Circular]" };
  if (depth >= maxDepth) return { name: value.name, message: "[Depth limited]" };

  seen.add(value);
  const next = { depth: depth + 1, seen };
  const record = coreErrorRecord(value, { includeStack });

  if (Object.hasOwn(value, "cause")) {
    record.cause = serializeFailure(value.cause, options, next);
  }
  if (value instanceof AggregateError) {
    record.errors = Array.from(value.errors)
      .slice(0, maxChildren)
      .map((item) => serializeFailure(item, options, next));
  }
  return record;
}

const root = new Error("socket refused");
const wrapped = new Error("checkout failed", { cause: root });
assert.equal(serializeFailure(wrapped).cause.message, "socket refused");

const group = new AggregateError([root, "cache missed"], "dependencies failed");
assert.equal(serializeFailure(group).errors.length, 2);
assert.equal(serializeFailure(group).errors[1].name, "NonErrorThrown");

root.cause = wrapped;
assert.equal(serializeFailure(wrapped).cause.cause.message, "[Circular]");

Native JSON cannot represent object identity or cycles. MDN's cyclic-object explanation documents the resulting TypeError. A placeholder is appropriate for an internal diagnostic record, but it is intentionally lossy. If exact graph identity matters, JSON logging is the wrong transport.

A WeakSet detects repeated Error objects across both cause and aggregate branches. Decide whether a repeated object should become a circular marker or a short reference identifier; either is defensible for logs if documented. The important behavior is termination. Also cap message and stack lengths separately, because a shallow chain can still contain a very large string.

6. Does the final JSON record obey one bounded production contract?

Test the final serialized text, parsed shape, maximum lengths, redaction, and required envelope fields together. Unit tests for helper functions are necessary but insufficient: the actual logging call adds timestamp, level, service, message, and error nesting. A fixture should prove that one complete event remains parseable, bounded, and free of forbidden fields.

This final check connects serialization mechanics to the log event consumers actually query. Keep an error object nested under a predictable key instead of double-encoding it as a JSON string. A stack may contain newline characters, but JSON.stringify escapes them so one event can remain one JSON Lines record.

import assert from "node:assert/strict";

function buildErrorEvent(caught) {
  return {
    timestamp: "2026-10-22T09:30:00.000Z",
    level: "error",
    service: "billing-api",
    message: "invoice creation failed",
    error: serializeFailure(caught, {
      includeStack: false,
      maxDepth: 3,
      maxChildren: 5,
    }),
  };
}

const error = new Error("gateway rejected invoice");
error.authorization = "Bearer secret-value";
const text = JSON.stringify(buildErrorEvent(error));
const parsed = JSON.parse(text);

assert.equal(parsed.level, "error");
assert.equal(parsed.error.message, "gateway rejected invoice");
assert.equal(typeof parsed.error, "object");
assert.equal(text.includes("secret-value"), false);
assert.equal(text.includes("\\n"), false);
assert.ok(Buffer.byteLength(text, "utf8") < 4096);

The 4 KB limit is an example contract for this fixture, not a universal logging standard. Choose a limit from your shipper, platform, retention budget, and diagnostic needs, then test that real policy. Also test truncation markers so operators can distinguish a complete message from a shortened one.

Run the contract test against the same adapter used in production. Some loggers accept an Error as a special positional argument, while others expect a named field or serializer option. Testing only the standalone helper misses argument-order mistakes, double encoding, and framework middleware that replaces the record. Capture one final line from the configured logger and parse that line in the test.

After generating a sanitized fixture, inspect it with the JSON Viewer to confirm the final tree and field types. The viewer does not execute JavaScript, detect leaked secrets, verify log-size limits, or decide whether a stack is safe. Automated tests and access-control policy remain the evidence.

What should the production checklist verify?

A release check should prove behavior at the complete logging boundary: supported caught values normalize, required diagnostics remain queryable, secrets are absent, recursion and size are bounded, stack policy matches the environment, and the emitted value parses as one JSON record. Repeat the checks whenever a logger, runtime, transport, or error wrapper changes.

Use this compact checklist in code review:

QuestionPassing evidence
Does a plain Error retain meaning?Name and message are explicit fields
Are custom details controlled?Approved metadata survives; secret fixtures do not
Are unknown thrown values safe?String, null, and object fixtures normalize
Are nested failures bounded?Cause depth, aggregate count, and cycles have limits
Is stack exposure deliberate?Environment-specific test selects includeStack
Is the wire record stable?Parse, shape, byte-size, and forbidden-text assertions pass

The current qualitative interest is clear from recurring developer questions about Error becoming an empty object and recent community discussion of JSON round trips losing Error data. That evidence supports the problem, not a claim about search volume or growth. No reliable public keyword-volume figure was used to choose this title.

Frequently Asked Questions

Why does JSON.stringify(new Error()) return an empty object?
JSON.stringify normally visits enumerable own string-keyed properties. A standard Error keeps important details such as message and cause in non-enumerable properties, while name is usually inherited. A fresh Error therefore has no ordinary enumerable data for JSON.stringify to emit, so the result is commonly an empty object.
Should a production JSON log include an error stack?
Include a stack in restricted internal logs when it helps diagnosis, but treat it as sensitive operational data. Stack text can reveal file paths, package layout, query fragments, or user-derived values. Exclude it from public API responses, control log access and retention, and test both stack-enabled and stack-disabled policies.
Can JavaScript throw a value that is not an Error?
Yes. JavaScript permits throwing strings, numbers, null, plain objects, and other values, even though throwing Error instances is preferable. A logging boundary should normalize unknown caught values without assuming name, message, or stack exists. Preserve a safe summary and classification while avoiding blind serialization of arbitrary objects.
How should Error cause chains be serialized?
Serialize each cause into the same explicit error record, but impose a maximum depth and detect repeated objects. The cause property can contain any value and a chain can be cyclic after construction. A bounded serializer preserves useful context while preventing recursion failures, oversized events, and unsafe traversal of arbitrary application objects.
How should AggregateError be represented in JSON?
Represent AggregateError with the normal name, message, and optional cause fields plus an errors array whose entries use the same normalization rules. Bound both array length and recursion depth. The errors iterable may contain non-Error values, so every child must pass through the unknown-thrown-value path rather than an Error-only shortcut.

Inspect the Serialized Error Record

Paste a sanitized JSON fixture into the tree viewer to check its final fields and nesting. Keep executable tests and your logging policy as the source of truth.

Open JSON Viewer