Skip to content

Beautiful logging for TypeScript

Same logger on every platform
  • Node.js
  • Deno
  • Bun
  • Workers
  • React Native
  • ChromeSafariEdgeFirefox

lang: Typescript License: MIT npm version CI: GitHub codecov.io code style: biome GitHub stars

Powerful, fast and expressive logging for TypeScript and JavaScript

tslog pretty output

  • Universal — one logger for Node.js, browser, Deno, Bun, workers and React Native
  • Structured, fields-first JSON — flat, observability-ready output that drops straight into log pipelines
  • Pretty by default, JSON optional — colored in your terminal, uncolored when piped/CI; structured JSON is one opt-in away
  • First-class support for agents & LLMs — fields-first calls, agent/session correlation, an llms.txt, and OTel-GenAI presets; used by OpenClaw for agent logging
  • Tree-shakeable subpaths — transports, presets and helpers ship as opt-in modules, sideEffects: false
  • Zero runtime dependencies — nothing pulled into your bundle but tslog itself
  • Fully typed — written in TypeScript 7, native ESM, accurate source-mapped line numbers
  • Secret masking — keys, JSONPath-lite paths, regex, and a hashing censor for correlation
  • Sub-loggers with inheritancechild() / getSubLogger() with merged settings and accumulated names
  • Pluggable transports & middleware — per-transport level/format, a use() pipeline, flush() and disposal
  • Interactive browser objectspretty.passObjectsNatively (on by default in browsers) keeps logged objects collapsible in DevTools
  • Pretty errors & stack traces — structured, fully serializable, captured only when needed ("auto" for pretty, "off" for JSON)
import { Logger } from "tslog";
// `new Logger()` is pretty everywhere: colorized in an
// interactive terminal, uncolored when piped/redirected/CI.
// Omit `type` for pretty; set "pretty" | "json" | "hidden".
const log = new Logger({ minLevel: "INFO" });
// Fields-first OR string-first — both work:
log.info({ port: 3000 }, "server started");
log.info("server started");
// A child logger per request or agent — name, settings and
// fields are inherited. `child(...)` aliases `getSubLogger(...)`:
const requestLog = log.getSubLogger({ name: "agent:planner" });
requestLog.info({ tool: "search", tokens: 318 }, "tool call done");
// JSON → {"message":"tool call done","level":"INFO","levelId":3,
// "time":"…","tool":"search","tokens":318,
// "_logMeta":{"v":5,"name":"agent:planner",…}}
// Keep secrets, PII and prompts out of your logs (grouped under `mask`):
const safeLog = new Logger({
type: "json",
mask: {
keys: ["password", "apiKey", "token", "prompt"],
paths: ["user.password", "*.token"],
},
});
safeLog.info({ user: { name: "Ada", password: "hunter2" } });
// → {"user":{"name":"Ada","password":"[***]"},"level":"INFO", …} (a lone object spreads its fields — no message key)

Donations help me allocate more time for my open source work.

tslog is published to npm as a single ESM package. How you pull it in depends on the runtime — npm for Node.js, bun add for Bun, an npm: specifier (or import map) for Deno, and a CDN URL for the browser:

Runtime Install / import
Node.js npm install tslogimport { Logger } from "tslog";
Bun bun add tslogimport { Logger } from "tslog";
Deno no install step — import { Logger } from "npm:tslog";
Browser no install step — import { Logger } from "https://esm.sh/tslog";

The per-runtime details are below.

Terminal window
npm install tslog

Set "type": "module" in your package.json and run Node with source maps for accurate line numbers:

{
"name": "NAME",
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "tsc -p .",
"start": "node --enable-source-maps dist/index.js"
},
"dependencies": {
"tslog": "^5"
}
}

After building (npm run build), start your app with npm start.

To run TypeScript directly, use a current ESM-aware runner, e.g. node --enable-source-maps --import tsx src/index.ts.

There is no install step in Deno — the npm: specifier pulls tslog from npm and caches it on first run (or add it to an import map / deno add npm:tslog if you prefer a bare "tslog" import):

main.ts
import { Logger } from "npm:tslog";
const logger = new Logger();
logger.info("Hello from Deno");
Terminal window
deno run main.ts
# grant optional metadata access: deno run --allow-env main.ts

Add the package with Bun’s own installer, then import it by name:

Terminal window
bun add tslog
main.ts
import { Logger } from "tslog";
const logger = new Logger();
logger.info("Hello from Bun");
Terminal window
bun run main.ts
<script type="module">
import { Logger } from "https://esm.sh/tslog";
const logger = new Logger();
logger.silly("I am a silly log.");
</script>

A prebuilt IIFE bundle is also published for <script src="tslog.js"> usage, exposing the global window.tslog. In the browser, the default output renders pretty logs with CSS styling.

Bundle-size sensitive? import { Logger } from "tslog/slim" ships the same structured-JSON pipeline at less than half the size (~9.8KB gzip vs ~20.7KB) by leaving out masking, pretty output, and stack capture — mask settings and type: "pretty" throw there instead of silently degrading. Both sizes are enforced by a CI budget (npm run check-bundle-size).

Enable TypeScript source-map support so tslog can point at the correct line in your source:

tsconfig.json
{
compilerOptions: {
// <!-- here
"inlineSourceMap": true,
"target": "es2022",
},
}
Terminal window
npm install tslog

