# tslog Recipes Copy-paste patterns for common logging tasks, with a focus on production services and AI/agentic apps. All patterns use the public v5 API. Settings are **grouped** (`mask`, `json`, `pretty`, `stack`, `meta`) — there are no flat keys. Snippets that construct a logger assume `import { Logger } from "tslog";` — recipe-specific imports are shown inline. ## 1. Structured JSON for observability / LLM ingestion ```ts import { Logger } from "tslog"; const log = new Logger({ type: "json", minLevel: "INFO" }); // Fields-first (pino-style) or string-first — both work: log.info({ event: "request", method: "GET", path: "/users", status: 200, durationMs: 12 }); log.info("request complete", { status: 200 }); // → one flat, fields-first JSON object per line: // { "message": "...", "level": "INFO", "levelId": 3, "time": "…", "status": 200, "_logMeta": { "v": 5, … } } ``` The default `type` is `pretty` everywhere: `new Logger()` is pretty + colorized in an interactive terminal, and uncolored pretty when piped/redirected/CI (`NO_COLOR` just strips colors). Structured JSON is opt-in — pass `type: "json"`. ## 2. Per-request (or per-agent) child logger Child loggers inherit settings, names, prefixes, and transports — create one per request, job, or agent step. `child(...)` is an alias for `getSubLogger(...)`. ```ts const log = new Logger({ type: "json", name: "api" }); function handleRequest(req) { const requestLog = log.getSubLogger({ name: "req" }); // or log.child({ name: "req" }) requestLog.info("started", { method: req.method, path: req.url }); } ``` ## 3. Automatic request correlation with `runInContext` (AsyncLocalStorage) `runInContext(ctx, fn)` attaches its fields onto every log emitted inside `fn` — across `await`, timers, and nested sub-loggers — without threading ids through your code. Backed by `AsyncLocalStorage`, auto-resolved on Node/Deno/Bun; a graceful no-op in browsers. `getContext()` reads the active fields. ```ts const log = new Logger({ type: "json" }); function withRequest(req, next) { return log.runInContext({ requestId: req.id, traceId: req.traceId }, next); } // Anywhere inside the request — including deep sub-loggers — every log carries the ids under _logMeta: log.info("processing"); ``` On **Cloudflare Workers**, auto-resolution can't work — inject the storage instead (enable the `nodejs_als` or `nodejs_compat` compatibility flag in `wrangler.toml`): ```ts import { AsyncLocalStorage } from "node:async_hooks"; const log = new Logger({ type: "json", contextStorage: new AsyncLocalStorage() }); export default { async fetch(request, env, ctx) { return log.runInContext({ requestId: crypto.randomUUID() }, () => handle(request)); }, }; ``` ## 4. Redact secrets, PII, and prompts Mask by key, by dotted path (`*` matches one segment), or by regex — all grouped under `mask`. ```ts const log = new Logger({ type: "json", mask: { keys: ["password", "apiKey", "authorization", "token", "prompt", "completion"], caseInsensitive: true, paths: ["user.password", "*.token", "headers.authorization"], // censor shapes PATH-matched values ("hash" also applies to key matches); // values matched by `keys` are otherwise always replaced with the placeholder: // censor: "remove", // delete a path-matched key instead of replacing its value // censor: (v) => `****${String(v).slice(-4)}`, regex: [/\b[A-Za-z0-9]{32,}\b/g], // long token-like substrings in strings }, }); log.info({ user: "alice", password: "hunter2", apiKey: "sk-..." }, "auth"); // → { "message": "auth", "user": "alice", "password": "[***]", "apiKey": "[***]", ... } ``` ## 5. Logging LLM calls (tokens, cost, latency, model) with the genai preset `tslog/presets/genai` maps a friendly input to OpenTelemetry `gen_ai.*` attributes plus a compact summary. Spread the result into a log call. ```ts import { genai } from "tslog/presets/genai"; const log = new Logger({ type: "json", name: "llm" }); log.info("chat completion", genai({ model: "claude-opus-4", inputTokens: 1200, outputTokens: 350, costUsd: 0.021, latencyMs: 845, tool: "search", })); // emits gen_ai.* attributes + a { model, tokens, costUsd, latencyMs } summary ``` ## 6. Route levels to the right console method ```ts const log = new Logger({ type: "pretty", pretty: { levelMethod: { WARN: console.warn, ERROR: console.error, FATAL: console.error, "*": console.log }, }, }); ``` ## 6a. Collapsible objects in the browser console In real browsers `pretty.passObjectsNatively` is **on by default**: non-`Error` args reach the console **by reference**, so DevTools renders them as interactive, collapsible trees — and, paired with `levelMethod`, warn/error get their native expandable stack group. Everywhere else (Node, workers/edge, React Native) it defaults off, because captured-as-text consoles degrade raw references. ```ts const log = new Logger({ type: "pretty", pretty: { levelMethod: { WARN: console.warn, ERROR: console.error, FATAL: console.error }, }, }); log.info("user loaded", { id: 42, roles: ["admin"] }); // object stays clickable in DevTools ``` `inspectOptions` does not apply to natively-passed args (the console renders them); logged `Error`s still use the pretty error template. Switch it off (`pretty: { passObjectsNatively: false }`) when you need **log-time snapshots** (a raw reference expanded later shows the object's *current*, possibly mutated state — unless `mask` is configured, which clones args) or **text-matchable output** (the DevTools filter box and console-capturing harnesses only match the rendered string). ## 6b. Keep pretty output on one line (log aggregators) Aggregators like CloudWatch or Datadog treat each line as one entry, so a multi-line inspected object becomes several fragmented entries. Raise `inspectOptions.breakLength` to keep objects on a single line while keeping color: ```ts const log = new Logger({ type: "pretty", pretty: { inspectOptions: { breakLength: Infinity, colors: true, compact: true } }, }); log.info("request", { method: "GET", path: "/health", ms: 3 }); // one line, still colored ``` ## 6c. Log only when a condition holds `log.if(condition)` returns the logger when truthy and a no-op when falsy, so a per-call guard reads as a chain: ```ts log.if(!ok).warn("action failed", { id }); ``` The falsy no-op still evaluates its arguments — to skip *expensive* construction, guard with `isLevelEnabled` (recipe 12). ## 7. Ship logs to a backend (custom transport + middleware) Transports run in isolation — a transport that throws never crashes logging or stops siblings. Use `use(...)` middleware to enrich or drop logs before they are formatted. ```ts const log = new Logger({ type: "json" }); // Enrich every log, then sample below WARN: log.use((ctx) => { ctx.meta.region = "eu"; return ctx; }); log.use((ctx) => (ctx.logLevelId >= 4 || Math.random() < 0.1 ? ctx : null)); // A sink that only sees WARN+ and receives a JSON line regardless of the logger's type: const detach = log.attachTransport({ name: "http", minLevel: "WARN", format: "json", write: (record, line) => { void fetch("https://logs.example.com/ingest", { method: "POST", body: line }); }, }); ``` ## 7b. Send errors and logs to Sentry Errors as Sentry issues: the record a transport receives still carries the native `Error` instance (as `nativeError` on the serialized error), so Sentry gets the real exception — full stack and `cause` chain — not a stringified copy. ```ts import * as Sentry from "@sentry/node"; const log = new Logger(); log.attachTransport({ name: "sentry", minLevel: "ERROR", // only errors and fatals leave the process format: "json", // `line` is the flat JSON record regardless of the logger's type write(record, line) { const { _logMeta, ...fields } = JSON.parse(line); const level = _logMeta.logLevelName === "FATAL" ? "fatal" : "error"; const nativeError = [record, ...Object.values(record)] .map((value) => (value as { nativeError?: unknown } | null)?.nativeError) .find((candidate): candidate is Error => candidate instanceof Error); if (nativeError) Sentry.captureException(nativeError, { level, extra: fields }); else Sentry.captureMessage(String(fields.message ?? line), { level, extra: fields }); }, }); ``` Records as [Sentry Logs](https://docs.sentry.io/platforms/javascript/guides/node/logs/): enable logs in the SDK (`Sentry.init({ dsn, enableLogs: true })`) and forward every record through `Sentry.logger.*` — the seven tslog levels map one-to-one, with `SILLY` joining `trace`. Runs fine alongside the issues transport above. ```ts const toSentry = { SILLY: "trace", TRACE: "trace", DEBUG: "debug", INFO: "info", WARN: "warn", ERROR: "error", FATAL: "fatal" } as const; log.attachTransport({ name: "sentry-logs", format: "json", // fields of the flat JSON record become Sentry log attributes write(_record, line) { const { _logMeta, message, ...attributes } = JSON.parse(line); const method = toSentry[_logMeta.logLevelName as keyof typeof toSentry] ?? "info"; Sentry.logger[method](String(message ?? ""), attributes); }, }); ``` ## 7c. Ship to Better Stack (Logtail) Better Stack ingests JSON over HTTP: point `httpTransport` at your source's ingesting host, authorize with the source token, and rename the time key to `dt`. ```ts import { httpTransport } from "tslog/transports/http"; const log = new Logger({ type: "json", json: { timeKey: "dt" } }); // Better Stack reads the timestamp from "dt" log.attachTransport( httpTransport({ url: "https://in.logs.betterstack.com", // your source's ingesting host (see the source's settings page) headers: { authorization: `Bearer ${process.env.BETTER_STACK_SOURCE_TOKEN}` }, format: "json", bodyFormat: "array", // Better Stack accepts a JSON array of events per request batchSize: 50, flushIntervalMs: 2000, }), ); await log.flush(); // drain before shutdown (await using does this automatically) ``` ## 8. Emit pino-shaped NDJSON (drop-in for pino consumers) ```ts import { pinoTransport } from "tslog/presets/pino"; const log = new Logger({ type: "hidden" }); // suppress console, let the transport own output log.attachTransport(pinoTransport((line) => process.stdout.write(line + "\n"))); log.info({ userId: 42 }, "user logged in"); // → {"level":30,"time":1751191872000,"pid":12345,"hostname":"…","msg":"user logged in","userId":42} // Errors ship in pino's serializer shape: err: { type, message, stack: "", cause? } // (pino-pretty and error trackers parse it as-is; pinoFormat({ errorShape: "tslog" }) keeps frame arrays.) ``` ## 9. OpenTelemetry logs — POST straight to a collector (OTLP/JSON) `otlpFormat` emits real OTLP/JSON (camelCase proto3 fields, typed attributes, `exception.*` mapping for logged errors); `otlpBatchBody` merges each HTTP batch into ONE envelope, so the transport can POST directly to a collector's `/v1/logs` endpoint. ```ts import { otlpFormat, otlpBatchBody } from "tslog/otel"; import { httpTransport } from "tslog/transports/http"; const log = new Logger({ type: "hidden" }); log.attachTransport( httpTransport({ url: "http://collector:4318/v1/logs", format: otlpFormat({ resource: { "service.name": "checkout" }, getSpanContext: () => log.getContext() }), encodeBody: otlpBatchBody, }), ); ``` For a custom (non-collector) pipeline that prefers the spec's prose field names (`Timestamp`, `Body`, `Attributes`, ...), use `otelFormat`/`toOtelRecord` instead — collectors do not ingest that shape. ## 10. Write to a file (Node), with flush on shutdown The `tslog/transports/file` transport buffers and flushes; `await using` (or `flush()`) drains it before exit. ```ts import { Logger } from "tslog"; import { fileTransport } from "tslog/transports/file"; await using log = new Logger({ type: "json" }); log.attachTransport(fileTransport({ path: "./logs/app.log", format: "json" })); log.info("ready"); // the buffered file output is flushed when the `await using` scope ends ``` Built-in exit safety: the file transport registers guarded exit hooks by default (`exitHooks: false` opts out) — an async flush on `beforeExit` and a synchronous drain on `exit`, so even a bare `process.exit(0)` or an uncaught exception does not lose the buffered tail. fs errors (disk full, permissions) are contained and reported via `onError` (default: one `console.error` per error burst); a failed open is retried on the next write. The http transport flushes on `beforeExit` (and `pagehide` in browsers) the same way; the worker transport (Node-only) drains on `beforeExit`. ## 10b. Graceful shutdown on SIGTERM/SIGINT (app-owned) Signal handling belongs to the application — a library installing a `SIGTERM` listener would change your process's termination semantics. Wire the logger into your own handler: ```ts import { Logger } from "tslog"; const log = new Logger({ type: "json" }); async function shutdown(code: number): Promise { await log.flush(); // awaits async transport writes AND each transport's own flush() process.exit(code); } process.on("SIGTERM", () => void shutdown(0)); process.on("SIGINT", () => void shutdown(130)); process.on("uncaughtException", (error) => { log.fatal("uncaught exception", error); void shutdown(1); }); ``` Note: disposing a sub-logger (`await using child = log.child(...)`) flushes shared transports but only *disposes* transports the child itself attached — a request-scoped child can never terminate the root logger's sinks. ## 11. Keep slow sink I/O off the event loop (Node worker thread) The `tslog/transports/worker` transport runs its destination write on a `node:worker_threads` worker, so a slow file/stream sink under high log volume doesn't stall the application's event loop (like pino's `thread-stream`). Note: this does **not** speed up `log.info()` — the record is still built and serialized on the main thread; only the write moves off-thread. Off Node it falls back to a synchronous inline write via `node:fs` where available (Deno/Bun); on runtimes without `fs` (browsers/edge) the line is dropped. ```ts import { Logger } from "tslog"; import { workerTransport } from "tslog/transports/worker"; const log = new Logger({ type: "json" }); await using sink = workerTransport({ destination: "file", path: "./logs/app.log", format: "json" }); log.attachTransport(sink); log.info("ready"); // `await using` drains the worker queue and terminates the thread on scope exit; // or call `await sink.flush()` then `await sink[Symbol.asyncDispose]()` manually. ``` ## 12. Fast production logging Skip stack capture (the dominant per-log cost) when you don't need code position. `type: "json"` already defaults `stack.capture` to `"off"`; set it explicitly for pretty too. ```ts const log = new Logger({ type: "json", stack: { capture: "off" } }); // Guard expensive payload construction with isLevelEnabled: if (log.isLevelEnabled("DEBUG")) { log.debug("state", expensiveSnapshot()); } ``` ## 12a. Original `.ts` positions in error stacks (Node, Bun & Deno) Logging an `Error` thrown from transpiled or bundled TypeScript normally reports the compiled `dist/*.js` position. Outside production, tslog resolves that position through the file's source map back to your original `.ts` file/line/column automatically — no config needed: ```ts const log = new Logger(); try { riskyCall(); // throws from compiled dist/app.js, which has a sourceMappingURL } catch (err) { log.error(err); // stack frames report src/app.ts, not dist/app.js } ``` This works with the maps every mainstream toolchain emits — `tsc`, esbuild, webpack, Rollup, and the indexed (`sections`) format from Turbopack/Next.js dev — and bundler-virtual source paths (`webpack://…`, `[project]/…`) come out as clean project-relative paths. Next.js (Turbopack) and TanStack Start (Vite) dev servers are verified end-to-end in CI. **Next.js dev log mirroring:** `next dev` mirrors server-side console output into the browser devtools with a `Server` badge, as raw terminal text. tslog's ANSI colors in that mirror render fine in Chrome (its console interprets ANSI) but appear as literal `[32m…` escapes in Safari and Firefox — that's the mirror, not tslog's own browser output. If it bothers you, run the dev server with `NO_COLOR=1` or configure the server logger with `pretty: { style: false }` in development; the text is unchanged, only terminal colors are lost. Off by default in production (`NODE_ENV === "production"`) since it costs a source-map read/parse the first time each file's frame is logged (cached after that). Force it either way with `TSLOG_SOURCE_MAPS=on` / `TSLOG_SOURCE_MAPS=off`. Node/Bun/Deno only — browsers already get this from devtools, so tslog never attempts it there. ## 12b. Smallest browser / edge bundle (`tslog/slim`) The full entry ships masking, pretty rendering, and stack parsing whether or not your config uses them (output `type` is a runtime value, so bundlers cannot drop them). When bundle size matters more, `tslog/slim` is the same structured-JSON pipeline at less than half the size — and it fails HONESTLY: `mask` settings and `type: "pretty"` throw instead of being silently ignored. ```ts import { Logger } from "tslog/slim"; // ~9.8KB gzip vs ~20.7KB for the full browser entry const log = new Logger({ name: "edge", bindings: { service: "checkout" } }); log.info("request", { requestId: crypto.randomUUID() }); // same flat fields-first JSON ``` Kept: levels, sub-loggers, bindings, custom levels, middleware, `runInContext` correlation, transports. Dropped: masking, pretty output, stack capture (`_logMeta.path`; error `stack` arrays are empty), `fromEnv`, settings validation. Develop against the full entry, ship slim. ## 12c. Next.js / TanStack Start: full-stack logging (server + client) Use the full `Logger` on the server and `tslog/lite` in `"use client"` components. Two files, no other setup: ```ts // lib/logger.server.ts — route handlers, server components, server actions import "server-only"; // optional guard: importing this from client code fails the build import { Logger } from "tslog"; export const log = new Logger({ name: "app", // pretty with original .ts positions in dev (Turbopack source maps work out of the box); // structured JSON for log collectors in production: type: process.env.NODE_ENV === "production" ? "json" : "pretty", }); ``` ```ts // lib/logger.client.ts — "use client" components import { createLiteLogger } from "tslog/lite"; export const log = createLiteLogger({ minLevel: process.env.NODE_ENV === "production" ? "WARN" : "SILLY", }); ``` ```tsx "use client"; import { log } from "../lib/logger.client"; export function AddToCart({ id }: { id: string }) { return ; } ``` Why `tslog/lite` on the client: each level method **is** the bound native `console.*` method, so the file:line badge devtools attaches to every log line points at *your component* — source-mapped by devtools to the original `.tsx` — and logged objects stay live and collapsible. The full `Logger` also runs in the browser, but any wrapped console makes that badge point at the wrapper, and printed positions under a bundler dev server show the served chunk path (browser-side source-map resolution is intentionally not done — `.map` fetches would be async, logging is synchronous). The same split works for any Vite-SSR framework (TanStack Start, etc.): full `Logger` in server routes/loaders, `tslog/lite` in components. ### Keeping one import path (retrofitting an existing codebase) The split above asks every call site to know which half it wants. In a codebase that already funnels logging through a single module — `import { log } from "@/lib/logger"` in hundreds of files — keep that entrypoint and let the bundler pick the half, with no call-site churn: ```jsonc // package.json — "#logger-impl" is a private import subpath, resolved per bundle target { "imports": { "#logger-impl": { "browser": "./src/lib/logger.client.ts", "default": "./src/lib/logger.server.ts" } } } ``` ```ts // src/lib/logger.ts — the unchanged entrypoint every file already imports export { log } from "#logger-impl"; ``` `logger.server.ts` and `logger.client.ts` stay exactly as above. Client bundles resolve `#logger-impl` through the `browser` condition; every server runtime falls through to `default`. Needs `moduleResolution: "bundler"` (or `node16`/`nodenext`) in `tsconfig.json`. The `imports` field and its `browser` condition are honored by Next.js/Turbopack, Vite, webpack, and esbuild. Two caveats specific to this shape: - **Don't key it on the `react-server` condition.** It reads like the "am I on the server?" switch and is not: React applies `react-server` only to Server Components, so route handlers and server actions resolve *without* it and would silently receive the client logger — no error, no build failure, just logs that stop reaching your transports. The `browser` condition keys on the bundle target, which is the distinction actually meant here. - **Drop the `import "server-only"` guard** from `logger.server.ts` if you added it. The shared entrypoint is reachable from client components by design, which is exactly what that guard fails the build for. The `browser` condition is what enforces the split here — and it keeps the full `Logger` and its transports out of the client bundle just the same. Modules that need the server-only API surface (`runInContext`/`AsyncLocalStorage` wiring, `attachTransport`) should import `./logger.server` directly instead of going through the shim. ### Sub-loggers on the client `LiteLogger` offers the same `getSubLogger()` / `child()` calls as the server logger, so per-area names carry across the split: ```ts // lib/logger.client.ts import { createLiteLogger } from "tslog/lite"; export const log = createLiteLogger({ name: "app", minLevel: process.env.NODE_ENV === "production" ? "WARN" : "SILLY", }); ``` ```tsx "use client"; import { log } from "../lib/logger.client"; const cartLog = log.getSubLogger({ name: "cart" }); // labels lines "app:cart" export function AddToCart({ id }: { id: string }) { return ; } ``` Sub-loggers nest to any depth and inherit `minLevel` and the sink unless overridden (an unresolvable `minLevel` override inherits the parent's level, as on the server). Names join with the *parent's* `nameSeparator` (default `":"`); a separator passed to `getSubLogger` only governs joins that child performs for its own descendants. The label is partially applied with `Function.prototype.bind`, which returns another *native* console function — so unlike a wrapper (`(...args) => console.info(name, ...args)`), the devtools file:line badge still points at your component rather than at the logger. Three things to know about that binding: - **Names can't hijack arguments.** Every `%` in a label is escaped before binding, so even a runtime-derived name containing `%s`/`%d`/`%c` renders literally instead of consuming the first logged value. - **Printf interpolation is off on named loggers.** The label occupies the console's format-string slot, so `log.info("count: %d", n)` prints the `%d` literally (with `n` appended) on a *named* lite logger. Unnamed loggers keep full printf semantics; if you need both, fold the label into the message yourself. - **`nameSeparator` and `console` are lite-only options.** Through the shared-entrypoint pattern above, `getSubLogger({ name })` works on both halves — but the full `Logger` rejects `nameSeparator`/`console` as unknown settings (a dev warning, and a **throw** under `strictConfig: true`). Keep shared call sites to `{ name, minLevel }` and set lite-only options where the client logger is constructed. Name *shape* also differs across the split: lite folds the chain into one string (`child.name === "app:cart"`), while the server logger keeps the leaf `name` plus a `parentNames` array — its JSON output never joins them, and its pretty output joins with `pretty.errorParentNamesSeparator` (also `":"` by default). Labels match visually at the defaults; code that reads names programmatically sees different shapes per half. ### Client logs to Sentry (and other console-instrumenting SDKs) `tslog/lite` has no transports by design — but its methods *are* the console, and browser telemetry SDKs instrument the console globally. So lite composes with Sentry without any transport code: - **Breadcrumbs come for free.** The Sentry browser SDK's default breadcrumbs integration records `console.*` calls, so every lite log becomes a breadcrumb attached to the next error event — usually the shape you want client logs in (context around a failure, not a firehose). - **Promote errors to events** with one line in `Sentry.init`: ```ts // instrumentation-client.ts / sentry.client.config.ts Sentry.init({ dsn: "...", integrations: [Sentry.captureConsoleIntegration({ levels: ["error"] })], }); ``` One caveat that follows from lite's `.bind()` design: lite captures the console method **at construction time**, and Sentry instruments the console by replacing its methods at `Sentry.init`. A `LiteLogger` constructed *before* Sentry initializes holds the original uninstrumented function and bypasses Sentry silently. With `@sentry/nextjs` the client config runs before app modules, so a module-scope `createLiteLogger(...)` is fine — but if you initialize Sentry lazily, construct your loggers after it. (Relatedly: once any SDK wraps the console, the devtools file:line badge points at the SDK's wrapper app-wide — that's inherent to console instrumentation, not to lite; add the SDK to devtools' ignore list to restore attribution.) Route around the console entirely — full `Logger` or `tslog/slim` in the browser with `attachTransport` / `tslog/transports/http` — when you need structured JSON to your own collector, sampling, or **masking**: client logs are where tokens and emails leak, lite has no masking, and Sentry's `beforeBreadcrumb` is a thin place to scrub. The trade is the one this section keeps making: transports and masking come from the full pipeline, and the full pipeline is what moves the devtools badge off your component. ## 13. Configure from environment / typed config ```ts import { Logger, defineConfig } from "tslog"; // TSLOG_LEVEL / TSLOG_TYPE / TSLOG_NAME (plus NO_COLOR / FORCE_COLOR), overrides win: const log = Logger.fromEnv({ name: "api" }); // defineConfig gives editor/agent autocomplete on the grouped settings: const settings = defineConfig({ type: "json", mask: { keys: ["password"] } }); const log2 = new Logger(settings); ``` ## 14. Deterministic logs in tests (snapshots without fake timers) `createTestLogger` can freeze ONLY the logger's clock (interval-driven transports and user timers keep running — no `vi.useFakeTimers()` sledgehammer) and normalize machine-varying meta for snapshots. ```ts import { createTestLogger, normalizeMeta } from "tslog/testing"; // Frozen per-logger clock: const { logger, logs, lines } = createTestLogger({ type: "json" }, { now: () => new Date("2026-01-01T00:00:00Z") }); // Snapshot-stable output (epoch clock, hostname/runtimeVersion pinned, no _logMeta.path): const snap = createTestLogger({ type: "json" }, { normalize: true }); snap.logger.info("ready", { port: 3000 }); expect(snap.lines[0]).toMatchSnapshot(); // Or scrub output captured elsewhere: expect(normalizeMeta(capturedJsonLine)).toMatchSnapshot(); ``` The same seam works on any logger in production: `new Logger({ clock: () => new Date(...) })`. ## 15. Timestamp control (`json.time`) ```ts new Logger({ type: "json", json: { time: "epoch" } }); // "time": 1751191872000 — pino-style epoch ms new Logger({ type: "json", json: { time: false } }); // no top-level time key — diff-friendly CI output new Logger({ type: "json", json: { time: (d) => String(BigInt(d.getTime()) * 1_000_000n) } }); // ns (Loki) ``` `_logMeta.date` always stays a UTC ISO string; `json.time` only shapes the top-level key.