Node Workers
BugWatch in plain Node.js: workers, queue consumers, cron jobs, CLI scriptslink
This page covers using @newinstance/bugwatch in a long-lived Node process with no web framework. If you run Express, Fastify, Koa, Hono, Hapi, NestJS or Next.js, use the framework adapter instead: it opens the per-request scope for you.
Installlink
npm install @newinstance/bugwatchThat is the only install command. Everything below ships in the same package via subpath exports (@newinstance/bugwatch/node). The package ships dual ESM and CommonJS builds, so both import and require work.
Get your key from the dashboard at www.newinstance.cloud: open your project, then Settings → Data Source Name (DSN) keys, and create a key. It looks like sk_live_abc123:your-secret. Use the project's DSN key, not an organization API key: an org-level key is rejected at ingest with This API key is not a BugWatch project key (HTTP 400).
BUGWATCH_KEY=sk_live_abc123:your-secret-here
APP_VERSION=1.0.0Initlink
Call this once, at the very top of your entry file, before anything else runs.
// src/worker.ts
import { BugWatch } from "@newinstance/bugwatch";
BugWatch.init({
projectKey: process.env.BUGWATCH_KEY!,
serviceName: "orders-worker", // attributes spans to this node on the service map
release: process.env.APP_VERSION,
environment: process.env.NODE_ENV,
});BugWatch.init is a singleton: a second call returns the existing client. For multi-tenant processes or isolated tests use createClient(options) instead, which returns the same API as BugWatch.* and is what the node helpers accept.
Process-level error handlerslink
import { createClient } from "@newinstance/bugwatch";
import { installNodeErrorHandlers } from "@newinstance/bugwatch/node";
const client = createClient({ projectKey: process.env.BUGWATCH_KEY! });
const uninstall = installNodeErrorHandlers(client);What it does:
uncaughtExceptionis captured at level 60 (fatal),unhandledRejectionat level 50 (error). Both trigger an immediateflush().- Errors thrown inside the handlers are swallowed. A monitor must never crash the host from its own handler.
- It is idempotent: a second install is a no-op and returns the first uninstall function.
- It returns an uninstall function that removes both
processlisteners. Useful in tests and in embedded CLIs that hand control back to a host process. - Each listener is registered only if the matching config flag (
captureUnhandledErrors,captureUnhandledRejections) is on.
This is a safety net, not job instrumentation. An uncaughtException in a worker usually means the process is already unsound, so keep your per-job try/catch too.
Why a long-lived worker must isolate each unit of worklink
A worker process runs for days and handles thousands of jobs on one event loop, often several concurrently. If you attach identity to the process, whichever job wrote last wins, and job A's exception gets attributed to job B's user.
// WRONG in a worker that processes jobs concurrently:
for await (const job of queue) {
client.setUser({ id: job.userId }); // process-wide setter
void handle(job); // not awaited: the next iteration overwrites the user
}client.setUser / BugWatch.setUser write the global root scope, shared by every job in flight. Reserve them for a process with exactly one identity for its whole life, such as a single-tenant cron script.
The fix is AsyncLocalStorage. installAsyncScope(client) registers the ALS store as the client's async-scope provider; runWithContext(ctx, fn) opens a fresh scope bound to that call tree. Every await, callback and timer inside inherits the same cell, and a sibling job's tree gets its own. When the SDK builds an event it merges the root scope with the active ALS scope, and the job scope wins on overlapping fields.
import {
installAsyncScope,
runWithContext,
setRequestUser,
setRequestTag,
setRequestContext,
} from "@newinstance/bugwatch/node";
installAsyncScope(client); // once, at startup
await runWithContext(
{ requestId: job.id, route: "job:send-invoice", tags: { queue: "emails" } },
async () => {
setRequestUser({ id: job.userId }); // this job only
setRequestTag("attempt", job.attempt);
setRequestContext("job", { payloadBytes: job.raw.length });
await handle(job); // bare captureException in here inherits all of the above
},
);RequestContext accepts requestId, route, method, url, tags, user, userResolver and traceparent. The first four are promoted to tags automatically, so a job shows up with requestId and route labels without extra work.
The three setters return boolean: false means you called them outside any scope (not inside runWithContext), in which case they do nothing and never touch the global scope. If you see false, the call is not descended from your runWithContext callback.
Use enterContext(ctx) (which calls als.enterWith) only when you cannot nest your work in a callback, for example a driver that hands you a job through an event emitter. It binds the scope for the remainder of the current async tree, so it is easier to leak. Prefer runWithContext.
Per-job trace identity and span linkslink
runWithContext sets trace context for the job: if you pass traceparent, it parses it and continues the producer's trace; if you do not, it generates a fresh trace ID so every event from the job still shares one trace.
Wrap the actual work in withSpan so the job appears on the waterfall, gets timed, and records a thrown exception with its stack before rethrowing. Because installAsyncScope is on, withSpan writes the span's trace context into the job's ALS scope, not the global one.
Most consumers want their own trace plus a link back to the producing message, which is what draws the async producer-to-consumer edge on the service map:
await BugWatch.withSpan(
"emails process",
async (span) => {
span?.setAttr("messaging.system", "redis-streams");
await handle(job);
},
{
kind: 5, // 1 internal, 2 server, 3 client, 4 producer, 5 consumer
links: [
{
traceId: job.traceId,
spanId: job.spanId,
attrs: { "messaging.message.id": job.id },
},
],
},
);Links are validated: only the first 10 entries are considered, and any of those with a malformed traceId or spanId is dropped silently.
On the producer side, put the trace context in the payload, since a queue message has no HTTP headers:
BugWatch.withSpan(
"emails publish",
(span) => queue.publish({ ...body, traceparent: span?.traceparent() }),
{ kind: 4 },
);
// Outside a span, BugWatch.traceHeaders() returns { traceparent } only when BOTH a trace id
// and a span id are active (i.e. inside withSpan or a propagated trace); otherwise {}.On the consumer side, either pass the raw value straight into runWithContext({ traceparent }), or parse it yourself:
import { parseTraceparent } from "@newinstance/bugwatch";
const ctx = parseTraceparent(job.traceparent); // { traceId, spanId } or nullSame trace (pass traceparent) or new trace plus link (pass links) is a real choice: continue the trace for a synchronous-feeling handoff, link for a decoupled queue where the producer does not wait.
Outbound HTTPlink
wrapFetch returns a fetch that opens a client-kind span per request, injects traceparent so the callee joins your trace, tags the response status and marks 5xx and network failures as error spans. An existing traceparent header is never overwritten.
import { wrapFetch } from "@newinstance/bugwatch";
const tracedFetch = wrapFetch(client, { captureErrors: true });
await tracedFetch("https://api.example.com/invoices", { method: "POST" });Flush cadence and shutdownlink
Events and spans buffer in memory and drain on a timer (flushInterval, default 5000 ms; queue caps at maxQueueSize, default 1000, oldest dropped). A worker loop runs long enough for that timer to work, so per-job flushing is normally wasted network calls. Flush explicitly at points where the process might stop soon: end of a cron run, end of a CLI command, before an intentional process.exit.
for await (const job of queue) {
await runWithContext({ requestId: job.id }, () => handle(job));
// The 5s timer covers a steady loop. Flush here only if jobs are rare and long.
}BugWatch.close() flushes events and spans, closes the span exporter and detaches the singleton. Wire it to your signals:
for (const sig of ["SIGTERM", "SIGINT"] as const) {
process.on(sig, () => {
void (async () => {
running = false; // let the loop finish the job it is on
await BugWatch.close();
process.exit(0);
})();
});
}Complete example: queue consumerlink
// src/worker.ts
import { BugWatch, parseTraceparent, wrapFetch } from "@newinstance/bugwatch";
import {
installNodeErrorHandlers,
installAsyncScope,
runWithContext,
setRequestUser,
} from "@newinstance/bugwatch/node";
if (!process.env.BUGWATCH_KEY) throw new Error("BUGWATCH_KEY is not set");
const client = BugWatch.init({
projectKey: process.env.BUGWATCH_KEY,
serviceName: "emails-worker",
release: process.env.APP_VERSION,
environment: process.env.NODE_ENV,
});
installNodeErrorHandlers(client); // fatal=60 uncaught, error=50 rejections
installAsyncScope(client); // ALS scopes for runWithContext
const tracedFetch = wrapFetch(client);
let running = true;
interface Job {
id: string;
userId: string;
attempt: number;
traceparent?: string;
to: string;
}
async function handleJob(job: Job): Promise<void> {
const producer = parseTraceparent(job.traceparent);
await BugWatch.withSpan(
"emails process",
async (span) => {
span?.setAttr("messaging.system", "redis-streams");
span?.setAttr("messaging.message.id", job.id);
const res = await tracedFetch("https://api.example.com/send", {
method: "POST",
body: JSON.stringify({ to: job.to }),
});
if (!res.ok) throw new Error(`send failed: ${res.status}`);
},
{
kind: 5,
links: producer
? [
{
traceId: producer.traceId,
spanId: producer.spanId,
attrs: { "messaging.message.id": job.id },
},
]
: [],
},
);
}
async function main(): Promise<void> {
while (running) {
const job = await queue.next<Job>();
if (!job) continue;
// One fresh scope per job: no identity or tag leaks into the next iteration.
await runWithContext(
{
requestId: job.id,
route: "job:emails.send",
tags: { queue: "emails", attempt: job.attempt },
},
async () => {
setRequestUser({ id: job.userId });
try {
await handleJob(job);
await queue.ack(job.id);
} catch (err) {
// Bare capture: user, tags, requestId and trace come from the scope.
BugWatch.captureException(err);
await queue.retry(job.id);
}
},
);
}
await BugWatch.close();
}
for (const sig of ["SIGTERM", "SIGINT"] as const) {
process.on(sig, () => {
running = false;
});
}
await main();For a cron job or CLI script, drop the loop and the signal handlers, wrap the whole run in one runWithContext, and end with await BugWatch.close().
Troubleshootinglink
Events never arrive from a short script. The process exited before the 5 s flush timer fired, or before an in-flight send finished. Always await BugWatch.flush() (or close()) as the last statement, and never process.exit() before that promise resolves. Turn on debug: true and watch for [bugwatch] delivery-failure diagnostics.
Events stop arriving under a hot loop. The queue caps at maxQueueSize (default 1000) and drops the oldest on overflow. Either raise it, lower flushInterval, or flush at a natural checkpoint.
setRequestUser returns false. The call is not descended from a runWithContext callback (a module-level initializer, or work handed off through an emitter registered outside the scope). Nothing is written, and the global scope is untouched. Separately: if you forget installAsyncScope, the setters still return true inside runWithContext but the client never merges the scope into events, so identity silently goes missing; always call installAsyncScope(client) once at startup.
Job A's events carry job B's user. You used client.setUser / BugWatch.setUser per job instead of runWithContext plus setRequestUser, or you used enterContext in a loop where the scope outlives the iteration. Wrap the work in runWithContext.
Fire-and-forget work keeps a scope alive. void sendReceipt(job) inside a job callback still runs in that job's ALS context until its async tree resolves. That is usually what you want, but a long-lived background task keeps carrying the job's identity, so give it its own runWithContext.
No spans at all. Spans are server-side only (projectKey mode). Check serviceName is set, and note flushInterval: 0 disables the timer entirely (manual flush only).