Works out of the box on Hermes and JSC — Metro resolves the react-native entry automatically. tslog detects React Native (_logMeta.runtime: "react-native", with the Hermes engine version when available), parses Hermes/JSC stack frames correctly, and defaults to pretty output in the Metro console.

One package, purpose-built distributions. For most apps the answer is simply tslog: the main entry adapts to where it runs (colorized pretty output in your terminal or devtools, uncolored pretty when piped, and opt-in flat JSON for production — see Default type and colorization), so the same import covers development and production without config. The other builds exist for the situations where the default trade-offs don’t fit:

Situation Import Why
Development (server, browser, RN) tslog Zero config: colorized pretty output on an interactive TTY / in the devtools console, code positions via stack capture, config validation with did-you-mean hints.
Production (services, containers, CI) tslog The very same import: pretty stays uncolored when piped/non-TTY; opt into flat fields-first JSON with type: "json". Add mask for secrets/PII, bindings + runInContext for correlation, and transports to ship logs.
Production, size-critical bundles (browser apps, edge workers) tslog/slim The same JSON pipeline at less than half the size (~9.8KB vs ~20.7KB gzip) by leaving out masking, pretty output, and stack capture — and it throws on mask / type: "pretty" instead of silently degrading. Develop against tslog, ship tslog/slim.
Testing tslog/testing createTestLogger() captures every record and rendered line for assertions — no console noise, no spies. (Any build also accepts type: "hidden" to mute the console while still returning records.)
Browser debugging with native line numbers tslog/lite Thin console wrappers with zero processing, so devtools still shows the caller’s file:line instead of the logger’s.
Reading production logs as a human tslog CLI Pipe NDJSON into the bundled bin and get the local pretty rendering: kubectl logs api | npx tslog -l warn.

All of them speak the same settings language and emit the same JSON shape, so switching between builds is an import change, not a rewrite.

How tslog compares to the most popular JavaScript loggers. This table looks only at what each library does in its core package, out of the box — because most of these can be extended with plugins, and a fair comparison has to draw the line somewhere. The line is “what you get from a fresh install with no extra packages.”

Legend: ✅ built-in / on by default · ⚪ available, but only by adding a separate plugin or package · 🟡 partial / manual / needs hand-rolling · ❌ not available

Feature
(core feat only)
tslog pino winston bunyan consola
Runtime dependencies 0 many many heavy (native) 0 (bundled)
Universal (Node + browser + Deno/Bun) 🟡
polyfill

Node-only

Node-only
First-class TypeScript types 🟡
loose
🟡
Pretty output in-process
pino-pretty

CLI pipe
Structured, fields-first JSON 🟡
via formats
Env-aware colorization (color ↔ plain) 🟡
fancy↔basic
Secret masking / redaction
keys · paths · regex

paths
🟡
manual format
Pretty errors + stack traces 🟡 🟡 🟡 🟡
Source-mapped call-site line numbers 🟡
src (slow)
🟡
Sub-loggers with inherited settings 🟡
bound fields
🟡
child fields
🟡
bound fields
Per-transport level / format 🟡
per-target
🟡
per-stream
flush() / disposal
Async-context correlation (ALS)
pino-http
OpenTelemetry / GenAI presets
File / HTTP / ring-buffer transports
subpaths

targets

transports
🟡
streams
Off-event-loop (worker-thread) sink
subpath

thread-stream

Ready to switch? Step-by-step guides: Migrating from pino, winston, or consola.

tslog ships seven default levels (the exported LogLevel enum):

idnamemethod
0SILLYlog.silly()
1TRACElog.trace()
2DEBUGlog.debug()
3INFOlog.info()
4WARNlog.warn()
5ERRORlog.error()
6FATALlog.fatal()
import { Logger } from "tslog";
const log = new Logger({ name: "myLogger" });
log.silly("I am a silly log.");
log.trace("I am a trace log.");
log.debug("I am a debug log.");
log.info("I am an info log.");
log.warn("I am a warn log with an object:", { foo: "bar" });
log.error("I am an error log.");
log.fatal(new Error("I am a pretty Error with a stacktrace."));

Use minLevel to suppress everything below a threshold (number or name). The LogLevel enum avoids magic numbers:

import { Logger, LogLevel } from "tslog";
// or minLevel: "WARN"
const log = new Logger({ minLevel: LogLevel.WARN });
log.info("hidden");
log.warn("visible");
// change at runtime
log.setMinLevel("DEBUG");

Custom levels declared with customLevels (or added at runtime with addLevel) install real level methods named after the lower-cased level. Use createLogger to get them fully typed, and level names resolve case-insensitively everywhere (minLevel, isLevelEnabled):

import { createLogger } from "tslog";
const log = createLogger({ customLevels: { AUDIT: 8, METRIC: 9 } });
// typed method
log.audit({ actor: "ada" }, "deleted account");
// runtime registration, chainable
log.addLevel("NOTICE", 3.5).notice("heads up");
// any casing works
const quiet = new Logger({ customLevels: { AUDIT: 8 }, minLevel: "audit" });

The generic log(levelId, levelName, ...args) dispatch still works; a call whose id drifts from the registered one warns in development. Names that collide with a logger member (log, flush, …), a canonical level, or another registered level in a different casing are rejected at construction.

A sub-logger inherits the parent’s settings (group-merged) and default logObj. Names accumulate into _logMeta.parentNames, so you can trace which module or request produced a line. child() is an alias for getSubLogger().

