OpenTelemetry
NestJS
Quick Start

NestJS (OpenTelemetry)

Instrument your NestJS application with OpenTelemetry and send traces, metrics, and logs to TracePath. There is no TracePath-specific NestJS module to install. OTel auto-instrumentation does the work.

Installation

npm install @opentelemetry/sdk-node \
  @opentelemetry/auto-instrumentations-node \
  @opentelemetry/exporter-trace-otlp-http \
  @opentelemetry/exporter-metrics-otlp-http \
  @opentelemetry/exporter-logs-otlp-http \
  @opentelemetry/sdk-metrics \
  @opentelemetry/sdk-logs \
  @opentelemetry/resources \
  @opentelemetry/instrumentation-express \
  @opentelemetry/api \
  @opentelemetry/api-logs

Install every package on that list, even the ones you never import directly. Some of them are pulled in as dependencies of @opentelemetry/sdk-node, so on npm the imports happen to resolve anyway. Under pnpm or Yarn PnP they do not, and the versions drift.

If you use Prisma, also install its OTel instrumentation for automatic query tracing:

npm install @prisma/instrumentation

Setup

1. Create src/instrumentation.ts

Put the file inside src/, not at the project root. A .ts file at the root widens the TypeScript rootDir, and nest build then emits dist/src/main.js instead of dist/main.js. That silently breaks the scaffolded start:prod script.

import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { BatchLogRecordProcessor } from "@opentelemetry/sdk-logs";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { ExpressLayerType } from "@opentelemetry/instrumentation-express";
import { resourceFromAttributes } from "@opentelemetry/resources";
 
const TRACEPATH_URL = "https://ingest.tracepath.dev";
const TRACEPATH_TOKEN = process.env.TRACEPATH_TOKEN ?? "your-project-token";
const headers = { Authorization: `Bearer ${TRACEPATH_TOKEN}` };
 
const sdk = new NodeSDK({
  resource: resourceFromAttributes({
    "service.name": "my-nestjs-app",
    "service.version": "1.0.0",
  }),
 
  traceExporter: new OTLPTraceExporter({
    url: `${TRACEPATH_URL}/api/otel/v1/traces`,
    headers,
  }),
 
  metricReaders: [
    new PeriodicExportingMetricReader({
      exporter: new OTLPMetricExporter({
        url: `${TRACEPATH_URL}/api/otel/v1/metrics`,
        headers,
      }),
      exportIntervalMillis: 30_000,
    }),
  ],
 
  logRecordProcessors: [
    new BatchLogRecordProcessor({
      exporter: new OTLPLogExporter({
        url: `${TRACEPATH_URL}/api/otel/v1/logs`,
        headers,
      }),
    }),
  ],
 
  instrumentations: [
    getNodeAutoInstrumentations({
      "@opentelemetry/instrumentation-fs": { enabled: false },
      "@opentelemetry/instrumentation-net": { enabled: false },
      "@opentelemetry/instrumentation-dns": { enabled: false },
      // NestJS 11 runs on Express 5, which routes through the `router` package.
      // This instrumentation duplicates instrumentation-express and emits
      // "middleware - patched" spans that can surface as junk endpoint rows.
      "@opentelemetry/instrumentation-router": { enabled: false },
      "@opentelemetry/instrumentation-express": {
        ignoreLayersType: [ExpressLayerType.MIDDLEWARE],
      },
    }),
  ],
});
 
sdk.start();
 
for (const signal of ["SIGTERM", "SIGINT"] as const) {
  process.on(signal, () => {
    sdk.shutdown().finally(() => process.exit(0));
  });
}

Three details in that file are easy to get wrong:

Use resourceFromAttributes, not new Resource(...). The Resource class was removed in @opentelemetry/resources 2.x, which is the version @opentelemetry/sdk-node installs today. Older guides that still show new Resource({ ... }) fail to compile with error TS2693: 'Resource' only refers to a type, but is being used as a value here.

