Blog Sending logs and traces

Centralized Node.js logging with ZipLogger

A Node service that logs to stdout is fine on one box. Once it runs on three containers, finding every error for one order means grepping three places, if the logs still exist. The ziplogger npm package sends those lines to one place you can search, through the logger you already use.

It ships three things: a transport for Pino, a transport for Winston, and a small client you can call directly. The core has no dependencies and uses Node's built-in fetch.

Install and get a key

npm install ziplogger

It needs Node 18 or newer. For a key, go to Settings → API keys in the app and click Create key. The key starts with zk_ and is shown once. A key that can only send data is enough here.

Pino

If you use Pino, add the transport. It also needs pino-abstract-transport, which is not installed for you:

npm install pino pino-abstract-transport
const pino = require('pino')

const logger = pino(pino.transport({
  target: 'ziplogger/pino',
  options: {
    endpoint: 'https://app.ziplogger.ai',
    apiKey: process.env.ZIPLOGGER_API_KEY,   // YOUR_API_KEY
    source: 'orders-api',
  },
}))

const log = logger.child({ requestId: 'req_7Hq2' })
log.info({ orderId: 83112, cart: { items: 3, total: 41.5 } }, 'Order created')
log.error({ err, orderId: 83112 }, 'Payment failed')

The transport runs in Pino's worker thread, so your request handlers never wait on the network. We ran this sample against a local HTTP catcher. This is what arrived for the first line (host name changed):

{
  "timestamp": "2026-09-29T11:23:29.744Z",
  "severity": "info",
  "message": "Order created",
  "source": "orders-api",
  "release": "2.3.1",
  "fields": {
    "environment": "production",
    "machineName": "web-01",
    "requestId": "req_7Hq2",
    "orderId": 83112,
    "cart": "{\"items\":3,\"total\":41.5}",
    "pid": 14116
  }
}

What the transport does with a Pino line:

  • Every property you log, and every child logger binding like requestId, becomes a field.
  • Strings, numbers, booleans and null are sent as they are. Objects and arrays are sent as JSON text. cart above is one string, not two fields. If you want to filter on cart.items, log cartItems: 3 instead.
  • msg becomes the message and Pino's numeric level becomes debug, info, warn, error or fatal. Pino's trace level is sent as debug.
  • hostname is dropped. The SDK sends machineName instead. pid is kept.
  • err is special. Its stack goes to stackTrace, its type to exceptionType and its message to exceptionMessage. For the error line above that was TypeError and Cannot read properties of null (reading 'total').

Winston

Winston users get a transport class. It needs winston-transport, which comes with winston:

const winston = require('winston')
const { ZipLoggerTransport } = require('ziplogger/winston')

const zipTransport = new ZipLoggerTransport({
  endpoint: 'https://app.ziplogger.ai',
  apiKey: process.env.ZIPLOGGER_API_KEY,
  source: 'orders-api',
})

const logger = winston.createLogger({
  transports: [new winston.transports.Console(), zipTransport],
})

logger.info('Order created', { orderId: 83112 })
logger.error('Payment failed', err)

The rules are close to Pino's. Metadata keys become fields, and objects are sent as JSON text. A stack property on the line becomes stackTrace. Winston adds one when you pass an Error as the metadata, and it appends the error's message to yours, so the line above arrives as Payment failed card declined. Unlike the Pino transport, the Winston one does not fill exceptionType or exceptionMessage. Winston's timestamp is not used; the SDK stamps the line when the transport receives it.

The client, with no logging library

For a script, a worker or your own wrapper, use the client:

const { ZipLoggerClient } = require('ziplogger')

const zl = new ZipLoggerClient({
  endpoint: 'https://app.ziplogger.ai',
  apiKey: process.env.ZIPLOGGER_API_KEY,
  source: 'orders-api',
})

zl.log({ severity: 'info', message: 'Order 83112 created', fields: { orderId: 83112, userId: 'u_42' } })

try {
  JSON.parse(body)
} catch (err) {
  zl.log({ severity: 'error', message: 'Could not read cart', error: err, fields: { orderId: 83112 } })
}

await zl.close()

Pass the Error as error. The client fills stackTrace, exceptionType (SyntaxError here) and exceptionMessage from it. fields values are sent as they are, including nested objects. The severity must be one of debug, info, warn, error or fatal. Anything else is sent as info.

