zulogs
v0.3.0
Published
Zulogs SDK for Node.js and the browser — automatic error capture and structured logging, with no runtime dependencies.
Maintainers
Readme
zulogs
The Zulogs SDK for JavaScript. Catches unhandled errors on its own, and gives you explicit methods for everything else. No runtime dependencies.
zulogs is the Node half. The browser half is zulogs/browser from the same
package — one install, one version, see In the browser.
npm install zulogsQuick start
Call init() as early as possible, so the error handlers are installed before
anything can throw.
import * as zulogs from 'zulogs';
zulogs.init({
key: process.env.ZULOGS_KEY,
environment: process.env.NODE_ENV,
release: process.env.GIT_SHA,
});
// Uncaught exceptions are reported automatically. Everything else is explicit:
zulogs.info('checkout started', { cartId });
try {
await charge(order);
} catch (error) {
zulogs.captureException(error, { orderId: order.id });
}key falls back to ZULOGS_KEY. Events go to hosted Zulogs, so there is
nothing else to point at.
Levels
trace, debug, info, warn, error, fatal — each is a function taking a
message and an optional context object:
zulogs.warn('payment gateway slow', { ms: 842 });
zulogs.log('error', 'dynamic level', { via: 'variable' });Events below minLevel (default info) are never sent. captureException()
ignores that threshold on purpose: an exception is the reason the SDK exists,
and dropping one because the log threshold is high would be a trap.
Request scope
A server handles many requests at once, so attaching a user to "the SDK" would
tag the wrong request. withScope() binds data to one async call tree via
AsyncLocalStorage, and everything captured inside it — however deeply nested
— carries that data.
import { withScope } from 'zulogs';
app.use((req, res, next) => withScope({ user: { id: req.userId }, traceId: req.id }, next));For process-wide data there is setUser(), setTags() and configureScope().
Scopes merge, with the innermost value winning.
Automatic error capture
uncaughtException is handled by default. The SDK reports the error, flushes,
and then restores what would have happened without it: if no other listener
exists, the process prints the error and exits 1, exactly as Node would.
Unhandled promise rejections are covered by the same handler, because Node
re-raises them as uncaught exceptions. There is a separate
captureUnhandledRejections option, but it is off by default and rarely
correct: registering an unhandledRejection listener is what tells Node the
rejection was handled, so turning it on stops rejections from terminating your
process. Only enable it when running with --unhandled-rejections=warn.
Set captureConsole: true to additionally mirror console.error and
console.warn. The console keeps printing exactly as before.
Keeping the local log
By default the SDK is write-only: zulogs.info() reaches Zulogs and leaves
nothing behind in the service log, which is the wrong trade while you are
attached to a container. mirrorToConsole prints every captured event locally
as well:
zulogs.init({ key: process.env.ZULOGS_KEY, mirrorToConsole: true });
zulogs.warn('payment gateway slow', { ms: 842 });
// 2026-08-16T20:44:33.001Z WARN payment gateway slow { ms: 842 }Levels map onto console.debug, console.info, console.warn and
console.error, and the context object is passed through unstringified so the
console renders it and a circular reference cannot throw. Printing happens
after beforeSend, so a redacted field never reappears locally and a
dropped event is not printed either. minLevel applies as usual: what gets sent
is what gets shown.
The output goes through the console as it was at import time, so enabling this
together with captureConsole cannot feed the mirror back into itself.
Delivery
Events are buffered and sent in batches, at most every flushIntervalMs
(default 2000) or as soon as maxBatchSize (default 100) has accumulated.
Failed requests are retried three times with exponential backoff and jitter;
a 4xx other than 429 is not retried, since repeating a malformed batch or a
dead key only repeats the rejection.
The buffer is bounded at maxQueueSize (default 1000). Beyond it the oldest
events are dropped — during an incident the newest ones describe what is
happening now — and the number dropped travels with the next batch, so the
counts you see are honest rather than silently short.
The flush timer is unref'd, so an idle logger never keeps a CLI alive.
Short-lived processes
Serverless handlers and scripts should flush before they return:
export async function handler(event) {
try {
return await work(event);
} finally {
await zulogs.flush();
}
}close() flushes, removes the handlers and shuts the client down.
Redacting
beforeSend sees every event just before it is queued. Return null to drop
it, or a modified event to strip fields:
zulogs.init({
beforeSend(event) {
if (event.context?.password) delete event.context.password;
return event;
},
});Options
| Option | Default | Meaning |
| ---------------------------- | --------------- | --------------------------------------- |
| key | ZULOGS_KEY | Ingest key |
| environment | NODE_ENV | production, staging, … |
| release | — | Version or commit, used for regressions |
| serverName | os.hostname() | Reported with every event |
| tags | — | Static tags on every event |
| minLevel | info | Lowest level that is sent |
| flushIntervalMs | 2000 | Background flush interval |
| maxBatchSize | 100 | Events per request |
| maxQueueSize | 1000 | Buffer ceiling before dropping oldest |
| captureUncaughtExceptions | true | Handle uncaughtException |
| captureUnhandledRejections | false | See the warning above |
| captureConsole | false | Mirror console.error / console.warn |
| mirrorToConsole | false | Also print every event locally |
| debug | false | Print SDK diagnostics |
| beforeSend | — | Redact or drop events |
Multiple clients
init() manages one default client. For several destinations, construct them
directly — note that only one should install the global handlers:
import { ZulogsClient } from 'zulogs';
const audit = new ZulogsClient({ key, captureUncaughtExceptions: false });In the browser
The browser half is the same package, imported from zulogs/browser:
import * as zulogs from 'zulogs/browser';
zulogs.init({
key: 'zl_pub_live_…',
release: __APP_VERSION__,
});Call it in your entry file, before the application renders, so the handlers are installed before anything can throw.
The key is public
A browser key is a different kind of credential. It ships inside your bundle,
where anyone can read it, so it is bound to the origins you list on the key in
the dashboard and gets a much tighter rate limit than a server key. init()
refuses a zl_live_… key outright: shipping a server credential to every visitor
is the one mistake worth being loud about.
What it captures on its own
error and unhandledrejection on window, which together cover everything a
page throws without catching. Both are one option, captureGlobalErrors,
because a page has no process to keep alive and no crash to restore — the SDK
reports and changes nothing else.
Stacks are parsed for all three engines: Chromium prints at fn (url:line:col),
Firefox and Safari print fn@url:line:col. A parser for one of them would
silently cost the stack of every Safari and Firefox user. Frames from your own
origin are marked as application code; a CDN or an extension is not.
Until sourcemap support lands, frames stay minified, and Zulogs groups browser exceptions by type and message rather than by frame — a minified function name changes with every build, and grouping on it would file the same defect anew after each deploy.
Breadcrumbs
The last 20 things that happened before the error travel with it: navigation,
fetch, XMLHttpRequest and clicks. A click records the element and its label,
never the value someone typed. They are attached to exceptions only, because a
log line needs no trail.
zulogs.addBreadcrumb({ category: 'click', message: 'checkout: pay' });
zulogs.init({
maxBreadcrumbs: 20, // 0 switches them off entirely
beforeBreadcrumb: crumb => (crumb.message.includes('token') ? null : crumb),
});Noise
Three defaults, all overridable:
- The same exception is sent once per five seconds. A render loop throws thousands of times and the hundredth copy says nothing the first one did not.
- At most 30 automatically captured events per page load. Explicit
captureException()calls are never capped. Script error.and the ResizeObserver loop warnings are dropped, as is anything whose top frame is a browser extension. Your ownignoreErrorsanddenyUrlsare added to that list, not swapped for it.
Delivery
Batches are smaller and rarer than on a server (20 events, every five seconds),
because every request competes with the application's own. Whatever is still
buffered goes out on pagehide and when the document becomes hidden.
Those last requests use fetch with keepalive, not navigator.sendBeacon: a
beacon cannot carry the authorization header the ingest endpoint requires. The
tradeoff is a 64 kB ceiling for all keepalive requests of a page, which is why
batches stay small.
What the browser does not have
withScope(). It exists on Node because one process interleaves many requests
and a mutable scope would attach the wrong user to an error. A page belongs to
one visitor for its whole lifetime, so setUser(), setTags() and
configureScope() are all it needs, and an async-looking withScope() would
only be promising something it cannot keep.
Browser options
| Option | Default | Meaning |
| --------------------- | ------- | --------------------------------------------- |
| key | — | Public ingest key, zl_pub_live_…. Required |
| release | — | Version or commit, used for regressions |
| environment | key | Falls back to the key's environment |
| captureGlobalErrors | true | Handle error and unhandledrejection |
| captureConsole | false | Mirror console.error / console.warn |
| maxBreadcrumbs | 20 | Breadcrumbs kept and attached to an exception |
| beforeBreadcrumb | — | Redact or drop a breadcrumb |
| ignoreErrors | — | Extra message patterns to drop |
| denyUrls | — | Extra top-frame patterns to drop |
| sendUrl | true | Report URL and referrer |
| maxAutoEvents | 30 | Automatic reports per page load |
| minLevel | info | Lowest level that is sent |
| flushIntervalMs | 5000 | Background flush interval |
| maxBatchSize | 20 | Events per request |
| maxQueueSize | 100 | Buffer ceiling before dropping oldest |
| mirrorToConsole | false | Also print every event locally |
| beforeSend | — | Redact or drop events |
License
MIT
