NestJS Profiler
Tutorials

HTTP client profiling

Capture outgoing HTTP requests and inspect them in the HTTP Client panel - with the axios and fetch adapters or any client of your own.

This tutorial shows how to profile outgoing HTTP requests in a NestJS application. The HTTP Client panel lives in @eleven-labs/nest-profiler-http and is client-agnostic: select one or both of the bundled adapters (axios, fetch), or wire any other client (undici, got…) yourself through the HttpProfilerRecorder or a custom HttpInstrumentation. Nothing is instrumented unless you select it.

Prerequisites

  • @eleven-labs/nest-profiler installed and configured
  • For the axios adapter only: @nestjs/axios and axios installed (fetch is a Node ≥ 22 built-in)

Step 1 - Install the package

pnpm add @eleven-labs/nest-profiler-http
# only if you select the axios adapter:
pnpm add @nestjs/axios axios

Step 2 - Register the module and select your client(s)

Import each adapter from its subpath and list it in instrumentations. The axios adapter auto-discovers every HttpService in your app (via DiscoveryService), so there is no axiosRef to wire — even across several feature modules that each create their own HttpService.

posts.module.ts
import { Module } from '@nestjs/common';
import { ConditionalModule } from '@nestjs/config';
import { HttpModule } from '@nestjs/axios';
import { HttpCollectorModule } from '@eleven-labs/nest-profiler-http';
import { AxiosInstrumentation } from '@eleven-labs/nest-profiler-http/axios';
import { PostsController } from './posts.controller';
import { PostsService } from './posts.service';

const isProfilerEnabled = (env: NodeJS.ProcessEnv) => env['PROFILER_ENABLED'] === 'true';

@Module({
  imports: [
    HttpModule,
    ConditionalModule.registerWhen(
      HttpCollectorModule.forRoot({ instrumentations: [AxiosInstrumentation] }),
      isProfilerEnabled,
    ),
  ],
  controllers: [PostsController],
  providers: [PostsService],
})
export class PostsModule {}

Using fetch instead of (or alongside) axios? Add FetchInstrumentation (/fetch) to the list. For any other client (got, undici, superagent…), write a small custom instrumentation using the client's own hooks — see Wire your own HTTP client below. Going through the client's native API captures full request and response bodies safely.

Step 3 - Use HttpService in a service

posts.service.ts
import { Injectable } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';

@Injectable()
export class PostsService {
  constructor(private readonly http: HttpService) {}

  async findAll(): Promise<unknown[]> {
    const { data } = await firstValueFrom(
      this.http.get('https://jsonplaceholder.typicode.com/posts?_limit=5'),
    );
    return data;
  }
}

Step 4 - Test it

Start your application and trigger a request that makes an outgoing HTTP call:

# Cache miss - triggers an outgoing call to the external API
curl -i http://localhost:3000/posts

Copy the X-Debug-Token from the response headers, open /_profiler/{token}, and click the HTTP Client tab.

You will see each outgoing request with:

  • Method - GET, POST, PUT, etc.
  • URL - full URL including query parameters
  • Status - HTTP response status code (e.g., 200, 404)
  • Duration - round-trip time in milliseconds
  • Phases - a stacked bar breaking that duration down, once you add a phases provider (next step)

The toolbar badge displays {n}req - for example 3req when three outgoing requests were made during the incoming request.

Step 5 - Break each call into its phases

A duration tells you the call took 180ms. A breakdown tells you whether that was DNS, a TLS handshake, an upstream thinking, or a large body coming down the wire — the difference between a fix in your infrastructure and a conversation with the team that owns the API.

Phases are opt-in and selected exactly like an adapter. Which provider you need depends on the transport, not on the client's name:

posts.module.ts
import { AxiosInstrumentation } from '@eleven-labs/nest-profiler-http/axios';
import { NodeHttpPhases } from '@eleven-labs/nest-profiler-http/phases';

