/

jsonl-logger GitHub

Lightweight JSON Lines (JSONL) logger with pluggable formatters for VictoriaLogs, Google Cloud Logging, and more.

2.3K/mo 2 typescriptloggingjsonlbunnodedenoopen-sourcenpmgoogle-cloudvictorialogs
bun add jsonl-logger

Structured logs are the right answer in production and the wrong one on my laptop. I want one JSON object per line when a log pipeline is reading, and a readable coloured line when I am. Keeping both usually means either two logging setups or a wrapper nobody enjoys maintaining.

jsonl-logger does it with one environment variable and no code changes.

Examples

import { 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'))

Locally, that reads as text — coloured on a TTY, plain the moment you pipe it to a file or CI:

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)

Set LOG_FORMAT and the same calls emit JSONL shaped for your log platform:

LOG_FORMAT=google-cloud-logging bun run server.ts
# {"message":"Server started","timestamp":"…","severity":"INFO",…}

Why it exists

Every logger I reached for made me choose. Pretty-printers are a separate dependency you must remember not to ship. Production loggers emit JSON everywhere, so local development becomes a wall of braces. The gap is not features — it is that the destination changes between environments while the call sites never should.

So the shape of the output became a runtime concern rather than a code concern. LOG_LEVEL even picks a sensible default per mode: debug for text, info for JSON, because that is what you want in each case anyway.

The other reason is annoyance-driven: frameworks that hardcode plain-text logs are painful in systems that require JSON-only ingestion. Routing those through a formatter fixes it without patching the framework.

How it’s different

PackageOptimised for
jsonl-loggerone call site, text locally and JSONL in production, zero dependencies, ESM-only
pinothroughput and a large transport/processor ecosystem
winstonmaximum configurability — transports, custom levels, formats
consoladeveloper-facing CLI output and pretty reporters

Use pino instead if you need its transport ecosystem or its single-minded focus on throughput — and this project even emits pino’s line shape, so you can pipe into pino-pretty and friends. Use winston if you genuinely need arbitrary transports. This one is deliberately small — and still enough for many typical projects: good local output, clean production JSON, and no logging stack to assemble.

What’s inside

Predefined formatters for the usual destinations

LOG_FORMATEmits
google-cloud-loggingseverity, timestamp, GCP-shaped payload
victoria-logs_msg, _time, level
ecs@timestamp, log.level, ecs.version for Elastic/Filebeat
datadogstatus, dd.trace_id, dd.span_id
pinonumeric level, epoch time, msg, err

Trace context maps to whatever each destination expects — trace.id/span.id for ECS, trace_id/span_id/trace_flags for pino.

Labels and colour, when it is text

import { Logger } from 'jsonl-logger'

const logger = new Logger({}, { labels: 'text', colors: false })

LOG_LABELS switches between icons (◆ ● ▲ ✖ ‼), text labels, or nothing at all. Colour follows FORCE_COLOR and NO_COLOR before falling back to TTY detection, so it behaves in CI without being told.

Custom formatters, because the list above is a head start, not a limit

A formatter is a small object describing the key names and how to render an entry, so an in-house log schema is a few lines rather than a fork:

import type { Formatter } from 'jsonl-logger'

const myFormatter: Formatter = {
  messageKey: 'msg',
  format: (record) => ({
    msg: record.message,
    ts: record.timestamp,
    lvl: record.level,
    ...record.context,
  }),
}

Design notes

The switch is an environment variable, not an API. Making it a constructor option would mean every service re-implements the same process.env check, and inevitably one of them gets it wrong in a Dockerfile. Moving it into the library means the decision lives where the environment already does.

No node:os import. Pino’s pid and hostname bindings are deliberately absent, because importing node:os would tie the package to Node-shaped runtimes. You can add them yourself as base context, and in exchange the logger stays portable across Bun, Node and Deno.

import os from 'node:os'
import { Logger } from 'jsonl-logger'

const logger = new Logger({ pid: process.pid, hostname: os.hostname() })

Datadog trace IDs are passed through untouched. Datadog’s APM correlation expects IDs in its own format, and dd-trace-js already produces them. Silently converting OpenTelemetry’s 128-bit hex IDs would look helpful and produce wrong correlations, so the library writes exactly what your traceContext getter returns and documents the mismatch instead.

Status

Live and in use. ESM-only and Bun-first by design — it runs on Node and Deno, but it is not trying to support bundler setups that predate ESM.

Other projects