Skip to content

Commit c9e8383

Browse files
genesorkakkoyun
andcommitted
fix(tracer): preserve keep/drop possibility for OTel bridge on unsampled spans (#4631)
Fixes the OTel bridge's handling of unsampled spans in `FromGenericCtx`. Previously, when an OTel parent context had `IsSampled() == false`, the bridge set `samplingDecision = decisionDrop` directly on the trace. Bypassing the atomic Compare-And-Swap (CAS) semantics that `keep()` and `drop()` rely on. This meant: - Error spans could never rescue the trace as `keep()` only CAS from `decisionNone`, not `decisionDrop` - The behavior diverged from the native DD tracer, where P0 traces are not hard-dropped client-side The fix leaves `samplingDecision` as `decisionNone` for drop decisions while still setting the P0 priority and locking the trace against resampling. This preserves the OTel sampling intent while restoring the native DD keep/drop CAS flow. The bug has been introduced in `v2.6.0` following: #4238 Fixes #4624 Discovered during investigation of [APMS-19054](https://datadoghq.atlassian.net/browse/APMS-19054) — a customer upgrading to dd-trace-go v2.6.0 + OTel observed `trace.*` metrics dropping to near-zero under low sampling rates when client-side stats were disabled. - [ ] Changed code has unit tests for its functionality at or near 100% coverage. - [ ] [System-Tests](https://github.com/DataDog/system-tests/) covering this feature have been added and enabled with the va.b.c-dev version tag. - [ ] There is a benchmark for any new code, or changes to existing code. - [ ] If this interacts with the agent in a new way, a system test has been added. - [ ] New code is free of linting errors. You can check this by running `make lint` locally. - [ ] New code doesn't break existing tests. You can check this by running `make test` locally. - [ ] Add an appropriate team label so this PR gets put in the right place for the release notes. - [ ] All generated files are up to date. You can check this by running `make generate` locally. - [ ] Non-trivial go.mod changes, e.g. adding new modules, are reviewed by @DataDog/dd-trace-go-guild. Make sure all nested modules are up to date by running `make fix-modules` locally. [APMS-19054]: https://datadoghq.atlassian.net/browse/APMS-19054?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ Co-authored-by: kakkoyun <[email protected]> Co-authored-by: benjamin.debernardi <[email protected]>
1 parent a4e12a4 commit c9e8383

3 files changed

Lines changed: 279 additions & 5 deletions

File tree

ddtrace/tracer/spancontext.go

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -151,13 +151,41 @@ func FromGenericCtx(c ddtrace.SpanContext) *SpanContext {
151151
return &sc
152152
}
153153

154-
// If the generic context has a sampling decision, set it on the trace
155-
// along with the priority if it exists.
156-
// After setting the sampling decision, lock the trace so the decision is
157-
// respected and avoid re-sampling.
154+
// Translate the external sampling decision into DD trace semantics.
155+
//
156+
// Two separate mechanisms are at play:
157+
//
158+
// - samplingDecision (decisionNone/Keep/Drop): controls whether the trace
159+
// payload is sent to the agent (willSend) and whether keep()/drop() can
160+
// modify it. Both keep() and drop() use atomic Compare-And-Swap (CAS)
161+
// from decisionNone only — they atomically check that the current value
162+
// is decisionNone before writing, so whichever goroutine calls first
163+
// wins and subsequent calls are no-ops. This lets error spans "rescue"
164+
// a trace: if keep() runs before drop(), the trace is sent.
165+
//
166+
// - sampling priority (P0/P1) + lock: the numeric priority sent to the
167+
// agent. Locking prevents the DD sampler from overriding it via
168+
// resampling.
169+
//
170+
// For keep decisions (OTel sampled=true): propagate decisionKeep directly
171+
// so the trace is guaranteed to be sent.
172+
//
173+
// For drop decisions (OTel sampled=false): set priority to P0 and lock it
174+
// (so the DD sampler won't override the OTel decision), but leave
175+
// samplingDecision as decisionNone. This preserves the CAS semantics: if
176+
// an error span finishes, keep() can still CAS(decisionNone -> decisionKeep)
177+
// and rescue the trace. If no span rescues it, drop() will
178+
// CAS(decisionNone -> decisionDrop) and the trace is dropped normally.
179+
// This matches native DD tracer behavior where P0 traces are not
180+
// hard-dropped client-side.
158181
if sDecision := samplingDecision(ctxSpl.SamplingDecision()); sDecision != decisionNone {
159182
sc.trace = newTrace()
160-
sc.trace.samplingDecision = sDecision
183+
if sDecision == decisionKeep {
184+
sc.trace.samplingDecision = sDecision // +checklocksignore - Initialization time, not shared yet.
185+
}
186+
// For decisionDrop we intentionally leave samplingDecision as
187+
// decisionNone (the newTrace default). The priority below still
188+
// records the OTel intent (P0), and locking prevents resampling.
161189

162190
if p := ctxSpl.Priority(); p != nil {
163191
sc.setSamplingPriority(int(*p), samplernames.Unknown)

ddtrace/tracer/spancontext_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1282,3 +1282,120 @@ func FuzzSpanIDHexEncoded(f *testing.F) {
12821282
}
12831283
})
12841284
}
1285+
1286+
// genericCtxWithDM is a minimal ddtrace.SpanContext + spanContextV1Adapter that
1287+
// simulates a context arriving from an external (non-native) integration.
1288+
// - propagatingTags: carries propagation metadata such as _dd.p.dm (decision maker),
1289+
// used to test that FromGenericCtx correctly caches the sampling mechanism.
1290+
// - decision: the sampling decision (decisionNone/Keep/Drop) from the external context,
1291+
// used to test how FromGenericCtx translates it into DD trace semantics.
1292+
// - priority: the numeric sampling priority (e.g. PriorityAutoKeep, PriorityAutoReject),
1293+
// used to test that FromGenericCtx sets and locks the priority on the trace.
1294+
type genericCtxWithDM struct {
1295+
propagatingTags map[string]string
1296+
decision uint32
1297+
priority *float64
1298+
}
1299+
1300+
func (g *genericCtxWithDM) SpanID() uint64 { return 1 }
1301+
func (g *genericCtxWithDM) TraceID() string { return "1" }
1302+
func (g *genericCtxWithDM) TraceIDBytes() [16]byte { var b [16]byte; b[15] = 1; return b }
1303+
func (g *genericCtxWithDM) TraceIDLower() uint64 { return 1 }
1304+
func (g *genericCtxWithDM) ForeachBaggageItem(_ func(k, v string) bool) {}
1305+
func (g *genericCtxWithDM) SamplingDecision() uint32 { return g.decision }
1306+
func (g *genericCtxWithDM) Priority() *float64 { return g.priority }
1307+
func (g *genericCtxWithDM) Origin() string { return "" }
1308+
func (g *genericCtxWithDM) PropagatingTags() map[string]string { return g.propagatingTags }
1309+
func (g *genericCtxWithDM) Tags() map[string]string { return nil }
1310+
1311+
func TestFromGenericCtxSamplingDecision(t *testing.T) {
1312+
t.Run("drop_preserves_CAS", func(t *testing.T) {
1313+
// When an external context (e.g. OTel bridge) signals a drop decision,
1314+
// FromGenericCtx should NOT set samplingDecision to decisionDrop directly.
1315+
// It should leave it as decisionNone so that the keep()/drop() CAS flow
1316+
// works normally — allowing error spans to rescue the trace.
1317+
p := float64(ext.PriorityAutoReject)
1318+
ctx := &genericCtxWithDM{
1319+
decision: uint32(decisionDrop),
1320+
priority: &p,
1321+
}
1322+
sc := FromGenericCtx(ctx)
1323+
require.NotNil(t, sc.trace)
1324+
1325+
// samplingDecision must be decisionNone, not decisionDrop.
1326+
got := samplingDecision(sc.trace.samplingDecision)
1327+
assert.Equal(t, decisionNone, got,
1328+
"drop decision from external context should not be propagated directly; "+
1329+
"samplingDecision should remain decisionNone so keep()/drop() CAS works")
1330+
1331+
// Priority should still reflect the external intent (P0).
1332+
pri, ok := sc.SamplingPriority()
1333+
require.True(t, ok)
1334+
assert.Equal(t, int(ext.PriorityAutoReject), pri,
1335+
"priority should be set to AutoReject (P0) to preserve the external sampling intent")
1336+
1337+
// Trace should be locked to prevent the DD sampler from overriding P0.
1338+
assert.True(t, sc.trace.isLocked(),
1339+
"trace should be locked to prevent resampling")
1340+
1341+
// keep() CAS should succeed since samplingDecision is decisionNone.
1342+
sc.trace.keep()
1343+
got = samplingDecision(sc.trace.samplingDecision)
1344+
assert.Equal(t, decisionKeep, got,
1345+
"keep() should be able to CAS from decisionNone to decisionKeep, "+
1346+
"allowing error spans to rescue the trace")
1347+
})
1348+
1349+
t.Run("keep_propagated", func(t *testing.T) {
1350+
// When an external context signals a keep decision, FromGenericCtx
1351+
// should propagate decisionKeep directly so the trace is guaranteed
1352+
// to be sent.
1353+
p := float64(ext.PriorityAutoKeep)
1354+
ctx := &genericCtxWithDM{
1355+
decision: uint32(decisionKeep),
1356+
priority: &p,
1357+
}
1358+
sc := FromGenericCtx(ctx)
1359+
require.NotNil(t, sc.trace)
1360+
1361+
got := samplingDecision(sc.trace.samplingDecision)
1362+
assert.Equal(t, decisionKeep, got,
1363+
"keep decision from external context should be propagated directly")
1364+
1365+
pri, ok := sc.SamplingPriority()
1366+
require.True(t, ok)
1367+
assert.Equal(t, int(ext.PriorityAutoKeep), pri)
1368+
1369+
assert.True(t, sc.trace.isLocked(),
1370+
"trace should be locked to prevent resampling")
1371+
})
1372+
1373+
t.Run("drop_error_span_rescue", func(t *testing.T) {
1374+
// Simulate the OTel bridge scenario: unsampled parent produces a child
1375+
// span that has an error. The error span should rescue the trace via
1376+
// keep(), which only works if samplingDecision starts as decisionNone.
1377+
p := float64(ext.PriorityAutoReject)
1378+
ctx := &genericCtxWithDM{
1379+
decision: uint32(decisionDrop),
1380+
priority: &p,
1381+
}
1382+
sc := FromGenericCtx(ctx)
1383+
require.NotNil(t, sc.trace)
1384+
1385+
// Simulate an error on the trace (as setErrorFlagLocked would do).
1386+
sc.errors.Add(1)
1387+
assert.Greater(t, sc.errors.Load(), int32(0))
1388+
1389+
// keep() must succeed: CAS(decisionNone -> decisionKeep).
1390+
sc.trace.keep()
1391+
got := samplingDecision(sc.trace.samplingDecision)
1392+
assert.Equal(t, decisionKeep, got,
1393+
"error span should rescue the trace: keep() must CAS to decisionKeep")
1394+
1395+
// After keep(), drop() should be a no-op (CAS from decisionNone fails).
1396+
sc.trace.drop()
1397+
got = samplingDecision(sc.trace.samplingDecision)
1398+
assert.Equal(t, decisionKeep, got,
1399+
"drop() after keep() should be a no-op — the trace stays rescued")
1400+
})
1401+
}

