JavaScript APM
@zharp/apm traces every request your app makes or serves — fetch and XMLHttpRequest in the browser, http and https in and out in Node — joins them into one trace across the browser, your services and your queue workers, and can record browser sessions for replay.
For logs and automatic error capture, add @zharp/logs. The two work alone or together; together, every log written inside a trace carries that trace's id.
Install
npm install @zharp/apm # or pnpm add @zharp/apm # or yarn add @zharp/apm
Tracing and session replay are only in @zharp/apm, whose first release is 1.0.0. @zharp/logs doesn't trace or record in any version: a tracing option or a @zharp/logs/replay import from an older guide won't work. To have logs carry trace ids, or to start ZharpAPM.init() from the logger's settings, use @zharp/logs 1.1.0 alongside it.
Use a key with the Ingest only scope, created under Integrations & API → Main API keys: it can send data but has no access to the REST API.
Start it
Importing the package starts nothing. ZharpAPM.init() — or the @zharp/apm/init entry, which calls it for you — patches fetch, XHR and http and starts sending.
Node.js
ZHARP_API_KEY=your-ingest-only-key ZHARP_SERVICE=checkout-api ZHARP_ENV=production \ node --import @zharp/apm/init app.js
Preloading is the surest way to trace a Node app: http is patched before your code or any library loads, so nothing holds on to an unpatched copy. Calling init() at the very top of your entry file works too:
import { ZharpAPM } from '@zharp/apm';
ZharpAPM.init(); // reads ZHARP_* from the environmentOn success, one line is printed:
[Zharp] APM active — service "checkout-api", environment "production"
Browser
import { ZharpAPM } from '@zharp/apm';
ZharpAPM.init({
apiKey: 'YOUR_INGEST_ONLY_KEY', // shipped in your bundle
service: 'checkout-web',
environment: 'production',
propagateTo: [/api\.yourdomain\.com/], // your own API, on another origin
});With @zharp/logs
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(); // reuses the logger's settings
// Node: node --import @zharp/logs/init --import @zharp/apm/init app.jsWhere the settings come from
Setting by setting: the options passed to init(), then the settings @zharp/logs was started with (when it started first), then — in Node only — the environment:
| Variable | Option |
|---|---|
ZHARP_API_KEY | apiKey |
ZHARP_SERVICE, then OTEL_SERVICE_NAME | service |
ZHARP_ENV, then NODE_ENV | environment |
ZHARP_VERSION | version |
ZHARP_SOURCE | source |
ZHARP_ENDPOINT | endpoint |
ZHARP_DISABLED | true, 1, yes or on: start nothing |
| Situation | Printed |
|---|---|
| Started | [Zharp] APM active — service "checkout-web", environment "production" |
| Started with replay (browser) | [Zharp] APM active — service "checkout-web", environment "production", session replay at 10% of sessions |
| No environment configured | [Zharp] APM active — service "checkout-web", no environment set |
| No API key — nothing starts | [Zharp] No API key found — set ZHARP_API_KEY or call ZharpAPM.init({ apiKey }) |
| No service name — it starts as unknown-service | [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 | [Zharp] APM disabled by ZHARP_DISABLED — nothing will be sent |
The span methods work before init(), and when it didn't start: the work runs and nothing is recorded, so a missing key never stops the app. Calling init() again replaces the running setup — everything is unpatched and patched again with the new configuration, and spans already finished are sent under the old key.
Options
| Option | Default | What it does |
|---|---|---|
apiKey | @zharp/logs' key, then ZHARP_API_KEY | The Ingest only key spans and recordings are sent with. Without one, nothing starts. |
service | @zharp/logs', then ZHARP_SERVICE, OTEL_SERVICE_NAME, the package.json name, unknown-service | The service every span is filed under. |
environment | @zharp/logs', then ZHARP_ENV, NODE_ENV | The span's environment. No default. |
version | @zharp/logs', then ZHARP_VERSION | The application version. appVersion is accepted too. |
source | @zharp/logs', then ZHARP_SOURCE | What produced the spans, such as web or worker. |
endpoint | @zharp/logs', then ZHARP_ENDPOINT, then https://api.zharp.io/api/v1 | Where spans and recordings are sent. |
flushInterval | 5000 | How often finished spans are sent, in milliseconds. |
sampleRate | 1 | The share of traces recorded, 0 to 1. |
fetch | true | Trace fetch and XMLHttpRequest. |
http | true | In Node, trace incoming requests and outgoing http/https calls. |
breadcrumbs | true | Keep a trail of clicks, input and navigations, attached to the next error. |
propagateTo | Same origin (browser), everything (Node) | Which requests get the traceparent header. Strings match anywhere in the URL; regular expressions are tested against it. |
ignore | none | URLs never traced, such as /health. Strings and regular expressions, matched the same way. |
replay | off | Session replay, browser only. true records every session with the default privacy settings; an object sets the options below. |
Every span carries the resource attributes service.name, deployment.environment.name (and the older deployment.environment), service.version and zharp.source — the same unified service tags as logs. One that isn't configured isn't sent.
What is traced automatically
In the browser
Every fetch and XMLHttpRequest becomes a CLIENT span named after its method and path, such as GET /api/orders, with http.method, http.url and http.status_code. A 4xx or 5xx reply, or a request that never got one — DNS, connection, CORS — marks the span as an error. The request carries a W3C traceparent header, so the backend request it causes joins the same trace.
In Node
- Every incoming request becomes a
SERVERspan. Express, Fastify, Nest and Koa all build onhttp.Server, so no framework-specific setup is needed. When the request carries atraceparent, the span joins the caller's trace. It hashttp.method,http.target,http.host,http.user_agentandhttp.status_code. Only a 5xx marks it as an error — a 4xx is the caller's mistake — and so does a connection closed before the response finished. - Every outgoing
http.request,http.get, theirhttpsversions andfetchbecomes aCLIENTspan beneath the request that made it, ended when the response body has been read. Work inside a request stays attached to it acrossawait, so concurrent requests never mix.
A traceparent you set on a request yourself is kept and becomes the span's parent. The SDK's own requests to Zharp are never traced.
Span names
A request span is named after its method and path; the query string is left out and the full URL stays on http.url or http.target. Ids in the path are grouped by Zharp, so GET /orders/8812 and GET /orders/8813 are both GET /orders/:id in APM and in an application monitor's operations. Numbers, UUIDs and long id-like tokens count as ids.
propagateTo
By default a browser sends traceparent to the page's own origin only. Adding a header to a cross-origin request turns it into a CORS preflight, and a third party that doesn't allow the header rejects the request outright — so list only APIs you control, and make sure they allow traceparent. In Node every request gets the header unless you set propagateTo.
ignore and sampleRate
ignore is checked against the request URL — for an incoming request, its path and query — and a match is neither traced nor timed. sampleRate is decided once, where a trace starts, and every span in it follows: a recorded trace is always whole, because a half-recorded one has gaps that look like latency. A request that arrives with a traceparent follows the caller's decision.
An app that runs as native ES modules ("type": "module" or .mjs) needs Node 20.16+ or 22.3+ for http tracing and for work after an await to keep its trace. On older Node the ES module build still records your own spans but doesn't patch http. Apps running as CommonJS are traced fully on any supported version.
Your own spans
startActiveSpan
Times a function and makes the span active while it runs, so anything traced inside — an outgoing call, a nested span, a log from @zharp/logs — attaches to it. The span ends when the function settles, including when it returns a promise; a thrown error is recorded on the span with its stack, the span is marked as an error, and the error is re-thrown unchanged.
const order = await ZharpAPM.startActiveSpan('createOrder', async (span) => {
span.setAttribute('cart.items', items.length);
return db.orders.create(input); // calls made in here attach to this span
});
// With options: a kind, attributes, or an explicit parent
await ZharpAPM.startActiveSpan('rebuildIndex', { attributes: { 'index.name': 'orders' } }, () => rebuild());startSpan
For work that doesn't nest — a span that outlives the function that opened it. It is not made active, and you end it yourself.
const span = ZharpAPM.startSpan('uploadBatch');
uploader.on('done', () => {
span.setStatus('OK');
span.end();
});
uploader.on('error', (error) => {
span.recordException(error);
span.setStatus('ERROR', error.message);
span.end();
});| On a span | What it does |
|---|---|
setAttribute(key, value) | Attach a string, number, boolean or an array of those. |
setAttributes(attributes) | Attach several at once. |
setName(name) | Rename the span before it ends. |
setStatus('OK' | 'ERROR', message?) | Mark it as succeeded or failed. The message is shown as the failure. |
addEvent(name, attributes?) | Add a timestamped moment. |
recordException(error) | Record an error with its stack, source context and breadcrumbs. It doesn't mark the span failed — call setStatus('ERROR') when it did. |
end() | Finish the span. Ending twice is ignored. |
Both take options as the second argument: kind (INTERNAL by default, or SERVER, CLIENT, PRODUCER, CONSUMER), attributes, and parent — omit it to use the active span, or pass null to start a new trace.
Across queues and workers
Queues, WebSocket messages and workers break the chain the SDK can follow. traceHeaders() returns { traceparent } for the active span (an empty object when there is none); continueTrace() picks it up on the other side:
// Producer: carry the trace with the job
await queue.add('sendEmail', { orderId, trace: ZharpAPM.traceHeaders() });
// Consumer: the third argument names the work
worker.process((job) =>
ZharpAPM.continueTrace(job.data.trace?.traceparent, () => sendEmail(job.data), 'sendEmail'));With a name, continueTrace runs the function inside a CONSUMER span of that name, so each job gets its own duration and error status the way a request does — and counts as a request for an application monitor. Without a name it only makes the caller's trace active, and creates no span. A missing or malformed traceparent starts a new trace.
Showing a trace id
res.status(500).json({ error: 'Something went wrong', traceId: ZharpAPM.currentTraceId() });currentTraceId() returns the active trace's id, or null. A support ticket quoting it leads straight to the trace.
What an error carries
An exception recorded on a span — thrown inside startActiveSpan or a named continueTrace, a fetch or Node http request that got no response, or passed to recordException — carries two things besides its stack.
Breadcrumbs
The last 20 things that happened before it, oldest first:
- Clicks (
Clicked button#checkout), named by id, then first class, then form name — never by the element's text. - Input, as
Typed into input#emailonce per changed field. What was typed is never captured, and a password field isn't even named: it readsTyped into a password field. - Navigations, including single-page route changes (
Navigated to /checkout). fetchrequests and their status (POST /api/pay → 502).- With
@zharp/logs1.1.0, itswarn,errorandfatallogs. - Your own:
ZharpAPM.addBreadcrumb('Applied discount code', { code: 'SUMMER' });
ZharpAPM.breadcrumbs(); // the trail as it stands, oldest firstbreadcrumbs: false stops recording clicks, input and navigations.
Source context
In Node, the five lines either side of each frame are read when the error is recorded and travel with it, for up to five frames of your own code — files under node_modules and Node's internals are skipped. The code is there in the trace even when the file is on a machine nobody can reach or a deploy behind. Not captured in the browser, where the stack points at bundled output.
Session replay
Records the page with rrweb and plays it back next to the session's traces and logs. Browser only; off unless you turn it on, with the replay option or later with startReplay():
ZharpAPM.init({
apiKey: 'YOUR_INGEST_ONLY_KEY',
service: 'checkout-web',
replay: { sampleRate: 0.1 }, // record one session in ten
});ZharpAPM.init({ apiKey: 'YOUR_INGEST_ONLY_KEY', service: 'checkout-web' });
// Later — after consent, say. Resolves to whether this session is being recorded.
if (user.consentedToRecording) await ZharpAPM.startReplay({ sampleRate: 0.1 });startReplay() needs ZharpAPM.init() first; without it, it prints [Zharp] Session replay needs ZharpAPM.init() first. and resolves to false.
Loaded only when recording
The recorder and rrweb are much larger than the rest of the SDK, so they are loaded with a dynamic import(). Bundlers (Vite, webpack, esbuild, Rollup, Next.js) put them in a chunk of their own, fetched only when replay starts. An app that never turns replay on never downloads it, and neither does a session that is sampled out.
Sampling
sampleRate is decided once per page load, before anything is downloaded, and never revisited — a recording that stops halfway is no use. Calling startReplay() again on the same page doesn't roll again.
Privacy
Every input is masked by default. For anything genuinely sensitive use blockSelector: masked text is still recorded as a shape of the right length, while a blocked element is never recorded at all.
ZharpAPM.init({
apiKey: 'YOUR_INGEST_ONLY_KEY',
service: 'checkout-web',
replay: {
sampleRate: 0.1,
blockSelector: '.payment-iframe, .document-preview', // never recorded
maskTextSelector: '.customer-name', // recorded, text masked
beforeSend: (events) => true, // return false to drop a batch
},
});| Option | Default | What it does |
|---|---|---|
sampleRate | 1 | The share of sessions recorded, 0 to 1. Every session is recorded unless you set it lower. |
flushInterval | 10000 | How often recorded frames are sent, in milliseconds. |
maskAllInputs | true | Mask what is in every input, textarea and select. Turning it off sends what people type. |
maskAllText | false | Mask every piece of text on the page. Takes precedence over maskTextSelector. |
blockSelector | none | Elements left out entirely: their space is kept, their contents never captured. |
maskTextSelector | none | Elements whose text is masked. |
beforeSend | none | Called with each batch of events before it is sent; return false to drop it. |
Finding the sessions worth watching
Recordings, spans and logs from the same page load share a session id, and a recording lists the traces started during it (up to 50), so from a replay you can open the requests it made. A session is flagged when an uncaught error or unhandled rejection happens on the page, when @zharp/logs records an error or fatal log, or when you call markError().
ZharpAPM.markError(); // flag this session as one worth watching await ZharpAPM.stopReplay(); // stop recording and send what is left ZharpAPM.isReplaying; // whether this session is being recorded
Calling init() again with replay keeps a running recording going; calling it without replay stops it.
Application monitors from SDK traces
An application monitor measures the requests a service handled — its SERVER spans, and the CONSUMER spans of a named continueTrace. A Node app sends those. A browser-only app sends none, so the monitor measures every span the app sent instead: its outgoing fetch and XHR calls, third-party ones included. There, error rate counts failed calls (4xx and 5xx, and network or CORS failures) and throughput is calls made, not pages served.
Delivery
- Finished spans are sent every
flushIntervalto/agent/traces, and recordings to/agent/replay, with the key in anX-API-KEYheader andX-Zharp-SDK: js-apm/1.0.0. No request is larger than about 1 MB. - A span request that fails on the network or with a 5xx is tried once more, a second later; a 4xx is not retried. While the network is down, at most 1,000 finished spans are held, and the oldest go first.
- Recorded frames are sent every replay
flushInterval(10 seconds), or sooner once 500 events have built up. A batch that fails on the network or with a 5xx is resent unchanged on the next send, up to 3 attempts in all; a 4xx drops it. At most 30 batches wait while the network is down. - When a tab is hidden or closed, spans and frames still waiting are sent with
navigator.sendBeacon, in pieces under 60 KB, with the key in the address because a beacon can't carry a header. A refused beacon falls back to akeepalivefetch. - In Node the send timer doesn't keep the process alive; spans still waiting when the work runs out are sent on
beforeExit.
await ZharpAPM.flush(); // send finished spans (and recorded replay) now await ZharpAPM.destroy(); // unpatch everything, stop replay, send what is left ZharpAPM.isActive; // whether init() succeeded and spans are being sent
Logs and automatic error capture with @zharp/logs.
speedThe SDK, or an OpenTelemetry app through the collector.
monitor_heartAlert on response time, error rate and throughput.
descriptionEvery way to send logs, and how they are tagged.