Bind static fields with bindings: they land on every JSON record, merge down the child chain, always lose to per-call fields on a collision, and are masked once with the logger’s mask settings.

const main = new Logger({ name: "app", bindings: { service: "checkout" } });
const db = main.getSubLogger({ name: "db" });
const query = db.child({ name: "query", minLevel: "DEBUG", bindings: { pool: "primary" } });
query.info("slow query", { ms: 812 });
// → {"message":"slow query","ms":812,"service":"checkout","pool":"primary",…}
query.debug("select 1");
// _logMeta.name = "query", _logMeta.parentNames = ["app", "db"]

You can override the default logObj per child by passing a second argument: main.getSubLogger({ name: "worker" }, { tenant: "acme" }).

v5 settings are organized into groups — there are no flat keys like prettyLogTemplate, maskValuesOfKeys, or hideLogPositionForProduction anymore. Top-level keys cover identity and routing; the groups cover everything else.

Top-level: type ("pretty" | "json" | "hidden" — omit for pretty everywhere, colored on a TTY), name, parentNames, minLevel, argumentsArrayName, prefix (string args prepended to every call and concatenated down the child chain — use bindings for JSON fields, prefix for message text), bindings (static bound fields — merged down the child chain, per-call fields win, masked once at construction), attachedTransports, middleware, customLevels, persistLevel / persistLevelKey (browser-only — persist runtime setMinLevel() changes in localStorage and restore on reload), contextStorage (bring-your-own AsyncLocalStorage for runInContext — the Cloudflare Workers seam), clock (injectable () => Date — deterministic tests, offset/monotonic stamping), strictConfig (throw a typed TslogConfigError on bad config — including unknown/typo’d keys and carried-over v4 flat keys, which otherwise warn in development with a did-you-mean suggestion).

Group Keys
pretty template, errorTemplate, errorStackTemplate, errorParentNamesSeparator, errorLoggerNameDelimiter, style (boolean), timeZone ("UTC" | "local"), styles, levelMethod, passObjectsNatively (boolean), inspectOptions, enabled
json messageKey ("message"), levelKey ("level"), levelIdKey ("levelId"), timeKey ("time"), time ("iso" | "epoch" | false | fn), errorKey ("error"), numericLevel (true), stableKeyOrder (false)
mask keys ([]), caseInsensitive, regex (RegExp[]), placeholder ("[***]"), paths (JSONPath-lite), censor (string | "remove" | "hash" | fn), hashLabel ("hash")
stack capture ("off" | "lazy" | "auto" | "full"), internalFramePatterns (RegExp[])
meta property ("_logMeta"), attachContext
const log = new Logger({
type: "json",
name: "api",
minLevel: "INFO",
json: { messageKey: "msg", timeKey: "ts" },
mask: { keys: ["password"], censor: "hash" },
stack: { capture: "lazy" },
});

When type is omitted, tslog defaults to pretty everywhere — a pipe, a redirect, CI, or docker logs are still read by a human most of the time, so that is what you get by default. Only the coloring is environment-aware:

  • Interactive TTYpretty, colorized
  • Piped / redirected / CI (non-TTY) → pretty, uncolored (no ANSI escapes leak into your file or log collector)
  • Browser / React Nativepretty (CSS styling in the browser)

Structured json is a deliberate production choice, so it is opt-in — set type: "json", run with TSLOG_TYPE=json (via Logger.fromEnv()), or attach a JSON transport/sink. NO_COLOR follows no-color.org semantics: it switches colors off, never the format. FORCE_COLOR forces colorized pretty. Both apply to new Logger() and the ready-made log export. Logger.fromEnv(overrides?) additionally reads TSLOG_LEVEL / TSLOG_TYPE / TSLOG_NAME (overrides win).

Source-mapped error positions (Node, Bun & Deno)

Section titled “Source-mapped error positions (Node, Bun & Deno)”

When you log an Error thrown from transpiled or bundled TypeScript (tsc, esbuild, webpack, Next.js, etc.), tslog resolves the reported file/line/column back through that file’s source map, so _logMeta.path and the pretty error stack point at your original .ts source instead of the compiled output — the same thing your browser devtools already do for console.log, that Node’s own V8 stack rewriting only does when the process is started with --enable-source-maps, and that Bun does not do at all for its own stack traces.

Resolution runs automatically outside production (NODE_ENV !== "production") on Node, Bun, and Deno — never in the browser, where devtools already own it. Each resolved file’s source map is parsed once and cached; a file with no //# sourceMappingURL= comment costs one failed read, then nothing. Override the default with TSLOG_SOURCE_MAPS=on or TSLOG_SOURCE_MAPS=off when you need it forced independent of NODE_ENV (e.g. to keep it on in a staging deploy, or off during a benchmark).

