# Hapi

`@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.

## Install

```sh
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 key

1. Sign in at [www.newinstance.cloud](https://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.

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

## Init and register the plugin

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

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 scope

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.

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

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

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

## Tracing

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.

## Shutdown

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.

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

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

## Troubleshooting

**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`.
