Skip to content

Preserve and re-propagate the B3 debug flag - #5870

Open
ocelotl wants to merge 13 commits into
open-telemetry:mainfrom
ocelotl:b3-propagator-preserve-debug-flag
Open

ocelotl wants to merge 13 commits into
open-telemetry:mainfrom
ocelotl:b3-propagator-preserve-debug-flag

Conversation

@ocelotl

@ocelotl ocelotl commented Oct 31, 2026

Copy link
Copy Markdown
Contributor

Description

The B3 propagator accepted a received debug flag and then threw it away.

On extract, a sampling state of d in the single header form, and X-B3-Flags: 1 in the multi header form, were both collapsed into the SAMPLED trace flag, and the debug state itself was not stored anywhere. On inject, the sampling decision was always re-emitted as 1 or 0. A debug trace therefore stopped being a debug trace at the first service that used this propagator, and the operator who set the flag at the edge lost it for the rest of the request chain.

The B3 extract requirements in specification/context/api-propagators.md say "MUST preserve a debug trace flag, if received, and propagate it with subsequent requests. Additionally, an OpenTelemetry implementation MUST set the sampled trace flag when the debug flag is set." The second sentence was already satisfied. The first was not.

extract now records the debug state of the carrier under a context key, which is where io.opentelemetry.extension.trace.propagation.B3Propagator keeps it, and inject reads it back:

  • B3SingleFormat emits the sampling state d.
  • B3MultiFormat emits X-B3-Flags: 1, together with X-B3-Sampled: 1.
  • X-B3-Flags joins B3MultiFormat.fields, because inject can now set it and a reused carrier has to have it cleared.

Three decisions in this change are worth a reviewer's attention.

X-B3-Sampled is still sent for a debug trace. https://github.com/openzipkin/b3-propagation carries the note "Debug implies an accept decision, so don't also send the X-B3-Sampled header", so emitting both is a deliberate deviation from it. B3PropagatorInjectorMultipleHeaders in opentelemetry-java sets X-B3-Sampled to 1 alongside X-B3-Flags, so this keeps the two implementations in agreement, and it keeps the accept decision readable by a receiver that handles X-B3-Sampled and ignores X-B3-Flags. Omitting it is a one-line change if the project prefers the note.

The two header forms are kept apart on extract. A sampling state of d means debug only in the single header, because X-B3-Sampled is not a carrier for the debug flag. This propagator has always accepted d in X-B3-Sampled as a request to sample, and that behaviour is unchanged.

A debug flag that arrives with no identifiers is kept. The B3 specification allows a sampling state to be sent on its own, so b3: d and X-B3-Flags: 1 are both complete carriers that mark a trace for debug without naming one. extract returns early when the identifiers are missing, which threw that flag away too, and the service then started a root trace and injected an ordinary sampled decision. The flag is now stored on that path, and only when no identifiers were supplied at all, so a carrier whose identifiers are present but unparsable still leaves the context untouched. B3PropagatorExtractorSingleHeader and B3PropagatorExtractorMultipleHeaders in opentelemetry-java both return before their debug check here, so this part goes further than that implementation. Under the SDK default sampler the root span that follows is recorded, so the injected decision and the local one agree.

One consequence of keeping the state on the Context rather than on the SpanContext is worth knowing. Tracer.use_span attaches set_value(_SPAN_KEY, span) against the current context, so a caller that passes an extracted context only as start_as_current_span(context=...), without attaching it, loses every non-span entry of that context, and the debug flag is then not re-emitted. This is not specific to this change: W3C baggage is lost by the same call in the same way. Instrumentation that attaches the extracted context, which is what _start_internal_or_server_span in opentelemetry-python-contrib does, re-emits the flag correctly, and there is a test for that path.

Type of change

  • Bug fix (non-breaking change which fixes an issue)

tox -e py313-test-opentelemetry-propagator-b3 passes 82 tests, tox -e lint-opentelemetry-propagator-b3 scores 10.00/10, and every tox -e precommit hook passes. tox -e typecheck reports two errors, both in the Python 3.14 standard library string/templatelib.py, and both are present on the base commit without these changes; [tool.pyright].include in pyproject.toml does not list this package, so the run does not examine the changed file.

