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,logsandmetricsscoped.
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 winstonSame 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:
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
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-serviceEndpoint 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:
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.
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:
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 15mSee 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
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.