Pass the log exporter as { exporter }. new BatchLogRecordProcessor({ exporter: ... }) is the shape current @opentelemetry/sdk-logs expects. The older positional form, new BatchLogRecordProcessor(exporter), fails to compile with error TS2345: Property 'exporter' is missing.

Configure getNodeAutoInstrumentations(). Called bare, it also turns on instrumentation-router, which duplicates instrumentation-express on Express 5 and adds 8 to 11 child spans per request. Those extra spans carry http.route but no HTTP method, and when a batch flush separates them from their parent TracePath reads them as roots and files them as junk endpoint rows named middleware - patched with status code 0. The config above removes them.

sdk.shutdown() flushes buffered spans, logs, and metrics. Without it a container restart drops the last few seconds of telemetry.

2. Import it first in src/main.ts

// MUST be the first import: it starts the OTel SDK before @nestjs/core
// (and therefore express and http) is loaded, so auto-instrumentation can patch them.
import './instrumentation';
 
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
 
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

The import has to be on the first line. Auto-instrumentation works by patching modules as they are loaded, so anything loaded before the SDK starts is never traced.

If you use Prisma, add import { PrismaInstrumentation } from "@prisma/instrumentation" and put new PrismaInstrumentation() in the instrumentations array next to getNodeAutoInstrumentations().

Running Your App

Nothing changes. The side-effect import means every script the NestJS CLI scaffolded keeps working as it is:

npm run start        # nest start
npm run start:dev    # nest start --watch
npm run build && npm run start:prod

Do not set NODE_OPTIONS="--require ./instrumentation.js". NODE_OPTIONS applies to the nest CLI process itself, which runs before anything is compiled, so nest start dies with Cannot find module.

If you would rather preload the file with --require instead of importing it, add scripts under new names so the scaffolded ones survive:

{
  "scripts": {
    "start:otel-require": "node --require ts-node/register --require ./src/instrumentation.ts src/main.ts",
    "start:prod:otel-require": "node --require ./dist/instrumentation.js dist/main.js"
  }
}

Note the paths: ./src/instrumentation.ts in development, ./dist/instrumentation.js after a build. With the import './instrumentation' line in main.ts you do not need either script.

Test Your Integration

Add two routes to a controller:

import { Controller, Get, Param } from "@nestjs/common";
 
@Controller("api")
export class AppController {
  @Get("users/:id")
  getUser(@Param("id") id: string) {
    return { id, name: `User ${id}` };
  }
 
  @Get("test-error")
  testError() {
    throw new Error("Test error from NestJS");
  }
}

Start the app and send a few requests:

curl http://localhost:3000/api/users/1
curl http://localhost:3000/api/users/2
curl http://localhost:3000/api/users/3
curl -i http://localhost:3000/api/test-error

The last one returns HTTP/1.1 500 Internal Server Error. Telemetry is batched, so wait about 10 seconds, or stop the app with Ctrl-C to flush it right away. Then check the dashboard:

  • Endpoints shows one row, GET /api/users/:id, with a count of 3. Three rows with literal ids means http.route is missing. See Endpoint Grouping below.
  • Endpoints shows GET /api/test-error with status code 500.
  • Issues shows Error: Test error from NestJS with the full stack trace, pointing at the line in app.controller.ts that threw.

Endpoint Grouping

@opentelemetry/instrumentation-express sets http.route on every request it handles, and the http instrumentation copies it onto the root span. So on the default Express adapter, grouping works with no extra code. GET /api/users/1 and GET /api/users/2 land in one GET /api/users/:id row.

A request that matched no route returns 404 and carries no usable http.route. TracePath groups all of those into one endpoint named UNMATCHED, which keeps scanner traffic and typos out of your endpoint list. A route you defined that returns 404 on purpose keeps its own name.

If you use Fastify

@nestjs/platform-fastify needs one more package. @opentelemetry/instrumentation-fastify is not part of @opentelemetry/auto-instrumentations-node, and without it http.route is never set, so every path parameter becomes its own endpoint row.

