BugBoardDocs

Serverless & edge

Lambda, Vercel Functions, Cloudflare Workers, Deno Deploy — the rule is one line:

There is no process lifecycle hook here, so the queue will not drain itself. Await the flush before you return.

The runtime may freeze or kill your invocation the instant you hand back a response. A report that is still sitting in the queue at that moment is simply gone, and nothing tells you so.

AWS Lambda

import { createClient } from 'bugboard';

// Module scope: created once per container, reused across warm invocations.
const bugboard = createClient({
    keyId: process.env.BUGBOARD_KEY_ID, // bbk_…
    signingSecret: process.env.BUGBOARD_SIGNING_SECRET, // bb_sec_…
    environment: process.env.STAGE,
});

export const handler = async (event) => {
    try {
        return { statusCode: 200, body: JSON.stringify(await process(event)) };
    } catch (err) {
        bugboard.criticalHigh('Lambda handler failed', err, ['lambda']);
        return { statusCode: 500, body: '{"error":"Internal Server Error"}' };
    } finally {
        await bugboard.flush();
    }
};

Two things to copy from this shape:

  • Module scope is right. The client is cheap to construct, but there is no reason to rebuild it per invocation and a warm container reuses it. Because the drain timer only runs while the queue is non-empty, an idle client between invocations schedules nothing and won't hold the container awake.
  • finally, not the catch block. flush() on an empty queue is effectively free, so an unconditional finally covers both paths and you never have to track whether you reported anything.

Cloudflare Workers

Use ctx.waitUntil() so the response isn't delayed by the flush:

export default {
    async fetch(
        request: Request,
        env: Env,
        ctx: ExecutionContext,
    ): Promise<Response> {
        const bugboard = createClient({
            keyId: env.BUGBOARD_KEY_ID,
            signingSecret: env.BUGBOARD_SIGNING_SECRET,
            environment: env.ENVIRONMENT,
        });

        try {
            return await handleRequest(request);
        } catch (err) {
            bugboard.criticalHigh('Worker request failed', err, ['workers']);
            return new Response('Internal Server Error', { status: 500 });
        } finally {
            ctx.waitUntil(bugboard.flush()); // response goes out now; flush finishes after
        }
    },
};

In Workers the client must be created inside fetch — bindings like env aren't available at module scope. This is the one place the "build it once at module scope" rule doesn't apply.

Vercel Edge Functions

Same idea, with waitUntil from @vercel/functions:

import { waitUntil } from '@vercel/functions';

export const config = { runtime: 'edge' };

export default async function handler(request: Request) {
    try {
        return await handle(request);
    } catch (err) {
        bugboard.criticalHigh('Edge function failed', err, ['vercel', 'edge']);
        return new Response('Error', { status: 500 });
    } finally {
        waitUntil(bugboard.flush());
    }
}

waitUntil is strictly better than await where it exists: the response goes out immediately while the runtime keeps the invocation alive long enough for delivery to finish.

Deno Deploy

No waitUntil equivalent — plain await bugboard.flush() before returning.

Tuning for pay-per-millisecond runtimes

The SDK defaults suit a long-running server. On serverless you're paying wall-clock time for the flush, so cap the worst case:

createClient({
    keyId: process.env.BUGBOARD_KEY_ID,
    signingSecret: process.env.BUGBOARD_SIGNING_SECRET,
    timeoutMs: 2000,
    maxRetries: 1, // 3 retries × backoff can add seconds to every invocation
    flushIntervalMs: 500, // less left to drain when the explicit flush arrives
});

With the defaults, a fully unreachable endpoint costs up to ~4 attempts × 5 s plus backoff on the invocation that happens to be flushing. maxRetries: 1 trades a little delivery reliability for a bounded tail — usually the right trade when you're billed by the millisecond.

PHP on serverless

The same rule applies to PHP deployed to Lambda (Bref) or any freeze-after-response runtime: the SDK's register_shutdown_function hook may not run, or may run after the runtime has already frozen. Call flush() explicitly at the end of the handler.

Under Laravel on a serverless target, the terminating phase still runs per request, so reports go out normally — but if you ever see one arriving an invocation late, add an explicit BugBoard::flush(). See Laravel.

Why the SDK can't do this for you

Every automatic hook the SDK has — Node's beforeExit, the browser's pagehide, PHP's register_shutdown_function — depends on the runtime telling it that things are ending. Serverless runtimes don't: they freeze the whole world between invocations and may never resume it. There is no event to listen for, which is why the explicit flush is the one piece of ceremony these environments require.

Next steps