# 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

```bash
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

```ts
// 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,
});
```

```dotenv
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:

```ts
// 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](/docs/api-reference#deduplication-important-for-sdk-design)** applied in
practice, and it is the single most valuable habit in a server integration.

**Fastify:**

```ts
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:**

```ts
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:

```ts
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:

```ts
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:

```ts
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:

```ts
#!/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](/docs/sdks/javascript)** — the base guide and the full method surface.
- **[NestJS](/docs/frameworks/nestjs)** — the same server, with providers and an exception filter.
- **[Serverless & edge](/docs/frameworks/serverless)** — where flushing becomes mandatory.
- **[API Reference](/docs/api-reference)** — every config option and the HTTP contract.
