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.
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".
Child loggers inherit settings, names, prefixes, and transports — create one per request, job, or agent step. child(...) is an alias for getSubLogger(...).
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 });
}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.
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):
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));
},
};Mask by key, by dotted path (* matches one segment), or by regex — all grouped under mask.
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": "[***]", ... }tslog/presets/genai maps a friendly input to OpenTelemetry gen_ai.* attributes plus a compact summary. Spread the result into a log call.
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 } summaryconst log = new Logger({
type: "pretty",
pretty: {
levelMethod: { WARN: console.warn, ERROR: console.error, FATAL: console.error, "*": console.log },
},
});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.
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 DevToolsinspectOptions does not apply to natively-passed args (the console renders them); logged Errors 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).
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:
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 coloredlog.if(condition) returns the logger when truthy and a no-op when falsy, so a per-call guard reads as a chain:
log.if(!ok).warn("action failed", { id });The falsy no-op still evaluates its arguments — to skip expensive construction, guard with isLevelEnabled (recipe 12).
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.
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 }); },
});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.
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: 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.
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);
},
});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.
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)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: "<raw multi-line string>", cause? }
// (pino-pretty and error trackers parse it as-is; pinoFormat({ errorShape: "tslog" }) keeps frame arrays.)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.
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.
The tslog/transports/file transport buffers and flushes; await using (or flush()) drains it before exit.
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 endsBuilt-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.
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:
import { Logger } from "tslog";
const log = new Logger({ type: "json" });
async function shutdown(code: number): Promise<void> {
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.
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.
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.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.
const log = new Logger({ type: "json", stack: { capture: "off" } });
// Guard expensive payload construction with isLevelEnabled:
if (log.isLevelEnabled("DEBUG")) {
log.debug("state", expensiveSnapshot());
}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:
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.
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.
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 JSONKept: 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.
Use the full Logger on the server and tslog/lite in "use client" components. Two files, no other setup:
// 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",
});// lib/logger.client.ts — "use client" components
import { createLiteLogger } from "tslog/lite";
export const log = createLiteLogger({
minLevel: process.env.NODE_ENV === "production" ? "WARN" : "SILLY",
});"use client";
import { log } from "../lib/logger.client";
export function AddToCart({ id }: { id: string }) {
return <button onClick={() => log.info("add to cart", { id })}>Add</button>;
}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.
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:
// 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-servercondition. It reads like the "am I on the server?" switch and is not: React appliesreact-serveronly 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. Thebrowsercondition keys on the bundle target, which is the distinction actually meant here. - Drop the
import "server-only"guard fromlogger.server.tsif you added it. The shared entrypoint is reachable from client components by design, which is exactly what that guard fails the build for. Thebrowsercondition is what enforces the split here — and it keeps the fullLoggerand 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.
LiteLogger offers the same getSubLogger() / child() calls as the server logger, so per-area names carry across the split:
// lib/logger.client.ts
import { createLiteLogger } from "tslog/lite";
export const log = createLiteLogger({
name: "app",
minLevel: process.env.NODE_ENV === "production" ? "WARN" : "SILLY",
});"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 <button onClick={() => cartLog.info("add to cart", { id })}>Add</button>;
}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/%crenders 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%dliterally (withnappended) on a named lite logger. Unnamed loggers keep full printf semantics; if you need both, fold the label into the message yourself. nameSeparatorandconsoleare lite-only options. Through the shared-entrypoint pattern above,getSubLogger({ name })works on both halves — but the fullLoggerrejectsnameSeparator/consoleas unknown settings (a dev warning, and a throw understrictConfig: 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.
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:
// 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.
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);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.
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(...) }).
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.