A working OpenTelemetry Node.js setup is four packages, one bootstrap file and a startup flag. Install @opentelemetry/sdk-node, @opentelemetry/api, @opentelemetry/auto-instrumentations-node and an OTLP exporter, start the SDK from a file that loads before your application with node --import ./instrumentation.mjs server.js, then point OTEL_EXPORTER_OTLP_ENDPOINT at a collector. If your app is ESM, that will silently produce nothing.
Why an ESM app exports no spans
Automatic instrumentation works by patching modules as they load. Under CommonJS that is a require hook, and it takes effect the moment your bootstrap file has run, which is why the CommonJS instructions in every tutorial work first time.
ESM imports resolve through a different mechanism. Patching them needs a loader hook registered in the resolution chain before the first import of express, pg or undici is evaluated. --import alone does not register that hook. Your SDK starts, the exporter connects, the collector reports a healthy session, and every request produces zero spans.
OpenTelemetry’s own ESM support document is blunt about it: the loader hook is required, and the only supported one is @opentelemetry/instrumentation/hook.mjs. So the ESM start line carries two flags, not one.
node \
--experimental-loader=@opentelemetry/instrumentation/hook.mjs \
--import ./instrumentation.mjs \
server.js
CommonJS needs neither flag, just the preload:
node --require ./instrumentation.js server.js
Now the awkward part. Node’s CLI documentation says --experimental-loader “is discouraged and may be removed in a future version of Node.js”, and points at --import with register() instead. But module.register() was itself deprecated in Node v25.9.0, with a runtime deprecation (DEP0205) arriving in v26.0.0, in favour of the synchronous module.registerHooks(). Three APIs, each pointing at the next one.
As of August 2026 the discouraged flag is still the documented path and it still works on Node 24, the current Active LTS. Use it, put it in NODE_OPTIONS rather than scattering it across Dockerfiles and process managers, and expect to rewrite that line inside a year.
What a minimal OpenTelemetry Node.js setup installs
npm install @opentelemetry/api @opentelemetry/sdk-node \
@opentelemetry/auto-instrumentations-node \
@opentelemetry/exporter-trace-otlp-http
@opentelemetry/api is the only one your application code imports directly, and it is deliberately tiny: if no SDK has registered a provider, every call becomes a no-op instead of a crash. That is what makes it safe to add spans to a shared library.
sdk-node wires up the providers, processors and resource detection. auto-instrumentations-node is a bundle of roughly forty instrumentations for HTTP, the popular frameworks, the database drivers and the AWS SDK. The exporter package speaks OTLP over HTTP; there is a gRPC variant if your collector prefers it.
The version numbers surprise people. At the time of writing, the stable packages sit on 2.x (@opentelemetry/sdk-trace-node is 2.10.0, @opentelemetry/api is 1.9.1) while @opentelemetry/sdk-node is on 0.221.0 and auto-instrumentations-node on 0.79.0. That is not a quality signal. The JS SDK 2.0 release kept packages that expose not-yet-stable signals on a parallel 0.2xx line, and both lines ship together. SDK 2.x requires Node ^18.19.0 || >=20.6.0, so it will not install on Node 16.
The bootstrap file worth copying
// instrumentation.mjs
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { resourceFromAttributes } from '@opentelemetry/resources';
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
} from '@opentelemetry/semantic-conventions';
const sdk = new NodeSDK({
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: 'checkout-api',
[ATTR_SERVICE_VERSION]: process.env.GIT_SHA ?? 'dev',
}),
traceExporter: new OTLPTraceExporter(),
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-fs': { enabled: false },
}),
],
});
sdk.start();
process.on('SIGTERM', () => {
sdk.shutdown().finally(() => process.exit(0));
});
Three decisions in there are worth defending. Filesystem instrumentation is off because it produces a span for every readFile your template engine does, which buries the spans you care about and costs real CPU. The SIGTERM handler exists because spans leave in batches; without a shutdown, the last few seconds of traces before a deploy or a scale-in are lost, and those are exactly the ones you want when a rollout goes wrong.
The resource block is the third. resourceFromAttributes() replaced the old new Resource() constructor in 2.0, and a service without a service.name shows up in your backend as unknown_service, which turns a trace list into a guessing game the first time two services report at once.
Which environment variables actually change behaviour
Four carry real weight. OTEL_SERVICE_NAME sets service.name without touching code, which matters when the same image runs as an API and a worker. OTEL_EXPORTER_OTLP_ENDPOINT is the collector address, and the exporter picks it up with no arguments passed. OTEL_NODE_DISABLED_INSTRUMENTATIONS takes a comma-separated list of names with the @opentelemetry/instrumentation- prefix dropped, so fs,dns is valid, which lets you silence a noisy instrumentation without a redeploy.
The fourth is sampling, below.
Two more are worth knowing about and not worth setting permanently. OTEL_LOG_LEVEL=debug is how you find out why nothing arrives, and it is loud enough to hurt throughput. OTEL_NODE_ENABLED_INSTRUMENTATIONS switches to an allow-list, which is a reasonable posture for a service where you know exactly which four libraries you want traced.
What sampling rate should you run in production
Start at 100% and drop it when the bill or the CPU tells you to, not before. When you do, use head sampling through the environment rather than code:
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1
The parentbased_ prefix does the useful work. A service that receives a request already carrying a sampling decision honours it, so a single trace does not end up half recorded across five hops. The ratio only applies at the point a trace starts.
Head sampling has an obvious flaw: it throws away 90% of traces before knowing which ones failed. The fix is not a higher rate, it is tail sampling in the collector, where the decision waits until the spans are in and can keep everything with an error or a slow root span. That is collector configuration, not application code, and it is the reason to run a collector rather than exporting straight to a vendor.
Adding your own spans
Automatic instrumentation gives you the boundaries. Your own code is where the time actually goes.
import { trace, SpanStatusCode } from '@opentelemetry/api';
const tracer = trace.getTracer('checkout');
export async function repriceBasket(basket) {
return tracer.startActiveSpan('reprice-basket', async (span) => {
span.setAttribute('basket.lines', basket.lines.length);
try {
return await applyPromotions(basket);
} catch (err) {
span.recordException(err);
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
throw err;
} finally {
span.end();
}
});
}
startActiveSpan sets the span as the active context for the duration of the callback, so anything the HTTP or database instrumentation records inside it nests underneath automatically. startSpan does not do that, and a flat list of unparented spans is usually someone reaching for the wrong one.
Keep attribute cardinality in check. A basket line count is fine. A basket ID on every span will make your backend expensive and your dashboards useless.
Where the Node SDK will not follow you
Not to the edge. The Node SDK instruments by patching modules in a Node process, so Cloudflare Workers, Deno Deploy and similar runtimes need their own tracing story rather than this one. If you are weighing that trade-off, our comparison of Cloudflare’s two deployment models covers what you give up on the Workers runtime.
Short-lived functions are the other gap. A Lambda that returns before the batch processor flushes loses its spans, and the SIGTERM handler above does not save you because the process is frozen rather than signalled. Use the vendor’s Lambda layer there.
One more, if you compile TypeScript: the ESM loader hook cannot instrument uncompiled TypeScript run through ts-node. Node’s own type stripping sidesteps some of this, and we wrote up how to run TypeScript in Node without a loader separately, but confirm your build output is what you think it is before you spend an afternoon on the loader flag.
What I would actually do: start with the zero-code register entrypoint and a local collector, so --require @opentelemetry/auto-instrumentations-node/register on CommonJS, or that same package after --import with the loader flag on ESM. Spend twenty minutes confirming spans arrive. Move to a bootstrap file only once you need a resource block, a custom sampler or your own spans. Do not reach for it at all on a single service with one database and good logs; the payoff starts when a request crosses three processes and nobody can say which one is slow.