boltZharp Docs
javascriptLogs & Applications

JavaScript Logs

@zharp/logs sends logs from browser and Node.js apps, and captures uncaught errors and unhandled promise rejections without another line of code — on its own, or as a transport for Winston, Pino and NestJS.

For distributed tracing and session replay, add @zharp/apm. The two packages work alone or together; together, every log written inside a trace carries that trace's id.

Install

npm install @zharp/logs
# or
pnpm add @zharp/logs
# or
yarn add @zharp/logs
info
Which version you need

The version on npm today is 1.0.9. It sends logs and captures errors, started with ZharpLogger.init({ apiKey, service, … }). Starting from environment variables, the @zharp/logs/init entry, the activation line, ZHARP_DISABLED, the environment/version/source tags, the source and endpoint options, isActive, replacing a running setup with a second init(), and working with @zharp/apm all need @zharp/logs 1.1.0. Code written for 1.0.9 keeps working on 1.1.0 unchanged. Check what you have with npm ls @zharp/logs.

@zharp/logs sends logs only. Tracing and session replay are not in any version of it — they are @zharp/apm.

API key

  • In the dashboard, open Integrations & API and choose Main API keys.
  • Click Create API key, give it a name, and tick Ingest only. That key can send data but has no access to the REST API, so it's safe in a server's environment or a browser bundle — and it's available on every plan.
  • Click Create key and copy the key. It's shown only once.
Create API key, with Ingest only tickedThe new key, shown once with a Copy button

Start it

Importing the package starts nothing. Call ZharpLogger.init() once, as early as possible: it hooks the error handlers and starts sending. Logs written before that are dropped. Give it its settings in code, or let it read them from the environment — either works.

In code — Node.js or the browser

Pass the settings to init(). In a browser this is the only way, since a browser has no environment to read:

Call once at startup
import { ZharpLogger } from '@zharp/logs';

// Once, at startup (main.ts, _app.tsx, index.js …)
ZharpLogger.init({
  apiKey: 'YOUR_INGEST_ONLY_KEY',   // an Ingest only key — safe in a bundle
  service: 'checkout-web',
  environment: 'production',
  appVersion: '1.4.2',
});

Without service, a browser's logs are filed under unknown-service, so always set it there.

From the environment — Node.js

Set the variables where the app runs:

ZHARP_API_KEY=your-ingest-only-key
ZHARP_SERVICE=checkout-api      # optional — defaults to the name in your package.json
ZHARP_ENV=production            # optional — defaults to NODE_ENV

and call init() with nothing:

main.ts
import { ZharpLogger } from '@zharp/logs';

ZharpLogger.init();   // reads ZHARP_* from the environment

Or don't touch your code at all: start Node with the @zharp/logs/init entry, which calls init() for you:

node --import @zharp/logs/init app.js

# or through NODE_OPTIONS, when you don't control the node command
NODE_OPTIONS="--import @zharp/logs/init" npm start

You can mix the two ways: what you pass to init() is used, and anything you leave out is read from the environment, setting by setting.

Either way, one line is printed on startup (with console.info in a browser):

[Zharp] Logging active — service "checkout-api", environment "production"

With no environment set it reads no environment set in place of environment "…". The app then uses ZharpLogger as usual, and gets the logger that was started whether it requires the package or imports it.

VariableOptionNotes
ZHARP_API_KEYapiKey
ZHARP_SERVICE, then OTEL_SERVICE_NAMEserviceThen the name in your app's package.json; with none of these, unknown-service, with a warning
ZHARP_ENV, then NODE_ENVenvironmentNo default — unset means no tag
ZHARP_VERSIONappVersion
ZHARP_SOURCEsource
ZHARP_ENDPOINTendpointDefault https://api.zharp.io/api/v1
ZHARP_CAPTURE_CONSOLEcaptureConsoleall, or a list such as warn,error
ZHARP_DISABLED—true, 1, yes or on: start nothing

A browser reads none of these: bundles often carry a process.env baked in at build time, which would tag a page with the build machine's settings.

What it prints when it doesn't start