npm install @opentelemetry/instrumentation-fastify

Then add the import at the top of src/instrumentation.ts and replace its instrumentations array:

import { FastifyInstrumentation } from "@opentelemetry/instrumentation-fastify";
 
  instrumentations: [
    getNodeAutoInstrumentations({
      "@opentelemetry/instrumentation-fs": { enabled: false },
      "@opentelemetry/instrumentation-net": { enabled: false },
      "@opentelemetry/instrumentation-dns": { enabled: false },
    }),
    new FastifyInstrumentation(),
  ],

The export is FastifyInstrumentation. FastifyOtelInstrumentation is a different package and that import fails to compile.

The instrumentation-router and instrumentation-express entries only matter on the Express adapter, so drop them from a Fastify config.

Errors and Issues

Nest's default exception filter turns an uncaught Error into a 500 response. @opentelemetry/instrumentation-nestjs-core records the exception on the handler span before that happens, so you get the Issue with a stack trace and an endpoint row correctly marked 500, with no extra code. Adding your own global @Catch() filter does not turn this off.

Two behaviours to expect:

  • Each throw produces two exception events. instrumentation-nestjs-core wraps every handler in two spans, one with nestjs.type=request_context and one with nestjs.type=handler, and both call recordException. One failed request shows a count of 2 on the issue.
  • HttpException subclasses are captured too. throw new NotFoundException("no such thing") returns a 404 as intended and still shows up under Issues. A global @Catch() filter runs after the span already recorded the exception, so it does not help. If you use exceptions for ordinary control flow, catch them inside the handler and build the response yourself, or expect 4xx noise in the feed.

