Skip to content

Fix: OpenTelemetry Not Working — Traces Not Appearing, Spans Missing, or Exporter Connection Refused

FixDevs · (Updated: )

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 nothing

Or 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 missing

Or the exporter throws ECONNREFUSED:

@opentelemetry/sdk-node - ERROR - Error: connect ECONNREFUSED 127.0.0.1:4317

Or 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 trace

Why This Happens

OpenTelemetry has strict initialization requirements:

  • SDK must start before importing instrumented libraries — if you import express before 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-node doesn’t include auto-instrumentation for Express, HTTP, or databases. You must install @opentelemetry/auto-instrumentations-node or specific packages like @opentelemetry/instrumentation-express.
  • Exporter endpoint must be correct — the default OTLP gRPC endpoint is localhost:4317, HTTP/protobuf is localhost: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 traceparent headers 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.js

Fix 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-node

Complete 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-contrib

Environment 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=value2

Configure 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.jar

Still 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 SimpleSpanProcessorBatchSpanProcessor (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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles