BugBoardDocs

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