tslog treats LLM apps and coding agents as a primary use case — it’s used by OpenClaw, an AI agent platform, for its agent logging:

  • llms.txt ships in the package, giving agents an accurate, condensed API surface to work from.

  • Fields-first calls make structured, queryable logs the natural default for tool calls and traces.

  • isLevelEnabled(level) guards expensive payloads (token counts, large prompts) without building them:

    if (log.isLevelEnabled("DEBUG")) {
    log.debug({ prompt: buildExpensivePrompt() });
    }
  • defineConfig(...) gives a typed, validated settings object you can share across loggers.

  • Request / agent correlation via runInContext(ctx, fn) (Node ALS). Context fields attach to _logMeta by default (meta.attachContext; set it to false to keep them out of the output), so every log inside the callback carries the same correlation id:

    const log = new Logger();
    await log.runInContext({ requestId: "abc123" }, async () => {
    // _logMeta carries requestId
    log.info("handling request");
    await doWork();
    });
    // read active context fields (e.g. for OTel span correlation)
    const { requestId } = log.getContext();

    runInContext propagates on Node, Deno, and Bun automatically. On runtimes where AsyncLocalStorage cannot be auto-resolved — most notably Cloudflare Workers — inject one via the contextStorage setting (requires the nodejs_als or nodejs_compat compatibility flag):

    import { AsyncLocalStorage } from "node:async_hooks";
    const log = new Logger({ contextStorage: new AsyncLocalStorage() });

    Where neither is available (browsers), runInContext still runs the function — it just propagates nothing, and warns once in development.

tslog’s JSON is flat and fields-first — message, level and time at the top, your fields spread next to them, and runtime metadata nested under _logMeta with a schema version (v: 5).

const log = new Logger({ type: "json" });
log.info({ userId: 42 }, "hi");
{
"message": "hi",
"level": "INFO",
"levelId": 3,
"time": "2026-06-29T22:37:07.599Z",
"userId": 42,
"_logMeta": {
"v": 5,
"runtime": "node",
"runtimeVersion": "24.15.0",
"hostname": "api-7f9c4",
"date": "2026-06-29T22:37:07.599Z",
"logLevelId": 3,
"logLevelName": "INFO"
}
}

How arguments map to output:

Call Result
log.info("hi") message: "hi"
log.info({ a: 1 }) fields spread top-level: a: 1
log.info({ a: 1 }, "hi") fields spread + message: "hi"
log.info("a", "b") message: "a", "1": "b"
log.error(new Error("x")) serialized under error (cause chain preserved)

name / parentNames only appear when set (no [undefined] noise). Every key name is configurable via the json group, so you can match an existing schema:

new Logger({
type: "json",
json: { messageKey: "msg", levelKey: "severity", timeKey: "@timestamp", errorKey: "err" },
});

The top-level timestamp representation is configurable via json.time (the _logMeta.date inside the runtime block always stays a UTC ISO string), and the clock itself is injectable:

// "time": 1751191872000 (pino-style ms)
new Logger({ type: "json", json: { time: "epoch" } });
// no top-level time key (diff-friendly)
new Logger({ type: "json", json: { time: false } });
// ns for Loki
new Logger({ type: "json", json: { time: (d) => String(BigInt(d.getTime()) * 1_000_000n) } });
// frozen clock — deterministic tests
new Logger({ clock: () => new Date(0) });

A throwing/invalid clock or time function never breaks logging (the runtime date / ISO string is kept). Sub-loggers inherit the parent’s clock.

On the Node entry, type: "json" lines are written through a buffered stdout sink: a whole event-loop turn’s lines are batched into one process.stdout.write (flushing early past ~8 KB), which removes the per-line console.log overhead that dominates logger throughput. Delivery is safeguarded: await logger.flush() (and await using) resolves only after stdout accepted everything, and process.exit() / uncaught exceptions trigger a synchronous drain via exit hooks. Note that code intercepting console.log will no longer see JSON output on Node — spy on process.stdout.write, or use type: "hidden" plus a transport. Browser/universal (Deno, Bun, workers) builds keep console.log.

One of the most common ways secrets leak is through logs. Configure masking once on the logger and it applies recursively to every log. All masking lives under the mask group.

const log = new Logger({
type: "json",
mask: {
// mask by key name
keys: ["password", "apiKey", "token"],
// also match Password, TOKEN, …
caseInsensitive: true,
// mask by value pattern
regex: [/Bearer\s+[A-Za-z0-9._-]+/],
// JSONPath-lite, "*" wildcard
paths: ["user.password", "*.token"],
// replacement (default)
placeholder: "[***]",
},
});

Masking is leak-proof by construction: regex patterns are always applied globally (every occurrence in a string is redacted, whether or not you wrote the g flag), shared references and circular structures resolve to the same masked clone (a secret can never escape through a second reference to the same object), and mask.keys / regex also apply inside Map and Set contents (mask.paths does not descend into them).

The censor option controls how a paths-matched value is replaced (keys- and regex-matched values always use placeholder, with one exception below):

  • a string — replace with that literal
  • "remove" — drop the key entirely
  • a function — custom logic returning the replacement
  • "hash" — replace with a fast, synchronous, non-cryptographic correlation token (e.g. "[hash:1a2b3c4d]", label configurable via hashLabel) so you can correlate a redacted value across logs without ever storing it. "hash" is the one censor that also applies to keys matches, so mask.keys and mask.paths hash alike
const log = new Logger({ mask: { keys: ["ssn"], censor: "hash", hashLabel: "hash" } });

A transport receives every (level-permitted) log record and decides where it goes — a file, an HTTP endpoint, an in-memory buffer, Slack, anything. tslog ships file, HTTP, ring-buffer and worker-thread transports as tree-shakeable subpaths, and you can write your own against the Transport contract.

