B3 Propagation in OpenTelemetry
How a small set of HTTP headers lets one request keep its identity as it hops across many services, languages and tools, and how OpenTelemetry reads and writes them so old and new systems can share one story.
Imagine sending a parcel that must pass through five courier companies before it reaches its owner. If each company invents its own tracking number and never tells the next one, you can see the parcel at each stop, but you can never see the whole journey. The parcel is real, the stops are real, yet the story is cut into five unrelated pieces. To follow it end to end, every company must pass along one shared tracking number, in a form the next company understands.
Software faces the same problem. A single click on a website can touch a dozen programs written in different languages by different teams. Distributed tracing is the tool that follows that click from start to finish. For it to work, each program must hand a small piece of identity to the next one. That hand-off is called propagation, and B3 is one of the most widely used formats for it. It was born in the Zipkin tracing system, and OpenTelemetry supports it so that older and newer systems can work together.
In this guide we begin from zero. We explain what tracing and propagation are, where B3 came from, and what every character of a B3 header means. Then we look at how OpenTelemetry reads and writes B3 inside your program, how to switch it on, how it compares with the newer W3C standard, and how to avoid common traps. Every new term is explained when it first appears, and each big idea comes with an everyday comparison.
1What Is B3 Propagation?
B3 is a small, agreed way of writing a trace’s identity into the headers of a request, so the next service can pick up the same trace.
To understand B3, we need three plain ideas. First, a request is one piece of work that a user or a program asks for, such as “show my shopping cart”. Second, a trace is the full record of everything that happened because of that one request, across every program that touched it. Third, propagation is the act of passing the identity of the trace from one program to the next, so that all the pieces can be glued together afterwards.
B3 is a propagation format. It says exactly which headers to use, what their names are, and what the values look like. A header is a small labelled note attached to a web request, like the address and stamps on an envelope. It travels with the request but is not part of the message itself. B3 uses these notes to carry a few facts: the identity of the trace, the identity of the current step, and whether the trace should be recorded.
Why the name B3?
The letter and number come from “BigBrotherBird”, the early working name of the Zipkin tracing project at Twitter. Three B’s, shortened to B3. Zipkin itself was inspired by a research paper from Google about a tracing system called Dapper. The name stuck even though the project grew and the old name faded. Today B3 simply means “the Zipkin-style way of passing trace identity”.
Two flavors of B3
B3 comes in two shapes. The multiple-header form spreads the facts over several headers, with names that start with X-B3-, such as X-B3-TraceId and X-B3-SpanId. The single-header form packs everything into one header named b3, with the facts joined by dashes. Both carry the same information. The single header was added later because it is shorter and easier to forward. We study both in detail in the next chapters.
Think of the numbered tag on a piece of airline luggage. The tag carries a barcode that identifies the bag, and it follows the bag from the first airport to the last, even when the bag changes planes and airlines. The tag does not contain your clothes; it only lets every handler recognize the bag. B3 headers are the luggage tag of a request. They hold no business data, only the identity that lets every service say “this belongs to the same journey”.
What B3 is not
It is easy to mix B3 up with other parts of tracing, so let us be clear. B3 is not the data about what happened inside a service; that data is the spans, which are exported separately to a backend. B3 is not a monitoring tool or a storage system. B3 is not a naming dictionary like the semantic conventions, which decide what to call attributes. And B3 is not the only choice: the W3C Trace Context standard is the default in OpenTelemetry, and B3 is an alternative that you switch on when you need it.
You only need B3 when something in your system already speaks B3, such as an older Zipkin-based service, a proxy configured for B3, or a library that defaults to it. If everything in your system is new and uses OpenTelemetry, the default W3C format is usually the simpler choice.
2The Problem: Broken Traces
Without propagation, a trace shatters into disconnected pieces, and the one view you wanted, the whole journey, disappears.
What happens without it
Picture a request that enters a gateway, is passed to an orders service, which calls a payment service and then a shipping service. Each service watches its own work and records spans. If nobody passes the trace identity along, each service starts a brand new trace when its request arrives. You end up with four small traces in your tool, each telling a fragment of the story, and no way to see that they all came from the same click.
This is called a broken trace or a set of orphan traces. The symptoms are easy to recognize. Dashboards show many traces that begin in the middle of the system. A slow request cannot be explained, because the slow part sits in a trace that nobody can link back to the user’s click. Engineers waste time guessing which payment trace belongs to which order.
flowchart LR subgraph Without["Without propagation: four unrelated traces"] G1["Gateway: trace A"] --> O1["Orders: trace B"] O1 --> P1["Payment: trace C"] O1 --> S1["Shipping: trace D"] end subgraph With["With propagation: one trace"] G2["Gateway: trace A"] --> O2["Orders: trace A"] O2 --> P2["Payment: trace A"] O2 --> S2["Shipping: trace A"] end
Four kinds of pain
No end-to-end view
You cannot see how long the whole request took or which service was the slowest link in the chain.
Slow debugging
Engineers must match timestamps and guess which pieces belong together. This is slow and often wrong.
Wrong sampling
If services decide separately which traces to keep, you may keep the payment piece and lose the gateway piece of the same request.
Mixed formats
Services speak different propagation formats, so even when each one propagates, they cannot understand each other.
The fourth pain is where B3 matters
Propagation only works if the sender and the receiver agree on the format. If service A writes its identity in the B3 headers, but service B only looks for W3C headers, service B sees nothing and starts a new trace. Both services are doing their job, yet the trace breaks at the border. This is the most common reason for broken traces in real companies, and it is exactly the situation where OpenTelemetry’s B3 support is valuable.
Two people can only pass a note if they write in a language both can read. If one writes in English and the other reads only French, the note arrives but nothing is understood. A propagation format is the shared language of the note. B3 and W3C Trace Context are two such languages, and a service that speaks both can talk to everyone.
A broken trace gives no error. Every service looks healthy, every request succeeds, and nothing in your logs complains. You only notice when you look for the full picture and find it missing. That is why we later cover checks that detect broken traces early.
How the pain grows with your system
With two services, a broken trace is an annoyance. With two hundred services, it is a serious risk. A single request in a mature system can touch ten or twenty services, and every border between two services is a place where propagation can fail. If each border has even a small chance of breaking, then the chance that a long chain stays whole drops quickly. This is why large organizations put so much effort into agreeing on one propagation format, and why mismatched formats are one of the first problems they solve.
The cost also shows up in people’s time. When an outage is in progress, engineers rely on traces to find the broken piece quickly. A trace cut in the middle forces them back to reading raw logs and comparing clocks, which can add many minutes to every incident. Fixing propagation early is cheap, and it pays back during the first major outage that it shortens.
Propagation works only if every border cooperates. One service that drops the headers is enough to break all traces that pass through it, no matter how well the other services are configured.
3A Quick Refresher: IDs, Context and Carriers
Six small terms explain almost everything about how propagation works: trace id, span id, parent, context, carrier and propagator.
The identity pieces
A trace id is a long random number that names the whole journey. Every span in the same trace carries the same trace id. A span is one timed step of work, like “handle the web request” or “call the payment service”. Each span has its own span id, another random number, which names just that step. A span that was started because of another span points to it as its parent. These parent links turn a pile of spans into a family tree.
Identifiers are written as strings of hexadecimal characters. Hexadecimal is a way of writing numbers with sixteen symbols: the digits 0 to 9 and the letters a to f. A 128-bit trace id becomes 32 such characters, and a 64-bit span id becomes 16 characters. You do not need to read them as numbers. Treat them like the long codes printed on a parcel label.
Context: the identity inside one program
Inside a single running program, OpenTelemetry keeps track of “which span am I in right now” using something called the context. The context is an invisible object that follows your code as it runs, so that any new span you start knows who its parent is. It does this work inside one process, without any headers.
Carrier and propagator: crossing the border
The trouble starts when the request leaves the program. The context lives in memory and cannot travel over a network by itself. So OpenTelemetry uses two helpers. The carrier is the thing that moves between programs, such as the headers of an HTTP request or the properties of a queue message. The propagator is the translator that writes the context into the carrier and reads it back out on the other side.
A propagator has exactly two jobs, given plain names. Inject means taking the current context and writing it into the carrier before sending. Extract means reading the carrier on arrival and rebuilding the context. A B3 propagator is simply a propagator that performs these two jobs using the B3 header format.
| Term | Plain meaning | Everyday comparison |
|---|---|---|
| Trace id | Names the whole journey | A parcel tracking number |
| Span id | Names one step of the journey | The scan at one depot |
| Parent | The step that caused this step | The depot the parcel came from |
| Context | Where I am right now, inside one program | The note in a worker’s pocket |
| Carrier | The thing that crosses the network | The envelope |
| Propagator | Writes and reads the context into the carrier | The clerk who fills in the label and reads it |
sequenceDiagram participant A as Service A, the caller participant B as Service B, the receiver A->>A: Start a CLIENT span with the current context A->>A: Inject trace id and span id into the carrier A->>B: Send the request with B3 headers B->>B: Extract the headers and rebuild the context B->>B: Start a SERVER span as a child of the caller B-->>A: Send the response
Think of a relay race. The baton is the context, because it says which team you are on and which leg of the race you have reached. A runner cannot hand over the baton by shouting; they must physically pass it. The carrier is the hand-over itself, and the propagator is the agreed way of doing it: how to hold the baton out and how to take it. If two runners practice different hand-over styles, the baton drops. B3 is one hand-over style.
OpenTelemetry also has baggage, a separate set of business key and value pairs that can travel with a request. Baggage uses its own propagator and is not part of B3. B3 carries only trace identity and the sampling decision.
Where the ids come from
A trace id must be unique across every request your whole system will ever handle, and it must be created without asking any central office, because services create traces at the same moment in many places. The solution is randomness. OpenTelemetry fills a trace id with random bits, and a 128-bit random number is so large that two requests picking the same one by accident is, for all practical purposes, impossible. A 64-bit id is smaller, so clashes become possible in very large systems over long periods, which is one reason that 128-bit ids are preferred today.
Randomness has a second benefit. Some samplers decide whether to keep a trace by looking at the trace id itself. A ratio sampler that wants to keep ten percent of traces can treat the id as a number and keep the trace if the number falls in a certain range. Because the id is random, this keeps about ten percent, and because every service looks at the same id, they all make the same choice. This gives consistent sampling even without a header carrying the decision. Ids that are not random, such as ones that count upward, would spoil this and should be avoided.
Span ids are random too, but they only need to be unique inside their own trace, so a 64-bit span id is enough. That is why trace ids can be 32 characters while span ids stay at 16.
4A Short History of B3
B3 was a practical answer written by a team that needed it, long before any official standard existed.
From Dapper to Zipkin
In 2010, Google published a paper describing Dapper, the internal system it used to trace requests across thousands of machines. The paper showed that following a request through many services was both possible and valuable. Engineers at Twitter, who ran a large collection of services of their own, built an open implementation of the same idea. They named it Zipkin, and it was released as open source in 2012. The early code name, BigBrotherBird, gave B3 its name.
Zipkin needed a way to pass trace identity between services, so its authors defined a set of HTTP headers. At the time there was no standard for this, so the Zipkin headers became a de facto standard, which means a standard by common use rather than by official decision. Because Zipkin was open source and popular, many other tools adopted the same headers.
The ecosystem that grew around B3
Brave
The Java tracing library from the Zipkin project. It uses B3 by default, and many Java services built on it still send B3 today.
Spring Cloud Sleuth
A long-used tracing layer for Spring applications. It built on Brave and used B3 by default. Newer Spring versions moved to Micrometer Tracing, which commonly uses W3C by default.
Envoy and service meshes
The Envoy proxy supports Zipkin and B3 style propagation. Service meshes built on Envoy, such as Istio, historically used B3 headers in their tracing setups.
Finagle and other Twitter-born tools
Libraries from the same family as Zipkin naturally speak B3, because they were built together.
Then came a W3C standard
As distributed tracing spread, the industry saw the cost of having many incompatible header formats. The World Wide Web Consortium, called W3C, wrote a common standard named Trace Context. It became an official W3C Recommendation in early 2020. It defines headers named traceparent and tracestate. OpenTelemetry chose W3C Trace Context as its default propagation format, because a neutral standard serves everybody better than the habit of one project.
B3 did not disappear. Millions of running services already sent it, and rewriting them all at once was not possible. OpenTelemetry therefore ships B3 propagators, so that a modern OpenTelemetry service can sit in the middle of an older B3 estate and keep the traces connected. This role, a bridge between generations, is the main reason B3 still matters.
2010
Google describes Dapper, proving that tracing across services is practical.
2012
Twitter open-sources Zipkin and its B3 headers.
2019
OpenTracing and OpenCensus merge to form OpenTelemetry, a project under the Cloud Native Computing Foundation.
2020
W3C Trace Context becomes an official Recommendation and later the OpenTelemetry default.
Today
B3 lives on in older estates and is supported in OpenTelemetry for compatibility.
Think of railways. Early railways were built by different companies, and each chose its own width between the rails. Trains could not cross from one network to the other. Later a standard width was agreed, but old lines kept running on the old width for decades, and stations needed special equipment to move trains between them. B3 is an old rail width that is still widely in use, and the B3 propagator is the equipment at the junction.
5The Multiple-Header Format
The original B3 uses a handful of separate headers, each carrying one fact. Let us read them one at a time.
The five headers
| Header | What it carries | Allowed values |
|---|---|---|
| X-B3-TraceId | The identity of the whole trace | 16 or 32 lowercase hexadecimal characters |
| X-B3-SpanId | The identity of the sender’s current span | 16 lowercase hexadecimal characters |
| X-B3-ParentSpanId | The parent of the sender’s current span, when there is one | 16 lowercase hexadecimal characters |
| X-B3-Sampled | Whether this trace should be recorded | 1 for yes, 0 for no |
| X-B3-Flags | Extra switches, in practice only the debug switch | 1 means debug |
Reading each header
X-B3-TraceId is the heart of the format. It is either 16 characters, which is a 64-bit number, or 32 characters, which is a 128-bit number. The 64-bit form comes from the early days of Zipkin. The 128-bit form is longer and makes accidental clashes between traces practically impossible, and it matches the size used by W3C Trace Context. Many systems accept both, and a correct receiver must handle either.
X-B3-SpanId names the span of the sender at the moment of sending. When the receiver starts its own span, it will give it a fresh span id, and it will treat the sender’s span id as the parent. This is how the family tree is built one hand-over at a time.
X-B3-ParentSpanId is optional. It names the parent of the sender’s span. The receiver does not need it to build a child span, because the sender’s span id is already enough. The header exists mainly for tools in the Zipkin style, where both sides of one call may share a span id, and it is often left out by newer implementations.
X-B3-Sampled tells the receiver whether to record the trace. A value of 1 means “accept, record this trace”. A value of 0 means “deny, do not record”. If the header is missing, the sender has made no decision, and the receiver is free to decide for itself. We look at this important idea in the sampling chapter.
X-B3-Flags is a leftover switch from the early design. The only meaningful value in practice is 1, which means “debug”. A debug trace is a request that must be recorded no matter what the normal sampling rules say. It is useful when an engineer wants to be sure that one specific request is captured.
A worked example
Suppose a gateway sends a request to the orders service. It writes the following facts into the headers, shown here in a table so that each part is clear.
| Header | Example value | Meaning |
|---|---|---|
| X-B3-TraceId | 80f198ee56343ba864fe8b2a57d3eff7 | The 128-bit identity of the trace |
| X-B3-SpanId | e457b5a2e4d86bd1 | The gateway’s span, which becomes the parent |
| X-B3-Sampled | 1 | Record this trace |
The orders service extracts these values. It starts a new span with a new span id of its own, keeps the same trace id, and records the gateway span as its parent. Because the sampled value is 1, it records the span too. When orders calls payment, it injects the same trace id, its own span id and the same sampling choice, and the chain continues.
The multiple headers are like a paper form with separate boxes: one box for the tracking number, one for the current depot, one for “should we keep records of this parcel”. Each box is easy to read and fill in on its own, but there are several boxes to copy at every hand-over. The single-header format, coming next, is the same form folded into one line.
Header names are not case sensitive in HTTP, so X-B3-TraceId and x-b3-traceid mean the same thing. Some systems, such as gRPC, require lowercase names and will send them that way. A correct implementation compares names without regard to case. The hexadecimal values themselves should be written in lowercase.
What makes a header valid
A careful receiver checks the values before using them, because a bad id can cause strange behavior later. The rules are short. The trace id must be exactly 16 or 32 characters. The span id must be exactly 16 characters. Every character must be a hexadecimal digit, and the letters should be lowercase. Finally, an id made only of zeros is invalid, because zero is reserved to mean “no id”. If any rule fails, a well-behaved propagator ignores the whole set of B3 headers and treats the request as if it had arrived without them.
Ignoring the headers, rather than guessing, is deliberate. Guessing could glue a request to the wrong trace and create misleading pictures. Starting a new trace is safer, because the result is merely an incomplete story and not a false one. The same caution is why you should not repair malformed ids by hand in your own code.
| Situation | What a good receiver does |
|---|---|
| All headers valid | Continue the same trace as a child span |
| No B3 headers at all | Start a new root trace |
| Trace id has the wrong length | Ignore the headers and start a new trace |
| Trace id is all zeros | Ignore the headers and start a new trace |
| Sampled value is something other than 1 or 0 | Ignore the sampling value, or the whole set, depending on the library |
| Only a sampled value of 0 in the single header | Honor the denial and do not record |
A security guard checking tickets does not guess what a smudged ticket was meant to say. A smudged ticket is simply not accepted, and the visitor is treated as new. A receiving propagator behaves like that guard: it accepts only clean, complete identity, and otherwise starts fresh.
6The Single-Header Format
The single header says the same things as the five older headers, folded into one short line.
Why a second format was invented
Passing five headers at every hop works, but it has costs. Each header must be copied by every proxy, every library and every message system along the way. Some messaging systems, and some gateways, handle only a limited number of headers. Forgetting just one of the five can break the trace. The Zipkin community therefore added a compact form, named simply b3, which carries all the same facts in one value.
The shape of the value
The value is made of up to four parts joined by dashes, always in the same order: the trace id, the span id, the sampling state and the parent span id. The first two parts are required when the header carries identity. The last two are optional.
| Position | Part | Required? | Notes |
|---|---|---|---|
| 1 | TraceId | Yes | 16 or 32 lowercase hexadecimal characters |
| 2 | SpanId | Yes | 16 lowercase hexadecimal characters |
| 3 | SamplingState | No | 1 for accept, 0 for deny, d for debug |
| 4 | ParentSpanId | No | 16 hexadecimal characters; often left out |
With the same values as our earlier example, the header would read: 80f198ee56343ba864fe8b2a57d3eff7-e457b5a2e4d86bd1-1. The three parts are the trace id, the span id and the sampling state of 1. If the sender also wants to include the parent, a fourth dash-separated part follows the sampling state. Notice that the parent comes last, and so it cannot be written without also writing the sampling state.
A special short form
A sender may want to pass only a sampling decision without any identity. For example, a service that decided not to trace a request can simply send the value 0 in the b3 header. This tells the next service “do not record this one”. Because there is no trace id in this short form, the receiver does not link anything to a parent, and it respects the decision to stay quiet.
The multiple headers are a form with several boxes. The single header is the barcode printed along the bottom of the same form. A barcode scanner reads the whole thing in one sweep, and it is easy to copy to the next page without missing a box. The price is that you must know the order of the fields to read it by eye.
Single or multiple: how to choose
Single header
- Short and easy to forward.
- Fewer chances to lose one piece on the way.
- Friendly to systems with limited header support.
- Newer, so some very old tools do not read it.
Multiple headers
- Understood by almost every Zipkin-era tool.
- Easy to read by eye in logs.
- More headers to copy at every hop.
- Higher risk that a proxy strips only some of them.
In practice, the choice is made by what your receivers understand. If you control every service and they all read the single header, use it. If older tools are in the path, the multiple-header form is the safe choice. Many OpenTelemetry propagators can read both formats automatically, and the choice you must make is only about what to write.
Senders should write one B3 format, not both at once, unless a migration needs it. Receivers should be able to read either. This simple rule, “be strict when writing and generous when reading”, prevents many surprises.
7Sampling and Debug in B3
B3 does more than pass an identity. It also carries a decision: should this trace be recorded at all?
What sampling means
A busy system can handle thousands of requests every second. Recording every trace in full would cost a great deal in storage, network and money. So most systems record only a fraction of traces. Choosing which ones to keep is called sampling. The important rule is that the choice should be made once, usually at the start of a request, and then followed by every service. If each service chose on its own, you would get a trace with some steps present and others missing.
The three states
B3 expresses the decision with three possible situations. Accept means “record this trace”, written as 1. Deny means “do not record it”, written as 0. Deferred means that no decision has been made yet, and it is shown by leaving the sampling part out. When a receiver sees a deferred state, it is free to make the choice for itself using its local sampling rules, and then it passes its decision to the next service.
flowchart TD
A["Request arrives with B3 headers"] --> B{"Is a sampling value present?"}
B -- "Yes, 1 or d" --> C["Record the trace"]
B -- "Yes, 0" --> D["Do not record the trace"]
B -- "No" --> E["Local sampler decides"]
E --> F["Pass the decision to the next service"]
C --> F
D --> F
How OpenTelemetry honors the decision
In OpenTelemetry, a sampler is the component that decides whether a new span is recorded. The commonly used parent-based sampler follows a simple rule: if the incoming request comes from a remote parent that was sampled, record this span too; if the remote parent was not sampled, do not record it; and if there is no parent, use a chosen rule such as “keep ten percent”. When the B3 propagator extracts a header, it translates the accept value into the same “sampled” flag that W3C uses, so the sampler works the same way for both formats.
Consider a school photographer who photographs some of the students at each assembly. Deciding independently at every row would give a photo with a random mix of rows missing. A better method is for the front row to announce, “we are taking the photo today”, and every other row follows. The sampling state in B3 is that announcement passed from row to row.
The debug state
Sometimes an engineer wants one particular request to be recorded for certain, whatever the normal rate. The debug state exists for this. In the multiple-header form it is written as X-B3-Flags with the value 1, and in the single-header form as the letter d in the sampling position. A debug request implies that the trace is accepted. Debug is treated as sampled by OpenTelemetry propagators, and some implementations also remember that debug was requested. Check your language’s package for the exact behavior.
Head sampling and tail sampling
The decision carried by B3 is a head sampling decision, made at the head, or start, of the trace before anything is known about how it ends. Another approach, tail sampling, waits until the trace is complete and then keeps the interesting ones, such as the slow or failed ones. Tail sampling is usually done in a Collector, and it does not depend on B3 flags. The two methods can work together, but remember that if the head decision was “deny”, no spans were recorded and a tail sampler cannot bring them back.
Never let anyone on the public internet force the debug state on your system. If outside callers can send d or a sampled value of 1 freely, they can make you record every request, which raises your costs and may let attackers flood your tracing backend. Edge services should reset or ignore sampling values from untrusted sources.
Sampler choices and how B3 interacts
OpenTelemetry offers a few ready-made samplers. Always on records everything. Always off records nothing. A ratio-based sampler records a chosen percentage of traces. The parent-based sampler wraps another sampler for the root decision and then follows the remote parent for every later span. The most common production setup is a parent-based sampler wrapped around a ratio-based one: the first service in the chain decides using the ratio, and everyone else follows.
The B3 sampling value plugs straight into this design. When the first service decides to record, it injects the accept state. When a downstream service extracts it, the parent-based sampler reads it as “remote parent sampled” and records the span. When the first service decides not to record, it injects the deny state, and the downstream services stay quiet. The whole chain behaves as one, which is exactly the goal.
| Incoming B3 state | Remote parent view | Typical result with a parent-based sampler |
|---|---|---|
| Accept, value 1 | Sampled | Span is recorded |
| Debug, value d or flags 1 | Sampled, with debug noted | Span is recorded |
| Deny, value 0 | Not sampled | Span is not recorded, but context still passes on |
| No sampling value | Treated according to the library | Local rule decides, or the library may treat it as not sampled |
That last row deserves attention. Libraries differ in how they treat a deferred decision when there is a valid trace id and span id but no sampling value. Some treat it as not sampled, and some let the local sampler decide. Read the documentation of your language package, and test the behavior with a real request rather than assuming.
A service that is “not sampled” must still pass the context onward, even though it records nothing. If it drops the headers instead, a later service may start a fresh trace and decide to record it, giving you traces that begin in the middle of the system.
8How It Works Inside OpenTelemetry
Follow one request step by step to see exactly when B3 headers are written and read.
The propagator interface
In every OpenTelemetry language, a text-based propagator offers the same small set of abilities. Inject takes a context, a carrier and a setter, and writes values into the carrier. Extract takes a context, a carrier and a getter, and returns a new context that includes what it found. A third ability, often called fields, lists the header names the propagator may write, so that other tools know which headers to allow or forward.
The setter and getter exist because carriers differ. An HTTP request, a gRPC call and a message on a queue all store their labels in slightly different ways. By giving the propagator a small helper that knows how to write one label or read one label, OpenTelemetry lets the same B3 code work in every setting.
The life of one request
The caller starts a CLIENT span
The HTTP client library creates a span for the outgoing call. This span becomes the current span in the context.
The B3 propagator injects
It reads the trace id, span id and sampled flag from the context and writes them to the request headers in the chosen B3 form.
The request crosses the network
Proxies and gateways in between should forward the headers untouched, as they do with any other header.
The receiver extracts
The server library hands the incoming headers to the propagator. It rebuilds a remote span context holding the trace id, the span id, the sampled flag and a mark that says it came from another process.
The sampler decides
The parent-based sampler looks at the remote span context and records or skips the new span accordingly.
A SERVER span starts as a child
The new span gets its own span id, keeps the trace id, and points to the caller’s span as parent. Work inside the receiver now hangs under it.
flowchart LR Ctx1["Context in caller"] --> Inj["B3 propagator: inject"] Inj --> Hdr["Carrier: HTTP headers"] Hdr --> Ext["B3 propagator: extract"] Ext --> Ctx2["Context in receiver"] Ctx2 --> Span["New child span"]
What if the headers are missing or wrong?
A good propagator is forgiving. If the headers are missing, extract simply returns the context unchanged, and the receiver starts a new root trace. If the headers are present but malformed, for example a trace id of the wrong length or with letters outside a to f, the propagator ignores them rather than failing the request. This protects your application from crashing because of a bad header, but it also means a typo on the sending side quietly produces a broken trace.
The composite propagator
Most real configurations use several propagators together. OpenTelemetry offers a composite propagator, which runs a list of propagators in order. On inject, it asks each one to write its own headers, so the request may carry both W3C and B3 headers. On extract, it asks each one in turn to read what it understands. The order can matter if different headers disagree, so keep the list short and test the result.
A composite propagator is a clerk who writes the address on the parcel in two alphabets, and who reads a label in whichever alphabet is present. If the label has only one alphabet, the clerk reads that one. If it has two, the clerk reads them in a fixed order. This helps parcels cross borders but needs a little care when two alphabets give different addresses.
You almost never call inject and extract yourself. Instrumentation libraries for web servers, HTTP clients and message systems do it for you at the right moments. You only handle them by hand when you use a transport that has no instrumentation, such as a custom protocol.
Where inject and extract are called
Different pieces of instrumentation call the propagator at different points. A web server library calls extract when a request first arrives and before it creates the server span. An HTTP client library calls inject just before it sends a request, after it has created the client span. A messaging library calls inject when a message is produced and extract when a message is consumed. Each of these libraries is pre-wired to the propagator that you configured globally, so one configuration change affects all of them together.
This global behavior is helpful but means you should set the propagators early, before the first request is handled. If a service starts handling traffic with the default list and only later switches to include B3, the early requests will have been processed with the wrong format.
When context gets lost inside a program
Propagation between programs is only half of the story. Inside one program, the context must also travel correctly from one piece of code to the next. In languages that use threads, thread pools or asynchronous callbacks, the context can be lost when work is handed from one thread to another. When that happens, the outgoing call is made with an empty context, and the B3 propagator has nothing to inject. The result looks exactly like a missing header problem, but the cause is inside your own code.
The remedy is to use the context-aware tools of your language and OpenTelemetry instrumentation, which wrap executors, tasks and callbacks so that the context follows the work. If you write a custom thread pool, make sure the context is captured when the task is submitted and restored when it runs.
Example: the vanishing parent
A service receives a request with valid B3 headers and records a SERVER span correctly. It then hands the work to a background thread pool that calls a downstream service. The downstream service sees no B3 headers at all. The headers were extracted correctly, but the background thread did not inherit the context, so inject had nothing to write. Wrapping the thread pool with the OpenTelemetry context helpers solved the problem.
9Turning On B3 in OpenTelemetry
Switching on B3 is mostly a matter of choosing a propagator by name and adding a small package.
The standard setting
OpenTelemetry defines an environment variable called OTEL_PROPAGATORS. Its value is a comma-separated list of propagator names. If you do not set it, the default is tracecontext,baggage, which means W3C Trace Context plus baggage. To add B3 you put its name in the list.
| Name | What it does |
|---|---|
| tracecontext | W3C Trace Context headers, traceparent and tracestate |
| baggage | W3C Baggage, for business key and value pairs |
| b3 | B3 in the single-header form |
| b3multi | B3 in the multiple-header form |
| jaeger | The Jaeger format, using the uber-trace-id header |
| xray | The AWS X-Ray format, using X-Amzn-Trace-Id |
| ottrace | The older OpenTracing format |
| none | No propagation at all |
Choosing b3 makes the SDK write the single header when sending. Choosing b3multi makes it write the five-header form. In both cases the propagator understands incoming B3 headers. A typical bridge setting that talks to both generations is the list tracecontext,baggage,b3, which writes W3C and single-header B3 together.
Language packages
The B3 propagator is usually shipped as an additional package rather than inside the core SDK, because most users do not need it. The names vary by language, so look for the “B3 propagator” in the documentation of the language you use.
Trace propagators extension
Provides a B3 propagator that can be created to write either the single header or multiple headers.
Propagator B3 package
Offers a B3 propagator with an option to choose between the single-header and multiple-header encoding.
Propagator B3 package
Provides separate classes for the single-header and the multiple-header formats.
Contrib propagators
The contrib collection includes a B3 propagator with options to select the encoding.
Extensions for propagators
An extensions package provides a B3 propagator alongside others such as Jaeger.
Agents and auto-configuration
Many auto-instrumentation agents read OTEL_PROPAGATORS and wire up the right propagator without code changes.
Package names, class names and supported options change between versions. Always confirm them in the current documentation for your language and version before you rely on a specific name from memory.
A safe rollout order
Find out who speaks what
List each service and note which headers it sends and which it reads. Logs of incoming headers or a quick packet inspection can tell you.
Add the package
Install the B3 propagator package in the services that need to talk to B3 neighbours.
Set the propagator list
Use the environment variable with both formats, so the service reads and writes what its neighbours expect.
Check a real trace
Send one test request across the boundary and confirm in your tracing tool that it forms one trace with the right parents.
Testing your configuration
After you change the propagator list, test it before you trust it. A good test is simple and needs no special tools. Send one request into the first service in your chain, give it a recognizable path, and then look for the resulting trace in your tracing tool. If the setup is right, you will see one trace containing spans from every service on the path, with the correct parent and child links.
If something is wrong, work backwards one border at a time. At each service, check whether the incoming request carried the headers you expected, using request logging or a debugging proxy. Then check what the service sent onward. The place where the headers appear on the way in but not on the way out, or the place where they never appear on the way in, is the place to fix.
One request, one trace
Confirm that a single request creates a single trace with all services present.
Both formats
Send a request carrying only B3, and another carrying only W3C, and confirm that both are joined.
Denied sampling
Send a request marked as not sampled and confirm that no spans are recorded downstream.
Bad input
Send a request with a broken trace id and confirm that the service still answers normally and starts a new trace.
10B3 Compared with W3C and Others
B3 is one of several propagation formats. Seeing them side by side makes the trade-offs clear.
| Format | Header names | Main idea |
|---|---|---|
| B3 multiple | X-B3-TraceId, X-B3-SpanId, X-B3-ParentSpanId, X-B3-Sampled, X-B3-Flags | One fact per header, from the Zipkin project |
| B3 single | b3 | All facts in one dash-joined value |
| W3C Trace Context | traceparent, tracestate | An official standard with room for vendor data |
| Jaeger | uber-trace-id | One header, from the Jaeger tracing project |
| AWS X-Ray | X-Amzn-Trace-Id | The format used by the X-Ray service |
B3 versus W3C Trace Context
These two formats solve the same problem, and they carry almost the same core facts, but they differ in important ways.
| Point | B3 | W3C Trace Context |
|---|---|---|
| Origin | Zipkin project, a common practice that grew by use | W3C, an official Recommendation |
| Trace id size | 64 or 128 bits | Always 128 bits |
| Sampling signal | Accept, deny, debug, or deferred by leaving it out | A sampled bit in the flags field |
| Vendor data | No standard place for it | The tracestate header |
| OpenTelemetry default | No, it is switched on by choice | Yes |
| Best fit | Zipkin-era and mesh-based estates | New systems and cross-vendor traffic |
W3C Trace Context gives every trace a 128-bit id and has a defined place, called tracestate, where each vendor can add its own information without clashing. B3 has no such place. B3 allows the deferred sampling state and a debug switch, which W3C does not model in the same way. When a 64-bit B3 trace id arrives at an OpenTelemetry service, the propagator extends it with leading zeros to reach 128 bits, so that the rest of the system sees a uniform size.
B3 and W3C are like two postal address layouts. One was invented by a large local mail company and became familiar through use. The other was agreed by an international body and has a field for notes from each carrier. Both can deliver the same parcel. The international layout is easier when many carriers are involved, and the local layout is perfectly fine inside a region that already uses it.
Use W3C Trace Context for anything new and for traffic that crosses company boundaries. Use B3 where existing components already send it, and plan to move away from it over time.
The other formats in short
You may meet two more formats in older systems. The Jaeger format puts the trace id, span id, parent span id and flags into a single header named uber-trace-id, joined by colons. It comes from the Jaeger tracing project, which began at Uber and is now a CNCF project. The AWS X-Ray format uses a header named X-Amzn-Trace-Id with labelled parts separated by semicolons, and it is understood by the AWS tracing service and by many AWS-managed components. OpenTelemetry has propagators for both, selected by the names jaeger and xray in the propagator list.
The lesson is the same for all of them. The details differ, but each format carries a trace id, a span id and a sampling flag. A bridge service can read any of them and continue the same OpenTelemetry trace, which is the main promise of the propagator design.
When you are not sure which format a neighbour sends, look at the header names. The names X-B3- and b3 mean B3, traceparent means W3C, uber-trace-id means Jaeger, and X-Amzn-Trace-Id means AWS X-Ray.
A decision guide for your propagator list
| Your situation | Suggested list |
|---|---|
| Everything is new and uses OpenTelemetry | tracecontext and baggage, the default |
| Older Zipkin or Brave services call you | tracecontext, baggage and b3 |
| A mesh sends the multiple-header form and you must reply in kind | tracecontext, baggage and b3multi |
| You live inside AWS X-Ray | tracecontext, baggage and xray, as your setup requires |
| Tracing must be off for a component | none |
11Living in a Mixed Estate
Real companies are rarely all old or all new. The B3 propagator earns its keep in the middle of that mix.
A typical mixed picture
Consider a company that began with Java services using Brave and Spring Cloud Sleuth, all writing B3. Over the years a service mesh was added, and its proxies also write B3 headers. Now new teams start building services with OpenTelemetry, which by default writes and reads W3C. If nothing is changed, the new services will not read B3 headers from the old ones, and the traces will break at every border.
flowchart LR Legacy["Legacy Java service: sends B3"] --> Mesh["Mesh proxy: forwards B3"] Mesh --> New["New OpenTelemetry service: reads B3 and W3C"] New --> Newer["Newer service: sends W3C and B3"] Newer --> Back["Tracing backend: one connected trace"]
The bridge rule
The cure is simple. Every service that sits between generations should read both formats and write the formats that its callees need. In configuration terms this usually means listing both W3C and B3 in the propagator setting. The bridge service extracts B3 from its older caller, continues the same trace, and injects both W3C and B3 for whatever it calls next.
Services that do no tracing at all
Not every program creates spans. Some are simple pass-through components: a gateway, a cache layer or a message router. These must still forward the tracing headers they received, because otherwise the trace breaks at that point. A service that has no tracing knowledge should copy all headers that begin with X-B3-, plus b3, plus traceparent and tracestate, onto its outgoing requests. This forwarding behaviour is a quiet but vital piece of the puzzle.
Example: the invisible gateway
A company has a gateway written ten years ago that strips every header it does not know. After the team adds OpenTelemetry to the services behind it, every trace starts at the first service behind the gateway, and the time spent in the gateway is lost. A one-line change to allow the tracing headers to pass restores the whole picture. The fix was in the gateway, not in OpenTelemetry at all.
An international airport has staff who speak several languages at the transfer desk. Passengers arriving from any country are understood, and they are sent onward with a ticket written in the language of the next airline. The B3 propagator in a bridge service is that multilingual transfer desk.
If both W3C and B3 headers arrive and they disagree about the trace id, the result depends on the propagator order. This can happen when two different systems along the path each start a trace. Keep the propagator list short, put the format you trust most first, and watch for traces that split in the middle.
12B3 Across Different Transports
B3 was born in HTTP, but modern systems also talk through gRPC, message queues and browsers. Each carrier has its own small rules.
Plain HTTP
HTTP is the easy case. Headers are free-form labels, names are not case sensitive, and every web library can read and write them. B3 works here with no special effort. The usual trouble is a proxy or gateway that removes headers it does not recognize, which we mentioned earlier.
gRPC
gRPC carries labels as metadata, and metadata keys must be lowercase. B3 headers are therefore sent as x-b3-traceid and similar, or simply as b3. Receivers must compare names without regard to case.
Message brokers
Systems such as Kafka and RabbitMQ let you attach headers or properties to each message. A producer injects B3 into those properties and a consumer extracts it. Some brokers store values as raw bytes, so the text must be encoded and decoded the same way on both sides.
Web pages calling APIs
When a page in a browser calls an API on another domain, the browser blocks custom headers unless the server lists them as allowed. The B3 header names must appear in the server’s allowed-headers list, or the call may fail or lose its tracing.
Background and scheduled work
A job started later from a stored record must save the trace identity with the record if you want the job linked to the original request. The stored identity is then extracted when the job runs.
Messages are different from calls
A call is answered quickly, and the caller waits. A message may sit in a queue for minutes or hours, and many consumers may read it at different times. Because of this delay, a consumer often links its span to the producer’s span instead of nesting under it as a strict child. Propagation still uses the same B3 values, but the tracing convention for messaging explains when to use a parent and when to use a link. Check the messaging guidance in the semantic conventions for the details.
The browser trap in detail
The browser trap deserves extra attention, because it is one of the most common surprises. For security, a browser enforces a rule called cross-origin resource sharing, shortened to CORS. When your page at one address calls an API at another address, the browser first asks the API, “which request headers will you accept from me?” If the answer does not include the B3 names, the browser refuses to send them, or blocks the call. The fix is on the API server: add the B3 header names to its list of allowed headers. A trace that begins in the browser then flows into the backend.
Different transports are like different ways of sending the same parcel label. A letter has an envelope, a courier van has a clipboard, and a locker has a screen. The label must be written onto each in the form that medium accepts. Whoever receives it must know where to look. B3 provides the contents of the label; each carrier decides where the label goes.
If you use a standard OpenTelemetry instrumentation library for your transport, the getter and setter details are already handled. Only custom or unusual transports require you to write them yourself.
13Migrating from B3 to W3C Trace Context
Many companies use B3 today and plan to retire it. A careful migration keeps every trace connected on the way.
Why migrate at all
Staying on B3 forever is possible, but there are good reasons to move. W3C Trace Context is the OpenTelemetry default, so new libraries and tools support it first. It is an official standard, which helps when traffic crosses organization boundaries or reaches cloud services that understand it. It also has the tracestate header for vendor information. And the more formats your services must support, the more settings, tests and surprises you carry.
The key idea: overlap, then retire
You cannot switch every service at one moment. A service that only writes W3C would break its B3-only callees, and the reverse is also true. The safe path is to pass through a period when services speak both formats, so that old and new neighbours both understand them, and only then to remove B3.
flowchart LR P1["Phase 1: all services use B3"] --> P2["Phase 2: read both formats everywhere"] P2 --> P3["Phase 3: write both formats"] P3 --> P4["Phase 4: stop writing B3"] P4 --> P5["Phase 5: remove B3 support"]
Make every service a reader of both
Before anyone writes W3C, every service must be able to read it. Enable both formats for extract everywhere. Because reading is harmless, this step is low risk.
Start writing both
Services begin sending W3C headers as well as B3. Each hop now works whichever format its neighbour prefers. Header size grows by a small amount.
Measure what still depends on B3
Count the incoming requests that carry only B3 headers. When that number reaches zero for every service, the old format has no readers left.
Stop writing B3
Remove B3 from the write side first, service by service, watching for broken traces after each step.
Remove B3 support
When no service reads B3 anymore, remove the package and the configuration. Update mesh and gateway settings too.
Things to remember during the move
Include the infrastructure in your plan, not just the application code. Service mesh proxies, API gateways, ingress controllers and load balancers all have tracing settings, and they may default to B3. Include third-party services that call you or that you call. Keep a rollback ready: because you add W3C before removing B3, you can usually undo a step by turning B3 writing back on.
Do not hurry to the last step. The cost of sending a few extra bytes per request is tiny compared with the cost of a week of broken traces. Leave the overlap phase in place until the numbers prove that nothing needs B3.
When a country changes the side of the road that cars drive on, it cannot do so one car at a time. Planners prepare signs and training first, then switch everyone together. In software we cannot switch everyone together, so we create a long period when both rules are understood. Only when almost nobody uses the old rule do we remove its signs.
14Security, Monitoring and Cost
B3 headers look harmless, but they cross trust boundaries. A little care keeps them safe, visible and cheap.
Security: treat incoming headers as untrusted input
Any caller on the internet can send any header. A service that blindly trusts the B3 headers it receives from the public can be abused in three ways. First, an attacker can send a sampled value of 1 or the debug state on every request, forcing you to record everything and increasing your storage bill. Second, an attacker can send malformed values in the hope of crashing a poorly written parser, which is why propagators must ignore bad input rather than fail. Third, an attacker can reuse a known trace id to pollute a real trace with fake spans.
Reset at the edge
At the public entry point, ignore incoming sampling values and trace identity from untrusted callers, and start fresh traces with your own decision.
Limit debug
Allow the debug state only from trusted internal tools or authenticated engineers, never from the open internet.
Validate strictly
Check lengths and characters, and ignore anything that does not match the format. Do not echo header values into logs without cleaning them.
No secrets in ids
Trace ids should be random. Never build them from user names, emails or account numbers, since they appear in many logs and tools.
Monitoring: spot broken traces early
Because broken traces make no noise, you need to look for them. A helpful signal is the share of traces whose first span is not a true entry point. If a service that should always have a parent suddenly shows many root spans, its extract step is failing. Another signal is the count of requests that carry B3 headers but produce no child span. A third is a regular test request, sometimes called a synthetic check, that crosses several services and verifies that the trace appears as one tree.
Cost: small, but not zero
The B3 single header is roughly fifty to one hundred bytes, depending on whether the ids are 64 or 128 bits and whether the parent is included. Writing both B3 and W3C together adds a few hundred bytes at most. For ordinary traffic this is negligible, but a system handling millions of requests per second with tiny payloads may notice it. The larger cost risk is the one in the previous chapter: forced sampling. Controlling who can set the accept and debug states controls most of the real cost.
Do not log full header values from untrusted sources without limits. A malicious caller could send a very long value to fill your logs. Keep a maximum length, and log only validated ids.
A small health dashboard for propagation
You can turn the ideas above into a few simple charts that tell you whether propagation is healthy. The first chart shows the share of server spans that have no parent, grouped by service. For services that sit behind others, this number should be close to zero. A sudden jump signals that extract has started failing, often after a deployment or a proxy change.
The second chart shows the average number of services per trace. If this falls after a change, traces are being cut short. The third chart counts incoming requests by header format, with groups such as B3 only, W3C only, both and none. This chart is the key tool during a migration, because it shows when the old format has stopped appearing. Your backend or Collector may need a small rule to record the format as an attribute on a span, and you should add it only if your privacy rules allow it.
Review these charts after every infrastructure change, not only after application changes. Gateways, meshes and load balancers affect propagation just as much as your own code.
15Advantages, Disadvantages and Trade-offs
B3 is neither good nor bad on its own. Whether it suits you depends on what already runs in your system.
Advantages
- Simple to understand and easy to read by eye in logs.
- Supported by a very large ecosystem of Zipkin-era tools, tracers and proxies.
- Offers a deferred sampling state and a clear debug switch.
- OpenTelemetry reads and writes it, so old and new services can share one trace.
- Two forms let you choose between compact and compatible.
- Years of production use mean most pitfalls are well known.
Disadvantages
- Not the OpenTelemetry default, so it needs extra packages and settings.
- No standard place for vendor information.
- Allows 64-bit trace ids, which clash more easily than 128-bit ones.
- Maintained by a community practice rather than an official standards body.
- Running two formats during migration adds work and risk.
- Some cloud and vendor services understand W3C but not B3.
Trade-offs you will face
| Choice | Benefit | Cost |
|---|---|---|
| Stay on B3 only | No change for the existing estate | New tools and partners may not understand it |
| Write W3C and B3 together | Works with both generations | Slightly larger headers and two formats to test |
| Use the single header | Compact and easy to forward | Not understood by the oldest tools |
| Use multiple headers | Maximum compatibility | More headers to copy and protect |
| Trust incoming sampling | Consistent traces across services | Exposure to forced sampling from outsiders |
| Reset sampling at the edge | Control of cost and safety | Traces from external partners start fresh |
An old but sturdy bridge still carries heavy traffic every day. Rebuilding it takes time and money, and for years it stays in service while a new bridge is built beside it. The wise engineer does not demolish the old bridge until the new one carries the load. B3 is that old bridge: perfectly fine for now, and worth keeping until its successor is ready.
Keep B3 as long as something you depend on needs it, add W3C beside it for the future, and retire B3 on a plan, not by accident.
Questions to ask before you decide
Choosing a format becomes simple when you ask a few plain questions. Who calls me? If any caller sends only B3, I must be able to read B3. Whom do I call? If any callee reads only B3, I must write B3. Who sits in between? Gateways and meshes in the middle may need their own settings. Who is outside my company? Partners and cloud services usually prefer the W3C standard. How long will the old services live? If they will retire soon, a short overlap is enough; if they will live for years, treat B3 as a long-term guest.
The answers tell you whether you need a bridge, how long it must stay, and which team owns the retirement plan. Write the answers down in a short document, because the question “why is B3 still on?” tends to come back months later.
Example: a quick decision
A team builds a new service that is called by an old Java service and calls a managed cloud database. The caller sends B3, and the database does not take part in tracing. The team therefore enables W3C and B3 for reading, writes W3C and B3 for neighbours that may need either, and records in its notes that B3 can be removed after the old caller is retired.
16Patterns, Anti-patterns and Best Practices
Teams that run propagation well repeat a few habits. Teams that struggle usually share a few mistakes.
Patterns that work
One list, set centrally
Define the propagator list once, in shared configuration, so every service starts with the same setting.
Bridge services
Place dual-format services at the border between generations and keep the rest of each side simple.
Edge reset
Gateways at the public edge decide sampling themselves and ignore outside values.
Synthetic cross-service check
A scheduled request that crosses many services verifies each day that the trace stays connected.
Header allow-lists
Gateways, proxies and browsers explicitly allow the tracing headers to pass.
Documented estate map
Keep a simple table showing which service reads and writes which format.
Anti-patterns to avoid
What it looks like
A new OpenTelemetry service is deployed with the default W3C setting into an estate that sends only B3.
Why it hurts
Every call from an older service starts a fresh trace, and nobody notices because nothing fails.
Do this instead
Check what the neighbours send before deploying, and enable the matching reader.
What it looks like
A public gateway forwards the sampling and debug values it receives from internet callers straight into the system.
Why it hurts
Outsiders can force full recording of their traffic, raising cost and exposing the tracing backend to floods.
Do this instead
Reset or ignore untrusted sampling values at the edge.
What it looks like
A team writes its own header parser by hand instead of using the propagator package.
Why it hurts
Small errors with id lengths, letter case or the order of fields cause silent trace breaks, and fixes must be copied everywhere.
Do this instead
Use the maintained propagator and let it handle edge cases.
Best practices at a glance
Read generously, write strictly
Accept every format you might receive, and send only what your neighbours need.
Prefer 128-bit trace ids
They make collisions practically impossible and match W3C.
Make one decision on sampling
Decide at the start and honour that decision downstream.
Forward headers in pass-through components
Gateways, caches and routers must copy tracing headers along.
Test across the border
After every change, send a request through the full chain and view the single trace.
Plan retirement
Give B3 an end date, and track the readers that still need it.
Common mistakes
Changing the propagator list in only half of the services. One side writes a format that the other side does not read, and the trace splits exactly there.
Forgetting the browser allowed-headers list, so traces never begin in the front end even though the back end is perfectly set up.
Writing uppercase letters in hexadecimal ids, or comparing header names with case sensitivity, which fails on systems that lowercase everything.
17Real-World Walkthrough and Industry Usage
Let us hunt a broken trace in an imaginary company, then see where the same ideas appear in well-known tools.
ShopFast: the missing half of the trace
ShopFast is an imaginary online store. Its older Java services were built with Brave and send B3 single-header values. A new recommendations service, written in Python with OpenTelemetry, was added last month. On launch day the team notices that every recommendation request shows up as a separate trace with no connection to the shopper’s page request. The recommendations service looks fast and healthy, yet the page is slow, and the traces cannot explain why.
flowchart LR Web["Web page"] --> GW["API gateway"] GW --> Old["Orders service: Java, sends B3"] Old --> Rec["Recommendations service: Python, default W3C"] Rec --> DB["Catalog database"]
Notice the symptom
Recommendation spans appear as root spans with no parent, while the Java services show complete trees.
Check the incoming headers
A debug log of headers on the new service shows a single b3 header on each call, and no traceparent.
Compare with the configuration
The propagator list is the default, tracecontext and baggage. B3 is not read, so extract finds nothing and starts a new trace.
Apply the fix
The team installs the B3 propagator package and sets the list to include b3 as well. Now extract reads the header and the trace continues.
Verify and protect
A test request shows one tree from the page to the database. A scheduled synthetic check is added so the problem cannot return unnoticed.
With the trace joined, the slow step is clear: a catalog query inside the recommendations service takes most of the page’s time. The fix took one afternoon, and the cause was never a bug in OpenTelemetry, only a mismatch of formats between neighbours.
Where the same ideas appear in industry
Zipkin and the Java tracing family
Zipkin, created at Twitter, defined B3, and its Java tracer Brave still uses it by default. Services built with this family typically keep sending B3 until they are moved to another format, which is why bridges are common.
Service meshes and proxies
The Envoy proxy and meshes built on it, such as Istio, have supported Zipkin and B3 style propagation. Many meshes were set up with B3 in earlier years, and newer releases offer OpenTelemetry-based tracing with W3C options. Operators often need both during migration.
Spring applications
Spring Cloud Sleuth long used B3 by default. Its successor, Micrometer Tracing, commonly defaults to W3C, and it can be set to B3 where required. Companies with many Spring services often run both generations side by side.
Observability vendors and clouds
Vendors such as Datadog, Grafana Labs, Honeycomb, New Relic, Splunk and Dynatrace accept OpenTelemetry traces, and OpenTelemetry’s multi-format propagation lets customers keep B3 on legacy links while using the vendor’s tooling. Cloud providers also publish OpenTelemetry distributions that support several formats.
Illustrative story: BookNest moves its mesh
BookNest, another imaginary company, runs about two hundred services behind a service mesh whose proxies write B3. The platform team wants the mesh to use W3C so that new services need no special setup. They first update every OpenTelemetry service to read both formats. Then they switch the mesh to write both formats for a month while counting how many requests still arrive with B3 only. When the count drops to zero, they stop writing B3 in the mesh, remove the B3 package from the services, and update the documentation. At no point did any trace break, because every step kept at least one common language between neighbours.
18Frequently Asked Questions
Short answers to the questions people ask most often.
19Summary and Key Takeaways
If you remember only a handful of ideas from this guide, make it these.
Key Takeaways
- B3 is a propagation format. It writes the trace id, span id and sampling decision into request headers so the next service can continue the same trace.
- It has two shapes. The multiple-header form uses X-B3-TraceId, X-B3-SpanId, X-B3-ParentSpanId, X-B3-Sampled and X-B3-Flags. The single-header form packs the same facts into one b3 header.
- Sampling travels with identity. Accept, deny, debug, and the deferred state let every service follow one decision made at the start.
- OpenTelemetry does the work. The propagator injects headers when sending and extracts them on arrival, and you choose formats through the OTEL_PROPAGATORS list.
- B3 is a bridge, not the default. W3C Trace Context is the default; use B3 where existing Zipkin-era services, proxies or libraries still send it, and list both formats in bridge services.
- Broken traces are silent. Format mismatches, stripped headers and browser rules cause them, so test across borders and monitor for unexpected root spans.
- Migrate in steps and guard the edge. Read both formats first, then write both, then retire B3, and never let untrusted callers force sampling or debug.