Issue grouping is based on the stack trace, so the same error gets a different hash in development and in production. ts-node frames point at src/*.ts, compiled frames point at dist/*.js. Do not be surprised by two issues for what is really one bug.

To record an error without throwing, put it on the active span:

import { Injectable } from "@nestjs/common";
import { trace, SpanStatusCode } from "@opentelemetry/api";
 
@Injectable()
export class PaymentService {
  async charge(amount: number) {
    try {
      await this.gateway.charge(amount);
    } catch (error: any) {
      const span = trace.getActiveSpan();
      if (span) {
        span.recordException(error);
        span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
      }
      return { ok: false };
    }
  }
}

Logs

The logRecordProcessors block in src/instrumentation.ts already ships logs. What is left is picking a logger that OTel can read.

Nest's built-in Logger is not bridged. new Logger("AppController").log("...") writes to stdout and never reaches TracePath. This is the most common reason a log pipeline looks broken when it is fine.

Option 1: winston

npm install winston @opentelemetry/winston-transport

@opentelemetry/winston-transport is required and is not a dependency of @opentelemetry/auto-instrumentations-node. Without it, the winston instrumentation still injects trace_id and span_id into your console output but quietly stops there, logging only @opentelemetry/winston-transport is not available, log records will not be automatically sent.

// src/logger.ts
import * as winston from 'winston';
 
export const logger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  transports: [new winston.transports.Console()],
});
// in a controller or service
import { logger } from './logger';
 
logger.info('fetching user', { userId: id });

Logs emitted inside a request handler pick up that request's trace_id and span_id on their own. There is no context to pass around.

Option 2: keep Nest's Logger, backed by winston

If you want to keep injecting Logger the NestJS way, route it through winston with nest-winston. Records written with this.logger.log(...) then reach TracePath with the right trace id.

npm install winston nest-winston @opentelemetry/winston-transport
// src/app.module.ts
import { Module } from '@nestjs/common';
import { WinstonModule } from 'nest-winston';
import * as winston from 'winston';
import { AppController } from './app.controller';
 
@Module({
  imports: [
    WinstonModule.forRoot({
      level: 'info',
      format: winston.format.json(),
      transports: [new winston.transports.Console()],
    }),
  ],
  controllers: [AppController],
})
export class AppModule {}
// src/main.ts
import './instrumentation';
 
import { NestFactory } from '@nestjs/core';
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';
import { AppModule } from './app.module';
 
async function bootstrap() {
  const app = await NestFactory.create(AppModule, { bufferLogs: true });
  app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER));
  await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

Option 3: the OTel Logs API directly

No logging library needed:

import { logs, SeverityNumber } from '@opentelemetry/api-logs';
 
const otelLogger = logs.getLogger('my-nestjs-app');
 
otelLogger.emit({
  severityNumber: SeverityNumber.INFO,
  severityText: 'INFO',
  body: `handling user ${id}`,
  attributes: { 'user.id': id },
});

Background Jobs

TracePath's Tasks page is filled by spans with SpanKind.CONSUMER. Auto-instrumentation never creates those, so @nestjs/schedule cron methods, BullMQ processors, and plain setInterval work stay invisible until you wrap them yourself:

import { Injectable } from '@nestjs/common';
import { trace, context, ROOT_CONTEXT, SpanKind, SpanStatusCode } from '@opentelemetry/api';
 
const tracer = trace.getTracer('my-nestjs-app');
 
@Injectable()
export class ReportsService {
  async runNightlyReport() {
    // ROOT_CONTEXT detaches the job from whatever request happened to trigger it,
    // so it gets its own trace instead of hanging off an HTTP endpoint.
    return context.with(ROOT_CONTEXT, () =>
      tracer.startActiveSpan(
        'nightly-report',
        { kind: SpanKind.CONSUMER, attributes: { 'messaging.system': 'cron' } },
        async (span) => {
          try {
            const rows = await this.buildReport();
            span.setAttribute('rows.processed', rows.length);
            span.setStatus({ code: SpanStatusCode.OK });
          } catch (error) {
            span.recordException(error as Error);
            span.setStatus({ code: SpanStatusCode.ERROR });
            throw error;
          } finally {
            span.end();
          }
        },
      ),
    );
  }
 
  // Replace this with your real work.
  private async buildReport(): Promise<unknown[]> {
    return [];
  }
}

The span name becomes the task name in TracePath. Any span.recordException(...) inside the job also shows up under Issues, attributed to that task.

Always set the kind. A root span left at the default SpanKind.INTERNAL with no HTTP attributes matches nothing TracePath stores, so it is dropped on arrival: no endpoint, no task, no span row. Logs you emitted inside it still arrive, pointing at a trace that does not exist. kind: SpanKind.CONSUMER is the whole fix.

Custom Metrics

The metricReaders entry in src/instrumentation.ts exports anything you record through the OTel metrics API:

import { Body, Controller, Post } from '@nestjs/common';
import { metrics } from '@opentelemetry/api';
import { OrdersService } from './orders.service';
 
const meter = metrics.getMeter('my-nestjs-app');
 
const ordersCreated = meter.createCounter('orders.created', {
  description: 'Orders successfully created',
});
 
const checkoutLatency = meter.createHistogram('checkout.latency', {
  description: 'Checkout handler latency',
  unit: 'ms',
});
 
@Controller('orders')
export class OrdersController {
  constructor(private readonly ordersService: OrdersService) {}
 
  @Post()
  async create(@Body() dto: { plan: string }) {
    const started = Date.now();
    const order = await this.ordersService.create(dto);
    ordersCreated.add(1, { plan: dto.plan });
    checkoutLatency.record(Date.now() - started, { plan: dto.plan });
    return order;
  }
}

OrdersService there is your own service. The two lines that matter are ordersCreated.add(...) and checkoutLatency.record(...).

Tag keys become filterable dimensions in TracePath.

OTel instrumentIn TracePath
Counter, UpDownCounterStored as is, for example orders.created
GaugeStored as is
HistogramSplit into two metrics, checkout.latency.avg and checkout.latency.count
ExponentialHistogram, SummaryDropped. Use a plain histogram instead

With exportIntervalMillis: 30_000, a brand new metric can take up to a minute to appear.

What Gets Captured

LayerWhat's Captured
HTTP (Express or Fastify)Incoming requests with method, route, status code, duration
http.routeParameterized routes such as GET /api/users/:id, set automatically
NestJS handlersController handler spans via instrumentation-nestjs-core
ExceptionsThrown errors recorded as Issues with stack traces
MetricsNode.js runtime metrics, HTTP server duration, plus anything you record yourself
LogsOnly through a bridged logger. See Logs
Background jobsOnly spans you create with SpanKind.CONSUMER. See Background Jobs
Prisma (@prisma/instrumentation)Query spans with operation name, model, duration
Database (pg, mysql2, mongodb)Query spans with SQL statements
Cache (redis, ioredis)Cache operation spans
Outgoing fetch()HTTP client spans, via instrumentation-undici
Outgoing HTTP (axios)HTTP client spans, via instrumentation-http

How TracePath Classifies Your Spans

Span shapeWhere it lands
Root span with http.route or url.pathEndpoints, named METHOD /route
Any span with kind: CONSUMERTasks, named after the span
Any non-root spanSpans, in the trace waterfall
An exception event on any spanIssues, with the stack trace
A root span with no HTTP attributes and a kind other than CONSUMERDropped. Nothing is stored

A single GET /api/users/:id gives you a trace like this:

GET /api/users/:id               ← Endpoint (root SERVER span)
  ├─ request handler - /api/users/:id
  ├─ AppController.getUser       ← @nestjs/core request context
  ├─ getUser                     ← @nestjs/core handler
  ├─ users.findOne               ← your own span
  └─ GET                         ← outgoing fetch

Prisma Auto-Instrumentation

With @prisma/instrumentation registered in the SDK, every Prisma query creates child spans on its own:

// Just use Prisma normally. Spans are created automatically.
const users = await this.prismaService.user.findMany();
const user = await this.prismaService.user.create({ data: { name, email } });

The resulting trace in TracePath:

GET /api/users              ← Endpoint
  └─ prisma:client:query    ← Span (findMany on User)

Environment Variable Alternative

To configure everything from the environment, strip resource, traceExporter, metricReaders, and logRecordProcessors out of the SDK. Options set in code win over OTEL_EXPORTER_OTLP_*, so leaving them in gives you a confusing half override where the environment sets the service name and the code keeps sending to the hardcoded URL.

Keep the getNodeAutoInstrumentations() config exactly as it is. Only the exporter and resource settings move to the environment. Calling it bare here puts instrumentation-router and the Express middleware spans back, which is the noise the setup above removes.

import { NodeSDK } from "@opentelemetry/sdk-node";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { ExpressLayerType } from "@opentelemetry/instrumentation-express";
 
const sdk = new NodeSDK({
  instrumentations: [
    getNodeAutoInstrumentations({
      "@opentelemetry/instrumentation-fs": { enabled: false },
      "@opentelemetry/instrumentation-net": { enabled: false },
      "@opentelemetry/instrumentation-dns": { enabled: false },
      "@opentelemetry/instrumentation-router": { enabled: false },
      "@opentelemetry/instrumentation-express": {
        ignoreLayersType: [ExpressLayerType.MIDDLEWARE],
      },
    }),
  ],
});
 
sdk.start();
export OTEL_SERVICE_NAME="my-nestjs-app"
export OTEL_RESOURCE_ATTRIBUTES="service.version=1.0.0"
export OTEL_EXPORTER_OTLP_ENDPOINT="https://ingest.tracepath.dev/api/otel"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer your-project-token"

OTEL_EXPORTER_OTLP_ENDPOINT is the /api/otel base. The SDK appends /v1/traces, /v1/metrics, and /v1/logs itself. There is no OTEL_SERVICE_VERSION variable, which is why the version goes in OTEL_RESOURCE_ATTRIBUTES.

Next Steps