ddtrace/tracer/tracer_test.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2830,6 +2830,135 @@ func TestEmptyChunksNotSent(t *testing.T) {
28302830
assert.Equal(decisionNone, span.context.trace.samplingDecision)
28312831
}
28322832

2833+
// TestOTelBridgeP0StatsAndErrorRescue verifies the full pipeline for spans
2834+
// created under an unsampled OTel parent (P0 via FromGenericCtx).
2835+
//
2836+
// With client-side stats enabled (the v2 default), the tracer's concentrator
2837+
// should compute stats from P0 spans regardless of the sampling decision.
2838+
// Additionally, if an error span is present, the keep() CAS should rescue
2839+
// the trace and the payload should be sent to the agent.
2840+
func TestOTelBridgeP0StatsAndErrorRescue(t *testing.T) {
2841+
t.Run("p0_dropped_stats_computed", func(t *testing.T) {
2842+
// Unsampled OTel parent, no errors: the trace payload should be
2843+
// dropped (P0 + canDropP0s), but stats should still be computed
2844+
// by the concentrator from the finished spans.
2845+
trc, transport, _, stop, err := startTestTracer(t,
2846+
WithStatsComputation(true),
2847+
WithService("test_service"),
2848+
)
2849+
require.NoError(t, err)
2850+
2851+
// Build a SpanContext that simulates an unsampled OTel parent.
2852+
dropPri := float64(ext.PriorityAutoReject)
2853+
otelParent := FromGenericCtx(&otelBridgeCtx{
2854+
decision: uint32(decisionDrop),
2855+
priority: &dropPri,
2856+
})
2857+
2858+
span := trc.StartSpan("http.server.request", ChildOf(otelParent))
2859+
span.Finish()
2860+
stop()
2861+
2862+
// Trace payload should be empty: P0 with no error rescue.
2863+
traces := transport.Traces()
2864+
assert.Empty(t, traces, "P0 trace without errors should not be sent as a payload")
2865+
2866+
// Stats should have been computed by the concentrator.
2867+
stats := transport.Stats()
2868+
require.NotEmpty(t, stats, "concentrator should have produced stats from the P0 span")
2869+
found := false
2870+
for _, sp := range stats {
2871+
for _, bucket := range sp.Stats {
2872+
for _, group := range bucket.Stats {
2873+
if group.Name == "http.server.request" {
2874+
found = true
2875+
}
2876+
}
2877+
}
2878+
}
2879+
assert.True(t, found,
2880+
"stats should contain an entry for the 'http.server.request' operation")
2881+
})
2882+
2883+
t.Run("p0_error_rescued", func(t *testing.T) {
2884+
// Unsampled OTel parent, but an error span is present: keep() should
2885+
// CAS(decisionNone -> decisionKeep), rescuing the trace.
2886+
trc, transport, flush, stop, err := startTestTracer(t,
2887+
WithStatsComputation(true),
2888+
WithService("test_service"),
2889+
)
2890+
require.NoError(t, err)
2891+
2892+
dropPri := float64(ext.PriorityAutoReject)
2893+
otelParent := FromGenericCtx(&otelBridgeCtx{
2894+
decision: uint32(decisionDrop),
2895+
priority: &dropPri,
2896+
})
2897+
2898+
span := trc.StartSpan("http.server.request", ChildOf(otelParent))
2899+
span.SetTag(ext.Error, true)
2900+
span.Finish()
2901+
flush(1)
2902+
stop()
2903+
2904+
traces := transport.Traces()
2905+
require.Len(t, traces, 1, "error span should rescue the P0 trace")
2906+
assert.Equal(t, "http.server.request", traces[0][0].name)
2907+
assert.Equal(t, decisionKeep, span.context.trace.samplingDecision,
2908+
"keep() CAS should have flipped samplingDecision to decisionKeep")
2909+
})
2910+
2911+
t.Run("p0_sent_when_stats_disabled", func(t *testing.T) {
2912+
// When client-side stats are disabled (DD_TRACE_STATS_COMPUTATION_ENABLED=false),
2913+
// canDropP0s() returns false and P0 traces must be sent to the agent so
2914+
// it can compute stats from them. This is the path that broke for OTel
2915+
// bridge spans before the fix: samplingDecision was hard-set to
2916+
// decisionDrop, so the trace was never sent regardless of canDropP0s.
2917+
trc, transport, flush, stop, err := startTestTracer(t,
2918+
WithStatsComputation(false),
2919+
WithService("test_service"),
2920+
)
2921+
require.NoError(t, err)
2922+
2923+
dropPri := float64(ext.PriorityAutoReject)
2924+
otelParent := FromGenericCtx(&otelBridgeCtx{
2925+
decision: uint32(decisionDrop),
2926+
priority: &dropPri,
2927+
})
2928+
2929+
span := trc.StartSpan("http.server.request", ChildOf(otelParent))
2930+
span.Finish()
2931+
flush(1)
2932+
stop()
2933+
2934+
// With stats disabled the tracer must keep all traces (even P0) so
2935+
// the agent can compute stats server-side.
2936+
traces := transport.Traces()
2937+
require.Len(t, traces, 1,
2938+
"P0 trace should be sent when client-side stats are disabled")
2939+
assert.Equal(t, "http.server.request", traces[0][0].name)
2940+
assert.Equal(t, decisionKeep, span.context.trace.samplingDecision,
2941+
"samplingDecision should be decisionKeep when canDropP0s is false")
2942+
})
2943+
}
2944+
2945+
// otelBridgeCtx simulates a span context from the OTel bridge with a
2946+
// configurable sampling decision and priority. It implements
2947+
// spanContextWithSamplingDecision but NOT spanContextV1Adapter, matching
2948+
// the interface that otelCtxToDDCtx provides in production.
2949+
type otelBridgeCtx struct {
2950+
decision uint32
2951+
priority *float64
2952+
}
2953+
2954+
func (c *otelBridgeCtx) SpanID() uint64 { return 1 }
2955+
func (c *otelBridgeCtx) TraceID() string { return "1" }
2956+
func (c *otelBridgeCtx) TraceIDBytes() [16]byte { var b [16]byte; b[15] = 1; return b }
2957+
func (c *otelBridgeCtx) TraceIDLower() uint64 { return 1 }
2958+
func (c *otelBridgeCtx) ForeachBaggageItem(_ func(k, v string) bool) {}
2959+
func (c *otelBridgeCtx) SamplingDecision() uint32 { return c.decision }
2960+
func (c *otelBridgeCtx) Priority() *float64 { return c.priority }
2961+
28332962
func TestPPROFLabelRootSpanRace(t *testing.T) {
28342963
tracer, _, _, stop, err := startTestTracer(t)
28352964
assert.NoError(t, err)

0 commit comments

Comments
 (0)