Express
BugWatch for Expresslink
BugWatch captures errors, logs, and traces from your Express app and sends them to your BugWatch project. This page takes you from an empty project to a fully instrumented server.
Installlink
npm install @newinstance/bugwatchThat is the only package you need. The Express integration ships in the same package as the subpath export @newinstance/bugwatch/express, and the request scope helpers live in @newinstance/bugwatch/node.
Requirements: Node.js 20.19.0 or newer, Express 4 or newer. The package ships dual ESM and CommonJS builds, so both import and require work.
Get a project keylink
- Sign in at www.newinstance.cloud.
- Open your project, then go to BugWatch, your project, Settings, Data Source Name (DSN) keys.
- Create a key and copy it. It looks like
sk_live_abc123:your-secret-here(orsk_test_...).
Use the project's DSN key, not an organization-level API key: a key that is not bound to exactly one project is rejected at ingest with This API key is not a BugWatch project key.
Put it in an environment variable, never in code:
BUGWATCH_KEY=sk_live_abc123:your-secret-here
APP_VERSION=1.0.0Initialize once at startuplink
Call BugWatch.init before any other code runs, at the top of your entry file:
// src/server.ts
import { BugWatch } from "@newinstance/bugwatch";
import { installNodeErrorHandlers } from "@newinstance/bugwatch/node";
if (!process.env.BUGWATCH_KEY)
throw new Error("BUGWATCH_KEY env var is required");
BugWatch.init({
projectKey: process.env.BUGWATCH_KEY,
serviceName: "checkout-api", // attributes spans on the service map
release: process.env.APP_VERSION ?? "dev",
environment: process.env.NODE_ENV ?? "development",
});
// Catches uncaught exceptions (level 60) and unhandled promise rejections (level 50)
// process-wide. Idempotent; returns an uninstall function. getClient() returns
// undefined before init, hence the non-null assertion after BugWatch.init.
installNodeErrorHandlers(BugWatch.getClient()!);If you prefer an explicit client instead of the singleton (multi-tenant apps, isolated clients in tests), use createClient({ projectKey }). It exposes the same methods and is passed to the adapters the same way.
Mount the two middlewareslink
The Express integration is two middlewares, and the order is the whole design:
import express from "express";
import {
bugWatchExpressRequestHandler,
bugWatchExpressErrorHandler,
} from "@newinstance/bugwatch/express";
const app = express();
app.use(express.json());
// FIRST, before any route or auth middleware.
app.use(bugWatchExpressRequestHandler(BugWatch.getClient()!));
// ...your routes...
// LAST, after every route and every other error handler.
app.use(bugWatchExpressErrorHandler(BugWatch.getClient()!));bugWatchExpressRequestHandler opens an AsyncLocalStorage scope for the request and calls next() inside it. Everything downstream (your middlewares, your routes, their awaits, timers, and callbacks) runs inside that scope, so any capture made during the request inherits the request's method, URL, user, tags, and trace context. Mount it after your routes and those routes run outside the scope, so their captures arrive bare. It never touches the response.
bugWatchExpressErrorHandler is an Express error middleware (4 arguments), so Express only reaches it for errors, and only if it is registered after the routes that throw. It captures the error at level 50 with tags method, route (the request URL), and status (the response status code), triggers a flush, and then calls next(err). It never swallows the error and never changes the response, so your own error handler still runs and still decides what the client sees. If you have your own error middleware, put BugWatch's last so it sees everything your handler passes along.
What is captured automatically, and what is notlink
Captured with no extra code:
- Errors thrown in a route handler, or passed to
next(err), once the error handler is mounted last. - Request metadata on every capture inside the request:
methodandurl. (Theroutetag appears on error-handler captures, where it carries the request URL; the request handler runs before Express resolves a route, so it cannot record one.) - The inbound
traceparentheader, or a fresh trace ID when there is none. - Uncaught exceptions and unhandled rejections, if you called
installNodeErrorHandlers.
Not captured automatically:
- Errors you catch yourself and never rethrow. Call
BugWatch.captureException(err)for those. - Request or response bodies, headers, and query strings. Attach only what you want with per-request tags or context.
- Non-error responses. A handler that returns a 500 without throwing is invisible to the error middleware.
Attaching the userlink
There are two ways, and both are isolated per request, so concurrent requests can never overwrite each other's identity.
Option 1: the getUser option (recommended). Pass an extractor when registering the request handler. It is resolved lazily at capture time, which means it runs after your auth middleware has populated req.user, even though the handler itself is mounted before that middleware:
app.use(
bugWatchExpressRequestHandler(BugWatch.getClient()!, {
getUser: (req) => req.user, // e.g. { id, email } set by Passport or your JWT check
}),
);Return null or undefined for anonymous requests.
Option 2: setRequestUser, for derived identity. Use it when the user is not simply a field on req, or when you want to reshape it. Call it from your own middleware, mounted after the BugWatch request handler:
import { setRequestUser } from "@newinstance/bugwatch/node";
app.use((req, _res, next) => {
if (req.user) setRequestUser({ id: req.user.id, email: req.user.email });
next();
});An explicit setRequestUser call always wins over getUser. It returns true when it was applied and false when called outside a request scope, in which case it does nothing at all and never touches the process-global scope. Do not use the global BugWatch.setUser() per request: that is process-wide, and a concurrent request will overwrite it.
Per-request tags and contextlink
setRequestTag and setRequestContext behave exactly like setRequestUser: they write only the current request's scope and return false outside one.
import { setRequestTag, setRequestContext } from "@newinstance/bugwatch/node";
app.post("/orders", async (req, res) => {
setRequestTag("orderId", req.body.orderId);
setRequestContext("cart", { items: req.body.items.length });
await processOrder(req.body); // if this throws, the tags are on the event
res.json({ ok: true });
});Anything set this way is attached to every capture and log for the rest of that request and disappears when the request ends.
Tracinglink
The request handler reads the inbound traceparent header and attaches its trace ID and span ID to every capture and log in that request, so a failure in your service links to the caller's trace. If the request arrives without a traceparent, the SDK synthesizes a fresh trace ID for the request (with no parent span), so every error and log in the request still shares a single trace. Nothing to configure beyond mounting the handler.
Time an operation inside a handler with withSpan. It records a thrown exception on the span, marks the span as an error, and rethrows:
const cart = await BugWatch.withSpan(
"db.query load-cart",
async (span) => {
span?.setAttr("db.system", "postgresql");
return loadCart(cartId);
},
{ kind: 3 }, // OTel span kind: 3 is client
);Trace outbound HTTP with wrapFetch. It opens a client span per request, injects the traceparent header so the downstream service joins your trace, tags the response status, and marks 5xx and network failures as error spans:
import { wrapFetch } from "@newinstance/bugwatch";
const tracedFetch = wrapFetch(BugWatch.getClient()!);
const res = await tracedFetch("https://api.example.com/charge", { method: "POST" });See the JavaScript & TypeScript overview's Distributed tracing section for startSpan, manual propagation with traceHeaders(), queue and job span links, and the OpenTelemetry bridge.
Minting browser session tokens from Expresslink
Your project key must never reach the browser. If you also run BugWatch in a frontend, mount the built-in route helper on your Express app so the browser can fetch a short-lived, ingest-only token instead:
import { bugWatchBrowserSessionHandler } from "@newinstance/bugwatch/express";
app.get(
"/bugwatch/session",
bugWatchBrowserSessionHandler({ projectKey: process.env.BUGWATCH_KEY! }),
);The handler mints the session server side with your key and responds with the session JSON, or with a 502 and { error: "failed to mint BugWatch session" } if minting fails. In the browser, init with initBrowser({ sessionUrl: "/bugwatch/session" }) and no project key. The SDK fetches, caches, and refreshes the token for you.
Flush and close on shutdownlink
Events and spans are batched and exported on a timer, so a process that exits immediately can drop whatever is still queued. Call BugWatch.close() in your shutdown path for a final flush, and BugWatch.flush() any time you need immediate delivery:
process.on("SIGTERM", () => {
server.close(async () => {
await BugWatch.close();
process.exit(0);
});
});Complete examplelink
// src/server.ts
import express, {
type Request,
type Response,
type NextFunction,
} from "express";
import { BugWatch } from "@newinstance/bugwatch";
import {
bugWatchExpressRequestHandler,
bugWatchExpressErrorHandler,
bugWatchBrowserSessionHandler,
} from "@newinstance/bugwatch/express";
import {
installNodeErrorHandlers,
setRequestUser,
setRequestTag,
} from "@newinstance/bugwatch/node";
if (!process.env.BUGWATCH_KEY)
throw new Error("BUGWATCH_KEY env var is required");
BugWatch.init({
projectKey: process.env.BUGWATCH_KEY,
serviceName: "checkout-api",
release: process.env.APP_VERSION ?? "dev",
environment: process.env.NODE_ENV ?? "development",
});
installNodeErrorHandlers(BugWatch.getClient()!);
// Optional: the SDK's built-in structured logger (ships logs to BugWatch too).
const log = BugWatch.createLogger().child({ service: "api" });
const app = express();
app.use(express.json());
// 1. FIRST: opens the per-request scope.
app.use(bugWatchExpressRequestHandler(BugWatch.getClient()!));
// 2. Session mint endpoint for the frontend.
app.get(
"/bugwatch/session",
bugWatchBrowserSessionHandler({ projectKey: process.env.BUGWATCH_KEY }),
);
// 3. Auth: runs inside the scope, so this writes THIS request only.
app.use((req: Request, _res: Response, next: NextFunction) => {
const userId = req.headers["x-user-id"] as string | undefined;
if (userId) setRequestUser({ id: userId });
next();
});
// 4. Routes.
app.get("/health", (_req: Request, res: Response) => {
res.json({ ok: true });
});
app.post("/checkout", async (req: Request, res: Response) => {
const orderId = (req.body as { orderId: string }).orderId;
setRequestTag("orderId", orderId);
log.info("checkout initiated", { orderId });
await BugWatch.withSpan("checkout.process", async (span) => {
span?.setAttr("order.id", orderId);
await processOrder(req.body); // a throw here reaches the error handler
});
log.info("checkout complete", { orderId });
res.json({ ok: true });
});
// 5. LAST: captures, flushes, then calls next(err).
app.use(bugWatchExpressErrorHandler(BugWatch.getClient()!));
const PORT = Number(process.env.PORT ?? 3000);
const server = app.listen(PORT, () => {
log.info(`server listening on port ${String(PORT)}`);
});
process.on("SIGTERM", () => {
server.close(async () => {
await BugWatch.close();
process.exit(0);
});
});
async function processOrder(_body: unknown): Promise<void> {
/* ... */
}Verify it: start the app, then curl -X POST http://localhost:3000/checkout -H "Content-Type: application/json" -d '{"orderId":"o_1"}' and open the BugWatch dashboard, your project, Issues or Logs. Entries appear within a few seconds.
Troubleshootinglink
Nothing is captured when a route throws. The error handler is not last. Express only routes an error to a 4-argument error middleware registered after the throwing route, so bugWatchExpressErrorHandler must be the final app.use, below every route, router, and other error handler.
Async route errors are missing. On Express 4, a rejected promise in an async handler is not forwarded automatically. Either wrap the handler so the rejection reaches next(err), or catch and call BugWatch.captureException(err) yourself. An error caught with no rethrow and no capture is invisible to BugWatch.
Your own error middleware swallows the error. If a handler mounted before BugWatch's sends a response without calling next(err), the chain stops there and BugWatch never sees the error. Always call next(err) from intermediate error handlers.
Events arrive with no user or tags. The identity was set outside the request scope. Confirm bugWatchExpressRequestHandler is mounted before the middleware calling setRequestUser, setRequestTag, or setRequestContext. Those helpers return false when there is no active scope, which is a quick way to check.
Events never show up from a short script or a test. The batch timer never fired before the process exited. await BugWatch.flush() after the capture, and await BugWatch.close() before exiting. The same applies to serverless handlers: flush before returning.
ingest 401: Invalid or inactive API key, or 400: This API key is not a BugWatch project key. The key is revoked, from the wrong environment, or is not a project DSN key. Mint a fresh key under your project's Settings, DSN keys, and use the sk_live_... key in production. Set debug: true at init and the SDK prints the server's exact reason after the status code.