Flush before the process exits

This is the part most people miss. log() only puts the line in a queue. A timer sends it, and that timer does not keep Node alive. We checked: a script that calls zl.log(...) and then ends sends nothing.

So in a script, await zl.close() at the end. It sends what is left and waits up to 5 seconds (close(timeoutMs) changes that).

In a server, Node does not run cleanup on SIGTERM by default, which is what Docker and Kubernetes send. Handle it:

process.on('SIGTERM', async () => {
  await zl.close()
  process.exit(0)
})

With Winston, call await zipTransport.client.close() in that handler. The transport's own close() does not wait for the send to finish. The Pino transport closes its client when Pino shuts its worker down, and in our test the last lines of a script that simply ended were delivered.

One more case. If the server answers 429 or 5xx, the client waits before it retries, and that wait does not keep Node alive either. A short script that hits a retry can exit with the batch still pending, even while it awaits close(). A long-running server is not affected.

What gets added to every line

You don't have to set these yourself:

FieldWhere it comes from
sourcethe source option, then ZIPLOGGER_SOURCE, then the script's file name
releasethe release option, then ZIPLOGGER_RELEASE, then version in the package.json of the directory you start Node from
commitShathe commitSha option, then ZIPLOGGER_COMMIT_SHA, GIT_COMMIT or COMMIT_SHA
fields.environmentthe environment option, then ZIPLOGGER_ENVIRONMENT, then NODE_ENV, then production
fields.machineNameos.hostname()

This is a field on each line. Which ZipLogger environment the line is stored in is decided by the API key, not by this value. See Separate staging and production logs.

Set source. Under a process manager the script name may be server or index, which tells you nothing.

release and commitSha, together with a stack trace, are what ZipLogger uses to point at the commit that likely caused a new error. Set GIT_COMMIT in your build or deploy. See finding the commit that broke production.

Delivery

  • log() never blocks and never throws.
  • The queue holds 10,000 lines (queueCapacity). When it is full, new lines are dropped and counted in client.dropped.
  • Lines go out as NDJSON to /ingest/v1/logs with an X-Api-Key header, up to 100 per request (batchSize). A partial batch waits at most 2 seconds (flushIntervalMs).
  • Network errors, timeouts, 408, 429 and 5xx are retried up to 5 times (maxRetries) with backoff capped at 30 seconds. Retry-After is respected. Other errors, such as 401 for a wrong key, drop the batch at once and count it.
  • When your plan's ingest volume is used up, the API answers 429. After the retries run out, the batch is dropped. Limits are on the pricing page.
  • A retry resends the whole batch, and the SDK does not send an Idempotency-Key. If a request timed out after the server had stored it, those lines can appear twice.
  • There is no disk buffer. If the process is killed, what is in memory is lost.

Finding the lines

In Search, the box searches the message unless you name a field. Your own fields are under fields.:

fields.orderId:83112
fields.requestId:req_7Hq2 AND severity:error
source:orders-api AND "Payment failed"

Click a line for its fields and Stack trace.

What it does not do

  • It does not capture console.log. Only lines you send through Pino, Winston or the client go out.
  • It sends logs only. There is no events API and no tracing in the Node package. For traces, use OpenTelemetry and send them over OTLP. If you add the trace id to a line as a string field named traceId, the log detail shows a View trace link.
  • It does not send a message template, so similar lines are grouped from a sample at read time. Keep the message fixed ('Order created') and put the ids in fields, and the groups stay clean. More in log pattern clustering.

The short version of the setup is in the Node.js docs. To try it, create an account and make a key under Settings → API keys. If you also run Go or Java services, see the Go and Java guides.

Parse log lines into fields and metrics

Add fields to plain-text logs as they arrive with pattern, JSON or key=value rules, then turn matching logs into a metric you can chart and alert on.

Alert on error logs and quiet services

Set up log-based alert rules that fire on error spikes or when a service goes quiet, send them to email or Slack, and keep them from paging for nothing.

Centralized .NET logging with Serilog and ILogger

Send ASP.NET Core logs to ZipLogger with the Serilog sink or the ILogger provider, with structured properties you can search by user or order id.

Try it on your own logs

There is a free plan and no sales call. Install an SDK, send a few log lines, and search them.