NestJS Profiler
Tutorials

Build a custom collector

Create your own profiler panel with custom data and an EJS template using the @ProfilerCollector() decorator.

This tutorial shows how to build a custom collector that adds a new panel to the profiler UI. As an example, we'll track all EventEmitter2 events emitted during a request.

What we'll build

A collector that:

  • Captures every event emitted via EventEmitter2 during a request
  • Shows them in a custom panel with event names, listener counts, and timestamps
  • Displays a badge with the event count in the toolbar

Step 1 - Create the collector class

event.collector.ts
import * as path from 'path';
import { Injectable } from '@nestjs/common';
import {
  ProfilerCollector,
  IProfilerCollector,
  Profile,
  TracerService,
} from '@eleven-labs/nest-profiler';

const EVENTS_ICON = `<svg viewBox="0 0 16 16" fill="currentColor">
  <path d="M8 1l2 4h4l-3 3 1 4L8 10l-4 2 1-4-3-3h4z"/>
</svg>`;

export interface EventEntry {
  eventName: string;
  listenerCount: number;
  timestamp: number;
}

export const EVENTS_KEY = '__events';

@Injectable()
@ProfilerCollector({
  name: 'events',
  label: 'Events',
  icon: EVENTS_ICON,
  priority: 60,
})
export class EventCollector implements IProfilerCollector {
  readonly name = 'events';
  readonly label = 'Events';
  readonly icon = EVENTS_ICON;
  readonly priority = 60;

  getBadgeValue(profile: Profile): string | null {
    const events = profile.collectors[EVENTS_KEY] as EventEntry[] | undefined;
    return events?.length ? String(events.length) : null;
  }

  getTemplatePath(): string {
    return path.join(__dirname, 'templates', 'events-panel.ejs');
  }

  collect(profile: Profile): EventEntry[] {
    const events = (profile.collectors[EVENTS_KEY] as EventEntry[] | undefined) ?? [];
    delete profile.collectors[EVENTS_KEY];
    return events;
  }
}

Step 2 - Emit events into the profile

Create a service that patches EventEmitter2.emit to record events into the CLS profile:

event-patch.service.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { ClsService } from 'nestjs-cls';
import { readProfile } from '@eleven-labs/nest-profiler';
import type { EventEntry } from './event.collector';
import { EVENTS_KEY } from './event.collector';

@Injectable()
export class EventPatchService implements OnModuleInit {
  constructor(
    private readonly emitter: EventEmitter2,
    private readonly cls: ClsService,
  ) {}

  onModuleInit(): void {
    const cls = this.cls;
    const originalEmit = this.emitter.emit.bind(this.emitter);

    this.emitter.emit = function (event: string | symbol, ...args: unknown[]): boolean {
      const result = originalEmit(event, ...args);
      // `readProfile` returns undefined outside a profiled execution — no key literal to get
      // wrong, and no try/catch: "nothing is being profiled" is an answer, not an error.
      const profile = readProfile(cls);
      if (profile) {
        const entry: EventEntry = {
          eventName: String(event),
          listenerCount: emitter.listenerCount(event as string),
          timestamp: Date.now(),
        };
        const existing = profile.collectors[EVENTS_KEY] as EventEntry[] | undefined;
        const events: EventEntry[] = existing ?? [];
        events.push(entry);
        if (!existing) profile.collectors[EVENTS_KEY] = events;
      }
      return result;
    };
  }
}

Step 3 - Create the EJS panel template

The profiler exposes a full design system of Tailwind tokens, badge classes, duration classes, and EJS helper functions. See the Template reference for the complete list.

templates/events-panel.ejs
<% const events = data || []; %>

<div class="flex items-center justify-between mb-4">
  <span class="text-xs text-foreground-muted">
    <strong class="text-foreground"><%= events.length %></strong>
    event<%= events.length !== 1 ? 's' : '' %> emitted
  </span>
</div>

