For the complete documentation index, see llms.txt. Markdown versions are available by appending .md to documentation URLs.

Context Propagation - Connect Spans Across Services

SigNoz Cloud - This page applies to SigNoz Cloud editions.
Self-Host - This page applies to self-hosted SigNoz editions.

A trace holds together because every span knows its parent. Context propagation moves that parent reference across two boundaries: from function to function inside one process, and from service to service over the network. Break either one and SigNoz shows two short traces where you expected a single request.

A checkout service with three nested spans passes trace context over an HTTP boundary to a payments service, where the entry span becomes a child of the caller
Context crosses two boundaries: the runtime carries it between functions, a traceparent header carries it between services

Overview

OpenTelemetry keeps the active span in a Context object. The specification describes context as an object that contains the information for the sending and receiving service, or execution unit, to correlate one signal with another. When you start a span, the tracer reads the current context. It finds the span already sitting there and records that span's ID as the new span's parent.

None of this reaches SigNoz as configuration. Propagation happens inside your application before a span is ever exported, so a trace arrives complete or arrives broken.

Instrumentation libraries handle both boundaries for the frameworks and clients they cover. You do the work yourself at any hop they miss.

Inside a process

How a child span finds its parent depends on what your language does with ambient state.

LanguageWhere the active span livesWhat you have to do
GoAn argument you pass by handThread ctx through every call, including goroutines
RustA thread-local Context, or an argumentAttach the context, and wrap a spawned future with with_context
JavaThread-local storageActivate the span, close the scope, and wrap work you hand to a thread pool
.NETAn AsyncLocal, so it follows await and Task.RunActivate the span, and pass an ActivityContext to a thread that predates it
Ruby, PHPContext storage the runtime managesActivate the span and detach the scope when the block ends
Python, Node.js, Denocontextvars and AsyncLocalStorageUse the active-span helper instead of starting a detached span

Go is the only one with no ambient state at all: every function that starts a span needs the context its caller was holding. Rust keeps a thread-local current context, so attach works inside one thread, but a task moved to another thread needs the context carried across. The rest keep the active span in storage the runtime manages. That covers straight-line code, and where it stops differs: Java drops it at a thread pool, while .NET carries it through await and Task.Run.

Across a process boundary

Two operations move context over the network:

  • Inject writes the current context into a carrier the transport can carry, usually a map of HTTP headers.
  • Extract reads that carrier on the receiving side and returns a context. You make it current, then start the entry span inside it.

Set the span kind at the same time. Mark a request and response hop Client on the caller and Server on the receiver. Mark a message queue hop Producer on the publisher and Consumer on the reader. SigNoz uses these to build the service map and APM metrics.

A propagator  owns both operations and decides the wire format. Both services have to agree on that format. A service writing B3 headers to a service reading W3C Trace Context produces an empty extraction and a fresh trace on every hop.

What the propagator puts on the wire

The default propagator writes a traceparent header defined by the W3C Trace Context specification:

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
FieldExampleWhat it carries
version00The Trace Context version in use
trace-id4bf92f3577b34da6a3ce929d0e0e473616 bytes, identical on every span in the trace
parent-id00f067aa0ba902b78 bytes, the caller's span ID, which becomes the parent of the receiver's entry span
trace-flags011 byte; the rightmost bit records whether the caller sampled the trace

Two more headers travel alongside it. tracestate holds vendor-specific key-value pairs and stays optional. baggage carries arbitrary key-value pairs you set yourself, such as a tenant ID. Keep secrets and personal data out of baggage, since downstream services and their logs receive whatever you put there.

When you propagate manually

When no library covers the hop, write the inject and extract calls yourself:

  • A message queue or event bus with no instrumentation library. Put the trace context on the message when you publish it. Read it back in the consumer.
  • A custom or binary protocol that carries no HTTP headers.
  • A background worker, scheduled job, or thread pool that starts work after the request that scheduled it has returned.
  • An HTTP client your instrumentation does not wrap.

