Koa

BugWatch with Koalink

@newinstance/bugwatch ships a Koa middleware in the @newinstance/bugwatch/koa subpath. It opens a per-request AsyncLocalStorage scope, captures anything thrown downstream, and rethrows so Koa's own error handling is unchanged.

Installlink

npm install @newinstance/bugwatch

That is the only install. Every framework adapter lives in the same package as a subpath export. Requires Node.js >= 20.19.0 and Koa >= 2. The package ships dual ESM and CommonJS builds.

Get a project keylink

  1. Sign in at www.newinstance.cloud.
  2. Open your project, then go to BugWatch → your project → Settings → DSN keys and create a key.
  3. The key looks like sk_live_abc123:your-secret. Put it in BUGWATCH_KEY in your environment, never in client code.

Use the project's DSN key, not an organization-level API key. Org keys are not bound to one project and are rejected at ingest.

Init and mountlink

import Koa from "koa";
import { createClient } from "@newinstance/bugwatch";
import { bugWatchKoa } from "@newinstance/bugwatch/koa";

const app = new Koa();
const client = createClient({
	projectKey: process.env.BUGWATCH_KEY!,
	serviceName: "checkout-api",
	release: process.env.APP_VERSION,
	environment: process.env.NODE_ENV,
});

// FIRST in the chain.
app.use(bugWatchKoa(client, { getUser: (ctx) => ctx.state.user }));

You can also use the BugWatch global singleton (BugWatch.init({ ... })) and pass BugWatch where the examples pass client. Both expose the same methods.

Why mount it firstlink

Koa middleware is an onion. bugWatchKoa wraps everything registered after it in an AsyncLocalStorage scope and in a try/catch around await next(). Anything mounted before it runs outside both, so:

  • errors thrown by earlier middleware are never captured;
  • setRequestUser / setRequestTag / setRequestContext called from earlier middleware return false and do nothing.

Mount it as the very first app.use(...), before body parsers, auth, loggers, and routers.

What is captured automaticallylink

When a downstream middleware or route handler throws (or rejects), the middleware calls captureException with:

  • level: 50 (error)
  • tags.method = ctx.method
  • tags.route = ctx.url (the raw request URL, including the query string)
  • tags.status = String(ctx.status)

then triggers a flush and rethrows the original error. It never swallows the error and never writes a response, so Koa's ctx.onerror and any custom error middleware behave exactly as before. If the capture itself fails, the failure is discarded and the original error is still rethrown.

The request scope also adds method and url tags to every capture made inside the request, including bare client.captureException(...) / client.captureLog(...) calls in your own code.

Note on tags.status: it reads ctx.status at the moment the error surfaces, before Koa maps the error onto the response. If your handler threw before setting a status, that is Koa's default 404, not the 500 the client eventually receives.

What is not capturedlink

  • Errors you catch yourself. A try/catch in your route means nothing reaches the middleware. Call client.captureException(err) inside your catch block; it inherits the request scope.
  • Errors in middleware mounted before bugWatchKoa.
  • Non-error responses. Returning ctx.status = 500 without throwing captures nothing.
  • Process-level crashes. Add installNodeErrorHandlers(client) from @newinstance/bugwatch/node for uncaught exceptions (captured at level 60) and unhandled rejections (level 50). It is idempotent and returns an uninstall function.

Because ctx.throw(400, ...) throws, 4xx client errors are captured as errors too. Filter them with beforeSend at init if you only want 5xx.

Attaching the userlink

getUser (recommended). Pass it once when mounting. It is stored as a lazy resolver and called at capture time, not at request start, so it reads ctx.state.user after your auth middleware has populated it:

app.use(bugWatchKoa(client, { getUser: (ctx) => ctx.state.user }));

getUser may return null or undefined for anonymous requests.

setRequestUser. Use it when identity is derived rather than a plain field. It writes only the current request's ALS scope and returns false if called outside a request scope. An explicit setRequestUser wins over getUser.

import { setRequestUser } from "@newinstance/bugwatch/node";

app.use(async (ctx, next) => {
	const id = ctx.headers["x-user-id"];
	if (typeof id === "string") setRequestUser({ id });
	await next();
});

Never call client.setUser(...) inside a request handler. That writes the process-global scope shared by every concurrent request, so request B overwrites request A's user. Reserve it for single-identity processes such as workers and CLIs.

Per-request tags and contextlink

setRequestTag and setRequestContext from @newinstance/bugwatch/node behave the same way: current request only, isolated from concurrent requests, false when called outside a request scope.

import { setRequestTag, setRequestContext } from "@newinstance/bugwatch/node";

router.post("/payments", async (ctx) => {
	const { paymentId, cart } = ctx.request.body as { paymentId: string; cart: unknown[] };
	setRequestTag("paymentId", paymentId);
	setRequestContext("cart", { items: cart.length, currency: "GBP" });
	ctx.body = { ok: true };
});

Distributed tracinglink