<% if (events.length === 0) { %>
<div class="py-8 text-center bg-surface-muted border border-line rounded-lg">
  <p class="text-foreground-muted text-sm">No events emitted during this request.</p>
</div>
<% } else { %>
<div class="rounded-lg border border-line overflow-hidden">
  <table class="w-full">
    <thead>
      <tr class="bg-surface-muted border-b border-line">
        <th
          class="text-left py-2.5 px-4 text-foreground-muted font-medium text-2xs uppercase tracking-widest"
        >
          Event
        </th>
        <th
          class="text-right py-2.5 px-4 text-foreground-muted font-medium text-2xs uppercase tracking-widest w-24"
        >
          Listeners
        </th>
        <th
          class="text-left py-2.5 px-4 text-foreground-muted font-medium text-2xs uppercase tracking-widest w-20"
        >
          Time
        </th>
      </tr>
    </thead>
    <tbody class="divide-y divide-line-subtle">
      <% for (const e of events) { %>
      <tr class="hover:bg-surface-muted transition-colors">
        <td class="py-2 px-4 text-foreground text-xs font-mono"><%= e.eventName %></td>
        <td class="py-2 px-4 text-right text-foreground-secondary text-xs tabular-nums">
          <%= e.listenerCount %>
        </td>
        <td class="py-2 px-4 text-foreground-faint text-2xs tabular-nums">
          <%= timeOnly(e.timestamp) %>
        </td>
      </tr>
      <% } %>
    </tbody>
  </table>
</div>
<% } %>

Step 4 - Register as a provider

events-profiler.module.ts
import { Module } from '@nestjs/common';
import { EventCollector } from './event.collector';
import { EventPatchService } from './event-patch.service';

@Module({
  providers: [EventPatchService, EventCollector],
})
export class EventsProfilerModule {}

Register it in the dev-only profiling bundle, next to the core module and the official collectors. The collector imports the profiler, so if it lives in your application it follows the same dev-only rule: keep its files out of production code (for example under src/profiling/).

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

@Module({
  imports: [
    ProfilerModule.forRoot({ isGlobal: true }),
    EventsProfilerModule,
    // ...
  ],
})
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 5 - Test it

Emit some events in a controller:

@Get('order')
async createOrder() {
  this.eventEmitter.emit('order.created', { id: 1 });
  this.eventEmitter.emit('order.payment.requested', { amount: 99.99 });
  return { status: 'created' };
}

Open /_profiler/{token} → Events tab. The toolbar badge shows 2.

IProfilerCollector interface summary

MemberRequiredDescription
name✓Unique identifier for the collector
labelDisplay label in the tab (or sub-tab inside a group)
iconSVG string for the tab icon and toolbar
priorityTab display order (lower = leftmost), default 100
groupGroup key - merge several collectors into one sidebar tab
groupLabelSidebar label for the group tab
groupIconSVG icon for the group tab
groupPrioritySort priority of the group tab
getBadgeValue()Badge value shown in the sidebar and toolbar
getTemplatePath()Absolute path to a custom EJS panel template
collect()✓Returns data to store for this profile

The collect() method is called after the request completes (in the interceptor). Data written to profile.collectors['__myKey'] during the request is available to read in collect().

Grouping collectors into a single tab

Multiple collectors can share a single sidebar tab by declaring the same group key. This is useful when two independent packages cover related concerns and should present as one unified panel to the user - for example TypeORM (SQL) and Mongoose (MongoDB) both appearing under a single Database tab.

Each collector remains fully independent: it can be installed alone or alongside other collectors in the same group.

sql.collector.ts
@Injectable()
@ProfilerCollector({
  name: 'my-sql',
  label: 'SQL', // sub-tab label (shown when multiple collectors share the group)
  icon: SQL_ICON,
  priority: 10,
  group: 'database', // group key
  groupLabel: 'Database', // sidebar tab label
  groupIcon: DB_ICON,
  groupPriority: 10, // sidebar tab sort priority
})
export class SqlCollector implements IProfilerCollector {
  readonly name = 'my-sql';
  readonly label = 'SQL';
  readonly group = 'database';
  readonly groupLabel = 'Database';
  readonly groupIcon = DB_ICON;
  readonly groupPriority = 10;
  // ...
}
nosql.collector.ts
@Injectable()
@ProfilerCollector({
  name: 'my-nosql',
  label: 'NoSQL',
  icon: NOSQL_ICON,
  priority: 15,
  group: 'database', // same group key → merged into the same tab
  groupLabel: 'Database',
  groupIcon: DB_ICON,
  groupPriority: 10,
})
export class NoSqlCollector implements IProfilerCollector {
  readonly name = 'my-nosql';
  readonly label = 'NoSQL';
  readonly group = 'database';
  // ...
}

Rendering behaviour:

  • One collector active - the group tab renders the single collector's template directly, with no sub-tabs.
  • Multiple collectors active - the group tab renders a sub-tab switcher. Only collectors that have data for the current request are shown; empty sub-tabs are hidden automatically.
  • Badge - the sidebar badge concatenates each collector's individual badge (e.g. 5q · 3q).

The grouping logic is handled entirely by the core profiler (@eleven-labs/nest-profiler). The two collector packages do not need to know about each other.

Powered & maintained by

On this page