Concepts

Structured data

How values are captured: scalars, objects, class instances, errors, limits, policies.

Capturing is the step between the logging call and the event: every argument is turned into a value that is safe to hold, serialise and ship — no cycles, no unbounded depth, no secrets — once, so every sink sees the same thing. It is Serilog's destructuring with JavaScript defaults.

The rules#

ValueWithout an operatorWith @With $
string, number, boolean, nullas isas isas is / String(v)
undefinednullnullnull
biginta number if it fits, else its digits as a stringsamestring
symbol, function"Symbol(x)", "[Function name]"samesame
Datekept as a Date (formatters render ISO 8601); an invalid date is nullsameISO string
Errora structure — see ErrorssameString(err)
plain object ({} or Object.create(null))captured structurallysametoString()
array, Set, typed arrayan array of captured valuessame"a,b"
Mapan object when every key is a string or number, else an array of [key, value] pairssamestring
class instanceits own toString() if overridden, else its toJSON(), else the class nameown enumerable properties + $typestring

The one place this differs from Serilog is plain objects: in JavaScript a literal { city, zip } is data, so it is captured whole without @. The operator is reserved for class instances, which are behaviour as often as data — and where dumping one by accident (a Prisma client, a Request) is the classic incident.

log.info('Shipped to {Address}', { city: 'Budapest' });   // "Address":{"city":"Budapest"}
log.info('Shipped to {Address}', new Address('Budapest')); // "Address":"Address"   (no toString override)
log.info('Shipped to {@Address}', new Address('Budapest')); // "Address":{"$type":"Address","city":"Budapest"}
log.info('Fetching {Url}', new URL('https://x.y/z'));       // "Url":"https://x.y/z"  (URL has toString)

Only own enumerable properties are captured — no prototype getters, no symbols. For a class whose data lives behind getters, write a policy.

Limits#

Set once in destructuring, applied everywhere — call arguments, forContext, enrichers:

configure({
  destructuring: {
    maxDepth: 10,             // beyond it: '[Object]' / '[Array]'
    maxStringLength: 4096,    // longer strings end with …
    maxCollectionCount: 100,  // arrays, Sets and Maps are cut
  },
});
OptionDefaultNote
maxDepth10counts nesting from the property down
maxStringLengthunlimitedthe cut string ends with …
maxCollectionCountunlimitedsilent truncation, as in Serilog

A cycle is replaced by '[Circular]'. A getter that throws becomes '[Throws: message]'.

Policies#

A policy sees every non-scalar value first and may substitute something else — Serilog's IDestructuringPolicy. byTransforming is the common shape:

import { byTransforming, configure } from '@unhingged/logit';

configure({
  destructuring: {
    policies: [
      byTransforming(Request, (r) => ({ method: r.method, url: r.url })),
      byTransforming(Money, (m) => `${m.amount} ${m.currency}`),
      byTransforming(User, (u) => ({ id: u.id, name: u.name })), // never the password hash
    ],
  },
});

The substitute is captured again by the normal rules, so return something simpler than you were given — returning the same type recurses, Serilog's rule too. A policy that throws is skipped.

A policy is any function (value: object) => { value: unknown } | undefined:

const policy = (value: object) => (value instanceof Buffer ? { value: `<${value.length} bytes>` } : undefined);

$type#

When a class instance is destructured, its class name is written as $type — the same tag Serilog writes to JSON — so a query can select $type = 'Order'. Turn it off with destructuring: { typeTag: false }.

Redaction#

Keys named in redact are replaced with '[Redacted]' at any depth, during capture, before any sink. Bare names match anywhere; dotted paths match from the root; * is one segment:

configure({ destructuring: { redact: ['password', 'authorization', 'user.*.ssn', '*.token'] } });

The whole story — what matches, what does not, policies for masking whole types — is in Redaction.

Capturing by hand#

The same function the logger uses is exported, for sinks and enrichers that build values of their own:

import { captureValue, DEFAULT_DESTRUCTURING } from '@unhingged/logit';

captureValue(new Map([['a', 1]]), undefined, DEFAULT_DESTRUCTURING); // { a: 1 }

Inside an enricher, event.addPropertyIfAbsent(name, value, destructure) captures with the logger's own options.