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.
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.