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#
| Value | Without an operator | With @ | With $ |
|---|---|---|---|
string, number, boolean, null | as is | as is | as is / String(v) |
undefined | null | null | null |
bigint | a number if it fits, else its digits as a string | same | string |
symbol, function | "Symbol(x)", "[Function name]" | same | same |
Date | kept as a Date (formatters render ISO 8601); an invalid date is null | same | ISO string |
Error | a structure — see Errors | same | String(err) |
plain object ({} or Object.create(null)) | captured structurally | same | toString() |
array, Set, typed array | an array of captured values | same | "a,b" |
Map | an object when every key is a string or number, else an array of [key, value] pairs | same | string |
| class instance | its own toString() if overridden, else its toJSON(), else the class name | own enumerable properties + $type | string |
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
},
});| Option | Default | Note |
|---|---|---|
maxDepth | 10 | counts nesting from the property down |
maxStringLength | unlimited | the cut string ends with … |
maxCollectionCount | unlimited | silent 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.