Hapi

BugWatch with Hapilink

@newinstance/bugwatch ships a Hapi plugin that opens a per-request scope, captures error (Boom) responses, and propagates W3C trace context. No extra packages, no changes to your handlers, no change to the response your users get.

Installlink

npm install @newinstance/bugwatch

The Hapi adapter lives in the same package under the @newinstance/bugwatch/hapi subpath. Requires @hapi/hapi >= 20. The package ships dual ESM and CommonJS builds.

Get a project keylink

  1. Sign in at www.newinstance.cloud.
  2. Open your project, then Settings → Data Source Name (DSN) keys, and create a key.
  3. Copy it. The format is <keyId>:<secret>, e.g. sk_live_abc123:your-secret.

Use the project's DSN key, not an organization-level API key: an org key is not bound to a single project and is rejected at ingest with HTTP 400. Keep it in an env var, one key per environment.

BUGWATCH_KEY=sk_live_abc123:your-secret-here
APP_VERSION=1.0.0

Init and register the pluginlink

import Hapi from "@hapi/hapi";
import { createClient } from "@newinstance/bugwatch";
import { createBugWatchHapiPlugin } from "@newinstance/bugwatch/hapi";

const server = Hapi.server({ port: 3000 });
const client = createClient({
	projectKey: process.env.BUGWATCH_KEY!,
	release: process.env.APP_VERSION,
	serviceName: "orders-api", // optional, attributes spans on the service map
});

await server.register(createBugWatchHapiPlugin(client));

Register it before your routes so the onRequest extension is in place for every request. BugWatch.init(...) plus BugWatch global methods works equally well; createClient is used here because the plugin takes a client instance.

What the plugin doeslink

It adds exactly two Hapi extensions:

  • onRequest opens a per-request scope carrying method, url (the request path), and the inbound traceparent header. This is the scope everything else in the request inherits.
  • onPreResponse inspects request.response. If it is a Boom error it calls captureException at level 50 (error) with tags method, route, and status (from response.output.statusCode), then triggers a flush. It returns h.continue, so the response your client receives is untouched.

Captured: anything that surfaces as a Boom response. That includes errors your handlers throw (Hapi wraps an uncaught throw into a Boom 500), explicit Boom.badRequest(...) / Boom.notFound(...) returns, and Hapi's own validation and routing errors. Note this means handled 4xx Boom responses are captured too, tagged with their status.

Not captured by the plugin: error-shaped responses that are not Boom, for example h.response({ error: "nope" }).code(500). Capture those yourself with captureException or captureLog inside the handler; the request scope is open, so they still carry the request's user, method, url, and trace id.

Both extensions swallow their own internal failures. A bug inside BugWatch can never break your request.

The per-request scopelink

The plugin opens an AsyncLocalStorage scope in onRequest. Consequences inside any handler, extension, or async work descended from a request:

  • A bare captureException / captureLog / logger call automatically inherits that request's user, method, url, and trace id. You do not pass a request object around.
  • setRequestUser, setRequestTag, and setRequestContext from @newinstance/bugwatch/node all work here.
  • Concurrent requests are isolated by Node's async scheduler; captures made outside any request (startup code, cron jobs) are unaffected and use the global scope.
import { setRequestTag, setRequestUser } from "@newinstance/bugwatch/node";

server.ext("onPostAuth", (request, h) => {
	if (request.auth.credentials) setRequestUser({ id: request.auth.credentials.id as string });
	return h.continue;
});

Hapi drives its own request lifecycle rather than nesting middleware around a next() call, so there is no callback to wrap. The adapter therefore binds the scope with enterContext(ctx) (als.enterWith) instead of runWithContext(ctx, fn). Prefer runWithContext in your own code wherever a callback boundary exists; enterContext exists for exactly this lifecycle shape.

The getUser optionlink

Pass getUser to attach the authenticated user with no extra extension. Hapi keeps it on request.auth.credentials:

await server.register(
	createBugWatchHapiPlugin(client, {
		getUser: (request) => request.auth?.credentials as { id?: string } | undefined,
	}),
);

