Concepts

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
  ],
});
EnricherEntryAdds
withProperty(name, value, destructure?)rootone fixed property
withProperties(object, destructure?)rootseveral fixed properties
withComputed(name, fn, destructure?)rootfn(event) per event, skipped when the event already has name
withEnvironment(name?)rootEnvironment from NODE_ENV or the given name; no-op without process.env
withTraceContext(getter)roottraceId / spanId from a getter — see OpenTelemetry
fromLogContext()rootthe ambient context — on by default, see below
withMachineName()/nodeMachineName
withProcessId()/nodeProcessId
withProcessInfo()/nodethe 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:

  1. Template arguments — {UserId} in the message
  2. One-off properties — log.info({ userId }, '…')
  3. forContext() properties bound to the logger
  4. LogContext — innermost frame first
  5. 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#

CallDoes
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.isAsyncwhether 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 here

Prefer 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.