OpenTelemetry’s context propagation is how traces know which spans belong to the same request, even across different services or threads.

Here’s a quick look at what that means in practice. Imagine a user clicking a button in a web app. That click might trigger a chain of events: a frontend request, a backend API call, a database query, and maybe even a message sent to a queue. Without context propagation, each of these steps would appear as an independent, unrelated operation in your tracing system. With it, OpenTelemetry injects a unique trace ID and span ID into the request headers (or message payloads) as it travels. When the next service receives the request, it extracts this information and uses it to create new spans that are linked back to the original trace. This creates a unified, end-to-end view of the entire user journey, making it infinitely easier to debug performance issues or understand the flow of requests.

Here’s a simple Node.js example. We’ll instrument a basic HTTP server and a client.

// server.js
const express = require('express');
const { trace, context, SpanStatusCode } = require('@opentelemetry/api');
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
const { Resource } = require('@opentelemetry/resources');
const { SemanticResourceAttributes } = require('@opentelemetry/semantic-conventions');
const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http');
const { ExpressInstrumentation } = require('@opentelemetry/instrumentation-express');

const sdk = new NodeSDK({
  resource: new Resource({
    [SemanticResourceAttributes.SERVICE_NAME]: 'my-server',
  }),
  traceExporter: new OTLPTraceExporter({
    url: 'http://localhost:4318/v1/traces', // OTLP endpoint
  }),
  instrumentations: [
    new HttpInstrumentation(),
    new ExpressInstrumentation(),
  ],
});

sdk.start();

const app = express();
const port = 3000;

app.get('/process', async (req, res) => {
  const tracer = trace.getTracer('my-server-tracer');
  const currentSpan = trace.getSpan(context.active());

  // Simulate some async work
  await new Promise(resolve => setTimeout(resolve, 100));

  // Here's where context propagation is key:
  // When we call another service (simulated by fetch below),
  // the context from the incoming request is automatically
  // propagated by the http instrumentation.
  try {
    const response = await fetch('http://localhost:3001/downstream');
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    const data = await response.json();
    res.json({ message: 'Processed successfully', downstream: data });
  } catch (error) {
    currentSpan.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
    res.status(500).json({ error: error.message });
  }
});

app.listen(port, () => {
  console.log(`Server listening on port ${port}`);
});
// client.js (a separate service)
const express = require('express');
const { trace, context, SpanStatusCode } = require('@opentelemetry/api');
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
const { Resource } = require('@opentelemetry/resources');
const { SemanticResourceAttributes } = require('@opentelemetry/semantic-conventions');
const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http');

const sdk = new NodeSDK({
  resource: new Resource({
    [SemanticResourceAttributes.SERVICE_NAME]: 'my-downstream',
  }),
  traceExporter: new OTLPTraceExporter({
    url: 'http://localhost:4318/v1/traces', // OTLP endpoint
  }),
  instrumentations: [
    new HttpInstrumentation(),
  ],
});

sdk.start();

const app = express();
const port = 3001;

app.get('/downstream', (req, res) => {
  const tracer = trace.getTracer('my-downstream-tracer');
  const currentSpan = trace.getSpan(context.active());

  // Simulate some work
  setTimeout(() => {
    // In a real scenario, this might involve database calls or other services.
    // The key is that the span created here will be linked to the incoming request's trace.
    res.json({ message: 'Downstream processed' });
  }, 50);
});

app.listen(port, () => {
  console.log(`Downstream server listening on port ${port}`);
});

When you run these two applications and hit http://localhost:3000/process, you’ll see a single trace in your observability backend (like Jaeger or Tempo). This trace will show a span for the /process request on my-server and, crucially, a child span for the /downstream request on my-downstream, all linked together. The HttpInstrumentation on both sides automatically handles injecting and extracting the trace context.

The magic for asynchronous code happens because OpenTelemetry’s instrumentation for common async operations (like setTimeout, Promises, async/await, and network requests) is designed to "wrap" these operations. When a span is active, the instrumentation captures the current context and ensures that when the asynchronous callback eventually runs, it’s executed within that same captured context. This means any new spans created inside the callback are automatically parented correctly.

For example, in server.js, when fetch is called, the HttpInstrumentation detects it. Before the fetch request is sent, it checks if there’s an active span. If there is (which there will be for the /process request), it injects the current trace context into the outgoing HTTP headers. When the my-downstream service receives this request, its HttpInstrumentation extracts the context from the headers and sets it as the active context for the incoming request handler.

The most surprising thing about OpenTelemetry context propagation is how seamlessly it handles asynchronous operations without explicit manual context passing in most common scenarios. Libraries like async_hooks in Node.js provide a foundation, but the OpenTelemetry SDK’s instrumentations abstract away the complexity, making it feel almost like magic. You write your code, and as long as you’re using supported frameworks and libraries, the tracing just works.

A crucial detail often missed is how context is restored in complex asynchronous scenarios, especially with callbacks nested within callbacks or when dealing with libraries that manage their own execution contexts. While async_hooks provides a mechanism for context preservation, ensuring that context is correctly bound to the execution context of a callback requires careful instrumentation. For instance, if you manually create a new Promise and pass a callback that doesn’t implicitly capture the context (which is rare with modern JavaScript but possible), you might need to explicitly use context.with(activeContext, () => { ... }) to ensure the callback runs with the correct trace context. However, the built-in instrumentations for Node.js’s http, express, fetch, and promise-based APIs are designed to handle this automatically.

The next step after mastering context propagation is understanding distributed sampling, where you decide which traces to keep based on criteria beyond just error status, impacting what data you send to your backend.

Want structured learning?

Take the full Opentelemetry course →