NestJS Profiler
Tutorials

Domain event profiling

Profile the events dispatched through @nestjs/event-emitter and see what each @OnEvent handler actually did.

This tutorial shows how to profile the domain events an application dispatches with @nestjs/event-emitter. Events are the classic blind spot of a request trace: the controller answers, and whatever the listeners did happens somewhere off to the side. This collector closes that gap on three fronts — an Events panel on the emitting profile, a Discover / Events view listing your subscriptions, and one profile per @OnEvent execution.

Prerequisites

  • @eleven-labs/nest-profiler installed and configured
  • @nestjs/event-emitter registered with EventEmitterModule.forRoot()

Step 1 - Install the package

pnpm add -D @eleven-labs/nest-profiler-event-emitter

Step 2 - Register the module

EventEmitterModule.forRoot() stays in your own application module. Add EventEmitterCollectorModule to the dev-only profiling bundle — it resolves the app's EventEmitter2 and discovers every @OnEvent handler across the application:

profiling/profiling.module.ts
import { Module } from '@nestjs/common';
import { ProfilerModule } from '@eleven-labs/nest-profiler';
import { EventEmitterCollectorModule } from '@eleven-labs/nest-profiler-event-emitter';

@Module({
  imports: [ProfilerModule.forRoot({ isGlobal: true }), EventEmitterCollectorModule.forRoot()],
})
export class ProfilingModule {}

ProfilingModule is the dev-only bundle loaded by main-dev.ts — see Enabling and disabling the profiler. If the profiler is installed as a production dependency behind the runtime gate, wrap the same call in ConditionalModule.registerWhen(..., isProfilerEnabled).

Step 3 - Keep your emitters and listeners unchanged

Nothing about your code changes. Emit as usual:

product.service.ts
await this.emitter.emitAsync('product.created', { id: product.id, name: product.name });

…and subscribe as usual:

notification.listener.ts
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';

@Injectable()
export class NotificationListener {
  @OnEvent('product.created')
  async onProductCreated(payload: { id: number; name: string }): Promise<void> {
    // … send the notification …
  }
}

Step 4 - Inspect the emission

Trigger the flow and open /_profiler. On the profile that published the event, the Events panel lists each emission with:

  • the event name, and an async chip when it went through emitAsync
  • how many listeners were subscribed — 0 · no listener in amber, the cheapest way to catch a typo in an event name
  • the duration (the awaited handlers for emitAsync, the synchronous dispatch for emit)
  • the redacted payload, expandable per row

Step 5 - Inspect the handler

Pick Events in the sidebar (?view=event): each @OnEvent execution has its own profile, listed with its status and duration. Open one to find:

  • Event — the event, the Provider.method() that handled it, the received payload, and the error when it failed
  • Logs, Database, HTTP Client, … — everything the handler itself did, no longer buried in the publishing request

With @eleven-labs/nest-profiler-routes installed, the sidebar also gains a Discover / Events view listing every subscription as ON product.created → NotificationListener.onProductCreated, each expanding to the @OnEvent options it registered with.

Try it in the example app

The in-process emitter is the example API's default messaging adapter, so there is no broker to start — only the catalog's Postgres:

pnpm example:dev

Create a product, then open /_profiler:

curl -X POST http://localhost:3000/api/v1/products \
  -H 'content-type: application/json' \
  -d '{ "name": "Profiled product", "price": 42 }'

The POST profile shows product.created in its Events panel, and the Events view lists the NotificationListener.onProductCreated execution it triggered.

Configuration

EventEmitterCollectorModule.forRoot({
  capturePayload: true, // redacted payload per emission - disable if payloads hold PII
  maxPayloadLength: 2000, // bound the stringified payload
  ignoreEvents: ['heartbeat', /^internal\./], // never recorded
  profileListeners: true, // one profile per @OnEvent execution
  slowThreshold: 100, // an emission at or above this (ms) is tagged `slow`
});

Set profileListeners: false to keep only the per-request Events panel — lighter storage, at the cost of losing what the handlers did.

Driving options from ConfigService

Use forRootAsync() in the bundle to resolve the values from ConfigService (or any provider). It resolves option values only: behind the runtime gate, wrap it in ConditionalModule.registerWhen(..., isProfilerEnabled) as usual (see Enabling and disabling the profiler).

import { ConfigService } from '@nestjs/config';

EventEmitterCollectorModule.forRootAsync({
  inject: [ConfigService],
  useFactory: (config: ConfigService) => ({
    capturePayload: config.get<boolean>('profiler.capturePayload') ?? true,
    slowThreshold: config.get<number>('profiler.performance.slowThreshold'),
  }),
});

Performance tags

Emissions feed the core rule engine under their own event domain, so the usual tags apply without inheriting the query thresholds: slow past slowThreshold, n-plus-one when the same event name repeats nPlusOneThreshold times in one profile, and chatty past chattyThreshold emissions. See Performance tags.

How it works

The collector wraps the app's EventEmitter2 (emit and emitAsync) and reads the active profile synchronously at emit entry. For listener profiling it scans the DI container at bootstrap — providers and controllers — and wraps each @OnEvent method so the execution runs in its own CLS branch with its own profile. See How it works on the package page, and its Limitations for request-scoped subscribers.

Powered & maintained by

On this page