Fastify
BugWatch with Fastifylink
Wire the BugWatch JavaScript SDK (@newinstance/bugwatch) into a Fastify app in two lines: create a client, register the plugin. You get automatic error capture, per-request identity isolation, and trace propagation.
Requirementslink
- Node.js >= 20.19.0
- Fastify >= 4
- The package ships dual ESM and CommonJS builds, so both
importandrequirework.
Installlink
npm install @newinstance/bugwatchNo extra plugin package. The Fastify adapter ships as the @newinstance/bugwatch/fastify subpath export.
Get a project keylink
Sign in at www.newinstance.cloud, open your project, then BugWatch, your project, Settings, Data Source Name (DSN) keys and create a key. It looks like sk_live_abc123:secret (or sk_test_...). Put the full value in BUGWATCH_KEY and read it at runtime. Use the project's DSN key, not an organization-level API key: org keys are rejected at ingest with This API key is not a BugWatch project key (HTTP 400).
Wire it uplink
import Fastify from "fastify";
import { createClient } from "@newinstance/bugwatch";
import { bugWatchFastify } from "@newinstance/bugwatch/fastify";
const client = createClient({
projectKey: process.env.BUGWATCH_KEY!,
serviceName: "checkout-api",
release: process.env.APP_VERSION,
environment: process.env.NODE_ENV,
});
const app = Fastify();
bugWatchFastify(app, client, { getUser: (req) => (req as any).user });bugWatchFastify(app, client, options?) takes exactly one option, getUser: (req: FastifyRequest) => BugWatchUser | null | undefined, where BugWatchUser is { id?, email?, username?, ip? }. Fastify's FastifyRequest has no user field by default, so either cast or declare it via module augmentation for your auth plugin.
Note bugWatchFastify(app, ...) is a plain function call on the instance, not a Fastify plugin: do not pass it to app.register(...), which would trap it in an encapsulated context.
Order: call bugWatchFastify on your root instance, before you register routes. It installs an onRequest hook, and Fastify runs onRequest hooks in registration order, so registering first means every later hook, preHandler, and handler runs inside the BugWatch request scope.
What it does, exactlylink
Two things, and nothing else:
- An
onRequesthook that opens a per-requestAsyncLocalStoragescope holdingmethod,url, the inboundtraceparentheader, and (when you passgetUser) a lazy user resolver. Every capture in that request's async tree inherits it. app.setErrorHandler(...)that captures any error reaching Fastify's error handler withlevel: 50and tags{ method, route, status }(routeisrequest.url,statusisreply.statusCode), triggers a flush, then callsreply.send(err)so Fastify produces its normal error response. Capture failures are swallowed internally: a monitor never crashes the host.
It does not install process-level handlers. Add those yourself for uncaught exceptions and unhandled rejections.
import { installNodeErrorHandlers } from "@newinstance/bugwatch/node";
installNodeErrorHandlers(client);Interaction with your own setErrorHandler (read this)link
Fastify allows one error handler per encapsulation context, and the last setErrorHandler call on a given instance wins. That has real consequences:
- If you call
app.setErrorHandler(...)on the root instance afterbugWatchFastify(app, client), your handler replaces BugWatch's and automatic error capture stops. - If you call it before, BugWatch's replaces yours and your error formatting is lost. BugWatch's handler ends with
reply.send(err), which yields Fastify's default error payload, not your custom shape. - A child plugin (an encapsulated
fastify.register(...)scope) with its ownsetErrorHandlerwins for its own routes, so errors in that subtree are not captured automatically.
If you need custom error responses, keep your handler and capture explicitly inside it. The onRequest scope is still open, so the user, tags, and trace context are attached for free:
bugWatchFastify(app, client, { getUser: (req) => (req as any).user });
// Overrides BugWatch's handler. Capture manually so nothing is lost.
app.setErrorHandler(async (err, request, reply) => {
client.captureException(err, {
level: 50,
tags: {
method: request.method,
route: request.url,
status: String(reply.statusCode),
},
});
await client.flush();
reply.status(err.statusCode ?? 500).send({ error: "internal_error" });
});Per-request identitylink
Two ways, both concurrency safe. Never call client.setUser() inside a request handler: that writes the process-global scope and concurrent requests will overwrite each other's user.
getUser (recommended). Resolved lazily at capture time, after auth has run, so req.user is already populated when an event is captured. One line, no extra hook.
setRequestUser (for derived identity). Call it from a preHandler once the request is authenticated. It writes only the current request's ALS cell and an explicit call wins over getUser. It returns false if called outside a request scope, in which case it does nothing.
import { setRequestUser } from "@newinstance/bugwatch/node";
app.addHook("preHandler", async (request) => {
const id = request.headers["x-user-id"] as string | undefined;
if (id) setRequestUser({ id });
});Per-request tags and contextlink
setRequestTag(key, value) and setRequestContext(key, data) from @newinstance/bugwatch/node behave the same way: current request only, false when outside a scope.
import { setRequestTag, setRequestContext } from "@newinstance/bugwatch/node";
app.post("/orders", async (request) => {
setRequestTag("orderId", (request.body as { orderId: string }).orderId);
setRequestContext("billing", { plan: "pro" });
return { ok: true };
});method and url are added as tags automatically by the onRequest hook.
Tracing: inbound traceparent and spanslink
The onRequest hook reads the inbound traceparent header and attaches its trace ID and span ID to every capture and log in that request. If the header is absent or unparseable, the SDK synthesizes a fresh trace ID for the request (with no parent span), so a request is always traceable end to end even when it originates the trace. Nothing to configure.
Inside a route, withSpan times an operation and links captures inside it, and wrapFetch opens a client span per outbound call and injects traceparent so the downstream service joins your trace:
import { wrapFetch } from "@newinstance/bugwatch";
const tracedFetch = wrapFetch(client);
app.get("/cart", async () => {
return 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 },
);
});Set serviceName at init so spans land on the right service in the service map. For startSpan, span links, manual traceHeaders() propagation, and the OpenTelemetry bridge, see the JavaScript & TypeScript overview's Distributed tracing section.
Shutdownlink
Events batch and flush on a timer, so a process that exits immediately can drop queued events. Flush on shutdown:
process.on("SIGTERM", async () => {
await app.close();
await client.close(); // final flush
process.exit(0);
});Use client.flush() when you want immediate delivery without shutting the client down (serverless handlers, tests, verification scripts).
If you also run BugWatch in a browser frontend, mint its session tokens from Fastify with 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/server.ts
import Fastify from "fastify";
import { createClient, wrapFetch } from "@newinstance/bugwatch";
import { bugWatchFastify } from "@newinstance/bugwatch/fastify";
import {
installNodeErrorHandlers,
setRequestTag,
setRequestUser,
} 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: "checkout-api",
release: process.env.APP_VERSION,
environment: process.env.NODE_ENV,
});
installNodeErrorHandlers(client);
const app = Fastify({ logger: true });
const tracedFetch = wrapFetch(client);
// 1. FIRST: opens the ALS scope in onRequest, captures errors in setErrorHandler.
bugWatchFastify(app, client, {
getUser: (req) => (req as { user?: { id: string; email: string } }).user,
});
// 2. Auth runs inside the scope opened above, so this request's scope only.
app.addHook("preHandler", async (request) => {
const userId = request.headers["x-user-id"] as string | undefined;
if (userId) {
setRequestUser({ id: userId });
setRequestTag("tier", (request.headers["x-tier"] as string) ?? "basic");
}
});
// 3. Routes.
app.get("/health", async () => ({ ok: true }));
app.post("/checkout", async (request) => {
const { orderId } = request.body as { orderId: string };
setRequestTag("orderId", orderId);
return client.withSpan(
"checkout.charge",
async (span) => {
span?.setAttr("order.id", orderId);
const res = await tracedFetch("https://api.example.com/charge", {
method: "POST",
body: JSON.stringify({ orderId }),
});
if (!res.ok) throw new Error(`charge failed: ${res.status}`);
return { ok: true };
},
{ kind: 3 },
);
});
// A throw here reaches BugWatch's setErrorHandler:
// user=<x-user-id>, tags={tier, orderId, method, url, route, status}, trace=inbound or synthesized.
// Concurrent requests each run their own ALS scope; neither can see the other's data.
process.on("SIGTERM", async () => {
await app.close();
await client.close();
process.exit(0);
});
await app.listen({ port: 3000, host: "0.0.0.0" });Verify: start the app, send a request that throws, then open the BugWatch dashboard at www.newinstance.cloud under your project's Issues tab. The error handler already calls flush, so it should appear within seconds.
Troubleshootinglink
Errors are not captured at all. Something replaced BugWatch's error handler. Check for a later app.setErrorHandler(...) on the root instance, or an encapsulated plugin with its own handler covering those routes. See the section above and capture manually inside your handler.
setRequestUser / setRequestTag returns false. The call happened outside an ALS request scope: either bugWatchFastify was not registered, or the code path is not descended from the request (a module-level initializer, a setInterval created at boot). The SDK does nothing and never touches the global scope.
User is missing on events. With getUser, confirm the field actually exists on FastifyRequest at capture time; req.user is not standard Fastify and depends on your auth plugin. With setRequestUser, confirm the preHandler is registered after bugWatchFastify.
ingest 401 ... Invalid or inactive API key. The key is unknown, revoked, or from the wrong environment. Mint a fresh DSN key and use sk_live_... for production, sk_test_... for 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.
Nothing appears in the dashboard. Init debug: true on the client and watch for [bugwatch] delivery-failure diagnostics. Confirm BUGWATCH_KEY is not undefined. On serverless, always await client.flush() before the function returns.
Traces do not join across services. Use wrapFetch for outbound calls, or forward client.traceHeaders() manually on non-fetch HTTP clients and queue payloads. An existing traceparent header on an outbound request is never overwritten.