interface Transport<LogObj> {
name?: string;
// per-transport level
minLevel?: number | TLogLevelName;
// per-transport format
format?: "pretty" | "json" | LogFormatter<LogObj>;
write(record, line): void | Promise<void>;
flush?(): Promise<void>;
[Symbol.asyncDispose]?(): Promise<void>;
}

attachTransport accepts a full Transport object or a bare function, and returns a detach function:

const log = new Logger();
const detach = log.attachTransport((record) => {
myQueue.push(record);
});
// later
detach();

Transports are isolated (a throwing transport cannot break logging) and inherited by sub-loggers. Flush buffered transports and dispose cleanly:

await log.flush();
// or scoped disposal:
await using scoped = new Logger({ /* ... */ });

Delivery guarantees: log.flush() awaits in-flight async transport writes as well as each transport’s own flush(). Disposing a sub-logger flushes shared transports but only disposes the ones the child itself attached. The built-in transports register guarded exit hooks by default: the file transport drains synchronously even on process.exit(...) or an uncaught exception (and never crashes the process on fs errors — they are contained, reported via onError, and the open is retried); the http transport bounds every request with a timeout, retries with backoff, caps its buffer (oldest lines drop first), and flushes on beforeExit/pagehide; the worker transport keeps its thread unref’d so it never blocks process exit, drains on beforeExit, and survives a dead worker by respawning (then falling back to inline writes).

// Node, non-blocking
import { fileTransport } from "tslog/transports/file";
// batched fetch
import { httpTransport } from "tslog/transports/http";
// in-memory
import { ringBufferTransport } from "tslog/transports/ringbuffer";
// Node, off-thread sink I/O
import { workerTransport } from "tslog/transports/worker";
const log = new Logger();
log.attachTransport(fileTransport({ path: "app.log", append: true }));
log.attachTransport(httpTransport({ url: "https://logs.example.com", batchSize: 50, flushIntervalMs: 1000 }));
const ring = ringBufferTransport({ size: 200 });
log.attachTransport(ring);
// ring.dump(); ring.clear();
// Run the destination write on a worker thread so slow sink I/O doesn't block the event loop under high volume.
// Note: this does NOT speed up `log.info()` —
// the record is still built and serialized on the main thread; only the file/stream write moves
// off-thread. `flush()` drains the worker; `await using` (or attachTransport's detach) cleans it up.
log.attachTransport(workerTransport({ destination: "file", path: "app.log", format: "json" }));

logger.use(middleware) appends a middleware to the pipeline. A middleware receives the log context (args, meta, settings, logLevelId, logLevelName), and can mutate it or drop the log by returning null / false:

const log = new Logger();
// enrich every log (meta is free-form scratch space attached under _logMeta)
log.use((ctx) => {
ctx.meta.env = process.env.NODE_ENV;
return ctx;
});
// drop noisy health checks (args holds the log call's arguments)
log.use((ctx) => (ctx.args[0] === "/health" ? null : ctx));

For custom output formatting, set format on a transport (a "pretty" | "json" string or a LogFormatter function) rather than overriding a global transport.

Presets are tree-shakeable subpaths that emit logs in a foreign schema, so tslog drops into existing tooling. They are off by default — import and wire them explicitly.

pinoFormat() produces pino-compatible NDJSON (integer level 10–60, time epoch ms, msg), so pino-pretty and pino transports keep working. pinoTransport() wires it up in one call; toPinoLevel(id) maps a tslog level id to its pino number.

import { pinoFormat, pinoTransport } from "tslog/presets/pino";
const log = new Logger();
// or attach a transport with { format: pinoFormat() }
log.attachTransport(pinoTransport((line) => process.stdout.write(line + "\n")));

Errors are emitted in pino’s serializer shape — err: { type, message, stack } with stack as the raw multi-line string (what pino-pretty, Datadog, GCP Error Reporting, and Sentry parse), cause chain recursed. Prefer tslog’s structured frame arrays instead? pinoFormat({ errorShape: "tslog" }).

otlpFormat() / toOtlpJson() emit real OTLP/JSON — the resourceLogs[].scopeLogs[].logRecords[] envelope with camelCase proto3 fields, typed attributes, exception.* semconv mapping for logged errors, and correct severityNumbers — so batches POST straight to a collector’s /v1/logs:

import { otelTraceContext, otlpBatchBody, otlpFormat } 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" } }),
// merges each batch into ONE OTLP envelope per POST
encodeBody: otlpBatchBody,
}),
);
// trace correlation (`trace` from @opentelemetry/api)
log.use(otelTraceContext({ getSpanContext: () => trace.getActiveSpan()?.spanContext() }));

otelFormat() / toOtelRecord() additionally emit the data-model prose shape (Timestamp, Body, Attributes, …) for custom pipelines — note that shape is not a wire format and collectors will not ingest it directly.

genai() / genaiAttributes() / genaiSummary() build OTel-GenAI semantic-convention attributes (gen_ai.*: model, tokens, tool calls) for LLM and agent apps.

import { genai } from "tslog/presets/genai";
log.info(genai({ model: "claude", inputTokens: 318, outputTokens: 142 }), "completion");

Error trackers and log platforms plug in as transports — no vendor-specific logger needed. Two worked examples follow; the same two patterns (a write function for SDK-based services, httpTransport for HTTP ingestion APIs) cover Datadog, Loki, Axiom and friends.

Sentry has two ingestion paths: issues (error tracking) and Sentry Logs (structured logs, searchable next to your traces). A tslog transport covers each — run one or both.

