Introduction
Node.js tracing with OpenTelemetry gives a service a shared language for understanding how requests move through code, dependencies, and other services. Instead of guessing which database query or external API call caused a delay, a trace lets you inspect a sequence of timed operations and see where work happens.
A trace is not a replacement for logs or metrics. Logs explain individual events, metrics summarize system behavior, and traces connect those events across request boundaries. Together, they provide a much clearer picture of application health. OpenTelemetry focuses on the instrumentation and export standards, while the selected backend decides where the trace data is stored, searched, and visualized.
This guide shows how to build a practical tracing setup for an Express-based Node.js service. It covers the core terminology, the runtime data flow, automatic and manual instrumentation, sampling, exporters, production deployment, and the mistakes that commonly prevent useful spans from appearing.
Table of Contents
- Core Concepts
- Architecture Overview
- Step-by-Step Guide
- Real-World Examples
- Production Code Examples
- Comparison Table
- Best Practices
- Common Mistakes
- Performance Tips
- Security Considerations
- Deployment Notes
- Debugging Tips
- FAQ
- Conclusion
Core Concepts
A trace is the complete record of work related to one request or job. In a distributed application, that record can cross an API gateway, an authentication service, a database, a queue, and a payment provider. Each participating component contributes information, and the trace ID connects the contributions into one navigable timeline.
A span is the fundamental unit of work inside a trace. An HTTP request might produce a server span, a database span, a cache span, and a business-operation span. Spans have a name, start time, duration, status, attributes, events, and a relationship to a parent span. A parent and child relationship shows that one operation caused or contained another operation.
Attributes are key-value details attached to a span. Examples include an HTTP method, a route template, a database name, a cache key namespace, or a job type. Keep attributes stable and useful for filtering. Avoid putting unrestricted user input, access tokens, full request bodies, email addresses, or large IDs into attributes because those values can create high-cardinality data and expose sensitive information.
Events are timestamped notes inside a span. A span can record that a message was accepted, a retry was attempted, or an external response was received. Events are useful when one operation contains several meaningful milestones, but they should not be used as a substitute for separate spans when each milestone is independently useful to investigate.
Context propagation is what allows a child span to remain connected to its parent across service boundaries. Framework instrumentations usually propagate context automatically for supported HTTP and message-queue libraries. Code that starts a span in a new thread, worker, or asynchronous boundary must preserve the active context. Without context propagation, the trace may break and make dependencies look unrelated.
Sampling determines which traces are retained. A sampler can keep every trace, keep only a percentage, or use a more selective strategy. Sampling reduces export and storage cost, but an aggressive sample rate can hide rare failures. Many teams retain all traces containing an error status while sampling successful traffic at a lower rate.
An exporter sends spans from the OpenTelemetry SDK to a collector or telemetry backend. The collector can batch, transform, enrich, and forward data. OpenTelemetry defines the instrumentation and data model; it does not require a particular vendor or storage product. This separation makes it possible to change an observability backend without rewriting every application instrumentation package.
Architecture Overview
A typical Node.js tracing architecture looks like this:
HTTP client or job -> Express application -> OpenTelemetry API and instrumentations -> NodeSDK and span processor -> OTLP trace exporter -> Collector -> Trace backend or analysis platformThe application creates spans through the OpenTelemetry API. Instrumentation packages can create spans automatically for supported frameworks and libraries. The NodeSDK coordinates resources, processors, exporters, and instrumentations. A batch processor collects spans in memory and exports them in groups instead of making a network request for every span.
The collector sits between applications and the final backend. It can accept traces from many services, apply sampling or transformation policies, and forward data to one or more destinations. Running a collector close to an application can reduce network complexity, while a centralized collector can provide consistent policies across a deployment.
Each service should have a stable service name. The name identifies the logical application rather than an individual container or pod. Adding environment, region, or version as separate attributes can help with filtering without turning the service name into a rapidly changing value.
Step-by-Step Guide
Start with one service and one exporter. A small working setup is easier to validate than a large configuration applied across dozens of services.
- Install the required packages. The NodeSDK, an OTLP trace exporter, a resource package, semantic conventions, and the Express instrumentation are the main pieces for this example.
npm install @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-http @opentelemetry/instrumentation-express @opentelemetry/resources @opentelemetry/semantic-conventions- Configure the export endpoint. Point the exporter at a local collector during development or at a collector endpoint in the deployment environment. Keep the endpoint in environment variables so the application code does not contain environment-specific values.
OTEL_SERVICE_NAME=checkout-apiOTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://localhost:4318/v1/traces- Create the SDK bootstrap. Create one NodeSDK instance during application startup, assign a service name, add the Express instrumentation, and configure a batch processor. Start the SDK before loading the application module that imports Express.
- Instrument the framework. The Express instrumentation records HTTP server spans and common request details. Do not also create a manual span for every request unless there is a specific business reason to do so. Double instrumentation creates duplicate spans and confusing timelines.
- Add business spans. Start a span around operations that are important to the domain, such as payment authorization, order validation, or a message-processing step. End the span in a finally block so it closes when the operation succeeds or fails.
- Choose a sampling policy. Begin with a simple configuration that preserves failed traces and samples successful traces. Adjust the policy after reviewing trace volume, storage cost, and the frequency of the incidents you need to investigate.
- Generate a controlled request. Send one request to the service and confirm that a trace appears with the expected service name, HTTP span, and child spans. Test a successful response and a deliberate error before enabling broader sampling.
- Review the data before expanding. Check span names, attribute cardinality, export failures, and context continuity. Fix these fundamentals before adding tracing to every route and dependency.
Real-World Examples
Consider an e-commerce checkout request. The HTTP server span enters the application, then a validation span runs, a payment authorization span calls an external provider, and a database span records the order write. The trace makes it possible to see whether the delay happened in validation, the provider timeout, the database, or the network between services.
Authentication provides another useful example. An authorization request may include an HTTP span, a token-validation span, a user-profile lookup, and a cache lookup. If a cached response is missing, the trace can show the database dependency and the time spent resolving the user. Attributes should describe the authorization outcome and cache state without storing the token itself.
Asynchronous work needs slightly different thinking. A queue consumer can create a consumer span, process a job, and record retry events. If the consumer runs in a worker process, context must be propagated from the message envelope to the job handler. The trace should show the producer and consumer relationship when the messaging library supports it, while still remaining understandable when only the consumer span is available.
These examples are valuable because they connect technical delay to a business operation. A trace that only shows framework calls can confirm that a request reached an application, but a trace with meaningful business spans helps a developer understand what the application was trying to accomplish.
Production Code Examples
The following bootstrap uses CommonJS for a straightforward Express deployment. It creates one exporter, reuses it for the batch processor, and starts the SDK before requiring the application module. In an ESM project, the same responsibilities can be arranged with static imports and top-level configuration, but the lifecycle principle remains the same.
'use strict';require('dotenv/config');const { NodeSDK } = require('@opentelemetry/sdk-node');const { Resource } = require('@opentelemetry/resources');const { ATTR_SERVICE_NAME } = require('@opentelemetry/semantic-conventions');const { ExpressInstrumentation } = require('@opentelemetry/instrumentation-express');const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');const { BatchSpanProcessor } = require('@opentelemetry/sdk-trace-node');const endpoint = process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT || 'http://localhost:4318/v1/traces';const traceExporter = new OTLPTraceExporter({ url: endpoint });const sdk = new NodeSDK({ resource: new Resource({ [ATTR_SERVICE_NAME]: process.env.SERVICE_NAME || 'checkout-api' }), traceExporter, spanProcessor: new BatchSpanProcessor(traceExporter), instrumentations: [new ExpressInstrumentation()]});sdk.start();const { app } = require('./app.js');module.exports = { app };The endpoint defaults to a local OTLP HTTP listener so the code can run during development. In a container, localhost normally refers to that same container, not a separate collector. Use a service DNS name or a collector sidecar when the collector runs in another container.
The application below adds a business span for payment charging. The span receives the active context, records a meaningful event, and reports an error status when the operation fails. The HTTP instrumentation remains responsible for the request span, while this manual span represents the domain operation that matters to the service owner.
const express = require('express');const { context, trace, SpanStatusCode } = require('@opentelemetry/api');const app = express();app.use(express.json());const tracer = trace.getTracer('checkout-api');async function chargeCustomer(input) { const span = tracer.startSpan('payment.charge', { attributes: { 'checkout.id': String(input.checkoutId) } }); try { return await context.with(context.active(), async () => { span.addEvent('payment.authorized', { 'payment.amount': String(input.amount) }); return 'paid'; }); } catch (error) { span.recordException(error); span.setStatus({ code: SpanStatusCode.ERROR, message: String(error && error.message || 'Unknown payment error') }); throw error; } finally { span.end(); }}app.post('/checkout', async (request, response, next) => { try { const result = await chargeCustomer(request.body); response.json({ status: result }); } catch (error) { next(error); }});app.get('/health', (request, response) => { response.json({ status: 'ok' });});app.use((error, request, response, next) => { console.error(error); response.status(500).json({ error: 'Internal server error' });});module.exports = { app, chargeCustomer };The example intentionally keeps span attributes small. A checkout ID can be useful for correlation, but a full customer record should not be copied into every span. If a downstream library creates noisy spans, configure its instrumentation rather than adding another layer of manual spans around the same work.
Comparison Table
| Approach | Best use | Benefit | Trade-off |
|---|---|---|---|
| Automatic framework instrumentation | HTTP requests, supported client calls, and common runtime behavior | Fast rollout with less application-specific code | Less control over custom business meaning |
| Manual domain spans | Payment, validation, job processing, and cross-cutting operations | Clear ownership and useful timeline milestones | Requires careful lifecycle and context handling |
| Log correlation | Quickly connecting a trace ID to detailed event output | Simple to add and useful during initial triage | Does not replace a structured span graph |
| Backend metrics | Trend analysis, capacity planning, and alerting | Compact summaries across many requests | Cannot reconstruct one request path by itself |
No single approach answers every observability question. Automatic instrumentation gives broad coverage, manual spans add domain context, logs provide detail, and metrics expose trends. A balanced setup avoids treating tracing as the only signal.
Best Practices
- Use stable names. Name services and spans after the responsibility they represent. Prefer names such as payment.authorize over names that include a customer ID or request UUID.
- Follow semantic conventions. Reuse established attribute names when they fit. Consistent names make dashboards, queries, and cross-team investigations easier.
- Keep attributes bounded. Use short strings and numeric values. Do not attach request bodies, tokens, cookies, full URLs with query strings, or unbounded arrays.
- End every span. Use a finally block or an instrumentation helper so spans close during exceptions, cancellation, and unexpected control flow.
- Preserve context. Test spans across HTTP, message, worker, and asynchronous boundaries. A missing parent is often a propagation problem rather than an exporter problem.
- Sample deliberately. Record the reason for sampling decisions and review them as traffic patterns change. Rare failures may require a different policy from routine traffic.
- Test the pipeline. Add a small health or diagnostic endpoint that generates a known span, then verify that the span reaches the collector and backend.
Common Mistakes
Installing the OpenTelemetry API alone does not create or export spans. The API provides the interfaces, but the SDK, processor, exporter, and instrumentations must be configured and started. A common symptom is an application that runs normally while no trace data appears.
Another frequent mistake is creating a span manually and forgetting to end it. An unclosed span can remain open indefinitely, distort durations, and retain memory. The same problem occurs when a span is ended twice. Centralizing span creation and cleanup in a small helper can reduce these errors.
Double instrumentation is equally costly. If Express instrumentation already records a request span, wrapping the entire route handler in another HTTP span creates duplicate request information. Use manual spans for domain work that automatic instrumentation cannot express well.
High-cardinality attributes can make a backend slow and expensive even when the application itself is healthy. A route parameter, user ID, session ID, or arbitrary message payload should usually be excluded from attributes. Store it in a secure log field or correlation identifier only when the downstream system is designed to handle that data.
Performance Tips
Batching is one of the most important performance settings. Exporting every span immediately creates a network request for each operation and can add latency to the request path. A batch processor collects spans and sends them in groups, subject to the exporter and processor configuration.
Avoid putting slow or blocking telemetry work inside hot request handlers. The exporter should run outside the critical path wherever the SDK configuration allows it. Do not serialize large objects for attributes, generate traces synchronously for every internal function, or perform network calls directly from span event creation.
Sampling reduces both CPU and network work, but it should be evaluated with incident needs in mind. Retaining all error spans can make failures easier to investigate, while a lower success-rate sample controls volume. Monitor exporter queue pressure, export latency, and dropped-span indicators rather than assuming that a silent configuration is healthy.
Finally, review the number of spans per request. More spans are not automatically better. A request with a few meaningful spans and useful attributes is usually easier to analyze than a request with thousands of low-value spans.
Security Considerations
Trace data can contain sensitive information even when it does not look like a database export. URLs may include query parameters, headers may contain authorization values, and business spans may include customer identifiers. Redact secrets before they reach an attribute, event, log, or exporter.
Use TLS for telemetry traffic in non-local environments. Treat the collector endpoint as a sensitive integration point and restrict network access with authentication, authorization, private networking, or gateway policies. Do not expose an unauthenticated telemetry endpoint to the public internet.
Sampling and retention policies should follow the same data-minimization principles as application logs. Define which attributes are permitted, which values must be redacted, and how long trace data is retained. If a backend supports custom processors or field removal, apply those controls before data leaves the service whenever possible.
Deployment Notes
For a containerized service, pass the service name and exporter endpoint through environment variables or a secure configuration system. A collector running in the same container requires a localhost endpoint, while a collector in a separate container requires a resolvable service name. Build the endpoint into the deployment configuration rather than hard-coding it into the application image.
Plan for graceful shutdown. When a process receives a termination signal, allow the SDK and batch processor enough time to flush outstanding spans. The exact shutdown window depends on the processor and backend, so test it with a known span rather than relying on an arbitrary timeout.
Version the instrumentation packages with the application. Upgrade them in a controlled release, generate a test trace, and compare the resulting span names and attributes. If a collector or backend is shared by multiple services, coordinate upgrades and document compatibility requirements.
Debugging Tips
When no trace appears, verify the bootstrap order, exporter endpoint, package versions, and process logs. A local console exporter can be useful temporarily because it makes span creation visible without depending on a collector. Replace it with the production exporter after confirming that spans are generated.
When a trace is incomplete, inspect the parent and child relationships. A request may reach the application but stop at a database client, an HTTP client, or a message consumer. Check whether the relevant instrumentation package is installed and supported by the library version in use.
When durations look incorrect, compare span start times, end times, and parent relationships. Clock differences between hosts can affect cross-service comparisons, so use synchronized hosts and understand the time model of the selected backend. A span duration should normally represent the work captured by that span, not the total time spent in unrelated logging or serialization.
For context failures, add a known parent span around the boundary and verify that the child span appears beneath it. Test the same path with a simple response and then with a real dependency. This isolates propagation problems from exporter or sampling problems.
FAQ
What is the difference between OpenTelemetry and an APM platform?
OpenTelemetry provides vendor-neutral APIs, SDKs, instrumentation conventions, and export formats. An APM platform usually stores, indexes, visualizes, alerts on, and analyzes telemetry. OpenTelemetry can send data to many backends, while an APM vendor determines the available user experience and platform features.
Do I need to instrument every function manually?
No. Use automatic instrumentation for supported framework and client behavior, then add manual spans for domain operations that are important to understand. Manual instrumentation should represent meaningful work, not every internal function call.
Why are my spans not reaching the backend?
Check that the SDK is started, the exporter is configured, the endpoint is reachable, and a processor is attached. Also verify that sampling is not discarding the test trace and that container networking can resolve the collector hostname.
Can OpenTelemetry trace calls between Node.js microservices?
Yes, when each service exports traces and the framework or client instrumentation propagates context. A trace can show the server span in one service and the client span in another, provided both sides use compatible instrumentation and context propagation.
How do I prevent high-cardinality attributes?
Use stable attribute names and bounded values. Avoid tokens, cookies, full URLs, free-form user input, and per-request identifiers unless there is a documented need. Prefer a separate correlation ID or secure log field for sensitive or highly variable data.
Can I use tracing in a serverless Node.js function?
Yes. Configure the SDK and exporter for the serverless runtime, preserve context across supported invocations, and account for cold starts and shortened execution windows. Test whether the runtime allows enough time for batched export during graceful shutdown.
How should sampling handle errors?
A practical starting point is to retain traces with error status while sampling successful traffic at a lower rate. Review whether the backend preserves error traces reliably and adjust the policy based on volume and incident value.
Can one service export to more than one backend?
Yes. The SDK can be configured with multiple processors or exporters, and a collector can forward data to multiple destinations. Keep the configuration understandable and monitor duplicate export costs.
Does OpenTelemetry replace logs and metrics?
No. Traces reconstruct request paths, logs provide detailed event narratives, and metrics summarize behavior over time. Correlating all three usually produces better operational insight than choosing one signal.
Is a trace ID the same as a request ID?
Not necessarily. A trace ID identifies a trace, while a request ID may identify only one request or be generated by an application. They can be related, but they have different scopes and should not be assumed to be interchangeable.
What should I put in a span event?
Put a concise, timestamped milestone in an event, such as a retry being attempted or an external response being received. Keep events free of secrets and avoid creating an event for every trivial internal action.
How do I know whether instrumentation adds too much overhead?
Measure the service under representative traffic and compare latency, CPU, memory, and export behavior before and after instrumentation. Use a test environment and realistic dependency behavior rather than relying on an unsupported benchmark.
Conclusion
Node.js tracing with OpenTelemetry becomes practical when you start with one service, one stable service name, automatic framework coverage, and a small number of meaningful business spans. Export through a batched OTLP pipeline, protect sensitive data, and verify context propagation before expanding the instrumentation.
Choose one endpoint today, generate a known request, and inspect the resulting trace. Then add the next domain operation that would have made a recent incident easier to understand. That single trace is the foundation for a more reliable, observable Node.js system.