NestJS

NestJSlink

Server-side error and log capture for NestJS with @newinstance/bugwatch. Two pieces do the work: a middleware that opens a per-request scope, and a global exception filter that captures whatever your handlers throw.

Requirementslink

  • Node.js >= 20.19.0
  • @nestjs/common >= 9
  • The package ships dual ESM and CommonJS builds, so it works in both ESM and classic CommonJS Nest projects.
  • Standard Nest decorator settings in tsconfig.json (experimentalDecorators, emitDecoratorMetadata). The nest subpath imports reflect-metadata itself, so you do not need an extra import for the adapter.

Installlink

npm install @newinstance/bugwatch

No extra plugin package. Framework integrations ship as subpath exports of the same package.

Get a project DSN key from the BugWatch dashboard at www.newinstance.cloud under BugWatch, your project, Settings, Data Source Name (DSN) keys. It looks like sk_live_abcdef1234:secret. Keep it in an environment variable, never in client code.

Create the client oncelink

Both the middleware and the filter need the same client instance, so create it in its own module-level file and import it from everywhere.

// src/bugwatch.ts
import { createClient } from "@newinstance/bugwatch";
import { installNodeErrorHandlers } from "@newinstance/bugwatch/node";

export const bugwatch = createClient({
	projectKey: process.env.BUGWATCH_KEY!,
	serviceName: "orders-api",
	release: process.env.GIT_SHA,
	environment: process.env.NODE_ENV,
});

// Catches uncaughtException / unhandledRejection outside the request lifecycle.
installNodeErrorHandlers(bugwatch);

Wire both pieceslink

1. bugWatchNestMiddleware in AppModule

Applied for all routes, it opens an AsyncLocalStorage (ALS) scope for the rest of the request. That scope carries method, url (from originalUrl or url), the inbound traceparent, and an optional lazy user resolver. Because Nest runs middleware before guards, interceptors, pipes, and handlers, everything downstream sits inside the scope.

import { Module, type MiddlewareConsumer, type NestModule } from "@nestjs/common";
import { bugWatchNestMiddleware } from "@newinstance/bugwatch/nest";
import { bugwatch } from "./bugwatch.js";

@Module({})
export class AppModule implements NestModule {
	configure(consumer: MiddlewareConsumer) {
		consumer
			.apply(bugWatchNestMiddleware(bugwatch, { getUser: (req) => (req as { user?: { id: string } }).user }))
			.forRoutes("*");
	}
}

getUser is resolved lazily at capture time, not when the request opens, so a guard that populates req.user after the middleware has already run is still picked up.

2. BugWatchExceptionFilter as a global filter

It is a catch-all (@Catch()) filter. On every unhandled exception it captures the error at level 50 with tags method, route, and status (read from exception.getStatus(), else the response statusCode, else 500), triggers a flush, then rethrows the exception so Nest's own exception layer still formats the response. It never swallows an error and never crashes the host: its capture path is wrapped in a try/catch that ignores its own failures.

Register it either way, not both.

// Option A: useGlobalFilters in main.ts
app.useGlobalFilters(new BugWatchExceptionFilter(bugwatch));

// Option B: APP_FILTER provider (keeps registration inside the module graph)
import { APP_FILTER } from "@nestjs/core";
import { createBugWatchNestFilter } from "@newinstance/bugwatch/nest";

@Module({
	providers: [{ provide: APP_FILTER, useFactory: () => createBugWatchNestFilter(bugwatch) }],
})
export class AppModule {}

createBugWatchNestFilter(client) is just a factory for new BugWatchExceptionFilter(client).

Note: on Nest 11 the catch-all route string is {*splat} rather than "*"; use whichever matches your Nest major.

Per-request user, tags, and context from serviceslink

Inside the scope opened by the middleware, use the request-scoped helpers from @newinstance/bugwatch/node. They write only the current request's ALS cell, so concurrent requests can never overwrite each other's identity.

import { Injectable } from "@nestjs/common";
import { setRequestUser, setRequestTag, setRequestContext } from "@newinstance/bugwatch/node";

@Injectable()
export class OrdersService {
	async place(orderId: string, userId: string) {
		setRequestUser({ id: userId }); // an explicit call wins over getUser
		setRequestTag("orderId", orderId);
		setRequestContext("cart", { items: 3 });
		await this.charge(orderId); // a throw here is captured with user + tags attached
	}
}

These work in services, controllers, guards, and interceptors because ALS propagates through the async call tree. Each returns true when applied and false when called outside a request scope (for example a top-level module initializer, or when the middleware was not registered). When it returns false the SDK does nothing and never touches the process-global scope.

Never call client.setUser() inside a request handler. That writes the process-global scope shared by every concurrent request and produces misattributed events under load. Use getUser or setRequestUser.

