docs

NestJS

Import UnhandledModule in a NestJS 11 app on the Express platform to get route-named requests, signed-in users, request bodies and server errors in Unhandled.

@unhandled/nestjs is Unhandled for NestJS 11 on the Express platform. It starts @unhandled/node, which traces the HTTP server, Express, Nest's controllers, outgoing requests and databases. Its module adds the signed-in user to each request, records request and response bodies when capture is on, and reports server errors from Nest's exception layer. The resource carries unhandled.framework.name=nestjs.

The package re-exports everything from @unhandled/node, so captureException, setUser, record and the rest import from @unhandled/nestjs too. Its init is the Node SDK's init with the NestJS layer added.

Requirements

  • NestJS 11 with @nestjs/platform-express. The Fastify platform is not supported yet.
  • Node.js 24 or later.
  • A service DSN. See Services.

Install

Terminal
npm install @unhandled/nestjs

Start the SDK

Load the register module ahead of the compiled entry file:

Terminal
UNHANDLED_DSN=https://[email protected]/0b4f6c2e-8a1d-4c3e-9f7a-1234567890ab \
  node --import @unhandled/nestjs/register dist/main.js

@unhandled/nestjs/register hooks ES module loading and starts the SDK from UNHANDLED_DSN, UNHANDLED_ENV (or APP_ENV) and OTEL_SERVICE_NAME. It works for CommonJS builds too, which is what the Nest CLI emits by default. Prefer it over @unhandled/node/register, which leaves the framework off the resource.

To pass options, write your own register file and load it with --import:

ts
// instrument.ts
import { startWithEsmHook } from '@unhandled/node/hook'

await startWithEsmHook(async () => {
  const { init } = await import('@unhandled/nestjs')
  init({ serviceName: 'shop', http: { captureBodies: { enabled: true } } })
})

In a CommonJS build you can instead call init(options) from @unhandled/nestjs on the first line of main.ts, before @nestjs/core is imported. init takes every Node SDK option.

Import the module

ts
import { Module } from '@nestjs/common'
import { UnhandledModule } from '@unhandled/nestjs'
import { OrdersController } from './orders.controller.js'

@Module({
  imports: [
    UnhandledModule.forRoot({
      user: (host) => {
        const { user } = host.switchToHttp().getRequest()
        return user ? { id: user.id, email: user.email } : undefined
      },
    }),
  ],
  controllers: [OrdersController],
})
export class AppModule {}

UnhandledModule.forRoot(options?) registers two global providers:

  • UnhandledInterceptor as APP_INTERCEPTOR. It runs after your guards and handles routes, users and bodies.
  • UnhandledExceptionFilter as APP_FILTER. It extends BaseExceptionFilter, reports the error, then lets the base filter send Nest's usual response.

Routes

The interceptor sets http.route from the Express route that matched, global prefix included, and renames the span, for example GET /orders/:id. Requests groups by that route.

The Express and NestJS instrumentations in @unhandled/node stay on and add middleware and controller spans. The module creates no spans of its own. If you turn the Express instrumentation off to drop the middleware spans, the interceptor still names the route.

Users

The interceptor calls user(host) after your guards have run. Return { id, email?, name? } or undefined. The result lands on the server span as user.id, user.email and user.name. The filter calls the same function for error reports.

Request and response bodies

With http.captureBodies on, the interceptor records the parsed request body and the value your handler returned. Returned objects are stored as JSON. Strings, buffers and streams are not recorded, because Nest picks their content type only while it writes them. Redaction runs before anything is stored.

Errors

UnhandledExceptionFilter reports with mechanism nestjs.exception_filter and handled: false.

ErrorReported
Any error that is not an HttpExceptionYes
HttpException with a status of 500 or moreYes
NotFoundException, BadRequestException, validation pipe errors and every other 4xx HttpExceptionNo. They are responses you chose to send.
Matched by ignoreExceptions, ignoreStatuses or ignoreCodesNo
Errors from microservice and WebSocket handlersNo. The filter covers HTTP requests only.

Nest hands an error to the first global filter that catches it. If your app registers its own catch-all filter, that filter wins and nothing is reported. In that case, extend UnhandledExceptionFilter instead of BaseExceptionFilter and call super.catch(exception, host). You can also call reportException() from @unhandled/node/framework yourself.

Uncaught exceptions and unhandled rejections outside a request are reported by the process handlers.

Verify the setup

  1. Add a route that throws:

    ts
    import { Controller, Get } from '@nestjs/common'
    
    @Controller('debug')
    export class DebugController {
      @Get('boom')
      boom() {
        throw new Error('Refund provider is unavailable')
      }
    }
  2. Add DebugController to a module, start the app with the register module and request /debug/boom once.

  3. In Unhandled, open Traces. The request shows as GET /debug/boom.

  4. Open Exceptions. The error shows with mechanism nestjs.exception_filter and its stack trace.

Exports

ExportEffect
init(options?)Starts the SDK with the NestJS layer. Takes UnhandledNestOptions, the Node SDK options.
UnhandledModule.forRoot(options?)Registers the interceptor and the filter. Options are UnhandledModuleOptions, with user as a UserResolver.
UnhandledInterceptorThe interceptor, for apps that register providers by hand.
UnhandledExceptionFilterThe filter, also a base class for your own catch-all filter.
Everything from @unhandled/nodecaptureException, setUser, track, record, flush and the rest. See Exports.
@unhandled/nestjs/registerSide effect only. Starts the SDK from the environment.