Node.js tracing with OpenTelemetry: automatic instrumentation and custom spans
Initialize OpenTelemetry before a Node.js app, configure OTLP over HTTP, add business spans, and verify async context, shutdown, and Guance trace correlation.
OpenTelemetry tracing in Node.js usually starts with NodeSDK and the Node automatic-instrumentation bundle. Supported instrumentation can create spans for HTTP servers, frameworks, and database clients without changing each request handler. Business operations still need custom spans when a framework-level view does not explain the work.
This guide configures a CommonJS application to send traces through an OpenTelemetry Collector, then adds a custom checkout span and verifies the result in Guance. Automatic coverage depends on library versions, module loading, and instrumentation support. It is not a guarantee that every dependency or asynchronous operation appears automatically.
This guide uses a documentation-based example; it does not report a production deployment or performance benchmark.
Load tracing before the application
Install compatible OpenTelemetry packages in the application project and keep the resolved versions in its lockfile:
npm install @opentelemetry/api @opentelemetry/sdk-node \
@opentelemetry/auto-instrumentations-node \
@opentelemetry/exporter-trace-otlp-http @opentelemetry/resources
Use a supported Node.js version that meets the packages' engine requirements. The following file uses the current resourceFromAttributes API. Older examples that construct new Resource(...) refer to a different resources API version. See OpenTelemetry JavaScript resources.
Save this as tracing.cjs:
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { resourceFromAttributes } = require('@opentelemetry/resources');
const sdk = new NodeSDK({
resource: resourceFromAttributes({ 'service.name': 'api-gateway' }),
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT
|| 'http://127.0.0.1:4318/v1/traces',
}),
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
module.exports = sdk;
Start a CommonJS application with:
node --require ./tracing.cjs app.cjs
The preload must run before instrumented application libraries are imported. Otherwise an instrumentation may miss the module it needs to patch. For an ESM application, follow the SDK's documented ESM loader setup; renaming an application file is not a substitute. The Node.js getting-started guide explains initialization order and module-system requirements.
Keep service.name stable across instances of the same service. Use separate resource attributes for instance identity and deployment context. The default URL above assumes the Collector is reachable on the application's loopback interface; inside a separate container, set the endpoint to the Collector's reachable hostname instead.
Keep OTLP protocols consistent through the Collector
The intended path is:
Node.js OTLP/HTTP exporter
-> Collector OTLP/HTTP receiver on port 4318
-> configured DataKit OTLP/HTTP trace endpoint
-> Guance
A Collector receiving OTLP over HTTP can forward it with an HTTP exporter:
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
processors:
batch:
timeout: 5s
exporters:
otlp_http/datakit:
traces_endpoint: ${env:DATAKIT_OTLP_HTTP_TRACES_ENDPOINT}
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [otlp_http/datakit]
Set DATAKIT_OTLP_HTTP_TRACES_ENDPOINT in the Collector process to the complete trace URL for the enabled DataKit receiver. Obtain the scheme, listening address, port, and path from that deployment's configuration. A standard Collector port does not establish DataKit's listening configuration. Confirm it with the DataKit OpenTelemetry integration.
Current Collector documentation uses otlp_http; older distributions may expose the same HTTP exporter as otlphttp. Check the components in the installed distribution. An otlp or otlp_grpc exporter denotes a gRPC path in relevant versions and cannot be made into an HTTP exporter merely by pointing it at port 4318. The exporter name in the pipeline must match the configured component. See Collector configuration.
Binding to 0.0.0.0 permits remote connections on the container or host interfaces. Limit access to intended senders and configure the TLS and authentication required by the environment. Batching reduces request overhead; it does not by itself provide durable buffering or unlimited outage tolerance.
Direct export to DataKit is another option when its enabled receiver supports the selected exporter. In that design, point the application at DataKit and omit the intermediate Collector. Keep the two topologies distinct while troubleshooting.
Customize automatic instrumentation
Replace the instrumentations value in the SDK configuration with this array if DNS spans are unnecessary and the service should exclude its health endpoint:
const instrumentations = [getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-dns': { enabled: false },
'@opentelemetry/instrumentation-http': {
requestHook: (span, request) => {
const value = request.headers?.['x-tenant-id']
?? request.getHeader?.('x-tenant-id');
if (typeof value === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(value)) {
span.setAttribute('app.tenant', value);
}
},
ignoreIncomingRequestHook: (request) =>
(request.url || '').split('?')[0] === '/health',
},
})];
Assign this variable before constructing NodeSDK, then use instrumentations in its options instead of the earlier inline value. The request hook handles incoming and outgoing request objects without assuming both expose the same header API. The tenant value is bounded correlation data, not a trusted authorization identity. Decide whether it belongs in telemetry at all, and avoid collecting arbitrary headers.
Ignoring a health request changes trace coverage. Verify the expected behavior in the HTTP instrumentation version in use, including whether other instrumentations still create work for that route. Configure only the libraries the application needs, then inspect actual output rather than assuming the bundle instruments every installed dependency.
Add a span around a business operation
Automatic instrumentation can show an HTTP call or database operation. A custom span can explain why the application made those calls. This function accepts its application dependencies explicitly:
const { trace, SpanStatusCode } = require('@opentelemetry/api');
const tracer = trace.getTracer('checkout-service');
async function checkout(cartId, items, validateInventory, chargePayment) {
return tracer.startActiveSpan('checkout.process', async (span) => {
try {
span.setAttribute('cart.id', String(cartId));
span.setAttribute('cart.item_count', items.length);
await validateInventory(items);
const result = await chargePayment(cartId, items);
span.setStatus({ code: SpanStatusCode.OK });
return result;
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
span.recordException(error);
span.setStatus({
code: SpanStatusCode.ERROR,
message: error.message,
});
throw err;
} finally {
span.end();
}
});
}
items is an array supplied by the application; the two function arguments perform the inventory and payment operations. The function preserves the payment result and propagates failures to its caller. Decide whether cart identifiers and exception messages are appropriate for the telemetry retention policy.
startActiveSpan makes this span active while the callback runs, allowing supported downstream instrumentation to create children. The callback must still end the span. Recording an exception and setting error status are separate operations; the example does both. The finally block ends the span after either success or failure.
Preserve asynchronous context and flush at shutdown
Supported Node.js context management carries context through many Promise and async/await paths. An older callback API, custom asynchronous abstraction, worker thread, or process boundary can need additional handling. Capture and bind the intended context at the scheduling boundary where necessary. Wrapping a later callback in the wrong active context cannot recover the original parent.
For a message queue, verify that the producer injects context and the consumer extracts it. Some messaging instrumentations do this for supported libraries; otherwise the application must implement propagation. A shared request ID does not by itself establish a parent-child span relationship. See context propagation (Chinese).
At shutdown, stop accepting work, let in-flight requests finish within the application's deadline, and then await the SDK shutdown:
const sdk = require('./tracing.cjs');
async function stopTelemetry() {
try {
await sdk.shutdown();
} catch (error) {
console.error('Telemetry shutdown failed', error);
process.exitCode = 1;
}
}
Call stopTelemetry from the application's existing shutdown sequence. Registering a SIGTERM listener changes Node's default termination handling; a listener that only calls sdk.shutdown() can leave an HTTP server running. Coordinate server closure and telemetry flushing in one lifecycle rather than installing competing signal handlers. Abrupt termination can still lose buffered spans.
Verify one complete request in Guance
Send a request containing a Promise-based operation, a downstream HTTP call, and the custom checkout span. In Guance trace details, inspect the service name, trace ID, parent span IDs, timing, and attributes. Then trigger a controlled error and check both the exception event and error status.
If the trace is missing, work along the path: SDK initialization, supported library loading, sampling decision, application exporter, Collector receiver and exporter, DataKit receiver, and the backend time range. A successful application response does not prove telemetry delivery.
For log correlation, record the active trace identifier in application logs and collect those logs too. Verify that both signals use the same ID representation and overlapping retention. An ordinary business request ID cannot substitute for the trace ID.
Common questions
How much overhead does automatic instrumentation add?
There is no universal five-percent ceiling. Measure request latency, CPU, memory, span rate, and exporter behavior under representative traffic. Frequent short operations can create substantial volume. Disable unnecessary instrumentation or adjust sampling only after checking the effect on diagnostic coverage.
Does this work in Next.js or serverless deployments?
Those environments can support OpenTelemetry, but their runtime, framework hooks, bundling, and shutdown behavior differ from this CommonJS server. Follow the platform's supported integration. Avoid initializing a second SDK when the framework already owns one, and test cold starts and export completion before the runtime freezes or ends an invocation.
Why do spans appear as separate traces?
Check whether context was active when work began and whether propagation crossed the network or queue boundary. Ensure downstream work occurs inside the startActiveSpan callback. Also inspect sampling and failed exports: missing spans can look like broken propagation even when identifiers were generated correctly.
How can tracing be debugged locally?
Use ConsoleSpanExporter from @opentelemetry/sdk-trace-base as the SDK's traceExporter, or send OTLP to a local backend with a compatible receiver. First confirm span creation and parent IDs, then restore the network export path. Console output establishes local instrumentation behavior, not delivery to Guance.
Related guides
Contact us
Join the community
to join the community
Try Guance
Start online and pay only for what you use.
Get startedChoose a Guance plan