New tests cover extraction of the debug flag from both header forms, extraction from a sampling state sent with no identifiers in both forms, the absence of a debug flag on an ordinary sampled trace, X-B3-Flags: 0 and a single header sampling state of 1 not being read as debug, X-B3-Sampled: d not being read as debug, a debug flag left in a context by an earlier carrier being cleared by a later one, re-propagation on inject from both forms, re-propagation onto the first local span after a debug flag received with no identifiers, re-propagation after the extracted context is attached, a received debug flag forcing an accept decision for an unsampled span, and X-B3-Flags: 1 winning over X-B3-Sampled: 0.

Checked by mutation: deleting or flags == "1" from the sampling decision, dropping the debug branch from either inject, removing X-B3-Flags from fields, making extract stop clearing a stored flag, or relaxing either debug comparison from an exact value to a presence check each fails at least one test.

The B3 debug flag arrives either as the sampling state "d" in the single
header or as x-b3-flags: 1 in the multi header form. Both were collapsed
into the SAMPLED trace flag and the debug state itself was discarded, so
nothing downstream could tell a debug trace from an ordinary sampled one.

Keep the collapse into SAMPLED and additionally record the debug state
under a context key, which the inject side can then read. The two forms
are kept apart: a sampling state of "d" means debug only in the single
header, since x-b3-sampled is not a carrier for it, even though this
propagator has always accepted "d" there as a request to sample.
Inject only ever emitted a sampling decision of 1 or 0, so a debug trace
arriving at a service left it as an ordinary sampled one and the debug
state was lost for the rest of the trace.

Read the debug state recorded by extract and re-emit it: the sampling
state "d" in the single header form, and x-b3-flags: 1 in the multi
header form. Debug implies an accept decision, so x-b3-sampled is set to
1 alongside it, which is what the Java implementation emits as well.

x-b3-flags joins the fields of B3MultiFormat, since inject can now set
it and a reused carrier has to have it cleared.
extract takes an optional context to extend, and it only ever set the
debug flag, never cleared it. Extracting a carrier that carries no debug
flag into a context that already held one therefore returned a context
still marked for debug, and injecting it put the debug flag on the wire
for a trace that was never marked for it.

Record the debug state of the carrier being extracted, whether or not it
is set, so the state the inject side reads is the state the carrier
actually carried.
Two paths the suite did not reach. Instrumentation activates an
extracted context by attaching it and then calls inject with no context
argument, and every test so far passed the context to inject explicitly.
test_flags_and_sampling named a combination of x-b3-flags and
x-b3-sampled that it never built, leaving it byte for byte the same
carrier as test_flags, so the case where the two headers disagree had no
test at all.
test_flags and test_flags_and_sampling used to prove that x-b3-flags: 1
sets TraceFlags.SAMPLED on the extracted span context. Rewriting them to
assert the debug flag replaced that with an assertion the inject side
satisfies from the debug state alone, so deleting the flags clause from
extract left the whole suite green.

Assert the trace flag on the extracted parent as well as the debug flag
on the injected carrier.
The B3 specification allows a sampling state to be sent on its own, so
"b3: d" and "x-b3-flags: 1" are both complete carriers that mark a trace
for debug without naming one. extract returned early on the missing ids
and threw the flag away, and the service then started a root trace and
injected an ordinary sampled decision.

Store the flag on that path, and only when no ids were supplied at all,
so a carrier whose ids are present but unparsable still leaves the
context alone.
Every test that reached inject used a carrier with ids, so the store on
the early-return path was only ever asserted through the context and
never through what goes on the wire.
Nothing failed when either comparison was relaxed to a presence check,
so an x-b3-flags of "0" read as debug and a single header accept state
of "1" read as debug, and the suite stayed green both times.
@opentelemetry-pr-dashboard

opentelemetry-pr-dashboard Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Pull request dashboard status

Waiting on the author · refreshed 2026-11-02 05:00 UTC

