NestJS Profiler

Build a custom entrypoint type

Profile a non-HTTP entrypoint end to end — give it its own data shape, its own list-page table and its own detail tab — without touching the core.

The profiler handles HTTP, GraphQL and CLI commands out of the box — each in its own list table — but the entrypoint model is open: you can teach the profiler about any kind of trigger — a WebSocket message, a queue job, a gRPC call, a scheduled task — and have it show up in its own list table with its own detail view. This tutorial profiles WebSocket messages end to end.

Adding an entrypoint kind is always the same two-part story, and neither part touches the core:

  1. Produce the profile and fill its entrypoint — here, with an IContextAdapter.
  2. Render it — with ProfilerCoreService.registerEntrypointType(), which contributes the list-page table, the detail tab(s), the kind-scoped filter bar and the breadcrumb summary.

What we'll build

A small module that:

  • Defines a typed WebSocketMessageData payload for the websocket entrypoint kind.
  • Captures every @SubscribeMessage handler invocation as a profile.
  • Lists WebSocket messages in their own WebSocket table at /_profiler, separate from HTTP requests.
  • Renders a Message detail tab showing the event, namespace, client id and payload.

The WebSocket gateway and the profiler UI run in the same process, so the default in-memory storage is enough — no file storage required. (A separate-process entrypoint like a CLI command would need cross-process storage; see Command profiling.)

Step 1 — Define the entrypoint's data shape

Every profile records what triggered it on a single discriminated field — entrypoint: { type, data }. Pick a stable type discriminator and a typed data shape that your templates will read. The Profile<TData> generic carries this shape through your code, so you never cast entrypoint.data.

websocket-entrypoint.types.ts
/** `entrypoint.type` discriminator for profiles produced by a WebSocket message. */
export const WEBSOCKET_ENTRYPOINT_TYPE = 'websocket';

/** Payload of the `websocket` entrypoint — owned entirely by this module. */
export interface WebSocketMessageData {
  /** The message handler's method name (e.g. `onChat`). Reflect `MESSAGE_MAPPING_METADATA` if you need the exact `@SubscribeMessage` event string. */
  event: string;
  /** Socket.IO namespace, e.g. `/` or `/chat`. */
  namespace: string;
  /** The connected client's id, when available. */
  clientId?: string;
  /** The message payload the client sent. */
  payload?: unknown;
}

Step 2 — Produce the profile (IContextAdapter)

Implement IContextAdapter and register it (Step 4): ProfilerInterceptor automatically routes every execution context whose type equals contextType ('ws' for WebSockets) to your adapter.

A WebSocket message never passes through the HTTP middleware, so there is no profile to recover — recoverProfile mints a fresh one. enrichProfile then fills the typed entrypoint.data. Note how both methods are typed Profile<WebSocketMessageData>: no as casts anywhere.

websocket-context.adapter.ts
import { randomUUID } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import type { ExecutionContext } from '@nestjs/common';
import type { IContextAdapter, Profile } from '@eleven-labs/nest-profiler';
import { WEBSOCKET_ENTRYPOINT_TYPE } from './websocket-entrypoint.types';
import type { WebSocketMessageData } from './websocket-entrypoint.types';

@Injectable()
export class WebSocketContextAdapter implements IContextAdapter {
  readonly contextType = 'ws';

  recoverProfile(_ctx: ExecutionContext): Profile<WebSocketMessageData> | null {
    // No HTTP middleware ran for this message, so mint a fresh profile. The
    // interceptor stores it in CLS, so profile-scoped collectors (cache, DB,
    // HTTP client…) keep capturing for the duration of the handler.
    return {
      token: randomUUID(),
      createdAt: Date.now(),
      entrypoint: { type: WEBSOCKET_ENTRYPOINT_TYPE, data: { event: '', namespace: '/' } },
      performance: { startTime: Date.now(), heapUsed: process.memoryUsage().heapUsed },
      logs: [],
      exceptions: [],
      collectors: {},
    };
  }

  enrichProfile(profile: Profile<WebSocketMessageData>, ctx: ExecutionContext): void {
    const ws = ctx.switchToWs();
    const client = ws.getClient<{ id?: string; nsp?: { name?: string } }>();
    // `entrypoint.data` is typed `WebSocketMessageData` thanks to the generic.
    profile.entrypoint.data = {
      event: ctx.getHandler().name,
      namespace: client.nsp?.name ?? '/',
      clientId: client.id,
      payload: ws.getData(),
    };
  }
}