View a connected trace in SigNoz

Open the Trace Explorer and select a request that crosses a service boundary. The waterfall names the service on every row, so a connected trace shows the service change while the indentation continues.

SigNoz trace waterfall showing process-order, validate-order, publish-order and charge-card in the checkout service, with handle-charge and capture-payment nested underneath in the payments service
One trace across two services: handle-charge runs in payments and sits under charge-card from checkout

handle-charge runs in payments, and it sits under charge-card from checkout. Both spans carry one trace ID, which is what propagation produced.

Select a span to open Span details. The parent_span_id field holds the caller's span ID. The is_remote field reports yes for a parent that arrived from another process.

SigNoz Span details panel for the handle-charge span, showing service payments, the trace ID, and a JSON view with is_remote set to yes and parent_span_id holding the caller's span ID
Span details for handle-charge: parent_span_id points at charge-card in the calling service, and is_remote confirms the parent came over the wire

Spot a broken trace in SigNoz

Open the Trace Explorer. A propagation failure leaves a recognizable shape:

  • The request you were following stops at the service boundary, and the downstream work sits in a separate trace under its own trace ID.
  • The downstream trace's root span is your own HTTP handler rather than the caller's span.
  • The upstream span covers the network call, but nothing inside it accounts for the time.

Open the downstream trace and look at its root span. When a span that needs a parent is a root, the caller never injected or the receiver never extracted.

Propagate context in your language

Each manual instrumentation guide covers the in-process rules for that language and shows a working inject and extract pair.

LanguageGuidePropagator entry point
GoManual instrumentation in Gootel.GetTextMapPropagator()
PythonManual instrumentation in Pythonopentelemetry.propagate.inject and extract
JavaManual instrumentation in JavaContextPropagators.getTextMapPropagator()
Node.jsManual instrumentation in Node.jspropagation.inject and propagation.extract
.NETManual instrumentation in .NETPropagators.DefaultTextMapPropagator
PHPManual instrumentation in PHPGlobals::propagator()
RustManual instrumentation in Rustglobal::get_text_map_propagator()
RubyManual instrumentation in RubyOpenTelemetry.propagation
DenoManual instrumentation in Denopropagation.inject and propagation.extract

Common mistakes

Each of these breaks a trace without raising an error.

MistakeWhat happensFix
The two services register different propagatorsOne side writes b3, the other reads traceparent. Every extraction returns an empty context.Register the same format on both sides, or register a composite propagator that accepts both.
You inject before the SDK configures a propagatorIn .NET, Propagators.DefaultTextMapPropagator stays a no-op until the SDK sets it. An early inject writes nothing.Build the tracer provider first, then inject.
You start the entry span from a fresh root contextThe entry span becomes a root and opens a new trace.Pass the context that extract returned, not context.Background(), Context.root(), or an empty carrier.
Work crosses a boundary the runtime does not carry context acrossA Java thread pool, a bare Python or Ruby thread, a Go goroutine, and a Rust task moved to another thread all start with no context, so the new span becomes a root. .NET is the exception: Activity.Current is an AsyncLocal, so it follows await and Task.Run.Wrap the executor, capture the context before you spawn, or pass it in as an argument.
A scope stays openJava and PHP hand you a scope to close. Later spans attach to the wrong parent.Call detach(), or use a try-with-resources block.
The carrier's header names are the wrong casePython and Ruby look up the literal lowercase traceparent. A carrier holding Traceparent extracts nothing.Lowercase the keys when you build the carrier. PHP is case-insensitive, and Node lowercases incoming headers for you.

Next steps

Get Help

If you need help with the steps in this topic, please reach out to us on SigNoz Community Slack. If you are a SigNoz Cloud user, please use in product chat support located at the bottom right corner of your SigNoz instance or contact us at cloud-support@signoz.io.

Is this page helpful

Last updatedSeptember 03, 2026

Edit on GitHub