HttpCollectorModule.forRoot({
  instrumentations: [AxiosInstrumentation, NodeHttpPhases],
});
  • NodeHttpPhases times every client built on node:http/node:https — axios, superagent, got, node-fetch — and reports wait, dns, tcp, tls, request, firstByte, download.
  • UndiciPhases times fetch, which runs on undici and never goes through node:http. It reports wait, connect, request, firstByte, download.

Call the same endpoint again and the Phases column shows a stacked bar; hover a segment for its name and value, or expand the row for the full list. The same breakdown appears as labelled extras on the call's bar in the Execution Trace of the Performance tab, next to the queries that ran alongside it.

A partial breakdown is the normal case, not a bug. A reused keep-alive connection reports no handshake — it connected nothing. An IP literal reports no DNS. undici publishes one connected event covering DNS, TCP and TLS, so it reports a coarse connect phase instead of the three. And since fetch() resolves on the response headers, a body still streaming when the call is recorded has no measured download. Whatever the phases do not account for is drawn as an explicit Other segment rather than folded into a neighbour.

A phases provider records nothing — no entry, no header, no body. It only notes when events fired, which is why a timings-only node:http hook is safe where a recording one is not: capturing a response body there would mean reading the stream and stealing chunks from a caller consuming it in paused mode.

Combining with cache profiling

A common pattern is to fetch from an external API and cache the result. With both CacheCollectorModule and HttpCollectorModule registered, you can compare the two panels across requests:

# First call - Cache MISS, outgoing call fires → HTTP Client tab shows 1req
curl http://localhost:3000/posts

# Second call - Cache HIT, no outgoing call → HTTP Client tab shows 0req
curl http://localhost:3000/posts

Wire your own HTTP client

Using a client without a bundled adapter (undici, got…)? Feed the panel yourself by injecting HttpProfilerRecorder - exported and provided by HttpCollectorModule. Time your call and pass the raw request/response material to recorder.capture(...): it applies your capture options (request/response headers + body) and masks sensitive headers for you, so a custom client shows the same detail in the panel as the bundled adapters. The collector registered by HttpCollectorModule renders the panel, so no other import is needed.

weather.service.ts
import { Injectable } from '@nestjs/common';
import { HttpProfilerRecorder } from '@eleven-labs/nest-profiler-http';

@Injectable()
export class WeatherService {
  constructor(private readonly recorder: HttpProfilerRecorder) {}

  async getForecast(): Promise<unknown> {
    const url = 'https://api.weather.example.com/forecast';
    const requestHeaders = { accept: 'application/json' };

    const startedAt = Date.now();
    const response = await fetch(url, { headers: requestHeaders });
    const body = await response.json();

    this.recorder.capture({
      method: 'GET',
      url,
      startedAt,
      duration: Date.now() - startedAt,
      statusCode: response.status,
      requestHeaders,
      responseHeaders: response.headers, // fetch `Headers` and `Map` are supported
      responseBody: body,
    });

    return body;
  }
}

capture() honours the configured captureRequestHeaders / captureRequestBody / captureResponseHeaders / captureResponseBody flags and the maskHeaders list, normalising any header bag (a fetch Headers, an axios AxiosHeaders, a Map, or a plain record) and redacting sensitive values with [REDACTED]. It is a no-op outside a request context, so it is safe to call unconditionally. If you have already built a final HttpRequestEntry and want to bypass the options, call recorder.record(entry) instead. The example API selects its whole ArticleGateway between the axios and fetch adapters with HTTP_CLIENT=axios|fetch, so you can run the same endpoints through either instrumentation.

For a reusable adapter — one that captures every call made with a given client, like the bundled axios/fetch adapters — implement the HttpInstrumentation interface instead of recording inline, then add it to the same instrumentations list. Hook the client's own API so you get full request and response bodies safely. Here is a complete example for got, a popular client with simple beforeRequest/afterResponse hooks:

got.instrumentation.ts
import { Injectable } from '@nestjs/common';
import type { HttpInstrumentation, HttpProfilerRecorder } from '@eleven-labs/nest-profiler-http';
import got from 'got';

@Injectable()
export class GotInstrumentation implements HttpInstrumentation {
  // `install` runs once at bootstrap. Extend got with hooks that time each call and
  // push it to the panel via the shared recorder — no per-call wiring in your services.
  install(recorder: HttpProfilerRecorder): void {
    got.extend({
      hooks: {
        beforeRequest: [
          (options) => {
            (options as { _start?: number })._start = Date.now();
          },
        ],
        afterResponse: [
          (response) => {
            const started = (response.request.options as { _start?: number })._start ?? Date.now();
            recorder.capture({
              method: response.request.options.method,
              url: response.requestUrl.toString(),
              startedAt: started,
              duration: Date.now() - started,
              statusCode: response.statusCode,
              responseHeaders: response.headers,
              responseBody: response.body,
            });
            return response;
          },
        ],
      },
    });
  }
}

Register it like any bundled adapter — HttpCollectorModule.forRoot({ instrumentations: [AxiosInstrumentation, GotInstrumentation] }). The same shape works for undici (via diagnostics_channel), superagent (via a plugin), or a bespoke NestJS HTTP service: hook the client once in install, call recorder.capture(...) on each response.

Phases for your own client

Add one line to capture(...): readHttpPhases(source) finds the breakdown behind whatever object you hold — a response, an error, a ClientRequest, an IncomingMessage, a follow-redirects wrapper, or a got response — and returns undefined when nothing timed the call.

import { readHttpPhases } from '@eleven-labs/nest-profiler-http';

recorder.capture({
  method: response.request.options.method,
  url: response.requestUrl.toString(),
  startedAt: started,
  duration: Date.now() - started,
  statusCode: response.statusCode,
  phases: readHttpPhases(response), // got exposes its own timings; a provider fills in for others
  responseHeaders: response.headers,
  responseBody: response.body,
});

Three ways to get a breakdown, in order of how little work they are:

  1. Your client uses node:http and NodeHttpPhases is installed — pass the response or the error to readHttpPhases. Nothing else to do. got needs no provider at all: it embeds @szmarczak/http-timer, and its native response.timings are read directly.

  2. Your client hands you its request object — time it yourself, with no global patch: got.stream(url).on('request', (req) => instrumentClientRequest(req)).

  3. Your client exposes neither — measure what you can and pass it. Every field of HttpPhases is optional, so a transport that only knows its time-to-first-byte says exactly that:

    recorder.capture({ method, url, startedAt, duration, phases: { firstByte: 42 } });

    The panel draws the segments you provided and shows the rest as Other. Never inflate a phase to make the total add up: a missing phase reads as missing, a wrong one reads as a fact.

For a client with no transport to reach at all — a fetch-like API that hides everything — a provider and an adapter can meet through the async context instead: open a slot around the call with openPhaseSlot, and have the provider write into activePhaseSlot(). That is exactly how UndiciPhases and FetchInstrumentation cooperate.

How it works

Each selected adapter installs once at bootstrap and funnels into the shared HttpProfilerRecorder:

  • AxiosInstrumentation enumerates the DI container with DiscoveryService, finds every axios instance (each HttpService, plus bare axios instances), and installs request/response interceptors on each — no axiosRef wiring, and it never imports @nestjs/axios.
  • FetchInstrumentation patches globalThis.fetch once.
  • NodeHttpPhases wraps request/get on node:http and node:https to start a timer on each outgoing request, and UndiciPhases subscribes to undici's diagnostics_channel events. Neither records anything: the adapter that records the call reads the breakdown they measured.

Every adapter records the start time, then on completion the end time, status code and final URL, pushing an HttpRequestEntry to the current profile. Requests made outside a request context (e.g. in onModuleInit) are silently ignored since there is no active CLS profile.

Powered & maintained by

On this page