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
nullare sent as they are. Objects and arrays are sent as JSON text.cartabove is one string, not two fields. If you want to filter oncart.items, logcartItems: 3instead. msgbecomes the message and Pino's numeric level becomesdebug,info,warn,errororfatal. Pino'stracelevel is sent asdebug.hostnameis dropped. The SDK sendsmachineNameinstead.pidis kept.erris special. Its stack goes tostackTrace, its type toexceptionTypeand its message toexceptionMessage. For the error line above that wasTypeErrorandCannot 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:
| Field | Where it comes from |
|---|---|
source | the source option, then ZIPLOGGER_SOURCE, then the script's file name |
release | the release option, then ZIPLOGGER_RELEASE, then version in the package.json of the directory you start Node from |
commitSha | the commitSha option, then ZIPLOGGER_COMMIT_SHA, GIT_COMMIT or COMMIT_SHA |
fields.environment | the environment option, then ZIPLOGGER_ENVIRONMENT, then NODE_ENV, then production |
fields.machineName | os.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 inclient.dropped. - Lines go out as NDJSON to
/ingest/v1/logswith anX-Api-Keyheader, 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-Afteris 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.