Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
1679462
refactor(tracer): improve variable names and comments in Extract
igoragoli Apr 9, 2026
721ee34
feat: start naively adding support for DD_TRACE_PROPAGATION_BEHAVIOR_…
igoragoli Apr 9, 2026
ec9d1fd
feat: check allowed values
igoragoli Apr 9, 2026
7166cb4
feat: make it elegant
igoragoli Apr 9, 2026
a31dfe1
refactor: start removing inline func() from Flags assignment
igoragoli Apr 9, 2026
e5d5cf4
refactor: finish removing inline func() from Flags assignment
igoragoli Apr 9, 2026
d502c97
docs: improve explanations on Extract() about propagation modes, keep…
igoragoli Apr 9, 2026
e74e765
chore: remove notes we don't need anymore
igoragoli Apr 9, 2026
6344be5
fix: trace priority bug
igoragoli Apr 9, 2026
dad691e
feat: set the restart SpanContext's baggageOnly to true to signal spa…
igoragoli Apr 15, 2026
502e5fc
chore: regen supported configurations
igoragoli Apr 15, 2026
c1876e6
test: add test drafts
igoragoli Apr 15, 2026
69414fc
chore: (PLEASE REVERT) add _context
igoragoli Apr 15, 2026
e86bc01
chore: add references to important files
igoragoli Apr 15, 2026
b3d13f8
feat: report propagation_behavior_extract in telemetry, fix supported…
igoragoli Apr 15, 2026
b856236
test: add unit tests for DD_TRACE_PROPAGATION_BEHAVIOR_EXTRACT
igoragoli Apr 15, 2026
abc8508
fix: propagate baggage when extract-first and restart modes are combined
igoragoli Apr 15, 2026
3b072e0
chore: document remaining work and system-tests requirements
igoragoli Apr 16, 2026
13b2517
chore: answer open questions in notes
igoragoli Apr 16, 2026
f240849
chore: format
igoragoli Apr 16, 2026
9857535
test: update tests
igoragoli Apr 16, 2026
0e1e163
refactor: clean up Extract() — remove TODO, extract extractBaggage he…
igoragoli Apr 16, 2026
7aef04e
chore: remove TODO above overrideDatadogParentID
igoragoli Apr 16, 2026
564bc64
chore: remove redundant test comment
igoragoli Apr 16, 2026
acbd99a
chore: untrack _context, keep locally
igoragoli Apr 16, 2026
7a49acc
refactor: restore getPropagatorName position, implement echotrace ass…
igoragoli Apr 16, 2026
c0d292e
chore: remove checklocksignore from test file
igoragoli Apr 16, 2026
adf7691
chore: align test names with RFC wording (unique/same trace-id)
igoragoli Apr 16, 2026
cb9a60b
test: start spans after Extract to verify produced trace ID and span …
igoragoli Apr 16, 2026
72b092f
chore: explain why WithSpanLinks is needed alongside ChildOf in resta…
igoragoli Apr 16, 2026
5c35603
chore: format, remove _context from .gitignore
igoragoli Apr 16, 2026
49391b2
Merge branch 'main' into augusto/trace-context-propagation-extraction…
igoragoli Apr 16, 2026
463dda0
fix: rename p shadow variable, fix JSON type casing
igoragoli Apr 16, 2026
cb0dc18
test: add benchmarks for restart and ignore extraction modes
igoragoli Apr 16, 2026
5a09f0a
test: remove unhelpful restart/ignore benchmarks
igoragoli Apr 16, 2026
3f504cb
Merge branch 'main' into augusto/trace-context-propagation-extraction…
igoragoli Apr 16, 2026
9ce00be
test: add continue case to echotrace propagation behavior test
igoragoli Apr 16, 2026
920565f
refactor: collapse duplicate generateUpperTraceID calls in newSpanCon…
igoragoli Apr 16, 2026
ddd05ad
refactor
igoragoli Apr 16, 2026
b16c4d8
refactor: use named constants for propagation behavior values
igoragoli Apr 16, 2026
5bfb26b
revert
igoragoli Apr 16, 2026
50b4212
docs: improve echotrace_test comment
igoragoli Apr 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions contrib/labstack/echo.v4/echotrace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -720,6 +720,106 @@ func TestWithErrorCheck(t *testing.T) {
}
}