It is resolved lazily at capture time, after Hapi's auth pipeline has run, so a user established well after onRequest fired is still picked up. An explicit setRequestUser wins over it for scope-inherited captures. If your extractor throws, the capture still goes out, just without a user.

Tracinglink

The onRequest extension reads the inbound traceparent header into the request scope, so every event and log in that request carries the caller's trace id and span id. If no traceparent arrives, the scope synthesizes a fresh trace id, so an origin request is still fully traceable end to end. Nothing to configure.

For spans on top of that: client.withSpan(name, fn, { kind }) times an operation, records a thrown exception on the span and re-throws; wrapFetch(client) returns a fetch that opens a client-kind span and injects traceparent into outbound calls so downstream services join your trace. See the Distributed tracing section of the JavaScript & TypeScript overview for span kinds, attributes, span links, and manual propagation.

Shutdownlink

The plugin flushes after each captured Boom error, and the client flushes on a timer (flushInterval, 5s by default). On graceful shutdown, flush what is still queued. Spans flush with events.

server.ext("onPostStop", async () => {
	await client.close(); // stops the background timer, then performs a final flush
});

process.on("SIGTERM", async () => {
	await server.stop({ timeout: 10_000 }); // triggers onPostStop above
	process.exit(0);
});

Use client.flush() if you want to force delivery but keep the client usable.

Complete examplelink

// src/server.ts
import Hapi from "@hapi/hapi";
import { createClient } from "@newinstance/bugwatch";
import { createBugWatchHapiPlugin } from "@newinstance/bugwatch/hapi";
import { 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,
	release: process.env.APP_VERSION,
	environment: process.env.NODE_ENV,
	serviceName: "transfers-api",
});

const server = Hapi.server({ port: 3000, host: "0.0.0.0" });

// 1. Register first: onRequest opens the per-request scope, onPreResponse captures Boom errors.
//    getUser is resolved lazily at capture time, after Hapi's auth pipeline has run.
await server.register(
	createBugWatchHapiPlugin(client, {
		getUser: (request) => request.auth?.credentials as { id?: string } | undefined,
	}),
);

// 2. Route. The request scope is already open here.
server.route({
	method: "POST",
	path: "/transfers",
	handler: async (request) => {
		const { transferId } = request.payload as { transferId: string };
		setRequestTag("transferId", transferId); // this request only

		await client.withSpan("transfer.process", async (span) => {
			span?.setAttr("transfer.id", transferId);
			await processTransfer(transferId); // a throw here becomes a Boom 500 and is captured
		});

		return { ok: true };
	},
});
// → captured event: level=50, user=<auth credentials>, trace id from the inbound traceparent
//   (or synthesized), tags={transferId, method, route, status, url}
// Concurrent requests never see each other's tags or user: each has its own ALS cell.

// 3. Flush on shutdown.
server.ext("onPostStop", async () => {
	await client.close();
});
process.on("SIGTERM", async () => {
	await server.stop({ timeout: 10_000 });
	process.exit(0);
});

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

await server.start();
console.log(`listening on ${server.info.uri}`);

Troubleshootinglink

Nothing is captured for a failing route. The response is probably not Boom. Check whether the handler returns h.response(...).code(500) rather than throwing or returning a Boom error. Only Boom responses are captured automatically; capture the rest explicitly.

setRequestUser / setRequestTag returns false. No request scope is open. Either the plugin was never registered, or the call runs outside the request's async tree (a module-level initializer, a detached timer created before the request). Confirm server.register is awaited before server.start().

Events do not appear in the dashboard. Set debug: true on the client and look for [bugwatch] flush and [bugwatch] transport send lines. Confirm BUGWATCH_KEY is defined, and that the process lives long enough for a flush (short-lived processes need await client.flush()).

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-level API key. Use the project's DSN key from Settings → DSN keys.

The user is missing on captured events. If you rely on getUser, confirm the route actually has an auth strategy and that request.auth.credentials is populated. If you rely on setRequestUser, confirm it returned true.