Node: Express, Fastify, Koa
A long-running Node server is the simple case: build the client once at module scope with a
secret key, report from your error handler, and let the automatic beforeExit hook handle
the rest.
1. Install
npm i bugboard
Node 20+. There is nothing framework-specific to install — createClient is the entire
surface on every runtime.
2. The shared client module
// src/lib/bugboard.ts import { createClient } from 'bugboard'; export default createClient({ keyId: process.env.BUGBOARD_KEY_ID, // bbk_… signingSecret: process.env.BUGBOARD_SIGNING_SECRET, // bb_sec_… environment: process.env.NODE_ENV, release: process.env.APP_VERSION, });
BUGBOARD_KEY_ID=bbk_xxxxxxxx BUGBOARD_SIGNING_SECRET=bb_sec_xxxxxxxx
Create one of these and import it everywhere. The client owns a queue, a drain timer and a shutdown hook — one per file gives you N of each.
3. Hook it into your framework
Express — the error middleware must have four parameters or Express won't treat it as one:
// src/app.ts import express from 'express'; import bugboard from './lib/bugboard'; const app = express(); // … routes … app.use((err, req, res, next) => { bugboard.criticalHigh( `Unhandled error: ${req.method} ${req.route?.path ?? 'unknown route'}`, err, ['express', 'unhandled'], ); res.status(500).json({ error: 'Internal Server Error' }); });
Note the title: req.route?.path is the route pattern (/users/:id), not the URL
(/users/91847). Using req.path would create a card per user id. This is the
dedup rule applied in
practice, and it is the single most valuable habit in a server integration.
Fastify:
fastify.setErrorHandler((error, request, reply) => { // Don't report 4xx — those are client mistakes, not your bugs. if ((error.statusCode ?? 500) >= 500) { bugboard.criticalHigh( `Unhandled error: ${request.method} ${request.routeOptions.url}`, error, ['fastify', 'unhandled'], ); } reply .status(error.statusCode ?? 500) .send({ error: 'Internal Server Error' }); });
Koa:
app.on('error', (err, ctx) => { bugboard.criticalHigh( `Unhandled error: ${ctx.method} ${ctx._matchedRoute ?? ctx.path}`, err, ['koa'], ); });
Process-level handlers
For what escapes the framework:
process.on('unhandledRejection', (reason) => { bugboard.criticalHigh('Unhandled promise rejection', reason, ['process']); }); process.on('uncaughtException', (err) => { bugboard.criticalHigh('Uncaught exception', err, ['process']); // The process is in an undefined state — flush, then let it die. void bugboard.flush().finally(() => process.exit(1)); });
The flush() in uncaughtException is required. beforeExit does not fire on this path, and
process.exit() would otherwise discard the report describing why you crashed — the single
most valuable report you will ever queue.
If you bundle your server
Running TypeScript through tsc, or plain JavaScript, needs nothing here — stacks already name
your files. But if you bundle for deployment (esbuild, tsup, Nitro, a zipped Lambda), stacks name
the bundle instead: dist/server.js:1:9271.
The fix costs one import, and uploads nothing — the .js.map files your build already writes sit
next to the bundle, and Node can read them itself:
// the first line of your entry point, above every other import import 'bugboard/preload';
Equivalently, without touching your code:
node --import bugboard/preload ./dist/server.js # or NODE_OPTIONS='--enable-source-maps' node ./dist/server.js
It has to run first: Node only maps modules compiled after support is enabled, so anything
imported above this line keeps its bundled coordinates. createClient() turns it on too, but by
then most of your app is already loaded.
This only works if your build emits maps — sourcemap: true for esbuild/tsup, "sourceMap": true
for tsc. Set sourceMaps: false in your client config to opt out entirely.
Delivery and flushing
Reports drain on a timer (flushIntervalMs, default 2 s) with bounded concurrency (default 3),
so a burst of errors never floods the API or self-inflicts a 429. The timer only runs while the
queue is non-empty, so an idle client schedules nothing.
For a normal long-running server this is all handled: beforeExit fires when the event loop
empties, and the process lives far longer than any 2-second drain interval.
But beforeExit does not fire on process.exit(), on an uncaught exception, or on
SIGINT/SIGTERM. That last one matters — a containerized server gets SIGTERM on every
deploy. To deliver in-flight reports across a rolling restart, flush in your shutdown handler:
import bugboard from './lib/bugboard'; async function shutdown(signal: string) { await server.close(); await bugboard.flush(); // deliver anything queued before we go process.exit(0); } process.on('SIGTERM', () => void shutdown('SIGTERM')); process.on('SIGINT', () => void shutdown('SIGINT'));
Long-running workers
Don't rely on the shutdown hook in a job worker — it fires when the process finally exits,
which could be days away, and a worker that reports faster than it exits will hit
maxQueueSize (default 100) and start dropping. Flush per unit of work:
for await (const job of queue) { try { await job.handle(); } catch (err) { bugboard.major(`Job failed: ${job.name}`, err, ['worker']); } finally { await bugboard.flush(); } }
CLI tools and scripts
For a short-lived script beforeExit is enough. The exception is an explicit process.exit(),
which skips it entirely and discards the queue:
#!/usr/bin/env node import bugboard from './lib/bugboard'; async function main() { try { await runMigration(); } catch (err) { bugboard.criticalHigh('Migration failed', err, ['cli', 'migration']); await bugboard.flush(); // required — process.exit() skips beforeExit process.exit(1); } } void main();
Next steps
- JavaScript SDK — the base guide and the full method surface.
- NestJS — the same server, with providers and an exception filter.
- Serverless & edge — where flushing becomes mandatory.
- API Reference — every config option and the HTTP contract.