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.

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