Lightweight JSON Lines logger with pluggable formatters (Google Cloud Logging, VictoriaLogs, Elastic/ECS, Datadog, Pino). Modern ESM-only, zero dependencies, Bun-first, works on Node.js and Deno.
Outputs plain text for local development — auto-colored on a TTY, plain when piped or redirected — and structured JSON (JSONL) for production, switched by a single LOG_FORMAT environment variable with no code changes.
Next.js (and other hardcoded plain text logs) become JSON-only logging for systems where it is required.
bun add jsonl-logger
# or
npm install jsonl-loggerimport { logger } from 'jsonl-logger'
logger.info('Server started', { port: 3000 })
logger.log('Neutral message', { note: 'no level icon' })
logger.error('Request failed', { path: '/api' }, new Error('timeout'))The active mode is selected by the LOG_FORMAT environment variable — no code changes required:
- Plain text (default,
LOG_FORMATunset) — human-readable lines with a timestamp, level label, and inline context; colored when writing to a TTY. Ideal for local development.LOG_LEVELdefaults todebug. - Structured JSON (
LOG_FORMATset) — one JSON object per line (JSONL) shaped by the selected formatter. Ideal for production log pipelines.LOG_LEVELdefaults toinfo. See Formatters.
Plain-text output for the Quick Start example above:
18:42:05 ● Server started {"port":3000}
18:42:05 Neutral message {"note":"no level icon"}
18:42:05 ✖ Request failed {"path":"/api"}
Error: timeout
at handler (/app/server.ts:12:9)
Color is auto-detected: on when stdout is an interactive TTY, off when output is piped or redirected (e.g. to a log file or in CI). Override via the colors constructor option or the environment — precedence is colors option > FORCE_COLOR > NO_COLOR > TTY detection:
colors: true | false— explicit per-logger control, wins over everything belowFORCE_COLOR— set (to anything but0/false) to force color onNO_COLOR— set (to any value) to force color off (no-color.org)
import { Logger } from 'jsonl-logger'
const logger = new Logger({}, { colors: false }) // never colorize, regardless of TTYIn plain-text mode, the per-level label is controlled by the LOG_LABELS environment variable or the labels constructor option (the option takes precedence):
LOG_LABELS |
Labels | Example line |
|---|---|---|
icon (default) |
◆ ● ▲ ✖ ‼ |
18:42:05 ● Server started |
text |
DEBUG INFO WARN ERROR FATAL |
18:42:05 INFO Server started |
none |
(timestamp only) | 18:42:05 Server started |
import { Logger } from 'jsonl-logger'
const logger = new Logger({}, { labels: 'text' }) // overrides LOG_LABELSThe neutral .log() method always renders without an icon or text label — just blank padding where the label would sit — so level-less lines stay visually distinct from leveled ones (any meta context is still appended). In JSON mode these styles are ignored; labels apply to plain text only.
Set LOG_FORMAT to enable JSON output with a specific formatter:
LOG_FORMAT=google-cloud-logging bun run server.ts
# Output: {"message":"...","timestamp":"...","severity":"INFO",...}LOG_FORMAT=victoria-logs bun run server.ts
# Output: {"_msg":"...","_time":"...","level":"info",...}For the Elastic/ELK stack — ingested directly by Filebeat / Elastic Agent:
LOG_FORMAT=ecs bun run server.ts
# Output: {"@timestamp":"...","log.level":"info","message":"...","ecs.version":"8.11.0",...}Trace context maps to trace.id / span.id; errors map to error.type / error.message / error.stack_trace (with the cause chain under error.cause.*).
For Datadog, collected from stdout by the Datadog Agent:
LOG_FORMAT=datadog bun run server.ts
# Output: {"status":"info","message":"...","timestamp":"...","dd.trace_id":"...","dd.span_id":"..."}Maps status (the Datadog severity), message, timestamp, and errors to error.kind / error.message / error.stack (cause chain under error.cause.*).
Trace correlation. Trace and span IDs are written to dd.trace_id / dd.span_id exactly as your traceContext getter returns them — no conversion. Datadog APM log↔trace correlation expects IDs in Datadog's own format: dd-trace-js already produces these, so its IDs correlate out of the box. If instead you wire an OpenTelemetry SDK (128-bit hex IDs), correlation may require aligning the ID format on both the log and trace sides.
Not a destination — emits the line shape the Pino ecosystem expects, so you can pipe output into pino-pretty, transports, or processors:
LOG_FORMAT=pino bun run server.ts | pino-pretty
# Output: {"level":30,"time":1735689600000,"msg":"...","err":{"type":"...","message":"...","stack":"..."}}Numeric level (debug=20 … fatal=60), epoch-millisecond time, msg, and errors nested under err (type / message / stack, cause chain under err.cause). Trace context maps to trace_id / span_id / trace_flags (matching @opentelemetry/instrumentation-pino).
Pino's pid / hostname bindings are not added by the library — that keeps it free of a node:os import. Supply them (or any base fields) via the logger context if you want them on every line:
import os from 'node:os'
import { Logger } from 'jsonl-logger'
// pid/hostname become base bindings emitted on every line (with LOG_FORMAT=pino)
const logger = new Logger({ pid: process.pid, hostname: os.hostname() })import type { Formatter } from 'jsonl-logger'
const myFormatter: Formatter = {
messageKey: 'msg',
format: (record) => ({
msg: record.message,
ts: record.timestamp,
lvl: record.level,
...record.context,
}),
}Monkey-patch console.* methods to output structured JSON — captures logs from third-party libraries:
import { intercept, originalConsole } from 'jsonl-logger/intercept'
intercept({
// Optional: custom formatter (default: GoogleCloudLogging)
formatter: VictoriaLogs,
// Optional: filter out noisy messages
filter: (level, message) => !message.includes('deprecation'),
// Optional: minimum log level
level: 'warn',
})
// Already-formatted JSON from the Logger class passes through unchanged
console.log('plain text') // → structured JSON
originalConsole.log('bypass interception')The logger supports automatic trace context injection. Supply a traceContext getter that returns the active span's trace/span IDs — the formatter maps them to platform-specific fields automatically.
import { trace } from '@opentelemetry/api'
import { Logger } from 'jsonl-logger'
const logger = new Logger({}, {
traceContext: () => {
const span = trace.getActiveSpan()
if (!span) return undefined
const { traceId, spanId, traceFlags } = span.spanContext()
return { traceId, spanId, traceFlags }
},
})
logger.info('request handled', { path: '/api' })
// GCL output includes "logging.googleapis.com/trace", "logging.googleapis.com/spanId", etc.
// VictoriaLogs output includes "trace_id", "span_id", etc.const logger = new Logger({}, {
traceContext: () => ({
traceId: myTracer.currentTraceId(),
spanId: myTracer.currentSpanId(),
}),
})The traceContext option is also available on intercept():
import { intercept } from 'jsonl-logger/intercept'
intercept({
traceContext: () => {
const span = trace.getActiveSpan()
if (!span) return undefined
const { traceId, spanId, traceFlags } = span.spanContext()
return { traceId, spanId, traceFlags }
},
})Child loggers inherit the traceContext getter from their parent.
The preload module reads LOG_FORMAT and only activates when it's set. Safe to include unconditionally — it's a no-op without LOG_FORMAT.
Next.js auto-detects instrumentation.ts at the project root. Use it to load the preload module on the server:
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs' || typeof Bun !== 'undefined') {
await import('jsonl-logger/preload')
}
}Next.js standalone output doesn't include all node_modules. Copy jsonl-logger explicitly from the build stage:
COPY --from=build /app/node_modules/jsonl-logger ./node_modules/jsonl-logger
ENV LOG_FORMAT=victoria-logs
CMD ["bun", "--preload", "jsonl-logger/preload", "server.js"]For non-Bun deployments, use --import to preload:
LOG_FORMAT=google-cloud-logging node --import jsonl-logger/preload server.jsconst requestLogger = logger.child({ requestId: 'abc-123', service: 'api' })
requestLogger.info('Processing request')
// All entries include requestId and serviceErrors passed to error() / fatal() capture the full stack trace and error.cause chain:
const inner = new Error('ECONNREFUSED')
const outer = new Error('fetch failed', { cause: inner })
logger.error('API call failed', { endpoint: '/users' }, outer)Dev mode (no LOG_FORMAT) — colored plain text with full stack:
18:42:05 ✖ API call failed {"endpoint":"/users"}
Error: fetch failed
at handler (/app/api/route.ts:42:5)
Caused by: Error: ECONNREFUSED
at connect (/app/db.ts:10:3)
Production (LOG_FORMAT set) — structured JSON with error.* and error.cause.* fields:
{
"message": "API call failed",
"severity": "ERROR",
"endpoint": "/users",
"error.name": "Error",
"error.message": "fetch failed",
"error.stack": "Error: fetch failed\n at handler ...",
"error.cause.name": "Error",
"error.cause.message": "ECONNREFUSED",
"error.cause.stack": "Error: ECONNREFUSED\n at connect ..."
}The errorInfo() helper is exported for use in custom formatters:
import { errorInfo } from 'jsonl-logger'
const info = errorInfo(caughtError)
// { name, message, stack, cause?: { name, message, stack, cause?: ... } }| Variable | Default | Description |
|---|---|---|
LOG_FORMAT |
(unset) | Set to enable JSON — selects the formatter (see Formatters). Unset = plain text (colored on a TTY) |
LOG_LEVEL |
info/debug |
Minimum log level (info when JSON, debug otherwise) |
LOG_LABELS |
icon |
Plain-text label style: icon, text, or none (also via the labels option) |
NO_COLOR |
(unset) | Set to any value to disable plain-text color (no-color.org) |
FORCE_COLOR |
(unset) | Force plain-text color on (0/false disables); overrides NO_COLOR |
The logger auto-detects the runtime and uses the fastest available I/O:
- Bun / Node.js —
process.stdout.write/process.stderr.write(bypasses console overhead) - Deno —
Deno.stdout.writeSync/Deno.stderr.writeSync - Browser / unknown — falls back to
console.log/console.error
| Subpath | Export |
|---|---|
jsonl-logger |
Logger, logger, errorInfo(), flattenError(), logLevelValues, stripAnsi(), types (ErrorInfo, LogRecord, TraceContext, etc.) |
jsonl-logger/datadog |
Datadog formatter |
jsonl-logger/elastic-common-schema |
ElasticCommonSchema formatter |
jsonl-logger/google-cloud-logging |
GoogleCloudLogging formatter |
jsonl-logger/pino |
Pino formatter |
jsonl-logger/victoria-logs |
VictoriaLogs formatter |
jsonl-logger/intercept |
intercept(), originalConsole |
jsonl-logger/preload |
Side-effect auto-intercept |
1.0 marks a stable public API under semantic versioning. The stable surface is:
- The package's public exports — the
Loggerclass,loggersingleton, formatter objects,intercept()/originalConsole, and the exported helpers (errorInfo,flattenError,logLevelValues,stripAnsi) and types (see Exports). - The
Formattercontract ({ messageKey, format(record) }) and theLogRecord/ErrorInfoshapes. - The
LOG_FORMATvalues,LOG_LEVEL,LOG_LABELS,NO_COLOR/FORCE_COLOR, and theLogger/interceptoptions.
Removing, renaming, or changing the behavior of any of the above bumps the major version. Additive changes — a new LOG_FORMAT value, a new optional option — are non-breaking and ship in minor releases.
MIT