// TestPropagationBehaviorExtract is an integration test verifying DD_TRACE_PROPAGATION_BEHAVIOR_EXTRACT
// through a real HTTP middleware stack.
// Echotrace was used because it was
// already available; any HTTP middleware integration would work equivalently.
func TestPropagationBehaviorExtract(t *testing.T) {
tests := []struct {
name string
behavior string
wantSameTraceID bool // server span continues the root trace
wantParentID bool // server span has root as parent
wantSpanLinks bool // server span has a span link to the root context
wantBaggage bool // baggage from root is propagated to server span
}{
{
name: "continue",
behavior: "continue",
wantSameTraceID: true,
wantParentID: true,
wantSpanLinks: false,
wantBaggage: true,
},
{
name: "restart",
behavior: "restart",
wantSameTraceID: false,
wantParentID: false,
wantSpanLinks: true,
wantBaggage: true,
},
{
name: "ignore",
behavior: "ignore",
wantSameTraceID: false,
wantParentID: false,
wantSpanLinks: false,
wantBaggage: false,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("DD_TRACE_PROPAGATION_BEHAVIOR_EXTRACT", tc.behavior)

mt := mocktracer.Start()
defer mt.Stop()

router := echo.New()
router.Use(Middleware(WithService("test-service")))
router.GET("/test", func(c echo.Context) error {
return c.NoContent(200)
})

root := tracer.StartSpan("incoming-request")
root.SetBaggageItem("test-baggage", "baggage-value")

r := httptest.NewRequest("GET", "/test", nil)
err := tracer.Inject(root.Context(), tracer.HTTPHeadersCarrier(r.Header))
require.NoError(t, err)

router.ServeHTTP(httptest.NewRecorder(), r)

spans := mt.FinishedSpans()
require.Len(t, spans, 1)
span := spans[0]

if tc.wantSameTraceID {
assert.Equal(t, root.Context().TraceIDLower(), span.TraceID())
} else {
assert.NotEqual(t, root.Context().TraceIDLower(), span.TraceID())
Comment on lines +789 to +791
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Shall we compare the entire 128bit IDs ?

}

if tc.wantParentID {
assert.Equal(t, root.Context().SpanID(), span.ParentID())
} else {
assert.Equal(t, uint64(0), span.ParentID())
}

links := span.Links()
if tc.wantSpanLinks {
require.Len(t, links, 1)
assert.Equal(t, root.Context().SpanID(), links[0].SpanID)
assert.Equal(t, map[string]string{"reason": "propagation_behavior_extract", "context_headers": "datadog"}, links[0].Attributes)
} else {
assert.Empty(t, links)
}

var baggageItems []string
span.Context().ForeachBaggageItem(func(k, v string) bool {
baggageItems = append(baggageItems, k+"="+v)
return true
})
if tc.wantBaggage {
assert.Contains(t, baggageItems, "test-baggage=baggage-value")
} else {
assert.Empty(t, baggageItems)
}
})
}
}

func BenchmarkEchoWithTracing(b *testing.B) {
tracer.Start(tracer.WithLogger(testutils.DiscardLogger()))
defer tracer.Stop()
Expand Down
6 changes: 5 additions & 1 deletion ddtrace/tracer/spancontext.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,11 @@
context.setBaggageItem(k, v)
return true
})
} else if traceID128BitEnabled.Load() {
}
// We generate a new upper trace ID when the trace is brand new (no parent)
// or when the parent is baggage only, since baggage only parents should
// not propagate their trace IDs
if (parent == nil || parent.baggageOnly) && traceID128BitEnabled.Load() { // +checklocksignore - Read-only after init.
// add 128 bit trace id, if enabled, formatted as big-endian:
// <32-bit unix seconds> <32 bits of zero> <64 random bits>
id128 := time.Duration(span.start) / time.Second
Expand Down Expand Up @@ -526,7 +530,7 @@
// reasonable as span is actually way bigger, and avoids re-allocating
// over and over. Could be fine-tuned at runtime.
traceStartSize = 10
traceMaxSize = internalconfig.TraceMaxSize

Check failure on line 533 in ddtrace/tracer/spancontext.go

View workflow job for this annotation

GitHub Actions / checklocks

may require checklocks annotation for mu, used with lock held 100% of the time
)

