Guides
Guide

OpenTelemetry for Node.js: Traces, Logs, and OTLP Setup

By Sazabi Team

Last verified on Nov 8, 2026.

OpenTelemetry for Node.js has two jobs: create spans for the work your app does, and export spans and logs to a backend over OTLP. The SDK is @opentelemetry/sdk-node, and the auto-instrumentations package patches libraries like Express, http, and your logger so most spans appear without code changes. This guide shows a working setup, tested with @opentelemetry/sdk-node 0.301.0 on Node.js 24, and the mistakes that cost the most time.

Install

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

The two -otlp-proto packages send OTLP over HTTP with protobuf, which the OpenTelemetry docs list as one of the three OTLP transports and the specification names as the default protocol.

Create the SDK File

Instrumentation must be loaded before your application imports the libraries it patches. Put it in its own file:

// instrumentation.js
const { NodeSDK } = require('@opentelemetry/sdk-node')
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node')
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-proto')
const { OTLPLogExporter } = require('@opentelemetry/exporter-logs-otlp-proto')
const { BatchLogRecordProcessor } = require('@opentelemetry/sdk-logs')

const sdk = new NodeSDK({
  serviceName: process.env.OTEL_SERVICE_NAME ?? 'checkout',
  traceExporter: new OTLPTraceExporter(),
  logRecordProcessors: [new BatchLogRecordProcessor({ exporter: new OTLPLogExporter() })],
  instrumentations: [getNodeAutoInstrumentations()],
})

sdk.start()

process.on('SIGTERM', () => {
  sdk.shutdown().finally(() => process.exit(0))
})

Run your app with the file preloaded and the exporter variables set:

export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
node --require ./instrumentation.js app.js

A request to an Express route then produced one POST to /v1/traces and one to /v1/logs at a local receiver. You set only the base endpoint. For OTLP/HTTP the SDK builds the per-signal paths itself, so do not put /v1/traces in OTEL_EXPORTER_OTLP_ENDPOINT.

If you prefer no code at all, the zero-code route sets NODE_OPTIONS="--require @opentelemetry/auto-instrumentations-node/register" and configures the same things through OTEL_* variables. In testing, both routes exported traces, logs, and metrics to the receiver with the SDK's defaults, because OTEL_TRACES_EXPORTER, OTEL_METRICS_EXPORTER, and OTEL_LOGS_EXPORTER all default to otlp. A process started the zero-code way also stayed running after SIGTERM until it was killed, so supply your own exit handling in containers.

ESM Needs a Loader Hook

The OpenTelemetry Node.js docs say an ESM app, or TypeScript compiled to ESM, needs a loader hook for instrumentation to work. This matters, because the failure is silent. With an .mjs app and only --import ./instrumentation.mjs, the app started and ran, and its log lines carried no trace IDs. Adding the hook fixed it:

node --experimental-loader=@opentelemetry/instrumentation/hook.mjs \
  --import ./instrumentation.mjs app.mjs

Node prints an experimental-loader warning and suggests module.register() as the replacement. Either form produced a trace_id in testing. Keep the SDK setup in the same file shape as above, using import syntax.

Add Your Own Spans

Auto-instrumentation covers inbound HTTP, outbound calls, and drivers. Business steps need manual spans:

const { trace, SpanStatusCode } = require('@opentelemetry/api')
const tracer = trace.getTracer('checkout')

app.get('/order', (req, res) => {
  tracer.startActiveSpan('charge-card', (span) => {
    try {
      span.setAttribute('order.id', 4812)
      logger.info({ order_id: 4812 }, 'charging card')
      res.send('ok')
    } catch (err) {
      span.recordException(err)
      span.setStatus({ code: SpanStatusCode.ERROR })
      throw err
    } finally {
      span.end()
    }
  })
})

startActiveSpan makes the span current for the callback, so child spans and log lines inside attach to it. Always call span.end(), because an unended span is never exported.

Put Trace IDs in Your Logs

getNodeAutoInstrumentations() includes instrumentations for pino and winston. Inside an active span, they add the trace context to each record. The pino line above, from the span in the previous section, came out as:

{
  "level": 30,
  "time": 1794121580614,
  "pid": 72493,
  "hostname": "664d1e2a-313",
  "trace_id": "7bb04c3ee7f8356e20e195791e4cb3b2",
  "span_id": "efccfe24a2d17951",
  "trace_flags": "01",
  "order_id": 4812,
  "msg": "charging card"
}

That trace ID is the join key between the log line and the trace. Details differ by logger, and the Pino guide and Winston guide cover each. For plain console.log there is nothing to instrument, which is one reason the Node.js logging guide recommends a library. The OpenTelemetry docs also note that the JavaScript logging library is still under development, so log export behavior can change between versions.

The same instrumentation sends records to the Logs SDK when you register a log record processor, which is what the logRecordProcessors line does.

Environment Variables

