OpenTelemetry — Node.js
SDK Initialization
typescript// otel.ts — import before everything else import { NodeSDK } from "@opentelemetry/sdk-node"; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base"; import { Resource } from "@opentelemetry/resources"; import { SEMRESATTRS_SERVICE_NAME } from "@opentelemetry/semantic-conventions"; const exporter = new OTLPTraceExporter({ url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT + "/v1/traces", }); export const sdk = new NodeSDK({ resource: new Resource({ [SEMRESATTRS_SERVICE_NAME]: process.env.SERVICE_NAME ?? "my-service", }), spanProcessor: new BatchSpanProcessor(exporter), }); sdk.start(); process.on("SIGTERM", () => sdk.shutdown());
Load before the app entry: node --import ./otel.js server.js or tsx --import ./otel.ts server.ts.
Auto-instrumentation packages
Add only what you need — each package patches a specific module:
bashpnpm add @opentelemetry/instrumentation-http # node:http / node:https pnpm add @opentelemetry/instrumentation-express # Express routing spans pnpm add @opentelemetry/instrumentation-pg # postgres queries pnpm add @opentelemetry/instrumentation-ioredis # Redis commands
Register in SDK init:
typescriptimport { HttpInstrumentation } from "@opentelemetry/instrumentation-http"; import { ExpressInstrumentation } from "@opentelemetry/instrumentation-express"; export const sdk = new NodeSDK({ instrumentations: [new HttpInstrumentation(), new ExpressInstrumentation()], // ... });
Hono: no official instrumentation package. Use manual middleware (see below).
esbuild ESM — Silent Auto-Instrumentation Failure
Symptom
App starts normally, SDK init log appears, but no spans arrive at the collector. No errors. Happens when:
- Output format is
--format=esm - Using
@opentelemetry/instrumentation-*auto-instrumentation - Bundler is esbuild (also Vite / SWC — same root cause)
Root cause
@opentelemetry/instrumentation-* uses require-in-the-middle to hook require() and monkey-patch target modules (node:http etc.). In an esbuild ESM bundle, import is resolved statically at bundle time — require() is never called at runtime → hook never fires → no patches → no spans. No error is raised (silent failure).
Fix A — Manual spans (recommended for Hono/esbuild)
Write a middleware that creates spans explicitly:
typescriptimport { context, propagation, SpanKind, SpanStatusCode, trace } from "@opentelemetry/api"; const tracer = trace.getTracer("app"); app.use(async (c, next) => { const ctx = propagation.extract(context.active(), c.req.raw.headers); const route = new URL(c.req.url).pathname; await tracer.startActiveSpan( `${c.req.method} ${route}`, { kind: SpanKind.SERVER, attributes: { "http.method": c.req.method, "http.route": route } }, ctx, async (span) => { try { await next(); span.setAttribute("http.status_code", c.res.status); span.setStatus({ code: SpanStatusCode.OK }); } catch (e: any) { span.recordException(e); span.setStatus({ code: SpanStatusCode.ERROR, message: e?.message }); throw e; } finally { span.end(); } } ); });
Propagate context to outgoing gRPC calls via metadata:
typescriptconst callUnary = (method, req) => { const metadata = new grpc.Metadata(); propagation.inject(context.active(), metadata, { set: (carrier, key, value) => (carrier as grpc.Metadata).set(key, value), }); return new Promise((resolve, reject) => client[method](req, metadata, (err, res) => (err ? reject(err) : resolve(res))) ); };
Fix B — Switch to CJS output
Change esbuild to --format=cjs. require() hook fires normally. Works when no ESM-only dependencies are present. Hono supports CJS.
Fix C — Run unbundled
Use tsx or node --loader ts-node/esm + --import @opentelemetry/auto-instrumentations-node/register. Skip bundling for the Node process. Increases container image size; not recommended for production.
Verifying the Pipeline
Add a debug exporter to the OTel Collector temporarily:
yamlexporters: debug: verbosity: detailed service: pipelines: traces: receivers: [otlp] exporters: [otlphttp/tempo, debug]
Collector logs show Trace ID: ... Name: GET /api/users when spans arrive. No output = broken before the collector.
Related
opentelemetry— signal design, span naming, sampling, W3C propagationcloudflare-workers-otel-utels— Cloudflare Workers telemetry (no Node runtime, fetch-boundary approach)- esbuild + Vite + SWC all share the same
require-in-the-middlefailure mode instrumentation-*packages do not support static import patching as of 2026-05

