Enrichment and context
Enrichers, the precedence of properties, and the ambient LogContext.
An enricher adds properties to every event that passes through a logger. Some are fixed (the application's name), some computed per event (memory in use), and one — fromLogContext() — copies the ambient context: properties that belong to the current request, job or user and should appear on everything logged while it runs.
Enrichers#
import { configure, withProperty, withProperties, withComputed, withEnvironment } from '@unhingged/logit';
import { withMachineName, withProcessId, withProcessInfo } from '@unhingged/logit/node';
configure({
enrichers: [
withProperty('Application', 'shop'),
withProperties({ Version: '1.4.0', Region: 'eu-central' }),
withComputed('HeapMB', () => Math.round(process.memoryUsage().heapUsed / 1e6)),
withEnvironment(), // Environment: NODE_ENV
withProcessInfo(), // MachineName, ProcessId, Environment — Node only
],
});| Enricher | Entry | Adds |
|---|---|---|
withProperty(name, value, destructure?) | root | one fixed property |
withProperties(object, destructure?) | root | several fixed properties |
withComputed(name, fn, destructure?) | root | fn(event) per event, skipped when the event already has name |
withEnvironment(name?) | root | Environment from NODE_ENV or the given name; no-op without process.env |
withTraceContext(getter) | root | traceId / spanId from a getter — see OpenTelemetry |
fromLogContext() | root | the ambient context — on by default, see below |
withMachineName() | /node | MachineName |
withProcessId() | /node | ProcessId |
withProcessInfo() | /node | the two above plus Environment |
properties: { Application: 'shop' } in the options is the shorthand for withProperties().
Writing one#
An enricher is a function of the event. Use addPropertyIfAbsent — it captures the value with the logger's destructuring options and lets anything closer to the call win:
import type { Enricher } from '@unhingged/logit';
const withRegion: Enricher = (event) => event.addPropertyIfAbsent('Region', process.env.FLY_REGION);addOrUpdateProperty overwrites; removeProperty deletes; event.traceId / event.spanId are plain fields. An enricher that throws is reported to selfLog and skipped.
Precedence#
Several places can supply the same property name. The one closest to the call wins, because each later step uses addPropertyIfAbsent:
- Template arguments —
{UserId}in the message - One-off properties —
log.info({ userId }, '…') forContext()properties bound to the loggerLogContext— innermost frame first- Enrichers, in the order configured
So a request's LogContext UserId is overridden by a {UserId} in a particular message, and an enricher's Environment never clobbers one set explicitly.
LogContext#
LogContext is Serilog's LogContext: properties pushed here are seen by fromLogContext(), which every createLogger / configure includes unless logContext: false.
import { LogContext, log } from '@unhingged/logit';
await LogContext.run({ RequestId: id, TenantId: tenant }, async () => {
await processOrder(); // everything logged in here, at any depth, carries both
await LogContext.run({ Step: 'payment' }, () => charge()); // nests; inner wins on a clash
});run, push, current#
| Call | Does |
|---|---|
LogContext.run(props, fn) | runs fn with props ambient; returns fn's result (a promise if fn is async) |
LogContext.push(props) | makes props ambient for the rest of the current scope; returns a handle with dispose() and [Symbol.dispose] |
LogContext.current() | the merged ambient properties, innermost winning |
LogContext.frame() | the innermost frame, for enrichers that walk it themselves |
LogContext.runScope(props, fn) | run plus a fresh diagnostic context — what the request adapters use |
LogContext.use(store) | installs a store (an AsyncLocalStorage) where detection failed |
LogContext.isAsync | whether the context follows async execution |
With TypeScript 5.2+ the using form reads best:
function handle(user: User) {
using _ = LogContext.push({ UserId: user.id });
log.info('Loading profile'); // UserId: …
} // popped herePrefer run() in async code: push() relies on AsyncLocalStorage.enterWith(), which binds the rest of the current async scope — fine inside a function, surprising at the top of a module.
Two names are special#
traceId and spanId in the context become the event's traceId / spanId fields — the ones CLEF writes as @tr / @sp and OTLP as the trace context — instead of ordinary properties:
LogContext.run({ traceId: span.spanContext().traceId, spanId: span.spanContext().spanId }, () => …);How it follows async code#
On Node, Bun and Deno the frames live in an AsyncLocalStorage, obtained through process.getBuiltinModule('node:async_hooks') so the root entry has no node: import and bundles for the browser. The /node, /next and /express entries import it statically and install it if detection failed — /next is how the Next.js edge runtime gets it.
In a browser there is no AsyncLocalStorage; the context is a synchronous stack. run() and push() work within synchronous code and lose the frame across await. See Browser.
If you run somewhere exotic that has an AsyncLocalStorage the package cannot find:
import { AsyncLocalStorage } from 'node:async_hooks';
LogContext.use(new AsyncLocalStorage());The diagnostic context#
diagnosticContext.set(name, value) is for one specific event: the request-completion event. Anything set during a request lands on that request's single completion line — see Request logging. Outside a request scope it is a no-op; diagnosticContext.active tells you which.