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-logsInstall 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/instrumentationSetup
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, notnew Resource(...). TheResourceclass was removed in@opentelemetry/resources2.x, which is the version@opentelemetry/sdk-nodeinstalls today. Older guides that still shownew Resource({ ... })fail to compile witherror 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-logsexpects. The older positional form,new BatchLogRecordProcessor(exporter), fails to compile witherror TS2345: Property 'exporter' is missing.
Configure
getNodeAutoInstrumentations(). Called bare, it also turns oninstrumentation-router, which duplicatesinstrumentation-expresson Express 5 and adds 8 to 11 child spans per request. Those extra spans carryhttp.routebut 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 namedmiddleware - patchedwith 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 putnew PrismaInstrumentation()in theinstrumentationsarray next togetNodeAutoInstrumentations().
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:prodDo 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-errorThe 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 meanshttp.routeis missing. See Endpoint Grouping below. - Endpoints shows
GET /api/test-errorwith status code 500. - Issues shows
Error: Test error from NestJSwith the full stack trace, pointing at the line inapp.controller.tsthat 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-fastifyThen 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-corewraps every handler in two spans, one withnestjs.type=request_contextand one withnestjs.type=handler, and both callrecordException. One failed request shows a count of 2 on the issue. HttpExceptionsubclasses 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
Loggeris 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.INTERNALwith 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.CONSUMERis 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 instrument | In TracePath |
|---|---|
| Counter, UpDownCounter | Stored as is, for example orders.created |
| Gauge | Stored as is |
| Histogram | Split into two metrics, checkout.latency.avg and checkout.latency.count |
| ExponentialHistogram, Summary | Dropped. Use a plain histogram instead |
With exportIntervalMillis: 30_000, a brand new metric can take up to a minute to appear.
What Gets Captured
| Layer | What's Captured |
|---|---|
| HTTP (Express or Fastify) | Incoming requests with method, route, status code, duration |
http.route | Parameterized routes such as GET /api/users/:id, set automatically |
| NestJS handlers | Controller handler spans via instrumentation-nestjs-core |
| Exceptions | Thrown errors recorded as Issues with stack traces |
| Metrics | Node.js runtime metrics, HTTP server duration, plus anything you record yourself |
| Logs | Only through a bridged logger. See Logs |
| Background jobs | Only 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 shape | Where it lands |
|---|---|
Root span with http.route or url.path | Endpoints, named METHOD /route |
Any span with kind: CONSUMER | Tasks, named after the span |
| Any non-root span | Spans, in the trace waterfall |
An exception event on any span | Issues, with the stack trace |
| A root span with no HTTP attributes and a kind other than CONSUMER | Dropped. 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 fetchPrisma 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
- OpenTelemetry Overview: endpoint, authentication, limits and quota behaviour
- Custom Instrumentation: manual spans, decorators, and exception capture
- Node.js OTel Guide: general Node.js setup details
- OTel Trace Mapping: how OTel spans map to TracePath concepts
- OTel Logs: shipping logs and linking them to traces
- OTel Metrics: how OTel instruments map to TracePath metrics