Respond to 2 review items (e.g. link a commit, explain why not, ask a follow-up):

  • Inline threads: 1
  • Top-level threads: 2
Status above doesn't look right?
  • Just replied or pushed? Anything around or after the refresh time above may not be picked up yet — give it a few minutes.
  • Should this be with reviewers? Comment /dashboard route:reviewers to route it to them.
  • Anything wrong — including the routing? Report it with what you expected; it helps us improve the dashboard.

@ocelotl
ocelotl marked this pull request as ready for review October 31, 2026 14:37
@ocelotl
ocelotl requested a review from a team as a code owner October 31, 2026 14:37

@chiragjangiir chiragjangiir left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: Preserve and re-propagate the B3 debug flag

1. Context & Motivation

The Zipkin B3 propagation specification supports a debug flag (X-B3-Flags: 1 or d in single-header format) which indicates that sampling should be forced regardless of typical rate limits. When spans crossed Python services using B3MultiFormat or B3SingleFormat, the debug flag was frequently lost upon re-injection into downstream headers.

2. Technical Evaluation

  • Context Extraction & Storage: The debug state is properly extracted into trace state / baggage or trace flags bitmask.
  • Header Formatting: Both single-header (b3: {trace_id}-{span_id}-d) and multi-header (X-B3-Flags: 1) serialization paths have been updated to preserve the debug state across outbound HTTP calls.
  • Interoperability: Maintains strict adherence to B3 specs where the sample decision (1 vs 0) is suppressed or overridden when debug (d or Flags: 1) is active.

3. Test Suite

  • Comprehensive matrix tests covering:
    • Multi-header extraction with X-B3-Flags: 1
    • Single-header extraction with debug flag
    • Downstream propagation to verify neither header format loses the debug bit.

Clean and compliant fix for distributed tracing fidelity.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

B3 extraction can retain stale debug state after sampling-only decisions and incorrectly enable debug for malformed carriers with empty identifiers.

1 open issue
What changed in this PR

This PR preserves received B3 debug state in the OpenTelemetry context so it can be propagated to downstream services.

Changes:

  • Store debug state during extraction and re-emit it in single- and multi-header formats.
  • Add regression tests for debug propagation, sampling-only carriers, context handling, and header fields.
  • Document the fix in the changelog.
File Description
propagator/​opentelemetry-propagator-b3/​tests/​test_b3_format.py Adds debug extraction and propagation tests.
propagator/​opentelemetry-propagator-b3/​src/​opentelemetry/​propagators/​b3/​__init__.py Preserves debug state and updates injected headers and declared fields.
.changelog/​5870.fixed Records the B3 debug propagation fix.

Review effort: Balanced

Comment on lines +87 to +88
if debug and trace_id == trace.INVALID_TRACE_ID and span_id == trace.INVALID_SPAN_ID:
context = set_value(_DEBUG_FLAG_KEY, True, context)
@herin049

herin049 commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Thanks for the fix! I had Codex perform a quick review and it found a few issues that are valid, but unrelated to your changes. You're welcome to fix them in this PR, or we can fix them in a follow-up:

  1. [P2] Explicitly empty identifiers incorrectly enable debug.
    Lines 74–75 collapse empty strings into the missing-ID sentinels. Consequently, {"x-b3-traceid": "", "x-b3-spanid": "", "x-b3-flags": "1"} reaches the new sampling-only branch and modifies the supplied context. Distinguish absent IDs from present but malformed IDs, and preserve the context for malformed input as required by the OTel extraction contract.
  2. [P2] Multi-header debug injection contradicts B3’s encoding guidance.
    Lines 137–139 emit both X-B3-Flags: 1 and X-B3-Sampled: 1. B3 explicitly says debug implies acceptance and therefore to omit X-B3-Sampled. This is a documented deviation in the PR, rather than strict B3 compliance; Java’s behavior does not change that guidance. Emit X-B3-Flags for debug and X-B3-Sampled otherwise, and update assertDebug accordingly. B3 debug flag

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

4 participants