boltZharp Docs
dnsSDKs

Node.js

@zharp/node sends your server's logs and errors, traces every request it handles and makes, and files them under the browser session and user that caused them.

info
@zharp/node 0.1.0

This SDK is new, and its API may still change before 1.0. Requires Node.js 22.15 or later, as OpenTelemetry does.

It is the official OpenTelemetry Node SDK and its automatic instrumentations, configured for Zharp. Express, Fastify, Koa, NestJS, pg, mysql, mongodb, redis, undici/fetch, gRPC, GraphQL and the AWS SDK are traced with no code.

Install

npm install @zharp/node

Initialize

Zharp has to start before your app loads its framework and database driver: it traces them by patching them as they load. That takes one new file with this in it, and one place where your app loads it first.

// instrument.js
import { Zharp } from "@zharp/node";

Zharp.init({
  key: "zpk_…",
  service: "checkout-api",
  environment: "production",
  release: process.env.RELEASE, // optional
});

The key can only send data, so it may live in code. To keep it in the environment instead, leave key out and set ZHARP_KEY.

NestJS (and other TypeScript built to CommonJS)

Save the file as src/instrument.ts, next to main.ts, and import it on the first line of main.ts. Compiled to CommonJS (Nest's default), imports run in order, so Zharp starts before Nest and Express load. Nothing else changes: build and start your app as you do now.

// src/main.ts
import "./instrument"; // first line, before anything else
import { NestFactory } from "@nestjs/core";
// …the rest of main.ts as it is

Express, Fastify and other Node apps

Save the file as instrument.js in your project's root, and start your app with --import ./instrument.js in front of the file you start today. Usually that's the start script in package.json:

my-api/
├── package.json
├── instrument.js   ← new: the file above
└── server.js       ← the file you start your app with today
// package.json
"scripts": {
  "start": "node --import ./instrument.js server.js"
}

// before: "start": "node server.js"

The same goes for a Dockerfile CMD, a pm2 config or a Procfile: wherever node server.js is, it becomes node --import ./instrument.js server.js. In ES modules ("type": "module") this flag is the only way: an import in your own code runs too late there.

On start it prints:

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

From the environment only

With nothing in code, preload the package's own entry and pass the settings as environment variables:

ZHARP_API_KEY=zpk_… ZHARP_SERVICE=checkout-api ZHARP_ENV=production \
  node --import @zharp/node/init server.js

Next.js

Next.js runs register() in instrumentation.ts before anything else on the server. Use @zharp/node there, and @zharp/browser in instrumentation-client.ts for the pages — as two apps, so the browser's traces continue into the server's.

// instrumentation.ts, at the project root (or in src/)
export async function register() {
  if (process.env.NEXT_RUNTIME === "nodejs") {
    const { Zharp } = await import("@zharp/node");
    Zharp.init({
      key: "zpk_…",
      service: "checkout-api",
      environment: "production",
    });
  }
}
// next.config.ts — only if the build complains about OpenTelemetry modules
const nextConfig = { serverExternalPackages: ["@zharp/node"] };
export default nextConfig;
warning

The Edge runtime isn't supported: routes and middleware running on Edge aren't traced.

Logs

console, pino, winston and bunyan are sent as they are, each line with the trace and span it was written in and the request's user. Turn the console off with captureConsole: false. Or write to Zharp directly:

Zharp.log.info("Order placed", { orderId: 42 });   // trace, debug, info, warn, error, fatal
Zharp.log.error("Charge failed", { error });       // an Error among the attributes is sent as the exception

Logs only

If you only want logs, turn tracing off. Logs, errors, and the user and session they belong to are still sent; no spans are recorded and no trace headers are added to outgoing requests. A request from a Zharp front end is still read, so its logs keep the caller's user, session and trace ID.

// instrument.js — load it first: node --import ./instrument.js server.js
import { Zharp } from "@zharp/node";

Zharp.init({
  key: "zpk_…",
  service: "checkout-api",
  tracing: false,
});

Or leave the code as it is and set ZHARP_TRACING=false in the environment.

The logs show on the Logs page under the service name, and on the app's Logs tab with the user and session of each line. The key comes from an app — see Get a key. To add tracing later, remove the option; nothing else changes.

Errors

Uncaught exceptions and unhandled rejections are sent as unhandled errors and grouped into issues. Report one you caught with captureError:

try {
  await charge(order);
} catch (error) {
  Zharp.captureError(error, { orderId: order.id });  // reported as handled
}

Listening for uncaughtException stops Node from exiting, so when Zharp's listener is the only one it does what Node would have: prints the error and exits with code 1, after the error is sent (waiting at most 2 seconds). If your app has its own listeners, they decide what happens and Zharp only reports. --unhandled-rejections is respected.

Readable stack traces

Server code that is bundled or minified — a Next.js server, an esbuild or webpack bundle — reports stacks into the bundle. Add Zharp's plugin to your build and errors are shown and grouped by your original functions, files and lines, with the code around each frame. On a production build the plugin turns on hidden source maps, gives each JS file a debug ID, uploads the files and maps, and deletes the maps from the output so your source isn't deployed.

// npm install --save-dev @zharp/vite-plugin
// vite.config.js
import { defineConfig } from "vite";
import { zharp } from "@zharp/vite-plugin";

export default defineConfig({
  plugins: [zharp({ app: 42 })],
});

42 is the app's ID, the number in its dashboard URL (or ZHARP_APP). Set ZHARP_AUTH_TOKEN in CI to a workspace key with the artifacts:write scope — see The upload key. Without it, local builds still work: the plugin warns, uploads nothing and still removes the maps.

Plugin optionDefaultNotes
appZHARP_APPThe application ID.
authTokenZHARP_AUTH_TOKENKeep it in CI secrets.
releaseZHARP_RELEASE, else the commit SHA from your CI or hostOptional: matching is by debug ID.
deleteSourcemapsAfterUploadtruefalse keeps the .map files.
include / excludeevery JS fileGlobs or regular expressions.
failOnErrorfalseFail the build when the upload fails, instead of warning.
dryRun, force, disabled, silentfalseReport only; run on non-production builds; do nothing; no output.

The SDK sends the debug IDs of the files in each error's stack, so maps are found whatever URL or CDN the code was served from. More: Readable stack traces, Zharp CLI.

Tracing

  • A span per incoming request, with method, route (where the framework knows it) and status — 5xx are errors — plus spans for outgoing HTTP calls and database and cache queries.
  • A request carrying traceparent continues the caller's trace; the user and session from its baggage are copied onto every span and log of that request.
  • dns, net and fs are off by default — a span per lookup or file read is noise.
const invoice = await Zharp.startSpan("render invoice", async () => render(order));

Zharp.setTag("region", "eu-west");                   // on every span and log from now on

The rest of @opentelemetry/api works as usual — trace.getActiveSpan(), trace.getTracer("my-lib") — and goes to Zharp too.

User context

Requests from @zharp/browser and the mobile SDKs already carry the user. When the server knows better, setUser inside a request sets it for the rest of that request; outside a request (a script or a worker) it applies to everything the process sends.

app.use((req, res, next) => {
  if (req.user) Zharp.setUser({ id: req.user.id, email: req.user.email });
  next();
});

Options

OptionEnvironmentDefault
keyZHARP_API_KEY or ZHARP_KEY— (nothing is sent without one)
serviceZHARP_SERVICE, OTEL_SERVICE_NAMEThe name in package.json
environmentZHARP_ENV, NODE_ENVnone
releaseZHARP_RELEASEnone
endpointZHARP_ENDPOINThttps://api.zharp.io/api/v1/otlp
tracingZHARP_TRACINGtrue; false is logs only
sampleRate—1. Share of new traces kept; a trace arriving from the browser keeps the browser's decision. Errors and logs are always sent.
captureConsole—true
captureUncaught—true
instrumentations—Settings per instrumentation, e.g. { "@opentelemetry/instrumentation-fs": { enabled: true } }
extraInstrumentations—More OpenTelemetry instrumentations
debug—Print what OpenTelemetry is doing
—ZHARP_DISABLEDtrue starts nothing

init runs once per process; a second call is ignored with a warning.

Delivery and shutdown

Traces and logs go as OTLP/protobuf, gzipped, to /v1/traces and /v1/logs. What is waiting is sent when the event loop empties and on SIGTERM or SIGINT. In a serverless function, flush before returning:

export async function handler(event) {
  try {
    return await process(event);
  } finally {
    await Zharp.flush();   // send before the platform freezes the function
  }
}

Zharp.shutdown() flushes and stops.

Troubleshooting

Nothing arrives

On the app's setup page (App Telemetry → the app → settings → Setup instructions), the verify step shows the first event as soon as it lands, and Send a test event checks the key on its own. If the test event arrives but your app's don't, the SDK isn't starting or isn't reaching Zharp:

  • Is init called, and before the code you expect to see? Without a key, the SDK starts nothing and says so once.
  • Does the service name match? With an app key everything goes to that app whatever it says; with a workspace key, service decides the app — see Keys.
  • Data is sent in batches every few seconds. A process that exits straight away should call flush first.
  • Logs and errors arrive but no traces? Check that tracing isn't turned off — see Logs only.
  • Does Zharp start before your app? NestJS and other CommonJS builds: import "./instrument" must be the first line of main.ts. ES modules and plain Node: start with node --import ./instrument.js. Loaded any later, errors and logs still arrive but requests and queries aren't traced.
  • Is Node 22.15 or later? Check with node --version.

401 or 403

Ingest answers 401 when the key is missing, mistyped, revoked or rolled, and 403 when the request comes from an origin or IP address the key doesn't allow. Neither is retried: the SDK warns once and drops what was refused, and it never throws into your code. Copy the key again from the app's settings.