SituationPrinted
No API key in the options or ZHARP_API_KEY — nothing starts, every logging call does nothing[Zharp] No API key found — set ZHARP_API_KEY or call ZharpLogger.init({ apiKey })
No service name — it starts anyway[Zharp] No service name — pass `service` to init(), set ZHARP_SERVICE, or give your package.json a name. Using 'unknown-service'.
ZHARP_DISABLED is set — nothing starts, whatever the code asks for[Zharp] Logging disabled by ZHARP_DISABLED — nothing will be sent

A missing key never throws, so a test run or a preview environment without one still starts. Calling init() again replaces the running setup: the old one's handlers are removed, what it had queued is still sent with its own key, and the new configuration takes over.

Options

Everything is optional in Node; in a browser, pass apiKey and service.

OptionDefaultWhat it does
apiKeyZHARP_API_KEYThe Ingest only key the SDK sends with. Without one, nothing starts.
serviceZHARP_SERVICE, then OTEL_SERVICE_NAME, then the package.json name, then unknown-serviceThe service every log is filed under.
environmentZHARP_ENV, then NODE_ENV; none in a browserThe log's Environment. Never defaults to production.
appVersionZHARP_VERSIONThe log's Version, so you can filter to one release.
sourceZHARP_SOURCEThe log's Source — what wrote it, such as node. The source also picks how a line is parsed.
endpointZHARP_ENDPOINT, then https://api.zharp.io/api/v1Where logs are sent.
flushInterval5000How often queued logs are sent, in milliseconds.
maxBatchSize50The most logs taken from the queue on each send.
captureConsoleOff; in Node, ZHARP_CAPTURE_CONSOLEAlso send console output: 'all' or a list such as ['warn', 'error']. See Console output.
autoCapture{ errors: true, unhandledRejections: true, consoleErrors: false }What is captured without any code — see below.

The environment-variable fallbacks, source and endpoint need 1.1.0. In 1.0.9, apiKey and service are required.

Automatic error capture

From the moment init() succeeds, unhandled errors are sent as error logs with their stack:

WhatBrowserNode.jsOption
Uncaught errorswindow.onerrorprocess 'uncaughtException'errors
Unhandled promise rejections'unhandledrejection'process 'unhandledRejection'unhandledRejections
console.erroropt-inopt-inconsoleErrors
ZharpLogger.init({
  apiKey: 'YOUR_INGEST_ONLY_KEY',
  service: 'checkout-web',
  autoCapture: {
    errors: true,               // window.onerror / process 'uncaughtException'
    unhandledRejections: true,  // 'unhandledrejection' / process 'unhandledRejection'
    consoleErrors: true,        // also send every console.error() call (off by default)
  },
});
  • The log's metadata says what caught it: type is uncaughtError (browser), uncaughtException (Node) or unhandledRejection, next to the stack. A browser error also carries source, lineno and colno; a console.error call carries source: 'console.error'.
  • A rejection with no reason is logged as Unhandled promise rejection.
  • A window.onerror handler set before init() is still called. consoleErrors wraps console.error, so the console still prints as before.
  • Every captured error is sent straight away, like any error log.
info
A crash is still a crash

In Node, when your app has no uncaughtException handler of its own, the SDK sends the error and anything still queued — waiting at most 2 seconds — then prints the error and exits with code 1, as Node would have. An unhandled rejection does the same unless Node runs with --unhandled-rejections=warn or none. If your app registers its own handler, what happens next is up to it.

Console output

Off by default. With captureConsole on, what your app writes to the console is sent too, and the console still prints exactly as before. In Node, ZHARP_CAPTURE_CONSOLE=all (or warn,error) turns it on without changing code. Needs @zharp/logs 1.2.0.

ZharpLogger.init({
  apiKey: 'YOUR_INGEST_ONLY_KEY',
  service: 'checkout-web',
  captureConsole: 'all',             // or only some: ['warn', 'error']
});
Console methodLog level
console.log, console.infoinfo
console.warnwarn
console.errorerror
console.debugdebug
  • Arguments are joined the way the console shows them. An Error reads TypeError: … in the message, and its stack goes in the metadata next to origin: 'console' and consoleMethod.
  • The SDK's own [Zharp] lines are never sent, and the activation line ends with capturing the console when it's on.
  • With error in the list, consoleErrors isn't needed — each call is sent once.
warning
Not where the agent already collects your output

Use it in a browser, a serverless function, or on a server where nothing else collects the app's output. Where the Zharp agent already reads the app's stdout — Docker, journald, PM2, Kubernetes — leave it off, or every line arrives twice.