VariablePurpose
OTEL_SERVICE_NAMEThe service.name resource attribute
OTEL_EXPORTER_OTLP_ENDPOINTBase URL for all signals
OTEL_EXPORTER_OTLP_PROTOCOLhttp/protobuf, http/json, or grpc
OTEL_TRACES_EXPORTER, OTEL_LOGS_EXPORTER, OTEL_METRICS_EXPORTERotlp by default, none to disable
OTEL_LOG_LEVELSDK diagnostics, such as debug

Common Mistakes

  • Passing the exporter positionally to BatchLogRecordProcessor. In @opentelemetry/sdk-logs 3.1.0, the constructor takes an options object. new BatchLogRecordProcessor(new OTLPLogExporter()) started fine, then threw Cannot read properties of undefined (reading 'shutdown') on shutdown and sent nothing. Use { exporter: ... }, which is how the pino instrumentation README shows it for SimpleLogRecordProcessor.
  • Loading instrumentation after Express or your logger. Patches apply only to modules loaded after the SDK starts. Use --require or --import so it runs first.
  • ESM without the loader hook. No errors, no spans.
  • Conflicting preloads. The docs warn not to combine --import with --require @opentelemetry/auto-instrumentations-node/register in NODE_OPTIONS.
  • Forgetting shutdown. Batched logs and spans from the last seconds are lost on exit.
  • Putting a signal path in the base endpoint. Set OTEL_EXPORTER_OTLP_ENDPOINT to the base URL only.
  • Leaving metrics on for a backend that does not take them. The SDK exported to /v1/metrics by default in testing. Set OTEL_METRICS_EXPORTER=none for logs-and-traces endpoints.

Production Tips

  • Set OTEL_SERVICE_NAME per deployable so spans and logs are grouped correctly.
  • Keep the exporter endpoint and any headers in environment variables.
  • Handle SIGTERM and SIGINT with sdk.shutdown().
  • Pair this with JSON logging and the logging best practices, and carry a correlation ID across services if some hops are not instrumented.

Sending Node.js Telemetry to Sazabi

Sazabi, an AI-native observability and security platform, takes OTLP over HTTP. Following its OpenTelemetry source docs, set OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf and OTEL_EXPORTER_OTLP_ENDPOINT to the intake URL from the log source's setup screen, then redeploy. Those docs say metrics sent to this endpoint are not stored, so set OTEL_METRICS_EXPORTER=none or use the separate metrics source, and that if traces arrive without logs you should add an explicit log record processor like the one above. Sazabi ingests OpenTelemetry metrics (Prometheus metrics through an OpenTelemetry Collector) and OTLP traces through the same intake as your logs. It stores each metric data point and each span as a log record that its agent queries alongside your logs, so there is no separate metrics store to run.

See the OpenTelemetry integration and the traces integration for what you can do once spans arrive, and Instrument traces in the Sazabi docs for a walkthrough.

Frequently Asked Questions

How do I add OpenTelemetry to a Node.js app?

Install @opentelemetry/sdk-node and @opentelemetry/auto-instrumentations-node, create a NodeSDK with getNodeAutoInstrumentations, start it in a file loaded before your app with node --require, and set the OTEL_EXPORTER_OTLP_ENDPOINT environment variable.

Why do I see no spans in my ESM Node.js app?

Auto-instrumentation patches modules as they load, and ESM needs a loader hook for that. Start Node with the @opentelemetry/instrumentation hook, or register it with module.register, in addition to loading your setup file.

How do I get trace IDs into Node.js logs?

Use a logger that OpenTelemetry instruments, such as pino or winston. The instrumentation adds trace_id, span_id, and trace_flags to every record logged inside an active span.

Do I need to call sdk.shutdown?

Yes, on exit. The SDK batches telemetry, and shutdown flushes whatever is pending, so call it from your SIGTERM handler before the process exits.

Sources

  1. NodeSDK with getNodeAutoInstrumentations, loading instrumentation before application code with the --import flag, the ESM loader hook requirement, and that the JavaScript logging library status is still under development (accessed Nov 8, 2026)
  2. Zero-code setup with @opentelemetry/auto-instrumentations-node/register, OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT, NODE_OPTIONS, and OTEL_LOG_LEVEL (accessed Nov 8, 2026)
  3. OTLP exporters over HTTP/protobuf, installed as @opentelemetry/exporter-trace-otlp-proto and related packages (accessed Nov 8, 2026)
  4. For OTLP/HTTP the SDK constructs signal-specific URLs such as /v1/traces from OTEL_EXPORTER_OTLP_ENDPOINT (accessed Nov 8, 2026)
  5. OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_PROTOCOL, and the OTEL_TRACES_EXPORTER, OTEL_METRICS_EXPORTER, and OTEL_LOGS_EXPORTER variables, which default to otlp and accept none (accessed Nov 8, 2026)
  6. The Pino instrumentation injects trace_id, span_id, and trace_flags into log records and can send them to the Logs SDK with logRecordProcessors (accessed Nov 8, 2026)
  7. Sazabi accepts OTLP/HTTP logs and traces at an intake URL set through OTEL_EXPORTER_OTLP_ENDPOINT with the http/protobuf protocol, does not store metrics from that endpoint, and recommends an explicit log record processor when traces arrive without logs (accessed Nov 8, 2026)