Adapters must be idempotent — the interceptor may call enrichProfile more than once per profile. Recomputing entrypoint.data from the context (as above) is naturally safe.

Step 3 — Render it (registerEntrypointType)

A single call teaches the profiler how to display the kind. It derives the list section, registers the filters scoped to this kind (shown above the WebSocket list only), and exposes the detail tab and breadcrumb summary. Typing the summary and matches parameters as Profile<WebSocketMessageData> keeps entrypoint.data cast-free here too.

register the type (called in Step 4)
import * as path from 'path';
import type {
  EntrypointSummary,
  Profile,
  ProfilerCoreService,
  ProfilerListFilter,
} from '@eleven-labs/nest-profiler';
import { WEBSOCKET_ENTRYPOINT_TYPE } from './websocket-entrypoint.types';
import type { WebSocketMessageData } from './websocket-entrypoint.types';

const TEMPLATES_DIR = path.join(__dirname, 'templates');

// Shown only above the WebSocket list and applied only to WebSocket profiles.
const eventFilter: ProfilerListFilter<string> = {
  key: 'event',
  label: 'Event',
  control: 'text',
  parse: (raw) => (raw && raw.length > 0 ? raw.toLowerCase() : undefined),
  matches: (profile: Profile<WebSocketMessageData>, value) =>
    profile.entrypoint.data.event.toLowerCase().includes(value),
};

function registerWebSocketEntrypoint(core: ProfilerCoreService): void {
  core.registerEntrypointType({
    type: WEBSOCKET_ENTRYPOINT_TYPE,
    label: 'WebSocket',
    listSection: {
      title: 'WebSocket',
      description: 'WebSocket messages captured by the profiler',
      order: 40,
      itemLabel: 'message',
      templatePath: path.join(TEMPLATES_DIR, 'websocket-section.ejs'),
    },
    detailTabs: [
      {
        name: 'message',
        label: 'Message',
        templatePath: path.join(TEMPLATES_DIR, 'websocket-message.ejs'),
      },
    ],
    listFilters: [eventFilter],
    summary(profile: Profile<WebSocketMessageData>): EntrypointSummary {
      const data = profile.entrypoint.data;
      return {
        badge: 'WS',
        badgeClass: 'badge-default',
        text: `${data.namespace} · ${data.event}`,
      };
    },
  });
}

The two templatePaths are absolute paths to EJS partials your package ships (bundle the templates/ folder into dist). A list-section partial receives { profiles, profilerPath }; a detail-tab partial receives { profile }. Both also get the shared template helpers (methodClass, statusClass, kvTable, isoDate, timeOnly, toJson…) — see the Template reference.

templates/websocket-section.ejs
<div class="rounded-xl border border-line overflow-hidden bg-surface">
  <table class="w-full">
    <thead>
      <tr class="bg-surface-muted border-b border-line">
        <th
          class="text-left py-3 px-4 text-foreground-muted font-medium text-2xs uppercase tracking-widest"
        >
          Token
        </th>
        <th
          class="text-left py-3 px-4 text-foreground-muted font-medium text-2xs uppercase tracking-widest"
        >
          Event
        </th>
        <th
          class="text-left py-3 px-4 text-foreground-muted font-medium text-2xs uppercase tracking-widest"
        >
          Namespace
        </th>
        <th
          class="text-left py-3 px-4 text-foreground-muted font-medium text-2xs uppercase tracking-widest"
        >
          Time
        </th>
      </tr>
    </thead>
    <tbody class="divide-y divide-line-subtle">
      <% for (const p of profiles) { %> <% const msg = p.entrypoint.data; %>
      <tr class="hover:bg-surface-muted transition-colors">
        <td class="py-2.5 px-4">
          <a
            href="<%= profilerPath %>/<%= p.token %>"
            class="text-nest hover:text-nest-light transition-colors font-mono font-medium text-xs"
          >
            <%= p.token.slice(0, 8) %>
          </a>
        </td>
        <td class="py-2.5 px-4">
          <div class="flex items-center gap-1.5">
            <span class="px-2 py-0.5 rounded text-2xs font-bold tracking-wide badge-default"
              >WS</span
            >
            <code class="text-foreground-secondary text-xs"><%= msg.event %></code>
          </div>
        </td>
        <td class="py-2.5 px-4 text-foreground-faint text-xs"><%= msg.namespace %></td>
        <td class="py-2.5 px-4 text-foreground-faint text-xs tabular-nums">
          <%= isoDate(p.createdAt) %>
        </td>
      </tr>
      <% } %>
    </tbody>
  </table>