Distributed tracinglink

The middleware reads the inbound traceparent header and binds its trace ID and span ID to the request scope, so every capture and log in that request joins the caller's trace. If no traceparent arrives, the SDK synthesizes a fresh trace ID for the request, so a request is always traceable end to end even when it originates the trace.

Add spans and traced outbound calls from any service:

import { Injectable } from "@nestjs/common";
import { wrapFetch } from "@newinstance/bugwatch";
import { bugwatch } from "./bugwatch.js";

const tracedFetch = wrapFetch(bugwatch);

@Injectable()
export class PaymentsService {
	async charge(orderId: string) {
		return bugwatch.withSpan(
			"db.query load-order",
			async (span) => {
				span.setAttr("db.system", "postgresql");
				// injects traceparent so the downstream service joins this trace
				await tracedFetch("https://payments.example.com/charge", { method: "POST" });
				return orderId;
			},
			{ kind: 3 }, // OTel span kind: 3 = client
		);
	}
}

See the Distributed tracing section of the JavaScript & TypeScript overview for startSpan, span links, traceHeaders(), and the OpenTelemetry bridge.

Flush on shutdownlink

Events are batched and flushed on a timer (flushInterval, 5000 ms by default), so a process that exits immediately can drop queued events. Enable Nest's shutdown hooks and close the client from a lifecycle hook.

import { Injectable, type OnApplicationShutdown } from "@nestjs/common";
import { bugwatch } from "./bugwatch.js";

@Injectable()
export class BugWatchShutdown implements OnApplicationShutdown {
	async onApplicationShutdown() {
		await bugwatch.close(); // final flush of events and spans
	}
}

app.enableShutdownHooks() is what makes Nest call this on SIGTERM / SIGINT.

Complete examplelink

// src/main.ts
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module.js";

async function bootstrap() {
	const app = await NestFactory.create(AppModule);
	app.enableShutdownHooks(); // required for OnApplicationShutdown to fire
	await app.listen(3000);
}
void bootstrap();
// src/app.module.ts
import {
	Module,
	Injectable,
	type MiddlewareConsumer,
	type NestModule,
	type OnApplicationShutdown,
} from "@nestjs/common";
import { APP_FILTER } from "@nestjs/core";
import { bugWatchNestMiddleware, createBugWatchNestFilter } from "@newinstance/bugwatch/nest";
import { bugwatch } from "./bugwatch.js";
import { OrdersController } from "./orders.controller.js";

@Injectable()
class BugWatchShutdown implements OnApplicationShutdown {
	async onApplicationShutdown() {
		await bugwatch.close();
	}
}

@Module({
	controllers: [OrdersController],
	providers: [
		BugWatchShutdown,
		{ provide: APP_FILTER, useFactory: () => createBugWatchNestFilter(bugwatch) },
	],
})
export class AppModule implements NestModule {
	configure(consumer: MiddlewareConsumer) {
		consumer
			.apply(bugWatchNestMiddleware(bugwatch, { getUser: (req) => (req as { user?: { id: string } }).user }))
			.forRoutes("*");
	}
}
// src/orders.controller.ts
import { Controller, Post, Body } from "@nestjs/common";
import { setRequestTag } from "@newinstance/bugwatch/node";

@Controller("orders")
export class OrdersController {
	@Post()
	async create(@Body() body: { orderId: string }) {
		setRequestTag("orderId", body.orderId);
		throw new Error("payment gateway timeout");
		// captured with user, orderId, method, route, status, and the request's trace id,
		// then rethrown so Nest returns its usual 500 response body
	}
}

Troubleshootinglink

  • setRequestUser / setRequestTag returns false. The call is outside an ALS request scope. Usually bugWatchNestMiddleware was not registered, was not applied with forRoutes("*"), or the code path is not descended from the request (a module initializer, a timer created at boot).
  • Errors captured without user or tags. The filter is registered but the middleware is not. The filter alone still reports method, route, and status; identity and per-request tags come from the middleware's scope.
  • Filter order. BugWatchExceptionFilter is @Catch() with no argument, so a narrower filter (for example @Catch(HttpException)) that Nest selects for a given exception type handles it instead and BugWatch never sees it. Put BugWatch's capture inside that filter, or let it delegate. Registering the filter through both useGlobalFilters and APP_FILTER double-reports every exception.
  • Nothing in the dashboard. Init with debug: true and watch for [bugwatch] delivery-failure diagnostics. Verify BUGWATCH_KEY is defined at boot.
  • This API key is not a BugWatch project key (HTTP 400). You used an organization-level API key. Ingest requires a per-project DSN key.
  • Events lost on redeploy. app.enableShutdownHooks() is missing, so onApplicationShutdown never runs and the queue is never drained.