Errors → Sentry issues. Forward ERROR/FATAL records while keeping your normal console/JSON output. 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, proper issue grouping — not a stringified copy:

import * as Sentry from "@sentry/node";
import { Logger } from "tslog";
Sentry.init({ dsn: process.env.SENTRY_DSN });
const log = new Logger();
log.attachTransport({
name: "sentry",
// only errors and fatals leave the process
minLevel: "ERROR",
// `line` becomes the flat JSON record, independent of the console output
format: "json",
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 });
}
},
});
// → console output + a Sentry issue, logged fields as `extra`
log.error(new Error("payment failed"));

Everything → Sentry Logs. Enable logs in Sentry.init (enableLogs: true, current @sentry/node) and forward every record through Sentry.logger.* — the seven tslog levels map one-to-one, with SILLY joining trace:

import * as Sentry from "@sentry/node";
import { Logger } from "tslog";
Sentry.init({ dsn: process.env.SENTRY_DSN, enableLogs: true });
const log = new Logger();
const toSentry = { SILLY: "trace", TRACE: "trace", DEBUG: "debug", INFO: "info", WARN: "warn", ERROR: "error", FATAL: "fatal" } as const;
log.attachTransport({
name: "sentry-logs",
// `line` is the flat JSON record; its fields become Sentry log attributes
format: "json",
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);
},
});
// → console output + a Sentry log with a `userId` attribute
log.info({ userId: 42 }, "user logged in");

Run both transports side by side: every record becomes a searchable Sentry log, and ERROR/FATAL additionally become issues. Because transports are inherited, every sub-logger reports to Sentry too — and transport isolation means a Sentry outage can never break your logging.

Better Stack ingests JSON over HTTP, so the built-in httpTransport is all you need. It reads the timestamp from a dt field and the message from message — the first is one json setting away, the second is tslog’s default:

import { Logger } from "tslog";
import { httpTransport } from "tslog/transports/http";
const log = new Logger({
type: "json",
// Better Stack reads the timestamp from "dt"
json: { timeKey: "dt" },
});
log.attachTransport(
httpTransport({
// use the ingesting host shown on your source's settings page
url: "https://in.logs.betterstack.com",
headers: { authorization: `Bearer ${process.env.BETTER_STACK_SOURCE_TOKEN}` },
format: "json",
// Better Stack accepts a JSON array of events per request
bodyFormat: "array",
batchSize: 50,
flushIntervalMs: 2000,
}),
);
log.info({ userId: 42 }, "user logged in");
// → stdout JSON + batched POSTs; "userId" lands as a queryable column
// drain the buffer before shutdown (await using does this automatically)
await log.flush();

The transport batches, retries with exponential backoff, bounds every request with a timeout, caps its buffer, and flushes on beforeExit/pagehide — see Transports. Field names are yours to shape if your dashboards expect different columns, e.g. json: { levelKey: "severity" }.

Pretty output is configured entirely under the pretty group. Templates use {{placeholder}} tokens and per-placeholder styles.

const log = new Logger({
type: "pretty",
pretty: {
template: "{{dateIsoStr}}\t{{logLevelName}}\t[{{filePathWithLine}}{{name}}]\t",
errorTemplate: "\n{{errorName}} {{errorMessage}}\nstack:\n{{errorStack}}",
errorStackTemplate: " • {{fileName}}\t{{method}}\n\t{{filePathWithLine}}",
style: true,
timeZone: "UTC",
styles: {
logLevelName: {
"*": ["bold", "black", "bgWhiteBright", "dim"],
SILLY: ["bold", "white"],
DEBUG: ["bold", "green"],
INFO: ["bold", "blue"],
WARN: ["bold", "yellow"],
ERROR: ["bold", "red"],
FATAL: ["bold", "redBright"],
},
name: ["white", "bold"],
},
},
});

Route levels to specific console methods with pretty.levelMethod:

new Logger({
type: "pretty",
pretty: {
levelMethod: { WARN: console.warn, ERROR: console.error, FATAL: console.error, "*": console.log },
},
});

On Node.js, object formatting uses native util.inspect; tune it with pretty.inspectOptions. For example, pretty.inspectOptions.breakLength controls when inspected objects wrap onto multiple lines — set it high (e.g. Infinity) to keep an object on a single line while preserving color, which is handy in log aggregators (CloudWatch, Datadog) that treat each line as one entry.

Interactive objects in the browser console (pretty.passObjectsNatively)

Section titled “Interactive objects in the browser console (pretty.passObjectsNatively)”

In a real browser (window + document present), pretty output hands non-Error arguments to the console method by reference — the styled meta prefix is still printed, but objects and arrays stay live, so DevTools renders them as the collapsible, clickable trees you’d get from a raw console.log(obj). This is pretty.passObjectsNatively, and it is on by default in browsers (best DX out of the box) and off everywhere else — Node, workers/edge runtimes, and React Native typically capture console output as text, where a raw reference degrades to [object Object].

new Logger({
type: "pretty",
pretty: {
// on by default in browsers — pair with levelMethod so warn/error use the
// console methods that attach an expandable stack group:
levelMethod: { WARN: console.warn, ERROR: console.error, FATAL: console.error },
},
});
log.info("user loaded", { id: 42, roles: ["admin"] }); // object is collapsible in DevTools