See your logs

Open Logs in the dashboard sidebar and pick your service in the service dropdown — the service name you gave init(), or ZHARP_SERVICE.

The Logs page, showing lines from the zharp-backend service
  • New lines appear as they arrive while LIVE is on; click it to pause the stream.
  • 1H and 24H show history; LEVELS filters by level.
  • The search box finds words in the lines, and @field conditions such as @http.status_code>=500 filter on fields parsed from them.
  • Click a line to see everything it carried: its metadata, the error stack, the session id.
  • To be told when something shows up, add a log monitor.

Logging

ZharpLogger.debug('Cache miss', { key: 'user:42' });
ZharpLogger.info('Request received', { path: '/checkout' });
ZharpLogger.warn('Rate limit approaching', { remaining: 5 });
ZharpLogger.error('Stripe webhook failed', { eventId: 'evt_123' });
ZharpLogger.fatal('Database connection lost');

// Any level, and optionally another service name for this one line:
ZharpLogger.log('error', 'Charge declined', { amount: 5000 });
ZharpLogger.log('payment-service', 'error', 'Charge declined', { amount: 5000 });

error and fatal are sent straight away; the other levels are queued and sent in batches, so logging never waits on the network. The message is always sent as text: an object as JSON, an Error as its message, anything else through String(), and an empty message as (empty message).

In a single-page app, call refreshContext() after each route change so the URL and route on later logs stay current:

// React Router
const location = useLocation();
useEffect(() => ZharpLogger.refreshContext(), [location.pathname]);

// Vue Router
router.afterEach(() => ZharpLogger.refreshContext());

// Next.js App Router, in a client component in the root layout
const pathname = usePathname();
useEffect(() => ZharpLogger.refreshContext(), [pathname]);

What a log carries

One log entry
{
  "service": "checkout-web",
  "level": "error",
  "message": "Payment failed",
  "timestamp": "2026-10-20T09:14:32.041Z",
  "environment": "production",
  "version": "1.4.2",
  "source": "web",
  "metadata": {
    "orderId": 99,
    "sessionId": "a3f8c2d1-…",
    "environment": "production",
    "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",   // only with @zharp/apm, inside a trace
    "spanId": "00f067aa0ba902b7",
    "context": {
      "url": "https://shop.example.com/checkout",
      "route": "/checkout",
      "userAgent": "Mozilla/5.0 …",
      "screen": "1920x1080",
      "appVersion": "1.4.2"
    }
  }
}
  • environment, version (from appVersion) and source are top-level tags, sent only when configured — or, in Node, found in the environment. environment is repeated in metadata for older backends. These tags need 1.1.0.
  • Your own fields sit at the top of metadata, next to sessionId — one per page load or process. The runtime details go under context so they never collide with yours: URL, route, user agent and screen size in a browser; hostname (the pod name in Kubernetes), pid, nodeVersion and platform in Node.
  • With @zharp/apm running, a log written inside a trace also carries traceId and spanId.

How lines are tagged

  • service becomes the log's service, environment its Environment, appVersion its Version and source its Source, so a log monitor scoped to an environment or source covers SDK logs too. See Unified service tagging.
  • An info line whose text starts with a level word — WARNING …, [ERROR] … — is stored at that level, the same as for lines from the agent. Lines sent at debug, warn, error or fatal keep the level you gave.

Delivery

How 1.1.0 sends:

  • Logs wait in a queue of up to 500 entries; when it is full, the oldest is dropped. Every flushInterval, up to maxBatchSize of them are sent, and no request is larger than about 1 MB — a larger batch is split.
  • Requests go to /agent/logs with the key in an X-API-KEY header, not in the address, and X-Zharp-SDK: js-logs/1.1.0.
  • A request that fails on the network or with a 5xx is tried three times in all: once, then after 2 seconds, then after 4. A 4xx is not retried.
  • When a tab is hidden or closed, everything queued is sent with navigator.sendBeacon, in pieces under 60 KB (browsers cap beacons at 64 KB). A beacon can't carry a header, so it is the one request with the key in its address (/agent/logs/<key>). A refused beacon falls back to a keepalive fetch, then an ordinary request.
  • In Node, the send timer doesn't keep the process alive, and what is still queued when the work runs out is sent on beforeExit — a script doesn't need to call destroy() to end.
