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/bugwatchThe 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
- Sign in at www.newinstance.cloud.
- Open your project, then Settings → Data Source Name (DSN) keys, and create a key.
- 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.0Init 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:
onRequestopens a per-request scope carryingmethod,url(the request path), and the inboundtraceparentheader. This is the scope everything else in the request inherits.onPreResponseinspectsrequest.response. If it is a Boom error it callscaptureExceptionat level 50 (error) with tagsmethod,route, andstatus(fromresponse.output.statusCode), then triggers a flush. It returnsh.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, andsetRequestContextfrom@newinstance/bugwatch/nodeall 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.