While it’s on, inspectOptions does not apply to those arguments (the console owns the rendering); logged Errors are still formatted through the pretty error template.

When to switch it off (pretty: { passObjectsNatively: false }) — the rendered-string mode has two properties the native mode gives up:

  • Log-time snapshots. DevTools renders a raw reference lazily: if the object mutates after the log call, expanding it shows the current state, not what you logged — the classic console.log gotcha. Rendering into the string freezes the value at log time. (With mask configured, arguments are masked clones, so they’re frozen either way.)
  • Text-matchable logs. The DevTools filter box and any console-capturing harness (test runners, screenshot-based tooling) only match the rendered string — field values inside natively-passed objects are invisible to them.

log.if(condition) returns the logger when condition is truthy and a no-op stand-in when it is falsy, so a per-call condition reads as a fluent chain instead of an if statement or a throwaway helper:

log.if(!ok).info("action failed", { id });
log.if(retries > maxRetries).warn("giving up", { retries });

The falsy stand-in still evaluates its arguments, so to skip expensive payload construction use isLevelEnabled instead — it short-circuits before the arguments are built. if() gates a single call; it isn’t meant to be chained ahead of getSubLogger/child.

Import What it gives you
tslog/lite lite (ready instance), LiteLogger, createLiteLogger(opts?) — minimal console wrappers, no mask/stack/clone, preserves native console line numbers
tslog/slim Logger, createLogger — the smallest structured-JSON build: the full pipeline (levels, sub-loggers, bindings, custom levels, middleware, runInContext, transports) at less than half the bundle size, minus masking/pretty/stack capture (mask and type: "pretty" throw instead of silently degrading)
tslog/testing createTestLogger(settings?, { now?, normalize? }){ logger, logs, lines, clear }, plus mockLogger(settings?) and normalizeMeta(recordOrLine) for snapshot-stable output
tslog/throttle throttle({ windowMs, key?, now? }) middleware (off by default), defaultThrottleKey
tslog/serializers stdSerializers { err, req, res, user }, the serialize(map) middleware helper, and the individual serializers
tslog/pretty/box box(content, opts?), tree(node, opts?) for boxed / tree-rendered output
tslog/console wrapConsole(logger), restoreConsole(), isConsoleWrapped() — route console.* through tslog
bin tslog (tslog/cli) NDJSON pretty-printer for stdin, with a -l/--level filter
import { createTestLogger } from "tslog/testing";
const { logger, logs, lines, clear } = createTestLogger({ type: "json" });
logger.info({ ok: true }, "tested");
// logs[0] is the captured record: logs[0]._logMeta.logLevelName === "INFO"
// lines[0] is the rendered line and contains the fields: lines[0].includes('"ok":true')

The defaults are tuned for a great developer experience; for hot production paths, the biggest lever is stack capture.

  • Batched stdout by default (Node). JSON lines are buffered and written to stdout once per event-loop turn instead of one console.log per line, with flush/exit-hook safeguards (see Batched stdout on Node above).
  • Stack capture only when it’s needed. Pretty output defaults to stack: { capture: "auto" } — frames are captured only when the rendered template actually shows a code position — and type: "json" defaults to "off", so production JSON pays nothing.
  • The stack lever. Set stack: { capture: "off" } to skip code-position capture entirely on hot paths, "lazy" to capture cheaply and defer parsing until something reads the frames, or "full" when you want complete frames for debugging.
  • Tree-shakeable everything. Presets, transports and helpers are opt-in subpaths with sideEffects: false, so unused features never reach your bundle.

Benchmarks are run internally against pino, winston, bunyan, and consola, but the raw numbers aren’t published — they vary too much by machine and workload to be a fair, stable comparison to share.

Switching loggers is mostly a mapping exercise, and tslog’s call signature is deliberately forgiving: a two-argument call pairing a message string with a plain fields object spreads the fields at the top level in either order, so pino-style log.info({ userId: 42 }, "hi") and winston-style log.info("hi", { userId: 42 }) produce the same flat JSON. Most call sites survive a migration untouched — the work is in the constructor. (Coming from tslog v4? See Upgrading from v4 instead.)

pino and tslog v5 share the fields-first call shape and the flat-JSON philosophy, so call sites carry over as-is:

// ── pino ──
import pino from "pino";
const logger = pino({
level: "info",
redact: { paths: ["user.password", "*.token"], censor: "[Redacted]" },
});
logger.info({ userId: 42 }, "user logged in");
const db = logger.child({ module: "db" });
// ── tslog v5 ──
import { Logger } from "tslog";
const logger = new Logger({
minLevel: "INFO",
mask: { paths: ["user.password", "*.token"], placeholder: "[Redacted]" },
});
// unchanged
logger.info({ userId: 42 }, "user logged in");
const db = logger.child({ name: "db", bindings: { module: "db" } });
pino tslog v5
level: "info" minLevel: "INFO" — names resolve case-insensitively
numeric levels trace(10)fatal(60) SILLY(0)FATAL(6); the pino preset maps them back to 10–60 on the wire
redact: { paths, censor } mask: { paths, placeholder } — same * wildcard, plus keys and regex matching pino doesn’t have
redact with remove: true mask: { censor: "remove" }
child({ module: "db" }) child({ bindings: { module: "db" } }) — children inherit settings too, not just bound fields
messageKey / timestamp options json: { messageKey, time } ("epoch", "iso", or a function)
pino-pretty for dev built in — omit type and you get pretty output everywhere, TTY or not; set type: "json" when you want structured output
transport: { target: "pino/file" } fileTransport(...) from tslog/transports/file; off-thread sink I/O via tslog/transports/worker
pino.stdSerializers.err/req/res stdSerializers from tslog/serializers
logger.flush(cb) await logger.flush()