// samplingPriorityCache holds pre-allocated pointers for the four standard
Expand Down
2 changes: 2 additions & 0 deletions ddtrace/tracer/telemetry.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ func startTelemetry(c *config) telemetry.Client {
telemetry.Configuration{Name: "trace_propagation_style_inject", Value: chained.injectorNames})
telemetryConfigs = append(telemetryConfigs,
telemetry.Configuration{Name: "trace_propagation_style_extract", Value: chained.extractorsNames})
telemetryConfigs = append(telemetryConfigs,
telemetry.Configuration{Name: "trace_propagation_behavior_extract", Value: chained.propagationBehaviorExtract})
}
for k, v := range c.internalConfig.FeatureFlags() {
telemetryConfigs = append(telemetryConfigs, telemetry.Configuration{Name: k, Value: v})
Expand Down
161 changes: 135 additions & 26 deletions ddtrace/tracer/textmap.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,22 @@ func (c TextMapCarrier) ForeachKey(handler func(key, val string) error) error {
}

const (
// headerPropagationBehaviorExtract specifies how to handle incoming trace
// context. Allowed values:
// - "continue" (default): Continue the trace from incoming headers.
// Baggage is propagated.
// - "restart": Start a new trace with a new trace ID and sampling
// decision. The incoming context is referenced via a span link.
// Baggage is propagated.
// - "ignore": Start a new trace with a new trace ID and sampling
// decision. No span links are created. Baggage is dropped.
headerPropagationBehaviorExtract = "DD_TRACE_PROPAGATION_BEHAVIOR_EXTRACT"

propagationBehaviorExtractContinue = "continue"
propagationBehaviorExtractRestart = "restart"
propagationBehaviorExtractIgnore = "ignore"

headerPropagationExtractFirst = "DD_TRACE_PROPAGATION_EXTRACT_FIRST"
headerPropagationStyleInject = "DD_TRACE_PROPAGATION_STYLE_INJECT"
headerPropagationStyleExtract = "DD_TRACE_PROPAGATION_STYLE_EXTRACT"
headerPropagationStyle = "DD_TRACE_PROPAGATION_STYLE"
Expand Down Expand Up @@ -166,7 +182,17 @@ func NewPropagator(cfg *PropagatorConfig, propagators ...Propagator) Propagator
cfg.BaggageHeader = DefaultBaggageHeader
}
cp := new(chainedPropagator)
cp.onlyExtractFirst = internal.BoolEnv("DD_TRACE_PROPAGATION_EXTRACT_FIRST", false)
cp.onlyExtractFirst = internal.BoolEnv(headerPropagationExtractFirst, false)
cp.propagationBehaviorExtract = env.Get(headerPropagationBehaviorExtract)
switch cp.propagationBehaviorExtract {
case propagationBehaviorExtractContinue, propagationBehaviorExtractRestart, propagationBehaviorExtractIgnore:
// valid
default:
if cp.propagationBehaviorExtract != "" {
log.Warn("unrecognized propagation behavior: %s. Defaulting to continue", cp.propagationBehaviorExtract)
}
Comment on lines +191 to +193
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

cp.propagationBehaviorExtract = propagationBehaviorExtractContinue
}
if len(propagators) > 0 {
cp.injectors = propagators
cp.extractors = propagators
Expand All @@ -183,11 +209,12 @@ func NewPropagator(cfg *PropagatorConfig, propagators ...Propagator) Propagator
// When injecting, all injectors are called to propagate the span context.
// When extracting, it tries each extractor, selecting the first successful one.
type chainedPropagator struct {
injectors []Propagator
extractors []Propagator
injectorNames string
extractorsNames string
onlyExtractFirst bool // value of DD_TRACE_PROPAGATION_EXTRACT_FIRST
injectors []Propagator
extractors []Propagator
injectorNames string
extractorsNames string
onlyExtractFirst bool // value of DD_TRACE_PROPAGATION_EXTRACT_FIRST
propagationBehaviorExtract string // value of DD_TRACE_PROPAGATION_BEHAVIOR_EXTRACT
}

// getPropagators returns a list of propagators based on ps, which is a comma seperated
Expand Down Expand Up @@ -277,12 +304,86 @@ func (p *chainedPropagator) Inject(spanCtx *SpanContext, carrier any) error {
// subsequent trace context has conflicting trace information, such information will
// be relayed in the returned SpanContext with a SpanLink.
func (p *chainedPropagator) Extract(carrier any) (*SpanContext, error) {
if p.propagationBehaviorExtract == propagationBehaviorExtractIgnore {
return nil, nil
}
Comment on lines +307 to +309
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm still uncertain of this as we might break the implicit contract of not returning nil, nil that was there before.

Internally we're fine, all callers guard against nil but this is a somewhat public function. The feature is opt-in, customers will have to be aware of what they are doing.

We should at least document this change in the doc of the function.


incomingCtx, err := p.extractIncomingSpanContext(carrier)
if err != nil {
return nil, err
}

// "restart" propagation behavior starts a new trace with a new trace ID
// and sampling decision. The incoming context is referenced via a span
// link. Baggage is propagated.
if p.propagationBehaviorExtract == propagationBehaviorExtractRestart {
ctx := &SpanContext{
baggageOnly: true, // signals spanStart to generate new traceID/spanID
}

link := SpanLink{
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We should guard against an incomingCtx that wouldn't bring anything else that the baggage too.

If we don't we're going to create a spanlink with 0

TraceID: incomingCtx.TraceIDLower(),
TraceIDHigh: incomingCtx.TraceIDUpper(),
SpanID: incomingCtx.SpanID(),
Attributes: map[string]string{
"reason": "propagation_behavior_extract",
"context_headers": getPropagatorName(p.extractors[0]),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The RFC states

If multiple propagators read the same trace-id and were consolidated into one trace context, listing the first propagator is sufficient rather than listing all of the propagators

Is that enough here or should we return the propagators that actually extracted the context ?

If the carrier has only W3C headers (no DD headers), the DD propagator fails, tracecontext succeeds, but the span link says "context_headers": "datadog".

},
}
if trace := incomingCtx.trace; trace != nil {
if prio := trace.priority.Load(); prio != nil && uint32(*prio) > 0 { // +checklocksignore - Initialization time, freshly extracted trace not yet shared.
link.Flags = 1
} else {
link.Flags = 0
}
link.Tracestate = trace.propagatingTag(tracestateHeader)
}
ctx.spanLinks = []SpanLink{link}

// When onlyExtractFirst is set, extractIncomingSpanContext returns after the
// first successful non-baggage extractor, so incomingCtx carries no baggage.
// Extract baggage explicitly so it is propagated regardless.
baggage := incomingCtx.baggage // +checklocksignore
if p.onlyExtractFirst {
baggage = p.extractBaggage(carrier)
}
if len(baggage) > 0 {
ctx.baggage = maps.Clone(baggage) // +checklocksignore
atomic.StoreUint32(&ctx.hasBaggage, 1)
}

return ctx, nil
}

// "continue" continues the trace from the incoming context. Baggage is
// propagated.
return incomingCtx, nil
}

// extractBaggage runs only the baggage propagator against the carrier and
// returns the extracted items. Used when onlyExtractFirst has prevented the
// baggage propagator from running inside extractIncomingSpanContext.
func (p *chainedPropagator) extractBaggage(carrier any) map[string]string {
for _, v := range p.extractors {
if _, isBaggage := v.(*propagatorBaggage); !isBaggage {
continue
}
if baggageCtx, err := v.Extract(carrier); err == nil && baggageCtx != nil {
return baggageCtx.baggage // +checklocksignore - Initialization time, freshly extracted ctx not yet shared.
}
break // there is only one baggage propagator
}
return nil
}

func (p *chainedPropagator) extractIncomingSpanContext(carrier any) (*SpanContext, error) {
var ctx *SpanContext
var links []SpanLink
pendingBaggage := make(map[string]string) // used to store baggage items temporarily

for _, v := range p.extractors {
firstExtract := (ctx == nil) // ctx stores the most recently extracted ctx across iterations; if it's nil, no extractor has run yet
// If incomingCtx is nil, no extraction has run yet
firstExtraction := (ctx == nil)
extractedCtx, err := v.Extract(carrier)

// If this is the baggage propagator, just stash its items into pendingBaggage
Expand All @@ -293,7 +394,7 @@ func (p *chainedPropagator) Extract(carrier any) (*SpanContext, error) {
continue
}

if firstExtract {
if firstExtraction {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Renaming to read more naturally.

if err != nil {
if p.onlyExtractFirst { // Every error is relevant when we are relying on the first extractor
return nil, err
Expand All @@ -306,35 +407,34 @@ func (p *chainedPropagator) Extract(carrier any) (*SpanContext, error) {
return extractedCtx, nil
}
ctx = extractedCtx
} else { // A local trace context has already been extracted
extractedCtx2 := extractedCtx
ctx2 := ctx
Comment on lines -310 to -311
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed unnecessary alias.


// If we can't cast to spanContext, we can't propgate tracestate or create span links
if extractedCtx2.TraceID() == ctx2.TraceID() {
} else { // A trace context was already extracted by a previous propagator
// When trace IDs match, merge W3C tracestate and resolve parent ID conflicts.
// When trace IDs differ, create span links to preserve the terminated context.
if extractedCtx.TraceID() == ctx.TraceID() {
if pW3C, ok := v.(*propagatorW3c); ok {
pW3C.propagateTracestate(ctx2, extractedCtx2)
// If trace IDs match but span IDs do not, use spanID from `*propagatorW3c` extractedCtx for parenting
if extractedCtx2.SpanID() != ctx2.SpanID() {
pW3C.propagateTracestate(ctx, extractedCtx)
// W3C and Datadog headers may specify different parent span IDs.
// Prefer W3C's span ID for parenting, and record the Datadog span ID as reparentID.
if extractedCtx.SpanID() != ctx.SpanID() {
var ddCtx *SpanContext
// Grab the datadog-propagated spancontext again
if ddp := getDatadogPropagator(p); ddp != nil {
if ddSpanCtx, err := ddp.Extract(carrier); err == nil {
ddCtx = ddSpanCtx
}
}
overrideDatadogParentID(ctx2, extractedCtx2, ddCtx)
overrideDatadogParentID(ctx, extractedCtx, ddCtx)
}
}
} else if extractedCtx2 != nil { // Trace IDs do not match - create span links
link := SpanLink{TraceID: extractedCtx2.TraceIDLower(), SpanID: extractedCtx2.SpanID(), TraceIDHigh: extractedCtx2.TraceIDUpper(), Attributes: map[string]string{"reason": "terminated_context", "context_headers": getPropagatorName(v)}}
if trace := extractedCtx2.trace; trace != nil {
} else if extractedCtx != nil { // Trace IDs do not match - create span links
link := SpanLink{TraceID: extractedCtx.TraceIDLower(), SpanID: extractedCtx.SpanID(), TraceIDHigh: extractedCtx.TraceIDUpper(), Attributes: map[string]string{"reason": "terminated_context", "context_headers": getPropagatorName(v)}}
if trace := extractedCtx.trace; trace != nil {
if p := trace.priority.Load(); p != nil && uint32(*p) > 0 { // +checklocksignore - Initialization time, freshly extracted trace not yet shared.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nit: We don't need this empty line 😄

link.Flags = 1
} else {
link.Flags = 0
}
link.Tracestate = extractedCtx2.trace.propagatingTag(tracestateHeader)
link.Tracestate = extractedCtx.trace.propagatingTag(tracestateHeader)
}
links = append(links, link)
}
Expand Down Expand Up @@ -587,9 +687,18 @@ func getDatadogPropagator(cp *chainedPropagator) *propagator {
return nil
}

// overrideDatadogParentID overrides the span ID of a context with the ID extracted from tracecontext headers.
// If the reparenting ID is not set on the context, the span ID from datadog headers is used.
// spanContexts are passed by reference to avoid copying lock value in spanContext type
// overrideDatadogParentID overrides a context's:
// 1. span ID with the span ID extracted from W3C tracecontext headers; and
// 2. reparent ID with either:
// - the reparent ID from W3C tracecontext headers (if set), or
// - the span ID from Datadog headers (as fallback).
//
// reparent ID is the last known Datadog parent span ID, used by Datadog's
// backend to fix broken parent-child relationships when non-Datadog tracers
// in the path don't report spans to Datadog.
//
// SpanContexts are passed by reference to avoid copying lock information in
// the SpanContext type.
func overrideDatadogParentID(ctx, w3cCtx, ddCtx *SpanContext) {
if ctx == nil || w3cCtx == nil || ddCtx == nil {
return
Expand Down
Loading
Loading