</div>
templates/websocket-message.ejs
<% const msg = profile.entrypoint.data; %>

<section class="mb-6">
  <h2 class="text-foreground-muted text-2xs uppercase tracking-widest font-semibold mb-3">
    Message
  </h2>
  <code
    class="block bg-surface-muted border border-line rounded-lg px-4 py-3 text-foreground text-sm break-all"
  >
    <span class="px-1.5 py-0.5 rounded text-2xs font-bold tracking-wide badge-default mr-2">WS</span
    ><%= msg.namespace %> · <%= msg.event %>
  </code>
</section>

<section class="mb-6">
  <h2 class="text-foreground-muted text-2xs uppercase tracking-widest font-semibold mb-3">
    Client
  </h2>
  <p class="text-foreground-secondary text-xs font-mono"><%= msg.clientId || '—' %></p>
</section>

<section>
  <h2 class="text-foreground-muted text-2xs uppercase tracking-widest font-semibold mb-3">
    Payload
  </h2>
  <pre
    class="bg-surface-muted border border-line rounded-lg px-4 py-3 text-foreground-secondary text-xs whitespace-pre-wrap break-all"
  >
<%= toJson(msg.payload) %></pre
  >
</section>

Step 4 — Wire up the module

Expose the adapter through the PROFILER_CONTEXT_ADAPTERS multi-token, and register the entrypoint type in onModuleInit (so it runs after the profiler core is available):

websocket-profiler.module.ts
import { Module } from '@nestjs/common';
import type { OnModuleInit } from '@nestjs/common';
import { PROFILER_CONTEXT_ADAPTERS, ProfilerCoreService } from '@eleven-labs/nest-profiler';
import { WebSocketContextAdapter } from './websocket-context.adapter';
import { registerWebSocketEntrypoint } from './websocket-entrypoint';

@Module({
  providers: [
    WebSocketContextAdapter,
    { provide: PROFILER_CONTEXT_ADAPTERS, useExisting: WebSocketContextAdapter, multi: true },
  ],
})
export class WebSocketProfilerModule implements OnModuleInit {
  constructor(private readonly core: ProfilerCoreService) {}

  onModuleInit(): void {
    registerWebSocketEntrypoint(this.core);
  }
}
app.module.ts
@Module({
  imports: [
    ProfilerModule.forRoot({ isGlobal: true, collectBody: true }),
    WebSocketProfilerModule,
    // ...your gateways
  ],
})
export class AppModule {}

Step 5 — Test it

Any ordinary gateway works — you change nothing for profiling:

chat.gateway.ts
import { MessageBody, SubscribeMessage, WebSocketGateway } from '@nestjs/websockets';

@WebSocketGateway()
export class ChatGateway {
  @SubscribeMessage('chat')
  onChat(@MessageBody() body: { text: string }): { echo: string } {
    return { echo: body.text };
  }
}

Emit a chat message from a client, then open /_profiler: a WebSocket table lists the message. Open it to find the Message tab with the event, namespace, client id and payload — alongside any cache/DB/HTTP-client activity the handler triggered, captured by the profile-scoped collectors.

How it works

For a non-HTTP context, ProfilerInterceptor looks up the adapter registered for the context type. It uses the profile already in CLS, or calls recoverProfile() — which here mints one. It then calls enrichProfile() (idempotently) to fill entrypoint.data, re-establishes the CLS context so ProfilerService.addLog() and profile-scoped collectors work inside the handler, runs the handler, then collects and persists the profile off the response path. Because the kind was registered with registerEntrypointType(), the profiler renders its dedicated list table, Message detail tab and its own filter bar (the universal filters plus your listFilters) — no core change required.

For the full extensibility model and a gRPC variant, see Custom entrypoints & adapters.

Powered & maintained by

On this page