Keep your pipeline running while you switch. If dashboards, shippers or pino-pretty expect pino’s exact wire shape (level: 30, epoch-ms time, msg, err), attach the preset instead of reshaping by hand — the output stays pino-compatible while tslog becomes the producer:

import { pinoTransport } from "tslog/presets/pino";
const logger = new Logger({ type: "hidden" });
logger.attachTransport(pinoTransport((line) => process.stdout.write(line + "\n")));

winston call sites keep working — logger.info("payment accepted", { orderId: 7 }) spreads the fields object in tslog too. What disappears is the format.combine() pipeline: output shape, timestamps and colors are settings, not composed formats.

// ── winston ──
import winston from "winston";
const logger = winston.createLogger({
level: "info",
format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
defaultMeta: { service: "checkout" },
transports: [
new winston.transports.Console(),
new winston.transports.File({ filename: "error.log", level: "error" }),
],
});
logger.info("payment accepted", { orderId: 7 });
// ── tslog v5 ──
import { Logger } from "tslog";
import { fileTransport } from "tslog/transports/file";
const logger = new Logger({
// or omit for pretty everywhere (JSON is opt-in)
type: "json",
minLevel: "INFO",
bindings: { service: "checkout" },
});
logger.attachTransport(fileTransport({ path: "error.log", format: "json", minLevel: "ERROR" }));
// unchanged
logger.info("payment accepted", { orderId: 7 });
winston tslog v5
npm levels error(0)silly(6) — priority, descending SILLY(0)FATAL(6) — severity, ascending; stick to names and the direction never bites
http / verbose levels closest built-in for verbose is trace; add your own via customLevels: { HTTP: 2.5 }
defaultMeta bindings (also per child: child({ bindings }))
format.json() + format.timestamp() type: "json" + the json group (messageKey, timeKey, time, …)
format.colorize() + format.simple() type: "pretty" — or omit type; pretty is the default (colored on a TTY, uncolored when piped)
format.printf(...) / custom formats use() middleware to mutate or drop records; per-transport format for custom rendering
transports: [...] with per-transport level / format attachTransport({ minLevel, format, write }) — same idea, and it returns a detach function
custom Transport subclass (stream machinery) a plain object or a bare function: attachTransport((record) => …)
logger.child({ requestId }) child({ bindings: { requestId } }) — or skip per-request children: runInContext({ requestId }, fn)
flushing on shutdown (long-standing footgun) await logger.flush(), await using, and built-in exit-hook drains

Gotchas. %s-style splat interpolation is not supported — log fields instead of format strings. handleExceptions / handleRejections have no equivalent — register process.on("uncaughtException", (err) => logger.fatal(err)) yourself. And winston’s default logger silently drops everything until a transport is added; tslog always has working output, so if you relied on that silence, use type: "hidden".

consola and tslog agree that dev logs should be beautiful; tslog adds the production half — structured JSON, masking, transports, correlation. The mechanical mapping:

// ── consola ──
import { consola } from "consola";
const logger = consola.create({ level: 4 }).withTag("build");
logger.start("building…");
logger.success("done");
logger.error(new Error("boom"));
// ── tslog v5 ──
import { createLogger } from "tslog";
const logger = createLogger({
name: "build",
minLevel: "DEBUG",
// installs a typed logger.success()
customLevels: { SUCCESS: 3.5 },
});
logger.info("building…");
logger.success("done");
// pretty error, parsed stack, cause chain
logger.error(new Error("boom"));
consola tslog v5
level: 0…5 (higher = more verbose) minLevel by name: consola 3 (default) ≈ "INFO", 4"DEBUG", 5"TRACE"; -999 (silent) ≈ type: "hidden"
withTag("build") child({ name: "build" }) — names accumulate in _logMeta.parentNames
success / ready / start / fail types map onto info / error, or install real levels via customLevels
consola.box("…") box() / tree() from tslog/pretty/box: logger.info("\n" + box("Deployed!", { title: "release" }))
reporters transports: attachTransport({ format, write }), with per-transport format
consola.wrapConsole() wrapConsole(logger) from tslog/console (undo with restoreConsole())
fancy ↔ basic auto-detect pretty everywhere, env-aware color — colored on a TTY, uncolored when piped/CI; structured JSON is opt-in via type: "json"
consola.prompt() out of scope for a logger — keep consola or a prompt library for interactive prompts

Whichever logger you come from, createTestLogger from tslog/testing captures records and rendered lines, so you can assert the migrated output still matches what your pipeline expects before flipping the switch.

Move to v5 when you actually want its new capabilities: opt-in structured JSON output, the flat fields-first JSON shape, grouped settings, use() middleware, per-transport level/format, the presets, and the AI/agent DX. v5 is ESM-only and requires Node ≥ 20.

👉 Full guide: MIGRATION_v4_to_v5.md — it maps every removed v4 setting (stylePrettyLogs, prettyLogTemplate, maskValuesOfKeys, metaProperty, hideLogPositionForProduction, the whole overwrite.* family, …) to its v5 replacement.

MIT