Fix: OpenTelemetry Not Working — Traces Not Appearing, Spans Missing, or Exporter Connection Refused
Part of: JavaScript & TypeScript Errors
Quick Answer
How to fix OpenTelemetry issues — SDK initialization order, auto-instrumentation setup, OTLP exporter configuration, context propagation, and missing spans in Node.js, Python, and Java.
The Problem
OpenTelemetry is set up but no traces appear in Jaeger/Tempo/Datadog:
// index.js
import { NodeSDK } from '@opentelemetry/sdk-node';
const sdk = new NodeSDK({ /* config */ });
sdk.start();
// app runs, requests are made — but trace UI shows nothingOr spans are created but HTTP calls aren’t traced:
const tracer = trace.getTracer('my-service');
const span = tracer.startSpan('my-operation');
// Manual spans appear, but auto-instrumented HTTP/DB spans are missingOr the exporter throws ECONNREFUSED:
@opentelemetry/sdk-node - ERROR - Error: connect ECONNREFUSED 127.0.0.1:4317Or traces are visible but context doesn’t propagate across services:
Service A calls Service B — but B's spans aren't linked to A's traceWhy This Happens
OpenTelemetry has strict initialization requirements:
- SDK must start before importing instrumented libraries — if you
import expressbefore starting the OpenTelemetry SDK, Express is already loaded without instrumentation hooks. The SDK monkey-patches modules at startup; modules loaded before the SDK miss the patches. - Auto-instrumentation packages must be installed separately — the
@opentelemetry/sdk-nodedoesn’t include auto-instrumentation for Express, HTTP, or databases. You must install@opentelemetry/auto-instrumentations-nodeor specific packages like@opentelemetry/instrumentation-express. - Exporter endpoint must be correct — the default OTLP gRPC endpoint is
localhost:4317, HTTP/protobuf islocalhost:4318. Mismatch between configured protocol and the collector’s listener causes connection errors. - Context propagation requires W3C Trace Context headers — for distributed tracing to work, the calling service must inject
traceparentheaders into outgoing requests, and the receiving service must extract them. Both require proper propagator configuration.
The instrumentation lifecycle is the single hardest thing about OpenTelemetry in Node.js. The SDK works by intercepting require() (or, in ESM, by hooking the loader) and wrapping modules as they load. If your application uses CommonJS, the SDK must be required before any other require runs — node --require ./otel.js src/index.js is the canonical pattern. If you use ESM, the SDK ships an experimental loader hook (@opentelemetry/instrumentation/hook.mjs) that you enable via --experimental-loader, and you still must run the SDK initialization in a separate file imported with --import. Skipping any of these steps produces the symptom that manual spans appear (because trace.getTracer works without instrumentation) while HTTP, database, and framework spans are silently missing.
The other class of silent failure comes from the batch span processor. By default, OpenTelemetry buffers spans and flushes them every five seconds or when the batch fills. If your process exits before the flush (a serverless function, a CLI script, a CI job), the spans are dropped without warning. The fix is to call await sdk.shutdown() at the end of the process, or to switch to SimpleSpanProcessor for short-lived programs. Sampling adds another layer: many production setups use parentbased_traceidratio with a low ratio like 0.01, which means 99% of traces never reach the collector. When debugging “missing traces,” temporarily set OTEL_TRACES_SAMPLER=always_on to rule out sampling before chasing exporter problems.
How Other Tools Handle This
OpenTelemetry’s promise is portability across observability backends. The other tools you might compare it against each take a different stance on instrumentation, transport, and vendor lock-in.
OpenTelemetry vs Jaeger native. Jaeger predates OpenTelemetry and has its own client libraries that emit Thrift over UDP or HTTP. Jaeger-native instrumentation was simpler to set up (one library, one protocol) but is now in maintenance mode — the Jaeger project officially recommends OpenTelemetry SDKs as the path forward. Jaeger as a backend still works, but you should send data via OTLP (using the collector or Jaeger 1.35+‘s native OTLP receiver), not the legacy Jaeger Thrift protocol. If you see references to JaegerExporter in tutorials, treat them as deprecated.
OpenTelemetry vs Zipkin. Zipkin uses a simpler JSON-over-HTTP wire format and a centralized collector. The Zipkin B3 propagation header (x-b3-traceid, x-b3-spanid, x-b3-sampled) predates W3C Trace Context and is still common in older systems. OpenTelemetry supports both B3 and W3C propagators side by side, which is the right migration approach when one team has adopted OTel and another still emits B3 headers. The Zipkin model is also simpler in scope: spans only, no metrics or logs.
OpenTelemetry vs Datadog APM. Datadog ships its own tracer libraries (dd-trace) that emit to a per-host agent over a proprietary HTTP API. The DX is excellent — auto-instrumentation is more thorough out of the box, and the agent handles batching, sampling, and security headers transparently. The trade-off is lock-in: switching from Datadog to another backend requires reinstrumenting. As of 2025, Datadog accepts OTLP directly (no dd-trace required) and provides an OTel Collector exporter, so new projects can use vendor-neutral SDKs and still land in Datadog.
OpenTelemetry vs New Relic. New Relic similarly has its own agents but accepts OTLP at otlp.nr-data.net:4317 with an api-key header. The conventions differ: New Relic’s UI expects service.name as a resource attribute (same as OTel) but interprets host.name differently and applies its own entity synthesis on the receiving side. The same OTel SDK config that works for Tempo or Jaeger usually works for New Relic with just an endpoint and header change.
OpenTelemetry vs AWS X-Ray. X-Ray is proprietary and integrated with AWS services (Lambda, ECS, API Gateway, ALB). It uses its own trace ID format (timestamp-prefixed) and JSON segment documents posted to a local daemon. The AWS Distro for OpenTelemetry (ADOT) is AWS’s blessed OTel distribution that translates OTLP to X-Ray’s format via a collector exporter — this is the recommended path. Pure OTel SDKs can also write to X-Ray via the awsxray collector exporter, but the trace ID translation must be enabled (AWSXRayIdGenerator in the SDK) or X-Ray rejects the spans.
Collector vs agent vs SDK-only. The three deployment patterns matter for debugging. SDK-only sends spans directly from your app to the backend over OTLP — simple but couples your app to backend availability and credentials. Agent (sidecar or daemon-set) puts a local collector next to your process; the SDK ships to the agent over OTLP, and the agent handles batching, retries, and credentials. Collector (a gateway tier) aggregates from many agents or SDKs and routes to multiple backends. When traces vanish, the question is always “at which hop?” Add a debug exporter to the agent or collector to confirm spans are arriving before chasing SDK config.
OTLP vs Jaeger native protocol. OTLP is the OpenTelemetry-defined wire format, with HTTP/protobuf and gRPC variants. It is the only protocol guaranteed to work across all OTel-conformant tools. Older protocols (Jaeger Thrift, Zipkin JSON, Datadog APM JSON) still exist and may be hardcoded into legacy systems, but for greenfield work, default to OTLP and only fall back to native protocols when an older collector refuses OTLP.
Fix 1: Initialize SDK Before Everything Else
The SDK must be the very first thing that runs:
// WRONG — Express loaded before SDK
import express from 'express'; // Express already loaded — won't be instrumented
import { NodeSDK } from '@opentelemetry/sdk-node';
const sdk = new NodeSDK({ /* ... */ });
sdk.start();
const app = express(); // Too late
// CORRECT — SDK starts in a separate file, loaded first
// otel.js — SDK initialization ONLY
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { Resource } from '@opentelemetry/resources';
import { SEMRESATTRS_SERVICE_NAME, SEMRESATTRS_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
const sdk = new NodeSDK({
resource: new Resource({
[SEMRESATTRS_SERVICE_NAME]: 'my-service',
[SEMRESATTRS_SERVICE_VERSION]: '1.0.0',
}),
traceExporter: new OTLPTraceExporter({
url: 'http://localhost:4318/v1/traces',
}),
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-fs': { enabled: false }, // Disable noisy fs traces
}),
],
});
sdk.start();
// Graceful shutdown
process.on('SIGTERM', () => sdk.shutdown().finally(() => process.exit(0)));// package.json — use --require to load otel.js first
{
"scripts": {
"start": "node --require ./otel.js src/index.js",
"dev": "nodemon --require ./otel.js src/index.ts"
}
}TypeScript with ts-node:
# Load otel.ts before anything else
node --require ts-node/register --require ./otel.ts src/index.ts
# Or with environment variable
OTEL_NODE_RESOURCE_DETECTORS=env,host NODE_OPTIONS="--require ./otel.js" node src/index.jsFix 2: Install the Right Packages
Auto-instrumentation requires specific packages:
# Core SDK
npm install @opentelemetry/sdk-node @opentelemetry/api
# All-in-one auto-instrumentation (recommended for getting started)
npm install @opentelemetry/auto-instrumentations-node
# Or install specific instrumentations you need
npm install \
@opentelemetry/instrumentation-http \
@opentelemetry/instrumentation-express \
@opentelemetry/instrumentation-pg \ # PostgreSQL
@opentelemetry/instrumentation-redis-4 \ # Redis
@opentelemetry/instrumentation-mongoose # MongoDB
# OTLP exporter (gRPC — port 4317)
npm install @opentelemetry/exporter-trace-otlp-grpc
# OTLP exporter (HTTP/protobuf — port 4318)
npm install @opentelemetry/exporter-trace-otlp-http
# Console exporter (for debugging — prints to stdout)
npm install @opentelemetry/sdk-trace-node
# The ConsoleSpanExporter is included in sdk-trace-nodeComplete working setup:
// otel.ts
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { ConsoleSpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-node';
import { Resource } from '@opentelemetry/resources';
import { SEMRESATTRS_SERVICE_NAME } from '@opentelemetry/semantic-conventions';
const isDev = process.env.NODE_ENV !== 'production';
const sdk = new NodeSDK({
resource: new Resource({
[SEMRESATTRS_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME || 'my-service',
}),
traceExporter: isDev
? new ConsoleSpanExporter() // Print spans to console during development
: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318/v1/traces',
headers: {
// Add auth headers if required by your collector
authorization: `Bearer ${process.env.OTEL_EXPORTER_API_KEY}`,
},
}),
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
console.log('OpenTelemetry initialized');Fix 3: Fix Exporter Connection Issues
Match the exporter protocol to your collector’s listener:
// gRPC exporter — default port 4317
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc';
const grpcExporter = new OTLPTraceExporter({
url: 'http://localhost:4317', // No path for gRPC
});
// HTTP/protobuf exporter — default port 4318
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
const httpExporter = new OTLPTraceExporter({
url: 'http://localhost:4318/v1/traces', // Path required for HTTP
});
// Verify the collector is running
// docker run -p 4317:4317 -p 4318:4318 otel/opentelemetry-collector-contribEnvironment variable configuration (recommended for production):
# Set via environment variables — no code changes needed
export OTEL_SERVICE_NAME=my-service
export OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf # or grpc
export OTEL_TRACES_SAMPLER=parentbased_traceidratio
export OTEL_TRACES_SAMPLER_ARG=0.1 # Sample 10% of traces
# For Datadog
export DD_SITE=datadoghq.com
export DD_API_KEY=your-api-key
export OTEL_EXPORTER_OTLP_ENDPOINT=https://trace.agent.datadoghq.com
# For Grafana Cloud
export OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp-gateway-prod-us-central-0.grafana.net/otlp
export OTEL_EXPORTER_OTLP_HEADERS=Authorization=Basic base64(instanceID:apiKey)Local collector config (otel-collector-config.yaml):
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
exporters:
debug:
verbosity: detailed # Log all received spans
jaeger:
endpoint: jaeger:14250
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
exporters: [debug, jaeger]Fix 4: Create Manual Spans
For operations not covered by auto-instrumentation, add manual spans:
import { trace, context, SpanStatusCode, SpanKind } from '@opentelemetry/api';
const tracer = trace.getTracer('my-service', '1.0.0');
// Basic span
async function processOrder(orderId: string) {
const span = tracer.startSpan('processOrder');
try {
span.setAttribute('order.id', orderId);
span.setAttribute('order.source', 'api');
const order = await fetchOrder(orderId);
span.setAttribute('order.total', order.total);
await chargePayment(order);
await sendConfirmation(order);
span.setStatus({ code: SpanStatusCode.OK });
return order;
} catch (err) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: err instanceof Error ? err.message : 'Unknown error',
});
span.recordException(err as Error);
throw err;
} finally {
span.end(); // Always end the span
}
}
// With context propagation (parent-child relationship)
async function handleRequest(req: Request) {
return tracer.startActiveSpan('handleRequest', async (span) => {
try {
span.setAttribute('http.method', req.method);
span.setAttribute('http.url', req.url);
// Child spans automatically become children of 'handleRequest'
const user = await tracer.startActiveSpan('authenticate', async (authSpan) => {
const user = await verifyToken(req.headers.get('authorization'));
authSpan.setAttribute('user.id', user.id);
authSpan.end();
return user;
});
const result = await processData(user);
span.setStatus({ code: SpanStatusCode.OK });
return result;
} finally {
span.end();
}
});
}
// Add span events (point-in-time annotations within a span)
span.addEvent('cache_miss', { 'cache.key': cacheKey });
span.addEvent('retry_attempt', { 'retry.count': retryCount });Fix 5: Context Propagation for Distributed Tracing
Distributed tracing requires passing trace context between services:
// Sender — inject trace context into outgoing HTTP requests
import { context, propagation } from '@opentelemetry/api';
async function callDownstreamService(url: string, body: object) {
const headers: Record<string, string> = {
'content-type': 'application/json',
};
// Inject current trace context into headers (adds traceparent, tracestate)
propagation.inject(context.active(), headers);
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
});
return response.json();
}
// Receiver — extract trace context from incoming request headers
import { propagation, context, trace } from '@opentelemetry/api';
function extractContext(headers: Record<string, string>) {
return propagation.extract(context.active(), headers);
}
// Express middleware — auto-instrumentation handles this automatically
// But for manual setup:
app.use((req, res, next) => {
const extractedContext = propagation.extract(
context.active(),
req.headers as Record<string, string>
);
context.with(extractedContext, () => {
next();
});
});W3C Trace Context headers:
traceparent: 00-0af7651916cd43dd8448eb211c80319c-b9c7c989f97918e1-01
version-traceId-parentSpanId-flags
tracestate: vendor1=value1,vendor2=value2Configure propagators:
import { W3CTraceContextPropagator } from '@opentelemetry/core';
import { B3Propagator } from '@opentelemetry/propagator-b3'; // For Zipkin compat
const sdk = new NodeSDK({
// W3C is the default and recommended propagator
textMapPropagator: new W3CTraceContextPropagator(),
// Or for compatibility with Zipkin/older systems:
// textMapPropagator: new B3Propagator(),
// ...
});Fix 6: Python and Java Setup
Python (opentelemetry-python):
# Install
# pip install opentelemetry-sdk opentelemetry-exporter-otlp \
# opentelemetry-instrumentation-fastapi \
# opentelemetry-instrumentation-requests \
# opentelemetry-instrumentation-sqlalchemy
# otel_setup.py — import this before your app
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource, SERVICE_NAME
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.requests import RequestsInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
resource = Resource.create({SERVICE_NAME: "my-python-service"})
provider = TracerProvider(resource=resource)
exporter = OTLPSpanExporter(endpoint="http://localhost:4318/v1/traces")
provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider)
# Auto-instrument frameworks
FastAPIInstrumentor.instrument()
RequestsInstrumentor.instrument()
# main.py
from otel_setup import * # Import before FastAPI
from fastapi import FastAPI
app = FastAPI()
@app.get("/users/{user_id}")
async def get_user(user_id: str):
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span("get_user") as span:
span.set_attribute("user.id", user_id)
return {"id": user_id, "name": "Alice"}Java (Spring Boot with OpenTelemetry Java agent):
# Download the Java agent (no code changes required)
wget https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/latest/download/opentelemetry-javaagent.jar
# Run with agent — auto-instruments Spring Boot, JDBC, HTTP clients, etc.
java \
-javaagent:opentelemetry-javaagent.jar \
-Dotel.service.name=my-spring-service \
-Dotel.exporter.otlp.endpoint=http://localhost:4318 \
-Dotel.exporter.otlp.protocol=http/protobuf \
-jar my-service.jarStill Not Working?
Console exporter shows spans but Jaeger shows nothing — the spans are being created and exported, but the connection to Jaeger is failing silently. Check that Jaeger is accepting OTLP (not just its native Thrift protocol): use otel/opentelemetry-collector as an intermediary, or start Jaeger with the OTLP receiver enabled (--collector.otlp.enabled=true).
Span sampling drops too many traces — the default sampler in production setups often uses probability sampling. If you’re debugging and need to see all traces, set OTEL_TRACES_SAMPLER=always_on temporarily. For production, use parentbased_traceidratio with a ratio appropriate for your traffic volume.
BatchSpanProcessor delays vs SimpleSpanProcessor — BatchSpanProcessor (default) buffers spans and exports in batches, introducing up to 5 second delays before spans appear. Use SimpleSpanProcessor in development for immediate export. In production, BatchSpanProcessor is required to avoid overwhelming the collector.
Missing database spans despite SQLAlchemy/pg instrumentation — verify the instrumentation version matches your library version. @opentelemetry/instrumentation-pg v0.40+ requires pg v8.x. Check the package’s README for version compatibility.
ESM imports break auto-instrumentation — the OpenTelemetry instrumentation system was designed around CommonJS require() hooks. Under pure ESM, the standard --require ./otel.js pattern silently skips most instrumentations. Use node --import ./otel.mjs ./src/index.mjs (Node 20.6+) and ensure your otel.mjs calls sdk.start() synchronously at the top of the file. For Node 18 and below, you need the experimental loader hook plus --experimental-loader=@opentelemetry/instrumentation/hook.mjs.
Spans cut off in Lambda/serverless — short-lived processes commonly lose spans because BatchSpanProcessor does not flush before the runtime freezes the container. Use AWS’s OTEL_LAMBDA_FLUSH_BEFORE_SUSPEND=true, or call await provider.forceFlush() before returning from the handler. For Lambda specifically, the OTel Lambda layer wraps the handler and forces a flush for you.
Cannot redefine property errors during SDK start — this happens when two OpenTelemetry SDKs (or two versions of the same SDK) try to monkey-patch the same module. Common cause: a dependency includes its own @opentelemetry/instrumentation-http at a different version. Run npm ls @opentelemetry/instrumentation-http to find conflicts and dedupe.
For related observability issues, see Fix: AWS CloudWatch Logs Not Appearing, Fix: Sentry Not Working, Fix: Node Unhandled Rejection Crash, and Fix: GitHub Actions Process Completed with Exit Code 1.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Structlog Not Working — Processor Chain, Context Variables, and Stdlib Integration
How to fix Structlog errors — output is dict not JSON, context vars not propagating, stdlib logging not unified, async context loss, configure_once, KeyValueRenderer vs JSONRenderer, and async filtering.
Fix: Loguru Not Working — Missing Logs, Rotation Errors, and Multiprocessing Issues
How to fix Loguru errors — logs not appearing after logger.add, file rotation not working, enqueue required for multiprocessing, structured logging JSON, intercepting stdlib logging, and handler removal.
Fix: Fastify Not Working — 404, Plugin Encapsulation, and Schema Validation Errors
How to fix Fastify issues — route 404 from plugin encapsulation, reply already sent, FST_ERR_VALIDATION, request.body undefined, @fastify/cors, hooks not running, and TypeScript type inference.
Fix: Kafka Consumer Not Receiving Messages, Connection Refused, and Rebalancing Errors
How to fix Apache Kafka issues — consumer not receiving messages, auto.offset.reset, Docker advertised.listeners, max.poll.interval.ms rebalancing, MessageSizeTooLargeException, and KafkaJS errors.