The middleware reads the inbound traceparent header off ctx.headers and binds its trace ID and span ID to the request scope, so every capture and log in the request joins the caller's trace. If no traceparent arrives, a fresh trace ID is synthesized for the request, so a request is always traceable end to end even when it originates the trace.

Inside a route, withSpan times an operation and wrapFetch propagates the trace to downstream services:

import { wrapFetch } from "@newinstance/bugwatch";

const tracedFetch = wrapFetch(client);

router.get("/cart", async (ctx) => {
	ctx.body = await client.withSpan(
		"db.query load-cart",
		async (span) => {
			span?.setAttr("db.system", "postgresql");
			const res = await tracedFetch("https://api.example.com/cart");
			return res.json();
		},
		{ kind: 3 },
	);
});

Full details, including startSpan, span links, and OpenTelemetry correlation, are in the Distributed tracing section of the JavaScript & TypeScript overview.

Flush and closelink

Events and spans are queued and flushed on a background timer (5 seconds by default). The Koa middleware fires a flush after capturing an error but does not await it, so a process that exits immediately can lose the event.

  • await client.flush() forces immediate delivery without shutting the client down.
  • await client.close() performs a final flush and stops the client. Call it from your SIGTERM handler.

Minting browser session tokens from Koa: use the core mintBrowserSession({ projectKey }) helper inside a route of your own (the ready-made handler is Express-only); see the Browser page.

Complete examplelink

// src/app.ts
import Koa, { type Context, type Next } from "koa";
import Router from "@koa/router";
import bodyParser from "koa-bodyparser";
import { createClient } from "@newinstance/bugwatch";
import { bugWatchKoa } from "@newinstance/bugwatch/koa";
import {
	installNodeErrorHandlers,
	setRequestTag,
} from "@newinstance/bugwatch/node";

if (!process.env.BUGWATCH_KEY) throw new Error("BUGWATCH_KEY is not set");

const client = createClient({
	projectKey: process.env.BUGWATCH_KEY,
	serviceName: "payments-api",
	release: process.env.APP_VERSION,
	environment: process.env.NODE_ENV,
});
installNodeErrorHandlers(client);

const app = new Koa();
const router = new Router();

// 1. FIRST: opens the ALS request scope, reads traceparent, catches and rethrows.
//    getUser is lazy, so it sees ctx.state.user set by the auth middleware below.
app.use(bugWatchKoa(client, { getUser: (ctx) => ctx.state.user }));

app.use(bodyParser());

// 2. Auth runs inside the scope.
app.use(async (ctx: Context, next: Next) => {
	const id = ctx.headers["x-user-id"];
	if (typeof id === "string") ctx.state.user = { id };
	await next();
});

// 3. Thrown errors are captured and rethrown.
router.post("/payments", async (ctx: Context) => {
	const { paymentId } = ctx.request.body as { paymentId: string };
	setRequestTag("paymentId", paymentId);
	await processPayment(paymentId);
	ctx.body = { ok: true };
});

// 4. Handled errors need an explicit capture.
router.get("/health", async (ctx: Context) => {
	try {
		await pingDatabase();
		ctx.body = { ok: true };
	} catch (err) {
		client.captureException(err, { tags: { check: "database" } });
		ctx.status = 503;
		ctx.body = { ok: false };
	}
});

app.use(router.routes()).use(router.allowedMethods());
// Captured event: user={id}, tags={method, url, route, status, paymentId}, trace from traceparent.
// Concurrent requests each get their own ALS scope, so identity never leaks between them.

const server = app.listen(3000);

process.on("SIGTERM", () => {
	server.close(async () => {
		await client.close();
		process.exit(0);
	});
});

async function processPayment(_id: string) {
	/* ... */
}
async function pingDatabase() {
	/* ... */
}

Troubleshootinglink

No events at all. Set debug: true at init and watch for [bugwatch] delivery-failure diagnostics. Confirm BUGWATCH_KEY is defined and that the client is created before any capture.

ingest 401 ... Invalid or inactive API key. The key is unknown, revoked, or from the wrong environment. Mint a fresh DSN key and match sk_live_ to production, sk_test_ to test.

ingest 400 ... This API key is not a BugWatch project key. You used an organization API key. Use the project's DSN key instead.

Errors from some middleware are missing. That middleware is mounted before bugWatchKoa. Move bugWatchKoa to the top of the chain.

No user on events. Either getUser was not passed, or auth writes somewhere other than ctx.state.user (adjust the getUser callback), or you used client.setUser instead of setRequestUser.

setRequestUser / setRequestTag returns false. The call is outside the ALS request scope: bugWatchKoa is not mounted, is mounted too late, or the code path is not descended from the request (for example a module-level initializer).

tags.status says 404 for a 500 response. Expected. The tag is ctx.status at throw time, before Koa maps the error onto the response. Set ctx.status before throwing if you want a specific value.

Events lost on shutdown or in serverless. await client.flush() before returning, and await client.close() on SIGTERM.