await ZharpLogger.flush();     // send what is queued now
await ZharpLogger.destroy();   // send all of it, remove every handler and listener
ZharpLogger.isActive;          // whether init() succeeded and logs are being sent

destroy() is useful before a hot reload in development or at the end of a test; to change the configuration, call init() again instead.

With your existing logger

Each recipe forwards your logger's lines to Zharp and keeps your current output. Start the SDK first — with ZharpLogger.init() or --import @zharp/logs/init.

Winston

Install winston-transport as well — pnpm needs it declared even though Winston uses it.

zharp-transport.ts
import Transport from 'winston-transport';
import { ZharpLogger } from '@zharp/logs';

export class ZharpTransport extends Transport {
  log(info: any, callback: () => void) {
    const { level, message, ...meta } = info;
    ZharpLogger.log(level, String(message), Object.keys(meta).length ? meta : undefined);

    // Tells Winston this transport has finished with the line.
    setImmediate(() => this.emit('logged', info));
    callback();
  }
}

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

Pino

import pino from 'pino';
import { Writable } from 'stream';
import { ZharpLogger } from '@zharp/logs';

// Pino levels are numbers — map them to Zharp's
const PINO_LEVELS: Record<number, string> = {
  10: 'debug', // trace → debug
  20: 'debug',
  30: 'info',
  40: 'warn',
  50: 'error',
  60: 'fatal',
};

const zharpStream = new Writable({
  write(chunk, _encoding, callback) {
    try {
      const { level, msg, ...meta } = JSON.parse(chunk.toString());
      // `logger.info({ orderId })` writes a line with no `msg` at all.
      const message = msg ?? (meta.err?.message || JSON.stringify(meta));
      ZharpLogger.log(PINO_LEVELS[level] ?? 'info', String(message), meta);
    } catch {}
    callback();
  },
});

const logger = pino(
  pino.multistream([
    { stream: process.stdout },   // keep your existing output
    { stream: zharpStream },      // and send to Zharp
  ]),
);

NestJS

// zharp-nest-logger.ts
import { LoggerService } from '@nestjs/common';
import { ZharpLogger } from '@zharp/logs';

// Named ZharpNestLogger so it doesn't shadow the ZharpLogger it forwards to.
export class ZharpNestLogger implements LoggerService {
  log(message: any, context?: string) { ZharpLogger.info(String(message), { context }); }
  error(message: any, trace?: string, context?: string) { ZharpLogger.error(String(message), { trace, context }); }
  warn(message: any, context?: string) { ZharpLogger.warn(String(message), { context }); }
  debug(message: any, context?: string) { ZharpLogger.debug(String(message), { context }); }
  verbose(message: any, context?: string) { ZharpLogger.debug(String(message), { context }); }
}

// main.ts
async function bootstrap() {
  // Key, service and environment from ZHARP_API_KEY / ZHARP_SERVICE / ZHARP_ENV,
  // or pass them: ZharpLogger.init({ apiKey, service: 'my-api', environment: process.env.NODE_ENV })
  ZharpLogger.init();

  const app = await NestFactory.create(AppModule, { logger: new ZharpNestLogger() });
  await app.listen(3000);
}
bootstrap();

With @zharp/apm

Start the logger first and ZharpAPM.init() needs no options — it reuses the logger's key, service, environment, version, source and endpoint:

import { ZharpLogger } from '@zharp/logs';
import { ZharpAPM } from '@zharp/apm';

ZharpLogger.init({ apiKey: 'YOUR_INGEST_ONLY_KEY', service: 'checkout-web', environment: 'production' });
ZharpAPM.init();   // no options: reuses the logger's key, service, environment, version, source and endpoint

In Node, preload both:

node --import @zharp/logs/init --import @zharp/apm/init app.js
  • A log written inside a trace carries its traceId and spanId, so you can open the trace from the log and find a trace's logs.
  • Logs, spans and session recordings from the same page load or process share one sessionId.
  • warn, error and fatal logs join the breadcrumb trail attached to the next exception, and an error or fatal log flags the session recording when replay is on.

Neither package imports the other, so an app that uses only one never bundles the other. This needs @zharp/logs 1.1.0; 1.0.9 doesn't connect to @zharp/apm.