Node.js

NestJS

Send logs, traces and metrics from a NestJS service, with the SDK started before NestFactory.create.

NestJS

NestJS runs on Node.js like any other service here, so the Node.js SDK guide's packages and environment variables apply unchanged. What NestJS needs its own guide for is timing: where the SDK bootstrap has to sit relative to NestFactory.create() so auto-instrumentation actually patches anything.

Prerequisites

  • Nest 10 or later, on the default Express platform adapter (@nestjs/platform-express).
  • An ingestion token with traces, logs and metrics scoped.

Install

npm install @opentelemetry/api @opentelemetry/api-logs \
  @opentelemetry/sdk-node @opentelemetry/sdk-logs \
  @opentelemetry/auto-instrumentations-node \
  @opentelemetry/exporter-trace-otlp-proto \
  @opentelemetry/exporter-metrics-otlp-proto \
  @opentelemetry/exporter-logs-otlp-proto \
  @opentelemetry/instrumentation-winston \
  @opentelemetry/winston-transport winston

Same winston choice as the Next.js guide: an actively maintained OTel instrumentation that injects trace correlation and forwards to the Logs SDK, with the same @opentelemetry/winston-transport requirement (see that guide's Logs section for why).

Put the SDK bootstrap in its own file and import it as the very first line of main.ts, ahead of @nestjs/core and your app module:

main.ts
import 'reflect-metadata';
import { shutdown } from './tracing';   // side effect: starts the SDK

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 ?? 8080);
}

bootstrap();

Import order is the requirement, not a style choice

NestFactory.create() is what constructs the Express adapter, which is what first require()s express, but that happens because main.ts imported @nestjs/core earlier in the file, and imports resolve before any code in the importing module runs. So ./tracing has to be imported before @nestjs/core, not merely called before NestFactory.create() textually. "Initialise the SDK before NestFactory.create" means, in practice, "import it before anything that transitively imports Express", which, in a typical main.ts, is everything else in the file.

Configure

tracing.ts
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { WinstonInstrumentation } from '@opentelemetry/instrumentation-winston';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-proto';
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-proto';
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
import { BatchLogRecordProcessor } from '@opentelemetry/sdk-logs';

const sdk = new NodeSDK({
  traceExporter: new OTLPTraceExporter(),
  metricReaders: [
    new PeriodicExportingMetricReader({ exporter: new OTLPMetricExporter() }),
  ],
  logRecordProcessors: [
    new BatchLogRecordProcessor({ exporter: new OTLPLogExporter() }),
  ],
  instrumentations: [getNodeAutoInstrumentations(), new WinstonInstrumentation()],
});

sdk.start();

export async function shutdown(): Promise<void> {
  await sdk.shutdown();
}
export OTEL_EXPORTER_OTLP_ENDPOINT=<UPZERO_OTLP_ENDPOINT>
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer <UPZERO_INGEST_TOKEN>"
export OTEL_SERVICE_NAME=my-nestjs-service

Endpoint policy

Do not disable transport verification against a real endpoint. See the guide template for the full policy.

Call shutdown() from main.ts's own SIGTERM/SIGINT handlers, after app.close(), so buffered telemetry flushes before the process exits:

main.ts (continued)
const close = async () => {
  await app.close();
  await shutdown();
  process.exit(0);
};
process.on('SIGTERM', close);
process.on('SIGINT', close);

Logs

Nest has its own built-in Logger class; this guide uses winston instead, because @opentelemetry/instrumentation-winston is what forwards records to the Logs SDK with trace_id/span_id injected, and Nest's built-in logger has no equivalent OTel instrumentation. The two coexist without conflict: Nest's own startup log lines (Mapped {/work, GET} route, and similar) keep printing exactly as before, unrelated to and unaffected by the winston logger this guide adds for telemetry.

logger.ts
import winston from 'winston';

export const logger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  transports: [new winston.transports.Console()],
});
import { logger } from './logger';

logger.info('work cycle complete', { items: 12 });

If you would rather not add a second logger, see the SDK guide's Logs section for the plain Logs API instead; it works the same way inside a Nest controller method.

Traces

No code needed for the request span: @opentelemetry/instrumentation-express (bundled in getNodeAutoInstrumentations()) produces it for every route, because Nest runs on Express underneath. Add child spans from inside a controller method the same way as every other guide on this site:

app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { trace, SpanStatusCode } from '@opentelemetry/api';

const tracer = trace.getTracer('my-nestjs-service');

@Controller()
export class AppController {
  @Get('work')
  async work() {
    return tracer.startActiveSpan('work-cycle', async (span) => {
      // ...
      span.setStatus({ code: SpanStatusCode.OK });
      span.end();
      return { status: 'ok' };
    });
  }
}

Metrics

Identical to the SDK guide: create a counter or histogram once at module scope, record from inside a controller method.

Verify

curl http://localhost:8080/work
up0 tail --type logs --since 15m
up0 tail --type traces --since 15m

See Verify ingestion for the full CLI walkthrough.

This guide was walked against javascript/nestjs in up0-otel-examples, built with this task (commit 19fa618 on branch docs/1643-nodejs, not yet pushed), run with tsc then node dist/main.js against a real Collector, curl driving /work and /boom. Real, token-redacted output:

$ up0 tail --type logs --since 15m
2026-09-16T13:22:43.156000Z info  1643-nodejs-nestjs-walk work cycle complete
2026-09-16T13:22:43.186000Z info  1643-nodejs-nestjs-walk work cycle complete
2026-09-16T13:22:43.204000Z error 1643-nodejs-nestjs-walk boom: deliberate example failure

$ up0 tail --type traces --since 15m
1643-nodejs-nestjs-walk request handler - /work 12850959ns Unset 5afc60fe26ab2d63898a49eb1584f975
1643-nodejs-nestjs-walk request handler - /boom 899667ns Unset 7dbdb39de0b8c85bee3366e6e050f3dd
1643-nodejs-nestjs-walk GET 13257334ns Unset 5afc60fe26ab2d63898a49eb1584f975
1643-nodejs-nestjs-walk GET 1117834ns Error 7dbdb39de0b8c85bee3366e6e050f3dd
1643-nodejs-nestjs-walk compute-batch 12226834ns Ok 5afc60fe26ab2d63898a49eb1584f975
1643-nodejs-nestjs-walk work-cycle 12316542ns Ok 5afc60fe26ab2d63898a49eb1584f975
1643-nodejs-nestjs-walk boom 449291ns Error 7dbdb39de0b8c85bee3366e6e050f3dd

$ up0 metrics ls --since 15m
metric_name             metric_type  series_count
work.cycle.duration      histogram    12
work.items.processed     counter      12

Explore filtered to the NestJS sample's service, showing the request handler and work-cycle spans and their correlated logs

Troubleshooting

The root SERVER span is named plainly GET, not GET /work. Unlike the Express guide, where the root span is named GET /work, Nest sits its own router on top of Express. By the time Nest resolves /work to AppController.work, instrumentation-http has already created and named the SERVER span from the raw, unrouted request, so no http.route reaches it either. The resolved route does appear, one level down, on the request handler - /work span instead. Match by that span, or by kind, rather than by the root span's name.

Nothing is instrumented, and nothing errors. The import-order requirement in Install above, missed. NestFactory.create() running before ./tracing is imported is exactly as broken as never importing it at all, and produces a clean startup with zero spans, because by the time the SDK patches express, @nestjs/platform-express has already required it.

Severity text is lowercase, same as the Next.js guide. Winston's own level names are lowercase (info, error); this is not an error, just a difference from the SDK and Express guides' uppercase severityText.

On this page