From 220ad1c3347d5b2b313899fe735babce2f0b61c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Thu, 12 Mar 2026 17:54:45 +0000 Subject: [PATCH 01/71] feat(ddtrace/tracer): promote span fields from meta They are dual-stored values to avoid breaking v0.4 encoding. --- ddtrace/tracer/span.go | 36 ++++++++++++++++++++++++++++++----- ddtrace/tracer/span_test.go | 34 ++++++++++++++++++++++++++++++++- ddtrace/tracer/tracer_test.go | 30 ++++++++++++++--------------- 3 files changed, 78 insertions(+), 22 deletions(-) diff --git a/ddtrace/tracer/span.go b/ddtrace/tracer/span.go index d7cdceda284..18ff8f82946 100644 --- a/ddtrace/tracer/span.go +++ b/ddtrace/tracer/span.go @@ -132,6 +132,18 @@ type Span struct { // +checklocks:mu spanType string `msg:"type"` // protocol associated with the span (i.e. "web", "db", "cache") // +checklocks:mu + // env/version/component/spanKind are V1-protocol promoted fields. They are dual-stored: + // the struct field is used by the V1 encoder and internal callers to avoid a map lookup; + // the meta map copy ensures V0.4 (msgp) encoding and the external stats concentrator + // (which reads Meta["span.kind"]) continue to work without modification. + env string `msg:"-"` // ext.Environment tag value + // +checklocks:mu + version string `msg:"-"` // ext.Version tag value + // +checklocks:mu + component string `msg:"-"` // ext.Component tag value + // +checklocks:mu + spanKind string `msg:"-"` // ext.SpanKind tag value + // +checklocks:mu start int64 `msg:"start"` // span start time expressed in nanoseconds since epoch // +checklocks:mu duration int64 `msg:"duration"` // duration of the span expressed in nanoseconds @@ -740,23 +752,37 @@ func (s *Span) setMetaLocked(key, v string) { // setMetaInit sets a string tag without acquiring the lock and asserting the lock is held. // +checklocksignore — Initialization time, span not yet shared. func (s *Span) setMetaInit(key, v string) { - if s.meta == nil { - s.meta = initMeta() - } delete(s.metrics, key) switch key { case ext.SpanName: s.name = v + return case ext.ServiceName: s.service = v s.serviceSource = serviceSourceManual + return case ext.ResourceName: s.resource = v + return case ext.SpanType: s.spanType = v - default: - s.meta[key] = v + return + case ext.Environment: + s.env = v + case ext.Version: + s.version = v + case ext.Component: + s.component = v + case ext.SpanKind: + s.spanKind = v + } + // Promoted fields (env/version/component/spanKind) fall through here so they + // remain in meta too. The V0.4 encoder and the external stats concentrator both + // read directly from the meta map, so dual-storage is required for correctness. + if s.meta == nil { + s.meta = make(map[string]string, 1) } + s.meta[key] = v } // setMetaStructLocked sets structured metadata. This method assumes the span lock is already held. diff --git a/ddtrace/tracer/span_test.go b/ddtrace/tracer/span_test.go index 0244ffe59cc..db2741e8e44 100644 --- a/ddtrace/tracer/span_test.go +++ b/ddtrace/tracer/span_test.go @@ -428,7 +428,8 @@ func TestSpanSetTag(t *testing.T) { assert.Equal("web.request", span.name) span.SetTag("component", "tracer") - assert.Equal("tracer", span.meta["component"]) + assert.Equal("tracer", span.component) + assert.Equal("tracer", span.meta[ext.Component]) // dual-stored span.SetTag("tagInt", 1234) assert.Equal(float64(1234), span.metrics["tagInt"]) @@ -537,6 +538,37 @@ func TestSpanTagsStartSpan(t *testing.T) { assert.Equal("operation-name", tags[ext.SpanName]) } +// TestPromotedFieldsDualStorage verifies that setting any of the four V1-promoted +// tags (env, version, component, span.kind) via SetTag stores the value in both +// the dedicated struct field and the meta map. The struct field is the fast path +// used by the V1 encoder; the meta entry is required by the V0.4 (msgp) encoder +// and the external stats concentrator. +func TestPromotedFieldsDualStorage(t *testing.T) { + assert := assert.New(t) + + for _, tc := range []struct { + tag string + field func(*Span) string + }{ + {ext.Environment, func(s *Span) string { return s.env }}, + {ext.Version, func(s *Span) string { return s.version }}, + {ext.Component, func(s *Span) string { return s.component }}, + {ext.SpanKind, func(s *Span) string { return s.spanKind }}, + } { + t.Run(tc.tag, func(t *testing.T) { + span := newBasicSpan("op") + span.SetTag(tc.tag, "value") + assert.Equal("value", tc.field(span), "struct field must be set") + assert.Equal("value", span.meta[tc.tag], "meta map must be set (dual-storage)") + + // Overwrite: both sides should track the update. + span.SetTag(tc.tag, "updated") + assert.Equal("updated", tc.field(span)) + assert.Equal("updated", span.meta[tc.tag]) + }) + } +} + type testMsgpStruct struct { A string } diff --git a/ddtrace/tracer/tracer_test.go b/ddtrace/tracer/tracer_test.go index e344f57eeb8..52ae4efba00 100644 --- a/ddtrace/tracer/tracer_test.go +++ b/ddtrace/tracer/tracer_test.go @@ -2012,8 +2012,8 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request") - v := sp.meta[ext.Version] - assert.Equal("4.5.6", v) + assert.Equal("4.5.6", sp.version) + assert.Equal("4.5.6", sp.meta[ext.Version]) // dual-stored }) t.Run("service", func(t *testing.T) { tracer, _, _, stop, err := startTestTracer(t, WithServiceVersion("4.5.6"), @@ -2023,8 +2023,8 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request", ServiceName("otherservenv")) - _, ok := sp.meta[ext.Version] - assert.False(ok) + assert.Empty(sp.version) + assert.Empty(sp.meta[ext.Version]) // dual-stored }) t.Run("universal", func(t *testing.T) { tracer, _, _, stop, err := startTestTracer(t, WithService("servenv"), WithUniversalVersion("4.5.6")) @@ -2033,9 +2033,8 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request", ServiceName("otherservenv")) - v, ok := sp.meta[ext.Version] - assert.True(ok) - assert.Equal("4.5.6", v) + assert.Equal("4.5.6", sp.version) + assert.Equal("4.5.6", sp.meta[ext.Version]) // dual-stored }) t.Run("service/universal", func(t *testing.T) { tracer, _, _, stop, err := startTestTracer(t, WithServiceVersion("4.5.6"), @@ -2045,9 +2044,8 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request", ServiceName("otherservenv")) - v, ok := sp.meta[ext.Version] - assert.True(ok) - assert.Equal("1.2.3", v) + assert.Equal("1.2.3", sp.version) + assert.Equal("1.2.3", sp.meta[ext.Version]) // dual-stored }) t.Run("universal/service", func(t *testing.T) { tracer, _, _, stop, err := startTestTracer(t, WithUniversalVersion("1.2.3"), @@ -2057,8 +2055,8 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request", ServiceName("otherservenv")) - _, ok := sp.meta[ext.Version] - assert.False(ok) + assert.Empty(sp.version) + assert.Empty(sp.meta[ext.Version]) // dual-stored }) } @@ -2070,8 +2068,8 @@ func TestEnvironment(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request") - v := sp.meta[ext.Environment] - assert.Equal("test", v) + assert.Equal("test", sp.env) + assert.Equal("test", sp.meta[ext.Environment]) // dual-stored }) t.Run("unset", func(t *testing.T) { @@ -2081,8 +2079,8 @@ func TestEnvironment(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request") - _, ok := sp.meta[ext.Environment] - assert.False(ok) + assert.Empty(sp.env) + assert.Empty(sp.meta[ext.Environment]) // dual-stored }) } From 4d323b6618946fc0d9658a8ccd763ccbb25c4139 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Thu, 12 Mar 2026 18:04:44 +0000 Subject: [PATCH 02/71] feat(ddtrace/tracer): use span.env --- ddtrace/tracer/sampler.go | 2 +- ddtrace/tracer/sampler_test.go | 44 ++++++++++++---------------------- 2 files changed, 16 insertions(+), 30 deletions(-) diff --git a/ddtrace/tracer/sampler.go b/ddtrace/tracer/sampler.go index 3568829ca46..338de5067cc 100644 --- a/ddtrace/tracer/sampler.go +++ b/ddtrace/tracer/sampler.go @@ -275,7 +275,7 @@ func (ps *prioritySampler) getRate(spn *Span) float64 { // +checklocksignore — Called during initialization in StartSpan, span not yet shared. func (ps *prioritySampler) getRateLocked(spn *Span) float64 { assert.RWMutexRLocked(&ps.mu) - key := serviceEnvKey{service: spn.service, env: spn.meta[ext.Environment]} + key := serviceEnvKey{service: spn.service, env: spn.env} if rate, ok := ps.rates[key]; ok { return rate } diff --git a/ddtrace/tracer/sampler_test.go b/ddtrace/tracer/sampler_test.go index b93aede2606..ee78c8ca52d 100644 --- a/ddtrace/tracer/sampler_test.go +++ b/ddtrace/tracer/sampler_test.go @@ -59,10 +59,8 @@ func TestParseServiceEnvKey(t *testing.T) { func TestPrioritySampler(t *testing.T) { // create a new span with given service/env mkSpan := func(svc, env string) *Span { - s := &Span{service: svc, meta: map[string]string{}} - if env != "" { - s.meta["env"] = env - } + s := &Span{service: svc} + s.SetTag(ext.Environment, env) return s } @@ -70,12 +68,13 @@ func TestPrioritySampler(t *testing.T) { assert := assert.New(t) s := mkSpan("my-service", "my-env") assert.Equal("my-service", s.service) + assert.Equal("my-env", s.env) assert.Equal("my-env", s.meta[ext.Environment]) s = mkSpan("my-service2", "") assert.Equal("my-service2", s.service) - _, ok := s.meta[ext.Environment] - assert.False(ok) + assert.Empty(s.env) + assert.Contains(s.meta, ext.Environment) }) t.Run("ops", func(t *testing.T) { @@ -378,7 +377,7 @@ func BenchmarkPrioritySamplerGetRate(b *testing.B) { } oldGetRate := func(ops *oldPrioritySampler, spn *Span) float64 { // Allocation doesn't escape to the heap. - key := "service:" + spn.service + ",env:" + spn.meta[ext.Environment] + key := "service:" + spn.service + ",env:" + spn.env if rate, ok := ops.rates[key]; ok { return rate } @@ -395,10 +394,12 @@ func BenchmarkPrioritySamplerGetRate(b *testing.B) { ps.rates[serviceEnvKey{service: "web", env: "prod"}] = 0.5 spnHit := newSpan("op", "web", "resource", 1, 1, 0) - spnHit.meta[ext.Environment] = "prod" + spnHit.env = "prod" + spnHit.SetTag(ext.Environment, "prod") spnMiss := newSpan("op", "other", "resource", 1, 1, 0) - spnMiss.meta[ext.Environment] = "staging" + spnMiss.env = "staging" + spnMiss.SetTag(ext.Environment, "staging") b.ResetTimer() b.Run("old/hit", func(b *testing.B) { @@ -2364,10 +2365,7 @@ func TestPrioritySamplerRampCooldownNoReset(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - s := &Span{service: svc, meta: map[string]string{}} - if env != "" { - s.meta["env"] = env - } + s := &Span{service: svc, env: env} return s } @@ -2412,10 +2410,7 @@ func TestPrioritySamplerRampUp(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - s := &Span{service: svc, meta: map[string]string{}} - if env != "" { - s.meta["env"] = env - } + s := &Span{service: svc, env: env} return s } @@ -2457,10 +2452,7 @@ func TestPrioritySamplerRampDown(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - s := &Span{service: svc, meta: map[string]string{}} - if env != "" { - s.meta["env"] = env - } + s := &Span{service: svc, env: env} return s } @@ -2483,10 +2475,7 @@ func TestPrioritySamplerRampConverges(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - s := &Span{service: svc, meta: map[string]string{}} - if env != "" { - s.meta["env"] = env - } + s := &Span{service: svc, env: env} return s } @@ -2512,10 +2501,7 @@ func TestPrioritySamplerRampDefaultRate(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - s := &Span{service: svc, meta: map[string]string{}} - if env != "" { - s.meta["env"] = env - } + s := &Span{service: svc, env: env} return s } From f0107ae4241e2fe8bbace819cfecf1cdfe58cd81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Thu, 12 Mar 2026 18:05:01 +0000 Subject: [PATCH 03/71] feat(ddtrace/tracer): use span.spanKind --- ddtrace/tracer/spancontext.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ddtrace/tracer/spancontext.go b/ddtrace/tracer/spancontext.go index c35818bc85a..f1c8a4ee33b 100644 --- a/ddtrace/tracer/spancontext.go +++ b/ddtrace/tracer/spancontext.go @@ -845,8 +845,7 @@ func (t *trace) finishedOneLocked(s *Span) { // +checklocks:s.mu func setPeerService(s *Span, tc TracerConf) { assert.RWMutexLocked(&s.mu) - spanKind := s.meta[ext.SpanKind] - isOutboundRequest := spanKind == ext.SpanKindClient || spanKind == ext.SpanKindProducer + isOutboundRequest := s.spanKind == ext.SpanKindClient || s.spanKind == ext.SpanKindProducer if _, ok := s.meta[ext.PeerService]; ok { // peer.service already set on the span s.setMetaLocked(keyPeerServiceSource, ext.PeerService) From a59baa9c5fdfde2714fd644ddce01c91eee46565 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Thu, 12 Mar 2026 18:16:47 +0000 Subject: [PATCH 04/71] feat(ddtrace/tracer): evolve promoted fields to state-aware values with tagValue --- ddtrace/tracer/abandonedspans.go | 5 ++-- ddtrace/tracer/sampler.go | 2 +- ddtrace/tracer/sampler_test.go | 16 +++++------ ddtrace/tracer/span.go | 49 +++++++++++++++++++++++--------- ddtrace/tracer/span_test.go | 8 +++--- ddtrace/tracer/spancontext.go | 5 +++- 6 files changed, 54 insertions(+), 31 deletions(-) diff --git a/ddtrace/tracer/abandonedspans.go b/ddtrace/tracer/abandonedspans.go index a0aba9dfa2b..4a003cf46b8 100644 --- a/ddtrace/tracer/abandonedspans.go +++ b/ddtrace/tracer/abandonedspans.go @@ -14,7 +14,6 @@ import ( "sync/atomic" "time" - "github.com/DataDog/dd-trace-go/v2/ddtrace/ext" "github.com/DataDog/dd-trace-go/v2/internal/log" ) @@ -85,8 +84,8 @@ type abandonedSpanCandidate struct { // +checklocksignore — Called while span is locked or during initialization. func newAbandonedSpanCandidate(s *Span, finished bool) *abandonedSpanCandidate { var component string - if v, ok := s.meta[ext.Component]; ok { - component = v + if s.component.set { + component = s.component.v } else { component = "manual" } diff --git a/ddtrace/tracer/sampler.go b/ddtrace/tracer/sampler.go index 338de5067cc..d29d5264d7b 100644 --- a/ddtrace/tracer/sampler.go +++ b/ddtrace/tracer/sampler.go @@ -275,7 +275,7 @@ func (ps *prioritySampler) getRate(spn *Span) float64 { // +checklocksignore — Called during initialization in StartSpan, span not yet shared. func (ps *prioritySampler) getRateLocked(spn *Span) float64 { assert.RWMutexRLocked(&ps.mu) - key := serviceEnvKey{service: spn.service, env: spn.env} + key := serviceEnvKey{service: spn.service, env: spn.env.v} if rate, ok := ps.rates[key]; ok { return rate } diff --git a/ddtrace/tracer/sampler_test.go b/ddtrace/tracer/sampler_test.go index ee78c8ca52d..49279cc39da 100644 --- a/ddtrace/tracer/sampler_test.go +++ b/ddtrace/tracer/sampler_test.go @@ -377,7 +377,7 @@ func BenchmarkPrioritySamplerGetRate(b *testing.B) { } oldGetRate := func(ops *oldPrioritySampler, spn *Span) float64 { // Allocation doesn't escape to the heap. - key := "service:" + spn.service + ",env:" + spn.env + key := "service:" + spn.service + ",env:" + spn.env.v if rate, ok := ops.rates[key]; ok { return rate } @@ -394,11 +394,11 @@ func BenchmarkPrioritySamplerGetRate(b *testing.B) { ps.rates[serviceEnvKey{service: "web", env: "prod"}] = 0.5 spnHit := newSpan("op", "web", "resource", 1, 1, 0) - spnHit.env = "prod" + spnHit.env = tagValue{v: "prod", set: true} spnHit.SetTag(ext.Environment, "prod") spnMiss := newSpan("op", "other", "resource", 1, 1, 0) - spnMiss.env = "staging" + spnMiss.env = tagValue{v: "staging", set: true} spnMiss.SetTag(ext.Environment, "staging") b.ResetTimer() @@ -2365,7 +2365,7 @@ func TestPrioritySamplerRampCooldownNoReset(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - s := &Span{service: svc, env: env} + s := &Span{service: svc, env: tagValue{v: env, set: true}} return s } @@ -2410,7 +2410,7 @@ func TestPrioritySamplerRampUp(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - s := &Span{service: svc, env: env} + s := &Span{service: svc, env: tagValue{v: env, set: true}} return s } @@ -2452,7 +2452,7 @@ func TestPrioritySamplerRampDown(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - s := &Span{service: svc, env: env} + s := &Span{service: svc, env: tagValue{v: env, set: true}} return s } @@ -2475,7 +2475,7 @@ func TestPrioritySamplerRampConverges(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - s := &Span{service: svc, env: env} + s := &Span{service: svc, env: tagValue{v: env, set: true}} return s } @@ -2501,7 +2501,7 @@ func TestPrioritySamplerRampDefaultRate(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - s := &Span{service: svc, env: env} + s := &Span{service: svc, env: tagValue{v: env, set: true}} return s } diff --git a/ddtrace/tracer/span.go b/ddtrace/tracer/span.go index 18ff8f82946..677fb776506 100644 --- a/ddtrace/tracer/span.go +++ b/ddtrace/tracer/span.go @@ -117,6 +117,14 @@ func (s *Span) spanEventsAsJSONString() string { return string(events) } +// tagValue holds a tag string and a flag indicating whether it was explicitly set. +// The zero value represents an absent tag; an explicit SetTag with an empty string +// is represented as {v: "", set: true}. +type tagValue struct { + v string + set bool +} + // Span represents a computation. Callers must call Finish when a Span is // complete to ensure it's submitted. type Span struct { @@ -132,17 +140,18 @@ type Span struct { // +checklocks:mu spanType string `msg:"type"` // protocol associated with the span (i.e. "web", "db", "cache") // +checklocks:mu - // env/version/component/spanKind are V1-protocol promoted fields. They are dual-stored: - // the struct field is used by the V1 encoder and internal callers to avoid a map lookup; - // the meta map copy ensures V0.4 (msgp) encoding and the external stats concentrator - // (which reads Meta["span.kind"]) continue to work without modification. - env string `msg:"-"` // ext.Environment tag value + // env/version/component/spanKind are V1-protocol promoted fields stored as tagValue + // so callers can distinguish "never set" from "explicitly set to empty". They are + // dual-stored: the struct field is used by the V1 encoder and internal callers to + // avoid a map lookup; the meta map copy ensures V0.4 (msgp) encoding and the external + // stats concentrator (which reads Meta["span.kind"]) continue to work without change. + env tagValue `msg:"-"` // ext.Environment tag value // +checklocks:mu - version string `msg:"-"` // ext.Version tag value + version tagValue `msg:"-"` // ext.Version tag value // +checklocks:mu - component string `msg:"-"` // ext.Component tag value + component tagValue `msg:"-"` // ext.Component tag value // +checklocks:mu - spanKind string `msg:"-"` // ext.SpanKind tag value + spanKind tagValue `msg:"-"` // ext.SpanKind tag value // +checklocks:mu start int64 `msg:"start"` // span start time expressed in nanoseconds since epoch // +checklocks:mu @@ -303,8 +312,8 @@ func (s *Span) debugInfo() (name string, spanID, traceID uint64, integration str name = s.name spanID = s.spanID traceID = s.traceID - if v, ok := s.meta[ext.Component]; ok { - integration = v + if s.component.set { + integration = s.component.v } else { integration = "manual" } @@ -768,13 +777,13 @@ func (s *Span) setMetaInit(key, v string) { s.spanType = v return case ext.Environment: - s.env = v + s.env = tagValue{v: v, set: true} case ext.Version: - s.version = v + s.version = tagValue{v: v, set: true} case ext.Component: - s.component = v + s.component = tagValue{v: v, set: true} case ext.SpanKind: - s.spanKind = v + s.spanKind = tagValue{v: v, set: true} } // Promoted fields (env/version/component/spanKind) fall through here so they // remain in meta too. The V0.4 encoder and the external stats concentrator both @@ -1280,6 +1289,18 @@ func initMeta() map[string]string { func getMeta(s *Span, key string) (string, bool) { s.mu.RLock() defer s.mu.RUnlock() + // Fast path for promoted fields: tagValue.set gives the correct "was it set?" + // semantics without a map lookup. + switch key { + case ext.Environment: + return s.env.v, s.env.set + case ext.Version: + return s.version.v, s.version.set + case ext.Component: + return s.component.v, s.component.set + case ext.SpanKind: + return s.spanKind.v, s.spanKind.set + } val, ok := s.meta[key] return val, ok } diff --git a/ddtrace/tracer/span_test.go b/ddtrace/tracer/span_test.go index db2741e8e44..a426c69b5c8 100644 --- a/ddtrace/tracer/span_test.go +++ b/ddtrace/tracer/span_test.go @@ -550,10 +550,10 @@ func TestPromotedFieldsDualStorage(t *testing.T) { tag string field func(*Span) string }{ - {ext.Environment, func(s *Span) string { return s.env }}, - {ext.Version, func(s *Span) string { return s.version }}, - {ext.Component, func(s *Span) string { return s.component }}, - {ext.SpanKind, func(s *Span) string { return s.spanKind }}, + {ext.Environment, func(s *Span) string { return s.env.v }}, + {ext.Version, func(s *Span) string { return s.version.v }}, + {ext.Component, func(s *Span) string { return s.component.v }}, + {ext.SpanKind, func(s *Span) string { return s.spanKind.v }}, } { t.Run(tc.tag, func(t *testing.T) { span := newBasicSpan("op") diff --git a/ddtrace/tracer/spancontext.go b/ddtrace/tracer/spancontext.go index f1c8a4ee33b..c237bf01916 100644 --- a/ddtrace/tracer/spancontext.go +++ b/ddtrace/tracer/spancontext.go @@ -845,7 +845,10 @@ func (t *trace) finishedOneLocked(s *Span) { // +checklocks:s.mu func setPeerService(s *Span, tc TracerConf) { assert.RWMutexLocked(&s.mu) - isOutboundRequest := s.spanKind == ext.SpanKindClient || s.spanKind == ext.SpanKindProducer + // .v is used directly: only specific non-empty values ("client", "producer") qualify + // as outbound requests, so an unset and an explicitly-empty spanKind are both + // correctly treated as non-outbound. + isOutboundRequest := s.spanKind.v == ext.SpanKindClient || s.spanKind.v == ext.SpanKindProducer if _, ok := s.meta[ext.PeerService]; ok { // peer.service already set on the span s.setMetaLocked(keyPeerServiceSource, ext.PeerService) From 5b2d9e087a5f3be44394a9abbcd6bea28ed42b27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Thu, 12 Mar 2026 18:17:14 +0000 Subject: [PATCH 05/71] feat(ddtrace/tracer): encode promoted fields instead of looking up them in span.meta --- ddtrace/tracer/payload_v1.go | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/ddtrace/tracer/payload_v1.go b/ddtrace/tracer/payload_v1.go index ca70c6fc4c6..c1cf1468505 100644 --- a/ddtrace/tracer/payload_v1.go +++ b/ddtrace/tracer/payload_v1.go @@ -591,17 +591,12 @@ func (p *payloadV1) encodeSpans(bm bitmap, fieldID int, spans spanList, st *stri p.encodeSpanLinks(fullSetBitmap, 11, span.spanLinks, st) p.encodeSpanEvents(fullSetBitmap, 12, span.spanEvents, st) - env := span.meta[ext.Environment] - p.buf = encodeField(p.buf, fullSetBitmap, 13, env, st) - - version := span.meta[ext.Version] - p.buf = encodeField(p.buf, fullSetBitmap, 14, version, st) - - component := span.meta[ext.Component] - p.buf = encodeField(p.buf, fullSetBitmap, 15, component, st) - - spanKind := span.meta[ext.SpanKind] - p.buf = encodeField(p.buf, fullSetBitmap, 16, getSpanKindValue(spanKind), st) + // .v is used directly: an absent key and an empty value are both encoded as an + // empty string, so the "was it set?" distinction is irrelevant for wire encoding. + p.buf = encodeField(p.buf, fullSetBitmap, 13, span.env.v, st) + p.buf = encodeField(p.buf, fullSetBitmap, 14, span.version.v, st) + p.buf = encodeField(p.buf, fullSetBitmap, 15, span.component.v, st) + p.buf = encodeField(p.buf, fullSetBitmap, 16, getSpanKindValue(span.spanKind.v), st) } return true, nil } From 2ab1efd1c98f45c7a6a706bb42aead857a0d9837 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Fri, 13 Mar 2026 10:26:33 +0000 Subject: [PATCH 06/71] feat(ddtrace/tracer): introduce API to tagValue to avoid leaking implementation details --- ddtrace/tracer/abandonedspans.go | 4 ++-- ddtrace/tracer/payload_v1.go | 10 +++++----- ddtrace/tracer/sampler.go | 4 +++- ddtrace/tracer/sampler_test.go | 20 ++++++++++---------- ddtrace/tracer/span.go | 32 +++++++++++++++++++++----------- ddtrace/tracer/span_test.go | 10 +++++----- ddtrace/tracer/spancontext.go | 8 ++++---- ddtrace/tracer/tracer_test.go | 14 +++++++------- 8 files changed, 57 insertions(+), 45 deletions(-) diff --git a/ddtrace/tracer/abandonedspans.go b/ddtrace/tracer/abandonedspans.go index 4a003cf46b8..3459092ccbb 100644 --- a/ddtrace/tracer/abandonedspans.go +++ b/ddtrace/tracer/abandonedspans.go @@ -84,8 +84,8 @@ type abandonedSpanCandidate struct { // +checklocksignore — Called while span is locked or during initialization. func newAbandonedSpanCandidate(s *Span, finished bool) *abandonedSpanCandidate { var component string - if s.component.set { - component = s.component.v + if v, ok := s.component.get(); ok { + component = v } else { component = "manual" } diff --git a/ddtrace/tracer/payload_v1.go b/ddtrace/tracer/payload_v1.go index c1cf1468505..688dbb14754 100644 --- a/ddtrace/tracer/payload_v1.go +++ b/ddtrace/tracer/payload_v1.go @@ -591,12 +591,12 @@ func (p *payloadV1) encodeSpans(bm bitmap, fieldID int, spans spanList, st *stri p.encodeSpanLinks(fullSetBitmap, 11, span.spanLinks, st) p.encodeSpanEvents(fullSetBitmap, 12, span.spanEvents, st) - // .v is used directly: an absent key and an empty value are both encoded as an + // val() is used: an absent key and an empty value are both encoded as an // empty string, so the "was it set?" distinction is irrelevant for wire encoding. - p.buf = encodeField(p.buf, fullSetBitmap, 13, span.env.v, st) - p.buf = encodeField(p.buf, fullSetBitmap, 14, span.version.v, st) - p.buf = encodeField(p.buf, fullSetBitmap, 15, span.component.v, st) - p.buf = encodeField(p.buf, fullSetBitmap, 16, getSpanKindValue(span.spanKind.v), st) + p.buf = encodeField(p.buf, fullSetBitmap, 13, span.env.val(), st) + p.buf = encodeField(p.buf, fullSetBitmap, 14, span.version.val(), st) + p.buf = encodeField(p.buf, fullSetBitmap, 15, span.component.val(), st) + p.buf = encodeField(p.buf, fullSetBitmap, 16, getSpanKindValue(span.spanKind.val()), st) } return true, nil } diff --git a/ddtrace/tracer/sampler.go b/ddtrace/tracer/sampler.go index d29d5264d7b..3ad4f19b00a 100644 --- a/ddtrace/tracer/sampler.go +++ b/ddtrace/tracer/sampler.go @@ -275,7 +275,9 @@ func (ps *prioritySampler) getRate(spn *Span) float64 { // +checklocksignore — Called during initialization in StartSpan, span not yet shared. func (ps *prioritySampler) getRateLocked(spn *Span) float64 { assert.RWMutexRLocked(&ps.mu) - key := serviceEnvKey{service: spn.service, env: spn.env.v} + // val() is used: a span with env explicitly set to "" and one with env never set + // both map to the same rate-table key (both fall back to the default rate). + key := serviceEnvKey{service: spn.service, env: spn.env.val()} if rate, ok := ps.rates[key]; ok { return rate } diff --git a/ddtrace/tracer/sampler_test.go b/ddtrace/tracer/sampler_test.go index 49279cc39da..21840cf97ae 100644 --- a/ddtrace/tracer/sampler_test.go +++ b/ddtrace/tracer/sampler_test.go @@ -68,12 +68,12 @@ func TestPrioritySampler(t *testing.T) { assert := assert.New(t) s := mkSpan("my-service", "my-env") assert.Equal("my-service", s.service) - assert.Equal("my-env", s.env) + assert.Equal("my-env", s.env.val()) assert.Equal("my-env", s.meta[ext.Environment]) s = mkSpan("my-service2", "") assert.Equal("my-service2", s.service) - assert.Empty(s.env) + assert.Equal(tagOf(""), s.env) // set to empty string, not absent assert.Contains(s.meta, ext.Environment) }) @@ -377,7 +377,7 @@ func BenchmarkPrioritySamplerGetRate(b *testing.B) { } oldGetRate := func(ops *oldPrioritySampler, spn *Span) float64 { // Allocation doesn't escape to the heap. - key := "service:" + spn.service + ",env:" + spn.env.v + key := "service:" + spn.service + ",env:" + spn.env.val() if rate, ok := ops.rates[key]; ok { return rate } @@ -394,11 +394,11 @@ func BenchmarkPrioritySamplerGetRate(b *testing.B) { ps.rates[serviceEnvKey{service: "web", env: "prod"}] = 0.5 spnHit := newSpan("op", "web", "resource", 1, 1, 0) - spnHit.env = tagValue{v: "prod", set: true} + spnHit.env = tagOf("prod") spnHit.SetTag(ext.Environment, "prod") spnMiss := newSpan("op", "other", "resource", 1, 1, 0) - spnMiss.env = tagValue{v: "staging", set: true} + spnMiss.env = tagOf("staging") spnMiss.SetTag(ext.Environment, "staging") b.ResetTimer() @@ -2365,7 +2365,7 @@ func TestPrioritySamplerRampCooldownNoReset(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - s := &Span{service: svc, env: tagValue{v: env, set: true}} + s := &Span{service: svc, env: tagOf(env)} return s } @@ -2410,7 +2410,7 @@ func TestPrioritySamplerRampUp(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - s := &Span{service: svc, env: tagValue{v: env, set: true}} + s := &Span{service: svc, env: tagOf(env)} return s } @@ -2452,7 +2452,7 @@ func TestPrioritySamplerRampDown(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - s := &Span{service: svc, env: tagValue{v: env, set: true}} + s := &Span{service: svc, env: tagOf(env)} return s } @@ -2475,7 +2475,7 @@ func TestPrioritySamplerRampConverges(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - s := &Span{service: svc, env: tagValue{v: env, set: true}} + s := &Span{service: svc, env: tagOf(env)} return s } @@ -2501,7 +2501,7 @@ func TestPrioritySamplerRampDefaultRate(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - s := &Span{service: svc, env: tagValue{v: env, set: true}} + s := &Span{service: svc, env: tagOf(env)} return s } diff --git a/ddtrace/tracer/span.go b/ddtrace/tracer/span.go index 677fb776506..3a35f1443e9 100644 --- a/ddtrace/tracer/span.go +++ b/ddtrace/tracer/span.go @@ -119,12 +119,22 @@ func (s *Span) spanEventsAsJSONString() string { // tagValue holds a tag string and a flag indicating whether it was explicitly set. // The zero value represents an absent tag; an explicit SetTag with an empty string -// is represented as {v: "", set: true}. +// is represented as tagOf(""), and an absent value is tagValue{}. type tagValue struct { v string set bool } +// tagOf returns a tagValue that is marked as explicitly set. +func tagOf(v string) tagValue { return tagValue{v: v, set: true} } + +// val returns the string value, ignoring whether it was set. +func (t tagValue) val() string { return t.v } + +// get returns the string value and whether it was explicitly set, +// mirroring the two-value map lookup idiom. +func (t tagValue) get() (string, bool) { return t.v, t.set } + // Span represents a computation. Callers must call Finish when a Span is // complete to ensure it's submitted. type Span struct { @@ -312,8 +322,8 @@ func (s *Span) debugInfo() (name string, spanID, traceID uint64, integration str name = s.name spanID = s.spanID traceID = s.traceID - if s.component.set { - integration = s.component.v + if v, ok := s.component.get(); ok { + integration = v } else { integration = "manual" } @@ -777,13 +787,13 @@ func (s *Span) setMetaInit(key, v string) { s.spanType = v return case ext.Environment: - s.env = tagValue{v: v, set: true} + s.env = tagOf(v) case ext.Version: - s.version = tagValue{v: v, set: true} + s.version = tagOf(v) case ext.Component: - s.component = tagValue{v: v, set: true} + s.component = tagOf(v) case ext.SpanKind: - s.spanKind = tagValue{v: v, set: true} + s.spanKind = tagOf(v) } // Promoted fields (env/version/component/spanKind) fall through here so they // remain in meta too. The V0.4 encoder and the external stats concentrator both @@ -1293,13 +1303,13 @@ func getMeta(s *Span, key string) (string, bool) { // semantics without a map lookup. switch key { case ext.Environment: - return s.env.v, s.env.set + return s.env.get() case ext.Version: - return s.version.v, s.version.set + return s.version.get() case ext.Component: - return s.component.v, s.component.set + return s.component.get() case ext.SpanKind: - return s.spanKind.v, s.spanKind.set + return s.spanKind.get() } val, ok := s.meta[key] return val, ok diff --git a/ddtrace/tracer/span_test.go b/ddtrace/tracer/span_test.go index a426c69b5c8..b6cd74f05b0 100644 --- a/ddtrace/tracer/span_test.go +++ b/ddtrace/tracer/span_test.go @@ -428,7 +428,7 @@ func TestSpanSetTag(t *testing.T) { assert.Equal("web.request", span.name) span.SetTag("component", "tracer") - assert.Equal("tracer", span.component) + assert.Equal("tracer", span.component.val()) assert.Equal("tracer", span.meta[ext.Component]) // dual-stored span.SetTag("tagInt", 1234) @@ -550,10 +550,10 @@ func TestPromotedFieldsDualStorage(t *testing.T) { tag string field func(*Span) string }{ - {ext.Environment, func(s *Span) string { return s.env.v }}, - {ext.Version, func(s *Span) string { return s.version.v }}, - {ext.Component, func(s *Span) string { return s.component.v }}, - {ext.SpanKind, func(s *Span) string { return s.spanKind.v }}, + {ext.Environment, func(s *Span) string { return s.env.val() }}, + {ext.Version, func(s *Span) string { return s.version.val() }}, + {ext.Component, func(s *Span) string { return s.component.val() }}, + {ext.SpanKind, func(s *Span) string { return s.spanKind.val() }}, } { t.Run(tc.tag, func(t *testing.T) { span := newBasicSpan("op") diff --git a/ddtrace/tracer/spancontext.go b/ddtrace/tracer/spancontext.go index c237bf01916..e2b91b5b00d 100644 --- a/ddtrace/tracer/spancontext.go +++ b/ddtrace/tracer/spancontext.go @@ -845,10 +845,10 @@ func (t *trace) finishedOneLocked(s *Span) { // +checklocks:s.mu func setPeerService(s *Span, tc TracerConf) { assert.RWMutexLocked(&s.mu) - // .v is used directly: only specific non-empty values ("client", "producer") qualify - // as outbound requests, so an unset and an explicitly-empty spanKind are both - // correctly treated as non-outbound. - isOutboundRequest := s.spanKind.v == ext.SpanKindClient || s.spanKind.v == ext.SpanKindProducer + // val() is used: only specific non-empty values ("client", "producer") qualify as + // outbound requests, so an unset and an explicitly-empty spanKind are both correctly + // treated as non-outbound. + isOutboundRequest := s.spanKind.val() == ext.SpanKindClient || s.spanKind.val() == ext.SpanKindProducer if _, ok := s.meta[ext.PeerService]; ok { // peer.service already set on the span s.setMetaLocked(keyPeerServiceSource, ext.PeerService) diff --git a/ddtrace/tracer/tracer_test.go b/ddtrace/tracer/tracer_test.go index 52ae4efba00..a9b2fa05c5b 100644 --- a/ddtrace/tracer/tracer_test.go +++ b/ddtrace/tracer/tracer_test.go @@ -2012,7 +2012,7 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request") - assert.Equal("4.5.6", sp.version) + assert.Equal("4.5.6", sp.version.val()) assert.Equal("4.5.6", sp.meta[ext.Version]) // dual-stored }) t.Run("service", func(t *testing.T) { @@ -2023,7 +2023,7 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request", ServiceName("otherservenv")) - assert.Empty(sp.version) + assert.Equal(tagValue{}, sp.version) assert.Empty(sp.meta[ext.Version]) // dual-stored }) t.Run("universal", func(t *testing.T) { @@ -2033,7 +2033,7 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request", ServiceName("otherservenv")) - assert.Equal("4.5.6", sp.version) + assert.Equal("4.5.6", sp.version.val()) assert.Equal("4.5.6", sp.meta[ext.Version]) // dual-stored }) t.Run("service/universal", func(t *testing.T) { @@ -2044,7 +2044,7 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request", ServiceName("otherservenv")) - assert.Equal("1.2.3", sp.version) + assert.Equal("1.2.3", sp.version.val()) assert.Equal("1.2.3", sp.meta[ext.Version]) // dual-stored }) t.Run("universal/service", func(t *testing.T) { @@ -2055,7 +2055,7 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request", ServiceName("otherservenv")) - assert.Empty(sp.version) + assert.Equal(tagValue{}, sp.version) assert.Empty(sp.meta[ext.Version]) // dual-stored }) } @@ -2068,7 +2068,7 @@ func TestEnvironment(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request") - assert.Equal("test", sp.env) + assert.Equal("test", sp.env.val()) assert.Equal("test", sp.meta[ext.Environment]) // dual-stored }) @@ -2079,7 +2079,7 @@ func TestEnvironment(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request") - assert.Empty(sp.env) + assert.Equal(tagValue{}, sp.env) assert.Empty(sp.meta[ext.Environment]) // dual-stored }) } From ef61c9dd6f615e7f63793321a9571a45a46033df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Fri, 13 Mar 2026 11:14:19 +0000 Subject: [PATCH 07/71] chore(ddtrace/tracer): preparing refactor to internalize --- ddtrace/tracer/abandonedspans.go | 2 +- ddtrace/tracer/payload_v1.go | 8 +++--- ddtrace/tracer/sampler.go | 2 +- ddtrace/tracer/sampler_test.go | 20 +++++++------- ddtrace/tracer/span.go | 47 ++++++++++++++++++-------------- ddtrace/tracer/span_test.go | 10 +++---- ddtrace/tracer/spancontext.go | 2 +- ddtrace/tracer/tracer_test.go | 14 +++++----- 8 files changed, 55 insertions(+), 50 deletions(-) diff --git a/ddtrace/tracer/abandonedspans.go b/ddtrace/tracer/abandonedspans.go index 3459092ccbb..62ebdecb40b 100644 --- a/ddtrace/tracer/abandonedspans.go +++ b/ddtrace/tracer/abandonedspans.go @@ -84,7 +84,7 @@ type abandonedSpanCandidate struct { // +checklocksignore — Called while span is locked or during initialization. func newAbandonedSpanCandidate(s *Span, finished bool) *abandonedSpanCandidate { var component string - if v, ok := s.component.get(); ok { + if v, ok := s.attrs.component.get(); ok { component = v } else { component = "manual" diff --git a/ddtrace/tracer/payload_v1.go b/ddtrace/tracer/payload_v1.go index 688dbb14754..322ad5f03e0 100644 --- a/ddtrace/tracer/payload_v1.go +++ b/ddtrace/tracer/payload_v1.go @@ -593,10 +593,10 @@ func (p *payloadV1) encodeSpans(bm bitmap, fieldID int, spans spanList, st *stri // val() is used: an absent key and an empty value are both encoded as an // empty string, so the "was it set?" distinction is irrelevant for wire encoding. - p.buf = encodeField(p.buf, fullSetBitmap, 13, span.env.val(), st) - p.buf = encodeField(p.buf, fullSetBitmap, 14, span.version.val(), st) - p.buf = encodeField(p.buf, fullSetBitmap, 15, span.component.val(), st) - p.buf = encodeField(p.buf, fullSetBitmap, 16, getSpanKindValue(span.spanKind.val()), st) + p.buf = encodeField(p.buf, fullSetBitmap, 13, span.attrs.env.val(), st) + p.buf = encodeField(p.buf, fullSetBitmap, 14, span.attrs.version.val(), st) + p.buf = encodeField(p.buf, fullSetBitmap, 15, span.attrs.component.val(), st) + p.buf = encodeField(p.buf, fullSetBitmap, 16, getSpanKindValue(span.attrs.spanKind.val()), st) } return true, nil } diff --git a/ddtrace/tracer/sampler.go b/ddtrace/tracer/sampler.go index 3ad4f19b00a..16910291639 100644 --- a/ddtrace/tracer/sampler.go +++ b/ddtrace/tracer/sampler.go @@ -277,7 +277,7 @@ func (ps *prioritySampler) getRateLocked(spn *Span) float64 { assert.RWMutexRLocked(&ps.mu) // val() is used: a span with env explicitly set to "" and one with env never set // both map to the same rate-table key (both fall back to the default rate). - key := serviceEnvKey{service: spn.service, env: spn.env.val()} + key := serviceEnvKey{service: spn.service, env: spn.attrs.env.val()} if rate, ok := ps.rates[key]; ok { return rate } diff --git a/ddtrace/tracer/sampler_test.go b/ddtrace/tracer/sampler_test.go index 21840cf97ae..d271a6672b1 100644 --- a/ddtrace/tracer/sampler_test.go +++ b/ddtrace/tracer/sampler_test.go @@ -68,12 +68,12 @@ func TestPrioritySampler(t *testing.T) { assert := assert.New(t) s := mkSpan("my-service", "my-env") assert.Equal("my-service", s.service) - assert.Equal("my-env", s.env.val()) + assert.Equal("my-env", s.attrs.env.val()) assert.Equal("my-env", s.meta[ext.Environment]) s = mkSpan("my-service2", "") assert.Equal("my-service2", s.service) - assert.Equal(tagOf(""), s.env) // set to empty string, not absent + assert.Equal(tagOf(""), s.attrs.env) // set to empty string, not absent assert.Contains(s.meta, ext.Environment) }) @@ -377,7 +377,7 @@ func BenchmarkPrioritySamplerGetRate(b *testing.B) { } oldGetRate := func(ops *oldPrioritySampler, spn *Span) float64 { // Allocation doesn't escape to the heap. - key := "service:" + spn.service + ",env:" + spn.env.val() + key := "service:" + spn.service + ",env:" + spn.attrs.env.val() if rate, ok := ops.rates[key]; ok { return rate } @@ -394,11 +394,11 @@ func BenchmarkPrioritySamplerGetRate(b *testing.B) { ps.rates[serviceEnvKey{service: "web", env: "prod"}] = 0.5 spnHit := newSpan("op", "web", "resource", 1, 1, 0) - spnHit.env = tagOf("prod") + spnHit.attrs.env = tagOf("prod") spnHit.SetTag(ext.Environment, "prod") spnMiss := newSpan("op", "other", "resource", 1, 1, 0) - spnMiss.env = tagOf("staging") + spnMiss.attrs.env = tagOf("staging") spnMiss.SetTag(ext.Environment, "staging") b.ResetTimer() @@ -2365,7 +2365,7 @@ func TestPrioritySamplerRampCooldownNoReset(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - s := &Span{service: svc, env: tagOf(env)} + s := &Span{service: svc, attrs: spanAttributes{env: tagOf(env)}} return s } @@ -2410,7 +2410,7 @@ func TestPrioritySamplerRampUp(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - s := &Span{service: svc, env: tagOf(env)} + s := &Span{service: svc, attrs: spanAttributes{env: tagOf(env)}} return s } @@ -2452,7 +2452,7 @@ func TestPrioritySamplerRampDown(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - s := &Span{service: svc, env: tagOf(env)} + s := &Span{service: svc, attrs: spanAttributes{env: tagOf(env)}} return s } @@ -2475,7 +2475,7 @@ func TestPrioritySamplerRampConverges(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - s := &Span{service: svc, env: tagOf(env)} + s := &Span{service: svc, attrs: spanAttributes{env: tagOf(env)}} return s } @@ -2501,7 +2501,7 @@ func TestPrioritySamplerRampDefaultRate(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - s := &Span{service: svc, env: tagOf(env)} + s := &Span{service: svc, attrs: spanAttributes{env: tagOf(env)}} return s } diff --git a/ddtrace/tracer/span.go b/ddtrace/tracer/span.go index 3a35f1443e9..5fd8cc33c63 100644 --- a/ddtrace/tracer/span.go +++ b/ddtrace/tracer/span.go @@ -135,6 +135,17 @@ func (t tagValue) val() string { return t.v } // mirroring the two-value map lookup idiom. func (t tagValue) get() (string, bool) { return t.v, t.set } +// spanAttributes holds the four V1-protocol promoted span fields. +// Grouping them prevents accidental field-level initialization in struct literals +// and makes their shared encoding semantics explicit. +// The zero value of each tagValue means "never set". +type spanAttributes struct { + env tagValue // ext.Environment + version tagValue // ext.Version + component tagValue // ext.Component + spanKind tagValue // ext.SpanKind +} + // Span represents a computation. Callers must call Finish when a Span is // complete to ensure it's submitted. type Span struct { @@ -150,18 +161,12 @@ type Span struct { // +checklocks:mu spanType string `msg:"type"` // protocol associated with the span (i.e. "web", "db", "cache") // +checklocks:mu - // env/version/component/spanKind are V1-protocol promoted fields stored as tagValue - // so callers can distinguish "never set" from "explicitly set to empty". They are - // dual-stored: the struct field is used by the V1 encoder and internal callers to - // avoid a map lookup; the meta map copy ensures V0.4 (msgp) encoding and the external - // stats concentrator (which reads Meta["span.kind"]) continue to work without change. - env tagValue `msg:"-"` // ext.Environment tag value - // +checklocks:mu - version tagValue `msg:"-"` // ext.Version tag value - // +checklocks:mu - component tagValue `msg:"-"` // ext.Component tag value - // +checklocks:mu - spanKind tagValue `msg:"-"` // ext.SpanKind tag value + // attrs holds the four V1-protocol promoted fields (env, version, component, spanKind) + // as tagValue so callers can distinguish "never set" from "explicitly set to empty". + // They are dual-stored: attrs is used by the V1 encoder and internal callers to avoid + // a map lookup; the meta map copy ensures V0.4 (msgp) encoding and the external stats + // concentrator (which reads Meta["span.kind"]) continue to work without change. + attrs spanAttributes `msg:"-"` // +checklocks:mu start int64 `msg:"start"` // span start time expressed in nanoseconds since epoch // +checklocks:mu @@ -322,7 +327,7 @@ func (s *Span) debugInfo() (name string, spanID, traceID uint64, integration str name = s.name spanID = s.spanID traceID = s.traceID - if v, ok := s.component.get(); ok { + if v, ok := s.attrs.component.get(); ok { integration = v } else { integration = "manual" @@ -787,13 +792,13 @@ func (s *Span) setMetaInit(key, v string) { s.spanType = v return case ext.Environment: - s.env = tagOf(v) + s.attrs.env = tagOf(v) case ext.Version: - s.version = tagOf(v) + s.attrs.version = tagOf(v) case ext.Component: - s.component = tagOf(v) + s.attrs.component = tagOf(v) case ext.SpanKind: - s.spanKind = tagOf(v) + s.attrs.spanKind = tagOf(v) } // Promoted fields (env/version/component/spanKind) fall through here so they // remain in meta too. The V0.4 encoder and the external stats concentrator both @@ -1303,13 +1308,13 @@ func getMeta(s *Span, key string) (string, bool) { // semantics without a map lookup. switch key { case ext.Environment: - return s.env.get() + return s.attrs.env.get() case ext.Version: - return s.version.get() + return s.attrs.version.get() case ext.Component: - return s.component.get() + return s.attrs.component.get() case ext.SpanKind: - return s.spanKind.get() + return s.attrs.spanKind.get() } val, ok := s.meta[key] return val, ok diff --git a/ddtrace/tracer/span_test.go b/ddtrace/tracer/span_test.go index b6cd74f05b0..cfd92a3d438 100644 --- a/ddtrace/tracer/span_test.go +++ b/ddtrace/tracer/span_test.go @@ -428,7 +428,7 @@ func TestSpanSetTag(t *testing.T) { assert.Equal("web.request", span.name) span.SetTag("component", "tracer") - assert.Equal("tracer", span.component.val()) + assert.Equal("tracer", span.attrs.component.val()) assert.Equal("tracer", span.meta[ext.Component]) // dual-stored span.SetTag("tagInt", 1234) @@ -550,10 +550,10 @@ func TestPromotedFieldsDualStorage(t *testing.T) { tag string field func(*Span) string }{ - {ext.Environment, func(s *Span) string { return s.env.val() }}, - {ext.Version, func(s *Span) string { return s.version.val() }}, - {ext.Component, func(s *Span) string { return s.component.val() }}, - {ext.SpanKind, func(s *Span) string { return s.spanKind.val() }}, + {ext.Environment, func(s *Span) string { return s.attrs.env.val() }}, + {ext.Version, func(s *Span) string { return s.attrs.version.val() }}, + {ext.Component, func(s *Span) string { return s.attrs.component.val() }}, + {ext.SpanKind, func(s *Span) string { return s.attrs.spanKind.val() }}, } { t.Run(tc.tag, func(t *testing.T) { span := newBasicSpan("op") diff --git a/ddtrace/tracer/spancontext.go b/ddtrace/tracer/spancontext.go index e2b91b5b00d..73644cb9ae6 100644 --- a/ddtrace/tracer/spancontext.go +++ b/ddtrace/tracer/spancontext.go @@ -848,7 +848,7 @@ func setPeerService(s *Span, tc TracerConf) { // val() is used: only specific non-empty values ("client", "producer") qualify as // outbound requests, so an unset and an explicitly-empty spanKind are both correctly // treated as non-outbound. - isOutboundRequest := s.spanKind.val() == ext.SpanKindClient || s.spanKind.val() == ext.SpanKindProducer + isOutboundRequest := s.attrs.spanKind.val() == ext.SpanKindClient || s.attrs.spanKind.val() == ext.SpanKindProducer if _, ok := s.meta[ext.PeerService]; ok { // peer.service already set on the span s.setMetaLocked(keyPeerServiceSource, ext.PeerService) diff --git a/ddtrace/tracer/tracer_test.go b/ddtrace/tracer/tracer_test.go index a9b2fa05c5b..5a9f8f66b29 100644 --- a/ddtrace/tracer/tracer_test.go +++ b/ddtrace/tracer/tracer_test.go @@ -2012,7 +2012,7 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request") - assert.Equal("4.5.6", sp.version.val()) + assert.Equal("4.5.6", sp.attrs.version.val()) assert.Equal("4.5.6", sp.meta[ext.Version]) // dual-stored }) t.Run("service", func(t *testing.T) { @@ -2023,7 +2023,7 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request", ServiceName("otherservenv")) - assert.Equal(tagValue{}, sp.version) + assert.Equal(tagValue{}, sp.attrs.version) assert.Empty(sp.meta[ext.Version]) // dual-stored }) t.Run("universal", func(t *testing.T) { @@ -2033,7 +2033,7 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request", ServiceName("otherservenv")) - assert.Equal("4.5.6", sp.version.val()) + assert.Equal("4.5.6", sp.attrs.version.val()) assert.Equal("4.5.6", sp.meta[ext.Version]) // dual-stored }) t.Run("service/universal", func(t *testing.T) { @@ -2044,7 +2044,7 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request", ServiceName("otherservenv")) - assert.Equal("1.2.3", sp.version.val()) + assert.Equal("1.2.3", sp.attrs.version.val()) assert.Equal("1.2.3", sp.meta[ext.Version]) // dual-stored }) t.Run("universal/service", func(t *testing.T) { @@ -2055,7 +2055,7 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request", ServiceName("otherservenv")) - assert.Equal(tagValue{}, sp.version) + assert.Equal(tagValue{}, sp.attrs.version) assert.Empty(sp.meta[ext.Version]) // dual-stored }) } @@ -2068,7 +2068,7 @@ func TestEnvironment(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request") - assert.Equal("test", sp.env.val()) + assert.Equal("test", sp.attrs.env.val()) assert.Equal("test", sp.meta[ext.Environment]) // dual-stored }) @@ -2079,7 +2079,7 @@ func TestEnvironment(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request") - assert.Equal(tagValue{}, sp.env) + assert.Equal(tagValue{}, sp.attrs.env) assert.Empty(sp.meta[ext.Environment]) // dual-stored }) } From 49cf5f3d373d71adf785dfc7a84e58432cfbb671 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Fri, 13 Mar 2026 15:11:40 +0000 Subject: [PATCH 08/71] chore(ddtrace/tracer): internalize SpanAttributes --- ddtrace/tracer/abandonedspans.go | 2 +- ddtrace/tracer/internal/span_attributes.go | 34 ++++++++++++++ ddtrace/tracer/payload_v1.go | 8 ++-- ddtrace/tracer/sampler.go | 2 +- ddtrace/tracer/sampler_test.go | 20 ++++---- ddtrace/tracer/span.go | 53 +++++++--------------- ddtrace/tracer/span_test.go | 10 ++-- ddtrace/tracer/spancontext.go | 3 +- ddtrace/tracer/tracer_test.go | 14 +++--- 9 files changed, 81 insertions(+), 65 deletions(-) create mode 100644 ddtrace/tracer/internal/span_attributes.go diff --git a/ddtrace/tracer/abandonedspans.go b/ddtrace/tracer/abandonedspans.go index 62ebdecb40b..add0d5fcad9 100644 --- a/ddtrace/tracer/abandonedspans.go +++ b/ddtrace/tracer/abandonedspans.go @@ -84,7 +84,7 @@ type abandonedSpanCandidate struct { // +checklocksignore — Called while span is locked or during initialization. func newAbandonedSpanCandidate(s *Span, finished bool) *abandonedSpanCandidate { var component string - if v, ok := s.attrs.component.get(); ok { + if v, ok := s.attrs.Component.Get(); ok { component = v } else { component = "manual" diff --git a/ddtrace/tracer/internal/span_attributes.go b/ddtrace/tracer/internal/span_attributes.go new file mode 100644 index 00000000000..39961523d32 --- /dev/null +++ b/ddtrace/tracer/internal/span_attributes.go @@ -0,0 +1,34 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016 Datadog, Inc. + +package internal + +// TagValue holds a tag string and a flag indicating whether it was explicitly set. +// The zero value represents an absent tag; TagOf("") represents an explicit empty string. +type TagValue struct { + v string + set bool +} + +// TagOf returns a TagValue that is marked as explicitly set. +func TagOf(v string) TagValue { return TagValue{v: v, set: true} } + +// Val returns the string value, ignoring whether it was set. +func (t TagValue) Val() string { return t.v } + +// Get returns the string value and whether it was explicitly set, +// mirroring the two-value map lookup idiom. +func (t TagValue) Get() (string, bool) { return t.v, t.set } + +// SpanAttributes holds the four V1-protocol promoted span fields. +// Grouping them prevents accidental field-level initialization in struct literals +// and makes their shared encoding semantics explicit. +// The zero value of each TagValue means "never set". +type SpanAttributes struct { + Env TagValue // ext.Environment + Version TagValue // ext.Version + Component TagValue // ext.Component + SpanKind TagValue // ext.SpanKind +} diff --git a/ddtrace/tracer/payload_v1.go b/ddtrace/tracer/payload_v1.go index 322ad5f03e0..10b32045cfb 100644 --- a/ddtrace/tracer/payload_v1.go +++ b/ddtrace/tracer/payload_v1.go @@ -593,10 +593,10 @@ func (p *payloadV1) encodeSpans(bm bitmap, fieldID int, spans spanList, st *stri // val() is used: an absent key and an empty value are both encoded as an // empty string, so the "was it set?" distinction is irrelevant for wire encoding. - p.buf = encodeField(p.buf, fullSetBitmap, 13, span.attrs.env.val(), st) - p.buf = encodeField(p.buf, fullSetBitmap, 14, span.attrs.version.val(), st) - p.buf = encodeField(p.buf, fullSetBitmap, 15, span.attrs.component.val(), st) - p.buf = encodeField(p.buf, fullSetBitmap, 16, getSpanKindValue(span.attrs.spanKind.val()), st) + p.buf = encodeField(p.buf, fullSetBitmap, 13, span.attrs.Env.Val(), st) + p.buf = encodeField(p.buf, fullSetBitmap, 14, span.attrs.Version.Val(), st) + p.buf = encodeField(p.buf, fullSetBitmap, 15, span.attrs.Component.Val(), st) + p.buf = encodeField(p.buf, fullSetBitmap, 16, getSpanKindValue(span.attrs.SpanKind.Val()), st) } return true, nil } diff --git a/ddtrace/tracer/sampler.go b/ddtrace/tracer/sampler.go index 16910291639..0384a825e1b 100644 --- a/ddtrace/tracer/sampler.go +++ b/ddtrace/tracer/sampler.go @@ -277,7 +277,7 @@ func (ps *prioritySampler) getRateLocked(spn *Span) float64 { assert.RWMutexRLocked(&ps.mu) // val() is used: a span with env explicitly set to "" and one with env never set // both map to the same rate-table key (both fall back to the default rate). - key := serviceEnvKey{service: spn.service, env: spn.attrs.env.val()} + key := serviceEnvKey{service: spn.service, env: spn.attrs.Env.Val()} if rate, ok := ps.rates[key]; ok { return rate } diff --git a/ddtrace/tracer/sampler_test.go b/ddtrace/tracer/sampler_test.go index d271a6672b1..a1126b0b466 100644 --- a/ddtrace/tracer/sampler_test.go +++ b/ddtrace/tracer/sampler_test.go @@ -68,12 +68,12 @@ func TestPrioritySampler(t *testing.T) { assert := assert.New(t) s := mkSpan("my-service", "my-env") assert.Equal("my-service", s.service) - assert.Equal("my-env", s.attrs.env.val()) + assert.Equal("my-env", s.attrs.Env.Val()) assert.Equal("my-env", s.meta[ext.Environment]) s = mkSpan("my-service2", "") assert.Equal("my-service2", s.service) - assert.Equal(tagOf(""), s.attrs.env) // set to empty string, not absent + assert.Equal(tagOf(""), s.attrs.Env) // set to empty string, not absent assert.Contains(s.meta, ext.Environment) }) @@ -377,7 +377,7 @@ func BenchmarkPrioritySamplerGetRate(b *testing.B) { } oldGetRate := func(ops *oldPrioritySampler, spn *Span) float64 { // Allocation doesn't escape to the heap. - key := "service:" + spn.service + ",env:" + spn.attrs.env.val() + key := "service:" + spn.service + ",env:" + spn.attrs.Env.Val() if rate, ok := ops.rates[key]; ok { return rate } @@ -394,11 +394,11 @@ func BenchmarkPrioritySamplerGetRate(b *testing.B) { ps.rates[serviceEnvKey{service: "web", env: "prod"}] = 0.5 spnHit := newSpan("op", "web", "resource", 1, 1, 0) - spnHit.attrs.env = tagOf("prod") + spnHit.attrs.Env = tagOf("prod") spnHit.SetTag(ext.Environment, "prod") spnMiss := newSpan("op", "other", "resource", 1, 1, 0) - spnMiss.attrs.env = tagOf("staging") + spnMiss.attrs.Env = tagOf("staging") spnMiss.SetTag(ext.Environment, "staging") b.ResetTimer() @@ -2365,7 +2365,7 @@ func TestPrioritySamplerRampCooldownNoReset(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - s := &Span{service: svc, attrs: spanAttributes{env: tagOf(env)}} + s := &Span{service: svc, attrs: spanAttributes{Env: tagOf(env)}} return s } @@ -2410,7 +2410,7 @@ func TestPrioritySamplerRampUp(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - s := &Span{service: svc, attrs: spanAttributes{env: tagOf(env)}} + s := &Span{service: svc, attrs: spanAttributes{Env: tagOf(env)}} return s } @@ -2452,7 +2452,7 @@ func TestPrioritySamplerRampDown(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - s := &Span{service: svc, attrs: spanAttributes{env: tagOf(env)}} + s := &Span{service: svc, attrs: spanAttributes{Env: tagOf(env)}} return s } @@ -2475,7 +2475,7 @@ func TestPrioritySamplerRampConverges(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - s := &Span{service: svc, attrs: spanAttributes{env: tagOf(env)}} + s := &Span{service: svc, attrs: spanAttributes{Env: tagOf(env)}} return s } @@ -2501,7 +2501,7 @@ func TestPrioritySamplerRampDefaultRate(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - s := &Span{service: svc, attrs: spanAttributes{env: tagOf(env)}} + s := &Span{service: svc, attrs: spanAttributes{Env: tagOf(env)}} return s } diff --git a/ddtrace/tracer/span.go b/ddtrace/tracer/span.go index 5fd8cc33c63..019e3304771 100644 --- a/ddtrace/tracer/span.go +++ b/ddtrace/tracer/span.go @@ -26,6 +26,7 @@ import ( "github.com/DataDog/dd-trace-go/v2/ddtrace/ext" "github.com/DataDog/dd-trace-go/v2/instrumentation/errortrace" sharedinternal "github.com/DataDog/dd-trace-go/v2/internal" + tinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" "github.com/DataDog/dd-trace-go/v2/internal/env" "github.com/DataDog/dd-trace-go/v2/internal/globalconfig" illmobs "github.com/DataDog/dd-trace-go/v2/internal/llmobs" @@ -117,34 +118,14 @@ func (s *Span) spanEventsAsJSONString() string { return string(events) } -// tagValue holds a tag string and a flag indicating whether it was explicitly set. -// The zero value represents an absent tag; an explicit SetTag with an empty string -// is represented as tagOf(""), and an absent value is tagValue{}. -type tagValue struct { - v string - set bool -} +// tagValue is a package-level alias for tinternal.TagValue. +type tagValue = tinternal.TagValue // tagOf returns a tagValue that is marked as explicitly set. -func tagOf(v string) tagValue { return tagValue{v: v, set: true} } - -// val returns the string value, ignoring whether it was set. -func (t tagValue) val() string { return t.v } - -// get returns the string value and whether it was explicitly set, -// mirroring the two-value map lookup idiom. -func (t tagValue) get() (string, bool) { return t.v, t.set } - -// spanAttributes holds the four V1-protocol promoted span fields. -// Grouping them prevents accidental field-level initialization in struct literals -// and makes their shared encoding semantics explicit. -// The zero value of each tagValue means "never set". -type spanAttributes struct { - env tagValue // ext.Environment - version tagValue // ext.Version - component tagValue // ext.Component - spanKind tagValue // ext.SpanKind -} +func tagOf(v string) tagValue { return tinternal.TagOf(v) } + +// spanAttributes is a package-level alias for tinternal.SpanAttributes. +type spanAttributes = tinternal.SpanAttributes // Span represents a computation. Callers must call Finish when a Span is // complete to ensure it's submitted. @@ -327,7 +308,7 @@ func (s *Span) debugInfo() (name string, spanID, traceID uint64, integration str name = s.name spanID = s.spanID traceID = s.traceID - if v, ok := s.attrs.component.get(); ok { + if v, ok := s.attrs.Component.Get(); ok { integration = v } else { integration = "manual" @@ -792,13 +773,13 @@ func (s *Span) setMetaInit(key, v string) { s.spanType = v return case ext.Environment: - s.attrs.env = tagOf(v) + s.attrs.Env = tagOf(v) case ext.Version: - s.attrs.version = tagOf(v) + s.attrs.Version = tagOf(v) case ext.Component: - s.attrs.component = tagOf(v) + s.attrs.Component = tagOf(v) case ext.SpanKind: - s.attrs.spanKind = tagOf(v) + s.attrs.SpanKind = tagOf(v) } // Promoted fields (env/version/component/spanKind) fall through here so they // remain in meta too. The V0.4 encoder and the external stats concentrator both @@ -1304,17 +1285,17 @@ func initMeta() map[string]string { func getMeta(s *Span, key string) (string, bool) { s.mu.RLock() defer s.mu.RUnlock() - // Fast path for promoted fields: tagValue.set gives the correct "was it set?" + // Fast path for promoted fields: TagValue.Set gives the correct "was it set?" // semantics without a map lookup. switch key { case ext.Environment: - return s.attrs.env.get() + return s.attrs.Env.Get() case ext.Version: - return s.attrs.version.get() + return s.attrs.Version.Get() case ext.Component: - return s.attrs.component.get() + return s.attrs.Component.Get() case ext.SpanKind: - return s.attrs.spanKind.get() + return s.attrs.SpanKind.Get() } val, ok := s.meta[key] return val, ok diff --git a/ddtrace/tracer/span_test.go b/ddtrace/tracer/span_test.go index cfd92a3d438..53e5fbb923a 100644 --- a/ddtrace/tracer/span_test.go +++ b/ddtrace/tracer/span_test.go @@ -428,7 +428,7 @@ func TestSpanSetTag(t *testing.T) { assert.Equal("web.request", span.name) span.SetTag("component", "tracer") - assert.Equal("tracer", span.attrs.component.val()) + assert.Equal("tracer", span.attrs.Component.Val()) assert.Equal("tracer", span.meta[ext.Component]) // dual-stored span.SetTag("tagInt", 1234) @@ -550,10 +550,10 @@ func TestPromotedFieldsDualStorage(t *testing.T) { tag string field func(*Span) string }{ - {ext.Environment, func(s *Span) string { return s.attrs.env.val() }}, - {ext.Version, func(s *Span) string { return s.attrs.version.val() }}, - {ext.Component, func(s *Span) string { return s.attrs.component.val() }}, - {ext.SpanKind, func(s *Span) string { return s.attrs.spanKind.val() }}, + {ext.Environment, func(s *Span) string { return s.attrs.Env.Val() }}, + {ext.Version, func(s *Span) string { return s.attrs.Version.Val() }}, + {ext.Component, func(s *Span) string { return s.attrs.Component.Val() }}, + {ext.SpanKind, func(s *Span) string { return s.attrs.SpanKind.Val() }}, } { t.Run(tc.tag, func(t *testing.T) { span := newBasicSpan("op") diff --git a/ddtrace/tracer/spancontext.go b/ddtrace/tracer/spancontext.go index 73644cb9ae6..4fc7bcfbf74 100644 --- a/ddtrace/tracer/spancontext.go +++ b/ddtrace/tracer/spancontext.go @@ -848,7 +848,8 @@ func setPeerService(s *Span, tc TracerConf) { // val() is used: only specific non-empty values ("client", "producer") qualify as // outbound requests, so an unset and an explicitly-empty spanKind are both correctly // treated as non-outbound. - isOutboundRequest := s.attrs.spanKind.val() == ext.SpanKindClient || s.attrs.spanKind.val() == ext.SpanKindProducer + spanKind := s.attrs.SpanKind.Val() + isOutboundRequest := spanKind == ext.SpanKindClient || spanKind == ext.SpanKindProducer if _, ok := s.meta[ext.PeerService]; ok { // peer.service already set on the span s.setMetaLocked(keyPeerServiceSource, ext.PeerService) diff --git a/ddtrace/tracer/tracer_test.go b/ddtrace/tracer/tracer_test.go index 5a9f8f66b29..718e48582fd 100644 --- a/ddtrace/tracer/tracer_test.go +++ b/ddtrace/tracer/tracer_test.go @@ -2012,7 +2012,7 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request") - assert.Equal("4.5.6", sp.attrs.version.val()) + assert.Equal("4.5.6", sp.attrs.Version.Val()) assert.Equal("4.5.6", sp.meta[ext.Version]) // dual-stored }) t.Run("service", func(t *testing.T) { @@ -2023,7 +2023,7 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request", ServiceName("otherservenv")) - assert.Equal(tagValue{}, sp.attrs.version) + assert.Equal(tagValue{}, sp.attrs.Version) assert.Empty(sp.meta[ext.Version]) // dual-stored }) t.Run("universal", func(t *testing.T) { @@ -2033,7 +2033,7 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request", ServiceName("otherservenv")) - assert.Equal("4.5.6", sp.attrs.version.val()) + assert.Equal("4.5.6", sp.attrs.Version.Val()) assert.Equal("4.5.6", sp.meta[ext.Version]) // dual-stored }) t.Run("service/universal", func(t *testing.T) { @@ -2044,7 +2044,7 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request", ServiceName("otherservenv")) - assert.Equal("1.2.3", sp.attrs.version.val()) + assert.Equal("1.2.3", sp.attrs.Version.Val()) assert.Equal("1.2.3", sp.meta[ext.Version]) // dual-stored }) t.Run("universal/service", func(t *testing.T) { @@ -2055,7 +2055,7 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request", ServiceName("otherservenv")) - assert.Equal(tagValue{}, sp.attrs.version) + assert.Equal(tagValue{}, sp.attrs.Version) assert.Empty(sp.meta[ext.Version]) // dual-stored }) } @@ -2068,7 +2068,7 @@ func TestEnvironment(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request") - assert.Equal("test", sp.attrs.env.val()) + assert.Equal("test", sp.attrs.Env.Val()) assert.Equal("test", sp.meta[ext.Environment]) // dual-stored }) @@ -2079,7 +2079,7 @@ func TestEnvironment(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request") - assert.Equal(tagValue{}, sp.attrs.env) + assert.Equal(tagValue{}, sp.attrs.Env) assert.Empty(sp.meta[ext.Environment]) // dual-stored }) } From b02162043d964e1ba172aaf4f03f9eef8621b347 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Fri, 13 Mar 2026 15:32:45 +0000 Subject: [PATCH 09/71] feat(ddtrace/tracer): enhancing API by using presence bitmap and accessing promoted fields with non-string values --- ddtrace/tracer/abandonedspans.go | 2 +- ddtrace/tracer/internal/span_attributes.go | 50 +++++++++++++--------- ddtrace/tracer/payload_v1.go | 8 ++-- ddtrace/tracer/sampler.go | 2 +- ddtrace/tracer/sampler_test.go | 37 +++++++++------- ddtrace/tracer/span.go | 31 +++++++------- ddtrace/tracer/span_test.go | 10 ++--- ddtrace/tracer/spancontext.go | 2 +- ddtrace/tracer/tracer_test.go | 20 ++++++--- 9 files changed, 92 insertions(+), 70 deletions(-) diff --git a/ddtrace/tracer/abandonedspans.go b/ddtrace/tracer/abandonedspans.go index add0d5fcad9..33598571c1a 100644 --- a/ddtrace/tracer/abandonedspans.go +++ b/ddtrace/tracer/abandonedspans.go @@ -84,7 +84,7 @@ type abandonedSpanCandidate struct { // +checklocksignore — Called while span is locked or during initialization. func newAbandonedSpanCandidate(s *Span, finished bool) *abandonedSpanCandidate { var component string - if v, ok := s.attrs.Component.Get(); ok { + if v, ok := s.attrs.Get(attrComponent); ok { component = v } else { component = "manual" diff --git a/ddtrace/tracer/internal/span_attributes.go b/ddtrace/tracer/internal/span_attributes.go index 39961523d32..2f192712d95 100644 --- a/ddtrace/tracer/internal/span_attributes.go +++ b/ddtrace/tracer/internal/span_attributes.go @@ -5,30 +5,38 @@ package internal -// TagValue holds a tag string and a flag indicating whether it was explicitly set. -// The zero value represents an absent tag; TagOf("") represents an explicit empty string. -type TagValue struct { - v string - set bool -} - -// TagOf returns a TagValue that is marked as explicitly set. -func TagOf(v string) TagValue { return TagValue{v: v, set: true} } +// AttrKey is an integer index into a SpanAttributes value array. +// Use the pre-declared constants; do not construct AttrKey from arbitrary integers. +type AttrKey uint8 -// Val returns the string value, ignoring whether it was set. -func (t TagValue) Val() string { return t.v } +const ( + AttrEnv AttrKey = 0 + AttrVersion AttrKey = 1 + AttrComponent AttrKey = 2 + AttrSpanKind AttrKey = 3 + numAttrs AttrKey = 4 +) -// Get returns the string value and whether it was explicitly set, -// mirroring the two-value map lookup idiom. -func (t TagValue) Get() (string, bool) { return t.v, t.set } +// Compile-time guard: the numeric values of AttrKey constants are load-bearing — +// they index both vals[] and setMask bit positions. If any value drifts (e.g. via +// iota + reorder), the expression below produces a compile error. +var ( + _ = [1]byte{}[AttrEnv] // AttrEnv must be 0 + _ = [1]byte{}[AttrVersion-1] // AttrVersion must be 1 + _ = [1]byte{}[AttrComponent-2] // AttrComponent must be 2 + _ = [1]byte{}[AttrSpanKind-3] // AttrSpanKind must be 3 +) // SpanAttributes holds the four V1-protocol promoted span fields. -// Grouping them prevents accidental field-level initialization in struct literals -// and makes their shared encoding semantics explicit. -// The zero value of each TagValue means "never set". +// Zero value = all fields absent. +// Set(key, "") is distinct from never-Set: the bit is set, the string is "". +// +// Layout: 1-byte setMask + 7B padding + [4]string (64B) = 72 bytes. type SpanAttributes struct { - Env TagValue // ext.Environment - Version TagValue // ext.Version - Component TagValue // ext.Component - SpanKind TagValue // ext.SpanKind + setMask uint8 + vals [numAttrs]string } + +func (a *SpanAttributes) Set(key AttrKey, v string) { a.vals[key] = v; a.setMask |= 1 << key } +func (a *SpanAttributes) Val(key AttrKey) string { return a.vals[key] } +func (a *SpanAttributes) Get(key AttrKey) (string, bool) { return a.vals[key], a.setMask>>key&1 != 0 } diff --git a/ddtrace/tracer/payload_v1.go b/ddtrace/tracer/payload_v1.go index 10b32045cfb..53c9a0feecc 100644 --- a/ddtrace/tracer/payload_v1.go +++ b/ddtrace/tracer/payload_v1.go @@ -593,10 +593,10 @@ func (p *payloadV1) encodeSpans(bm bitmap, fieldID int, spans spanList, st *stri // val() is used: an absent key and an empty value are both encoded as an // empty string, so the "was it set?" distinction is irrelevant for wire encoding. - p.buf = encodeField(p.buf, fullSetBitmap, 13, span.attrs.Env.Val(), st) - p.buf = encodeField(p.buf, fullSetBitmap, 14, span.attrs.Version.Val(), st) - p.buf = encodeField(p.buf, fullSetBitmap, 15, span.attrs.Component.Val(), st) - p.buf = encodeField(p.buf, fullSetBitmap, 16, getSpanKindValue(span.attrs.SpanKind.Val()), st) + p.buf = encodeField(p.buf, fullSetBitmap, 13, span.attrs.Val(attrEnv), st) + p.buf = encodeField(p.buf, fullSetBitmap, 14, span.attrs.Val(attrVersion), st) + p.buf = encodeField(p.buf, fullSetBitmap, 15, span.attrs.Val(attrComponent), st) + p.buf = encodeField(p.buf, fullSetBitmap, 16, getSpanKindValue(span.attrs.Val(attrSpanKind)), st) } return true, nil } diff --git a/ddtrace/tracer/sampler.go b/ddtrace/tracer/sampler.go index 0384a825e1b..be0aa9baa82 100644 --- a/ddtrace/tracer/sampler.go +++ b/ddtrace/tracer/sampler.go @@ -277,7 +277,7 @@ func (ps *prioritySampler) getRateLocked(spn *Span) float64 { assert.RWMutexRLocked(&ps.mu) // val() is used: a span with env explicitly set to "" and one with env never set // both map to the same rate-table key (both fall back to the default rate). - key := serviceEnvKey{service: spn.service, env: spn.attrs.Env.Val()} + key := serviceEnvKey{service: spn.service, env: spn.attrs.Val(attrEnv)} if rate, ok := ps.rates[key]; ok { return rate } diff --git a/ddtrace/tracer/sampler_test.go b/ddtrace/tracer/sampler_test.go index a1126b0b466..c00ca6ebba1 100644 --- a/ddtrace/tracer/sampler_test.go +++ b/ddtrace/tracer/sampler_test.go @@ -68,12 +68,14 @@ func TestPrioritySampler(t *testing.T) { assert := assert.New(t) s := mkSpan("my-service", "my-env") assert.Equal("my-service", s.service) - assert.Equal("my-env", s.attrs.Env.Val()) + assert.Equal("my-env", s.attrs.Val(attrEnv)) assert.Equal("my-env", s.meta[ext.Environment]) s = mkSpan("my-service2", "") assert.Equal("my-service2", s.service) - assert.Equal(tagOf(""), s.attrs.Env) // set to empty string, not absent + v, ok := s.attrs.Get(attrEnv) + assert.Equal("", v) + assert.True(ok) // set to empty string, not absent assert.Contains(s.meta, ext.Environment) }) @@ -377,7 +379,7 @@ func BenchmarkPrioritySamplerGetRate(b *testing.B) { } oldGetRate := func(ops *oldPrioritySampler, spn *Span) float64 { // Allocation doesn't escape to the heap. - key := "service:" + spn.service + ",env:" + spn.attrs.Env.Val() + key := "service:" + spn.service + ",env:" + spn.attrs.Val(attrEnv) if rate, ok := ops.rates[key]; ok { return rate } @@ -394,11 +396,11 @@ func BenchmarkPrioritySamplerGetRate(b *testing.B) { ps.rates[serviceEnvKey{service: "web", env: "prod"}] = 0.5 spnHit := newSpan("op", "web", "resource", 1, 1, 0) - spnHit.attrs.Env = tagOf("prod") + spnHit.attrs.Set(attrEnv, "prod") spnHit.SetTag(ext.Environment, "prod") spnMiss := newSpan("op", "other", "resource", 1, 1, 0) - spnMiss.attrs.Env = tagOf("staging") + spnMiss.attrs.Set(attrEnv, "staging") spnMiss.SetTag(ext.Environment, "staging") b.ResetTimer() @@ -2365,8 +2367,9 @@ func TestPrioritySamplerRampCooldownNoReset(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - s := &Span{service: svc, attrs: spanAttributes{Env: tagOf(env)}} - return s + var a spanAttributes + a.Set(attrEnv, env) + return &Span{service: svc, attrs: a} } // Set initial low rate. @@ -2410,8 +2413,9 @@ func TestPrioritySamplerRampUp(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - s := &Span{service: svc, attrs: spanAttributes{Env: tagOf(env)}} - return s + var a spanAttributes + a.Set(attrEnv, env) + return &Span{service: svc, attrs: a} } // Set initial low rate (decrease from default 1.0, applied immediately). @@ -2452,8 +2456,9 @@ func TestPrioritySamplerRampDown(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - s := &Span{service: svc, attrs: spanAttributes{Env: tagOf(env)}} - return s + var a spanAttributes + a.Set(attrEnv, env) + return &Span{service: svc, attrs: a} } // Set initial rate (decrease from default 1.0). @@ -2475,8 +2480,9 @@ func TestPrioritySamplerRampConverges(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - s := &Span{service: svc, attrs: spanAttributes{Env: tagOf(env)}} - return s + var a spanAttributes + a.Set(attrEnv, env) + return &Span{service: svc, attrs: a} } // Start at 0.1, target 0.5. @@ -2501,8 +2507,9 @@ func TestPrioritySamplerRampDefaultRate(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - s := &Span{service: svc, attrs: spanAttributes{Env: tagOf(env)}} - return s + var a spanAttributes + a.Set(attrEnv, env) + return &Span{service: svc, attrs: a} } // Set default rate to 0.1 (decrease from initial 1.0, applied immediately). diff --git a/ddtrace/tracer/span.go b/ddtrace/tracer/span.go index 019e3304771..6a5d5693e0d 100644 --- a/ddtrace/tracer/span.go +++ b/ddtrace/tracer/span.go @@ -118,15 +118,16 @@ func (s *Span) spanEventsAsJSONString() string { return string(events) } -// tagValue is a package-level alias for tinternal.TagValue. -type tagValue = tinternal.TagValue - -// tagOf returns a tagValue that is marked as explicitly set. -func tagOf(v string) tagValue { return tinternal.TagOf(v) } - // spanAttributes is a package-level alias for tinternal.SpanAttributes. type spanAttributes = tinternal.SpanAttributes +const ( + attrEnv = tinternal.AttrEnv + attrVersion = tinternal.AttrVersion + attrComponent = tinternal.AttrComponent + attrSpanKind = tinternal.AttrSpanKind +) + // Span represents a computation. Callers must call Finish when a Span is // complete to ensure it's submitted. type Span struct { @@ -308,7 +309,7 @@ func (s *Span) debugInfo() (name string, spanID, traceID uint64, integration str name = s.name spanID = s.spanID traceID = s.traceID - if v, ok := s.attrs.Component.Get(); ok { + if v, ok := s.attrs.Get(attrComponent); ok { integration = v } else { integration = "manual" @@ -773,13 +774,13 @@ func (s *Span) setMetaInit(key, v string) { s.spanType = v return case ext.Environment: - s.attrs.Env = tagOf(v) + s.attrs.Set(attrEnv, v) case ext.Version: - s.attrs.Version = tagOf(v) + s.attrs.Set(attrVersion, v) case ext.Component: - s.attrs.Component = tagOf(v) + s.attrs.Set(attrComponent, v) case ext.SpanKind: - s.attrs.SpanKind = tagOf(v) + s.attrs.Set(attrSpanKind, v) } // Promoted fields (env/version/component/spanKind) fall through here so they // remain in meta too. The V0.4 encoder and the external stats concentrator both @@ -1289,13 +1290,13 @@ func getMeta(s *Span, key string) (string, bool) { // semantics without a map lookup. switch key { case ext.Environment: - return s.attrs.Env.Get() + return s.attrs.Get(attrEnv) case ext.Version: - return s.attrs.Version.Get() + return s.attrs.Get(attrVersion) case ext.Component: - return s.attrs.Component.Get() + return s.attrs.Get(attrComponent) case ext.SpanKind: - return s.attrs.SpanKind.Get() + return s.attrs.Get(attrSpanKind) } val, ok := s.meta[key] return val, ok diff --git a/ddtrace/tracer/span_test.go b/ddtrace/tracer/span_test.go index 53e5fbb923a..4fcb11521ab 100644 --- a/ddtrace/tracer/span_test.go +++ b/ddtrace/tracer/span_test.go @@ -428,7 +428,7 @@ func TestSpanSetTag(t *testing.T) { assert.Equal("web.request", span.name) span.SetTag("component", "tracer") - assert.Equal("tracer", span.attrs.Component.Val()) + assert.Equal("tracer", span.attrs.Val(attrComponent)) assert.Equal("tracer", span.meta[ext.Component]) // dual-stored span.SetTag("tagInt", 1234) @@ -550,10 +550,10 @@ func TestPromotedFieldsDualStorage(t *testing.T) { tag string field func(*Span) string }{ - {ext.Environment, func(s *Span) string { return s.attrs.Env.Val() }}, - {ext.Version, func(s *Span) string { return s.attrs.Version.Val() }}, - {ext.Component, func(s *Span) string { return s.attrs.Component.Val() }}, - {ext.SpanKind, func(s *Span) string { return s.attrs.SpanKind.Val() }}, + {ext.Environment, func(s *Span) string { return s.attrs.Val(attrEnv) }}, + {ext.Version, func(s *Span) string { return s.attrs.Val(attrVersion) }}, + {ext.Component, func(s *Span) string { return s.attrs.Val(attrComponent) }}, + {ext.SpanKind, func(s *Span) string { return s.attrs.Val(attrSpanKind) }}, } { t.Run(tc.tag, func(t *testing.T) { span := newBasicSpan("op") diff --git a/ddtrace/tracer/spancontext.go b/ddtrace/tracer/spancontext.go index 4fc7bcfbf74..afad567a70d 100644 --- a/ddtrace/tracer/spancontext.go +++ b/ddtrace/tracer/spancontext.go @@ -848,7 +848,7 @@ func setPeerService(s *Span, tc TracerConf) { // val() is used: only specific non-empty values ("client", "producer") qualify as // outbound requests, so an unset and an explicitly-empty spanKind are both correctly // treated as non-outbound. - spanKind := s.attrs.SpanKind.Val() + spanKind := s.attrs.Val(attrSpanKind) isOutboundRequest := spanKind == ext.SpanKindClient || spanKind == ext.SpanKindProducer if _, ok := s.meta[ext.PeerService]; ok { // peer.service already set on the span diff --git a/ddtrace/tracer/tracer_test.go b/ddtrace/tracer/tracer_test.go index 718e48582fd..dd3198953bd 100644 --- a/ddtrace/tracer/tracer_test.go +++ b/ddtrace/tracer/tracer_test.go @@ -2012,7 +2012,7 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request") - assert.Equal("4.5.6", sp.attrs.Version.Val()) + assert.Equal("4.5.6", sp.attrs.Val(attrVersion)) assert.Equal("4.5.6", sp.meta[ext.Version]) // dual-stored }) t.Run("service", func(t *testing.T) { @@ -2023,7 +2023,9 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request", ServiceName("otherservenv")) - assert.Equal(tagValue{}, sp.attrs.Version) + v, ok := sp.attrs.Get(attrVersion) + assert.Equal("", v) + assert.False(ok) assert.Empty(sp.meta[ext.Version]) // dual-stored }) t.Run("universal", func(t *testing.T) { @@ -2033,7 +2035,7 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request", ServiceName("otherservenv")) - assert.Equal("4.5.6", sp.attrs.Version.Val()) + assert.Equal("4.5.6", sp.attrs.Val(attrVersion)) assert.Equal("4.5.6", sp.meta[ext.Version]) // dual-stored }) t.Run("service/universal", func(t *testing.T) { @@ -2044,7 +2046,7 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request", ServiceName("otherservenv")) - assert.Equal("1.2.3", sp.attrs.Version.Val()) + assert.Equal("1.2.3", sp.attrs.Val(attrVersion)) assert.Equal("1.2.3", sp.meta[ext.Version]) // dual-stored }) t.Run("universal/service", func(t *testing.T) { @@ -2055,7 +2057,9 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request", ServiceName("otherservenv")) - assert.Equal(tagValue{}, sp.attrs.Version) + v, ok := sp.attrs.Get(attrVersion) + assert.Equal("", v) + assert.False(ok) assert.Empty(sp.meta[ext.Version]) // dual-stored }) } @@ -2068,7 +2072,7 @@ func TestEnvironment(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request") - assert.Equal("test", sp.attrs.Env.Val()) + assert.Equal("test", sp.attrs.Val(attrEnv)) assert.Equal("test", sp.meta[ext.Environment]) // dual-stored }) @@ -2079,7 +2083,9 @@ func TestEnvironment(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request") - assert.Equal(tagValue{}, sp.attrs.Env) + v, ok := sp.attrs.Get(attrEnv) + assert.Equal("", v) + assert.False(ok) assert.Empty(sp.meta[ext.Environment]) // dual-stored }) } From 9bc84d9923f9c8b3abca72165e98aba783570d42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Mon, 16 Mar 2026 10:09:32 +0000 Subject: [PATCH 10/71] chore: make lint/go/fix --- ddtrace/tracer/span.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ddtrace/tracer/span.go b/ddtrace/tracer/span.go index 6a5d5693e0d..79cc082a35e 100644 --- a/ddtrace/tracer/span.go +++ b/ddtrace/tracer/span.go @@ -24,9 +24,9 @@ import ( "time" "github.com/DataDog/dd-trace-go/v2/ddtrace/ext" + tinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" "github.com/DataDog/dd-trace-go/v2/instrumentation/errortrace" sharedinternal "github.com/DataDog/dd-trace-go/v2/internal" - tinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" "github.com/DataDog/dd-trace-go/v2/internal/env" "github.com/DataDog/dd-trace-go/v2/internal/globalconfig" illmobs "github.com/DataDog/dd-trace-go/v2/internal/llmobs" From 5f64898c4a5d6dec658dda6cd755f0a62b638669 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Mon, 16 Mar 2026 11:32:50 +0000 Subject: [PATCH 11/71] chore(ddtrace/tracer): remove unexported alias --- ddtrace/tracer/abandonedspans.go | 3 ++- ddtrace/tracer/payload_v1.go | 9 +++++---- ddtrace/tracer/sampler.go | 3 ++- ddtrace/tracer/sampler_test.go | 31 ++++++++++++++++--------------- ddtrace/tracer/span.go | 30 ++++++++++-------------------- ddtrace/tracer/span_test.go | 11 ++++++----- ddtrace/tracer/spancontext.go | 3 ++- ddtrace/tracer/tracer_test.go | 15 ++++++++------- 8 files changed, 51 insertions(+), 54 deletions(-) diff --git a/ddtrace/tracer/abandonedspans.go b/ddtrace/tracer/abandonedspans.go index 33598571c1a..e349a5527e3 100644 --- a/ddtrace/tracer/abandonedspans.go +++ b/ddtrace/tracer/abandonedspans.go @@ -14,6 +14,7 @@ import ( "sync/atomic" "time" + tinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" "github.com/DataDog/dd-trace-go/v2/internal/log" ) @@ -84,7 +85,7 @@ type abandonedSpanCandidate struct { // +checklocksignore — Called while span is locked or during initialization. func newAbandonedSpanCandidate(s *Span, finished bool) *abandonedSpanCandidate { var component string - if v, ok := s.attrs.Get(attrComponent); ok { + if v, ok := s.attrs.Get(tinternal.AttrComponent); ok { component = v } else { component = "manual" diff --git a/ddtrace/tracer/payload_v1.go b/ddtrace/tracer/payload_v1.go index 53c9a0feecc..d7b89bee54a 100644 --- a/ddtrace/tracer/payload_v1.go +++ b/ddtrace/tracer/payload_v1.go @@ -16,6 +16,7 @@ import ( "github.com/tinylib/msgp/msgp" "github.com/DataDog/dd-trace-go/v2/ddtrace/ext" + tinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" "github.com/DataDog/dd-trace-go/v2/internal/log" "github.com/DataDog/dd-trace-go/v2/internal/processtags" ) @@ -593,10 +594,10 @@ func (p *payloadV1) encodeSpans(bm bitmap, fieldID int, spans spanList, st *stri // val() is used: an absent key and an empty value are both encoded as an // empty string, so the "was it set?" distinction is irrelevant for wire encoding. - p.buf = encodeField(p.buf, fullSetBitmap, 13, span.attrs.Val(attrEnv), st) - p.buf = encodeField(p.buf, fullSetBitmap, 14, span.attrs.Val(attrVersion), st) - p.buf = encodeField(p.buf, fullSetBitmap, 15, span.attrs.Val(attrComponent), st) - p.buf = encodeField(p.buf, fullSetBitmap, 16, getSpanKindValue(span.attrs.Val(attrSpanKind)), st) + p.buf = encodeField(p.buf, fullSetBitmap, 13, span.attrs.Val(tinternal.AttrEnv), st) + p.buf = encodeField(p.buf, fullSetBitmap, 14, span.attrs.Val(tinternal.AttrVersion), st) + p.buf = encodeField(p.buf, fullSetBitmap, 15, span.attrs.Val(tinternal.AttrComponent), st) + p.buf = encodeField(p.buf, fullSetBitmap, 16, getSpanKindValue(span.attrs.Val(tinternal.AttrSpanKind)), st) } return true, nil } diff --git a/ddtrace/tracer/sampler.go b/ddtrace/tracer/sampler.go index be0aa9baa82..e055438b4cd 100644 --- a/ddtrace/tracer/sampler.go +++ b/ddtrace/tracer/sampler.go @@ -15,6 +15,7 @@ import ( "time" "github.com/DataDog/dd-trace-go/v2/ddtrace/ext" + tinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" "github.com/DataDog/dd-trace-go/v2/internal/locking" "github.com/DataDog/dd-trace-go/v2/internal/locking/assert" "github.com/DataDog/dd-trace-go/v2/internal/samplernames" @@ -277,7 +278,7 @@ func (ps *prioritySampler) getRateLocked(spn *Span) float64 { assert.RWMutexRLocked(&ps.mu) // val() is used: a span with env explicitly set to "" and one with env never set // both map to the same rate-table key (both fall back to the default rate). - key := serviceEnvKey{service: spn.service, env: spn.attrs.Val(attrEnv)} + key := serviceEnvKey{service: spn.service, env: spn.attrs.Val(tinternal.AttrEnv)} if rate, ok := ps.rates[key]; ok { return rate } diff --git a/ddtrace/tracer/sampler_test.go b/ddtrace/tracer/sampler_test.go index c00ca6ebba1..cc031ef540a 100644 --- a/ddtrace/tracer/sampler_test.go +++ b/ddtrace/tracer/sampler_test.go @@ -18,6 +18,7 @@ import ( "time" "github.com/DataDog/dd-trace-go/v2/ddtrace/ext" + tinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" internalconfig "github.com/DataDog/dd-trace-go/v2/internal/config" "github.com/DataDog/dd-trace-go/v2/internal/locking" "github.com/DataDog/dd-trace-go/v2/internal/samplernames" @@ -68,12 +69,12 @@ func TestPrioritySampler(t *testing.T) { assert := assert.New(t) s := mkSpan("my-service", "my-env") assert.Equal("my-service", s.service) - assert.Equal("my-env", s.attrs.Val(attrEnv)) + assert.Equal("my-env", s.attrs.Val(tinternal.AttrEnv)) assert.Equal("my-env", s.meta[ext.Environment]) s = mkSpan("my-service2", "") assert.Equal("my-service2", s.service) - v, ok := s.attrs.Get(attrEnv) + v, ok := s.attrs.Get(tinternal.AttrEnv) assert.Equal("", v) assert.True(ok) // set to empty string, not absent assert.Contains(s.meta, ext.Environment) @@ -379,7 +380,7 @@ func BenchmarkPrioritySamplerGetRate(b *testing.B) { } oldGetRate := func(ops *oldPrioritySampler, spn *Span) float64 { // Allocation doesn't escape to the heap. - key := "service:" + spn.service + ",env:" + spn.attrs.Val(attrEnv) + key := "service:" + spn.service + ",env:" + spn.attrs.Val(tinternal.AttrEnv) if rate, ok := ops.rates[key]; ok { return rate } @@ -396,11 +397,11 @@ func BenchmarkPrioritySamplerGetRate(b *testing.B) { ps.rates[serviceEnvKey{service: "web", env: "prod"}] = 0.5 spnHit := newSpan("op", "web", "resource", 1, 1, 0) - spnHit.attrs.Set(attrEnv, "prod") + spnHit.attrs.Set(tinternal.AttrEnv, "prod") spnHit.SetTag(ext.Environment, "prod") spnMiss := newSpan("op", "other", "resource", 1, 1, 0) - spnMiss.attrs.Set(attrEnv, "staging") + spnMiss.attrs.Set(tinternal.AttrEnv, "staging") spnMiss.SetTag(ext.Environment, "staging") b.ResetTimer() @@ -2367,8 +2368,8 @@ func TestPrioritySamplerRampCooldownNoReset(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - var a spanAttributes - a.Set(attrEnv, env) + var a tinternal.SpanAttributes + a.Set(tinternal.AttrEnv, env) return &Span{service: svc, attrs: a} } @@ -2413,8 +2414,8 @@ func TestPrioritySamplerRampUp(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - var a spanAttributes - a.Set(attrEnv, env) + var a tinternal.SpanAttributes + a.Set(tinternal.AttrEnv, env) return &Span{service: svc, attrs: a} } @@ -2456,8 +2457,8 @@ func TestPrioritySamplerRampDown(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - var a spanAttributes - a.Set(attrEnv, env) + var a tinternal.SpanAttributes + a.Set(tinternal.AttrEnv, env) return &Span{service: svc, attrs: a} } @@ -2480,8 +2481,8 @@ func TestPrioritySamplerRampConverges(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - var a spanAttributes - a.Set(attrEnv, env) + var a tinternal.SpanAttributes + a.Set(tinternal.AttrEnv, env) return &Span{service: svc, attrs: a} } @@ -2507,8 +2508,8 @@ func TestPrioritySamplerRampDefaultRate(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - var a spanAttributes - a.Set(attrEnv, env) + var a tinternal.SpanAttributes + a.Set(tinternal.AttrEnv, env) return &Span{service: svc, attrs: a} } diff --git a/ddtrace/tracer/span.go b/ddtrace/tracer/span.go index 79cc082a35e..aa103332719 100644 --- a/ddtrace/tracer/span.go +++ b/ddtrace/tracer/span.go @@ -118,16 +118,6 @@ func (s *Span) spanEventsAsJSONString() string { return string(events) } -// spanAttributes is a package-level alias for tinternal.SpanAttributes. -type spanAttributes = tinternal.SpanAttributes - -const ( - attrEnv = tinternal.AttrEnv - attrVersion = tinternal.AttrVersion - attrComponent = tinternal.AttrComponent - attrSpanKind = tinternal.AttrSpanKind -) - // Span represents a computation. Callers must call Finish when a Span is // complete to ensure it's submitted. type Span struct { @@ -148,7 +138,7 @@ type Span struct { // They are dual-stored: attrs is used by the V1 encoder and internal callers to avoid // a map lookup; the meta map copy ensures V0.4 (msgp) encoding and the external stats // concentrator (which reads Meta["span.kind"]) continue to work without change. - attrs spanAttributes `msg:"-"` + attrs tinternal.SpanAttributes `msg:"-"` // +checklocks:mu start int64 `msg:"start"` // span start time expressed in nanoseconds since epoch // +checklocks:mu @@ -309,7 +299,7 @@ func (s *Span) debugInfo() (name string, spanID, traceID uint64, integration str name = s.name spanID = s.spanID traceID = s.traceID - if v, ok := s.attrs.Get(attrComponent); ok { + if v, ok := s.attrs.Get(tinternal.AttrComponent); ok { integration = v } else { integration = "manual" @@ -774,13 +764,13 @@ func (s *Span) setMetaInit(key, v string) { s.spanType = v return case ext.Environment: - s.attrs.Set(attrEnv, v) + s.attrs.Set(tinternal.AttrEnv, v) case ext.Version: - s.attrs.Set(attrVersion, v) + s.attrs.Set(tinternal.AttrVersion, v) case ext.Component: - s.attrs.Set(attrComponent, v) + s.attrs.Set(tinternal.AttrComponent, v) case ext.SpanKind: - s.attrs.Set(attrSpanKind, v) + s.attrs.Set(tinternal.AttrSpanKind, v) } // Promoted fields (env/version/component/spanKind) fall through here so they // remain in meta too. The V0.4 encoder and the external stats concentrator both @@ -1290,13 +1280,13 @@ func getMeta(s *Span, key string) (string, bool) { // semantics without a map lookup. switch key { case ext.Environment: - return s.attrs.Get(attrEnv) + return s.attrs.Get(tinternal.AttrEnv) case ext.Version: - return s.attrs.Get(attrVersion) + return s.attrs.Get(tinternal.AttrVersion) case ext.Component: - return s.attrs.Get(attrComponent) + return s.attrs.Get(tinternal.AttrComponent) case ext.SpanKind: - return s.attrs.Get(attrSpanKind) + return s.attrs.Get(tinternal.AttrSpanKind) } val, ok := s.meta[key] return val, ok diff --git a/ddtrace/tracer/span_test.go b/ddtrace/tracer/span_test.go index 4fcb11521ab..3116c645c5e 100644 --- a/ddtrace/tracer/span_test.go +++ b/ddtrace/tracer/span_test.go @@ -16,6 +16,7 @@ import ( "time" "github.com/DataDog/dd-trace-go/v2/ddtrace/ext" + tinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" "github.com/DataDog/dd-trace-go/v2/instrumentation/errortrace" sharedinternal "github.com/DataDog/dd-trace-go/v2/internal" "github.com/DataDog/dd-trace-go/v2/internal/log" @@ -428,7 +429,7 @@ func TestSpanSetTag(t *testing.T) { assert.Equal("web.request", span.name) span.SetTag("component", "tracer") - assert.Equal("tracer", span.attrs.Val(attrComponent)) + assert.Equal("tracer", span.attrs.Val(tinternal.AttrComponent)) assert.Equal("tracer", span.meta[ext.Component]) // dual-stored span.SetTag("tagInt", 1234) @@ -550,10 +551,10 @@ func TestPromotedFieldsDualStorage(t *testing.T) { tag string field func(*Span) string }{ - {ext.Environment, func(s *Span) string { return s.attrs.Val(attrEnv) }}, - {ext.Version, func(s *Span) string { return s.attrs.Val(attrVersion) }}, - {ext.Component, func(s *Span) string { return s.attrs.Val(attrComponent) }}, - {ext.SpanKind, func(s *Span) string { return s.attrs.Val(attrSpanKind) }}, + {ext.Environment, func(s *Span) string { return s.attrs.Val(tinternal.AttrEnv) }}, + {ext.Version, func(s *Span) string { return s.attrs.Val(tinternal.AttrVersion) }}, + {ext.Component, func(s *Span) string { return s.attrs.Val(tinternal.AttrComponent) }}, + {ext.SpanKind, func(s *Span) string { return s.attrs.Val(tinternal.AttrSpanKind) }}, } { t.Run(tc.tag, func(t *testing.T) { span := newBasicSpan("op") diff --git a/ddtrace/tracer/spancontext.go b/ddtrace/tracer/spancontext.go index afad567a70d..ca116fd10b4 100644 --- a/ddtrace/tracer/spancontext.go +++ b/ddtrace/tracer/spancontext.go @@ -17,6 +17,7 @@ import ( "github.com/DataDog/dd-trace-go/v2/ddtrace" "github.com/DataDog/dd-trace-go/v2/ddtrace/ext" "github.com/DataDog/dd-trace-go/v2/ddtrace/internal/tracerstats" + tinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" sharedinternal "github.com/DataDog/dd-trace-go/v2/internal" internalconfig "github.com/DataDog/dd-trace-go/v2/internal/config" "github.com/DataDog/dd-trace-go/v2/internal/locking" @@ -848,7 +849,7 @@ func setPeerService(s *Span, tc TracerConf) { // val() is used: only specific non-empty values ("client", "producer") qualify as // outbound requests, so an unset and an explicitly-empty spanKind are both correctly // treated as non-outbound. - spanKind := s.attrs.Val(attrSpanKind) + spanKind := s.attrs.Val(tinternal.AttrSpanKind) isOutboundRequest := spanKind == ext.SpanKindClient || spanKind == ext.SpanKindProducer if _, ok := s.meta[ext.PeerService]; ok { // peer.service already set on the span diff --git a/ddtrace/tracer/tracer_test.go b/ddtrace/tracer/tracer_test.go index dd3198953bd..c3152ab5809 100644 --- a/ddtrace/tracer/tracer_test.go +++ b/ddtrace/tracer/tracer_test.go @@ -32,6 +32,7 @@ import ( "github.com/DataDog/dd-trace-go/v2/ddtrace" "github.com/DataDog/dd-trace-go/v2/ddtrace/ext" "github.com/DataDog/dd-trace-go/v2/ddtrace/internal/tracerstats" + tinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" "github.com/DataDog/dd-trace-go/v2/internal" internalconfig "github.com/DataDog/dd-trace-go/v2/internal/config" "github.com/DataDog/dd-trace-go/v2/internal/globalconfig" @@ -2012,7 +2013,7 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request") - assert.Equal("4.5.6", sp.attrs.Val(attrVersion)) + assert.Equal("4.5.6", sp.attrs.Val(tinternal.AttrVersion)) assert.Equal("4.5.6", sp.meta[ext.Version]) // dual-stored }) t.Run("service", func(t *testing.T) { @@ -2023,7 +2024,7 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request", ServiceName("otherservenv")) - v, ok := sp.attrs.Get(attrVersion) + v, ok := sp.attrs.Get(tinternal.AttrVersion) assert.Equal("", v) assert.False(ok) assert.Empty(sp.meta[ext.Version]) // dual-stored @@ -2035,7 +2036,7 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request", ServiceName("otherservenv")) - assert.Equal("4.5.6", sp.attrs.Val(attrVersion)) + assert.Equal("4.5.6", sp.attrs.Val(tinternal.AttrVersion)) assert.Equal("4.5.6", sp.meta[ext.Version]) // dual-stored }) t.Run("service/universal", func(t *testing.T) { @@ -2046,7 +2047,7 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request", ServiceName("otherservenv")) - assert.Equal("1.2.3", sp.attrs.Val(attrVersion)) + assert.Equal("1.2.3", sp.attrs.Val(tinternal.AttrVersion)) assert.Equal("1.2.3", sp.meta[ext.Version]) // dual-stored }) t.Run("universal/service", func(t *testing.T) { @@ -2057,7 +2058,7 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request", ServiceName("otherservenv")) - v, ok := sp.attrs.Get(attrVersion) + v, ok := sp.attrs.Get(tinternal.AttrVersion) assert.Equal("", v) assert.False(ok) assert.Empty(sp.meta[ext.Version]) // dual-stored @@ -2072,7 +2073,7 @@ func TestEnvironment(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request") - assert.Equal("test", sp.attrs.Val(attrEnv)) + assert.Equal("test", sp.attrs.Val(tinternal.AttrEnv)) assert.Equal("test", sp.meta[ext.Environment]) // dual-stored }) @@ -2083,7 +2084,7 @@ func TestEnvironment(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request") - v, ok := sp.attrs.Get(attrEnv) + v, ok := sp.attrs.Get(tinternal.AttrEnv) assert.Equal("", v) assert.False(ok) assert.Empty(sp.meta[ext.Environment]) // dual-stored From 21dfaa2356c34b64ccca9d392f7cd3f8eb575d6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Mon, 16 Mar 2026 11:33:48 +0000 Subject: [PATCH 12/71] feat(ddtrace/tracer): add language to SpanAttributes --- ddtrace/ext/tags.go | 3 +++ ddtrace/tracer/internal/span_attributes.go | 6 ++++-- ddtrace/tracer/span.go | 2 ++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/ddtrace/ext/tags.go b/ddtrace/ext/tags.go index cb9afc05752..94b79da04af 100644 --- a/ddtrace/ext/tags.go +++ b/ddtrace/ext/tags.go @@ -168,4 +168,7 @@ const ( // DSMTransactionCheckpoint is the span tag key for a Data Streams transaction checkpoint name. DSMTransactionCheckpoint = "dsm.transaction.checkpoint" + + // Language is the span tag key for runtime language. + Language = "language" ) diff --git a/ddtrace/tracer/internal/span_attributes.go b/ddtrace/tracer/internal/span_attributes.go index 2f192712d95..f253a3fc398 100644 --- a/ddtrace/tracer/internal/span_attributes.go +++ b/ddtrace/tracer/internal/span_attributes.go @@ -14,7 +14,8 @@ const ( AttrVersion AttrKey = 1 AttrComponent AttrKey = 2 AttrSpanKind AttrKey = 3 - numAttrs AttrKey = 4 + AttrLanguage AttrKey = 4 + numAttrs AttrKey = 5 ) // Compile-time guard: the numeric values of AttrKey constants are load-bearing — @@ -25,13 +26,14 @@ var ( _ = [1]byte{}[AttrVersion-1] // AttrVersion must be 1 _ = [1]byte{}[AttrComponent-2] // AttrComponent must be 2 _ = [1]byte{}[AttrSpanKind-3] // AttrSpanKind must be 3 + _ = [1]byte{}[AttrLanguage-4] // AttrLanguage must be 4 ) // SpanAttributes holds the four V1-protocol promoted span fields. // Zero value = all fields absent. // Set(key, "") is distinct from never-Set: the bit is set, the string is "". // -// Layout: 1-byte setMask + 7B padding + [4]string (64B) = 72 bytes. +// Layout: 1-byte setMask + 7B padding + [5]string (80B) = 88 bytes. type SpanAttributes struct { setMask uint8 vals [numAttrs]string diff --git a/ddtrace/tracer/span.go b/ddtrace/tracer/span.go index aa103332719..25692ba418a 100644 --- a/ddtrace/tracer/span.go +++ b/ddtrace/tracer/span.go @@ -763,6 +763,8 @@ func (s *Span) setMetaInit(key, v string) { case ext.SpanType: s.spanType = v return + case ext.Language: + s.attrs.Set(tinternal.AttrLanguage, v) case ext.Environment: s.attrs.Set(tinternal.AttrEnv, v) case ext.Version: From d1664fcb35717c578d129de865422cc5e930804e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Mon, 16 Mar 2026 11:46:29 +0000 Subject: [PATCH 13/71] test(ddtrace/tracer): add tests and benchmarks for SpanAttributes --- .../tracer/internal/span_attributes_test.go | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 ddtrace/tracer/internal/span_attributes_test.go diff --git a/ddtrace/tracer/internal/span_attributes_test.go b/ddtrace/tracer/internal/span_attributes_test.go new file mode 100644 index 00000000000..abc9530ae7e --- /dev/null +++ b/ddtrace/tracer/internal/span_attributes_test.go @@ -0,0 +1,168 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016 Datadog, Inc. + +package internal + +import ( + "testing" +) + +func TestSpanAttributesZeroValue(t *testing.T) { + var a SpanAttributes + for _, key := range []AttrKey{AttrEnv, AttrVersion, AttrComponent, AttrSpanKind, AttrLanguage} { + if v, ok := a.Get(key); ok || v != "" { + t.Errorf("key %d: expected absent zero value, got (%q, %v)", key, v, ok) + } + } +} + +func TestSpanAttributesSetAndGet(t *testing.T) { + tests := []struct { + key AttrKey + val string + }{ + {AttrEnv, "prod"}, + {AttrVersion, "1.2.3"}, + {AttrComponent, "net/http"}, + {AttrSpanKind, "server"}, + {AttrLanguage, "go"}, + } + var a SpanAttributes + for _, tt := range tests { + a.Set(tt.key, tt.val) + } + for _, tt := range tests { + got, ok := a.Get(tt.key) + if !ok { + t.Errorf("key %d: expected present, got absent", tt.key) + } + if got != tt.val { + t.Errorf("key %d: expected %q, got %q", tt.key, tt.val, got) + } + if a.Val(tt.key) != tt.val { + t.Errorf("key %d: Val returned %q, expected %q", tt.key, a.Val(tt.key), tt.val) + } + } +} + +// Set(key, "") is distinct from never-Set: the bit should be set and value "". +func TestSpanAttributesSetEmptyString(t *testing.T) { + var a SpanAttributes + a.Set(AttrEnv, "") + v, ok := a.Get(AttrEnv) + if !ok { + t.Error("expected key to be marked present after Set with empty string") + } + if v != "" { + t.Errorf("expected empty string value, got %q", v) + } +} + +func TestSpanAttributesSetOverwrite(t *testing.T) { + var a SpanAttributes + a.Set(AttrEnv, "staging") + a.Set(AttrEnv, "prod") + v, ok := a.Get(AttrEnv) + if !ok { + t.Error("expected key to be present") + } + if v != "prod" { + t.Errorf("expected overwritten value %q, got %q", "prod", v) + } +} + +func TestSpanAttributesIndependentKeys(t *testing.T) { + var a SpanAttributes + a.Set(AttrEnv, "prod") + + // Other keys must remain absent. + for _, key := range []AttrKey{AttrVersion, AttrComponent, AttrSpanKind, AttrLanguage} { + if _, ok := a.Get(key); ok { + t.Errorf("key %d should be absent after setting only AttrEnv", key) + } + } +} + +func TestSpanAttributesValUnset(t *testing.T) { + var a SpanAttributes + // Val on an unset key returns "" without panicking. + if v := a.Val(AttrVersion); v != "" { + t.Errorf("expected empty string from unset key, got %q", v) + } +} + +// BenchmarkSpanAttributesSet benchmarks setting all four promoted fields using +// SpanAttributes versus an equivalent map[string]string. +func BenchmarkSpanAttributesSet(b *testing.B) { + b.Run("SpanAttributes", func(b *testing.B) { + a := SpanAttributes{} + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + a.Set(AttrEnv, "prod") + a.Set(AttrVersion, "1.2.3") + a.Set(AttrComponent, "net/http") + a.Set(AttrSpanKind, "server") + a.Set(AttrLanguage, "go") + } + _ = a + }) + + b.Run("map", func(b *testing.B) { + m := make(map[string]string, 4) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + m["env"] = "prod" + m["version"] = "1.2.3" + m["component"] = "net/http" + m["span.kind"] = "server" + } + _ = m + }) +} + +// BenchmarkSpanAttributesGet benchmarks reading all four promoted fields. +func BenchmarkSpanAttributesGet(b *testing.B) { + b.Run("SpanAttributes", func(b *testing.B) { + var a SpanAttributes + a.Set(AttrEnv, "prod") + a.Set(AttrVersion, "1.2.3") + a.Set(AttrComponent, "net/http") + a.Set(AttrSpanKind, "server") + a.Set(AttrLanguage, "go") + b.ReportAllocs() + b.ResetTimer() + var s string + var ok bool + for i := 0; i < b.N; i++ { + s, ok = a.Get(AttrEnv) + s, ok = a.Get(AttrVersion) + s, ok = a.Get(AttrComponent) + s, ok = a.Get(AttrSpanKind) + } + _, _ = s, ok + }) + + b.Run("map", func(b *testing.B) { + m := map[string]string{ + "env": "prod", + "version": "1.2.3", + "component": "net/http", + "span.kind": "server", + } + b.ReportAllocs() + b.ResetTimer() + var s string + var ok bool + for i := 0; i < b.N; i++ { + s, ok = m["env"] + s, ok = m["version"] + s, ok = m["component"] + s, ok = m["span.kind"] + } + _, _ = s, ok + }) +} From ab6f4126456c3830d60b0b658fda9e8da281835a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Mon, 16 Mar 2026 11:47:28 +0000 Subject: [PATCH 14/71] feat(ddtrace/tracer): avoid double-encoding for promoted fields --- ddtrace/tracer/internal/span_attributes.go | 5 +++++ ddtrace/tracer/payload_v1.go | 12 ++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/ddtrace/tracer/internal/span_attributes.go b/ddtrace/tracer/internal/span_attributes.go index f253a3fc398..7c10a6e5dbd 100644 --- a/ddtrace/tracer/internal/span_attributes.go +++ b/ddtrace/tracer/internal/span_attributes.go @@ -5,10 +5,15 @@ package internal +import "math/bits" + // AttrKey is an integer index into a SpanAttributes value array. // Use the pre-declared constants; do not construct AttrKey from arbitrary integers. type AttrKey uint8 +// Count returns the number of promoted fields that have been set. +func (a *SpanAttributes) Count() int { return bits.OnesCount8(a.setMask) } + const ( AttrEnv AttrKey = 0 AttrVersion AttrKey = 1 diff --git a/ddtrace/tracer/payload_v1.go b/ddtrace/tracer/payload_v1.go index d7b89bee54a..c9b190faf8f 100644 --- a/ddtrace/tracer/payload_v1.go +++ b/ddtrace/tracer/payload_v1.go @@ -560,13 +560,21 @@ func (p *payloadV1) encodeSpans(bm bitmap, fieldID int, spans spanList, st *stri p.buf = encodeField(p.buf, fullSetBitmap, 7, span.duration, st) p.buf = encodeField(p.buf, fullSetBitmap, 8, span.error != 0, st) - // span attributes combine the meta (tags), metrics and meta_struct + // span attributes combine the meta (tags), metrics and meta_struct. // To avoid increased allocations, we serialize attributes immediately without // creating an intermediate map. - size := len(span.meta) + len(span.metrics) + len(span.metaStruct) + // Promoted fields (env, version, component, spanKind) are encoded as + // dedicated span fields 13-16, so we exclude them from the meta loop + // to avoid double-encoding. + promoted := span.attrs.Count() + size := len(span.meta) - promoted + len(span.metrics) + len(span.metaStruct) p.buf = msgp.AppendUint32(p.buf, uint32(9)) // attributes fieldID p.buf = msgp.AppendArrayHeader(p.buf, uint32(size)*3) // number of attributes for k, v := range span.meta { + switch k { + case ext.Environment, ext.Version, ext.Component, ext.SpanKind, ext.Language: + continue // promoted fields: encoded as dedicated span fields (13-16) or not needed in attributes + } p.buf = st.serialize(k, p.buf) p.buf = msgp.AppendUint32(p.buf, uint32(StringValueType)) p.buf = st.serialize(v, p.buf) From 55c7693f60424f1726875327ee9bb986ff1c0a33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Mon, 16 Mar 2026 15:16:29 +0000 Subject: [PATCH 15/71] feat(ddtrace/tracer): COW-aware SpanAttributes; revert ext.Language --- ddtrace/tracer/internal/span_attributes.go | 61 ++++++++++++++++--- .../tracer/internal/span_attributes_test.go | 7 +-- ddtrace/tracer/payload_v1.go | 2 +- ddtrace/tracer/sampler_test.go | 10 +-- ddtrace/tracer/span.go | 46 ++++++++++---- ddtrace/tracer/tracer.go | 21 ++++++- 6 files changed, 112 insertions(+), 35 deletions(-) diff --git a/ddtrace/tracer/internal/span_attributes.go b/ddtrace/tracer/internal/span_attributes.go index 7c10a6e5dbd..923df67fb4a 100644 --- a/ddtrace/tracer/internal/span_attributes.go +++ b/ddtrace/tracer/internal/span_attributes.go @@ -11,15 +11,11 @@ import "math/bits" // Use the pre-declared constants; do not construct AttrKey from arbitrary integers. type AttrKey uint8 -// Count returns the number of promoted fields that have been set. -func (a *SpanAttributes) Count() int { return bits.OnesCount8(a.setMask) } - const ( AttrEnv AttrKey = 0 AttrVersion AttrKey = 1 AttrComponent AttrKey = 2 AttrSpanKind AttrKey = 3 - AttrLanguage AttrKey = 4 numAttrs AttrKey = 5 ) @@ -31,19 +27,64 @@ var ( _ = [1]byte{}[AttrVersion-1] // AttrVersion must be 1 _ = [1]byte{}[AttrComponent-2] // AttrComponent must be 2 _ = [1]byte{}[AttrSpanKind-3] // AttrSpanKind must be 3 - _ = [1]byte{}[AttrLanguage-4] // AttrLanguage must be 4 ) -// SpanAttributes holds the four V1-protocol promoted span fields. +// SpanAttributes holds the V1-protocol promoted span fields. // Zero value = all fields absent. // Set(key, "") is distinct from never-Set: the bit is set, the string is "". // -// Layout: 1-byte setMask + 7B padding + [5]string (80B) = 88 bytes. +// Layout: 1-byte setMask + 1-byte shared + 6B padding + [5]string (80B) = 88 bytes. +// +// When shared is true, the instance is owned by the tracer and must not be +// mutated. Callers must Clone before writing (copy-on-write). type SpanAttributes struct { setMask uint8 + shared bool vals [numAttrs]string } -func (a *SpanAttributes) Set(key AttrKey, v string) { a.vals[key] = v; a.setMask |= 1 << key } -func (a *SpanAttributes) Val(key AttrKey) string { return a.vals[key] } -func (a *SpanAttributes) Get(key AttrKey) (string, bool) { return a.vals[key], a.setMask>>key&1 != 0 } +// All read methods are nil-safe so callers holding a *SpanAttributes don't +// need nil guards. + +func (a *SpanAttributes) Set(key AttrKey, v string) { + a.vals[key] = v + a.setMask |= 1 << key +} + +func (a *SpanAttributes) Val(key AttrKey) string { + if a == nil { + return "" + } + return a.vals[key] +} + +func (a *SpanAttributes) Get(key AttrKey) (string, bool) { + if a == nil { + return "", false + } + return a.vals[key], a.setMask>>key&1 != 0 +} + +// Count returns the number of promoted fields that have been set. +func (a *SpanAttributes) Count() int { + if a == nil { + return 0 + } + return bits.OnesCount8(a.setMask) +} + +// MarkShared marks this instance as shared (read-only). Clone before mutating. +func (a *SpanAttributes) MarkShared() { a.shared = true } + +// IsShared reports whether this is a shared instance requiring COW. +func (a *SpanAttributes) IsShared() bool { return a != nil && a.shared } + +// Clone returns a mutable (non-shared) shallow copy. +func (a *SpanAttributes) Clone() *SpanAttributes { + if a == nil { + return &SpanAttributes{} + } + cp := *a + cp.shared = false + return &cp +} diff --git a/ddtrace/tracer/internal/span_attributes_test.go b/ddtrace/tracer/internal/span_attributes_test.go index abc9530ae7e..457da0b197e 100644 --- a/ddtrace/tracer/internal/span_attributes_test.go +++ b/ddtrace/tracer/internal/span_attributes_test.go @@ -11,7 +11,7 @@ import ( func TestSpanAttributesZeroValue(t *testing.T) { var a SpanAttributes - for _, key := range []AttrKey{AttrEnv, AttrVersion, AttrComponent, AttrSpanKind, AttrLanguage} { + for _, key := range []AttrKey{AttrEnv, AttrVersion, AttrComponent, AttrSpanKind} { if v, ok := a.Get(key); ok || v != "" { t.Errorf("key %d: expected absent zero value, got (%q, %v)", key, v, ok) } @@ -27,7 +27,6 @@ func TestSpanAttributesSetAndGet(t *testing.T) { {AttrVersion, "1.2.3"}, {AttrComponent, "net/http"}, {AttrSpanKind, "server"}, - {AttrLanguage, "go"}, } var a SpanAttributes for _, tt := range tests { @@ -78,7 +77,7 @@ func TestSpanAttributesIndependentKeys(t *testing.T) { a.Set(AttrEnv, "prod") // Other keys must remain absent. - for _, key := range []AttrKey{AttrVersion, AttrComponent, AttrSpanKind, AttrLanguage} { + for _, key := range []AttrKey{AttrVersion, AttrComponent, AttrSpanKind} { if _, ok := a.Get(key); ok { t.Errorf("key %d should be absent after setting only AttrEnv", key) } @@ -105,7 +104,6 @@ func BenchmarkSpanAttributesSet(b *testing.B) { a.Set(AttrVersion, "1.2.3") a.Set(AttrComponent, "net/http") a.Set(AttrSpanKind, "server") - a.Set(AttrLanguage, "go") } _ = a }) @@ -132,7 +130,6 @@ func BenchmarkSpanAttributesGet(b *testing.B) { a.Set(AttrVersion, "1.2.3") a.Set(AttrComponent, "net/http") a.Set(AttrSpanKind, "server") - a.Set(AttrLanguage, "go") b.ReportAllocs() b.ResetTimer() var s string diff --git a/ddtrace/tracer/payload_v1.go b/ddtrace/tracer/payload_v1.go index c9b190faf8f..5a0098e51ae 100644 --- a/ddtrace/tracer/payload_v1.go +++ b/ddtrace/tracer/payload_v1.go @@ -572,7 +572,7 @@ func (p *payloadV1) encodeSpans(bm bitmap, fieldID int, spans spanList, st *stri p.buf = msgp.AppendArrayHeader(p.buf, uint32(size)*3) // number of attributes for k, v := range span.meta { switch k { - case ext.Environment, ext.Version, ext.Component, ext.SpanKind, ext.Language: + case ext.Environment, ext.Version, ext.Component, ext.SpanKind: continue // promoted fields: encoded as dedicated span fields (13-16) or not needed in attributes } p.buf = st.serialize(k, p.buf) diff --git a/ddtrace/tracer/sampler_test.go b/ddtrace/tracer/sampler_test.go index cc031ef540a..714eedc03a4 100644 --- a/ddtrace/tracer/sampler_test.go +++ b/ddtrace/tracer/sampler_test.go @@ -2368,7 +2368,7 @@ func TestPrioritySamplerRampCooldownNoReset(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - var a tinternal.SpanAttributes + a := new(tinternal.SpanAttributes) a.Set(tinternal.AttrEnv, env) return &Span{service: svc, attrs: a} } @@ -2414,7 +2414,7 @@ func TestPrioritySamplerRampUp(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - var a tinternal.SpanAttributes + a := new(tinternal.SpanAttributes) a.Set(tinternal.AttrEnv, env) return &Span{service: svc, attrs: a} } @@ -2457,7 +2457,7 @@ func TestPrioritySamplerRampDown(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - var a tinternal.SpanAttributes + a := new(tinternal.SpanAttributes) a.Set(tinternal.AttrEnv, env) return &Span{service: svc, attrs: a} } @@ -2481,7 +2481,7 @@ func TestPrioritySamplerRampConverges(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - var a tinternal.SpanAttributes + a := new(tinternal.SpanAttributes) a.Set(tinternal.AttrEnv, env) return &Span{service: svc, attrs: a} } @@ -2508,7 +2508,7 @@ func TestPrioritySamplerRampDefaultRate(t *testing.T) { assert := assert.New(t) mkSpan := func(svc, env string) *Span { - var a tinternal.SpanAttributes + a := new(tinternal.SpanAttributes) a.Set(tinternal.AttrEnv, env) return &Span{service: svc, attrs: a} } diff --git a/ddtrace/tracer/span.go b/ddtrace/tracer/span.go index 25692ba418a..a2c30ebc806 100644 --- a/ddtrace/tracer/span.go +++ b/ddtrace/tracer/span.go @@ -133,12 +133,12 @@ type Span struct { // +checklocks:mu spanType string `msg:"type"` // protocol associated with the span (i.e. "web", "db", "cache") // +checklocks:mu - // attrs holds the four V1-protocol promoted fields (env, version, component, spanKind) - // as tagValue so callers can distinguish "never set" from "explicitly set to empty". - // They are dual-stored: attrs is used by the V1 encoder and internal callers to avoid - // a map lookup; the meta map copy ensures V0.4 (msgp) encoding and the external stats - // concentrator (which reads Meta["span.kind"]) continue to work without change. - attrs tinternal.SpanAttributes `msg:"-"` + // attrs holds the V1-protocol promoted fields (env, version, component, spanKind, language). + // Pointer to allow copy-on-write sharing: the tracer creates one shared instance with + // process-level values (env, version, language). Spans that only inherit those values + // share the pointer (8 bytes). Spans that set per-span fields (component, spanKind) + // clone on first write. Nil-safe read methods avoid nil checks at call sites. + attrs *tinternal.SpanAttributes `msg:"-"` // +checklocks:mu start int64 `msg:"start"` // span start time expressed in nanoseconds since epoch // +checklocks:mu @@ -763,16 +763,14 @@ func (s *Span) setMetaInit(key, v string) { case ext.SpanType: s.spanType = v return - case ext.Language: - s.attrs.Set(tinternal.AttrLanguage, v) case ext.Environment: - s.attrs.Set(tinternal.AttrEnv, v) + s.setAttrCOW(tinternal.AttrEnv, v) case ext.Version: - s.attrs.Set(tinternal.AttrVersion, v) + s.setAttrCOW(tinternal.AttrVersion, v) case ext.Component: - s.attrs.Set(tinternal.AttrComponent, v) + s.setAttrCOW(tinternal.AttrComponent, v) case ext.SpanKind: - s.attrs.Set(tinternal.AttrSpanKind, v) + s.setAttrCOW(tinternal.AttrSpanKind, v) } // Promoted fields (env/version/component/spanKind) fall through here so they // remain in meta too. The V0.4 encoder and the external stats concentrator both @@ -783,6 +781,30 @@ func (s *Span) setMetaInit(key, v string) { s.meta[key] = v } +// setAttrCOW sets a promoted attribute with copy-on-write semantics. +// If the value already matches what's in attrs (e.g. from the shared tracer +// instance), the write is skipped entirely — no clone, no allocation. +// +checklocksignore — Initialization time, span not yet shared. +func (s *Span) setAttrCOW(key tinternal.AttrKey, v string) { + if s.attrs != nil && s.attrs.Val(key) == v { + return // already has the right value (shared or local) + } + s.ensureAttrsLocal() + s.attrs.Set(key, v) +} + +// ensureAttrsLocal guarantees s.attrs is a mutable, span-local instance. +// +checklocksignore — Initialization time, span not yet shared. +func (s *Span) ensureAttrsLocal() { + if s.attrs == nil { + s.attrs = new(tinternal.SpanAttributes) + return + } + if s.attrs.IsShared() { + s.attrs = s.attrs.Clone() + } +} + // setMetaStructLocked sets structured metadata. This method assumes the span lock is already held. // +checklocks:s.mu func (s *Span) setMetaStructLocked(key string, v any) { diff --git a/ddtrace/tracer/tracer.go b/ddtrace/tracer/tracer.go index 527f06a82f1..5659dbc6ebe 100644 --- a/ddtrace/tracer/tracer.go +++ b/ddtrace/tracer/tracer.go @@ -26,6 +26,7 @@ import ( "github.com/DataDog/dd-trace-go/v2/ddtrace/ext" "github.com/DataDog/dd-trace-go/v2/ddtrace/internal/tracerstats" + tinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" globalinternal "github.com/DataDog/dd-trace-go/v2/internal" "github.com/DataDog/dd-trace-go/v2/internal/appsec" appsecConfig "github.com/DataDog/dd-trace-go/v2/internal/appsec/config" @@ -176,6 +177,11 @@ type tracer struct { // State related to the Dynamic Instrumentation product. dynInstSubscriptions dynInstSubscriptions + + // sharedAttrs holds the process-level promoted span attributes (env, version, + // language). All spans start by sharing this pointer; copy-on-write clones it + // only when a span needs to set per-span fields (component, spanKind). + sharedAttrs tinternal.SpanAttributes } type dynInstSubscriptions struct { @@ -483,6 +489,16 @@ func newUnstartedTracer(opts ...StartOption) (t *tracer, err error) { dataStreams: dataStreamsProcessor, logFile: logFile, } + // Build the shared SpanAttributes that every span will start from. + // Process-level values (env, version, language) are set once here; + // spans share this pointer and only clone on per-span overrides. + if env := c.internalConfig.Env(); env != "" { + t.sharedAttrs.Set(tinternal.AttrEnv, env) + } + if ver := c.internalConfig.Version(); ver != "" { + t.sharedAttrs.Set(tinternal.AttrVersion, ver) + } + t.sharedAttrs.MarkShared() return t, nil } @@ -736,7 +752,7 @@ func (t *tracer) pushChunk(trace *chunk) { } // +checklocksignore — Initialization time, span not yet shared. -func spanStart(operationName string, options ...StartSpanOption) *Span { +func spanStart(operationName string, sharedAttrs *tinternal.SpanAttributes, options ...StartSpanOption) *Span { var opts StartSpanConfig for _, fn := range options { if fn == nil { @@ -801,6 +817,7 @@ func spanStart(operationName string, options ...StartSpanOption) *Span { traceID: id, start: startTime, integration: "manual", + attrs: sharedAttrs, // COW: shared until a per-span field is set } span.spanLinks = append(span.spanLinks, opts.SpanLinks...) @@ -849,7 +866,7 @@ func (t *tracer) StartSpan(operationName string, options ...StartSpanOption) *Sp if !t.config.enabled.get() { return nil } - span := spanStart(operationName, options...) + span := spanStart(operationName, &t.sharedAttrs, options...) if span.service == "" { span.service = t.config.internalConfig.ServiceName() } From 2cbde380410f490a2225043311973a2c2f0ed079 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Mon, 16 Mar 2026 15:25:13 +0000 Subject: [PATCH 16/71] chore(ddtrace/ext): drop Language --- ddtrace/ext/tags.go | 3 --- ddtrace/tracer/internal/span_attributes.go | 4 ++-- ddtrace/tracer/span.go | 4 ++-- ddtrace/tracer/tracer.go | 6 +++--- 4 files changed, 7 insertions(+), 10 deletions(-) diff --git a/ddtrace/ext/tags.go b/ddtrace/ext/tags.go index 94b79da04af..cb9afc05752 100644 --- a/ddtrace/ext/tags.go +++ b/ddtrace/ext/tags.go @@ -168,7 +168,4 @@ const ( // DSMTransactionCheckpoint is the span tag key for a Data Streams transaction checkpoint name. DSMTransactionCheckpoint = "dsm.transaction.checkpoint" - - // Language is the span tag key for runtime language. - Language = "language" ) diff --git a/ddtrace/tracer/internal/span_attributes.go b/ddtrace/tracer/internal/span_attributes.go index 923df67fb4a..0176b017c70 100644 --- a/ddtrace/tracer/internal/span_attributes.go +++ b/ddtrace/tracer/internal/span_attributes.go @@ -16,7 +16,7 @@ const ( AttrVersion AttrKey = 1 AttrComponent AttrKey = 2 AttrSpanKind AttrKey = 3 - numAttrs AttrKey = 5 + numAttrs AttrKey = 4 ) // Compile-time guard: the numeric values of AttrKey constants are load-bearing — @@ -33,7 +33,7 @@ var ( // Zero value = all fields absent. // Set(key, "") is distinct from never-Set: the bit is set, the string is "". // -// Layout: 1-byte setMask + 1-byte shared + 6B padding + [5]string (80B) = 88 bytes. +// Layout: 1-byte setMask + 1-byte shared + 6B padding + [4]string (64B) = 72 bytes. // // When shared is true, the instance is owned by the tracer and must not be // mutated. Callers must Clone before writing (copy-on-write). diff --git a/ddtrace/tracer/span.go b/ddtrace/tracer/span.go index a2c30ebc806..4bd21fec438 100644 --- a/ddtrace/tracer/span.go +++ b/ddtrace/tracer/span.go @@ -133,9 +133,9 @@ type Span struct { // +checklocks:mu spanType string `msg:"type"` // protocol associated with the span (i.e. "web", "db", "cache") // +checklocks:mu - // attrs holds the V1-protocol promoted fields (env, version, component, spanKind, language). + // attrs holds the V1-protocol promoted fields (env, version, component, spanKind). // Pointer to allow copy-on-write sharing: the tracer creates one shared instance with - // process-level values (env, version, language). Spans that only inherit those values + // process-level values (env, version). Spans that only inherit those values // share the pointer (8 bytes). Spans that set per-span fields (component, spanKind) // clone on first write. Nil-safe read methods avoid nil checks at call sites. attrs *tinternal.SpanAttributes `msg:"-"` diff --git a/ddtrace/tracer/tracer.go b/ddtrace/tracer/tracer.go index 5659dbc6ebe..a1908bc848b 100644 --- a/ddtrace/tracer/tracer.go +++ b/ddtrace/tracer/tracer.go @@ -178,8 +178,8 @@ type tracer struct { // State related to the Dynamic Instrumentation product. dynInstSubscriptions dynInstSubscriptions - // sharedAttrs holds the process-level promoted span attributes (env, version, - // language). All spans start by sharing this pointer; copy-on-write clones it + // sharedAttrs holds the process-level promoted span attributes (env, version). + // All spans start by sharing this pointer; copy-on-write clones it // only when a span needs to set per-span fields (component, spanKind). sharedAttrs tinternal.SpanAttributes } @@ -490,7 +490,7 @@ func newUnstartedTracer(opts ...StartOption) (t *tracer, err error) { logFile: logFile, } // Build the shared SpanAttributes that every span will start from. - // Process-level values (env, version, language) are set once here; + // Process-level values (env, version) are set once here; // spans share this pointer and only clone on per-span overrides. if env := c.internalConfig.Env(); env != "" { t.sharedAttrs.Set(tinternal.AttrEnv, env) From 9db4e6099b9798f7fef1be6efc10944f066b8088 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Mon, 16 Mar 2026 18:15:12 +0000 Subject: [PATCH 17/71] feat(ddtrace/tracer): introduce spanMeta to group map and SpanAttributes as a single unit in payload v0.4 trace protocol --- ddtrace/tracer/abandonedspans.go | 2 +- ddtrace/tracer/civisibility_payload_test.go | 2 +- ddtrace/tracer/civisibility_tslv.go | 4 +- ddtrace/tracer/internal/span_attributes.go | 41 ++++++ .../tracer/internal/span_attributes_test.go | 51 +++++++ ddtrace/tracer/option_test.go | 6 +- ddtrace/tracer/payload_test.go | 10 +- ddtrace/tracer/payload_v1.go | 23 ++-- ddtrace/tracer/sampler.go | 2 +- ddtrace/tracer/sampler_test.go | 32 ++--- ddtrace/tracer/span.go | 112 ++++++++------- ddtrace/tracer/span_event_test.go | 4 +- ddtrace/tracer/span_meta.go | 126 +++++++++++++++++ ddtrace/tracer/span_msgp.go | 48 +------ ddtrace/tracer/span_test.go | 116 ++++++++-------- ddtrace/tracer/spancontext.go | 14 +- ddtrace/tracer/spancontext_test.go | 38 ++--- ddtrace/tracer/srv_src_test.go | 43 +++--- ddtrace/tracer/stats.go | 22 ++- ddtrace/tracer/stats_test.go | 4 +- ddtrace/tracer/textmap_test.go | 12 +- ddtrace/tracer/tracer.go | 8 +- ddtrace/tracer/tracer_test.go | 130 +++++++++--------- ddtrace/tracer/transport_bench_test.go | 2 +- ddtrace/tracer/transport_test.go | 2 +- ddtrace/tracer/writer.go | 12 +- ddtrace/tracer/writer_test.go | 8 +- 27 files changed, 540 insertions(+), 334 deletions(-) create mode 100644 ddtrace/tracer/span_meta.go diff --git a/ddtrace/tracer/abandonedspans.go b/ddtrace/tracer/abandonedspans.go index e349a5527e3..f8e928a7640 100644 --- a/ddtrace/tracer/abandonedspans.go +++ b/ddtrace/tracer/abandonedspans.go @@ -85,7 +85,7 @@ type abandonedSpanCandidate struct { // +checklocksignore — Called while span is locked or during initialization. func newAbandonedSpanCandidate(s *Span, finished bool) *abandonedSpanCandidate { var component string - if v, ok := s.attrs.Get(tinternal.AttrComponent); ok { + if v, ok := s.meta.attrs.Get(tinternal.AttrComponent); ok { component = v } else { component = "manual" diff --git a/ddtrace/tracer/civisibility_payload_test.go b/ddtrace/tracer/civisibility_payload_test.go index d7d44a440b7..e356a404e06 100644 --- a/ddtrace/tracer/civisibility_payload_test.go +++ b/ddtrace/tracer/civisibility_payload_test.go @@ -143,7 +143,7 @@ func benchmarkCiVisibilityPayloadThroughput(count int) func(*testing.B) { return func(b *testing.B) { p := newCiVisibilityPayload() s := newBasicSpan("X") - s.meta["key"] = strings.Repeat("X", 10*1024) + s.meta.m["key"] = strings.Repeat("X", 10*1024) e := getCiVisibilityEvent(s) events := make(ciVisibilityEvents, count) for i := range count { diff --git a/ddtrace/tracer/civisibility_tslv.go b/ddtrace/tracer/civisibility_tslv.go index bd2e698bd63..382eacb017f 100644 --- a/ddtrace/tracer/civisibility_tslv.go +++ b/ddtrace/tracer/civisibility_tslv.go @@ -164,7 +164,7 @@ type ciVisibilityEvent struct { // +checklocksignore — CI visibility event: reads span fields after SetTag releases lock. func (e *ciVisibilityEvent) SetTag(key string, value any) { e.span.SetTag(key, value) - e.Content.Meta = e.span.meta + e.Content.Meta = e.span.meta.m e.Content.Metrics = e.span.metrics } @@ -411,7 +411,7 @@ func createTslvSpan(span *Span) tslvSpan { Duration: span.duration, ParentID: span.parentID, Error: span.error, - Meta: span.meta, + Meta: span.meta.m, Metrics: span.metrics, } } diff --git a/ddtrace/tracer/internal/span_attributes.go b/ddtrace/tracer/internal/span_attributes.go index 0176b017c70..dd2be1cd91e 100644 --- a/ddtrace/tracer/internal/span_attributes.go +++ b/ddtrace/tracer/internal/span_attributes.go @@ -88,3 +88,44 @@ func (a *SpanAttributes) Clone() *SpanAttributes { cp.shared = false return &cp } + +// AttrDef maps an AttrKey to its canonical tag name. +type AttrDef struct { + Key AttrKey + Name string +} + +// Defs enumerates all promoted attribute definitions. +var Defs = [numAttrs]AttrDef{ + {AttrEnv, "env"}, + {AttrVersion, "version"}, + {AttrComponent, "component"}, + {AttrSpanKind, "span.kind"}, +} + +// ForEach calls fn for each attribute that has been set. +func (a *SpanAttributes) ForEach(fn func(name, val string)) { + if a == nil { + return + } + for _, d := range Defs { + if a.setMask>>d.Key&1 != 0 { + fn(d.Name, a.vals[d.Key]) + } + } +} + +// AttrKeyForTag returns the AttrKey for a promoted tag name, if any. +func AttrKeyForTag(tag string) (AttrKey, bool) { + switch tag { + case "env": + return AttrEnv, true + case "version": + return AttrVersion, true + case "component": + return AttrComponent, true + case "span.kind": + return AttrSpanKind, true + } + return 0, false +} diff --git a/ddtrace/tracer/internal/span_attributes_test.go b/ddtrace/tracer/internal/span_attributes_test.go index 457da0b197e..ae9a29bafe7 100644 --- a/ddtrace/tracer/internal/span_attributes_test.go +++ b/ddtrace/tracer/internal/span_attributes_test.go @@ -92,6 +92,57 @@ func TestSpanAttributesValUnset(t *testing.T) { } } +func TestSpanAttributesForEach(t *testing.T) { + var a SpanAttributes + a.Set(AttrEnv, "prod") + a.Set(AttrSpanKind, "server") + // AttrVersion and AttrComponent are NOT set + + got := make(map[string]string) + a.ForEach(func(name, val string) { + got[name] = val + }) + if len(got) != 2 { + t.Fatalf("expected 2 entries, got %d: %v", len(got), got) + } + if got["env"] != "prod" { + t.Errorf("expected env=prod, got %q", got["env"]) + } + if got["span.kind"] != "server" { + t.Errorf("expected span.kind=server, got %q", got["span.kind"]) + } +} + +func TestSpanAttributesForEachNil(t *testing.T) { + var a *SpanAttributes + called := false + a.ForEach(func(_, _ string) { called = true }) + if called { + t.Error("ForEach should not call fn on nil receiver") + } +} + +func TestAttrKeyForTag(t *testing.T) { + tests := []struct { + tag string + key AttrKey + ok bool + }{ + {"env", AttrEnv, true}, + {"version", AttrVersion, true}, + {"component", AttrComponent, true}, + {"span.kind", AttrSpanKind, true}, + {"unknown", 0, false}, + {"", 0, false}, + } + for _, tt := range tests { + key, ok := AttrKeyForTag(tt.tag) + if ok != tt.ok || key != tt.key { + t.Errorf("AttrKeyForTag(%q) = (%d, %v), want (%d, %v)", tt.tag, key, ok, tt.key, tt.ok) + } + } +} + // BenchmarkSpanAttributesSet benchmarks setting all four promoted fields using // SpanAttributes versus an equivalent map[string]string. func BenchmarkSpanAttributesSet(b *testing.B) { diff --git a/ddtrace/tracer/option_test.go b/ddtrace/tracer/option_test.go index 9e236ab926f..fd38a668f84 100644 --- a/ddtrace/tracer/option_test.go +++ b/ddtrace/tracer/option_test.go @@ -1846,7 +1846,7 @@ func TestWithStartSpanConfig(t *testing.T) { s := tracer.StartSpan("test", WithStartSpanConfig(cfg)) defer s.Finish() assert.Equal(float64(1), s.metrics[keyMeasured]) - assert.Equal("value", s.meta["key"]) + assert.Equal("value", s.meta.m["key"]) assert.Equal(parent.Context().SpanID(), s.parentID) assert.Equal(parent.Context().TraceID(), s.Context().TraceID()) assert.Equal("resource", s.resource) @@ -1895,8 +1895,8 @@ func TestWithStartSpanConfigNonEmptyTags(t *testing.T) { Tag("key", "after_start_span_config"), ) defer s.Finish() - assert.Equal("should_override", s.meta["k2"]) - assert.Equal("after_start_span_config", s.meta["key"]) + assert.Equal("should_override", s.meta.m["k2"]) + assert.Equal("after_start_span_config", s.meta.m["key"]) } func optsTestConsumer(opts ...StartSpanOption) { diff --git a/ddtrace/tracer/payload_test.go b/ddtrace/tracer/payload_test.go index f13001b911e..89c31d355d7 100644 --- a/ddtrace/tracer/payload_test.go +++ b/ddtrace/tracer/payload_test.go @@ -513,7 +513,7 @@ func TestPayloadV1IncrementalChunkEncoding(t *testing.T) { s := got.chunks[i].spans[0] assert.Equal(t, c.service, s.service, "chunk %d: wrong service", i) assert.Equal(t, c.name, s.name, "chunk %d: wrong name", i) - assert.Equal(t, c.tagVal, s.meta[c.tagKey], "chunk %d: wrong tag value", i) + assert.Equal(t, c.tagVal, s.meta.m[c.tagKey], "chunk %d: wrong tag value", i) } } @@ -521,7 +521,7 @@ func assertProcessTags(t *testing.T, payload spanLists) { assert := assert.New(t) for i, spanList := range payload { for j, span := range spanList { - processTags, ok := span.meta[keyProcessTags] + processTags, ok := span.meta.m[keyProcessTags] if i+j == 0 { assert.True(ok, "process tags should be present on the first span of each chunk only") assert.Contains(processTags, "entrypoint.name", "process tags should have entrypoint.name") @@ -545,7 +545,7 @@ func benchmarkPayloadThroughput(count int) func(*testing.B) { return func(b *testing.B) { p := newPayloadV04() s := newBasicSpan("X") - s.meta["key"] = strings.Repeat("X", 10*1024) + s.meta.m["key"] = strings.Repeat("X", 10*1024) trace := make(spanList, count) for i := range count { trace[i] = s @@ -685,7 +685,7 @@ func BenchmarkPayloadPush(b *testing.B) { spans := make(spanList, size.numSpans) for i := 0; i < size.numSpans; i++ { span := newBasicSpan("benchmark-span") - span.meta["data"] = strings.Repeat("x", size.spanSize*1024) + span.meta.m["data"] = strings.Repeat("x", size.spanSize*1024) spans[i] = span } @@ -776,7 +776,7 @@ func TestMsgsizeAnalysis(t *testing.T) { spans := make(spanList, numSpans) for i := range numSpans { span := newBasicSpan("test") - span.meta["data"] = strings.Repeat("x", 1024) + span.meta.m["data"] = strings.Repeat("x", 1024) spans[i] = span } diff --git a/ddtrace/tracer/payload_v1.go b/ddtrace/tracer/payload_v1.go index 5a0098e51ae..4874e296348 100644 --- a/ddtrace/tracer/payload_v1.go +++ b/ddtrace/tracer/payload_v1.go @@ -563,18 +563,13 @@ func (p *payloadV1) encodeSpans(bm bitmap, fieldID int, spans spanList, st *stri // span attributes combine the meta (tags), metrics and meta_struct. // To avoid increased allocations, we serialize attributes immediately without // creating an intermediate map. - // Promoted fields (env, version, component, spanKind) are encoded as - // dedicated span fields 13-16, so we exclude them from the meta loop - // to avoid double-encoding. - promoted := span.attrs.Count() - size := len(span.meta) - promoted + len(span.metrics) + len(span.metaStruct) + // Promoted fields (env, version, component, spanKind) live in + // span.meta.attrs and are encoded as dedicated span fields 13-16. + // They are no longer in span.meta.m, so no skip logic is needed. + size := len(span.meta.m) + len(span.metrics) + len(span.metaStruct) p.buf = msgp.AppendUint32(p.buf, uint32(9)) // attributes fieldID p.buf = msgp.AppendArrayHeader(p.buf, uint32(size)*3) // number of attributes - for k, v := range span.meta { - switch k { - case ext.Environment, ext.Version, ext.Component, ext.SpanKind: - continue // promoted fields: encoded as dedicated span fields (13-16) or not needed in attributes - } + for k, v := range span.meta.m { p.buf = st.serialize(k, p.buf) p.buf = msgp.AppendUint32(p.buf, uint32(StringValueType)) p.buf = st.serialize(v, p.buf) @@ -602,10 +597,10 @@ func (p *payloadV1) encodeSpans(bm bitmap, fieldID int, spans spanList, st *stri // val() is used: an absent key and an empty value are both encoded as an // empty string, so the "was it set?" distinction is irrelevant for wire encoding. - p.buf = encodeField(p.buf, fullSetBitmap, 13, span.attrs.Val(tinternal.AttrEnv), st) - p.buf = encodeField(p.buf, fullSetBitmap, 14, span.attrs.Val(tinternal.AttrVersion), st) - p.buf = encodeField(p.buf, fullSetBitmap, 15, span.attrs.Val(tinternal.AttrComponent), st) - p.buf = encodeField(p.buf, fullSetBitmap, 16, getSpanKindValue(span.attrs.Val(tinternal.AttrSpanKind)), st) + p.buf = encodeField(p.buf, fullSetBitmap, 13, span.meta.attrs.Val(tinternal.AttrEnv), st) + p.buf = encodeField(p.buf, fullSetBitmap, 14, span.meta.attrs.Val(tinternal.AttrVersion), st) + p.buf = encodeField(p.buf, fullSetBitmap, 15, span.meta.attrs.Val(tinternal.AttrComponent), st) + p.buf = encodeField(p.buf, fullSetBitmap, 16, getSpanKindValue(span.meta.attrs.Val(tinternal.AttrSpanKind)), st) } return true, nil } diff --git a/ddtrace/tracer/sampler.go b/ddtrace/tracer/sampler.go index e055438b4cd..162583d7250 100644 --- a/ddtrace/tracer/sampler.go +++ b/ddtrace/tracer/sampler.go @@ -278,7 +278,7 @@ func (ps *prioritySampler) getRateLocked(spn *Span) float64 { assert.RWMutexRLocked(&ps.mu) // val() is used: a span with env explicitly set to "" and one with env never set // both map to the same rate-table key (both fall back to the default rate). - key := serviceEnvKey{service: spn.service, env: spn.attrs.Val(tinternal.AttrEnv)} + key := serviceEnvKey{service: spn.service, env: spn.meta.attrs.Val(tinternal.AttrEnv)} if rate, ok := ps.rates[key]; ok { return rate } diff --git a/ddtrace/tracer/sampler_test.go b/ddtrace/tracer/sampler_test.go index 714eedc03a4..7fdcbc8f6ca 100644 --- a/ddtrace/tracer/sampler_test.go +++ b/ddtrace/tracer/sampler_test.go @@ -69,15 +69,13 @@ func TestPrioritySampler(t *testing.T) { assert := assert.New(t) s := mkSpan("my-service", "my-env") assert.Equal("my-service", s.service) - assert.Equal("my-env", s.attrs.Val(tinternal.AttrEnv)) - assert.Equal("my-env", s.meta[ext.Environment]) + assert.Equal("my-env", s.meta.attrs.Val(tinternal.AttrEnv)) s = mkSpan("my-service2", "") assert.Equal("my-service2", s.service) - v, ok := s.attrs.Get(tinternal.AttrEnv) + v, ok := s.meta.attrs.Get(tinternal.AttrEnv) assert.Equal("", v) assert.True(ok) // set to empty string, not absent - assert.Contains(s.meta, ext.Environment) }) t.Run("ops", func(t *testing.T) { @@ -380,7 +378,7 @@ func BenchmarkPrioritySamplerGetRate(b *testing.B) { } oldGetRate := func(ops *oldPrioritySampler, spn *Span) float64 { // Allocation doesn't escape to the heap. - key := "service:" + spn.service + ",env:" + spn.attrs.Val(tinternal.AttrEnv) + key := "service:" + spn.service + ",env:" + spn.meta.attrs.Val(tinternal.AttrEnv) if rate, ok := ops.rates[key]; ok { return rate } @@ -397,11 +395,11 @@ func BenchmarkPrioritySamplerGetRate(b *testing.B) { ps.rates[serviceEnvKey{service: "web", env: "prod"}] = 0.5 spnHit := newSpan("op", "web", "resource", 1, 1, 0) - spnHit.attrs.Set(tinternal.AttrEnv, "prod") + spnHit.meta.attrs.Set(tinternal.AttrEnv, "prod") spnHit.SetTag(ext.Environment, "prod") spnMiss := newSpan("op", "other", "resource", 1, 1, 0) - spnMiss.attrs.Set(tinternal.AttrEnv, "staging") + spnMiss.meta.attrs.Set(tinternal.AttrEnv, "staging") spnMiss.SetTag(ext.Environment, "staging") b.ResetTimer() @@ -2125,8 +2123,8 @@ func TestSampleTagsRootOnly(t *testing.T) { assert.Equal(0., root.metrics[keyRulesSamplerAppliedRate]) assert.NotContains(root.metrics, keyRulesSamplerLimiterRate) // Knuth sampling rate tag should be set even when rate is 0 - assert.Contains(root.meta, keyKnuthSamplingRate) - assert.Equal("0", root.meta[keyKnuthSamplingRate]) + assert.Contains(root.meta.m, keyKnuthSamplingRate) + assert.Equal("0", root.meta.m[keyKnuthSamplingRate]) // neither"_dd.limit_psr", nor "_dd.rule_psr" should be present // on the child span @@ -2178,15 +2176,15 @@ func TestSampleTagsRootOnly(t *testing.T) { assert.Contains(root.metrics, keyRulesSamplerAppliedRate) assert.NotContains(root.metrics, keyRulesSamplerLimiterRate) // Knuth sampling rate tag should be set even when rate is 0 - assert.Contains(root.meta, keyKnuthSamplingRate) - assert.Equal("0", root.meta[keyKnuthSamplingRate]) + assert.Contains(root.meta.m, keyKnuthSamplingRate) + assert.Equal("0", root.meta.m[keyKnuthSamplingRate]) // neither"_dd.limit_psr", nor "_dd.rule_psr" should be present // on the child span assert.NotContains(child.metrics, keyRulesSamplerAppliedRate) assert.NotContains(child.metrics, keyRulesSamplerLimiterRate) // child span should not have Knuth sampling rate tag - assert.NotContains(child.meta, keyKnuthSamplingRate) + assert.NotContains(child.meta.m, keyKnuthSamplingRate) // context propagation locks the span, so no re-sampling should occur tr.Inject(root.Context(), TextMapCarrier(map[string]string{})) @@ -2370,7 +2368,7 @@ func TestPrioritySamplerRampCooldownNoReset(t *testing.T) { mkSpan := func(svc, env string) *Span { a := new(tinternal.SpanAttributes) a.Set(tinternal.AttrEnv, env) - return &Span{service: svc, attrs: a} + return &Span{service: svc, meta: spanMeta{attrs: a}} } // Set initial low rate. @@ -2416,7 +2414,7 @@ func TestPrioritySamplerRampUp(t *testing.T) { mkSpan := func(svc, env string) *Span { a := new(tinternal.SpanAttributes) a.Set(tinternal.AttrEnv, env) - return &Span{service: svc, attrs: a} + return &Span{service: svc, meta: spanMeta{attrs: a}} } // Set initial low rate (decrease from default 1.0, applied immediately). @@ -2459,7 +2457,7 @@ func TestPrioritySamplerRampDown(t *testing.T) { mkSpan := func(svc, env string) *Span { a := new(tinternal.SpanAttributes) a.Set(tinternal.AttrEnv, env) - return &Span{service: svc, attrs: a} + return &Span{service: svc, meta: spanMeta{attrs: a}} } // Set initial rate (decrease from default 1.0). @@ -2483,7 +2481,7 @@ func TestPrioritySamplerRampConverges(t *testing.T) { mkSpan := func(svc, env string) *Span { a := new(tinternal.SpanAttributes) a.Set(tinternal.AttrEnv, env) - return &Span{service: svc, attrs: a} + return &Span{service: svc, meta: spanMeta{attrs: a}} } // Start at 0.1, target 0.5. @@ -2510,7 +2508,7 @@ func TestPrioritySamplerRampDefaultRate(t *testing.T) { mkSpan := func(svc, env string) *Span { a := new(tinternal.SpanAttributes) a.Set(tinternal.AttrEnv, env) - return &Span{service: svc, attrs: a} + return &Span{service: svc, meta: spanMeta{attrs: a}} } // Set default rate to 0.1 (decrease from initial 1.0, applied immediately). diff --git a/ddtrace/tracer/span.go b/ddtrace/tracer/span.go index 4bd21fec438..83b6ca13684 100644 --- a/ddtrace/tracer/span.go +++ b/ddtrace/tracer/span.go @@ -85,9 +85,12 @@ func (s *Span) AsMap() map[string]any { m[ext.SpanType] = s.spanType m[ext.MapSpanStart] = s.start m[ext.MapSpanDuration] = s.duration - for k, v := range s.meta { + for k, v := range s.meta.m { m[k] = v } + s.meta.attrs.ForEach(func(name, val string) { + m[name] = val + }) for k, v := range s.metrics { m[k] = v } @@ -105,7 +108,7 @@ func (s *Span) AsMap() map[string]any { // +checklocksignore — Called from AsMap (test-only, not concurrent). func (s *Span) spanEventsAsJSONString() string { if !s.supportsEvents { - return s.meta["events"] + return s.meta.m["events"] } if s.spanEvents == nil { return "" @@ -133,18 +136,14 @@ type Span struct { // +checklocks:mu spanType string `msg:"type"` // protocol associated with the span (i.e. "web", "db", "cache") // +checklocks:mu - // attrs holds the V1-protocol promoted fields (env, version, component, spanKind). - // Pointer to allow copy-on-write sharing: the tracer creates one shared instance with - // process-level values (env, version). Spans that only inherit those values - // share the pointer (8 bytes). Spans that set per-span fields (component, spanKind) - // clone on first write. Nil-safe read methods avoid nil checks at call sites. - attrs *tinternal.SpanAttributes `msg:"-"` - // +checklocks:mu start int64 `msg:"start"` // span start time expressed in nanoseconds since epoch // +checklocks:mu duration int64 `msg:"duration"` // duration of the span expressed in nanoseconds // +checklocks:mu - meta map[string]string `msg:"meta,omitempty"` // arbitrary map of metadata + // meta holds string metadata. Promoted attributes (env, version, component, + // span.kind) live in meta.attrs and are excluded from meta.m; the custom + // msgp codec merges both for wire encoding. + meta spanMeta `msg:"meta,omitempty"` // arbitrary map of metadata + promoted attrs // +checklocks:mu metaStruct metaStructMap `msg:"meta_struct,omitempty"` // arbitrary map of metadata with structured values // +checklocks:mu @@ -231,11 +230,11 @@ func (s *Span) getResource() string { func (s *Span) getAndRemoveMeta(key string) string { s.mu.Lock() defer s.mu.Unlock() - if s.meta == nil { - s.meta = make(map[string]string, 1) + if s.meta.m == nil { + s.meta.m = make(map[string]string, 1) } - if v, ok := s.meta[key]; ok { - delete(s.meta, key) + if v, ok := s.meta.m[key]; ok { + delete(s.meta.m, key) delete(s.metrics, key) return v } @@ -299,7 +298,7 @@ func (s *Span) debugInfo() (name string, spanID, traceID uint64, integration str name = s.name spanID = s.spanID traceID = s.traceID - if v, ok := s.attrs.Get(tinternal.AttrComponent); ok { + if v, ok := s.meta.attrs.Get(tinternal.AttrComponent); ok { integration = v } else { integration = "manual" @@ -312,8 +311,11 @@ func (s *Span) debugInfo() (name string, spanID, traceID uint64, integration str func (s *Span) getMetadata() map[string]string { s.mu.RLock() defer s.mu.RUnlock() - meta := make(map[string]string, len(s.meta)) - maps.Copy(meta, s.meta) + meta := make(map[string]string, len(s.meta.m)+s.meta.attrs.Count()) + maps.Copy(meta, s.meta.m) + s.meta.attrs.ForEach(func(name, val string) { + meta[name] = val + }) return meta } @@ -325,8 +327,13 @@ func (s *Span) matchTagsForSampling(tagPatterns map[string]func(string) bool) bo defer s.mu.RUnlock() for k, matchFunc := range tagPatterns { - if s.meta != nil { - if v, ok := s.meta[k]; ok && matchFunc(v) { + if s.meta.m != nil { + if v, ok := s.meta.m[k]; ok && matchFunc(v) { + continue + } + } + if ak, ok := tinternal.AttrKeyForTag(k); ok { + if v, has := s.meta.attrs.Get(ak); has && matchFunc(v) { continue } } @@ -575,7 +582,7 @@ func (s *Span) SetUser(id string, opts ...UserMonitoringOption) { } if cfg.PropagateID { // Delete usr.id from the tags since _dd.p.usr.id takes precedence - delete(root.meta, keyUserID) + delete(root.meta.m, keyUserID) idenc := base64.StdEncoding.EncodeToString([]byte(id)) trace.setPropagatingTag(keyPropagatedUserID, idenc) s.context.updated = true @@ -585,7 +592,7 @@ func (s *Span) SetUser(id string, opts ...UserMonitoringOption) { trace.unsetPropagatingTag(keyPropagatedUserID) s.context.updated = true } - delete(root.meta, keyPropagatedUserID) + delete(root.meta.m, keyPropagatedUserID) } usrData := map[string]string{ @@ -765,20 +772,24 @@ func (s *Span) setMetaInit(key, v string) { return case ext.Environment: s.setAttrCOW(tinternal.AttrEnv, v) + return case ext.Version: s.setAttrCOW(tinternal.AttrVersion, v) + return case ext.Component: s.setAttrCOW(tinternal.AttrComponent, v) + return case ext.SpanKind: s.setAttrCOW(tinternal.AttrSpanKind, v) + return } // Promoted fields (env/version/component/spanKind) fall through here so they // remain in meta too. The V0.4 encoder and the external stats concentrator both // read directly from the meta map, so dual-storage is required for correctness. - if s.meta == nil { - s.meta = make(map[string]string, 1) + if s.meta.m == nil { + s.meta.m = initMeta() } - s.meta[key] = v + s.meta.m[key] = v } // setAttrCOW sets a promoted attribute with copy-on-write semantics. @@ -786,22 +797,22 @@ func (s *Span) setMetaInit(key, v string) { // instance), the write is skipped entirely — no clone, no allocation. // +checklocksignore — Initialization time, span not yet shared. func (s *Span) setAttrCOW(key tinternal.AttrKey, v string) { - if s.attrs != nil && s.attrs.Val(key) == v { + if s.meta.attrs != nil && s.meta.attrs.Val(key) == v { return // already has the right value (shared or local) } s.ensureAttrsLocal() - s.attrs.Set(key, v) + s.meta.attrs.Set(key, v) } -// ensureAttrsLocal guarantees s.attrs is a mutable, span-local instance. +// ensureAttrsLocal guarantees s.meta.attrs is a mutable, span-local instance. // +checklocksignore — Initialization time, span not yet shared. func (s *Span) ensureAttrsLocal() { - if s.attrs == nil { - s.attrs = new(tinternal.SpanAttributes) + if s.meta.attrs == nil { + s.meta.attrs = new(tinternal.SpanAttributes) return } - if s.attrs.IsShared() { - s.attrs = s.attrs.Clone() + if s.meta.attrs.IsShared() { + s.meta.attrs = s.meta.attrs.Clone() } } @@ -850,7 +861,7 @@ func (s *Span) setMetricInit(key string, v float64) { if s.metrics == nil { s.metrics = make(map[string]float64, 1) } - delete(s.meta, key) + delete(s.meta.m, key) // Note: We don't handle ManualKeep or _sampling_priority_v1shim during init // because those require modifying trace-level state which needs locking s.metrics[key] = v @@ -870,7 +881,7 @@ func (s *Span) setMetricLocked(key string, v float64) { if s.metrics == nil { s.metrics = make(map[string]float64, 1) } - delete(s.meta, key) + delete(s.meta.m, key) switch key { case ext.ManualKeep: if v == float64(samplernames.AppSec) { @@ -919,10 +930,10 @@ func (s *Span) serializeSpanLinksInMeta() { log.Debug("Unable to marshal span links. Not adding span links to span meta.") return } - if s.meta == nil { - s.meta = make(map[string]string) + if s.meta.m == nil { + s.meta.m = make(map[string]string) } - s.meta["_dd.span_links"] = string(spanLinkBytes) + s.meta.m["_dd.span_links"] = string(spanLinkBytes) } // serializeSpanEvents sets the span events from the current span in the correct transport, depending on whether the @@ -946,7 +957,7 @@ func (s *Span) serializeSpanEvents() { log.Debug("Unable to marshal span events; events dropped from span meta\n%s", err.Error()) return } - s.meta["events"] = string(b) + s.meta.m["events"] = string(b) } // Finish closes this Span (but not its children) providing the duration @@ -1036,10 +1047,10 @@ func (s *Span) enrichServiceSource() { if s.serviceSource == "" || s.service == globalconfig.ServiceName() { return } - if s.meta == nil { - s.meta = make(map[string]string, 1) + if s.meta.m == nil { + s.meta.m = make(map[string]string, 1) } - s.meta[ext.KeyServiceSource] = s.serviceSource + s.meta.m[ext.KeyServiceSource] = s.serviceSource } func (s *Span) finish(finishTime int64) { @@ -1191,9 +1202,12 @@ func (s *Span) String() string { fmt.Sprintf("Type: %s", s.spanType), "Tags:", } - for key, val := range s.meta { + for key, val := range s.meta.m { lines = append(lines, fmt.Sprintf("\t%s:%s", key, val)) } + s.meta.attrs.ForEach(func(name, val string) { + lines = append(lines, fmt.Sprintf("\t%s:%s", name, val)) + }) for key, val := range s.metrics { lines = append(lines, fmt.Sprintf("\t%s:%f", key, val)) } @@ -1288,9 +1302,11 @@ func setLLMObsPropagatingTags(ctx context.Context, spanCtx *SpanContext) { // The 4/3 factor (≈ inverse of the standard 0.75 load factor) provides // ~33% slack so small overestimates don't trigger an immediate rehash. func initMeta() map[string]string { - // Unconditionally set meta tags: env, version, component, span.kind, language + // Non-promoted tags that commonly appear at construction time. + // Promoted fields (env, version, component, span.kind) now live in + // meta.attrs and are excluded from this map. const ( - expectedEntries = 5 + expectedEntries = 1 loadFactor = 4 / 3 ) return make(map[string]string, expectedEntries*loadFactor) @@ -1304,15 +1320,15 @@ func getMeta(s *Span, key string) (string, bool) { // semantics without a map lookup. switch key { case ext.Environment: - return s.attrs.Get(tinternal.AttrEnv) + return s.meta.attrs.Get(tinternal.AttrEnv) case ext.Version: - return s.attrs.Get(tinternal.AttrVersion) + return s.meta.attrs.Get(tinternal.AttrVersion) case ext.Component: - return s.attrs.Get(tinternal.AttrComponent) + return s.meta.attrs.Get(tinternal.AttrComponent) case ext.SpanKind: - return s.attrs.Get(tinternal.AttrSpanKind) + return s.meta.attrs.Get(tinternal.AttrSpanKind) } - val, ok := s.meta[key] + val, ok := s.meta.m[key] return val, ok } diff --git a/ddtrace/tracer/span_event_test.go b/ddtrace/tracer/span_event_test.go index c3c8d0ad9c5..903d53f5242 100644 --- a/ddtrace/tracer/span_event_test.go +++ b/ddtrace/tracer/span_event_test.go @@ -137,10 +137,10 @@ func Test_spanAddEvent(t *testing.T) { s.Finish() require.Empty(t, s.spanEvents) - assert.NotEmpty(t, s.meta["events"]) + assert.NotEmpty(t, s.meta.m["events"]) var spanEvents []spanEvent - err := json.Unmarshal([]byte(s.meta["events"]), &spanEvents) + err := json.Unmarshal([]byte(s.meta.m["events"]), &spanEvents) require.NoError(t, err) require.Len(t, spanEvents, 3) diff --git a/ddtrace/tracer/span_meta.go b/ddtrace/tracer/span_meta.go new file mode 100644 index 00000000000..82799c18fdc --- /dev/null +++ b/ddtrace/tracer/span_meta.go @@ -0,0 +1,126 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2025 Datadog, Inc. + +package tracer + +import ( + "fmt" + "strings" + + "github.com/tinylib/msgp/msgp" + + tinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" +) + +var ( + _ msgp.Encodable = (*spanMeta)(nil) + _ msgp.Decodable = (*spanMeta)(nil) + _ msgp.Sizer = (*spanMeta)(nil) +) + +// spanMeta replaces a plain map[string]string for the Span.meta field. +// Promoted attributes (env, version, component, span.kind) live in attrs +// and are excluded from the map m. The msgp codec merges both sources +// transparently so the wire format is unchanged. +type spanMeta struct { + m map[string]string + attrs *tinternal.SpanAttributes +} + +// String returns a merged map representation (m + promoted attrs) for debug logging. +func (sm spanMeta) String() string { + var b strings.Builder + b.WriteString("map[") + first := true + for k, v := range sm.m { + if !first { + b.WriteByte(' ') + } + first = false + fmt.Fprintf(&b, "%s:%s", k, v) + } + sm.attrs.ForEach(func(name, val string) { + if !first { + b.WriteByte(' ') + } + first = false + fmt.Fprintf(&b, "%s:%s", name, val) + }) + b.WriteByte(']') + return b.String() +} + +// EncodeMsg writes the combined map header (m entries + promoted attrs), +// then emits all map entries followed by promoted attribute entries. +func (sm *spanMeta) EncodeMsg(en *msgp.Writer) error { + total := uint32(len(sm.m) + sm.attrs.Count()) + if err := en.WriteMapHeader(total); err != nil { + return msgp.WrapError(err, "Meta") + } + for k, v := range sm.m { + if err := en.WriteString(k); err != nil { + return msgp.WrapError(err, "Meta") + } + if err := en.WriteString(v); err != nil { + return msgp.WrapError(err, "Meta", k) + } + } + var encErr error + sm.attrs.ForEach(func(name, val string) { + if encErr != nil { + return + } + if err := en.WriteString(name); err != nil { + encErr = msgp.WrapError(err, "Meta") + return + } + if err := en.WriteString(val); err != nil { + encErr = msgp.WrapError(err, "Meta", name) + } + }) + return encErr +} + +// DecodeMsg reads a msgp map and routes promoted keys into attrs, +// everything else into m. +func (sm *spanMeta) DecodeMsg(dc *msgp.Reader) error { + header, err := dc.ReadMapHeader() + if err != nil { + return msgp.WrapError(err, "Meta") + } + // Pre-allocate assuming most keys go to m. + sm.m = make(map[string]string, header) + for range header { + key, err := dc.ReadString() + if err != nil { + return msgp.WrapError(err, "Meta") + } + val, err := dc.ReadString() + if err != nil { + return msgp.WrapError(err, "Meta", key) + } + if ak, ok := tinternal.AttrKeyForTag(key); ok { + if sm.attrs == nil { + sm.attrs = new(tinternal.SpanAttributes) + } + sm.attrs.Set(ak, val) + } else { + sm.m[key] = val + } + } + return nil +} + +// Msgsize returns an upper bound estimate of the serialized size. +func (sm *spanMeta) Msgsize() int { + size := msgp.MapHeaderSize + for k, v := range sm.m { + size += msgp.StringPrefixSize + len(k) + msgp.StringPrefixSize + len(v) + } + sm.attrs.ForEach(func(name, val string) { + size += msgp.StringPrefixSize + len(name) + msgp.StringPrefixSize + len(val) + }) + return size +} diff --git a/ddtrace/tracer/span_msgp.go b/ddtrace/tracer/span_msgp.go index 4d1a326de9d..3bf3c8ad7b1 100644 --- a/ddtrace/tracer/span_msgp.go +++ b/ddtrace/tracer/span_msgp.go @@ -62,33 +62,11 @@ func (z *Span) DecodeMsg(dc *msgp.Reader) (err error) { return } case "meta": - var zb0002 uint32 - zb0002, err = dc.ReadMapHeader() + err = z.meta.DecodeMsg(dc) if err != nil { err = msgp.WrapError(err, "meta") return } - if z.meta == nil { - z.meta = make(map[string]string, zb0002) - } else if len(z.meta) > 0 { - clear(z.meta) - } - for zb0002 > 0 { - zb0002-- - var za0001 string - za0001, err = dc.ReadString() - if err != nil { - err = msgp.WrapError(err, "meta") - return - } - var za0002 string - za0002, err = dc.ReadString() - if err != nil { - err = msgp.WrapError(err, "meta", za0001) - return - } - z.meta[za0001] = za0002 - } case "meta_struct": err = z.metaStruct.DecodeMsg(dc) if err != nil { @@ -203,7 +181,7 @@ func (z *Span) EncodeMsg(en *msgp.Writer) (err error) { zb0001Len := uint32(15) var zb0001Mask uint16 /* 15 bits */ _ = zb0001Mask - if z.meta == nil { + if z.meta.m == nil && z.meta.attrs.Count() == 0 { zb0001Len-- zb0001Mask |= 0x40 } @@ -293,23 +271,11 @@ func (z *Span) EncodeMsg(en *msgp.Writer) (err error) { if err != nil { return } - err = en.WriteMapHeader(uint32(len(z.meta))) + err = z.meta.EncodeMsg(en) if err != nil { err = msgp.WrapError(err, "meta") return } - for za0001, za0002 := range z.meta { - err = en.WriteString(za0001) - if err != nil { - err = msgp.WrapError(err, "meta") - return - } - err = en.WriteString(za0002) - if err != nil { - err = msgp.WrapError(err, "meta", za0001) - return - } - } } // write "meta_struct" err = en.Append(0xab, 0x6d, 0x65, 0x74, 0x61, 0x5f, 0x73, 0x74, 0x72, 0x75, 0x63, 0x74) @@ -430,13 +396,7 @@ func (z *Span) EncodeMsg(en *msgp.Writer) (err error) { // Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message // +checklocksignore — Generated code: post-finish serialization or pre-share deserialization. func (z *Span) Msgsize() (s int) { - s = 1 + 5 + msgp.StringPrefixSize + len(z.name) + 8 + msgp.StringPrefixSize + len(z.service) + 9 + msgp.StringPrefixSize + len(z.resource) + 5 + msgp.StringPrefixSize + len(z.spanType) + 6 + msgp.Int64Size + 9 + msgp.Int64Size + 5 + msgp.MapHeaderSize - if z.meta != nil { - for za0001, za0002 := range z.meta { - _ = za0002 - s += msgp.StringPrefixSize + len(za0001) + msgp.StringPrefixSize + len(za0002) - } - } + s = 1 + 5 + msgp.StringPrefixSize + len(z.name) + 8 + msgp.StringPrefixSize + len(z.service) + 9 + msgp.StringPrefixSize + len(z.resource) + 5 + msgp.StringPrefixSize + len(z.spanType) + 6 + msgp.Int64Size + 9 + msgp.Int64Size + 5 + z.meta.Msgsize() s += 12 + z.metaStruct.Msgsize() + 8 + msgp.MapHeaderSize if z.metrics != nil { for za0003, za0004 := range z.metrics { diff --git a/ddtrace/tracer/span_test.go b/ddtrace/tracer/span_test.go index 3116c645c5e..e6735bcd778 100644 --- a/ddtrace/tracer/span_test.go +++ b/ddtrace/tracer/span_test.go @@ -40,7 +40,7 @@ func newSpan(name, service, resource string, spanID, traceID, parentID uint64) * name: name, service: service, resource: resource, - meta: map[string]string{}, + meta: spanMeta{m: map[string]string{}}, metrics: map[string]float64{}, spanID: spanID, traceID: traceID, @@ -429,14 +429,13 @@ func TestSpanSetTag(t *testing.T) { assert.Equal("web.request", span.name) span.SetTag("component", "tracer") - assert.Equal("tracer", span.attrs.Val(tinternal.AttrComponent)) - assert.Equal("tracer", span.meta[ext.Component]) // dual-stored + assert.Equal("tracer", span.meta.attrs.Val(tinternal.AttrComponent)) span.SetTag("tagInt", 1234) assert.Equal(float64(1234), span.metrics["tagInt"]) span.SetTag("tagStruct", struct{ A, B int }{1, 2}) - assert.Equal("{1 2}", span.meta["tagStruct"]) + assert.Equal("{1 2}", span.meta.m["tagStruct"]) span.SetTag(ext.Error, true) assert.Equal(int32(1), span.error) @@ -446,9 +445,9 @@ func TestSpanSetTag(t *testing.T) { span.SetTag(ext.Error, errors.New("abc")) assert.Equal(int32(1), span.error) - assert.Equal("abc", span.meta[ext.ErrorMsg]) - assert.Equal("*errors.errorString", span.meta[ext.ErrorType]) - assert.NotEmpty(span.meta[ext.ErrorHandlingStack]) + assert.Equal("abc", span.meta.m[ext.ErrorMsg]) + assert.Equal("*errors.errorString", span.meta.m[ext.ErrorType]) + assert.NotEmpty(span.meta.m[ext.ErrorHandlingStack]) span.SetTag(ext.Error, "something else") assert.Equal(int32(1), span.error) @@ -457,24 +456,24 @@ func TestSpanSetTag(t *testing.T) { assert.Equal(int32(0), span.error) span.SetTag("some.bool", true) - assert.Equal("true", span.meta["some.bool"]) + assert.Equal("true", span.meta.m["some.bool"]) span.SetTag("some.other.bool", false) - assert.Equal("false", span.meta["some.other.bool"]) + assert.Equal("false", span.meta.m["some.other.bool"]) span.SetTag("time", (*time.Time)(nil)) - assert.Equal("", span.meta["time"]) + assert.Equal("", span.meta.m["time"]) span.SetTag("nilStringer", (*nilStringer)(nil)) - assert.Equal("", span.meta["nilStringer"]) + assert.Equal("", span.meta.m["nilStringer"]) span.SetTag("somestrings", []string{"foo", "bar"}) - assert.Equal("foo", span.meta["somestrings.0"]) - assert.Equal("bar", span.meta["somestrings.1"]) + assert.Equal("foo", span.meta.m["somestrings.0"]) + assert.Equal("bar", span.meta.m["somestrings.1"]) span.SetTag("somebools", []bool{true, false}) - assert.Equal("true", span.meta["somebools.0"]) - assert.Equal("false", span.meta["somebools.1"]) + assert.Equal("true", span.meta.m["somebools.0"]) + assert.Equal("false", span.meta.m["somebools.1"]) span.SetTag("somenums", []int{-1, 5, 2}) assert.Equal(-1., span.metrics["somenums.0"]) @@ -482,10 +481,10 @@ func TestSpanSetTag(t *testing.T) { assert.Equal(2., span.metrics["somenums.2"]) span.SetTag("someslices", [][]string{{"a, b, c"}, {"d"}, nil, {"e, f"}}) - assert.Equal("[a, b, c]", span.meta["someslices.0"]) - assert.Equal("[d]", span.meta["someslices.1"]) - assert.Equal("[]", span.meta["someslices.2"]) - assert.Equal("[e, f]", span.meta["someslices.3"]) + assert.Equal("[a, b, c]", span.meta.m["someslices.0"]) + assert.Equal("[d]", span.meta.m["someslices.1"]) + assert.Equal("[]", span.meta.m["someslices.2"]) + assert.Equal("[e, f]", span.meta.m["someslices.3"]) mapStrStr := map[string]string{"b": "c"} span.SetTag("map", sharedinternal.MetaStructValue{Value: map[string]string{"b": "c"}}) @@ -515,10 +514,10 @@ func TestSpanSetTag(t *testing.T) { s := "string" span.SetTag("str_ptr", &s) - assert.Equal(s, span.meta["str_ptr"]) + assert.Equal(s, span.meta.m["str_ptr"]) span.SetTag("nil_str_ptr", (*string)(nil)) - assert.Equal("", span.meta["nil_str_ptr"]) + assert.Equal("", span.meta.m["nil_str_ptr"]) assert.Panics(func() { span.SetTag("panicStringer", &panicStringer{}) @@ -539,33 +538,30 @@ func TestSpanTagsStartSpan(t *testing.T) { assert.Equal("operation-name", tags[ext.SpanName]) } -// TestPromotedFieldsDualStorage verifies that setting any of the four V1-promoted -// tags (env, version, component, span.kind) via SetTag stores the value in both -// the dedicated struct field and the meta map. The struct field is the fast path -// used by the V1 encoder; the meta entry is required by the V0.4 (msgp) encoder -// and the external stats concentrator. -func TestPromotedFieldsDualStorage(t *testing.T) { +// TestPromotedFieldsStorage verifies that setting any of the four V1-promoted +// tags (env, version, component, span.kind) via SetTag stores the value in the +// dedicated SpanAttributes struct field inside meta. Promoted fields no longer +// appear in the meta.m map. +func TestPromotedFieldsStorage(t *testing.T) { assert := assert.New(t) for _, tc := range []struct { tag string field func(*Span) string }{ - {ext.Environment, func(s *Span) string { return s.attrs.Val(tinternal.AttrEnv) }}, - {ext.Version, func(s *Span) string { return s.attrs.Val(tinternal.AttrVersion) }}, - {ext.Component, func(s *Span) string { return s.attrs.Val(tinternal.AttrComponent) }}, - {ext.SpanKind, func(s *Span) string { return s.attrs.Val(tinternal.AttrSpanKind) }}, + {ext.Environment, func(s *Span) string { return s.meta.attrs.Val(tinternal.AttrEnv) }}, + {ext.Version, func(s *Span) string { return s.meta.attrs.Val(tinternal.AttrVersion) }}, + {ext.Component, func(s *Span) string { return s.meta.attrs.Val(tinternal.AttrComponent) }}, + {ext.SpanKind, func(s *Span) string { return s.meta.attrs.Val(tinternal.AttrSpanKind) }}, } { t.Run(tc.tag, func(t *testing.T) { span := newBasicSpan("op") span.SetTag(tc.tag, "value") assert.Equal("value", tc.field(span), "struct field must be set") - assert.Equal("value", span.meta[tc.tag], "meta map must be set (dual-storage)") - // Overwrite: both sides should track the update. + // Overwrite: field should track the update. span.SetTag(tc.tag, "updated") assert.Equal("updated", tc.field(span)) - assert.Equal("updated", span.meta[tc.tag]) }) } } @@ -583,13 +579,13 @@ func TestSpanSetTagError(t *testing.T) { t.Run("off", func(t *testing.T) { span := newBasicSpan("web.request") span.SetTag(ext.ErrorNoStackTrace, errors.New("error value with no trace")) - assert.Empty(t, span.meta[ext.ErrorHandlingStack]) + assert.Empty(t, span.meta.m[ext.ErrorHandlingStack]) }) t.Run("on", func(t *testing.T) { span := newBasicSpan("web.request") span.SetTag(ext.Error, errors.New("error value with trace")) - assert.NotEmpty(t, span.meta[ext.ErrorHandlingStack]) + assert.NotEmpty(t, span.meta.m[ext.ErrorHandlingStack]) }) } @@ -792,9 +788,9 @@ func TestSpanStartNilOption(t *testing.T) { t.Run(tc.name, func(_ *testing.T) { span := tracer.StartSpan("pylons.request", tc.options...) if tc.wantTag { - assert.Equal(tc.wantTag, span.meta["tag"] == "value") + assert.Equal(tc.wantTag, span.meta.m["tag"] == "value") } else { - assert.Empty(span.meta["tag"]) + assert.Empty(span.meta.m["tag"]) } }) } @@ -846,12 +842,12 @@ func TestSpanSetMetric(t *testing.T) { "toobig": func(assert *assert.Assertions, span *Span) { span.SetTag("bytes", intUpperLimit) assert.Equal(0.0, span.metrics["bytes"]) - assert.Equal(fmt.Sprint(intUpperLimit), span.meta["bytes"]) + assert.Equal(fmt.Sprint(intUpperLimit), span.meta.m["bytes"]) }, "toosmall": func(assert *assert.Assertions, span *Span) { span.SetTag("bytes", intLowerLimit) assert.Equal(0.0, span.metrics["bytes"]) - assert.Equal(fmt.Sprint(intLowerLimit), span.meta["bytes"]) + assert.Equal(fmt.Sprint(intLowerLimit), span.meta.m["bytes"]) }, "finished": func(assert *assert.Assertions, span *Span) { span.Finish() @@ -914,10 +910,10 @@ func TestErrorStack(t *testing.T) { err = createErrorTrace() span.SetTag(ext.Error, err) assert.Equal(int32(1), span.error) - assert.Equal("Something wrong", span.meta[ext.ErrorMsg]) - assert.Equal("*errortrace.TracerError", span.meta[ext.ErrorType]) + assert.Equal("Something wrong", span.meta.m[ext.ErrorMsg]) + assert.Equal("*errortrace.TracerError", span.meta.m[ext.ErrorType]) - stack := span.meta[ext.ErrorHandlingStack] + stack := span.meta.m[ext.ErrorHandlingStack] assert.NotEqual("", stack) span.Finish() @@ -933,10 +929,10 @@ func TestErrorStack(t *testing.T) { err = createTestError() span.SetTag(ext.Error, err) assert.Equal(int32(1), span.error) - assert.Equal("Something wrong", span.meta[ext.ErrorMsg]) - assert.Equal("*errors.errorString", span.meta[ext.ErrorType]) + assert.Equal("Something wrong", span.meta.m[ext.ErrorMsg]) + assert.Equal("*errors.errorString", span.meta.m[ext.ErrorType]) - stack := span.meta[ext.ErrorHandlingStack] + stack := span.meta.m[ext.ErrorHandlingStack] assert.NotEqual("", stack) span.Finish() @@ -955,14 +951,14 @@ func TestSpanError(t *testing.T) { err = errors.New("Something wrong") span.SetTag(ext.Error, err) assert.Equal(int32(1), span.error) - assert.Equal("Something wrong", span.meta[ext.ErrorMsg]) - assert.Equal("*errors.errorString", span.meta[ext.ErrorType]) - assert.NotEqual("", span.meta[ext.ErrorHandlingStack]) + assert.Equal("Something wrong", span.meta.m[ext.ErrorMsg]) + assert.Equal("*errors.errorString", span.meta.m[ext.ErrorType]) + assert.NotEqual("", span.meta.m[ext.ErrorHandlingStack]) span.Finish() // operating on a finished span is a no-op span = tracer.newRootSpan("flask.request", "flask", "/") - nMeta := len(span.meta) + nMeta := len(span.meta.m) span.Finish() span.SetTag(ext.Error, err) assert.Equal(int32(0), span.error) @@ -970,7 +966,7 @@ func TestSpanError(t *testing.T) { // '+4' is `_dd.p.dm` + `_dd.base_service` + `_dd.p.tid` + `_dd.svc_src` meta := span.getMetadata() t.Logf("%q\n", meta) - assert.Equal(nMeta+4, len(meta)) + assert.Equal(nMeta+3+span.meta.attrs.Count(), len(meta)) assert.Equal("", meta[ext.ErrorMsg]) assert.Equal("", meta[ext.ErrorType]) assert.Equal("", meta[ext.ErrorHandlingStack]) @@ -987,9 +983,9 @@ func TestSpanError_Typed(t *testing.T) { err = &boomError{} span.SetTag(ext.Error, err) assert.Equal(int32(1), span.error) - assert.Equal("boom", span.meta[ext.ErrorMsg]) - assert.Equal("*tracer.boomError", span.meta[ext.ErrorType]) - assert.NotEqual("", span.meta[ext.ErrorHandlingStack]) + assert.Equal("boom", span.meta.m[ext.ErrorMsg]) + assert.Equal("*tracer.boomError", span.meta.m[ext.ErrorType]) + assert.NotEqual("", span.meta.m[ext.ErrorHandlingStack]) } func TestSpanErrorNil(t *testing.T) { @@ -1001,10 +997,10 @@ func TestSpanErrorNil(t *testing.T) { span := tracer.newRootSpan("pylons.request", "pylons", "/") // don't set the error if it's nil - nMeta := len(span.meta) + nMeta := len(span.meta.m) span.SetTag(ext.Error, nil) assert.Equal(int32(0), span.error) - assert.Equal(nMeta, len(span.meta)) + assert.Equal(nMeta, len(span.meta.m)) } func TestSpanErrorStackMetrics(t *testing.T) { @@ -1139,14 +1135,14 @@ func TestUniqueTagKeys(t *testing.T) { span.SetTag("foo.bar", "val") assert.NotContains(span.metrics, "foo.bar") - assert.Equal("val", span.meta["foo.bar"]) + assert.Equal("val", span.meta.m["foo.bar"]) // check to see if setMetric correctly wipes out a meta tag span.SetTag("foo.bar", "val") span.SetTag("foo.bar", 12) assert.Equal(12.0, span.metrics["foo.bar"]) - assert.NotContains(span.meta, "foo.bar") + assert.NotContains(span.meta.m, "foo.bar") } // Prior to a bug fix, this failed when running `go test -race` @@ -1737,7 +1733,7 @@ func TestSpanLinksInMeta(t *testing.T) { sp.Finish() internalSpan := sp - _, ok := internalSpan.meta["_dd.span_links"] + _, ok := internalSpan.meta.m["_dd.span_links"] assert.False(t, ok, "Expected no _dd.span_links in Meta.") }) @@ -1753,7 +1749,7 @@ func TestSpanLinksInMeta(t *testing.T) { sp.Finish() internalSpan := sp - raw, ok := internalSpan.meta["_dd.span_links"] + raw, ok := internalSpan.meta.m["_dd.span_links"] require.True(t, ok, "Expected _dd.span_links in Meta after adding links.") var links []SpanLink diff --git a/ddtrace/tracer/spancontext.go b/ddtrace/tracer/spancontext.go index ca116fd10b4..a931d004646 100644 --- a/ddtrace/tracer/spancontext.go +++ b/ddtrace/tracer/spancontext.go @@ -849,15 +849,15 @@ func setPeerService(s *Span, tc TracerConf) { // val() is used: only specific non-empty values ("client", "producer") qualify as // outbound requests, so an unset and an explicitly-empty spanKind are both correctly // treated as non-outbound. - spanKind := s.attrs.Val(tinternal.AttrSpanKind) + spanKind := s.meta.attrs.Val(tinternal.AttrSpanKind) isOutboundRequest := spanKind == ext.SpanKindClient || spanKind == ext.SpanKindProducer - if _, ok := s.meta[ext.PeerService]; ok { // peer.service already set on the span + if _, ok := s.meta.m[ext.PeerService]; ok { // peer.service already set on the span s.setMetaLocked(keyPeerServiceSource, ext.PeerService) } else if isServerless(tc) { // Set peerService only in outbound Lambda requests if isOutboundRequest { - if ps := deriveAWSPeerService(s.meta); ps != "" { + if ps := deriveAWSPeerService(s.meta.m); ps != "" { s.setMetaLocked(ext.PeerService, ps) s.setMetaLocked(keyPeerServiceSource, ext.PeerService) } else { @@ -877,7 +877,7 @@ func setPeerService(s *Span, tc TracerConf) { s.setMetaLocked(keyPeerServiceSource, source) } // Overwrite existing peer.service value if remapped by the user - ps := s.meta[ext.PeerService] + ps := s.meta.m[ext.PeerService] if to, ok := tc.PeerServiceMappings[ps]; ok { s.setMetaLocked(keyPeerServiceRemappedFrom, ps) s.setMetaLocked(ext.PeerService, to) @@ -936,7 +936,7 @@ func deriveAWSPeerService(sm map[string]string) string { // +checklocks:s.mu func (s *Span) hasMetaKeyLocked(tag string) bool { assert.RWMutexLocked(&s.mu) - _, ok := s.meta[tag] + _, ok := s.meta.m[tag] return ok } @@ -959,7 +959,7 @@ func setPeerServiceFromSource(s *Span) string { "tablename", "bucketname", } - case s.meta[ext.DBSystem] == ext.DBSystemCassandra: + case s.meta.m[ext.DBSystem] == ext.DBSystemCassandra: sources = []string{ ext.CassandraContactPoints, } @@ -987,7 +987,7 @@ func setPeerServiceFromSource(s *Span) string { }...) } for _, source := range sources { - if val, ok := s.meta[source]; ok { + if val, ok := s.meta.m[source]; ok { s.setMetaLocked(ext.PeerService, val) return source } diff --git a/ddtrace/tracer/spancontext_test.go b/ddtrace/tracer/spancontext_test.go index c50fc1d72ac..6c6da995f54 100644 --- a/ddtrace/tracer/spancontext_test.go +++ b/ddtrace/tracer/spancontext_test.go @@ -226,7 +226,7 @@ func testAsyncSpanRace(t *testing.T) { finishes.Wait() for range 500 { - for range root.meta { + for range root.meta.m { // this range simulates iterating over the meta map // as we do when encoding msgpack upon flushing. continue @@ -314,9 +314,9 @@ func TestPartialFlush(t *testing.T) { ts := transport.Traces() require.Len(t, ts, 1) require.Len(t, ts[0], 2) - assert.Equal(t, "someValue", ts[0][0].meta["someTraceTag"]) + assert.Equal(t, "someValue", ts[0][0].meta.m["someTraceTag"]) assert.Equal(t, 1.0, ts[0][0].metrics[keySamplingPriority]) - assert.Empty(t, ts[0][1].meta["someTraceTag"]) // the tag should only be on the first span in the chunk + assert.Empty(t, ts[0][1].meta.m["someTraceTag"]) // the tag should only be on the first span in the chunk assert.Equal(t, 1.0, ts[0][1].metrics[keySamplingPriority]) // the tag should only be on the first span in the chunk comparePayloadSpans(t, children[0], ts[0][0]) comparePayloadSpans(t, children[1], ts[0][1]) @@ -330,9 +330,9 @@ func TestPartialFlush(t *testing.T) { tsRoot := transport.Traces() require.Len(t, tsRoot, 1) require.Len(t, tsRoot[0], 2) - assert.Equal(t, "someValue", ts[0][0].meta["someTraceTag"]) + assert.Equal(t, "someValue", ts[0][0].meta.m["someTraceTag"]) assert.Equal(t, 1.0, ts[0][0].metrics[keySamplingPriority]) - assert.Empty(t, ts[0][1].meta["someTraceTag"]) // the tag should only be on the first span in the chunk + assert.Empty(t, ts[0][1].meta.m["someTraceTag"]) // the tag should only be on the first span in the chunk assert.Equal(t, 1.0, ts[0][1].metrics[keySamplingPriority]) // the tag should only be on the first span in the chunk comparePayloadSpans(t, root, tsRoot[0][0]) comparePayloadSpans(t, children[2], tsRoot[0][1]) @@ -787,19 +787,19 @@ func TestSpanPeerService(t *testing.T) { for _, tc := range testCases { assertSpan := func(t *testing.T, s *Span) { if tc.wantPeerService == "" { - assert.NotContains(t, s.meta, "peer.service") + assert.NotContains(t, s.meta.m, "peer.service") } else { - assert.Equal(t, tc.wantPeerService, s.meta["peer.service"]) + assert.Equal(t, tc.wantPeerService, s.meta.m["peer.service"]) } if tc.wantPeerServiceSource == "" { - assert.NotContains(t, s.meta, "_dd.peer.service.source") + assert.NotContains(t, s.meta.m, "_dd.peer.service.source") } else { - assert.Equal(t, tc.wantPeerServiceSource, s.meta["_dd.peer.service.source"]) + assert.Equal(t, tc.wantPeerServiceSource, s.meta.m["_dd.peer.service.source"]) } if tc.wantPeerServiceRemappedFrom == "" { - assert.NotContains(t, s.meta, "_dd.peer.service.remapped_from") + assert.NotContains(t, s.meta.m, "_dd.peer.service.remapped_from") } else { - assert.Equal(t, tc.wantPeerServiceRemappedFrom, s.meta["_dd.peer.service.remapped_from"]) + assert.Equal(t, tc.wantPeerServiceRemappedFrom, s.meta.m["_dd.peer.service.remapped_from"]) } } t.Run(tc.name, func(t *testing.T) { @@ -867,7 +867,7 @@ func TestSpanDDBaseService(t *testing.T) { spans := run(t, tracerOpts, spanOpts) for _, s := range spans { assert.Equal(t, "span-service", s.service) - assert.Equal(t, "global-service", s.meta["_dd.base_service"]) + assert.Equal(t, "global-service", s.meta.m["_dd.base_service"]) } }) t.Run("span-service-equal-global-service", func(t *testing.T) { @@ -880,7 +880,7 @@ func TestSpanDDBaseService(t *testing.T) { spans := run(t, tracerOpts, spanOpts) for _, s := range spans { assert.Equal(t, "global-service", s.service) - assert.NotContains(t, s.meta, "_dd.base_service") + assert.NotContains(t, s.meta.m, "_dd.base_service") } }) t.Run("span-service-equal-different-case", func(t *testing.T) { @@ -893,7 +893,7 @@ func TestSpanDDBaseService(t *testing.T) { spans := run(t, tracerOpts, spanOpts) for _, s := range spans { assert.Equal(t, "GLOBAL-service", s.service) - assert.NotContains(t, s.meta, "_dd.base_service") + assert.NotContains(t, s.meta.m, "_dd.base_service") } }) t.Run("global-service-not-set", func(t *testing.T) { @@ -905,7 +905,7 @@ func TestSpanDDBaseService(t *testing.T) { assert.Equal(t, "span-service", s.service) // in this case we don't assert to a concrete value because the default tracer service name is calculated // based on the process name and might change depending on how tests are run. - assert.NotEmpty(t, s.meta["_dd.base_service"]) + assert.NotEmpty(t, s.meta.m["_dd.base_service"]) } }) t.Run("using-tag-option", func(t *testing.T) { @@ -918,7 +918,7 @@ func TestSpanDDBaseService(t *testing.T) { spans := run(t, tracerOpts, spanOpts) for _, s := range spans { assert.Equal(t, "span-service", s.service) - assert.Equal(t, "global-service", s.meta["_dd.base_service"]) + assert.Equal(t, "global-service", s.meta.m["_dd.base_service"]) } }) } @@ -1247,13 +1247,13 @@ func TestSpanProcessTags(t *testing.T) { root := traces[0][0] assert.Equal(t, "p", root.name) if tc.enabled { - assert.NotEmpty(t, root.meta["_dd.tags.process"]) + assert.NotEmpty(t, root.meta.m["_dd.tags.process"]) } else { - assert.NotContains(t, root.meta, "_dd.tags.process") + assert.NotContains(t, root.meta.m, "_dd.tags.process") } for _, s := range traces[0][1:] { - assert.NotContains(t, s.meta, "_dd.tags.process") + assert.NotContains(t, s.meta.m, "_dd.tags.process") } }) } diff --git a/ddtrace/tracer/srv_src_test.go b/ddtrace/tracer/srv_src_test.go index 519a42e1cfd..b31139fcf61 100644 --- a/ddtrace/tracer/srv_src_test.go +++ b/ddtrace/tracer/srv_src_test.go @@ -82,24 +82,7 @@ func TestServiceSource(t *testing.T) { child.mu.RLock() defer child.mu.RUnlock() - assert.Equal(t, serviceSourceManual, child.meta[ext.KeyServiceSource]) - }) - - t.Run("ChildWithExplicitServiceGetsSrvSrc", func(t *testing.T) { - // A child span that explicitly sets its own service name gets _dd.svc_src. - tracer, err := newTracer() - require.NoError(t, err) - defer tracer.Stop() - - parent := tracer.StartSpan("parent", Tag(ext.KeyServiceSource, sharedinternal.ServiceOverride{Name: "parent-service", Source: serviceSourceManual})) - child := tracer.StartSpan("child", ChildOf(parent.Context()), Tag(ext.KeyServiceSource, sharedinternal.ServiceOverride{Name: "child-service", Source: serviceSourceManual})) - child.Finish() - parent.Finish() - - child.mu.RLock() - defer child.mu.RUnlock() - assert.Equal(t, "child-service", child.service) - assert.Equal(t, serviceSourceManual, child.meta[ext.KeyServiceSource]) + assert.Equal(t, "m", child.meta.m[ext.KeyServiceSource]) }) t.Run("NoExplicitServiceNoSrvSrc", func(t *testing.T) { @@ -113,12 +96,28 @@ func TestServiceSource(t *testing.T) { span.mu.RLock() defer span.mu.RUnlock() - _, hasSrvSrc := span.meta[ext.KeyServiceSource] + _, hasSrvSrc := span.meta.m[ext.KeyServiceSource] assert.False(t, hasSrvSrc, "_dd.svc_src should not be set when no service is explicitly set") }) - t.Run("ServiceMappingOverridesSrvSrc", func(t *testing.T) { - // When a service mapping renames a span's service, _dd.svc_src becomes ext.ServiceSourceMapping. + t.Run("ChildWithExplicitServiceGetsSrvSrc", func(t *testing.T) { + // A child span that explicitly sets its own service name gets _dd.svc_src = "m". + tracer, err := newTracer() + require.NoError(t, err) + defer tracer.Stop() + + parent := tracer.StartSpan("parent", Tag(ext.KeyServiceSource, sharedinternal.ServiceOverride{Name: "parent-service", Source: "m"})) + child := tracer.StartSpan("child", ChildOf(parent.Context()), Tag(ext.KeyServiceSource, sharedinternal.ServiceOverride{Name: "child-service", Source: "m"})) + child.Finish() + parent.Finish() + + child.mu.RLock() + defer child.mu.RUnlock() + assert.Equal(t, "m", child.meta.m[ext.KeyServiceSource]) + }) + + t.Run("ServiceMappingSetsSrvSrc", func(t *testing.T) { + // When a service mapping renames a span's service, _dd.svc_src = "opt.mapping". tracer, err := newTracer(WithServiceMapping("original", "remapped")) require.NoError(t, err) defer tracer.Stop() @@ -129,7 +128,7 @@ func TestServiceSource(t *testing.T) { span.mu.RLock() defer span.mu.RUnlock() assert.Equal(t, "remapped", span.service) - assert.Equal(t, ext.ServiceSourceMapping, span.meta[ext.KeyServiceSource]) + assert.Equal(t, ext.ServiceSourceMapping, span.meta.m[ext.KeyServiceSource]) }) t.Run("SetMetaInitServiceName", func(t *testing.T) { diff --git a/ddtrace/tracer/stats.go b/ddtrace/tracer/stats.go index e18fcf6d8ae..ec0310000fe 100644 --- a/ddtrace/tracer/stats.go +++ b/ddtrace/tracer/stats.go @@ -6,6 +6,7 @@ package tracer import ( + "maps" "sync" "sync/atomic" "time" @@ -14,6 +15,7 @@ import ( "github.com/DataDog/datadog-agent/pkg/trace/stats" "github.com/DataDog/dd-trace-go/v2/ddtrace/ext" + tinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" "github.com/DataDog/dd-trace-go/v2/internal" "github.com/DataDog/dd-trace-go/v2/internal/civisibility/constants" "github.com/DataDog/dd-trace-go/v2/internal/civisibility/utils" @@ -166,8 +168,20 @@ func (c *concentrator) newTracerStatSpan(s *Span, obfuscator *obfuscate.Obfuscat resource = obfuscatedResource(obfuscator, s.spanType, s.resource) } - httpMethod := s.meta[ext.HTTPMethod] - httpEndpoint := s.meta[ext.HTTPEndpoint] + httpMethod := s.meta.m[ext.HTTPMethod] + httpEndpoint := s.meta.m[ext.HTTPEndpoint] + + // The stats concentrator reads span.kind from the Meta map. Since promoted + // fields no longer live in meta.m, inject span.kind into a shallow copy. + meta := s.meta.m + if sk := s.meta.attrs.Val(tinternal.AttrSpanKind); sk != "" { + if meta == nil { + meta = map[string]string{ext.SpanKind: sk} + } else { + meta = maps.Clone(s.meta.m) + meta[ext.SpanKind] = sk + } + } statSpan, ok := c.spanConcentrator.NewStatSpanWithConfig(stats.StatSpanConfig{ Service: s.service, @@ -178,7 +192,7 @@ func (c *concentrator) newTracerStatSpan(s *Span, obfuscator *obfuscate.Obfuscat Start: s.start, Duration: s.duration, Error: s.error, - Meta: s.meta, + Meta: meta, Metrics: s.metrics, PeerTags: c.cfg.agent.load().peerTags, HTTPMethod: httpMethod, @@ -187,7 +201,7 @@ func (c *concentrator) newTracerStatSpan(s *Span, obfuscator *obfuscate.Obfuscat if !ok { return nil, false } - origin := s.meta[keyOrigin] + origin := s.meta.m[keyOrigin] return &tracerStatSpan{ statSpan: statSpan, origin: origin, diff --git a/ddtrace/tracer/stats_test.go b/ddtrace/tracer/stats_test.go index ac5563f2d49..5a5f807066d 100644 --- a/ddtrace/tracer/stats_test.go +++ b/ddtrace/tracer/stats_test.go @@ -326,10 +326,10 @@ func TestStatsIncludeHTTPMethodAndEndpoint(t *testing.T) { start: time.Now().UnixNano(), duration: int64(time.Millisecond), metrics: map[string]float64{keyMeasured: 1}, - meta: map[string]string{ + meta: spanMeta{m: map[string]string{ ext.HTTPMethod: uniqueMethod, ext.HTTPEndpoint: uniqueEndpoint, - }, + }}, } transport := newDummyTransport() c := newConcentrator(newTestConfigWithTransport(t, transport), bucketSize, &statsd.NoOpClientDirect{}) diff --git a/ddtrace/tracer/textmap_test.go b/ddtrace/tracer/textmap_test.go index 5aa49e453cf..67f43587dd0 100644 --- a/ddtrace/tracer/textmap_test.go +++ b/ddtrace/tracer/textmap_test.go @@ -1669,7 +1669,7 @@ func TestEnvVars(t *testing.T) { checkSameElements(assert, tc.outHeaders[tracestateHeader], headers[tracestateHeader]) // NOTE: this will be set for phase 3 - assert.Empty(root.meta["_dd.parent_id"], "extraction happened from DD headers, so _dd.parent_id mustn't be set") + assert.Empty(root.meta.m["_dd.parent_id"], "extraction happened from DD headers, so _dd.parent_id mustn't be set") ddTag := strings.SplitN(headers[tracestateHeader], ",", 2)[0] // -3 as we don't count dd= as part of the "value" length limit @@ -1814,9 +1814,9 @@ func TestEnvVars(t *testing.T) { } if tc.lastParent == "" { - assert.Empty(s.meta["_dd.parent_id"]) + assert.Empty(s.meta.m["_dd.parent_id"]) } else { - assert.Equal(s.meta["_dd.parent_id"], tc.lastParent) + assert.Equal(s.meta.m["_dd.parent_id"], tc.lastParent) } headers := TextMapCarrier(map[string]string{}) @@ -2516,7 +2516,7 @@ func TestMalformedTID(t *testing.T) { assert.Nil(err) root := tracer.StartSpan("web.request", ChildOf(sctx)) root.Finish() - assert.NotContains(root.meta, keyTraceID128) + assert.NotContains(root.meta.m, keyTraceID128) }) t.Run("datadog, malformed tid", func(_ *testing.T) { @@ -2529,7 +2529,7 @@ func TestMalformedTID(t *testing.T) { assert.Nil(err) root := tracer.StartSpan("web.request", ChildOf(sctx)) root.Finish() - assert.NotContains(root.meta, keyTraceID128) + assert.NotContains(root.meta.m, keyTraceID128) }) t.Run("datadog, valid tid", func(_ *testing.T) { @@ -2542,7 +2542,7 @@ func TestMalformedTID(t *testing.T) { assert.Nil(err) root := tracer.StartSpan("web.request", ChildOf(sctx)) root.Finish() - assert.Equal("640cfd8d00000000", root.meta[keyTraceID128]) + assert.Equal("640cfd8d00000000", root.meta.m[keyTraceID128]) }) } diff --git a/ddtrace/tracer/tracer.go b/ddtrace/tracer/tracer.go index a1908bc848b..f7aab2d50ae 100644 --- a/ddtrace/tracer/tracer.go +++ b/ddtrace/tracer/tracer.go @@ -495,7 +495,11 @@ func newUnstartedTracer(opts ...StartOption) (t *tracer, err error) { if env := c.internalConfig.Env(); env != "" { t.sharedAttrs.Set(tinternal.AttrEnv, env) } - if ver := c.internalConfig.Version(); ver != "" { + if ver := c.internalConfig.Version(); ver != "" && c.universalVersion { + // Only include version in shared attrs when universalVersion is true, + // because non-universal version should only be set on spans whose + // service name matches the tracer's configured service name. That + // check happens at span-start time in StartSpan. t.sharedAttrs.Set(tinternal.AttrVersion, ver) } t.sharedAttrs.MarkShared() @@ -817,7 +821,7 @@ func spanStart(operationName string, sharedAttrs *tinternal.SpanAttributes, opti traceID: id, start: startTime, integration: "manual", - attrs: sharedAttrs, // COW: shared until a per-span field is set + meta: spanMeta{attrs: sharedAttrs}, // COW: shared until a per-span field is set } span.spanLinks = append(span.spanLinks, opts.SpanLinks...) diff --git a/ddtrace/tracer/tracer_test.go b/ddtrace/tracer/tracer_test.go index c3152ab5809..e45e90015b0 100644 --- a/ddtrace/tracer/tracer_test.go +++ b/ddtrace/tracer/tracer_test.go @@ -325,10 +325,10 @@ func TestTracerStartSpan(t *testing.T) { }, span.metrics[keySamplingPriority]) assert.Equal("-1", span.context.trace.propagatingTags[keyDecisionMaker]) // A span is not measured unless made so specifically - _, ok := span.meta[keyMeasured] + _, ok := span.meta.m[keyMeasured] assert.False(ok) - assert.Equal(globalconfig.RuntimeID(), span.meta[ext.RuntimeID]) - assert.NotEqual("", span.meta[ext.RuntimeID]) + assert.Equal(globalconfig.RuntimeID(), span.meta.m[ext.RuntimeID]) + assert.NotEqual("", span.meta.m[ext.RuntimeID]) }) t.Run("priority", func(t *testing.T) { @@ -819,7 +819,7 @@ func TestTracerStartSpanOptions128(t *testing.T) { assert.Equal(uint64(987654), s.spanID) assert.Equal(uint64(987654), s.traceID) id := id128FromSpan(assert, s.Context()) - assert.Empty(s.meta[keyTraceID128]) + assert.Empty(s.meta.m[keyTraceID128]) idBytes, err := hex.DecodeString(id) assert.NoError(err) assert.Equal(uint64(0), binary.BigEndian.Uint64(idBytes[:8])) // high 64 bits should be 0 @@ -841,7 +841,7 @@ func TestTracerStartSpanOptions128(t *testing.T) { // 0001e240 (123456) + 00000000 (zeros) + 00000000000f1206 (987654) assert.Equal("0001e2400000000000000000000f1206", id) s.Finish() - assert.Equal(id[:16], s.meta[keyTraceID128]) + assert.Equal(id[:16], s.meta.m[keyTraceID128]) }) } @@ -917,11 +917,11 @@ func TestStartSpanOrigin(t *testing.T) { // first child contains tag child := tracer.StartSpan("child", ChildOf(ctx)) - assert.Equal("synthetics", child.meta[keyOrigin]) + assert.Equal("synthetics", child.meta.m[keyOrigin]) // secondary child doesn't child2 := tracer.StartSpan("child2", ChildOf(child.Context())) - assert.Empty(child2.meta[keyOrigin]) + assert.Empty(child2.meta.m[keyOrigin]) // but injecting its context marks origin carrier2 := TextMapCarrier(map[string]string{}) @@ -1143,7 +1143,7 @@ func TestTracerSpanTags(t *testing.T) { tag := Tag("key", "value") span := tracer.StartSpan("web.request", tag) assert := assert.New(t) - assert.Equal("value", span.meta["key"]) + assert.Equal("value", span.meta.m["key"]) } func TestTracerSpanGlobalTags(t *testing.T) { @@ -1152,9 +1152,9 @@ func TestTracerSpanGlobalTags(t *testing.T) { defer tracer.Stop() assert.Nil(err) s := tracer.StartSpan("web.request") - assert.Equal("value", s.meta["key"]) + assert.Equal("value", s.meta.m["key"]) child := tracer.StartSpan("db.query", ChildOf(s.Context())) - assert.Equal("value", child.meta["key"]) + assert.Equal("value", child.meta.m["key"]) } func TestTracerSpanServiceMappings(t *testing.T) { @@ -1213,7 +1213,7 @@ func TestTracerNoDebugStack(t *testing.T) { s := tracer.StartSpan("web.request") err = errors.New("test error") s.Finish(WithError(err)) - assert.Empty(t, s.meta[ext.ErrorStack]) + assert.Empty(t, s.meta.m[ext.ErrorStack]) }) t.Run("SetTag", func(t *testing.T) { @@ -1223,7 +1223,7 @@ func TestTracerNoDebugStack(t *testing.T) { s := tracer.StartSpan("web.request") err = errors.New("error value with no trace") s.SetTag(ext.Error, err) - assert.Empty(t, s.meta[ext.ErrorStack]) + assert.Empty(t, s.meta.m[ext.ErrorStack]) }) } @@ -1279,11 +1279,11 @@ func testNewSpanChild(t *testing.T, is128 bool) { parent.Finish() child.Finish() if is128 { - assert.Equal(id[:16], parent.meta[keyTraceID128]) - assert.Empty(child.meta[keyTraceID128]) + assert.Equal(id[:16], parent.meta.m[keyTraceID128]) + assert.Empty(child.meta.m[keyTraceID128]) } else { - assert.Empty(child.meta[keyTraceID128]) - assert.Empty(parent.meta[keyTraceID128]) + assert.Empty(child.meta.m[keyTraceID128]) + assert.Empty(parent.meta.m[keyTraceID128]) } }) } @@ -1308,7 +1308,7 @@ func TestNewChildHasNoPid(t *testing.T) { root := tracer.newRootSpan("pylons.request", "pylons", "/") child := tracer.newChildSpan("redis.command", root) - assert.Equal("", child.meta[ext.Pid]) + assert.Equal("", child.meta.m[ext.Pid]) } func TestTracerSampler(t *testing.T) { @@ -1796,7 +1796,7 @@ func TestPushPayload(t *testing.T) { defer stop() s := newBasicSpan("3MB") - s.meta["key"] = strings.Repeat("X", payloadSizeLimit/2+10) + s.meta.m["key"] = strings.Repeat("X", payloadSizeLimit/2+10) // half payload size reached tracer.pushChunk(&chunk{[]*Span{s}, true}) @@ -1907,11 +1907,11 @@ func TestTracerReportsHostname(t *testing.T) { assert := assert.New(t) - name, ok := root.meta[keyHostname] + name, ok := root.meta.m[keyHostname] assert.True(ok) assert.Equal(name, tracer.config.internalConfig.Hostname()) - name, ok = child.meta[keyHostname] + name, ok = child.meta.m[keyHostname] assert.True(ok) assert.Equal(name, tracer.config.internalConfig.Hostname()) }) @@ -1933,9 +1933,9 @@ func TestTracerReportsHostname(t *testing.T) { assert := assert.New(t) - _, ok := root.meta[keyHostname] + _, ok := root.meta.m[keyHostname] assert.False(ok) - _, ok = child.meta[keyHostname] + _, ok = child.meta.m[keyHostname] assert.False(ok) }) } @@ -1954,11 +1954,11 @@ func TestTracerReportsHostname(t *testing.T) { assert := assert.New(t) - got, ok := root.meta[keyHostname] + got, ok := root.meta.m[keyHostname] assert.True(ok) assert.Equal(got, hostname) - got, ok = child.meta[keyHostname] + got, ok = child.meta.m[keyHostname] assert.True(ok) assert.Equal(got, hostname) }) @@ -1977,11 +1977,11 @@ func TestTracerReportsHostname(t *testing.T) { assert := assert.New(t) - got, ok := root.meta[keyHostname] + got, ok := root.meta.m[keyHostname] assert.True(ok) assert.Equal(got, hostname) - got, ok = child.meta[keyHostname] + got, ok = child.meta.m[keyHostname] assert.True(ok) assert.Equal(got, hostname) }) @@ -1998,9 +1998,9 @@ func TestTracerReportsHostname(t *testing.T) { assert := assert.New(t) - _, ok := root.meta[keyHostname] + _, ok := root.meta.m[keyHostname] assert.False(ok) - _, ok = child.meta[keyHostname] + _, ok = child.meta.m[keyHostname] assert.False(ok) }) } @@ -2013,8 +2013,7 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request") - assert.Equal("4.5.6", sp.attrs.Val(tinternal.AttrVersion)) - assert.Equal("4.5.6", sp.meta[ext.Version]) // dual-stored + assert.Equal("4.5.6", sp.meta.attrs.Val(tinternal.AttrVersion)) }) t.Run("service", func(t *testing.T) { tracer, _, _, stop, err := startTestTracer(t, WithServiceVersion("4.5.6"), @@ -2024,10 +2023,9 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request", ServiceName("otherservenv")) - v, ok := sp.attrs.Get(tinternal.AttrVersion) + v, ok := sp.meta.attrs.Get(tinternal.AttrVersion) assert.Equal("", v) assert.False(ok) - assert.Empty(sp.meta[ext.Version]) // dual-stored }) t.Run("universal", func(t *testing.T) { tracer, _, _, stop, err := startTestTracer(t, WithService("servenv"), WithUniversalVersion("4.5.6")) @@ -2036,8 +2034,7 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request", ServiceName("otherservenv")) - assert.Equal("4.5.6", sp.attrs.Val(tinternal.AttrVersion)) - assert.Equal("4.5.6", sp.meta[ext.Version]) // dual-stored + assert.Equal("4.5.6", sp.meta.attrs.Val(tinternal.AttrVersion)) }) t.Run("service/universal", func(t *testing.T) { tracer, _, _, stop, err := startTestTracer(t, WithServiceVersion("4.5.6"), @@ -2047,8 +2044,7 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request", ServiceName("otherservenv")) - assert.Equal("1.2.3", sp.attrs.Val(tinternal.AttrVersion)) - assert.Equal("1.2.3", sp.meta[ext.Version]) // dual-stored + assert.Equal("1.2.3", sp.meta.attrs.Val(tinternal.AttrVersion)) }) t.Run("universal/service", func(t *testing.T) { tracer, _, _, stop, err := startTestTracer(t, WithUniversalVersion("1.2.3"), @@ -2058,10 +2054,9 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request", ServiceName("otherservenv")) - v, ok := sp.attrs.Get(tinternal.AttrVersion) + v, ok := sp.meta.attrs.Get(tinternal.AttrVersion) assert.Equal("", v) assert.False(ok) - assert.Empty(sp.meta[ext.Version]) // dual-stored }) } @@ -2073,8 +2068,7 @@ func TestEnvironment(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request") - assert.Equal("test", sp.attrs.Val(tinternal.AttrEnv)) - assert.Equal("test", sp.meta[ext.Environment]) // dual-stored + assert.Equal("test", sp.meta.attrs.Val(tinternal.AttrEnv)) }) t.Run("unset", func(t *testing.T) { @@ -2084,10 +2078,9 @@ func TestEnvironment(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request") - v, ok := sp.attrs.Get(tinternal.AttrEnv) + v, ok := sp.meta.attrs.Get(tinternal.AttrEnv) assert.Equal("", v) assert.False(ok) - assert.Empty(sp.meta[ext.Environment]) // dual-stored }) } @@ -2104,9 +2097,9 @@ func TestGitMetadata(t *testing.T) { sp := tracer.StartSpan("http.request") sp.Finish() - assert.Equal("123456789ABCD", sp.meta[internal.TraceTagCommitSha]) - assert.Equal("github.com/user/repo", sp.meta[internal.TraceTagRepositoryURL]) - assert.Equal("somepath", sp.meta[internal.TraceTagGoPath]) + assert.Equal("123456789ABCD", sp.meta.m[internal.TraceTagCommitSha]) + assert.Equal("github.com/user/repo", sp.meta.m[internal.TraceTagRepositoryURL]) + assert.Equal("somepath", sp.meta.m[internal.TraceTagGoPath]) }) t.Run("git-metadata-from-dd-tags-with-credentials", func(t *testing.T) { @@ -2121,9 +2114,9 @@ func TestGitMetadata(t *testing.T) { sp := tracer.StartSpan("http.request") sp.Finish() - assert.Equal("123456789ABCD", sp.meta[internal.TraceTagCommitSha]) - assert.Equal("https://github.com/user/repo", sp.meta[internal.TraceTagRepositoryURL]) - assert.Equal("somepath", sp.meta[internal.TraceTagGoPath]) + assert.Equal("123456789ABCD", sp.meta.m[internal.TraceTagCommitSha]) + assert.Equal("https://github.com/user/repo", sp.meta.m[internal.TraceTagRepositoryURL]) + assert.Equal("somepath", sp.meta.m[internal.TraceTagGoPath]) }) t.Run("git-metadata-from-env", func(t *testing.T) { @@ -2142,8 +2135,8 @@ func TestGitMetadata(t *testing.T) { sp := tracer.StartSpan("http.request") sp.Finish() - assert.Equal("123456789ABCDE", sp.meta[internal.TraceTagCommitSha]) - assert.Equal("github.com/user/repo_new", sp.meta[internal.TraceTagRepositoryURL]) + assert.Equal("123456789ABCDE", sp.meta.m[internal.TraceTagCommitSha]) + assert.Equal("github.com/user/repo_new", sp.meta.m[internal.TraceTagRepositoryURL]) }) t.Run("git-metadata-from-env-with-credentials", func(t *testing.T) { @@ -2159,8 +2152,8 @@ func TestGitMetadata(t *testing.T) { sp := tracer.StartSpan("http.request") sp.Finish() - assert.Equal("123456789ABCDE", sp.meta[internal.TraceTagCommitSha]) - assert.Equal("https://github.com/user/repo_new", sp.meta[internal.TraceTagRepositoryURL]) + assert.Equal("123456789ABCDE", sp.meta.m[internal.TraceTagCommitSha]) + assert.Equal("https://github.com/user/repo_new", sp.meta.m[internal.TraceTagRepositoryURL]) }) t.Run("git-metadata-from-env-and-tags", func(t *testing.T) { @@ -2176,8 +2169,8 @@ func TestGitMetadata(t *testing.T) { sp := tracer.StartSpan("http.request") sp.Finish() - assert.Equal("123456789ABCD", sp.meta[internal.TraceTagCommitSha]) - assert.Equal("github.com/user/repo", sp.meta[internal.TraceTagRepositoryURL]) + assert.Equal("123456789ABCD", sp.meta.m[internal.TraceTagCommitSha]) + assert.Equal("github.com/user/repo", sp.meta.m[internal.TraceTagRepositoryURL]) }) t.Run("git-metadata-disabled", func(t *testing.T) { @@ -2196,8 +2189,8 @@ func TestGitMetadata(t *testing.T) { sp := tracer.StartSpan("http.request") sp.Finish() - assert.Equal("", sp.meta[internal.TraceTagCommitSha]) - assert.Equal("", sp.meta[internal.TraceTagRepositoryURL]) + assert.Equal("", sp.meta.m[internal.TraceTagCommitSha]) + assert.Equal("", sp.meta.m[internal.TraceTagRepositoryURL]) }) } @@ -2441,8 +2434,11 @@ func cpspan(s *Span) *Span { if len(s.metrics) == 0 { s.metrics = nil } - if len(s.meta) == 0 { - s.meta = nil + if len(s.meta.m) == 0 { + s.meta.m = nil + } + if s.meta.attrs != nil && s.meta.attrs.Count() == 0 { + s.meta.attrs = nil } return &Span{ name: s.name, @@ -2613,7 +2609,7 @@ func TestUserMonitoring(t *testing.T) { WithUserRole(role), WithUserSessionID(sessionID)) s.Finish() for _, pair := range expected { - assert.Equal(t, pair.value, s.meta[pair.key]) + assert.Equal(t, pair.value, s.meta.m[pair.key]) } }) @@ -2625,7 +2621,7 @@ func TestUserMonitoring(t *testing.T) { child.Finish() root.Finish() for _, pair := range expected { - assert.Equal(t, pair.value, root.meta[pair.key]) + assert.Equal(t, pair.value, root.meta.m[pair.key]) } }) @@ -2633,19 +2629,19 @@ func TestUserMonitoring(t *testing.T) { s := tr.newRootSpan("root", "test", "test") SetUser(s, id, WithPropagation()) s.Finish() - assert.Equal(t, id, s.meta[keyUserID]) + assert.Equal(t, id, s.meta.m[keyUserID]) encoded := base64.StdEncoding.EncodeToString([]byte(id)) assert.Equal(t, encoded, s.context.trace.propagatingTags[keyPropagatedUserID]) - assert.Equal(t, encoded, s.meta[keyPropagatedUserID]) + assert.Equal(t, encoded, s.meta.m[keyPropagatedUserID]) }) t.Run("no-propagation", func(t *testing.T) { s := tr.newRootSpan("root", "test", "test") SetUser(s, id) s.Finish() - _, ok := s.meta[keyUserID] + _, ok := s.meta.m[keyUserID] assert.True(t, ok) - _, ok = s.meta[keyPropagatedUserID] + _, ok = s.meta.m[keyPropagatedUserID] assert.False(t, ok) _, ok = s.context.trace.propagatingTags[keyPropagatedUserID] assert.False(t, ok) @@ -2780,9 +2776,9 @@ func TestExecutionTraceSpanTagged(t *testing.T) { untracedSpan := tracer.StartSpan("untraced") untracedSpan.Finish() - assert.Equal(t, tracedSpan.meta["go_execution_traced"], "yes") - assert.Equal(t, partialSpan.meta["go_execution_traced"], "partial") - assert.NotContains(t, untracedSpan.meta, "go_execution_traced") + assert.Equal(t, tracedSpan.meta.m["go_execution_traced"], "yes") + assert.Equal(t, partialSpan.meta.m["go_execution_traced"], "partial") + assert.NotContains(t, untracedSpan.meta.m, "go_execution_traced") } func wasteA(d time.Duration) { diff --git a/ddtrace/tracer/transport_bench_test.go b/ddtrace/tracer/transport_bench_test.go index 4bc2711b921..ee9f2d27050 100644 --- a/ddtrace/tracer/transport_bench_test.go +++ b/ddtrace/tracer/transport_bench_test.go @@ -43,7 +43,7 @@ func BenchmarkHTTPTransportSend(b *testing.B) { spans := make([]*Span, size.numSpans) for i := 0; i < size.numSpans; i++ { span := newBasicSpan("transport-test") - span.meta["data"] = strings.Repeat("x", size.spanSize*1024) + span.meta.m["data"] = strings.Repeat("x", size.spanSize*1024) spans[i] = span } _, _ = payload.push(spans) diff --git a/ddtrace/tracer/transport_test.go b/ddtrace/tracer/transport_test.go index e5b78afc3be..5232d8ee8b2 100644 --- a/ddtrace/tracer/transport_test.go +++ b/ddtrace/tracer/transport_test.go @@ -43,7 +43,7 @@ func getTestSpan() *Span { resource: "SEND /data", start: 1481215590883401105, duration: 1000000000, - meta: map[string]string{"http.host": "192.168.0.1"}, + meta: spanMeta{m: map[string]string{"http.host": "192.168.0.1"}}, metaStruct: map[string]any{"_dd.appsec.json": map[string]any{"triggers": []any{map[string]any{"id": "1"}}}}, metrics: map[string]float64{"http.monitor": 41.99}, } diff --git a/ddtrace/tracer/writer.go b/ddtrace/tracer/writer.go index 08946e8e867..9397e748860 100644 --- a/ddtrace/tracer/writer.go +++ b/ddtrace/tracer/writer.go @@ -245,7 +245,7 @@ func (h *logTraceWriter) encodeSpan(s *Span) { h.buf.Write(strconv.AppendInt(scratch[:0], int64(s.error), 10)) h.buf.WriteString(`,"meta":{`) first := true - for k, v := range s.meta { + for k, v := range s.meta.m { if first { first = false } else { @@ -255,6 +255,16 @@ func (h *logTraceWriter) encodeSpan(s *Span) { h.buf.WriteString(":") h.marshalString(v) } + s.meta.attrs.ForEach(func(name, val string) { + if first { + first = false + } else { + h.buf.WriteString(",") + } + h.marshalString(name) + h.buf.WriteString(":") + h.marshalString(val) + }) // We cannot pack messagepack into JSON, so we need to marshal the meta struct as JSON, and send them through the `meta` field for k, v := range s.metaStruct { if first { diff --git a/ddtrace/tracer/writer_test.go b/ddtrace/tracer/writer_test.go index c1ee016f2ed..e02f97ed852 100644 --- a/ddtrace/tracer/writer_test.go +++ b/ddtrace/tracer/writer_test.go @@ -42,7 +42,7 @@ func makeSpan(n int) *Span { s := newSpan("encodeName", "encodeService", "encodeResource", randUint64(), randUint64(), randUint64()) for i := range n { istr := fmt.Sprintf("%0.10d", i) - s.meta[istr] = istr + s.meta.m[istr] = istr s.metrics[istr] = float64(i) } return s @@ -186,10 +186,10 @@ func TestLogWriter(t *testing.T) { name: "basicName", service: "basicService", resource: "basicResource", - meta: map[string]string{ + meta: spanMeta{m: map[string]string{ "env": "prod", "version": "1.26.0", - }, + }}, metaStruct: map[string]any{ "_dd.stack": map[string]string{ "0": "github.com/DataDog/dd-trace-go/v1/internal/tracer.TestLogWriter", @@ -247,7 +247,7 @@ func TestLogWriter(t *testing.T) { assert := assert.New(t) s := newSpan("name\n", "srv\t", `"res"`, 2, 1, 3) s.start = 12 - s.meta["query\n"] = "Select * from \n Where\nvalue" + s.meta.m["query\n"] = "Select * from \n Where\nvalue" s.metrics["version\n"] = 3 var w logTraceWriter From 417937238b0b56415d9a300e7af2f4977bbd248a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Mon, 16 Mar 2026 18:34:24 +0000 Subject: [PATCH 18/71] fix(ddtrace/tracer): regenerate msgp code --- ddtrace/tracer/span.go | 1 + ddtrace/tracer/span_meta.go | 6 ++ ddtrace/tracer/span_msgp.go | 93 ++++++++++++++--------------- scripts/msgp_span_meta_omitempty.go | 90 ++++++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 47 deletions(-) create mode 100644 scripts/msgp_span_meta_omitempty.go diff --git a/ddtrace/tracer/span.go b/ddtrace/tracer/span.go index 83b6ca13684..ee918b7c57a 100644 --- a/ddtrace/tracer/span.go +++ b/ddtrace/tracer/span.go @@ -5,6 +5,7 @@ //msgp:ignore inheritedData //go:generate go run github.com/tinylib/msgp -unexported -marshal=false -o=span_msgp.go -tests=false +//go:generate go run ../../scripts/msgp_span_meta_omitempty.go -file span_msgp.go //go:generate go run ../../scripts/msgp_checklocks_ignore.go -type Span -file span_msgp.go package tracer diff --git a/ddtrace/tracer/span_meta.go b/ddtrace/tracer/span_meta.go index 82799c18fdc..ff1f9a550bb 100644 --- a/ddtrace/tracer/span_meta.go +++ b/ddtrace/tracer/span_meta.go @@ -29,6 +29,12 @@ type spanMeta struct { attrs *tinternal.SpanAttributes } +// IsZero reports whether the spanMeta contains no entries (map or promoted). +// The msgp generator emits z.meta.IsZero() for the omitempty check. +func (sm spanMeta) IsZero() bool { + return len(sm.m) == 0 && sm.attrs.Count() == 0 +} + // String returns a merged map representation (m + promoted attrs) for debug logging. func (sm spanMeta) String() string { var b strings.Builder diff --git a/ddtrace/tracer/span_msgp.go b/ddtrace/tracer/span_msgp.go index 3bf3c8ad7b1..c0d6d883af1 100644 --- a/ddtrace/tracer/span_msgp.go +++ b/ddtrace/tracer/span_msgp.go @@ -74,32 +74,32 @@ func (z *Span) DecodeMsg(dc *msgp.Reader) (err error) { return } case "metrics": - var zb0003 uint32 - zb0003, err = dc.ReadMapHeader() + var zb0002 uint32 + zb0002, err = dc.ReadMapHeader() if err != nil { err = msgp.WrapError(err, "metrics") return } if z.metrics == nil { - z.metrics = make(map[string]float64, zb0003) + z.metrics = make(map[string]float64, zb0002) } else if len(z.metrics) > 0 { clear(z.metrics) } - for zb0003 > 0 { - zb0003-- - var za0003 string - za0003, err = dc.ReadString() + for zb0002 > 0 { + zb0002-- + var za0001 string + za0001, err = dc.ReadString() if err != nil { err = msgp.WrapError(err, "metrics") return } - var za0004 float64 - za0004, err = dc.ReadFloat64() + var za0002 float64 + za0002, err = dc.ReadFloat64() if err != nil { - err = msgp.WrapError(err, "metrics", za0003) + err = msgp.WrapError(err, "metrics", za0001) return } - z.metrics[za0003] = za0004 + z.metrics[za0001] = za0002 } case "span_id": z.spanID, err = dc.ReadUint64() @@ -126,40 +126,40 @@ func (z *Span) DecodeMsg(dc *msgp.Reader) (err error) { return } case "span_links": - var zb0004 uint32 - zb0004, err = dc.ReadArrayHeader() + var zb0003 uint32 + zb0003, err = dc.ReadArrayHeader() if err != nil { err = msgp.WrapError(err, "spanLinks") return } - if cap(z.spanLinks) >= int(zb0004) { - z.spanLinks = (z.spanLinks)[:zb0004] + if cap(z.spanLinks) >= int(zb0003) { + z.spanLinks = (z.spanLinks)[:zb0003] } else { - z.spanLinks = make([]SpanLink, zb0004) + z.spanLinks = make([]SpanLink, zb0003) } - for za0005 := range z.spanLinks { - err = z.spanLinks[za0005].DecodeMsg(dc) + for za0003 := range z.spanLinks { + err = z.spanLinks[za0003].DecodeMsg(dc) if err != nil { - err = msgp.WrapError(err, "spanLinks", za0005) + err = msgp.WrapError(err, "spanLinks", za0003) return } } case "span_events": - var zb0005 uint32 - zb0005, err = dc.ReadArrayHeader() + var zb0004 uint32 + zb0004, err = dc.ReadArrayHeader() if err != nil { err = msgp.WrapError(err, "spanEvents") return } - if cap(z.spanEvents) >= int(zb0005) { - z.spanEvents = (z.spanEvents)[:zb0005] + if cap(z.spanEvents) >= int(zb0004) { + z.spanEvents = (z.spanEvents)[:zb0004] } else { - z.spanEvents = make([]spanEvent, zb0005) + z.spanEvents = make([]spanEvent, zb0004) } - for za0006 := range z.spanEvents { - err = z.spanEvents[za0006].DecodeMsg(dc) + for za0004 := range z.spanEvents { + err = z.spanEvents[za0004].DecodeMsg(dc) if err != nil { - err = msgp.WrapError(err, "spanEvents", za0006) + err = msgp.WrapError(err, "spanEvents", za0004) return } } @@ -181,7 +181,7 @@ func (z *Span) EncodeMsg(en *msgp.Writer) (err error) { zb0001Len := uint32(15) var zb0001Mask uint16 /* 15 bits */ _ = zb0001Mask - if z.meta.m == nil && z.meta.attrs.Count() == 0 { + if z.meta.IsZero() { zb0001Len-- zb0001Mask |= 0x40 } @@ -298,15 +298,15 @@ func (z *Span) EncodeMsg(en *msgp.Writer) (err error) { err = msgp.WrapError(err, "metrics") return } - for za0003, za0004 := range z.metrics { - err = en.WriteString(za0003) + for za0001, za0002 := range z.metrics { + err = en.WriteString(za0001) if err != nil { err = msgp.WrapError(err, "metrics") return } - err = en.WriteFloat64(za0004) + err = en.WriteFloat64(za0002) if err != nil { - err = msgp.WrapError(err, "metrics", za0003) + err = msgp.WrapError(err, "metrics", za0001) return } } @@ -362,10 +362,10 @@ func (z *Span) EncodeMsg(en *msgp.Writer) (err error) { err = msgp.WrapError(err, "spanLinks") return } - for za0005 := range z.spanLinks { - err = z.spanLinks[za0005].EncodeMsg(en) + for za0003 := range z.spanLinks { + err = z.spanLinks[za0003].EncodeMsg(en) if err != nil { - err = msgp.WrapError(err, "spanLinks", za0005) + err = msgp.WrapError(err, "spanLinks", za0003) return } } @@ -381,10 +381,10 @@ func (z *Span) EncodeMsg(en *msgp.Writer) (err error) { err = msgp.WrapError(err, "spanEvents") return } - for za0006 := range z.spanEvents { - err = z.spanEvents[za0006].EncodeMsg(en) + for za0004 := range z.spanEvents { + err = z.spanEvents[za0004].EncodeMsg(en) if err != nil { - err = msgp.WrapError(err, "spanEvents", za0006) + err = msgp.WrapError(err, "spanEvents", za0004) return } } @@ -396,21 +396,20 @@ func (z *Span) EncodeMsg(en *msgp.Writer) (err error) { // Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message // +checklocksignore — Generated code: post-finish serialization or pre-share deserialization. func (z *Span) Msgsize() (s int) { - s = 1 + 5 + msgp.StringPrefixSize + len(z.name) + 8 + msgp.StringPrefixSize + len(z.service) + 9 + msgp.StringPrefixSize + len(z.resource) + 5 + msgp.StringPrefixSize + len(z.spanType) + 6 + msgp.Int64Size + 9 + msgp.Int64Size + 5 + z.meta.Msgsize() - s += 12 + z.metaStruct.Msgsize() + 8 + msgp.MapHeaderSize + s = 1 + 5 + msgp.StringPrefixSize + len(z.name) + 8 + msgp.StringPrefixSize + len(z.service) + 9 + msgp.StringPrefixSize + len(z.resource) + 5 + msgp.StringPrefixSize + len(z.spanType) + 6 + msgp.Int64Size + 9 + msgp.Int64Size + 5 + z.meta.Msgsize() + 12 + z.metaStruct.Msgsize() + 8 + msgp.MapHeaderSize if z.metrics != nil { - for za0003, za0004 := range z.metrics { - _ = za0004 - s += msgp.StringPrefixSize + len(za0003) + msgp.Float64Size + for za0001, za0002 := range z.metrics { + _ = za0002 + s += msgp.StringPrefixSize + len(za0001) + msgp.Float64Size } } s += 8 + msgp.Uint64Size + 9 + msgp.Uint64Size + 10 + msgp.Uint64Size + 6 + msgp.Int32Size + 11 + msgp.ArrayHeaderSize - for za0005 := range z.spanLinks { - s += z.spanLinks[za0005].Msgsize() + for za0003 := range z.spanLinks { + s += z.spanLinks[za0003].Msgsize() } s += 12 + msgp.ArrayHeaderSize - for za0006 := range z.spanEvents { - s += z.spanEvents[za0006].Msgsize() + for za0004 := range z.spanEvents { + s += z.spanEvents[za0004].Msgsize() } return } diff --git a/scripts/msgp_span_meta_omitempty.go b/scripts/msgp_span_meta_omitempty.go new file mode 100644 index 00000000000..b0f3ad3d611 --- /dev/null +++ b/scripts/msgp_span_meta_omitempty.go @@ -0,0 +1,90 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2025 Datadog, Inc. + +//go:build ignore + +// msgp_span_meta_omitempty patches the generated span_msgp.go to add an +// omitempty guard for the spanMeta field. +// +// msgp does not emit omitempty checks for types implementing msgp.Encodable, +// even when the struct tag says ",omitempty". This script injects the +// z.meta.IsZero() guard that the generator would produce for a plain struct +// with an IsZero method. +// +// Usage (in go:generate directives, chained after msgp): +// +// //go:generate go run ../../scripts/msgp_span_meta_omitempty.go -file span_msgp.go +package main + +import ( + "flag" + "fmt" + "os" + "strings" +) + +func main() { + fileName := flag.String("file", "", "generated file to process") + flag.Parse() + + if *fileName == "" { + fmt.Fprintf(os.Stderr, "usage: msgp_span_meta_omitempty -file \n") + os.Exit(1) + } + + content, err := os.ReadFile(*fileName) + if err != nil { + fmt.Fprintf(os.Stderr, "error reading %s: %v\n", *fileName, err) + os.Exit(1) + } + + src := string(content) + + // --- Patch EncodeMsg: wrap the "write meta" block with an omitempty guard --- + // The generator produces: + // // write "meta" + // err = en.Append(0xa4, 0x6d, 0x65, 0x74, 0x61) + // ... + // err = z.meta.EncodeMsg(en) + // ... + // // write "meta_struct" + // + // We wrap it with: + // if !z.meta.IsZero() { + // ... + // } + // + // And decrement the field count when meta is empty. + + // 1. Add the IsZero field-count decrement in EncodeMsg's "check for omitted fields" block. + // Target the EncodeMsg context by matching the tab-level: "\n\tif z.metrics == nil {" + // (DecodeMsg has deeper indentation for its metrics nil check.) + metricsOmit := "\n\tif z.metrics == nil {" + metaOmit := "\n\tif z.meta.IsZero() {\n\t\tzb0001Len--\n\t\tzb0001Mask |= 0x40\n\t}" + if !strings.Contains(src, "z.meta.IsZero()") { + src = strings.Replace(src, metricsOmit, metaOmit+metricsOmit, 1) + } + + // 2. Wrap the "write meta" block with the omitempty guard. + writeMetaMarker := "\t\t// write \"meta\"\n" + writeMetaStructMarker := "\t\t// write \"meta_struct\"\n" + if !strings.Contains(src, "(zb0001Mask & 0x40)") { + metaStart := strings.Index(src, writeMetaMarker) + metaStructStart := strings.Index(src, writeMetaStructMarker) + if metaStart >= 0 && metaStructStart > metaStart { + // Extract the meta block + metaBlock := src[metaStart:metaStructStart] + // Indent it one more tab level and wrap + indented := strings.ReplaceAll(metaBlock, "\n\t\t", "\n\t\t\t") + wrapped := "\t\tif (zb0001Mask & 0x40) == 0 { // if not omitted\n\t" + indented + "\t\t}\n" + src = src[:metaStart] + wrapped + src[metaStructStart:] + } + } + + if err := os.WriteFile(*fileName, []byte(src), 0644); err != nil { + fmt.Fprintf(os.Stderr, "error writing %s: %v\n", *fileName, err) + os.Exit(1) + } +} From 6eb24be069b0a2a8d11535d8b2f3561e2409e5dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Mon, 16 Mar 2026 19:18:20 +0000 Subject: [PATCH 19/71] fix(ddtrace/mocktracer): update spanStart linkname signature with unsafe.Pointer because internal type can't be imported --- ddtrace/mocktracer/mockspan.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ddtrace/mocktracer/mockspan.go b/ddtrace/mocktracer/mockspan.go index 937b3886b9a..d01a3ad2c87 100644 --- a/ddtrace/mocktracer/mockspan.go +++ b/ddtrace/mocktracer/mockspan.go @@ -10,7 +10,7 @@ import ( "fmt" "testing" "time" - _ "unsafe" // Needed for go:linkname directive. + "unsafe" // Needed for go:linkname directive. "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -21,10 +21,10 @@ import ( ) //go:linkname spanStart github.com/DataDog/dd-trace-go/v2/ddtrace/tracer.spanStart -func spanStart(operationName string, options ...tracer.StartSpanOption) *tracer.Span +func spanStart(operationName string, sharedAttrs unsafe.Pointer, options ...tracer.StartSpanOption) *tracer.Span func newSpan(operationName string, cfg *tracer.StartSpanConfig) *tracer.Span { - return spanStart(operationName, func(c *tracer.StartSpanConfig) { + return spanStart(operationName, nil, func(c *tracer.StartSpanConfig) { *c = *cfg }) } From 823d13b35f2696fd3897639298d0ce1712a050e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Mon, 16 Mar 2026 21:48:52 +0000 Subject: [PATCH 20/71] fix(ddtrace/tracer): reduce introduced allocations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Root cause**: When `DD_VERSION` is set but `universalVersion=false` (the default), `sharedAttrs` was built *without* version. Every main-service span calling `setAttrCOW(AttrVersion, ver)` would fail the COW early-exit check (`sharedAttrs.Val(AttrVersion) == ""`) and trigger `Clone()` — one extra 72-byte heap allocation per span. **Fix** (`tracer.go`): Add `sharedAttrsForMainSvc` that pre-populates version. Swap each main-service span's attrs to this version-inclusive instance *before* any tags are applied. The version write then becomes a COW no-op. **Bonus fix** (`span_meta.go`): `DecodeMsg` was always `new(map[string]string)` even when a span object is reused (the pattern the old generated code handled with `clear(z.meta)`). Restore the `clear(sm.m)` / `sm.attrs.Reset()` reuse path. **Root cause**: `setMetaInit` was restructured from `switch + default:` (map write in default) to `switch with 8 explicit returns + post-switch map write`. This changed the switch dispatch shape and disabled the compiler's ability to merge the "no-match" path into the default arm, yielding slightly worse branch-prediction behaviour. **Fix** (`span.go`): Restore the `default:` arm with lazy map init inside it. All 8 special cases now fall through to exit the switch cleanly; unmatched keys go to `default:` exactly as before. **Root cause**: `Span.meta` grew from 8 bytes (`map[string]string`) to 16 bytes (`spanMeta{m, attrs}`) → Span struct is 8 bytes larger → more GC scan work. No clean fix without architectural changes; this is the inherent cost of the promoted-fields model. **New helper** (`span_attributes.go`): `SpanAttributes.Reset()` — nil-safe in-place zeroing for reuse in decode loops, avoiding a `new(SpanAttributes)` allocation for recycled span objects. --- ddtrace/tracer/internal/span_attributes.go | 10 +++++++ ddtrace/tracer/span.go | 21 ++++---------- ddtrace/tracer/span_meta.go | 11 ++++++-- ddtrace/tracer/tracer.go | 33 +++++++++++++++++----- 4 files changed, 50 insertions(+), 25 deletions(-) diff --git a/ddtrace/tracer/internal/span_attributes.go b/ddtrace/tracer/internal/span_attributes.go index dd2be1cd91e..655b09705f3 100644 --- a/ddtrace/tracer/internal/span_attributes.go +++ b/ddtrace/tracer/internal/span_attributes.go @@ -79,6 +79,16 @@ func (a *SpanAttributes) MarkShared() { a.shared = true } // IsShared reports whether this is a shared instance requiring COW. func (a *SpanAttributes) IsShared() bool { return a != nil && a.shared } +// Reset clears all set attributes, returning the instance to its zero state. +// It is nil-safe and does not free the underlying memory, making it suitable +// for reuse (e.g. in a decode loop that reuses Span objects). +func (a *SpanAttributes) Reset() { + if a == nil { + return + } + *a = SpanAttributes{} +} + // Clone returns a mutable (non-shared) shallow copy. func (a *SpanAttributes) Clone() *SpanAttributes { if a == nil { diff --git a/ddtrace/tracer/span.go b/ddtrace/tracer/span.go index ee918b7c57a..ac62cdec8af 100644 --- a/ddtrace/tracer/span.go +++ b/ddtrace/tracer/span.go @@ -760,37 +760,26 @@ func (s *Span) setMetaInit(key, v string) { switch key { case ext.SpanName: s.name = v - return case ext.ServiceName: s.service = v - s.serviceSource = serviceSourceManual - return case ext.ResourceName: s.resource = v - return case ext.SpanType: s.spanType = v - return case ext.Environment: s.setAttrCOW(tinternal.AttrEnv, v) - return case ext.Version: s.setAttrCOW(tinternal.AttrVersion, v) - return case ext.Component: s.setAttrCOW(tinternal.AttrComponent, v) - return case ext.SpanKind: s.setAttrCOW(tinternal.AttrSpanKind, v) - return - } - // Promoted fields (env/version/component/spanKind) fall through here so they - // remain in meta too. The V0.4 encoder and the external stats concentrator both - // read directly from the meta map, so dual-storage is required for correctness. - if s.meta.m == nil { - s.meta.m = initMeta() + default: + if s.meta.m == nil { + s.meta.m = initMeta() + } + s.meta.m[key] = v } - s.meta.m[key] = v } // setAttrCOW sets a promoted attribute with copy-on-write semantics. diff --git a/ddtrace/tracer/span_meta.go b/ddtrace/tracer/span_meta.go index ff1f9a550bb..bbd23ec1961 100644 --- a/ddtrace/tracer/span_meta.go +++ b/ddtrace/tracer/span_meta.go @@ -96,8 +96,15 @@ func (sm *spanMeta) DecodeMsg(dc *msgp.Reader) error { if err != nil { return msgp.WrapError(err, "Meta") } - // Pre-allocate assuming most keys go to m. - sm.m = make(map[string]string, header) + // Reuse sm.m and sm.attrs if already allocated (pool/reuse pattern from generated + // msgp code); otherwise allocate fresh. This avoids one heap allocation per decoded + // span in environments where Span objects are recycled between decode calls. + if sm.m != nil { + clear(sm.m) + } else { + sm.m = make(map[string]string, header) + } + sm.attrs.Reset() // nil-safe: clears bits if set, no-op if nil for range header { key, err := dc.ReadString() if err != nil { diff --git a/ddtrace/tracer/tracer.go b/ddtrace/tracer/tracer.go index f7aab2d50ae..32adb6c1742 100644 --- a/ddtrace/tracer/tracer.go +++ b/ddtrace/tracer/tracer.go @@ -178,10 +178,17 @@ type tracer struct { // State related to the Dynamic Instrumentation product. dynInstSubscriptions dynInstSubscriptions - // sharedAttrs holds the process-level promoted span attributes (env, version). + // sharedAttrs holds the process-level promoted span attributes. + // When universalVersion=true it includes env+version; otherwise env only. // All spans start by sharing this pointer; copy-on-write clones it // only when a span needs to set per-span fields (component, spanKind). sharedAttrs tinternal.SpanAttributes + + // sharedAttrsForMainSvc is like sharedAttrs but always includes version + // (when a version is configured). It is used for main-service spans when + // universalVersion=false so that the version write in StartSpan is a + // copy-on-write no-op rather than triggering an unnecessary Clone. + sharedAttrsForMainSvc tinternal.SpanAttributes } type dynInstSubscriptions struct { @@ -494,15 +501,20 @@ func newUnstartedTracer(opts ...StartOption) (t *tracer, err error) { // spans share this pointer and only clone on per-span overrides. if env := c.internalConfig.Env(); env != "" { t.sharedAttrs.Set(tinternal.AttrEnv, env) + t.sharedAttrsForMainSvc.Set(tinternal.AttrEnv, env) } - if ver := c.internalConfig.Version(); ver != "" && c.universalVersion { - // Only include version in shared attrs when universalVersion is true, - // because non-universal version should only be set on spans whose - // service name matches the tracer's configured service name. That - // check happens at span-start time in StartSpan. - t.sharedAttrs.Set(tinternal.AttrVersion, ver) + if ver := c.internalConfig.Version(); ver != "" { + if c.universalVersion { + // universalVersion=true: all spans get version via sharedAttrs. + t.sharedAttrs.Set(tinternal.AttrVersion, ver) + } + // Always pre-populate sharedAttrsForMainSvc with version so that + // main-service spans in non-universal mode get a COW no-op rather + // than a Clone when StartSpan applies version (see below). + t.sharedAttrsForMainSvc.Set(tinternal.AttrVersion, ver) } t.sharedAttrs.MarkShared() + t.sharedAttrsForMainSvc.MarkShared() return t, nil } @@ -875,6 +887,13 @@ func (t *tracer) StartSpan(operationName string, options ...StartSpanOption) *Sp span.service = t.config.internalConfig.ServiceName() } + // For non-universal version, promote main-service spans to the version-inclusive + // shared attrs before applying any tags. This makes the subsequent version write + // (from config or global tags) a COW no-op instead of triggering a Clone. + if !t.config.universalVersion && span.service == t.config.internalConfig.ServiceName() && span.meta.attrs == &t.sharedAttrs { + span.meta.attrs = &t.sharedAttrsForMainSvc + } + cfg := t.config.internalConfig span.noDebugStack = !cfg.DebugStack() if hostname := cfg.Hostname(); hostname != "" && cfg.ReportHostname() { From 63a0bdf49481a4b53207e66c271429c7180a6468 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Tue, 17 Mar 2026 10:09:10 +0000 Subject: [PATCH 21/71] fix(ddtrace/tracer): avoid nil dereference on tests --- ddtrace/tracer/sampler_test.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/ddtrace/tracer/sampler_test.go b/ddtrace/tracer/sampler_test.go index 7fdcbc8f6ca..cf2d83ef5d7 100644 --- a/ddtrace/tracer/sampler_test.go +++ b/ddtrace/tracer/sampler_test.go @@ -395,11 +395,9 @@ func BenchmarkPrioritySamplerGetRate(b *testing.B) { ps.rates[serviceEnvKey{service: "web", env: "prod"}] = 0.5 spnHit := newSpan("op", "web", "resource", 1, 1, 0) - spnHit.meta.attrs.Set(tinternal.AttrEnv, "prod") spnHit.SetTag(ext.Environment, "prod") spnMiss := newSpan("op", "other", "resource", 1, 1, 0) - spnMiss.meta.attrs.Set(tinternal.AttrEnv, "staging") spnMiss.SetTag(ext.Environment, "staging") b.ResetTimer() From aff9637c5746a16d17995ab567c8b066b41de626 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Tue, 17 Mar 2026 10:51:50 +0000 Subject: [PATCH 22/71] fix(ddtrace/tracer): optimize code layout for favour improved inlining --- ddtrace/tracer/span.go | 8 ++++++ ddtrace/tracer/span_meta.go | 51 +++++++++++++++++++++---------------- 2 files changed, 37 insertions(+), 22 deletions(-) diff --git a/ddtrace/tracer/span.go b/ddtrace/tracer/span.go index ac62cdec8af..f6bdd6bf0f9 100644 --- a/ddtrace/tracer/span.go +++ b/ddtrace/tracer/span.go @@ -785,11 +785,19 @@ func (s *Span) setMetaInit(key, v string) { // setAttrCOW sets a promoted attribute with copy-on-write semantics. // If the value already matches what's in attrs (e.g. from the shared tracer // instance), the write is skipped entirely — no clone, no allocation. +// The slow path is split out with //go:noinline to reduce code size at call sites. // +checklocksignore — Initialization time, span not yet shared. func (s *Span) setAttrCOW(key tinternal.AttrKey, v string) { if s.meta.attrs != nil && s.meta.attrs.Val(key) == v { return // already has the right value (shared or local) } + s.setAttrCOWSlow(key, v) +} + +// setAttrCOWSlow is the slow path for setAttrCOW that handles the clone + set. +// +//go:noinline +func (s *Span) setAttrCOWSlow(key tinternal.AttrKey, v string) { s.ensureAttrsLocal() s.meta.attrs.Set(key, v) } diff --git a/ddtrace/tracer/span_meta.go b/ddtrace/tracer/span_meta.go index bbd23ec1961..5f2275c7d7a 100644 --- a/ddtrace/tracer/span_meta.go +++ b/ddtrace/tracer/span_meta.go @@ -47,13 +47,17 @@ func (sm spanMeta) String() string { first = false fmt.Fprintf(&b, "%s:%s", k, v) } - sm.attrs.ForEach(func(name, val string) { - if !first { - b.WriteByte(' ') + if sm.attrs != nil { + for _, d := range tinternal.Defs { + if v, ok := sm.attrs.Get(d.Key); ok { + if !first { + b.WriteByte(' ') + } + first = false + fmt.Fprintf(&b, "%s:%s", d.Name, v) + } } - first = false - fmt.Fprintf(&b, "%s:%s", name, val) - }) + } b.WriteByte(']') return b.String() } @@ -73,20 +77,19 @@ func (sm *spanMeta) EncodeMsg(en *msgp.Writer) error { return msgp.WrapError(err, "Meta", k) } } - var encErr error - sm.attrs.ForEach(func(name, val string) { - if encErr != nil { - return - } - if err := en.WriteString(name); err != nil { - encErr = msgp.WrapError(err, "Meta") - return - } - if err := en.WriteString(val); err != nil { - encErr = msgp.WrapError(err, "Meta", name) + if sm.attrs != nil { + for _, d := range tinternal.Defs { + if v, ok := sm.attrs.Get(d.Key); ok { + if err := en.WriteString(d.Name); err != nil { + return msgp.WrapError(err, "Meta") + } + if err := en.WriteString(v); err != nil { + return msgp.WrapError(err, "Meta", d.Name) + } + } } - }) - return encErr + } + return nil } // DecodeMsg reads a msgp map and routes promoted keys into attrs, @@ -132,8 +135,12 @@ func (sm *spanMeta) Msgsize() int { for k, v := range sm.m { size += msgp.StringPrefixSize + len(k) + msgp.StringPrefixSize + len(v) } - sm.attrs.ForEach(func(name, val string) { - size += msgp.StringPrefixSize + len(name) + msgp.StringPrefixSize + len(val) - }) + if sm.attrs != nil { + for _, d := range tinternal.Defs { + if v, ok := sm.attrs.Get(d.Key); ok { + size += msgp.StringPrefixSize + len(d.Name) + msgp.StringPrefixSize + len(v) + } + } + } return size } From f5e90d38d0b2b206a2b782ee048b62c90bd509dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Tue, 17 Mar 2026 11:21:43 +0000 Subject: [PATCH 23/71] fix(ddtrace/tracer): ensure checklocks is ok --- ddtrace/tracer/span.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ddtrace/tracer/span.go b/ddtrace/tracer/span.go index f6bdd6bf0f9..e086c1f3a61 100644 --- a/ddtrace/tracer/span.go +++ b/ddtrace/tracer/span.go @@ -786,7 +786,7 @@ func (s *Span) setMetaInit(key, v string) { // If the value already matches what's in attrs (e.g. from the shared tracer // instance), the write is skipped entirely — no clone, no allocation. // The slow path is split out with //go:noinline to reduce code size at call sites. -// +checklocksignore — Initialization time, span not yet shared. +// +checklocksignore — Called from setMetaInit (init, no lock) and setMetaLocked (lock held). func (s *Span) setAttrCOW(key tinternal.AttrKey, v string) { if s.meta.attrs != nil && s.meta.attrs.Val(key) == v { return // already has the right value (shared or local) @@ -795,6 +795,7 @@ func (s *Span) setAttrCOW(key tinternal.AttrKey, v string) { } // setAttrCOWSlow is the slow path for setAttrCOW that handles the clone + set. +// +checklocksignore — Called from setMetaInit (init, no lock) and setMetaLocked (lock held). // //go:noinline func (s *Span) setAttrCOWSlow(key tinternal.AttrKey, v string) { @@ -803,7 +804,7 @@ func (s *Span) setAttrCOWSlow(key tinternal.AttrKey, v string) { } // ensureAttrsLocal guarantees s.meta.attrs is a mutable, span-local instance. -// +checklocksignore — Initialization time, span not yet shared. +// +checklocksignore — Called from setMetaInit (init, no lock) and setMetaLocked (lock held). func (s *Span) ensureAttrsLocal() { if s.meta.attrs == nil { s.meta.attrs = new(tinternal.SpanAttributes) From 581e57fe6fab8060597e824216303afc7018abc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Tue, 17 Mar 2026 13:45:14 +0000 Subject: [PATCH 24/71] fix(ddtrace/tracer): revert DecodeMsg to a simpler state because fidelity collides with reproducibility --- ddtrace/tracer/span_meta.go | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/ddtrace/tracer/span_meta.go b/ddtrace/tracer/span_meta.go index 5f2275c7d7a..bab26a6f71d 100644 --- a/ddtrace/tracer/span_meta.go +++ b/ddtrace/tracer/span_meta.go @@ -92,22 +92,20 @@ func (sm *spanMeta) EncodeMsg(en *msgp.Writer) error { return nil } -// DecodeMsg reads a msgp map and routes promoted keys into attrs, -// everything else into m. +// DecodeMsg reads a msgp map into m. All keys — including promoted ones — go +// into the flat map so that no SpanAttributes allocation is needed on the +// decode path. attrs is only populated on the encode (span-creation) path. func (sm *spanMeta) DecodeMsg(dc *msgp.Reader) error { header, err := dc.ReadMapHeader() if err != nil { return msgp.WrapError(err, "Meta") } - // Reuse sm.m and sm.attrs if already allocated (pool/reuse pattern from generated - // msgp code); otherwise allocate fresh. This avoids one heap allocation per decoded - // span in environments where Span objects are recycled between decode calls. + // Reuse sm.m if already allocated; otherwise allocate fresh pre-sized. if sm.m != nil { clear(sm.m) } else { sm.m = make(map[string]string, header) } - sm.attrs.Reset() // nil-safe: clears bits if set, no-op if nil for range header { key, err := dc.ReadString() if err != nil { @@ -117,14 +115,7 @@ func (sm *spanMeta) DecodeMsg(dc *msgp.Reader) error { if err != nil { return msgp.WrapError(err, "Meta", key) } - if ak, ok := tinternal.AttrKeyForTag(key); ok { - if sm.attrs == nil { - sm.attrs = new(tinternal.SpanAttributes) - } - sm.attrs.Set(ak, val) - } else { - sm.m[key] = val - } + sm.m[key] = val } return nil } From 2973d1c8671c4cb323275f1b9e1bd2f1f17228cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Wed, 18 Mar 2026 16:58:31 +0000 Subject: [PATCH 25/71] feat(ddtrace/tracer): hide spanMeta's implementation details behind a nicer API --- ddtrace/tracer/abandonedspans.go | 4 +- ddtrace/tracer/civisibility_payload_test.go | 2 +- ddtrace/tracer/civisibility_tslv.go | 5 +- ddtrace/tracer/data_streams_test.go | 14 +- ddtrace/tracer/internal/span_attributes.go | 9 ++ ddtrace/tracer/option_test.go | 9 +- ddtrace/tracer/payload_test.go | 11 +- ddtrace/tracer/payload_v1.go | 21 ++- ddtrace/tracer/sampler.go | 4 +- ddtrace/tracer/sampler_test.go | 20 ++- ddtrace/tracer/span.go | 144 +++-------------- ddtrace/tracer/span_event_test.go | 5 +- ddtrace/tracer/span_meta.go | 134 ++++++++++++++-- ddtrace/tracer/span_test.go | 153 +++++++++++------- ddtrace/tracer/spancontext.go | 32 ++-- ddtrace/tracer/spancontext_test.go | 50 +++--- ddtrace/tracer/srv_src_test.go | 11 +- ddtrace/tracer/stats.go | 20 +-- ddtrace/tracer/textmap_test.go | 16 +- ddtrace/tracer/tracer.go | 4 +- ddtrace/tracer/tracer_test.go | 164 ++++++++++++-------- ddtrace/tracer/transport_bench_test.go | 2 +- ddtrace/tracer/writer.go | 14 +- ddtrace/tracer/writer_test.go | 4 +- 24 files changed, 473 insertions(+), 379 deletions(-) diff --git a/ddtrace/tracer/abandonedspans.go b/ddtrace/tracer/abandonedspans.go index f8e928a7640..d6ff8e53f3b 100644 --- a/ddtrace/tracer/abandonedspans.go +++ b/ddtrace/tracer/abandonedspans.go @@ -14,7 +14,7 @@ import ( "sync/atomic" "time" - tinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" + "github.com/DataDog/dd-trace-go/v2/ddtrace/ext" "github.com/DataDog/dd-trace-go/v2/internal/log" ) @@ -85,7 +85,7 @@ type abandonedSpanCandidate struct { // +checklocksignore — Called while span is locked or during initialization. func newAbandonedSpanCandidate(s *Span, finished bool) *abandonedSpanCandidate { var component string - if v, ok := s.meta.attrs.Get(tinternal.AttrComponent); ok { + if v, ok := s.meta.Get(ext.Component); ok { component = v } else { component = "manual" diff --git a/ddtrace/tracer/civisibility_payload_test.go b/ddtrace/tracer/civisibility_payload_test.go index e356a404e06..193cd1cabd5 100644 --- a/ddtrace/tracer/civisibility_payload_test.go +++ b/ddtrace/tracer/civisibility_payload_test.go @@ -143,7 +143,7 @@ func benchmarkCiVisibilityPayloadThroughput(count int) func(*testing.B) { return func(b *testing.B) { p := newCiVisibilityPayload() s := newBasicSpan("X") - s.meta.m["key"] = strings.Repeat("X", 10*1024) + s.meta.Set("key", strings.Repeat("X", 10*1024)) e := getCiVisibilityEvent(s) events := make(ciVisibilityEvents, count) for i := range count { diff --git a/ddtrace/tracer/civisibility_tslv.go b/ddtrace/tracer/civisibility_tslv.go index 382eacb017f..c853ada9d41 100644 --- a/ddtrace/tracer/civisibility_tslv.go +++ b/ddtrace/tracer/civisibility_tslv.go @@ -8,6 +8,7 @@ package tracer import ( + "maps" "strconv" "github.com/tinylib/msgp/msgp" @@ -164,7 +165,7 @@ type ciVisibilityEvent struct { // +checklocksignore — CI visibility event: reads span fields after SetTag releases lock. func (e *ciVisibilityEvent) SetTag(key string, value any) { e.span.SetTag(key, value) - e.Content.Meta = e.span.meta.m + e.Content.Meta = maps.Collect(e.span.meta.All()) e.Content.Metrics = e.span.metrics } @@ -411,7 +412,7 @@ func createTslvSpan(span *Span) tslvSpan { Duration: span.duration, ParentID: span.parentID, Error: span.error, - Meta: span.meta.m, + Meta: maps.Collect(span.meta.All()), Metrics: span.metrics, } } diff --git a/ddtrace/tracer/data_streams_test.go b/ddtrace/tracer/data_streams_test.go index dfd20acbc65..c0098520cb2 100644 --- a/ddtrace/tracer/data_streams_test.go +++ b/ddtrace/tracer/data_streams_test.go @@ -47,9 +47,10 @@ func TestTrackDataStreamsTransactionTagsSpan(t *testing.T) { s, ok := SpanFromContext(ctx) require.True(t, ok) - meta := s.getMetadata() - assert.Equal(t, "tx-span-tag", meta[ext.DSMTransactionID]) - assert.Equal(t, "processed", meta[ext.DSMTransactionCheckpoint]) + v, _ := s.meta.Get(ext.DSMTransactionID) + assert.Equal(t, "tx-span-tag", v) + v, _ = s.meta.Get(ext.DSMTransactionCheckpoint) + assert.Equal(t, "processed", v) } // TestTrackDataStreamsTransactionNoSpanInContextNoops verifies that when the context @@ -95,7 +96,8 @@ func TestTrackDataStreamsTransactionAtTagsSpan(t *testing.T) { s, ok := SpanFromContext(ctx) require.True(t, ok) - meta := s.getMetadata() - assert.Equal(t, "tx-at-span", meta[ext.DSMTransactionID]) - assert.Equal(t, "delivered", meta[ext.DSMTransactionCheckpoint]) + v, _ := s.meta.Get(ext.DSMTransactionID) + assert.Equal(t, "tx-at-span", v) + v, _ = s.meta.Get(ext.DSMTransactionCheckpoint) + assert.Equal(t, "delivered", v) } diff --git a/ddtrace/tracer/internal/span_attributes.go b/ddtrace/tracer/internal/span_attributes.go index 655b09705f3..6ee44990ae3 100644 --- a/ddtrace/tracer/internal/span_attributes.go +++ b/ddtrace/tracer/internal/span_attributes.go @@ -51,6 +51,15 @@ func (a *SpanAttributes) Set(key AttrKey, v string) { a.setMask |= 1 << key } +// Unset clears the attribute for key, making it absent (as if never set). nil-safe. +func (a *SpanAttributes) Unset(key AttrKey) { + if a == nil { + return + } + a.vals[key] = "" + a.setMask &^= 1 << key +} + func (a *SpanAttributes) Val(key AttrKey) string { if a == nil { return "" diff --git a/ddtrace/tracer/option_test.go b/ddtrace/tracer/option_test.go index fd38a668f84..3dfb0b4f8fa 100644 --- a/ddtrace/tracer/option_test.go +++ b/ddtrace/tracer/option_test.go @@ -1846,7 +1846,8 @@ func TestWithStartSpanConfig(t *testing.T) { s := tracer.StartSpan("test", WithStartSpanConfig(cfg)) defer s.Finish() assert.Equal(float64(1), s.metrics[keyMeasured]) - assert.Equal("value", s.meta.m["key"]) + v, _ := s.meta.Get("key") + assert.Equal("value", v) assert.Equal(parent.Context().SpanID(), s.parentID) assert.Equal(parent.Context().TraceID(), s.Context().TraceID()) assert.Equal("resource", s.resource) @@ -1895,8 +1896,10 @@ func TestWithStartSpanConfigNonEmptyTags(t *testing.T) { Tag("key", "after_start_span_config"), ) defer s.Finish() - assert.Equal("should_override", s.meta.m["k2"]) - assert.Equal("after_start_span_config", s.meta.m["key"]) + v, _ := s.meta.Get("k2") + assert.Equal("should_override", v) + v, _ = s.meta.Get("key") + assert.Equal("after_start_span_config", v) } func optsTestConsumer(opts ...StartSpanOption) { diff --git a/ddtrace/tracer/payload_test.go b/ddtrace/tracer/payload_test.go index 89c31d355d7..8d80900ce0b 100644 --- a/ddtrace/tracer/payload_test.go +++ b/ddtrace/tracer/payload_test.go @@ -513,7 +513,8 @@ func TestPayloadV1IncrementalChunkEncoding(t *testing.T) { s := got.chunks[i].spans[0] assert.Equal(t, c.service, s.service, "chunk %d: wrong service", i) assert.Equal(t, c.name, s.name, "chunk %d: wrong name", i) - assert.Equal(t, c.tagVal, s.meta.m[c.tagKey], "chunk %d: wrong tag value", i) + v, _ := s.meta.Get(c.tagKey) + assert.Equal(t, c.tagVal, v, "chunk %d: wrong tag value", i) } } @@ -521,7 +522,7 @@ func assertProcessTags(t *testing.T, payload spanLists) { assert := assert.New(t) for i, spanList := range payload { for j, span := range spanList { - processTags, ok := span.meta.m[keyProcessTags] + processTags, ok := span.meta.Get(keyProcessTags) if i+j == 0 { assert.True(ok, "process tags should be present on the first span of each chunk only") assert.Contains(processTags, "entrypoint.name", "process tags should have entrypoint.name") @@ -545,7 +546,7 @@ func benchmarkPayloadThroughput(count int) func(*testing.B) { return func(b *testing.B) { p := newPayloadV04() s := newBasicSpan("X") - s.meta.m["key"] = strings.Repeat("X", 10*1024) + s.meta.Set("key", strings.Repeat("X", 10*1024)) trace := make(spanList, count) for i := range count { trace[i] = s @@ -685,7 +686,7 @@ func BenchmarkPayloadPush(b *testing.B) { spans := make(spanList, size.numSpans) for i := 0; i < size.numSpans; i++ { span := newBasicSpan("benchmark-span") - span.meta.m["data"] = strings.Repeat("x", size.spanSize*1024) + span.meta.Set("data", strings.Repeat("x", size.spanSize*1024)) spans[i] = span } @@ -776,7 +777,7 @@ func TestMsgsizeAnalysis(t *testing.T) { spans := make(spanList, numSpans) for i := range numSpans { span := newBasicSpan("test") - span.meta.m["data"] = strings.Repeat("x", 1024) + span.meta.Set("data", strings.Repeat("x", 1024)) spans[i] = span } diff --git a/ddtrace/tracer/payload_v1.go b/ddtrace/tracer/payload_v1.go index 4874e296348..a83f19741db 100644 --- a/ddtrace/tracer/payload_v1.go +++ b/ddtrace/tracer/payload_v1.go @@ -566,10 +566,13 @@ func (p *payloadV1) encodeSpans(bm bitmap, fieldID int, spans spanList, st *stri // Promoted fields (env, version, component, spanKind) live in // span.meta.attrs and are encoded as dedicated span fields 13-16. // They are no longer in span.meta.m, so no skip logic is needed. - size := len(span.meta.m) + len(span.metrics) + len(span.metaStruct) + size := span.meta.Count() + len(span.metrics) + len(span.metaStruct) p.buf = msgp.AppendUint32(p.buf, uint32(9)) // attributes fieldID p.buf = msgp.AppendArrayHeader(p.buf, uint32(size)*3) // number of attributes - for k, v := range span.meta.m { + for k, v := range span.meta.All() { + if _, ok := tinternal.AttrKeyForTag(k); ok { + continue // promoted attrs encoded separately as fields 13-16 + } p.buf = st.serialize(k, p.buf) p.buf = msgp.AppendUint32(p.buf, uint32(StringValueType)) p.buf = st.serialize(v, p.buf) @@ -597,10 +600,16 @@ func (p *payloadV1) encodeSpans(bm bitmap, fieldID int, spans spanList, st *stri // val() is used: an absent key and an empty value are both encoded as an // empty string, so the "was it set?" distinction is irrelevant for wire encoding. - p.buf = encodeField(p.buf, fullSetBitmap, 13, span.meta.attrs.Val(tinternal.AttrEnv), st) - p.buf = encodeField(p.buf, fullSetBitmap, 14, span.meta.attrs.Val(tinternal.AttrVersion), st) - p.buf = encodeField(p.buf, fullSetBitmap, 15, span.meta.attrs.Val(tinternal.AttrComponent), st) - p.buf = encodeField(p.buf, fullSetBitmap, 16, getSpanKindValue(span.meta.attrs.Val(tinternal.AttrSpanKind)), st) + var ( + env, _ = span.meta.Get(ext.Environment) + version, _ = span.meta.Get(ext.Environment) + component, _ = span.meta.Get(ext.Component) + spanKind, _ = span.meta.Get(ext.SpanKind) + ) + p.buf = encodeField(p.buf, fullSetBitmap, 13, env, st) + p.buf = encodeField(p.buf, fullSetBitmap, 14, version, st) + p.buf = encodeField(p.buf, fullSetBitmap, 15, component, st) + p.buf = encodeField(p.buf, fullSetBitmap, 16, getSpanKindValue(spanKind), st) } return true, nil } diff --git a/ddtrace/tracer/sampler.go b/ddtrace/tracer/sampler.go index 162583d7250..8e197c6d6e5 100644 --- a/ddtrace/tracer/sampler.go +++ b/ddtrace/tracer/sampler.go @@ -15,7 +15,6 @@ import ( "time" "github.com/DataDog/dd-trace-go/v2/ddtrace/ext" - tinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" "github.com/DataDog/dd-trace-go/v2/internal/locking" "github.com/DataDog/dd-trace-go/v2/internal/locking/assert" "github.com/DataDog/dd-trace-go/v2/internal/samplernames" @@ -278,7 +277,8 @@ func (ps *prioritySampler) getRateLocked(spn *Span) float64 { assert.RWMutexRLocked(&ps.mu) // val() is used: a span with env explicitly set to "" and one with env never set // both map to the same rate-table key (both fall back to the default rate). - key := serviceEnvKey{service: spn.service, env: spn.meta.attrs.Val(tinternal.AttrEnv)} + env, _ := spn.meta.Get(ext.Environment) + key := serviceEnvKey{service: spn.service, env: env} if rate, ok := ps.rates[key]; ok { return rate } diff --git a/ddtrace/tracer/sampler_test.go b/ddtrace/tracer/sampler_test.go index cf2d83ef5d7..61082f4acc0 100644 --- a/ddtrace/tracer/sampler_test.go +++ b/ddtrace/tracer/sampler_test.go @@ -69,11 +69,12 @@ func TestPrioritySampler(t *testing.T) { assert := assert.New(t) s := mkSpan("my-service", "my-env") assert.Equal("my-service", s.service) - assert.Equal("my-env", s.meta.attrs.Val(tinternal.AttrEnv)) + v, _ := s.meta.Get(ext.Environment) + assert.Equal("my-env", v) s = mkSpan("my-service2", "") assert.Equal("my-service2", s.service) - v, ok := s.meta.attrs.Get(tinternal.AttrEnv) + v, ok := s.meta.Get(ext.Environment) assert.Equal("", v) assert.True(ok) // set to empty string, not absent }) @@ -378,7 +379,8 @@ func BenchmarkPrioritySamplerGetRate(b *testing.B) { } oldGetRate := func(ops *oldPrioritySampler, spn *Span) float64 { // Allocation doesn't escape to the heap. - key := "service:" + spn.service + ",env:" + spn.meta.attrs.Val(tinternal.AttrEnv) + v, _ := spn.meta.Get(ext.Environment) + key := "service:" + spn.service + ",env:" + v if rate, ok := ops.rates[key]; ok { return rate } @@ -2121,8 +2123,9 @@ func TestSampleTagsRootOnly(t *testing.T) { assert.Equal(0., root.metrics[keyRulesSamplerAppliedRate]) assert.NotContains(root.metrics, keyRulesSamplerLimiterRate) // Knuth sampling rate tag should be set even when rate is 0 - assert.Contains(root.meta.m, keyKnuthSamplingRate) - assert.Equal("0", root.meta.m[keyKnuthSamplingRate]) + assert.True(root.meta.Has(keyKnuthSamplingRate)) + v, _ := root.meta.Get(keyKnuthSamplingRate) + assert.Equal("0", v) // neither"_dd.limit_psr", nor "_dd.rule_psr" should be present // on the child span @@ -2174,15 +2177,16 @@ func TestSampleTagsRootOnly(t *testing.T) { assert.Contains(root.metrics, keyRulesSamplerAppliedRate) assert.NotContains(root.metrics, keyRulesSamplerLimiterRate) // Knuth sampling rate tag should be set even when rate is 0 - assert.Contains(root.meta.m, keyKnuthSamplingRate) - assert.Equal("0", root.meta.m[keyKnuthSamplingRate]) + assert.True(root.meta.Has(keyKnuthSamplingRate)) + v, _ := root.meta.Get(keyKnuthSamplingRate) + assert.Equal("0", v) // neither"_dd.limit_psr", nor "_dd.rule_psr" should be present // on the child span assert.NotContains(child.metrics, keyRulesSamplerAppliedRate) assert.NotContains(child.metrics, keyRulesSamplerLimiterRate) // child span should not have Knuth sampling rate tag - assert.NotContains(child.meta.m, keyKnuthSamplingRate) + assert.False(child.meta.Has(keyKnuthSamplingRate)) // context propagation locks the span, so no re-sampling should occur tr.Inject(root.Context(), TextMapCarrier(map[string]string{})) diff --git a/ddtrace/tracer/span.go b/ddtrace/tracer/span.go index e086c1f3a61..5c842aa5b07 100644 --- a/ddtrace/tracer/span.go +++ b/ddtrace/tracer/span.go @@ -25,7 +25,6 @@ import ( "time" "github.com/DataDog/dd-trace-go/v2/ddtrace/ext" - tinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" "github.com/DataDog/dd-trace-go/v2/instrumentation/errortrace" sharedinternal "github.com/DataDog/dd-trace-go/v2/internal" "github.com/DataDog/dd-trace-go/v2/internal/env" @@ -86,12 +85,9 @@ func (s *Span) AsMap() map[string]any { m[ext.SpanType] = s.spanType m[ext.MapSpanStart] = s.start m[ext.MapSpanDuration] = s.duration - for k, v := range s.meta.m { + for k, v := range s.meta.All() { m[k] = v } - s.meta.attrs.ForEach(func(name, val string) { - m[name] = val - }) for k, v := range s.metrics { m[k] = v } @@ -109,7 +105,8 @@ func (s *Span) AsMap() map[string]any { // +checklocksignore — Called from AsMap (test-only, not concurrent). func (s *Span) spanEventsAsJSONString() string { if !s.supportsEvents { - return s.meta.m["events"] + v, _ := s.meta.Get("events") + return v } if s.spanEvents == nil { return "" @@ -231,11 +228,8 @@ func (s *Span) getResource() string { func (s *Span) getAndRemoveMeta(key string) string { s.mu.Lock() defer s.mu.Unlock() - if s.meta.m == nil { - s.meta.m = make(map[string]string, 1) - } - if v, ok := s.meta.m[key]; ok { - delete(s.meta.m, key) + if v, ok := s.meta.Get(key); ok { + s.meta.Delete(key) delete(s.metrics, key) return v } @@ -299,7 +293,7 @@ func (s *Span) debugInfo() (name string, spanID, traceID uint64, integration str name = s.name spanID = s.spanID traceID = s.traceID - if v, ok := s.meta.attrs.Get(tinternal.AttrComponent); ok { + if v, ok := s.meta.Get(ext.Component); ok { integration = v } else { integration = "manual" @@ -307,18 +301,6 @@ func (s *Span) debugInfo() (name string, spanID, traceID uint64, integration str return } -// getMetadata returns a copy of the meta map for testing purposes. -// This is only for use in tests to verify span metadata state. -func (s *Span) getMetadata() map[string]string { - s.mu.RLock() - defer s.mu.RUnlock() - meta := make(map[string]string, len(s.meta.m)+s.meta.attrs.Count()) - maps.Copy(meta, s.meta.m) - s.meta.attrs.ForEach(func(name, val string) { - meta[name] = val - }) - return meta -} // matchTagsForSampling checks if span tags match the sampling rule tag patterns. // Used by rules_sampler.go for tag-based sampling rule matching. @@ -328,15 +310,8 @@ func (s *Span) matchTagsForSampling(tagPatterns map[string]func(string) bool) bo defer s.mu.RUnlock() for k, matchFunc := range tagPatterns { - if s.meta.m != nil { - if v, ok := s.meta.m[k]; ok && matchFunc(v) { - continue - } - } - if ak, ok := tinternal.AttrKeyForTag(k); ok { - if v, has := s.meta.attrs.Get(ak); has && matchFunc(v) { - continue - } + if v, ok := s.meta.Get(k); ok && matchFunc(v) { + continue } if s.metrics != nil { if v, ok := s.metrics[k]; ok { @@ -583,7 +558,7 @@ func (s *Span) SetUser(id string, opts ...UserMonitoringOption) { } if cfg.PropagateID { // Delete usr.id from the tags since _dd.p.usr.id takes precedence - delete(root.meta.m, keyUserID) + root.meta.Delete(keyUserID) idenc := base64.StdEncoding.EncodeToString([]byte(id)) trace.setPropagatingTag(keyPropagatedUserID, idenc) s.context.updated = true @@ -593,7 +568,7 @@ func (s *Span) SetUser(id string, opts ...UserMonitoringOption) { trace.unsetPropagatingTag(keyPropagatedUserID) s.context.updated = true } - delete(root.meta.m, keyPropagatedUserID) + root.meta.Delete(keyPropagatedUserID) } usrData := map[string]string{ @@ -766,52 +741,8 @@ func (s *Span) setMetaInit(key, v string) { s.resource = v case ext.SpanType: s.spanType = v - case ext.Environment: - s.setAttrCOW(tinternal.AttrEnv, v) - case ext.Version: - s.setAttrCOW(tinternal.AttrVersion, v) - case ext.Component: - s.setAttrCOW(tinternal.AttrComponent, v) - case ext.SpanKind: - s.setAttrCOW(tinternal.AttrSpanKind, v) default: - if s.meta.m == nil { - s.meta.m = initMeta() - } - s.meta.m[key] = v - } -} - -// setAttrCOW sets a promoted attribute with copy-on-write semantics. -// If the value already matches what's in attrs (e.g. from the shared tracer -// instance), the write is skipped entirely — no clone, no allocation. -// The slow path is split out with //go:noinline to reduce code size at call sites. -// +checklocksignore — Called from setMetaInit (init, no lock) and setMetaLocked (lock held). -func (s *Span) setAttrCOW(key tinternal.AttrKey, v string) { - if s.meta.attrs != nil && s.meta.attrs.Val(key) == v { - return // already has the right value (shared or local) - } - s.setAttrCOWSlow(key, v) -} - -// setAttrCOWSlow is the slow path for setAttrCOW that handles the clone + set. -// +checklocksignore — Called from setMetaInit (init, no lock) and setMetaLocked (lock held). -// -//go:noinline -func (s *Span) setAttrCOWSlow(key tinternal.AttrKey, v string) { - s.ensureAttrsLocal() - s.meta.attrs.Set(key, v) -} - -// ensureAttrsLocal guarantees s.meta.attrs is a mutable, span-local instance. -// +checklocksignore — Called from setMetaInit (init, no lock) and setMetaLocked (lock held). -func (s *Span) ensureAttrsLocal() { - if s.meta.attrs == nil { - s.meta.attrs = new(tinternal.SpanAttributes) - return - } - if s.meta.attrs.IsShared() { - s.meta.attrs = s.meta.attrs.Clone() + s.meta.Set(key, v) } } @@ -860,7 +791,7 @@ func (s *Span) setMetricInit(key string, v float64) { if s.metrics == nil { s.metrics = make(map[string]float64, 1) } - delete(s.meta.m, key) + s.meta.Delete(key) // Note: We don't handle ManualKeep or _sampling_priority_v1shim during init // because those require modifying trace-level state which needs locking s.metrics[key] = v @@ -880,7 +811,7 @@ func (s *Span) setMetricLocked(key string, v float64) { if s.metrics == nil { s.metrics = make(map[string]float64, 1) } - delete(s.meta.m, key) + s.meta.Delete(key) switch key { case ext.ManualKeep: if v == float64(samplernames.AppSec) { @@ -929,10 +860,7 @@ func (s *Span) serializeSpanLinksInMeta() { log.Debug("Unable to marshal span links. Not adding span links to span meta.") return } - if s.meta.m == nil { - s.meta.m = make(map[string]string) - } - s.meta.m["_dd.span_links"] = string(spanLinkBytes) + s.meta.Set("_dd.span_links", string(spanLinkBytes)) } // serializeSpanEvents sets the span events from the current span in the correct transport, depending on whether the @@ -956,7 +884,7 @@ func (s *Span) serializeSpanEvents() { log.Debug("Unable to marshal span events; events dropped from span meta\n%s", err.Error()) return } - s.meta.m["events"] = string(b) + s.meta.Set("events", string(b)) } // Finish closes this Span (but not its children) providing the duration @@ -1046,10 +974,7 @@ func (s *Span) enrichServiceSource() { if s.serviceSource == "" || s.service == globalconfig.ServiceName() { return } - if s.meta.m == nil { - s.meta.m = make(map[string]string, 1) - } - s.meta.m[ext.KeyServiceSource] = s.serviceSource + s.meta.Set(ext.KeyServiceSource, s.serviceSource) } func (s *Span) finish(finishTime int64) { @@ -1201,12 +1126,9 @@ func (s *Span) String() string { fmt.Sprintf("Type: %s", s.spanType), "Tags:", } - for key, val := range s.meta.m { - lines = append(lines, fmt.Sprintf("\t%s:%s", key, val)) + for k, v := range s.meta.All() { + lines = append(lines, fmt.Sprintf("\t%s:%s", k, v)) } - s.meta.attrs.ForEach(func(name, val string) { - lines = append(lines, fmt.Sprintf("\t%s:%s", name, val)) - }) for key, val := range s.metrics { lines = append(lines, fmt.Sprintf("\t%s:%f", key, val)) } @@ -1296,39 +1218,11 @@ func setLLMObsPropagatingTags(ctx context.Context, spanCtx *SpanContext) { spanCtx.trace.setPropagatingTag(keyPropagatedLLMObsMLAPP, llmSpan.MLApp()) } -// initMeta pre-allocates the meta map with headroom. -// expectedEntries should be the count of tags known at construction time. -// The 4/3 factor (≈ inverse of the standard 0.75 load factor) provides -// ~33% slack so small overestimates don't trigger an immediate rehash. -func initMeta() map[string]string { - // Non-promoted tags that commonly appear at construction time. - // Promoted fields (env, version, component, span.kind) now live in - // meta.attrs and are excluded from this map. - const ( - expectedEntries = 1 - loadFactor = 4 / 3 - ) - return make(map[string]string, expectedEntries*loadFactor) -} - // used in internal/civisibility/integrations/manual_api_common.go using linkname func getMeta(s *Span, key string) (string, bool) { s.mu.RLock() defer s.mu.RUnlock() - // Fast path for promoted fields: TagValue.Set gives the correct "was it set?" - // semantics without a map lookup. - switch key { - case ext.Environment: - return s.meta.attrs.Get(tinternal.AttrEnv) - case ext.Version: - return s.meta.attrs.Get(tinternal.AttrVersion) - case ext.Component: - return s.meta.attrs.Get(tinternal.AttrComponent) - case ext.SpanKind: - return s.meta.attrs.Get(tinternal.AttrSpanKind) - } - val, ok := s.meta.m[key] - return val, ok + return s.meta.Get(key) } // used in internal/civisibility/integrations/manual_api_common.go using linkname diff --git a/ddtrace/tracer/span_event_test.go b/ddtrace/tracer/span_event_test.go index 903d53f5242..f5a21169e6b 100644 --- a/ddtrace/tracer/span_event_test.go +++ b/ddtrace/tracer/span_event_test.go @@ -137,10 +137,11 @@ func Test_spanAddEvent(t *testing.T) { s.Finish() require.Empty(t, s.spanEvents) - assert.NotEmpty(t, s.meta.m["events"]) + events, _ := s.meta.Get("events") + assert.NotEmpty(t, events) var spanEvents []spanEvent - err := json.Unmarshal([]byte(s.meta.m["events"]), &spanEvents) + err := json.Unmarshal([]byte(events), &spanEvents) require.NoError(t, err) require.Len(t, spanEvents, 3) diff --git a/ddtrace/tracer/span_meta.go b/ddtrace/tracer/span_meta.go index bab26a6f71d..2aeb634773f 100644 --- a/ddtrace/tracer/span_meta.go +++ b/ddtrace/tracer/span_meta.go @@ -7,6 +7,7 @@ package tracer import ( "fmt" + "iter" "strings" "github.com/tinylib/msgp/msgp" @@ -35,29 +36,138 @@ func (sm spanMeta) IsZero() bool { return len(sm.m) == 0 && sm.attrs.Count() == 0 } +// ReplaceSharedAttrs replaces the current attrs pointer with next if it +// currently equals prev. Used by the tracer to upgrade a newly-created span +// from the base shared attrs to the main-service shared attrs. +func (sm *spanMeta) ReplaceSharedAttrs(prev, next *tinternal.SpanAttributes) { + if sm.attrs == prev { + sm.attrs = next + } +} + +// Normalize sets m and attrs to nil when they are empty so that a zero-length +// spanMeta compares equal to a freshly-zeroed one. Intended for test helpers. +func (sm *spanMeta) Normalize() { + if len(sm.m) == 0 { + sm.m = nil + } + if sm.attrs != nil && sm.attrs.Count() == 0 { + sm.attrs = nil + } +} + +// Get returns the value for key, checking attrs for promoted keys and the flat map for others. +func (sm spanMeta) Get(key string) (string, bool) { + if ak, ok := tinternal.AttrKeyForTag(key); ok { + return sm.attrs.Get(ak) + } + v, ok := sm.m[key] + return v, ok +} + +// Has reports whether key is present. Promoted keys check attrs; others check the flat map. +func (sm spanMeta) Has(key string) bool { + if ak, ok := tinternal.AttrKeyForTag(key); ok { + _, ok := sm.attrs.Get(ak) + return ok + } + _, ok := sm.m[key] + return ok +} + +// Set sets key→value, routing promoted keys to attrs (with copy-on-write) and others to the flat map. +// +checklocksignore — called both at init time (no lock) and under lock. +func (sm *spanMeta) Set(key, value string) { + if ak, ok := tinternal.AttrKeyForTag(key); ok { + // Check if whether setting key=v on attrs would be a no-op + // no-op (i.e. the current value is already v, shared or local). + if sm.attrs != nil && sm.attrs.Val(ak) == value { + return + } + sm.setAttrCOWSlow(ak, value) + return + } + if sm.m == nil { + sm.m = make(map[string]string, 1) + } + sm.m[key] = value +} + +// ensureAttrsLocal guarantees attrs is a mutable, span-local instance. +// If attrs is nil a fresh one is allocated; if shared, it is cloned. +func (sm *spanMeta) ensureAttrsLocal() { + if sm.attrs == nil { + sm.attrs = new(tinternal.SpanAttributes) + return + } + if sm.attrs.IsShared() { + sm.attrs = sm.attrs.Clone() + } +} + +// setAttrCOWSlow is the slow path for Set when the value is a promoted attr that needs writing. +// Separated with //go:noinline to keep the hot path in Set small. +// +//go:noinline +func (sm *spanMeta) setAttrCOWSlow(key tinternal.AttrKey, v string) { + sm.ensureAttrsLocal() + sm.attrs.Set(key, v) +} + +// Delete removes key. For promoted keys, clears the attribute (with copy-on-write); for others, +// removes from the flat map. No-op if the key is absent. +// +checklocksignore — called both at init time (no lock) and under lock. +func (sm *spanMeta) Delete(key string) { + if ak, ok := tinternal.AttrKeyForTag(key); ok { + if _, isSet := sm.attrs.Get(ak); !isSet { + return // already absent, skip unnecessary COW + } + sm.ensureAttrsLocal() + sm.attrs.Unset(ak) + return + } + delete(sm.m, key) +} + +// Count returns the total number of entries (flat map + promoted attrs). +func (sm spanMeta) Count() int { + return len(sm.m) + sm.attrs.Count() +} + +// All returns an iterator over all entries. Flat-map entries are yielded first +// (in unspecified order), followed by promoted attributes in definition order +// (env, version, component, span.kind). Returning false from yield stops iteration. +func (sm spanMeta) All() iter.Seq2[string, string] { + return func(yield func(string, string) bool) { + for k, v := range sm.m { + if !yield(k, v) { + return + } + } + if sm.attrs != nil { + for _, d := range tinternal.Defs { + if v, ok := sm.attrs.Get(d.Key); ok { + if !yield(d.Name, v) { + return + } + } + } + } + } +} + // String returns a merged map representation (m + promoted attrs) for debug logging. func (sm spanMeta) String() string { var b strings.Builder b.WriteString("map[") first := true - for k, v := range sm.m { + for k, v := range sm.All() { if !first { b.WriteByte(' ') } first = false fmt.Fprintf(&b, "%s:%s", k, v) } - if sm.attrs != nil { - for _, d := range tinternal.Defs { - if v, ok := sm.attrs.Get(d.Key); ok { - if !first { - b.WriteByte(' ') - } - first = false - fmt.Fprintf(&b, "%s:%s", d.Name, v) - } - } - } b.WriteByte(']') return b.String() } diff --git a/ddtrace/tracer/span_test.go b/ddtrace/tracer/span_test.go index e6735bcd778..d70fc004e02 100644 --- a/ddtrace/tracer/span_test.go +++ b/ddtrace/tracer/span_test.go @@ -16,7 +16,6 @@ import ( "time" "github.com/DataDog/dd-trace-go/v2/ddtrace/ext" - tinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" "github.com/DataDog/dd-trace-go/v2/instrumentation/errortrace" sharedinternal "github.com/DataDog/dd-trace-go/v2/internal" "github.com/DataDog/dd-trace-go/v2/internal/log" @@ -429,13 +428,15 @@ func TestSpanSetTag(t *testing.T) { assert.Equal("web.request", span.name) span.SetTag("component", "tracer") - assert.Equal("tracer", span.meta.attrs.Val(tinternal.AttrComponent)) + v, _ := span.meta.Get(ext.Component) + assert.Equal("tracer", v) span.SetTag("tagInt", 1234) assert.Equal(float64(1234), span.metrics["tagInt"]) span.SetTag("tagStruct", struct{ A, B int }{1, 2}) - assert.Equal("{1 2}", span.meta.m["tagStruct"]) + v, _ = span.meta.Get("tagStruct") + assert.Equal("{1 2}", v) span.SetTag(ext.Error, true) assert.Equal(int32(1), span.error) @@ -445,9 +446,12 @@ func TestSpanSetTag(t *testing.T) { span.SetTag(ext.Error, errors.New("abc")) assert.Equal(int32(1), span.error) - assert.Equal("abc", span.meta.m[ext.ErrorMsg]) - assert.Equal("*errors.errorString", span.meta.m[ext.ErrorType]) - assert.NotEmpty(span.meta.m[ext.ErrorHandlingStack]) + v, _ = span.meta.Get(ext.ErrorMsg) + assert.Equal("abc", v) + v, _ = span.meta.Get(ext.ErrorType) + assert.Equal("*errors.errorString", v) + v, _ = span.meta.Get(ext.ErrorHandlingStack) + assert.NotEmpty(v) span.SetTag(ext.Error, "something else") assert.Equal(int32(1), span.error) @@ -456,24 +460,32 @@ func TestSpanSetTag(t *testing.T) { assert.Equal(int32(0), span.error) span.SetTag("some.bool", true) - assert.Equal("true", span.meta.m["some.bool"]) + v, _ = span.meta.Get("some.bool") + assert.Equal("true", v) span.SetTag("some.other.bool", false) - assert.Equal("false", span.meta.m["some.other.bool"]) + v, _ = span.meta.Get("some.other.bool") + assert.Equal("false", v) span.SetTag("time", (*time.Time)(nil)) - assert.Equal("", span.meta.m["time"]) + v, _ = span.meta.Get("time") + assert.Equal("", v) span.SetTag("nilStringer", (*nilStringer)(nil)) - assert.Equal("", span.meta.m["nilStringer"]) + v, _ = span.meta.Get("nilStringer") + assert.Equal("", v) span.SetTag("somestrings", []string{"foo", "bar"}) - assert.Equal("foo", span.meta.m["somestrings.0"]) - assert.Equal("bar", span.meta.m["somestrings.1"]) + v, _ = span.meta.Get("somestrings.0") + assert.Equal("foo", v) + v, _ = span.meta.Get("somestrings.1") + assert.Equal("bar", v) span.SetTag("somebools", []bool{true, false}) - assert.Equal("true", span.meta.m["somebools.0"]) - assert.Equal("false", span.meta.m["somebools.1"]) + v, _ = span.meta.Get("somebools.0") + assert.Equal("true", v) + v, _ = span.meta.Get("somebools.1") + assert.Equal("false", v) span.SetTag("somenums", []int{-1, 5, 2}) assert.Equal(-1., span.metrics["somenums.0"]) @@ -481,10 +493,14 @@ func TestSpanSetTag(t *testing.T) { assert.Equal(2., span.metrics["somenums.2"]) span.SetTag("someslices", [][]string{{"a, b, c"}, {"d"}, nil, {"e, f"}}) - assert.Equal("[a, b, c]", span.meta.m["someslices.0"]) - assert.Equal("[d]", span.meta.m["someslices.1"]) - assert.Equal("[]", span.meta.m["someslices.2"]) - assert.Equal("[e, f]", span.meta.m["someslices.3"]) + v, _ = span.meta.Get("someslices.0") + assert.Equal("[a, b, c]", v) + v, _ = span.meta.Get("someslices.1") + assert.Equal("[d]", v) + v, _ = span.meta.Get("someslices.2") + assert.Equal("[]", v) + v, _ = span.meta.Get("someslices.3") + assert.Equal("[e, f]", v) mapStrStr := map[string]string{"b": "c"} span.SetTag("map", sharedinternal.MetaStructValue{Value: map[string]string{"b": "c"}}) @@ -514,10 +530,12 @@ func TestSpanSetTag(t *testing.T) { s := "string" span.SetTag("str_ptr", &s) - assert.Equal(s, span.meta.m["str_ptr"]) + strPtr, _ := span.meta.Get("str_ptr") + assert.Equal(s, strPtr) span.SetTag("nil_str_ptr", (*string)(nil)) - assert.Equal("", span.meta.m["nil_str_ptr"]) + nilStrPtr, _ := span.meta.Get("nil_str_ptr") + assert.Equal("", nilStrPtr) assert.Panics(func() { span.SetTag("panicStringer", &panicStringer{}) @@ -546,22 +564,25 @@ func TestPromotedFieldsStorage(t *testing.T) { assert := assert.New(t) for _, tc := range []struct { - tag string - field func(*Span) string + tag string }{ - {ext.Environment, func(s *Span) string { return s.meta.attrs.Val(tinternal.AttrEnv) }}, - {ext.Version, func(s *Span) string { return s.meta.attrs.Val(tinternal.AttrVersion) }}, - {ext.Component, func(s *Span) string { return s.meta.attrs.Val(tinternal.AttrComponent) }}, - {ext.SpanKind, func(s *Span) string { return s.meta.attrs.Val(tinternal.AttrSpanKind) }}, + {ext.Environment}, + {ext.Version}, + {ext.Component}, + {ext.SpanKind}, } { t.Run(tc.tag, func(t *testing.T) { span := newBasicSpan("op") span.SetTag(tc.tag, "value") - assert.Equal("value", tc.field(span), "struct field must be set") + got, ok := span.meta.Get(tc.tag) + assert.True(ok) + assert.Equal("value", got, "field must be set") // Overwrite: field should track the update. span.SetTag(tc.tag, "updated") - assert.Equal("updated", tc.field(span)) + got, ok = span.meta.Get(tc.tag) + assert.True(ok) + assert.Equal("updated", got, "field must be set") }) } } @@ -579,13 +600,15 @@ func TestSpanSetTagError(t *testing.T) { t.Run("off", func(t *testing.T) { span := newBasicSpan("web.request") span.SetTag(ext.ErrorNoStackTrace, errors.New("error value with no trace")) - assert.Empty(t, span.meta.m[ext.ErrorHandlingStack]) + v, _ := span.meta.Get(ext.ErrorHandlingStack) + assert.Empty(t, v) }) t.Run("on", func(t *testing.T) { span := newBasicSpan("web.request") span.SetTag(ext.Error, errors.New("error value with trace")) - assert.NotEmpty(t, span.meta.m[ext.ErrorHandlingStack]) + v, _ := span.meta.Get(ext.ErrorHandlingStack) + assert.NotEmpty(t, v) }) } @@ -788,9 +811,11 @@ func TestSpanStartNilOption(t *testing.T) { t.Run(tc.name, func(_ *testing.T) { span := tracer.StartSpan("pylons.request", tc.options...) if tc.wantTag { - assert.Equal(tc.wantTag, span.meta.m["tag"] == "value") + v, _ := span.meta.Get("tag") + assert.Equal(tc.wantTag, v == "value") } else { - assert.Empty(span.meta.m["tag"]) + v, _ := span.meta.Get("tag") + assert.Empty(v) } }) } @@ -842,12 +867,14 @@ func TestSpanSetMetric(t *testing.T) { "toobig": func(assert *assert.Assertions, span *Span) { span.SetTag("bytes", intUpperLimit) assert.Equal(0.0, span.metrics["bytes"]) - assert.Equal(fmt.Sprint(intUpperLimit), span.meta.m["bytes"]) + v, _ := span.meta.Get("bytes") + assert.Equal(fmt.Sprint(intUpperLimit), v) }, "toosmall": func(assert *assert.Assertions, span *Span) { span.SetTag("bytes", intLowerLimit) assert.Equal(0.0, span.metrics["bytes"]) - assert.Equal(fmt.Sprint(intLowerLimit), span.meta.m["bytes"]) + v, _ := span.meta.Get("bytes") + assert.Equal(fmt.Sprint(intLowerLimit), v) }, "finished": func(assert *assert.Assertions, span *Span) { span.Finish() @@ -910,10 +937,12 @@ func TestErrorStack(t *testing.T) { err = createErrorTrace() span.SetTag(ext.Error, err) assert.Equal(int32(1), span.error) - assert.Equal("Something wrong", span.meta.m[ext.ErrorMsg]) - assert.Equal("*errortrace.TracerError", span.meta.m[ext.ErrorType]) + v, _ := span.meta.Get(ext.ErrorMsg) + assert.Equal("Something wrong", v) + v, _ = span.meta.Get(ext.ErrorType) + assert.Equal("*errortrace.TracerError", v) - stack := span.meta.m[ext.ErrorHandlingStack] + stack, _ := span.meta.Get(ext.ErrorHandlingStack) assert.NotEqual("", stack) span.Finish() @@ -929,10 +958,12 @@ func TestErrorStack(t *testing.T) { err = createTestError() span.SetTag(ext.Error, err) assert.Equal(int32(1), span.error) - assert.Equal("Something wrong", span.meta.m[ext.ErrorMsg]) - assert.Equal("*errors.errorString", span.meta.m[ext.ErrorType]) + v, _ := span.meta.Get(ext.ErrorMsg) + assert.Equal("Something wrong", v) + v, _ = span.meta.Get(ext.ErrorType) + assert.Equal("*errors.errorString", v) - stack := span.meta.m[ext.ErrorHandlingStack] + stack, _ := span.meta.Get(ext.ErrorHandlingStack) assert.NotEqual("", stack) span.Finish() @@ -951,25 +982,23 @@ func TestSpanError(t *testing.T) { err = errors.New("Something wrong") span.SetTag(ext.Error, err) assert.Equal(int32(1), span.error) - assert.Equal("Something wrong", span.meta.m[ext.ErrorMsg]) - assert.Equal("*errors.errorString", span.meta.m[ext.ErrorType]) - assert.NotEqual("", span.meta.m[ext.ErrorHandlingStack]) + v, _ := span.meta.Get(ext.ErrorMsg) + assert.Equal("Something wrong", v) + v, _ = span.meta.Get(ext.ErrorType) + assert.Equal("*errors.errorString", v) + v, _ = span.meta.Get(ext.ErrorHandlingStack) + assert.NotEqual("", v) span.Finish() // operating on a finished span is a no-op span = tracer.newRootSpan("flask.request", "flask", "/") - nMeta := len(span.meta.m) span.Finish() span.SetTag(ext.Error, err) assert.Equal(int32(0), span.error) - // '+4' is `_dd.p.dm` + `_dd.base_service` + `_dd.p.tid` + `_dd.svc_src` - meta := span.getMetadata() - t.Logf("%q\n", meta) - assert.Equal(nMeta+3+span.meta.attrs.Count(), len(meta)) - assert.Equal("", meta[ext.ErrorMsg]) - assert.Equal("", meta[ext.ErrorType]) - assert.Equal("", meta[ext.ErrorHandlingStack]) + assert.False(span.meta.Has(ext.ErrorMsg)) + assert.False(span.meta.Has(ext.ErrorType)) + assert.False(span.meta.Has(ext.ErrorHandlingStack)) } func TestSpanError_Typed(t *testing.T) { @@ -983,9 +1012,12 @@ func TestSpanError_Typed(t *testing.T) { err = &boomError{} span.SetTag(ext.Error, err) assert.Equal(int32(1), span.error) - assert.Equal("boom", span.meta.m[ext.ErrorMsg]) - assert.Equal("*tracer.boomError", span.meta.m[ext.ErrorType]) - assert.NotEqual("", span.meta.m[ext.ErrorHandlingStack]) + v, _ := span.meta.Get(ext.ErrorMsg) + assert.Equal("boom", v) + v, _ = span.meta.Get(ext.ErrorType) + assert.Equal("*tracer.boomError", v) + v, _ = span.meta.Get(ext.ErrorHandlingStack) + assert.NotEqual("", v) } func TestSpanErrorNil(t *testing.T) { @@ -997,10 +1029,10 @@ func TestSpanErrorNil(t *testing.T) { span := tracer.newRootSpan("pylons.request", "pylons", "/") // don't set the error if it's nil - nMeta := len(span.meta.m) + n := span.meta.Count() span.SetTag(ext.Error, nil) assert.Equal(int32(0), span.error) - assert.Equal(nMeta, len(span.meta.m)) + assert.Equal(n, span.meta.Count()) } func TestSpanErrorStackMetrics(t *testing.T) { @@ -1135,14 +1167,15 @@ func TestUniqueTagKeys(t *testing.T) { span.SetTag("foo.bar", "val") assert.NotContains(span.metrics, "foo.bar") - assert.Equal("val", span.meta.m["foo.bar"]) + v, _ := span.meta.Get("foo.bar") + assert.Equal("val", v) // check to see if setMetric correctly wipes out a meta tag span.SetTag("foo.bar", "val") span.SetTag("foo.bar", 12) assert.Equal(12.0, span.metrics["foo.bar"]) - assert.NotContains(span.meta.m, "foo.bar") + assert.False(span.meta.Has("foo.bar")) } // Prior to a bug fix, this failed when running `go test -race` @@ -1733,7 +1766,7 @@ func TestSpanLinksInMeta(t *testing.T) { sp.Finish() internalSpan := sp - _, ok := internalSpan.meta.m["_dd.span_links"] + _, ok := internalSpan.meta.Get("_dd.span_links") assert.False(t, ok, "Expected no _dd.span_links in Meta.") }) @@ -1749,7 +1782,7 @@ func TestSpanLinksInMeta(t *testing.T) { sp.Finish() internalSpan := sp - raw, ok := internalSpan.meta.m["_dd.span_links"] + raw, ok := internalSpan.meta.Get("_dd.span_links") require.True(t, ok, "Expected _dd.span_links in Meta after adding links.") var links []SpanLink diff --git a/ddtrace/tracer/spancontext.go b/ddtrace/tracer/spancontext.go index a931d004646..d07cccb5b46 100644 --- a/ddtrace/tracer/spancontext.go +++ b/ddtrace/tracer/spancontext.go @@ -17,7 +17,6 @@ import ( "github.com/DataDog/dd-trace-go/v2/ddtrace" "github.com/DataDog/dd-trace-go/v2/ddtrace/ext" "github.com/DataDog/dd-trace-go/v2/ddtrace/internal/tracerstats" - tinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" sharedinternal "github.com/DataDog/dd-trace-go/v2/internal" internalconfig "github.com/DataDog/dd-trace-go/v2/internal/config" "github.com/DataDog/dd-trace-go/v2/internal/locking" @@ -849,15 +848,15 @@ func setPeerService(s *Span, tc TracerConf) { // val() is used: only specific non-empty values ("client", "producer") qualify as // outbound requests, so an unset and an explicitly-empty spanKind are both correctly // treated as non-outbound. - spanKind := s.meta.attrs.Val(tinternal.AttrSpanKind) + spanKind, _ := s.meta.Get(ext.SpanKind) isOutboundRequest := spanKind == ext.SpanKindClient || spanKind == ext.SpanKindProducer - if _, ok := s.meta.m[ext.PeerService]; ok { // peer.service already set on the span + if s.meta.Has(ext.PeerService) { // peer.service already set on the span s.setMetaLocked(keyPeerServiceSource, ext.PeerService) } else if isServerless(tc) { // Set peerService only in outbound Lambda requests if isOutboundRequest { - if ps := deriveAWSPeerService(s.meta.m); ps != "" { + if ps := deriveAWSPeerService(s.meta); ps != "" { s.setMetaLocked(ext.PeerService, ps) s.setMetaLocked(keyPeerServiceSource, ext.PeerService) } else { @@ -877,7 +876,7 @@ func setPeerService(s *Span, tc TracerConf) { s.setMetaLocked(keyPeerServiceSource, source) } // Overwrite existing peer.service value if remapped by the user - ps := s.meta.m[ext.PeerService] + ps, _ := s.meta.Get(ext.PeerService) if to, ok := tc.PeerServiceMappings[ps]; ok { s.setMetaLocked(keyPeerServiceRemappedFrom, ps) s.setMetaLocked(ext.PeerService, to) @@ -907,24 +906,25 @@ The mapping is as follows: - s3: .s3..amazonaws.com (if Bucket param present) s3..amazonaws.com (otherwise) */ -func deriveAWSPeerService(sm map[string]string) string { - service, region := sm[ext.AWSService], sm[ext.AWSRegion] - if service == "" || region == "" { +func deriveAWSPeerService(sm spanMeta) string { + service, ok := sm.Get(ext.AWSService) + if !ok { + return "" + } + region, ok := sm.Get(ext.AWSRegion) + if !ok { return "" } s := strings.ToLower(service) switch s { - case "s3": - if bucket := sm[ext.S3BucketName]; bucket != "" { + if bucket, ok := sm.Get(ext.S3BucketName); ok { return bucket + ".s3." + region + ".amazonaws.com" } return "s3." + region + ".amazonaws.com" - case "eventbridge": return "events." + region + ".amazonaws.com" - case "sqs", "sns", "dynamodb", "kinesis": return s + "." + region + ".amazonaws.com" } @@ -936,8 +936,7 @@ func deriveAWSPeerService(sm map[string]string) string { // +checklocks:s.mu func (s *Span) hasMetaKeyLocked(tag string) bool { assert.RWMutexLocked(&s.mu) - _, ok := s.meta.m[tag] - return ok + return s.meta.Has(tag) } // setPeerServiceFromSource sets peer.service from the sources determined @@ -949,6 +948,7 @@ func setPeerServiceFromSource(s *Span) string { assert.RWMutexLocked(&s.mu) var sources []string useTargetHost := true + dbSys, _ := s.meta.Get(ext.DBSystem) switch { // order of the cases and their sources matters here. These are in priority order (highest to lowest) case s.hasMetaKeyLocked("aws_service"): @@ -959,7 +959,7 @@ func setPeerServiceFromSource(s *Span) string { "tablename", "bucketname", } - case s.meta.m[ext.DBSystem] == ext.DBSystemCassandra: + case dbSys == ext.DBSystemCassandra: sources = []string{ ext.CassandraContactPoints, } @@ -987,7 +987,7 @@ func setPeerServiceFromSource(s *Span) string { }...) } for _, source := range sources { - if val, ok := s.meta.m[source]; ok { + if val, ok := s.meta.Get(source); ok { s.setMetaLocked(ext.PeerService, val) return source } diff --git a/ddtrace/tracer/spancontext_test.go b/ddtrace/tracer/spancontext_test.go index 6c6da995f54..50f80c400f4 100644 --- a/ddtrace/tracer/spancontext_test.go +++ b/ddtrace/tracer/spancontext_test.go @@ -226,10 +226,9 @@ func testAsyncSpanRace(t *testing.T) { finishes.Wait() for range 500 { - for range root.meta.m { + for range root.meta.All() { // this range simulates iterating over the meta map // as we do when encoding msgpack upon flushing. - continue } } }) @@ -314,9 +313,11 @@ func TestPartialFlush(t *testing.T) { ts := transport.Traces() require.Len(t, ts, 1) require.Len(t, ts[0], 2) - assert.Equal(t, "someValue", ts[0][0].meta.m["someTraceTag"]) + v0, _ := ts[0][0].meta.Get("someTraceTag") + assert.Equal(t, "someValue", v0) assert.Equal(t, 1.0, ts[0][0].metrics[keySamplingPriority]) - assert.Empty(t, ts[0][1].meta.m["someTraceTag"]) // the tag should only be on the first span in the chunk + v1, _ := ts[0][1].meta.Get("someTraceTag") + assert.Empty(t, v1) // the tag should only be on the first span in the chunk assert.Equal(t, 1.0, ts[0][1].metrics[keySamplingPriority]) // the tag should only be on the first span in the chunk comparePayloadSpans(t, children[0], ts[0][0]) comparePayloadSpans(t, children[1], ts[0][1]) @@ -330,9 +331,11 @@ func TestPartialFlush(t *testing.T) { tsRoot := transport.Traces() require.Len(t, tsRoot, 1) require.Len(t, tsRoot[0], 2) - assert.Equal(t, "someValue", ts[0][0].meta.m["someTraceTag"]) + v0, _ = ts[0][0].meta.Get("someTraceTag") + assert.Equal(t, "someValue", v0) assert.Equal(t, 1.0, ts[0][0].metrics[keySamplingPriority]) - assert.Empty(t, ts[0][1].meta.m["someTraceTag"]) // the tag should only be on the first span in the chunk + v1, _ = ts[0][1].meta.Get("someTraceTag") + assert.Empty(t, v1) // the tag should only be on the first span in the chunk assert.Equal(t, 1.0, ts[0][1].metrics[keySamplingPriority]) // the tag should only be on the first span in the chunk comparePayloadSpans(t, root, tsRoot[0][0]) comparePayloadSpans(t, children[2], tsRoot[0][1]) @@ -787,19 +790,22 @@ func TestSpanPeerService(t *testing.T) { for _, tc := range testCases { assertSpan := func(t *testing.T, s *Span) { if tc.wantPeerService == "" { - assert.NotContains(t, s.meta.m, "peer.service") + assert.False(t, s.meta.Has("peer.service")) } else { - assert.Equal(t, tc.wantPeerService, s.meta.m["peer.service"]) + v, _ := s.meta.Get("peer.service") + assert.Equal(t, tc.wantPeerService, v) } if tc.wantPeerServiceSource == "" { - assert.NotContains(t, s.meta.m, "_dd.peer.service.source") + assert.False(t, s.meta.Has("_dd.peer.service.source")) } else { - assert.Equal(t, tc.wantPeerServiceSource, s.meta.m["_dd.peer.service.source"]) + v, _ := s.meta.Get("_dd.peer.service.source") + assert.Equal(t, tc.wantPeerServiceSource, v) } if tc.wantPeerServiceRemappedFrom == "" { - assert.NotContains(t, s.meta.m, "_dd.peer.service.remapped_from") + assert.False(t, s.meta.Has("_dd.peer.service.remapped_from")) } else { - assert.Equal(t, tc.wantPeerServiceRemappedFrom, s.meta.m["_dd.peer.service.remapped_from"]) + v, _ := s.meta.Get("_dd.peer.service.remapped_from") + assert.Equal(t, tc.wantPeerServiceRemappedFrom, v) } } t.Run(tc.name, func(t *testing.T) { @@ -867,7 +873,8 @@ func TestSpanDDBaseService(t *testing.T) { spans := run(t, tracerOpts, spanOpts) for _, s := range spans { assert.Equal(t, "span-service", s.service) - assert.Equal(t, "global-service", s.meta.m["_dd.base_service"]) + v, _ := s.meta.Get("_dd.base_service") + assert.Equal(t, "global-service", v) } }) t.Run("span-service-equal-global-service", func(t *testing.T) { @@ -880,7 +887,7 @@ func TestSpanDDBaseService(t *testing.T) { spans := run(t, tracerOpts, spanOpts) for _, s := range spans { assert.Equal(t, "global-service", s.service) - assert.NotContains(t, s.meta.m, "_dd.base_service") + assert.False(t, s.meta.Has("_dd.base_service")) } }) t.Run("span-service-equal-different-case", func(t *testing.T) { @@ -893,7 +900,7 @@ func TestSpanDDBaseService(t *testing.T) { spans := run(t, tracerOpts, spanOpts) for _, s := range spans { assert.Equal(t, "GLOBAL-service", s.service) - assert.NotContains(t, s.meta.m, "_dd.base_service") + assert.False(t, s.meta.Has("_dd.base_service")) } }) t.Run("global-service-not-set", func(t *testing.T) { @@ -905,7 +912,8 @@ func TestSpanDDBaseService(t *testing.T) { assert.Equal(t, "span-service", s.service) // in this case we don't assert to a concrete value because the default tracer service name is calculated // based on the process name and might change depending on how tests are run. - assert.NotEmpty(t, s.meta.m["_dd.base_service"]) + v, _ := s.meta.Get("_dd.base_service") + assert.NotEmpty(t, v) } }) t.Run("using-tag-option", func(t *testing.T) { @@ -918,7 +926,8 @@ func TestSpanDDBaseService(t *testing.T) { spans := run(t, tracerOpts, spanOpts) for _, s := range spans { assert.Equal(t, "span-service", s.service) - assert.Equal(t, "global-service", s.meta.m["_dd.base_service"]) + v, _ := s.meta.Get("_dd.base_service") + assert.Equal(t, "global-service", v) } }) } @@ -1247,13 +1256,14 @@ func TestSpanProcessTags(t *testing.T) { root := traces[0][0] assert.Equal(t, "p", root.name) if tc.enabled { - assert.NotEmpty(t, root.meta.m["_dd.tags.process"]) + v, _ := root.meta.Get("_dd.tags.process") + assert.NotEmpty(t, v) } else { - assert.NotContains(t, root.meta.m, "_dd.tags.process") + assert.False(t, root.meta.Has("_dd.tags.process")) } for _, s := range traces[0][1:] { - assert.NotContains(t, s.meta.m, "_dd.tags.process") + assert.False(t, s.meta.Has("_dd.tags.process")) } }) } diff --git a/ddtrace/tracer/srv_src_test.go b/ddtrace/tracer/srv_src_test.go index b31139fcf61..75e188e727d 100644 --- a/ddtrace/tracer/srv_src_test.go +++ b/ddtrace/tracer/srv_src_test.go @@ -82,7 +82,8 @@ func TestServiceSource(t *testing.T) { child.mu.RLock() defer child.mu.RUnlock() - assert.Equal(t, "m", child.meta.m[ext.KeyServiceSource]) + v, _ := child.meta.Get(ext.KeyServiceSource) + assert.Equal(t, "m", v) }) t.Run("NoExplicitServiceNoSrvSrc", func(t *testing.T) { @@ -96,7 +97,7 @@ func TestServiceSource(t *testing.T) { span.mu.RLock() defer span.mu.RUnlock() - _, hasSrvSrc := span.meta.m[ext.KeyServiceSource] + _, hasSrvSrc := span.meta.Get(ext.KeyServiceSource) assert.False(t, hasSrvSrc, "_dd.svc_src should not be set when no service is explicitly set") }) @@ -113,7 +114,8 @@ func TestServiceSource(t *testing.T) { child.mu.RLock() defer child.mu.RUnlock() - assert.Equal(t, "m", child.meta.m[ext.KeyServiceSource]) + v, _ := child.meta.Get(ext.KeyServiceSource) + assert.Equal(t, "m", v) }) t.Run("ServiceMappingSetsSrvSrc", func(t *testing.T) { @@ -128,7 +130,8 @@ func TestServiceSource(t *testing.T) { span.mu.RLock() defer span.mu.RUnlock() assert.Equal(t, "remapped", span.service) - assert.Equal(t, ext.ServiceSourceMapping, span.meta.m[ext.KeyServiceSource]) + v, _ := span.meta.Get(ext.KeyServiceSource) + assert.Equal(t, ext.ServiceSourceMapping, v) }) t.Run("SetMetaInitServiceName", func(t *testing.T) { diff --git a/ddtrace/tracer/stats.go b/ddtrace/tracer/stats.go index ec0310000fe..a720f6cc00d 100644 --- a/ddtrace/tracer/stats.go +++ b/ddtrace/tracer/stats.go @@ -15,7 +15,6 @@ import ( "github.com/DataDog/datadog-agent/pkg/trace/stats" "github.com/DataDog/dd-trace-go/v2/ddtrace/ext" - tinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" "github.com/DataDog/dd-trace-go/v2/internal" "github.com/DataDog/dd-trace-go/v2/internal/civisibility/constants" "github.com/DataDog/dd-trace-go/v2/internal/civisibility/utils" @@ -168,20 +167,9 @@ func (c *concentrator) newTracerStatSpan(s *Span, obfuscator *obfuscate.Obfuscat resource = obfuscatedResource(obfuscator, s.spanType, s.resource) } - httpMethod := s.meta.m[ext.HTTPMethod] - httpEndpoint := s.meta.m[ext.HTTPEndpoint] - - // The stats concentrator reads span.kind from the Meta map. Since promoted - // fields no longer live in meta.m, inject span.kind into a shallow copy. - meta := s.meta.m - if sk := s.meta.attrs.Val(tinternal.AttrSpanKind); sk != "" { - if meta == nil { - meta = map[string]string{ext.SpanKind: sk} - } else { - meta = maps.Clone(s.meta.m) - meta[ext.SpanKind] = sk - } - } + meta := maps.Collect(s.meta.All()) + httpMethod, _ := meta[ext.HTTPMethod] + httpEndpoint, _ := meta[ext.HTTPEndpoint] statSpan, ok := c.spanConcentrator.NewStatSpanWithConfig(stats.StatSpanConfig{ Service: s.service, @@ -201,7 +189,7 @@ func (c *concentrator) newTracerStatSpan(s *Span, obfuscator *obfuscate.Obfuscat if !ok { return nil, false } - origin := s.meta.m[keyOrigin] + origin, _ := s.meta.Get(keyOrigin) return &tracerStatSpan{ statSpan: statSpan, origin: origin, diff --git a/ddtrace/tracer/textmap_test.go b/ddtrace/tracer/textmap_test.go index 67f43587dd0..fe3ba43f435 100644 --- a/ddtrace/tracer/textmap_test.go +++ b/ddtrace/tracer/textmap_test.go @@ -1669,7 +1669,8 @@ func TestEnvVars(t *testing.T) { checkSameElements(assert, tc.outHeaders[tracestateHeader], headers[tracestateHeader]) // NOTE: this will be set for phase 3 - assert.Empty(root.meta.m["_dd.parent_id"], "extraction happened from DD headers, so _dd.parent_id mustn't be set") + v, _ := root.meta.Get("_dd.parent_id") + assert.Empty(v, "extraction happened from DD headers, so _dd.parent_id mustn't be set") ddTag := strings.SplitN(headers[tracestateHeader], ",", 2)[0] // -3 as we don't count dd= as part of the "value" length limit @@ -1814,9 +1815,11 @@ func TestEnvVars(t *testing.T) { } if tc.lastParent == "" { - assert.Empty(s.meta.m["_dd.parent_id"]) + v, _ := s.meta.Get("_dd.parent_id") + assert.Empty(v) } else { - assert.Equal(s.meta.m["_dd.parent_id"], tc.lastParent) + v, _ := s.meta.Get("_dd.parent_id") + assert.Equal(v, tc.lastParent) } headers := TextMapCarrier(map[string]string{}) @@ -2516,7 +2519,7 @@ func TestMalformedTID(t *testing.T) { assert.Nil(err) root := tracer.StartSpan("web.request", ChildOf(sctx)) root.Finish() - assert.NotContains(root.meta.m, keyTraceID128) + assert.False(root.meta.Has(keyTraceID128)) }) t.Run("datadog, malformed tid", func(_ *testing.T) { @@ -2529,7 +2532,7 @@ func TestMalformedTID(t *testing.T) { assert.Nil(err) root := tracer.StartSpan("web.request", ChildOf(sctx)) root.Finish() - assert.NotContains(root.meta.m, keyTraceID128) + assert.False(root.meta.Has(keyTraceID128)) }) t.Run("datadog, valid tid", func(_ *testing.T) { @@ -2542,7 +2545,8 @@ func TestMalformedTID(t *testing.T) { assert.Nil(err) root := tracer.StartSpan("web.request", ChildOf(sctx)) root.Finish() - assert.Equal("640cfd8d00000000", root.meta.m[keyTraceID128]) + v, _ := root.meta.Get(keyTraceID128) + assert.Equal("640cfd8d00000000", v) }) } diff --git a/ddtrace/tracer/tracer.go b/ddtrace/tracer/tracer.go index 32adb6c1742..9ac2262be5b 100644 --- a/ddtrace/tracer/tracer.go +++ b/ddtrace/tracer/tracer.go @@ -890,8 +890,8 @@ func (t *tracer) StartSpan(operationName string, options ...StartSpanOption) *Sp // For non-universal version, promote main-service spans to the version-inclusive // shared attrs before applying any tags. This makes the subsequent version write // (from config or global tags) a COW no-op instead of triggering a Clone. - if !t.config.universalVersion && span.service == t.config.internalConfig.ServiceName() && span.meta.attrs == &t.sharedAttrs { - span.meta.attrs = &t.sharedAttrsForMainSvc + if !t.config.universalVersion && span.service == t.config.internalConfig.ServiceName() { + span.meta.ReplaceSharedAttrs(&t.sharedAttrs, &t.sharedAttrsForMainSvc) } cfg := t.config.internalConfig diff --git a/ddtrace/tracer/tracer_test.go b/ddtrace/tracer/tracer_test.go index e45e90015b0..5551d5684e1 100644 --- a/ddtrace/tracer/tracer_test.go +++ b/ddtrace/tracer/tracer_test.go @@ -32,7 +32,6 @@ import ( "github.com/DataDog/dd-trace-go/v2/ddtrace" "github.com/DataDog/dd-trace-go/v2/ddtrace/ext" "github.com/DataDog/dd-trace-go/v2/ddtrace/internal/tracerstats" - tinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" "github.com/DataDog/dd-trace-go/v2/internal" internalconfig "github.com/DataDog/dd-trace-go/v2/internal/config" "github.com/DataDog/dd-trace-go/v2/internal/globalconfig" @@ -325,10 +324,11 @@ func TestTracerStartSpan(t *testing.T) { }, span.metrics[keySamplingPriority]) assert.Equal("-1", span.context.trace.propagatingTags[keyDecisionMaker]) // A span is not measured unless made so specifically - _, ok := span.meta.m[keyMeasured] + _, ok := span.meta.Get(keyMeasured) assert.False(ok) - assert.Equal(globalconfig.RuntimeID(), span.meta.m[ext.RuntimeID]) - assert.NotEqual("", span.meta.m[ext.RuntimeID]) + v, _ := span.meta.Get(ext.RuntimeID) + assert.Equal(globalconfig.RuntimeID(), v) + assert.NotEqual("", v) }) t.Run("priority", func(t *testing.T) { @@ -819,7 +819,8 @@ func TestTracerStartSpanOptions128(t *testing.T) { assert.Equal(uint64(987654), s.spanID) assert.Equal(uint64(987654), s.traceID) id := id128FromSpan(assert, s.Context()) - assert.Empty(s.meta.m[keyTraceID128]) + v, _ := s.meta.Get(keyTraceID128) + assert.Empty(v) idBytes, err := hex.DecodeString(id) assert.NoError(err) assert.Equal(uint64(0), binary.BigEndian.Uint64(idBytes[:8])) // high 64 bits should be 0 @@ -841,7 +842,8 @@ func TestTracerStartSpanOptions128(t *testing.T) { // 0001e240 (123456) + 00000000 (zeros) + 00000000000f1206 (987654) assert.Equal("0001e2400000000000000000000f1206", id) s.Finish() - assert.Equal(id[:16], s.meta.m[keyTraceID128]) + v, _ := s.meta.Get(keyTraceID128) + assert.Equal(id[:16], v) }) } @@ -917,11 +919,13 @@ func TestStartSpanOrigin(t *testing.T) { // first child contains tag child := tracer.StartSpan("child", ChildOf(ctx)) - assert.Equal("synthetics", child.meta.m[keyOrigin]) + v, _ := child.meta.Get(keyOrigin) + assert.Equal("synthetics", v) // secondary child doesn't child2 := tracer.StartSpan("child2", ChildOf(child.Context())) - assert.Empty(child2.meta.m[keyOrigin]) + v, _ = child2.meta.Get(keyOrigin) + assert.Empty(v) // but injecting its context marks origin carrier2 := TextMapCarrier(map[string]string{}) @@ -1143,7 +1147,8 @@ func TestTracerSpanTags(t *testing.T) { tag := Tag("key", "value") span := tracer.StartSpan("web.request", tag) assert := assert.New(t) - assert.Equal("value", span.meta.m["key"]) + v, _ := span.meta.Get("key") + assert.Equal("value", v) } func TestTracerSpanGlobalTags(t *testing.T) { @@ -1152,9 +1157,11 @@ func TestTracerSpanGlobalTags(t *testing.T) { defer tracer.Stop() assert.Nil(err) s := tracer.StartSpan("web.request") - assert.Equal("value", s.meta.m["key"]) + v, _ := s.meta.Get("key") + assert.Equal("value", v) child := tracer.StartSpan("db.query", ChildOf(s.Context())) - assert.Equal("value", child.meta.m["key"]) + v, _ = child.meta.Get("key") + assert.Equal("value", v) } func TestTracerSpanServiceMappings(t *testing.T) { @@ -1213,7 +1220,8 @@ func TestTracerNoDebugStack(t *testing.T) { s := tracer.StartSpan("web.request") err = errors.New("test error") s.Finish(WithError(err)) - assert.Empty(t, s.meta.m[ext.ErrorStack]) + v, _ := s.meta.Get(ext.ErrorStack) + assert.Empty(t, v) }) t.Run("SetTag", func(t *testing.T) { @@ -1223,7 +1231,8 @@ func TestTracerNoDebugStack(t *testing.T) { s := tracer.StartSpan("web.request") err = errors.New("error value with no trace") s.SetTag(ext.Error, err) - assert.Empty(t, s.meta.m[ext.ErrorStack]) + v, _ := s.meta.Get(ext.ErrorStack) + assert.Empty(t, v) }) } @@ -1279,11 +1288,15 @@ func testNewSpanChild(t *testing.T, is128 bool) { parent.Finish() child.Finish() if is128 { - assert.Equal(id[:16], parent.meta.m[keyTraceID128]) - assert.Empty(child.meta.m[keyTraceID128]) + v, _ := parent.meta.Get(keyTraceID128) + assert.Equal(id[:16], v) + v, _ = child.meta.Get(keyTraceID128) + assert.Empty(v) } else { - assert.Empty(child.meta.m[keyTraceID128]) - assert.Empty(parent.meta.m[keyTraceID128]) + v, _ := child.meta.Get(keyTraceID128) + assert.Empty(v) + v, _ = parent.meta.Get(keyTraceID128) + assert.Empty(v) } }) } @@ -1308,7 +1321,8 @@ func TestNewChildHasNoPid(t *testing.T) { root := tracer.newRootSpan("pylons.request", "pylons", "/") child := tracer.newChildSpan("redis.command", root) - assert.Equal("", child.meta.m[ext.Pid]) + v, _ := child.meta.Get(ext.Pid) + assert.Empty(v) } func TestTracerSampler(t *testing.T) { @@ -1796,8 +1810,7 @@ func TestPushPayload(t *testing.T) { defer stop() s := newBasicSpan("3MB") - s.meta.m["key"] = strings.Repeat("X", payloadSizeLimit/2+10) - + s.meta.Set("key", strings.Repeat("X", payloadSizeLimit/2+10)) // half payload size reached tracer.pushChunk(&chunk{[]*Span{s}, true}) tracer.awaitPayload(t, 1) @@ -1907,11 +1920,11 @@ func TestTracerReportsHostname(t *testing.T) { assert := assert.New(t) - name, ok := root.meta.m[keyHostname] + name, ok := root.meta.Get(keyHostname) assert.True(ok) assert.Equal(name, tracer.config.internalConfig.Hostname()) - name, ok = child.meta.m[keyHostname] + name, ok = child.meta.Get(keyHostname) assert.True(ok) assert.Equal(name, tracer.config.internalConfig.Hostname()) }) @@ -1933,9 +1946,9 @@ func TestTracerReportsHostname(t *testing.T) { assert := assert.New(t) - _, ok := root.meta.m[keyHostname] + _, ok := root.meta.Get(keyHostname) assert.False(ok) - _, ok = child.meta.m[keyHostname] + _, ok = child.meta.Get(keyHostname) assert.False(ok) }) } @@ -1954,11 +1967,11 @@ func TestTracerReportsHostname(t *testing.T) { assert := assert.New(t) - got, ok := root.meta.m[keyHostname] + got, ok := root.meta.Get(keyHostname) assert.True(ok) assert.Equal(got, hostname) - got, ok = child.meta.m[keyHostname] + got, ok = child.meta.Get(keyHostname) assert.True(ok) assert.Equal(got, hostname) }) @@ -1977,11 +1990,11 @@ func TestTracerReportsHostname(t *testing.T) { assert := assert.New(t) - got, ok := root.meta.m[keyHostname] + got, ok := root.meta.Get(keyHostname) assert.True(ok) assert.Equal(got, hostname) - got, ok = child.meta.m[keyHostname] + got, ok = child.meta.Get(keyHostname) assert.True(ok) assert.Equal(got, hostname) }) @@ -1998,9 +2011,9 @@ func TestTracerReportsHostname(t *testing.T) { assert := assert.New(t) - _, ok := root.meta.m[keyHostname] + _, ok := root.meta.Get(keyHostname) assert.False(ok) - _, ok = child.meta.m[keyHostname] + _, ok = child.meta.Get(keyHostname) assert.False(ok) }) } @@ -2013,7 +2026,8 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request") - assert.Equal("4.5.6", sp.meta.attrs.Val(tinternal.AttrVersion)) + v, _ := sp.meta.Get(ext.Version) + assert.Equal("4.5.6", v) }) t.Run("service", func(t *testing.T) { tracer, _, _, stop, err := startTestTracer(t, WithServiceVersion("4.5.6"), @@ -2023,7 +2037,7 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request", ServiceName("otherservenv")) - v, ok := sp.meta.attrs.Get(tinternal.AttrVersion) + v, ok := sp.meta.Get(ext.Version) assert.Equal("", v) assert.False(ok) }) @@ -2034,7 +2048,8 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request", ServiceName("otherservenv")) - assert.Equal("4.5.6", sp.meta.attrs.Val(tinternal.AttrVersion)) + v, _ := sp.meta.Get(ext.Version) + assert.Equal("4.5.6", v) }) t.Run("service/universal", func(t *testing.T) { tracer, _, _, stop, err := startTestTracer(t, WithServiceVersion("4.5.6"), @@ -2044,7 +2059,8 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request", ServiceName("otherservenv")) - assert.Equal("1.2.3", sp.meta.attrs.Val(tinternal.AttrVersion)) + v, _ := sp.meta.Get(ext.Version) + assert.Equal("1.2.3", v) }) t.Run("universal/service", func(t *testing.T) { tracer, _, _, stop, err := startTestTracer(t, WithUniversalVersion("1.2.3"), @@ -2054,7 +2070,7 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request", ServiceName("otherservenv")) - v, ok := sp.meta.attrs.Get(tinternal.AttrVersion) + v, ok := sp.meta.Get(ext.Version) assert.Equal("", v) assert.False(ok) }) @@ -2068,7 +2084,8 @@ func TestEnvironment(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request") - assert.Equal("test", sp.meta.attrs.Val(tinternal.AttrEnv)) + v, _ := sp.meta.Get(ext.Environment) + assert.Equal("test", v) }) t.Run("unset", func(t *testing.T) { @@ -2078,7 +2095,7 @@ func TestEnvironment(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request") - v, ok := sp.meta.attrs.Get(tinternal.AttrEnv) + v, ok := sp.meta.Get(ext.Environment) assert.Equal("", v) assert.False(ok) }) @@ -2097,9 +2114,12 @@ func TestGitMetadata(t *testing.T) { sp := tracer.StartSpan("http.request") sp.Finish() - assert.Equal("123456789ABCD", sp.meta.m[internal.TraceTagCommitSha]) - assert.Equal("github.com/user/repo", sp.meta.m[internal.TraceTagRepositoryURL]) - assert.Equal("somepath", sp.meta.m[internal.TraceTagGoPath]) + v, _ := sp.meta.Get(internal.TraceTagCommitSha) + assert.Equal("123456789ABCD", v) + v, _ = sp.meta.Get(internal.TraceTagRepositoryURL) + assert.Equal("github.com/user/repo", v) + v, _ = sp.meta.Get(internal.TraceTagGoPath) + assert.Equal("somepath", v) }) t.Run("git-metadata-from-dd-tags-with-credentials", func(t *testing.T) { @@ -2114,9 +2134,12 @@ func TestGitMetadata(t *testing.T) { sp := tracer.StartSpan("http.request") sp.Finish() - assert.Equal("123456789ABCD", sp.meta.m[internal.TraceTagCommitSha]) - assert.Equal("https://github.com/user/repo", sp.meta.m[internal.TraceTagRepositoryURL]) - assert.Equal("somepath", sp.meta.m[internal.TraceTagGoPath]) + v, _ := sp.meta.Get(internal.TraceTagCommitSha) + assert.Equal("123456789ABCD", v) + v, _ = sp.meta.Get(internal.TraceTagRepositoryURL) + assert.Equal("https://github.com/user/repo", v) + v, _ = sp.meta.Get(internal.TraceTagGoPath) + assert.Equal("somepath", v) }) t.Run("git-metadata-from-env", func(t *testing.T) { @@ -2135,8 +2158,10 @@ func TestGitMetadata(t *testing.T) { sp := tracer.StartSpan("http.request") sp.Finish() - assert.Equal("123456789ABCDE", sp.meta.m[internal.TraceTagCommitSha]) - assert.Equal("github.com/user/repo_new", sp.meta.m[internal.TraceTagRepositoryURL]) + v, _ := sp.meta.Get(internal.TraceTagCommitSha) + assert.Equal("123456789ABCDE", v) + v, _ = sp.meta.Get(internal.TraceTagRepositoryURL) + assert.Equal("github.com/user/repo_new", v) }) t.Run("git-metadata-from-env-with-credentials", func(t *testing.T) { @@ -2152,8 +2177,10 @@ func TestGitMetadata(t *testing.T) { sp := tracer.StartSpan("http.request") sp.Finish() - assert.Equal("123456789ABCDE", sp.meta.m[internal.TraceTagCommitSha]) - assert.Equal("https://github.com/user/repo_new", sp.meta.m[internal.TraceTagRepositoryURL]) + v, _ := sp.meta.Get(internal.TraceTagCommitSha) + assert.Equal("123456789ABCDE", v) + v, _ = sp.meta.Get(internal.TraceTagRepositoryURL) + assert.Equal("https://github.com/user/repo_new", v) }) t.Run("git-metadata-from-env-and-tags", func(t *testing.T) { @@ -2169,8 +2196,10 @@ func TestGitMetadata(t *testing.T) { sp := tracer.StartSpan("http.request") sp.Finish() - assert.Equal("123456789ABCD", sp.meta.m[internal.TraceTagCommitSha]) - assert.Equal("github.com/user/repo", sp.meta.m[internal.TraceTagRepositoryURL]) + v, _ := sp.meta.Get(internal.TraceTagCommitSha) + assert.Equal("123456789ABCD", v) + v, _ = sp.meta.Get(internal.TraceTagRepositoryURL) + assert.Equal("github.com/user/repo", v) }) t.Run("git-metadata-disabled", func(t *testing.T) { @@ -2189,8 +2218,10 @@ func TestGitMetadata(t *testing.T) { sp := tracer.StartSpan("http.request") sp.Finish() - assert.Equal("", sp.meta.m[internal.TraceTagCommitSha]) - assert.Equal("", sp.meta.m[internal.TraceTagRepositoryURL]) + v, _ := sp.meta.Get(internal.TraceTagCommitSha) + assert.Empty(v) + v, _ = sp.meta.Get(internal.TraceTagRepositoryURL) + assert.Empty(v) }) } @@ -2434,12 +2465,7 @@ func cpspan(s *Span) *Span { if len(s.metrics) == 0 { s.metrics = nil } - if len(s.meta.m) == 0 { - s.meta.m = nil - } - if s.meta.attrs != nil && s.meta.attrs.Count() == 0 { - s.meta.attrs = nil - } + s.meta.Normalize() return &Span{ name: s.name, service: s.service, @@ -2609,7 +2635,8 @@ func TestUserMonitoring(t *testing.T) { WithUserRole(role), WithUserSessionID(sessionID)) s.Finish() for _, pair := range expected { - assert.Equal(t, pair.value, s.meta.m[pair.key]) + v, _ := s.meta.Get(pair.key) + assert.Equal(t, pair.value, v) } }) @@ -2621,7 +2648,8 @@ func TestUserMonitoring(t *testing.T) { child.Finish() root.Finish() for _, pair := range expected { - assert.Equal(t, pair.value, root.meta.m[pair.key]) + v, _ := root.meta.Get(pair.key) + assert.Equal(t, pair.value, v) } }) @@ -2629,19 +2657,21 @@ func TestUserMonitoring(t *testing.T) { s := tr.newRootSpan("root", "test", "test") SetUser(s, id, WithPropagation()) s.Finish() - assert.Equal(t, id, s.meta.m[keyUserID]) + v, _ := s.meta.Get(keyUserID) + assert.Equal(t, id, v) encoded := base64.StdEncoding.EncodeToString([]byte(id)) assert.Equal(t, encoded, s.context.trace.propagatingTags[keyPropagatedUserID]) - assert.Equal(t, encoded, s.meta.m[keyPropagatedUserID]) + v, _ = s.meta.Get(keyPropagatedUserID) + assert.Equal(t, encoded, v) }) t.Run("no-propagation", func(t *testing.T) { s := tr.newRootSpan("root", "test", "test") SetUser(s, id) s.Finish() - _, ok := s.meta.m[keyUserID] + _, ok := s.meta.Get(keyUserID) assert.True(t, ok) - _, ok = s.meta.m[keyPropagatedUserID] + _, ok = s.meta.Get(keyPropagatedUserID) assert.False(t, ok) _, ok = s.context.trace.propagatingTags[keyPropagatedUserID] assert.False(t, ok) @@ -2776,9 +2806,11 @@ func TestExecutionTraceSpanTagged(t *testing.T) { untracedSpan := tracer.StartSpan("untraced") untracedSpan.Finish() - assert.Equal(t, tracedSpan.meta.m["go_execution_traced"], "yes") - assert.Equal(t, partialSpan.meta.m["go_execution_traced"], "partial") - assert.NotContains(t, untracedSpan.meta.m, "go_execution_traced") + v, _ := tracedSpan.meta.Get("go_execution_traced") + assert.Equal(t, v, "yes") + v, _ = partialSpan.meta.Get("go_execution_traced") + assert.Equal(t, v, "partial") + assert.False(t, untracedSpan.meta.Has("go_execution_traced")) } func wasteA(d time.Duration) { diff --git a/ddtrace/tracer/transport_bench_test.go b/ddtrace/tracer/transport_bench_test.go index ee9f2d27050..3ce9b4c487a 100644 --- a/ddtrace/tracer/transport_bench_test.go +++ b/ddtrace/tracer/transport_bench_test.go @@ -43,7 +43,7 @@ func BenchmarkHTTPTransportSend(b *testing.B) { spans := make([]*Span, size.numSpans) for i := 0; i < size.numSpans; i++ { span := newBasicSpan("transport-test") - span.meta.m["data"] = strings.Repeat("x", size.spanSize*1024) + span.meta.Set("data", strings.Repeat("x", size.spanSize*1024)) spans[i] = span } _, _ = payload.push(spans) diff --git a/ddtrace/tracer/writer.go b/ddtrace/tracer/writer.go index 9397e748860..ab1086fe63f 100644 --- a/ddtrace/tracer/writer.go +++ b/ddtrace/tracer/writer.go @@ -245,26 +245,16 @@ func (h *logTraceWriter) encodeSpan(s *Span) { h.buf.Write(strconv.AppendInt(scratch[:0], int64(s.error), 10)) h.buf.WriteString(`,"meta":{`) first := true - for k, v := range s.meta.m { + for k, v := range s.meta.All() { if first { first = false } else { - h.buf.WriteString(`,`) + h.buf.WriteString(",") } h.marshalString(k) h.buf.WriteString(":") h.marshalString(v) } - s.meta.attrs.ForEach(func(name, val string) { - if first { - first = false - } else { - h.buf.WriteString(",") - } - h.marshalString(name) - h.buf.WriteString(":") - h.marshalString(val) - }) // We cannot pack messagepack into JSON, so we need to marshal the meta struct as JSON, and send them through the `meta` field for k, v := range s.metaStruct { if first { diff --git a/ddtrace/tracer/writer_test.go b/ddtrace/tracer/writer_test.go index e02f97ed852..8d9175cc0ad 100644 --- a/ddtrace/tracer/writer_test.go +++ b/ddtrace/tracer/writer_test.go @@ -42,7 +42,7 @@ func makeSpan(n int) *Span { s := newSpan("encodeName", "encodeService", "encodeResource", randUint64(), randUint64(), randUint64()) for i := range n { istr := fmt.Sprintf("%0.10d", i) - s.meta.m[istr] = istr + s.meta.Set(istr, istr) s.metrics[istr] = float64(i) } return s @@ -247,7 +247,7 @@ func TestLogWriter(t *testing.T) { assert := assert.New(t) s := newSpan("name\n", "srv\t", `"res"`, 2, 1, 3) s.start = 12 - s.meta.m["query\n"] = "Select * from \n Where\nvalue" + s.meta.Set("query\n", "Select * from \n Where\nvalue") s.metrics["version\n"] = 3 var w logTraceWriter From 2106eee8ce552c929c6095fc474e087ee44c127f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Wed, 18 Mar 2026 17:27:15 +0000 Subject: [PATCH 26/71] refactor(ddtrace/tracer): move spanMeta to ddtrace/tracer/internal --- ddtrace/tracer/internal/span_attributes.go | 25 +++--- .../tracer/internal/span_attributes_test.go | 10 +-- ddtrace/tracer/{ => internal}/span_meta.go | 80 ++++++++++--------- ddtrace/tracer/sampler_test.go | 10 +-- ddtrace/tracer/span.go | 3 +- ddtrace/tracer/span_test.go | 3 +- ddtrace/tracer/spancontext.go | 3 +- ddtrace/tracer/stats_test.go | 5 +- ddtrace/tracer/tracer.go | 2 +- ddtrace/tracer/transport_test.go | 3 +- ddtrace/tracer/writer_test.go | 10 ++- 11 files changed, 88 insertions(+), 66 deletions(-) rename ddtrace/tracer/{ => internal}/span_meta.go (75%) diff --git a/ddtrace/tracer/internal/span_attributes.go b/ddtrace/tracer/internal/span_attributes.go index 6ee44990ae3..6456f6a0028 100644 --- a/ddtrace/tracer/internal/span_attributes.go +++ b/ddtrace/tracer/internal/span_attributes.go @@ -5,7 +5,10 @@ package internal -import "math/bits" +import ( + "iter" + "math/bits" +) // AttrKey is an integer index into a SpanAttributes value array. // Use the pre-declared constants; do not construct AttrKey from arbitrary integers. @@ -122,14 +125,18 @@ var Defs = [numAttrs]AttrDef{ {AttrSpanKind, "span.kind"}, } -// ForEach calls fn for each attribute that has been set. -func (a *SpanAttributes) ForEach(fn func(name, val string)) { - if a == nil { - return - } - for _, d := range Defs { - if a.setMask>>d.Key&1 != 0 { - fn(d.Name, a.vals[d.Key]) +// All returns an iterator over the set attributes (name, value) pairs. +func (a *SpanAttributes) All() iter.Seq2[string, string] { + return func(yield func(string, string) bool) { + if a == nil { + return + } + for _, d := range Defs { + if a.setMask>>d.Key&1 != 0 { + if !yield(d.Name, a.vals[d.Key]) { + return + } + } } } } diff --git a/ddtrace/tracer/internal/span_attributes_test.go b/ddtrace/tracer/internal/span_attributes_test.go index ae9a29bafe7..1f1dd44197b 100644 --- a/ddtrace/tracer/internal/span_attributes_test.go +++ b/ddtrace/tracer/internal/span_attributes_test.go @@ -6,6 +6,7 @@ package internal import ( + "maps" "testing" ) @@ -98,10 +99,7 @@ func TestSpanAttributesForEach(t *testing.T) { a.Set(AttrSpanKind, "server") // AttrVersion and AttrComponent are NOT set - got := make(map[string]string) - a.ForEach(func(name, val string) { - got[name] = val - }) + got := maps.Collect(a.All()) if len(got) != 2 { t.Fatalf("expected 2 entries, got %d: %v", len(got), got) } @@ -116,7 +114,9 @@ func TestSpanAttributesForEach(t *testing.T) { func TestSpanAttributesForEachNil(t *testing.T) { var a *SpanAttributes called := false - a.ForEach(func(_, _ string) { called = true }) + for range a.All() { + called = true + } if called { t.Error("ForEach should not call fn on nil receiver") } diff --git a/ddtrace/tracer/span_meta.go b/ddtrace/tracer/internal/span_meta.go similarity index 75% rename from ddtrace/tracer/span_meta.go rename to ddtrace/tracer/internal/span_meta.go index 2aeb634773f..543e41d537b 100644 --- a/ddtrace/tracer/span_meta.go +++ b/ddtrace/tracer/internal/span_meta.go @@ -3,7 +3,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2025 Datadog, Inc. -package tracer +package internal import ( "fmt" @@ -11,43 +11,51 @@ import ( "strings" "github.com/tinylib/msgp/msgp" - - tinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" ) var ( - _ msgp.Encodable = (*spanMeta)(nil) - _ msgp.Decodable = (*spanMeta)(nil) - _ msgp.Sizer = (*spanMeta)(nil) + _ msgp.Encodable = (*SpanMeta)(nil) + _ msgp.Decodable = (*SpanMeta)(nil) + _ msgp.Sizer = (*SpanMeta)(nil) ) -// spanMeta replaces a plain map[string]string for the Span.meta field. +// SpanMeta replaces a plain map[string]string for the Span.meta field. // Promoted attributes (env, version, component, span.kind) live in attrs // and are excluded from the map m. The msgp codec merges both sources // transparently so the wire format is unchanged. -type spanMeta struct { +type SpanMeta struct { m map[string]string - attrs *tinternal.SpanAttributes + attrs *SpanAttributes +} + +// NewSpanMeta returns a SpanMeta initialized with shared attrs (used during span creation). +func NewSpanMeta(attrs *SpanAttributes) SpanMeta { + return SpanMeta{attrs: attrs} +} + +// NewSpanMetaFromMap returns a SpanMeta pre-loaded with a flat map. Intended for test helpers. +func NewSpanMetaFromMap(m map[string]string) SpanMeta { + return SpanMeta{m: m} } -// IsZero reports whether the spanMeta contains no entries (map or promoted). +// IsZero reports whether the SpanMeta contains no entries (map or promoted). // The msgp generator emits z.meta.IsZero() for the omitempty check. -func (sm spanMeta) IsZero() bool { +func (sm SpanMeta) IsZero() bool { return len(sm.m) == 0 && sm.attrs.Count() == 0 } // ReplaceSharedAttrs replaces the current attrs pointer with next if it // currently equals prev. Used by the tracer to upgrade a newly-created span // from the base shared attrs to the main-service shared attrs. -func (sm *spanMeta) ReplaceSharedAttrs(prev, next *tinternal.SpanAttributes) { +func (sm *SpanMeta) ReplaceSharedAttrs(prev, next *SpanAttributes) { if sm.attrs == prev { sm.attrs = next } } // Normalize sets m and attrs to nil when they are empty so that a zero-length -// spanMeta compares equal to a freshly-zeroed one. Intended for test helpers. -func (sm *spanMeta) Normalize() { +// SpanMeta compares equal to a freshly-zeroed one. Intended for test helpers. +func (sm *SpanMeta) Normalize() { if len(sm.m) == 0 { sm.m = nil } @@ -57,8 +65,8 @@ func (sm *spanMeta) Normalize() { } // Get returns the value for key, checking attrs for promoted keys and the flat map for others. -func (sm spanMeta) Get(key string) (string, bool) { - if ak, ok := tinternal.AttrKeyForTag(key); ok { +func (sm SpanMeta) Get(key string) (string, bool) { + if ak, ok := AttrKeyForTag(key); ok { return sm.attrs.Get(ak) } v, ok := sm.m[key] @@ -66,8 +74,8 @@ func (sm spanMeta) Get(key string) (string, bool) { } // Has reports whether key is present. Promoted keys check attrs; others check the flat map. -func (sm spanMeta) Has(key string) bool { - if ak, ok := tinternal.AttrKeyForTag(key); ok { +func (sm SpanMeta) Has(key string) bool { + if ak, ok := AttrKeyForTag(key); ok { _, ok := sm.attrs.Get(ak) return ok } @@ -77,8 +85,8 @@ func (sm spanMeta) Has(key string) bool { // Set sets key→value, routing promoted keys to attrs (with copy-on-write) and others to the flat map. // +checklocksignore — called both at init time (no lock) and under lock. -func (sm *spanMeta) Set(key, value string) { - if ak, ok := tinternal.AttrKeyForTag(key); ok { +func (sm *SpanMeta) Set(key, value string) { + if ak, ok := AttrKeyForTag(key); ok { // Check if whether setting key=v on attrs would be a no-op // no-op (i.e. the current value is already v, shared or local). if sm.attrs != nil && sm.attrs.Val(ak) == value { @@ -95,9 +103,9 @@ func (sm *spanMeta) Set(key, value string) { // ensureAttrsLocal guarantees attrs is a mutable, span-local instance. // If attrs is nil a fresh one is allocated; if shared, it is cloned. -func (sm *spanMeta) ensureAttrsLocal() { +func (sm *SpanMeta) ensureAttrsLocal() { if sm.attrs == nil { - sm.attrs = new(tinternal.SpanAttributes) + sm.attrs = new(SpanAttributes) return } if sm.attrs.IsShared() { @@ -109,7 +117,7 @@ func (sm *spanMeta) ensureAttrsLocal() { // Separated with //go:noinline to keep the hot path in Set small. // //go:noinline -func (sm *spanMeta) setAttrCOWSlow(key tinternal.AttrKey, v string) { +func (sm *SpanMeta) setAttrCOWSlow(key AttrKey, v string) { sm.ensureAttrsLocal() sm.attrs.Set(key, v) } @@ -117,8 +125,8 @@ func (sm *spanMeta) setAttrCOWSlow(key tinternal.AttrKey, v string) { // Delete removes key. For promoted keys, clears the attribute (with copy-on-write); for others, // removes from the flat map. No-op if the key is absent. // +checklocksignore — called both at init time (no lock) and under lock. -func (sm *spanMeta) Delete(key string) { - if ak, ok := tinternal.AttrKeyForTag(key); ok { +func (sm *SpanMeta) Delete(key string) { + if ak, ok := AttrKeyForTag(key); ok { if _, isSet := sm.attrs.Get(ak); !isSet { return // already absent, skip unnecessary COW } @@ -130,14 +138,14 @@ func (sm *spanMeta) Delete(key string) { } // Count returns the total number of entries (flat map + promoted attrs). -func (sm spanMeta) Count() int { +func (sm SpanMeta) Count() int { return len(sm.m) + sm.attrs.Count() } // All returns an iterator over all entries. Flat-map entries are yielded first // (in unspecified order), followed by promoted attributes in definition order // (env, version, component, span.kind). Returning false from yield stops iteration. -func (sm spanMeta) All() iter.Seq2[string, string] { +func (sm SpanMeta) All() iter.Seq2[string, string] { return func(yield func(string, string) bool) { for k, v := range sm.m { if !yield(k, v) { @@ -145,9 +153,9 @@ func (sm spanMeta) All() iter.Seq2[string, string] { } } if sm.attrs != nil { - for _, d := range tinternal.Defs { - if v, ok := sm.attrs.Get(d.Key); ok { - if !yield(d.Name, v) { + for _, d := range Defs { + if sm.attrs.setMask>>d.Key&1 != 0 { + if !yield(d.Name, sm.attrs.vals[d.Key]) { return } } @@ -157,7 +165,7 @@ func (sm spanMeta) All() iter.Seq2[string, string] { } // String returns a merged map representation (m + promoted attrs) for debug logging. -func (sm spanMeta) String() string { +func (sm SpanMeta) String() string { var b strings.Builder b.WriteString("map[") first := true @@ -174,7 +182,7 @@ func (sm spanMeta) String() string { // EncodeMsg writes the combined map header (m entries + promoted attrs), // then emits all map entries followed by promoted attribute entries. -func (sm *spanMeta) EncodeMsg(en *msgp.Writer) error { +func (sm *SpanMeta) EncodeMsg(en *msgp.Writer) error { total := uint32(len(sm.m) + sm.attrs.Count()) if err := en.WriteMapHeader(total); err != nil { return msgp.WrapError(err, "Meta") @@ -188,7 +196,7 @@ func (sm *spanMeta) EncodeMsg(en *msgp.Writer) error { } } if sm.attrs != nil { - for _, d := range tinternal.Defs { + for _, d := range Defs { if v, ok := sm.attrs.Get(d.Key); ok { if err := en.WriteString(d.Name); err != nil { return msgp.WrapError(err, "Meta") @@ -205,7 +213,7 @@ func (sm *spanMeta) EncodeMsg(en *msgp.Writer) error { // DecodeMsg reads a msgp map into m. All keys — including promoted ones — go // into the flat map so that no SpanAttributes allocation is needed on the // decode path. attrs is only populated on the encode (span-creation) path. -func (sm *spanMeta) DecodeMsg(dc *msgp.Reader) error { +func (sm *SpanMeta) DecodeMsg(dc *msgp.Reader) error { header, err := dc.ReadMapHeader() if err != nil { return msgp.WrapError(err, "Meta") @@ -231,13 +239,13 @@ func (sm *spanMeta) DecodeMsg(dc *msgp.Reader) error { } // Msgsize returns an upper bound estimate of the serialized size. -func (sm *spanMeta) Msgsize() int { +func (sm *SpanMeta) Msgsize() int { size := msgp.MapHeaderSize for k, v := range sm.m { size += msgp.StringPrefixSize + len(k) + msgp.StringPrefixSize + len(v) } if sm.attrs != nil { - for _, d := range tinternal.Defs { + for _, d := range Defs { if v, ok := sm.attrs.Get(d.Key); ok { size += msgp.StringPrefixSize + len(d.Name) + msgp.StringPrefixSize + len(v) } diff --git a/ddtrace/tracer/sampler_test.go b/ddtrace/tracer/sampler_test.go index 61082f4acc0..bc247cd3ed0 100644 --- a/ddtrace/tracer/sampler_test.go +++ b/ddtrace/tracer/sampler_test.go @@ -2370,7 +2370,7 @@ func TestPrioritySamplerRampCooldownNoReset(t *testing.T) { mkSpan := func(svc, env string) *Span { a := new(tinternal.SpanAttributes) a.Set(tinternal.AttrEnv, env) - return &Span{service: svc, meta: spanMeta{attrs: a}} + return &Span{service: svc, meta: tinternal.NewSpanMeta(a)} } // Set initial low rate. @@ -2416,7 +2416,7 @@ func TestPrioritySamplerRampUp(t *testing.T) { mkSpan := func(svc, env string) *Span { a := new(tinternal.SpanAttributes) a.Set(tinternal.AttrEnv, env) - return &Span{service: svc, meta: spanMeta{attrs: a}} + return &Span{service: svc, meta: tinternal.NewSpanMeta(a)} } // Set initial low rate (decrease from default 1.0, applied immediately). @@ -2459,7 +2459,7 @@ func TestPrioritySamplerRampDown(t *testing.T) { mkSpan := func(svc, env string) *Span { a := new(tinternal.SpanAttributes) a.Set(tinternal.AttrEnv, env) - return &Span{service: svc, meta: spanMeta{attrs: a}} + return &Span{service: svc, meta: tinternal.NewSpanMeta(a)} } // Set initial rate (decrease from default 1.0). @@ -2483,7 +2483,7 @@ func TestPrioritySamplerRampConverges(t *testing.T) { mkSpan := func(svc, env string) *Span { a := new(tinternal.SpanAttributes) a.Set(tinternal.AttrEnv, env) - return &Span{service: svc, meta: spanMeta{attrs: a}} + return &Span{service: svc, meta: tinternal.NewSpanMeta(a)} } // Start at 0.1, target 0.5. @@ -2510,7 +2510,7 @@ func TestPrioritySamplerRampDefaultRate(t *testing.T) { mkSpan := func(svc, env string) *Span { a := new(tinternal.SpanAttributes) a.Set(tinternal.AttrEnv, env) - return &Span{service: svc, meta: spanMeta{attrs: a}} + return &Span{service: svc, meta: tinternal.NewSpanMeta(a)} } // Set default rate to 0.1 (decrease from initial 1.0, applied immediately). diff --git a/ddtrace/tracer/span.go b/ddtrace/tracer/span.go index 5c842aa5b07..aeb24a49d58 100644 --- a/ddtrace/tracer/span.go +++ b/ddtrace/tracer/span.go @@ -25,6 +25,7 @@ import ( "time" "github.com/DataDog/dd-trace-go/v2/ddtrace/ext" + tinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" "github.com/DataDog/dd-trace-go/v2/instrumentation/errortrace" sharedinternal "github.com/DataDog/dd-trace-go/v2/internal" "github.com/DataDog/dd-trace-go/v2/internal/env" @@ -141,7 +142,7 @@ type Span struct { // meta holds string metadata. Promoted attributes (env, version, component, // span.kind) live in meta.attrs and are excluded from meta.m; the custom // msgp codec merges both for wire encoding. - meta spanMeta `msg:"meta,omitempty"` // arbitrary map of metadata + promoted attrs + meta tinternal.SpanMeta `msg:"meta,omitempty"` // arbitrary map of metadata + promoted attrs // +checklocks:mu metaStruct metaStructMap `msg:"meta_struct,omitempty"` // arbitrary map of metadata with structured values // +checklocks:mu diff --git a/ddtrace/tracer/span_test.go b/ddtrace/tracer/span_test.go index d70fc004e02..b868f9f334a 100644 --- a/ddtrace/tracer/span_test.go +++ b/ddtrace/tracer/span_test.go @@ -16,6 +16,7 @@ import ( "time" "github.com/DataDog/dd-trace-go/v2/ddtrace/ext" + tinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" "github.com/DataDog/dd-trace-go/v2/instrumentation/errortrace" sharedinternal "github.com/DataDog/dd-trace-go/v2/internal" "github.com/DataDog/dd-trace-go/v2/internal/log" @@ -39,7 +40,7 @@ func newSpan(name, service, resource string, spanID, traceID, parentID uint64) * name: name, service: service, resource: resource, - meta: spanMeta{m: map[string]string{}}, + meta: tinternal.NewSpanMetaFromMap(map[string]string{}), metrics: map[string]float64{}, spanID: spanID, traceID: traceID, diff --git a/ddtrace/tracer/spancontext.go b/ddtrace/tracer/spancontext.go index d07cccb5b46..95eacbc9843 100644 --- a/ddtrace/tracer/spancontext.go +++ b/ddtrace/tracer/spancontext.go @@ -17,6 +17,7 @@ import ( "github.com/DataDog/dd-trace-go/v2/ddtrace" "github.com/DataDog/dd-trace-go/v2/ddtrace/ext" "github.com/DataDog/dd-trace-go/v2/ddtrace/internal/tracerstats" + tinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" sharedinternal "github.com/DataDog/dd-trace-go/v2/internal" internalconfig "github.com/DataDog/dd-trace-go/v2/internal/config" "github.com/DataDog/dd-trace-go/v2/internal/locking" @@ -906,7 +907,7 @@ The mapping is as follows: - s3: .s3..amazonaws.com (if Bucket param present) s3..amazonaws.com (otherwise) */ -func deriveAWSPeerService(sm spanMeta) string { +func deriveAWSPeerService(sm tinternal.SpanMeta) string { service, ok := sm.Get(ext.AWSService) if !ok { return "" diff --git a/ddtrace/tracer/stats_test.go b/ddtrace/tracer/stats_test.go index 5a5f807066d..8d1f7c718a9 100644 --- a/ddtrace/tracer/stats_test.go +++ b/ddtrace/tracer/stats_test.go @@ -22,6 +22,7 @@ import ( "github.com/DataDog/datadog-go/v5/statsd" "github.com/DataDog/dd-trace-go/v2/ddtrace/ext" + tinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" "github.com/DataDog/dd-trace-go/v2/internal/civisibility/constants" "github.com/DataDog/dd-trace-go/v2/internal/civisibility/utils" internalconfig "github.com/DataDog/dd-trace-go/v2/internal/config" @@ -326,10 +327,10 @@ func TestStatsIncludeHTTPMethodAndEndpoint(t *testing.T) { start: time.Now().UnixNano(), duration: int64(time.Millisecond), metrics: map[string]float64{keyMeasured: 1}, - meta: spanMeta{m: map[string]string{ + meta: tinternal.NewSpanMetaFromMap(map[string]string{ ext.HTTPMethod: uniqueMethod, ext.HTTPEndpoint: uniqueEndpoint, - }}, + }), } transport := newDummyTransport() c := newConcentrator(newTestConfigWithTransport(t, transport), bucketSize, &statsd.NoOpClientDirect{}) diff --git a/ddtrace/tracer/tracer.go b/ddtrace/tracer/tracer.go index 9ac2262be5b..dd970d5baff 100644 --- a/ddtrace/tracer/tracer.go +++ b/ddtrace/tracer/tracer.go @@ -833,7 +833,7 @@ func spanStart(operationName string, sharedAttrs *tinternal.SpanAttributes, opti traceID: id, start: startTime, integration: "manual", - meta: spanMeta{attrs: sharedAttrs}, // COW: shared until a per-span field is set + meta: tinternal.NewSpanMeta(sharedAttrs), // COW: shared until a per-span field is set } span.spanLinks = append(span.spanLinks, opts.SpanLinks...) diff --git a/ddtrace/tracer/transport_test.go b/ddtrace/tracer/transport_test.go index 5232d8ee8b2..5ae61ec1687 100644 --- a/ddtrace/tracer/transport_test.go +++ b/ddtrace/tracer/transport_test.go @@ -23,6 +23,7 @@ import ( pb "github.com/DataDog/datadog-agent/pkg/proto/pbgo/trace" + tinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" "github.com/DataDog/dd-trace-go/v2/internal" internalconfig "github.com/DataDog/dd-trace-go/v2/internal/config" "github.com/DataDog/dd-trace-go/v2/internal/statsdtest" @@ -43,7 +44,7 @@ func getTestSpan() *Span { resource: "SEND /data", start: 1481215590883401105, duration: 1000000000, - meta: spanMeta{m: map[string]string{"http.host": "192.168.0.1"}}, + meta: tinternal.NewSpanMetaFromMap(map[string]string{"http.host": "192.168.0.1"}), metaStruct: map[string]any{"_dd.appsec.json": map[string]any{"triggers": []any{map[string]any{"id": "1"}}}}, metrics: map[string]float64{"http.monitor": 41.99}, } diff --git a/ddtrace/tracer/writer_test.go b/ddtrace/tracer/writer_test.go index 8d9175cc0ad..480b7f3ab23 100644 --- a/ddtrace/tracer/writer_test.go +++ b/ddtrace/tracer/writer_test.go @@ -22,6 +22,8 @@ import ( pb "github.com/DataDog/datadog-agent/pkg/proto/pbgo/trace" + "github.com/DataDog/dd-trace-go/v2/ddtrace/ext" + tinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" internalconfig "github.com/DataDog/dd-trace-go/v2/internal/config" "github.com/DataDog/dd-trace-go/v2/internal/log" "github.com/DataDog/dd-trace-go/v2/internal/processtags" @@ -186,10 +188,10 @@ func TestLogWriter(t *testing.T) { name: "basicName", service: "basicService", resource: "basicResource", - meta: spanMeta{m: map[string]string{ - "env": "prod", - "version": "1.26.0", - }}, + meta: tinternal.NewSpanMetaFromMap(map[string]string{ + ext.Environment: "prod", + ext.Version: "1.26.0", + }), metaStruct: map[string]any{ "_dd.stack": map[string]string{ "0": "github.com/DataDog/dd-trace-go/v1/internal/tracer.TestLogWriter", From b6fcf9eb3e4d86944aabcebe773b6a9b87ee4d57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Wed, 18 Mar 2026 17:36:16 +0000 Subject: [PATCH 27/71] fix(ddtrace/tracer): detected mishaps --- ddtrace/tracer/internal/span_attributes_test.go | 2 +- ddtrace/tracer/payload_v1.go | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/ddtrace/tracer/internal/span_attributes_test.go b/ddtrace/tracer/internal/span_attributes_test.go index 1f1dd44197b..a300427d0aa 100644 --- a/ddtrace/tracer/internal/span_attributes_test.go +++ b/ddtrace/tracer/internal/span_attributes_test.go @@ -118,7 +118,7 @@ func TestSpanAttributesForEachNil(t *testing.T) { called = true } if called { - t.Error("ForEach should not call fn on nil receiver") + t.Error("All() should not call fn on nil receiver") } } diff --git a/ddtrace/tracer/payload_v1.go b/ddtrace/tracer/payload_v1.go index a83f19741db..b34a259b664 100644 --- a/ddtrace/tracer/payload_v1.go +++ b/ddtrace/tracer/payload_v1.go @@ -563,9 +563,6 @@ func (p *payloadV1) encodeSpans(bm bitmap, fieldID int, spans spanList, st *stri // span attributes combine the meta (tags), metrics and meta_struct. // To avoid increased allocations, we serialize attributes immediately without // creating an intermediate map. - // Promoted fields (env, version, component, spanKind) live in - // span.meta.attrs and are encoded as dedicated span fields 13-16. - // They are no longer in span.meta.m, so no skip logic is needed. size := span.meta.Count() + len(span.metrics) + len(span.metaStruct) p.buf = msgp.AppendUint32(p.buf, uint32(9)) // attributes fieldID p.buf = msgp.AppendArrayHeader(p.buf, uint32(size)*3) // number of attributes @@ -602,7 +599,7 @@ func (p *payloadV1) encodeSpans(bm bitmap, fieldID int, spans spanList, st *stri // empty string, so the "was it set?" distinction is irrelevant for wire encoding. var ( env, _ = span.meta.Get(ext.Environment) - version, _ = span.meta.Get(ext.Environment) + version, _ = span.meta.Get(ext.Version) component, _ = span.meta.Get(ext.Component) spanKind, _ = span.meta.Get(ext.SpanKind) ) From a720748af3d87bd7036c5533bad7268f6d04edbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Wed, 18 Mar 2026 17:39:20 +0000 Subject: [PATCH 28/71] chore(ddtrace/tracer): make lint/go/fix --- ddtrace/tracer/span.go | 1 - ddtrace/tracer/spancontext_test.go | 4 ++-- ddtrace/tracer/textmap_test.go | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/ddtrace/tracer/span.go b/ddtrace/tracer/span.go index aeb24a49d58..6e485109fb5 100644 --- a/ddtrace/tracer/span.go +++ b/ddtrace/tracer/span.go @@ -302,7 +302,6 @@ func (s *Span) debugInfo() (name string, spanID, traceID uint64, integration str return } - // matchTagsForSampling checks if span tags match the sampling rule tag patterns. // Used by rules_sampler.go for tag-based sampling rule matching. // Returns true if all tag patterns match, false otherwise. diff --git a/ddtrace/tracer/spancontext_test.go b/ddtrace/tracer/spancontext_test.go index 50f80c400f4..30abc5ef257 100644 --- a/ddtrace/tracer/spancontext_test.go +++ b/ddtrace/tracer/spancontext_test.go @@ -317,7 +317,7 @@ func TestPartialFlush(t *testing.T) { assert.Equal(t, "someValue", v0) assert.Equal(t, 1.0, ts[0][0].metrics[keySamplingPriority]) v1, _ := ts[0][1].meta.Get("someTraceTag") - assert.Empty(t, v1) // the tag should only be on the first span in the chunk + assert.Empty(t, v1) // the tag should only be on the first span in the chunk assert.Equal(t, 1.0, ts[0][1].metrics[keySamplingPriority]) // the tag should only be on the first span in the chunk comparePayloadSpans(t, children[0], ts[0][0]) comparePayloadSpans(t, children[1], ts[0][1]) @@ -335,7 +335,7 @@ func TestPartialFlush(t *testing.T) { assert.Equal(t, "someValue", v0) assert.Equal(t, 1.0, ts[0][0].metrics[keySamplingPriority]) v1, _ = ts[0][1].meta.Get("someTraceTag") - assert.Empty(t, v1) // the tag should only be on the first span in the chunk + assert.Empty(t, v1) // the tag should only be on the first span in the chunk assert.Equal(t, 1.0, ts[0][1].metrics[keySamplingPriority]) // the tag should only be on the first span in the chunk comparePayloadSpans(t, root, tsRoot[0][0]) comparePayloadSpans(t, children[2], tsRoot[0][1]) diff --git a/ddtrace/tracer/textmap_test.go b/ddtrace/tracer/textmap_test.go index fe3ba43f435..a9d68b95e97 100644 --- a/ddtrace/tracer/textmap_test.go +++ b/ddtrace/tracer/textmap_test.go @@ -1670,7 +1670,7 @@ func TestEnvVars(t *testing.T) { // NOTE: this will be set for phase 3 v, _ := root.meta.Get("_dd.parent_id") - assert.Empty(v, "extraction happened from DD headers, so _dd.parent_id mustn't be set") + assert.Empty(v, "extraction happened from DD headers, so _dd.parent_id mustn't be set") ddTag := strings.SplitN(headers[tracestateHeader], ",", 2)[0] // -3 as we don't count dd= as part of the "value" length limit From 824625873e381317c1a6fd5649d816bb1bb76028 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Thu, 19 Mar 2026 07:14:44 +0000 Subject: [PATCH 29/71] feat(ddtrace/tracer): reduce allocations on hot paths; reduce CPU cycles by early exiting AttrKeyForTag --- ddtrace/tracer/internal/span_attributes.go | 10 +++++++++- .../tracer/internal/span_attributes_test.go | 4 ++-- ddtrace/tracer/internal/span_meta.go | 20 +++++++++++++++++++ ddtrace/tracer/stats.go | 8 +++----- 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/ddtrace/tracer/internal/span_attributes.go b/ddtrace/tracer/internal/span_attributes.go index 6456f6a0028..4babcada0ef 100644 --- a/ddtrace/tracer/internal/span_attributes.go +++ b/ddtrace/tracer/internal/span_attributes.go @@ -20,6 +20,10 @@ const ( AttrComponent AttrKey = 2 AttrSpanKind AttrKey = 3 numAttrs AttrKey = 4 + + // AttrUnknown is returned by AttrKeyForTag when no promoted tag matches. + // Its value is intentionally out of range for vals[] so misuse panics immediately. + AttrUnknown AttrKey = 0xFF ) // Compile-time guard: the numeric values of AttrKey constants are load-bearing — @@ -142,7 +146,11 @@ func (a *SpanAttributes) All() iter.Seq2[string, string] { } // AttrKeyForTag returns the AttrKey for a promoted tag name, if any. +// Returns (AttrUnknown, false) when the tag is not a promoted attribute. func AttrKeyForTag(tag string) (AttrKey, bool) { + if len(tag) != 3 && len(tag) != 7 && len(tag) != 9 { + return AttrUnknown, false + } switch tag { case "env": return AttrEnv, true @@ -153,5 +161,5 @@ func AttrKeyForTag(tag string) (AttrKey, bool) { case "span.kind": return AttrSpanKind, true } - return 0, false + return AttrUnknown, false } diff --git a/ddtrace/tracer/internal/span_attributes_test.go b/ddtrace/tracer/internal/span_attributes_test.go index a300427d0aa..a38cf18f4ac 100644 --- a/ddtrace/tracer/internal/span_attributes_test.go +++ b/ddtrace/tracer/internal/span_attributes_test.go @@ -132,8 +132,8 @@ func TestAttrKeyForTag(t *testing.T) { {"version", AttrVersion, true}, {"component", AttrComponent, true}, {"span.kind", AttrSpanKind, true}, - {"unknown", 0, false}, - {"", 0, false}, + {"unknown", AttrUnknown, false}, + {"", AttrUnknown, false}, } for _, tt := range tests { key, ok := AttrKeyForTag(tt.tag) diff --git a/ddtrace/tracer/internal/span_meta.go b/ddtrace/tracer/internal/span_meta.go index 543e41d537b..1dc9b04c1d1 100644 --- a/ddtrace/tracer/internal/span_meta.go +++ b/ddtrace/tracer/internal/span_meta.go @@ -142,6 +142,26 @@ func (sm SpanMeta) Count() int { return len(sm.m) + sm.attrs.Count() } +// Merge returns a map[string]string containing all entries (flat map + promoted attrs). +// When no promoted attrs are set, the internal flat map is returned directly without +// allocating — the caller must not mutate it. When promoted attrs are present, a new +// merged map is allocated and returned. +func (sm SpanMeta) Merge() map[string]string { + if sm.attrs.Count() == 0 { + return sm.m // nil-safe: callers must handle a nil map + } + m := make(map[string]string, len(sm.m)+sm.attrs.Count()) + for k, v := range sm.m { + m[k] = v + } + for _, d := range Defs { + if sm.attrs.setMask>>d.Key&1 != 0 { + m[d.Name] = sm.attrs.vals[d.Key] + } + } + return m +} + // All returns an iterator over all entries. Flat-map entries are yielded first // (in unspecified order), followed by promoted attributes in definition order // (env, version, component, span.kind). Returning false from yield stops iteration. diff --git a/ddtrace/tracer/stats.go b/ddtrace/tracer/stats.go index a720f6cc00d..9590732f871 100644 --- a/ddtrace/tracer/stats.go +++ b/ddtrace/tracer/stats.go @@ -6,7 +6,6 @@ package tracer import ( - "maps" "sync" "sync/atomic" "time" @@ -167,9 +166,8 @@ func (c *concentrator) newTracerStatSpan(s *Span, obfuscator *obfuscate.Obfuscat resource = obfuscatedResource(obfuscator, s.spanType, s.resource) } - meta := maps.Collect(s.meta.All()) - httpMethod, _ := meta[ext.HTTPMethod] - httpEndpoint, _ := meta[ext.HTTPEndpoint] + httpMethod, _ := s.meta.Get(ext.HTTPMethod) + httpEndpoint, _ := s.meta.Get(ext.HTTPEndpoint) statSpan, ok := c.spanConcentrator.NewStatSpanWithConfig(stats.StatSpanConfig{ Service: s.service, @@ -180,7 +178,7 @@ func (c *concentrator) newTracerStatSpan(s *Span, obfuscator *obfuscate.Obfuscat Start: s.start, Duration: s.duration, Error: s.error, - Meta: meta, + Meta: s.meta.Merge(), Metrics: s.metrics, PeerTags: c.cfg.agent.load().peerTags, HTTPMethod: httpMethod, From 40dbd10a4802015506eeccbeeadca43e232f59f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Thu, 19 Mar 2026 07:51:19 +0000 Subject: [PATCH 30/71] fix(ddtrace/tracer): modernize using maps.Copy on spanMeta.Merge --- ddtrace/tracer/internal/span_meta.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ddtrace/tracer/internal/span_meta.go b/ddtrace/tracer/internal/span_meta.go index 1dc9b04c1d1..0f115208088 100644 --- a/ddtrace/tracer/internal/span_meta.go +++ b/ddtrace/tracer/internal/span_meta.go @@ -8,6 +8,7 @@ package internal import ( "fmt" "iter" + "maps" "strings" "github.com/tinylib/msgp/msgp" @@ -151,9 +152,7 @@ func (sm SpanMeta) Merge() map[string]string { return sm.m // nil-safe: callers must handle a nil map } m := make(map[string]string, len(sm.m)+sm.attrs.Count()) - for k, v := range sm.m { - m[k] = v - } + maps.Copy(m, sm.m) for _, d := range Defs { if sm.attrs.setMask>>d.Key&1 != 0 { m[d.Name] = sm.attrs.vals[d.Key] From d31f2465090e7bddfaf12703bca16e92696f9999 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Thu, 19 Mar 2026 11:07:58 +0000 Subject: [PATCH 31/71] fix(ddtrace/tracer): clean up; ensure new API is inlinable --- ddtrace/tracer/internal/span_attributes.go | 2 +- ddtrace/tracer/internal/span_meta.go | 159 ++++++++++++++++----- ddtrace/tracer/span.go | 35 +++-- ddtrace/tracer/tracer.go | 6 +- 4 files changed, 156 insertions(+), 46 deletions(-) diff --git a/ddtrace/tracer/internal/span_attributes.go b/ddtrace/tracer/internal/span_attributes.go index 4babcada0ef..d8363149752 100644 --- a/ddtrace/tracer/internal/span_attributes.go +++ b/ddtrace/tracer/internal/span_attributes.go @@ -148,7 +148,7 @@ func (a *SpanAttributes) All() iter.Seq2[string, string] { // AttrKeyForTag returns the AttrKey for a promoted tag name, if any. // Returns (AttrUnknown, false) when the tag is not a promoted attribute. func AttrKeyForTag(tag string) (AttrKey, bool) { - if len(tag) != 3 && len(tag) != 7 && len(tag) != 9 { + if !IsPromotedKeyLen(len(tag)) { return AttrUnknown, false } switch tag { diff --git a/ddtrace/tracer/internal/span_meta.go b/ddtrace/tracer/internal/span_meta.go index 0f115208088..2f9b3f548fc 100644 --- a/ddtrace/tracer/internal/span_meta.go +++ b/ddtrace/tracer/internal/span_meta.go @@ -24,6 +24,11 @@ var ( // Promoted attributes (env, version, component, span.kind) live in attrs // and are excluded from the map m. The msgp codec merges both sources // transparently so the wire format is unchanged. +// +// Hot paths (setMetaInit, setMetricLocked) use SetMap/DeleteMap for direct +// flat-map access without promoted-key overhead. Callers that may write +// promoted keys use Set/Delete or setMetaTagLocked which route to attrs +// via copy-on-write. This ensures promoted keys never appear in m. type SpanMeta struct { m map[string]string attrs *SpanAttributes @@ -65,40 +70,100 @@ func (sm *SpanMeta) Normalize() { } } -// Get returns the value for key, checking attrs for promoted keys and the flat map for others. +// --------------------------------------------------------------------------- +// Read methods +// --------------------------------------------------------------------------- + +// Get returns the value for key, checking the flat map then attrs for promoted keys. func (sm SpanMeta) Get(key string) (string, bool) { - if ak, ok := AttrKeyForTag(key); ok { - return sm.attrs.Get(ak) + if v, ok := sm.m[key]; ok { + return v, ok + } + if IsPromotedKeyLen(len(key)) { + if v, ok, handled := sm.getPromoted(key); handled { + return v, ok + } } - v, ok := sm.m[key] - return v, ok + return "", false } -// Has reports whether key is present. Promoted keys check attrs; others check the flat map. -func (sm SpanMeta) Has(key string) bool { - if ak, ok := AttrKeyForTag(key); ok { - _, ok := sm.attrs.Get(ak) - return ok +// getPromoted is the slow path for Get when the key might be a promoted attribute. +// +//go:noinline +func (sm SpanMeta) getPromoted(key string) (string, bool, bool) { + ak, ok := AttrKeyForTag(key) + if !ok { + return "", false, false } - _, ok := sm.m[key] + v, found := sm.attrs.Get(ak) + return v, found, true +} + +// Has reports whether key is present. +func (sm SpanMeta) Has(key string) bool { + _, ok := sm.Get(key) return ok } -// Set sets key→value, routing promoted keys to attrs (with copy-on-write) and others to the flat map. +// --------------------------------------------------------------------------- +// Write methods — fast (map-only) and full (promoted-aware) variants. +// --------------------------------------------------------------------------- + +// SetMap writes key=value directly to the flat map without checking for +// promoted attributes. It is the caller's responsibility to ensure that +// promoted-key routing is handled externally (or that the encoding's +// dedup logic will cover the overlap). This method is designed to be +// inlinable so that setMetaInit remains inlinable. +func (sm *SpanMeta) SetMap(key, value string) { + if sm.m == nil { + sm.m = make(map[string]string, 1) + } + sm.m[key] = value +} + +// DeleteMap removes key from the flat map only. Safe to call on a nil map. +// Like SetMap, this bypasses promoted-key routing for performance. +func (sm *SpanMeta) DeleteMap(key string) { + delete(sm.m, key) +} + +// Set sets key→value, routing promoted keys to attrs (with copy-on-write) +// and others to the flat map. // +checklocksignore — called both at init time (no lock) and under lock. func (sm *SpanMeta) Set(key, value string) { - if ak, ok := AttrKeyForTag(key); ok { - // Check if whether setting key=v on attrs would be a no-op - // no-op (i.e. the current value is already v, shared or local). - if sm.attrs != nil && sm.attrs.Val(ak) == value { - return - } - sm.setAttrCOWSlow(ak, value) + if IsPromotedKeyLen(len(key)) && sm.setPromoted(key, value) { return } if sm.m == nil { - sm.m = make(map[string]string, 1) + sm.initMap(key, value) + return + } + sm.m[key] = value +} + +// setPromoted is the slow path for Set when the key might be a promoted attribute. +// Returns true if the key was handled (set or skipped as no-op). +// +//go:noinline +func (sm *SpanMeta) setPromoted(key, value string) bool { + ak, ok := AttrKeyForTag(key) + if !ok { + return false } + if sm.attrs != nil && sm.attrs.Val(ak) == value { + return true // no-op: value already matches + } + sm.ensureAttrsLocal() + sm.attrs.Set(ak, value) + return true +} + +// initMap allocates the flat map and inserts the first entry. +// Separated to keep Set's fast path (map already exists) small and inlinable. +// +//go:noinline +func (sm *SpanMeta) initMap(key, value string) { + sm.m = make(map[string]string, 1) sm.m[key] = value } @@ -114,30 +179,50 @@ func (sm *SpanMeta) ensureAttrsLocal() { } } -// setAttrCOWSlow is the slow path for Set when the value is a promoted attr that needs writing. -// Separated with //go:noinline to keep the hot path in Set small. +// Delete removes key from both the flat map and (for promoted keys) attrs. +// +checklocksignore — called both at init time (no lock) and under lock. +func (sm *SpanMeta) Delete(key string) { + delete(sm.m, key) + if IsPromotedKeyLen(len(key)) { + sm.deleteFromAttrs(key) + } +} + +// deleteFromAttrs handles the promoted-key side of Delete. // //go:noinline -func (sm *SpanMeta) setAttrCOWSlow(key AttrKey, v string) { +func (sm *SpanMeta) deleteFromAttrs(key string) { + ak, ok := AttrKeyForTag(key) + if !ok { + return + } + if _, isSet := sm.attrs.Get(ak); !isSet { + return + } sm.ensureAttrsLocal() - sm.attrs.Set(key, v) + sm.attrs.Unset(ak) } -// Delete removes key. For promoted keys, clears the attribute (with copy-on-write); for others, -// removes from the flat map. No-op if the key is absent. -// +checklocksignore — called both at init time (no lock) and under lock. -func (sm *SpanMeta) Delete(key string) { - if ak, ok := AttrKeyForTag(key); ok { - if _, isSet := sm.attrs.Get(ak); !isSet { - return // already absent, skip unnecessary COW +// IsPromotedKeyLen reports whether n matches the length of any promoted attribute name. +// Promoted keys: "env"(3), "version"(7), "component"(9), "span.kind"(9). +// This must stay in sync with the Defs table in span_attributes.go; the init +// check below enforces this at program start. +func IsPromotedKeyLen(n int) bool { + return n == 3 || n == 7 || n == 9 +} + +func init() { + for _, d := range Defs { + if !IsPromotedKeyLen(len(d.Name)) { + panic("IsPromotedKeyLen out of sync with Defs: missing length " + d.Name) } - sm.ensureAttrsLocal() - sm.attrs.Unset(ak) - return } - delete(sm.m, key) } +// --------------------------------------------------------------------------- +// Counting / iteration — promoted keys are in attrs only, never in m. +// --------------------------------------------------------------------------- + // Count returns the total number of entries (flat map + promoted attrs). func (sm SpanMeta) Count() int { return len(sm.m) + sm.attrs.Count() @@ -199,6 +284,10 @@ func (sm SpanMeta) String() string { return b.String() } +// --------------------------------------------------------------------------- +// msgp codec +// --------------------------------------------------------------------------- + // EncodeMsg writes the combined map header (m entries + promoted attrs), // then emits all map entries followed by promoted attribute entries. func (sm *SpanMeta) EncodeMsg(en *msgp.Writer) error { diff --git a/ddtrace/tracer/span.go b/ddtrace/tracer/span.go index 6e485109fb5..e0e2566ccf6 100644 --- a/ddtrace/tracer/span.go +++ b/ddtrace/tracer/span.go @@ -441,7 +441,7 @@ func (s *Span) setTagLocked(key string, value any) { s.pprofCtxActive = pprof.WithLabels(s.pprofCtxActive, pprof.Labels(traceprof.TraceEndpoint, v)) pprof.SetGoroutineLabels(s.pprofCtxActive) } - s.setMetaLocked(key, v) + s.setMetaTagLocked(key, v) return } if v, ok := sharedinternal.ToFloat64(value); ok { @@ -449,12 +449,12 @@ func (s *Span) setTagLocked(key string, value any) { return } if v, ok := value.(fmt.Stringer); ok { - s.setMetaLocked(key, safeStringerValue(v, value)) + s.setMetaTagLocked(key, safeStringerValue(v, value)) return } if v, ok := value.([]byte); ok { - s.setMetaLocked(key, string(v)) + s.setMetaTagLocked(key, string(v)) return } @@ -471,7 +471,7 @@ func (s *Span) setTagLocked(key string, value any) { if num, ok := sharedinternal.ToFloat64(v.Interface()); ok { s.setMetricLocked(key, num) } else { - s.setMetaLocked(key, fmt.Sprintf("%v", v)) + s.setMetaTagLocked(key, fmt.Sprintf("%v", v)) } } return @@ -498,7 +498,7 @@ func (s *Span) setTagLocked(key string, value any) { } // not numeric, not a string, not a fmt.Stringer, not a bool, and not an error - s.setMetaLocked(key, fmt.Sprint(value)) + s.setMetaTagLocked(key, fmt.Sprint(value)) } // setSamplingPriority locks the span, then updates the sampling priority. @@ -722,12 +722,31 @@ func (s *Span) setMeta(key, v string) { } // setMetaLocked sets a string tag. This method assumes the span lock is already held. +// Promoted keys (env, version, component, span.kind) are written to the flat +// map via setMetaInit/SetMap — callers that may receive promoted keys should +// use setMetaTagLocked instead. // +checklocks:s.mu func (s *Span) setMetaLocked(key, v string) { assert.RWMutexLocked(&s.mu) s.setMetaInit(key, v) } +// setMetaTagLocked is like setMetaLocked but routes promoted keys to COW attrs +// via meta.Set, keeping them out of the flat map. Used by setTagLocked where +// the key may be any user-supplied string. +// +checklocks:s.mu +func (s *Span) setMetaTagLocked(key, v string) { + assert.RWMutexLocked(&s.mu) + if tinternal.IsPromotedKeyLen(len(key)) { + if _, ok := tinternal.AttrKeyForTag(key); ok { + delete(s.metrics, key) + s.meta.Set(key, v) + return + } + } + s.setMetaInit(key, v) +} + // setMetaInit sets a string tag without acquiring the lock and asserting the lock is held. // +checklocksignore — Initialization time, span not yet shared. func (s *Span) setMetaInit(key, v string) { @@ -742,7 +761,7 @@ func (s *Span) setMetaInit(key, v string) { case ext.SpanType: s.spanType = v default: - s.meta.Set(key, v) + s.meta.SetMap(key, v) } } @@ -791,7 +810,7 @@ func (s *Span) setMetricInit(key string, v float64) { if s.metrics == nil { s.metrics = make(map[string]float64, 1) } - s.meta.Delete(key) + s.meta.DeleteMap(key) // Note: We don't handle ManualKeep or _sampling_priority_v1shim during init // because those require modifying trace-level state which needs locking s.metrics[key] = v @@ -811,7 +830,7 @@ func (s *Span) setMetricLocked(key string, v float64) { if s.metrics == nil { s.metrics = make(map[string]float64, 1) } - s.meta.Delete(key) + s.meta.DeleteMap(key) switch key { case ext.ManualKeep: if v == float64(samplernames.AppSec) { diff --git a/ddtrace/tracer/tracer.go b/ddtrace/tracer/tracer.go index dd970d5baff..2ff6e7165e6 100644 --- a/ddtrace/tracer/tracer.go +++ b/ddtrace/tracer/tracer.go @@ -912,11 +912,13 @@ func (t *tracer) StartSpan(operationName string, options ...StartSpanOption) *Sp if ver := cfg.Version(); ver != "" { if t.config.universalVersion || (!t.config.universalVersion && span.service == t.config.internalConfig.ServiceName()) { - span.setMetaInit(ext.Version, ver) + delete(span.metrics, ext.Version) + span.meta.Set(ext.Version, ver) } } if env := cfg.Env(); env != "" { - span.setMetaInit(ext.Environment, env) + delete(span.metrics, ext.Environment) + span.meta.Set(ext.Environment, env) } if _, ok := span.context.SamplingPriority(); !ok { // if not already sampled or a brand new trace, sample it From e8097323fe1f3b6c56804cc1f9ee76595f9f8d17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Thu, 19 Mar 2026 14:56:12 +0000 Subject: [PATCH 32/71] fix(ddtrace/tracer): try to reduce allocations, again (meta init hint was implemented alredy on main) --- ddtrace/tracer/internal/span_meta.go | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/ddtrace/tracer/internal/span_meta.go b/ddtrace/tracer/internal/span_meta.go index 2f9b3f548fc..e3ef2b16ac9 100644 --- a/ddtrace/tracer/internal/span_meta.go +++ b/ddtrace/tracer/internal/span_meta.go @@ -14,6 +14,12 @@ import ( "github.com/tinylib/msgp/msgp" ) +// metaMapHint is the initial capacity for the flat map m. +// A typical span carries "language" plus a handful of internal tags +// (_dd.base_service, runtime-id, etc.). 5 accommodates these without +// a rehash and matches the pre-refactor initMeta allocation profile. +const metaMapHint = 5 + var ( _ msgp.Encodable = (*SpanMeta)(nil) _ msgp.Decodable = (*SpanMeta)(nil) @@ -110,13 +116,11 @@ func (sm SpanMeta) Has(key string) bool { // --------------------------------------------------------------------------- // SetMap writes key=value directly to the flat map without checking for -// promoted attributes. It is the caller's responsibility to ensure that -// promoted-key routing is handled externally (or that the encoding's -// dedup logic will cover the overlap). This method is designed to be -// inlinable so that setMetaInit remains inlinable. +// promoted attributes. This method is designed to be inlinable so that +// setMetaInit remains inlinable. func (sm *SpanMeta) SetMap(key, value string) { if sm.m == nil { - sm.m = make(map[string]string, 1) + sm.m = make(map[string]string, metaMapHint) } sm.m[key] = value } @@ -163,7 +167,7 @@ func (sm *SpanMeta) setPromoted(key, value string) bool { // //go:noinline func (sm *SpanMeta) initMap(key, value string) { - sm.m = make(map[string]string, 1) + sm.m = make(map[string]string, metaMapHint) sm.m[key] = value } From aad2032b273fc80051de1db6f4051d17db9fd69a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Thu, 19 Mar 2026 16:14:26 +0000 Subject: [PATCH 33/71] fix(ddtrace/tracer): fixing SpanMeta.Merge to avoid bad allocations --- ddtrace/tracer/internal/span_attributes.go | 11 +++++----- ddtrace/tracer/internal/span_meta.go | 25 ++++++++++------------ 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/ddtrace/tracer/internal/span_attributes.go b/ddtrace/tracer/internal/span_attributes.go index d8363149752..f42bd079249 100644 --- a/ddtrace/tracer/internal/span_attributes.go +++ b/ddtrace/tracer/internal/span_attributes.go @@ -74,11 +74,12 @@ func (a *SpanAttributes) Val(key AttrKey) string { return a.vals[key] } +func (a *SpanAttributes) Has(key AttrKey) bool { + return a != nil && a.setMask>>key&1 != 0 +} + func (a *SpanAttributes) Get(key AttrKey) (string, bool) { - if a == nil { - return "", false - } - return a.vals[key], a.setMask>>key&1 != 0 + return a.Val(key), a.Has(key) } // Count returns the number of promoted fields that have been set. @@ -136,7 +137,7 @@ func (a *SpanAttributes) All() iter.Seq2[string, string] { return } for _, d := range Defs { - if a.setMask>>d.Key&1 != 0 { + if a.Has(d.Key) { if !yield(d.Name, a.vals[d.Key]) { return } diff --git a/ddtrace/tracer/internal/span_meta.go b/ddtrace/tracer/internal/span_meta.go index e3ef2b16ac9..294482620eb 100644 --- a/ddtrace/tracer/internal/span_meta.go +++ b/ddtrace/tracer/internal/span_meta.go @@ -8,7 +8,6 @@ package internal import ( "fmt" "iter" - "maps" "strings" "github.com/tinylib/msgp/msgp" @@ -232,21 +231,19 @@ func (sm SpanMeta) Count() int { return len(sm.m) + sm.attrs.Count() } -// Merge returns a map[string]string containing all entries (flat map + promoted attrs). -// When no promoted attrs are set, the internal flat map is returned directly without -// allocating — the caller must not mutate it. When promoted attrs are present, a new -// merged map is allocated and returned. +// Merge returns a read-only map containing the flat map entries plus only +// the promoted attrs that the stats concentrator needs (span.kind). +// When span.kind is not set in attrs the internal flat map is returned +// directly without allocating. The caller must not mutate the returned map. func (sm SpanMeta) Merge() map[string]string { - if sm.attrs.Count() == 0 { - return sm.m // nil-safe: callers must handle a nil map + if !sm.attrs.Has(AttrSpanKind) { + return sm.m } - m := make(map[string]string, len(sm.m)+sm.attrs.Count()) - maps.Copy(m, sm.m) - for _, d := range Defs { - if sm.attrs.setMask>>d.Key&1 != 0 { - m[d.Name] = sm.attrs.vals[d.Key] - } + m := make(map[string]string, len(sm.m)+1) + for k, v := range sm.m { + m[k] = v } + m[Defs[AttrSpanKind].Name] = sm.attrs.Val(AttrSpanKind) return m } @@ -262,7 +259,7 @@ func (sm SpanMeta) All() iter.Seq2[string, string] { } if sm.attrs != nil { for _, d := range Defs { - if sm.attrs.setMask>>d.Key&1 != 0 { + if sm.attrs.Has(d.Key) { if !yield(d.Name, sm.attrs.vals[d.Key]) { return } From 851fe39f4f04e7629bbd9dbbca752bc8cf8ab712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Fri, 20 Mar 2026 09:11:31 +0000 Subject: [PATCH 34/71] fix(ddtrace/tracer): pool StatSpanConfig; ensure SpanMeta.Merge doesn't have leaked implementation details from datadog-agent/pkg/trace internals --- ddtrace/tracer/internal/span_meta.go | 23 +++++++----- ddtrace/tracer/stats.go | 55 ++++++++++++++++++++-------- 2 files changed, 52 insertions(+), 26 deletions(-) diff --git a/ddtrace/tracer/internal/span_meta.go b/ddtrace/tracer/internal/span_meta.go index 294482620eb..76ecf6e07be 100644 --- a/ddtrace/tracer/internal/span_meta.go +++ b/ddtrace/tracer/internal/span_meta.go @@ -8,6 +8,7 @@ package internal import ( "fmt" "iter" + "maps" "strings" "github.com/tinylib/msgp/msgp" @@ -231,19 +232,21 @@ func (sm SpanMeta) Count() int { return len(sm.m) + sm.attrs.Count() } -// Merge returns a read-only map containing the flat map entries plus only -// the promoted attrs that the stats concentrator needs (span.kind). -// When span.kind is not set in attrs the internal flat map is returned -// directly without allocating. The caller must not mutate the returned map. +// Merge returns a freshly-allocated map containing all flat map entries plus +// all promoted attrs. It always allocates so that the caller owns the map +// exclusively — safe to pool or pass to code that retains the map after +// return. To iterate without allocating, use All(). func (sm SpanMeta) Merge() map[string]string { - if !sm.attrs.Has(AttrSpanKind) { - return sm.m + m := make(map[string]string, len(sm.m)+sm.attrs.Count()) + maps.Copy(m, sm.m) + if sm.attrs.Count() == 0 { + return m } - m := make(map[string]string, len(sm.m)+1) - for k, v := range sm.m { - m[k] = v + for _, d := range Defs { + if sm.attrs.Has(d.Key) { + m[d.Name] = sm.attrs.vals[d.Key] + } } - m[Defs[AttrSpanKind].Name] = sm.attrs.Val(AttrSpanKind) return m } diff --git a/ddtrace/tracer/stats.go b/ddtrace/tracer/stats.go index 9590732f871..a72a9b87824 100644 --- a/ddtrace/tracer/stats.go +++ b/ddtrace/tracer/stats.go @@ -27,6 +27,12 @@ import ( // In the future this can be pulled directly from our obfuscation import. var tracerObfuscationVersion = 1 +var statSpanConfigPool = sync.Pool{ + New: func() any { + return &stats.StatSpanConfig{} + }, +} + // defaultStatsBucketSize specifies the default span of time that will be // covered in one stats bucket. var defaultStatsBucketSize = (10 * time.Second).Nanoseconds() @@ -165,25 +171,42 @@ func (c *concentrator) newTracerStatSpan(s *Span, obfuscator *obfuscate.Obfuscat if c.shouldObfuscate() { resource = obfuscatedResource(obfuscator, s.spanType, s.resource) } - httpMethod, _ := s.meta.Get(ext.HTTPMethod) httpEndpoint, _ := s.meta.Get(ext.HTTPEndpoint) - statSpan, ok := c.spanConcentrator.NewStatSpanWithConfig(stats.StatSpanConfig{ - Service: s.service, - Resource: resource, - Name: s.name, - Type: s.spanType, - ParentID: s.parentID, - Start: s.start, - Duration: s.duration, - Error: s.error, - Meta: s.meta.Merge(), - Metrics: s.metrics, - PeerTags: c.cfg.agent.load().peerTags, - HTTPMethod: httpMethod, - HTTPEndpoint: httpEndpoint, - }) + cfg := statSpanConfigPool.Get().(*stats.StatSpanConfig) + if cfg.Meta == nil { + cfg.Meta = s.meta.Merge() + } else { + clear(cfg.Meta) + for k, v := range s.meta.All() { + cfg.Meta[k] = v + } + } + + cfg.Service = s.service + cfg.Resource = resource + cfg.Name = s.name + cfg.Type = s.spanType + cfg.ParentID = s.parentID + cfg.Start = s.start + cfg.Duration = s.duration + cfg.Error = s.error + cfg.Metrics = s.metrics + cfg.PeerTags = c.cfg.agent.load().peerTags + cfg.HTTPMethod = httpMethod + cfg.HTTPEndpoint = httpEndpoint + + statSpan, ok := c.spanConcentrator.NewStatSpanWithConfig(*cfg) + + // Return to pool — NewStatSpanWithConfig consumed cfg by value. + // Keep the Meta map, zero everything else to release string refs. + m := cfg.Meta + clear(m) + *cfg = stats.StatSpanConfig{} // This should compile to memclr + cfg.Meta = m + statSpanConfigPool.Put(cfg) + if !ok { return nil, false } From a373301afd0063d794c0e65e565dd4ed5b4118b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Fri, 20 Mar 2026 09:38:10 +0000 Subject: [PATCH 35/71] chore(ddtrace/tracer): encapsulate pooling logic around the statSpanConfigPool --- ddtrace/tracer/stats.go | 50 +++++++++++++++++++++++------------------ 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/ddtrace/tracer/stats.go b/ddtrace/tracer/stats.go index a72a9b87824..eab228184ac 100644 --- a/ddtrace/tracer/stats.go +++ b/ddtrace/tracer/stats.go @@ -27,10 +27,32 @@ import ( // In the future this can be pulled directly from our obfuscation import. var tracerObfuscationVersion = 1 -var statSpanConfigPool = sync.Pool{ - New: func() any { - return &stats.StatSpanConfig{} - }, +type statSpanPool struct{ sync.Pool } + +func (p *statSpanPool) get(s *Span) *stats.StatSpanConfig { + cfg := p.Get().(*stats.StatSpanConfig) + if cfg.Meta == nil { + cfg.Meta = s.meta.Merge() + } else { + for k, v := range s.meta.All() { + cfg.Meta[k] = v + } + } + return cfg +} + +// put returns cfg to the pool. It preserves the Meta map allocation (clearing +// its contents) so subsequent gets can reuse it without allocating. +func (p *statSpanPool) put(cfg *stats.StatSpanConfig) { + m := cfg.Meta + clear(m) + *cfg = stats.StatSpanConfig{} + cfg.Meta = m + p.Put(cfg) +} + +var statSpanConfigPool = &statSpanPool{ + Pool: sync.Pool{New: func() any { return &stats.StatSpanConfig{} }}, } // defaultStatsBucketSize specifies the default span of time that will be @@ -174,16 +196,7 @@ func (c *concentrator) newTracerStatSpan(s *Span, obfuscator *obfuscate.Obfuscat httpMethod, _ := s.meta.Get(ext.HTTPMethod) httpEndpoint, _ := s.meta.Get(ext.HTTPEndpoint) - cfg := statSpanConfigPool.Get().(*stats.StatSpanConfig) - if cfg.Meta == nil { - cfg.Meta = s.meta.Merge() - } else { - clear(cfg.Meta) - for k, v := range s.meta.All() { - cfg.Meta[k] = v - } - } - + cfg := statSpanConfigPool.get(s) cfg.Service = s.service cfg.Resource = resource cfg.Name = s.name @@ -198,14 +211,7 @@ func (c *concentrator) newTracerStatSpan(s *Span, obfuscator *obfuscate.Obfuscat cfg.HTTPEndpoint = httpEndpoint statSpan, ok := c.spanConcentrator.NewStatSpanWithConfig(*cfg) - - // Return to pool — NewStatSpanWithConfig consumed cfg by value. - // Keep the Meta map, zero everything else to release string refs. - m := cfg.Meta - clear(m) - *cfg = stats.StatSpanConfig{} // This should compile to memclr - cfg.Meta = m - statSpanConfigPool.Put(cfg) + statSpanConfigPool.put(cfg) if !ok { return nil, false From d4c7ec4f163b951f9ef65a07170e686dbe7eb477 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Fri, 20 Mar 2026 09:38:42 +0000 Subject: [PATCH 36/71] fix(ddtrace/tracer): avoid v1 payload corruption by exposing attrs presence to the encoder --- ddtrace/tracer/internal/span_meta.go | 5 +++++ ddtrace/tracer/payload_v1.go | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/ddtrace/tracer/internal/span_meta.go b/ddtrace/tracer/internal/span_meta.go index 76ecf6e07be..51271b9065e 100644 --- a/ddtrace/tracer/internal/span_meta.go +++ b/ddtrace/tracer/internal/span_meta.go @@ -232,6 +232,11 @@ func (sm SpanMeta) Count() int { return len(sm.m) + sm.attrs.Count() } +// AttrCount returns the number of promoted attrs currently set. +func (sm SpanMeta) AttrCount() int { + return sm.attrs.Count() +} + // Merge returns a freshly-allocated map containing all flat map entries plus // all promoted attrs. It always allocates so that the caller owns the map // exclusively — safe to pool or pass to code that retains the map after diff --git a/ddtrace/tracer/payload_v1.go b/ddtrace/tracer/payload_v1.go index b34a259b664..8d5bf03e9b1 100644 --- a/ddtrace/tracer/payload_v1.go +++ b/ddtrace/tracer/payload_v1.go @@ -563,7 +563,9 @@ func (p *payloadV1) encodeSpans(bm bitmap, fieldID int, spans spanList, st *stri // span attributes combine the meta (tags), metrics and meta_struct. // To avoid increased allocations, we serialize attributes immediately without // creating an intermediate map. - size := span.meta.Count() + len(span.metrics) + len(span.metaStruct) + // Promoted attrs (env, version, component, span.kind) are encoded separately + // as fields 13-16 and must not appear in the attributes array. + size := span.meta.Count() - span.meta.AttrCount() + len(span.metrics) + len(span.metaStruct) p.buf = msgp.AppendUint32(p.buf, uint32(9)) // attributes fieldID p.buf = msgp.AppendArrayHeader(p.buf, uint32(size)*3) // number of attributes for k, v := range span.meta.All() { From 9fd77aa3f4f233157695b87b71bfbd65855b02d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Fri, 20 Mar 2026 10:33:59 +0000 Subject: [PATCH 37/71] chore(ddtrace/tracer): revert to simpler design --- ddtrace/tracer/civisibility_tslv.go | 5 +-- ddtrace/tracer/internal/span_meta.go | 12 +++--- ddtrace/tracer/stats.go | 60 +++++++--------------------- 3 files changed, 23 insertions(+), 54 deletions(-) diff --git a/ddtrace/tracer/civisibility_tslv.go b/ddtrace/tracer/civisibility_tslv.go index c853ada9d41..56e57ef943d 100644 --- a/ddtrace/tracer/civisibility_tslv.go +++ b/ddtrace/tracer/civisibility_tslv.go @@ -8,7 +8,6 @@ package tracer import ( - "maps" "strconv" "github.com/tinylib/msgp/msgp" @@ -165,7 +164,7 @@ type ciVisibilityEvent struct { // +checklocksignore — CI visibility event: reads span fields after SetTag releases lock. func (e *ciVisibilityEvent) SetTag(key string, value any) { e.span.SetTag(key, value) - e.Content.Meta = maps.Collect(e.span.meta.All()) + e.Content.Meta = e.span.meta.Merge() e.Content.Metrics = e.span.metrics } @@ -412,7 +411,7 @@ func createTslvSpan(span *Span) tslvSpan { Duration: span.duration, ParentID: span.parentID, Error: span.error, - Meta: maps.Collect(span.meta.All()), + Meta: span.meta.Merge(), Metrics: span.metrics, } } diff --git a/ddtrace/tracer/internal/span_meta.go b/ddtrace/tracer/internal/span_meta.go index 51271b9065e..383e1af3f73 100644 --- a/ddtrace/tracer/internal/span_meta.go +++ b/ddtrace/tracer/internal/span_meta.go @@ -238,15 +238,15 @@ func (sm SpanMeta) AttrCount() int { } // Merge returns a freshly-allocated map containing all flat map entries plus -// all promoted attrs. It always allocates so that the caller owns the map -// exclusively — safe to pool or pass to code that retains the map after -// return. To iterate without allocating, use All(). +// all promoted attrs. When no promoted attrs are set, the internal flat map +// is returned directly without allocating. The caller must not mutate +// the returned map. To iterate without allocating, use All(). func (sm SpanMeta) Merge() map[string]string { - m := make(map[string]string, len(sm.m)+sm.attrs.Count()) - maps.Copy(m, sm.m) if sm.attrs.Count() == 0 { - return m + return sm.m } + m := make(map[string]string, len(sm.m)+sm.attrs.Count()) + maps.Copy(m, sm.m) for _, d := range Defs { if sm.attrs.Has(d.Key) { m[d.Name] = sm.attrs.vals[d.Key] diff --git a/ddtrace/tracer/stats.go b/ddtrace/tracer/stats.go index eab228184ac..64539cd2f71 100644 --- a/ddtrace/tracer/stats.go +++ b/ddtrace/tracer/stats.go @@ -27,34 +27,6 @@ import ( // In the future this can be pulled directly from our obfuscation import. var tracerObfuscationVersion = 1 -type statSpanPool struct{ sync.Pool } - -func (p *statSpanPool) get(s *Span) *stats.StatSpanConfig { - cfg := p.Get().(*stats.StatSpanConfig) - if cfg.Meta == nil { - cfg.Meta = s.meta.Merge() - } else { - for k, v := range s.meta.All() { - cfg.Meta[k] = v - } - } - return cfg -} - -// put returns cfg to the pool. It preserves the Meta map allocation (clearing -// its contents) so subsequent gets can reuse it without allocating. -func (p *statSpanPool) put(cfg *stats.StatSpanConfig) { - m := cfg.Meta - clear(m) - *cfg = stats.StatSpanConfig{} - cfg.Meta = m - p.Put(cfg) -} - -var statSpanConfigPool = &statSpanPool{ - Pool: sync.Pool{New: func() any { return &stats.StatSpanConfig{} }}, -} - // defaultStatsBucketSize specifies the default span of time that will be // covered in one stats bucket. var defaultStatsBucketSize = (10 * time.Second).Nanoseconds() @@ -196,23 +168,21 @@ func (c *concentrator) newTracerStatSpan(s *Span, obfuscator *obfuscate.Obfuscat httpMethod, _ := s.meta.Get(ext.HTTPMethod) httpEndpoint, _ := s.meta.Get(ext.HTTPEndpoint) - cfg := statSpanConfigPool.get(s) - cfg.Service = s.service - cfg.Resource = resource - cfg.Name = s.name - cfg.Type = s.spanType - cfg.ParentID = s.parentID - cfg.Start = s.start - cfg.Duration = s.duration - cfg.Error = s.error - cfg.Metrics = s.metrics - cfg.PeerTags = c.cfg.agent.load().peerTags - cfg.HTTPMethod = httpMethod - cfg.HTTPEndpoint = httpEndpoint - - statSpan, ok := c.spanConcentrator.NewStatSpanWithConfig(*cfg) - statSpanConfigPool.put(cfg) - + statSpan, ok := c.spanConcentrator.NewStatSpanWithConfig(stats.StatSpanConfig{ + Service: s.service, + Resource: resource, + Name: s.name, + Type: s.spanType, + ParentID: s.parentID, + Start: s.start, + Duration: s.duration, + Error: s.error, + Meta: s.meta.Merge(), + Metrics: s.metrics, + PeerTags: c.cfg.agent.load().peerTags, + HTTPMethod: httpMethod, + HTTPEndpoint: httpEndpoint, + }) if !ok { return nil, false } From b15fe0887081d033822512f140173f301a130937 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Fri, 20 Mar 2026 11:56:45 +0000 Subject: [PATCH 38/71] fix(ddtrace/tracer): inline promoted attributes into meta map to reduce allocations and complexity on consumers (not for encoders, but this is ok) --- ddtrace/tracer/internal/span_meta.go | 87 ++++++++++++++++++++++------ ddtrace/tracer/span.go | 4 ++ 2 files changed, 73 insertions(+), 18 deletions(-) diff --git a/ddtrace/tracer/internal/span_meta.go b/ddtrace/tracer/internal/span_meta.go index 383e1af3f73..9c11473b0b9 100644 --- a/ddtrace/tracer/internal/span_meta.go +++ b/ddtrace/tracer/internal/span_meta.go @@ -80,16 +80,18 @@ func (sm *SpanMeta) Normalize() { // Read methods // --------------------------------------------------------------------------- -// Get returns the value for key, checking the flat map then attrs for promoted keys. +// Get returns the value for key. Promoted keys are checked in attrs first +// (fast array+bitmask path), then the flat map. Non-promoted keys go directly +// to the flat map. func (sm SpanMeta) Get(key string) (string, bool) { - if v, ok := sm.m[key]; ok { - return v, ok - } if IsPromotedKeyLen(len(key)) { if v, ok, handled := sm.getPromoted(key); handled { return v, ok } } + if v, ok := sm.m[key]; ok { + return v, ok + } return "", false } @@ -224,12 +226,50 @@ func init() { } // --------------------------------------------------------------------------- -// Counting / iteration — promoted keys are in attrs only, never in m. +// Counting / iteration // --------------------------------------------------------------------------- -// Count returns the total number of entries (flat map + promoted attrs). +// attrsNotInM counts promoted attrs that are set in attrs but not yet in m. +// Returns 0 after Inline() has been called, since Inline() copies all set +// attrs into m. Cost: up to 4 map lookups on a small map. +func (sm SpanMeta) attrsNotInM() int { + if sm.attrs == nil { + return 0 + } + count := 0 + for _, d := range Defs { + if sm.attrs.Has(d.Key) { + if _, inM := sm.m[d.Name]; !inM { + count++ + } + } + } + return count +} + +// Inline copies all promoted attr values into m so that Merge() can return +// sm.m directly without allocating. Call once at span finish, after all +// mutations and before tracer.submit(). Does not clear attrs — attrs remains +// the authoritative fast source for promoted-key reads via Get(). +func (sm *SpanMeta) Inline() { + if sm.attrs == nil || sm.attrs.Count() == 0 { + return + } + if sm.m == nil { + sm.m = make(map[string]string, sm.attrs.Count()) + } + for _, d := range Defs { + if sm.attrs.Has(d.Key) { + sm.m[d.Name] = sm.attrs.vals[d.Key] + } + } +} + +// Count returns the total number of distinct entries (flat map + promoted attrs +// not yet inlined). After Inline(), all promoted attrs are in m, so this equals +// len(m). func (sm SpanMeta) Count() int { - return len(sm.m) + sm.attrs.Count() + return len(sm.m) + sm.attrsNotInM() } // AttrCount returns the number of promoted attrs currently set. @@ -237,15 +277,16 @@ func (sm SpanMeta) AttrCount() int { return sm.attrs.Count() } -// Merge returns a freshly-allocated map containing all flat map entries plus -// all promoted attrs. When no promoted attrs are set, the internal flat map -// is returned directly without allocating. The caller must not mutate +// Merge returns a map containing all flat map entries plus all promoted attrs. +// After Inline() has been called (at span finish), all attrs are already in m +// so sm.m is returned directly — zero allocation. The caller must not mutate // the returned map. To iterate without allocating, use All(). func (sm SpanMeta) Merge() map[string]string { - if sm.attrs.Count() == 0 { + n := sm.attrsNotInM() + if n == 0 { return sm.m } - m := make(map[string]string, len(sm.m)+sm.attrs.Count()) + m := make(map[string]string, len(sm.m)+n) maps.Copy(m, sm.m) for _, d := range Defs { if sm.attrs.Has(d.Key) { @@ -268,6 +309,9 @@ func (sm SpanMeta) All() iter.Seq2[string, string] { if sm.attrs != nil { for _, d := range Defs { if sm.attrs.Has(d.Key) { + if _, inM := sm.m[d.Name]; inM { + continue // already yielded from m + } if !yield(d.Name, sm.attrs.vals[d.Key]) { return } @@ -297,11 +341,12 @@ func (sm SpanMeta) String() string { // msgp codec // --------------------------------------------------------------------------- -// EncodeMsg writes the combined map header (m entries + promoted attrs), -// then emits all map entries followed by promoted attribute entries. +// EncodeMsg writes the combined map header (m entries + promoted attrs not +// already in m), then emits all map entries followed by any not-yet-inlined +// promoted attribute entries. After Inline(), the attrs loop is skipped. func (sm *SpanMeta) EncodeMsg(en *msgp.Writer) error { - total := uint32(len(sm.m) + sm.attrs.Count()) - if err := en.WriteMapHeader(total); err != nil { + n := sm.attrsNotInM() + if err := en.WriteMapHeader(uint32(len(sm.m) + n)); err != nil { return msgp.WrapError(err, "Meta") } for k, v := range sm.m { @@ -312,9 +357,12 @@ func (sm *SpanMeta) EncodeMsg(en *msgp.Writer) error { return msgp.WrapError(err, "Meta", k) } } - if sm.attrs != nil { + if sm.attrs != nil && n > 0 { for _, d := range Defs { if v, ok := sm.attrs.Get(d.Key); ok { + if _, inM := sm.m[d.Name]; inM { + continue // already encoded from m + } if err := en.WriteString(d.Name); err != nil { return msgp.WrapError(err, "Meta") } @@ -361,9 +409,12 @@ func (sm *SpanMeta) Msgsize() int { for k, v := range sm.m { size += msgp.StringPrefixSize + len(k) + msgp.StringPrefixSize + len(v) } - if sm.attrs != nil { + if sm.attrs != nil && sm.attrsNotInM() > 0 { for _, d := range Defs { if v, ok := sm.attrs.Get(d.Key); ok { + if _, inM := sm.m[d.Name]; inM { + continue // already sized from m + } size += msgp.StringPrefixSize + len(d.Name) + msgp.StringPrefixSize + len(v) } } diff --git a/ddtrace/tracer/span.go b/ddtrace/tracer/span.go index e0e2566ccf6..25605234fb3 100644 --- a/ddtrace/tracer/span.go +++ b/ddtrace/tracer/span.go @@ -1052,6 +1052,10 @@ func (s *Span) finish(finishTime int64) { // Lock ordering is span.mu -> trace.mu. s.context.finish() + // Inline promoted attrs into m so that Merge() can return sm.m directly + // (zero allocation) once this span is submitted for encoding. + s.meta.Inline() + // compute stats after finishing the span. This ensures any normalization or tag propagation has been applied if hasTracer { tracer.submit(s) From 3c9e3fa8765ee287de58ade1bc18001dcc574d72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Fri, 20 Mar 2026 12:02:32 +0000 Subject: [PATCH 39/71] chore(ddtrace/tracer): break down big conditional into individual guards --- ddtrace/tracer/internal/span_meta.go | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/ddtrace/tracer/internal/span_meta.go b/ddtrace/tracer/internal/span_meta.go index 9c11473b0b9..bccde7323c3 100644 --- a/ddtrace/tracer/internal/span_meta.go +++ b/ddtrace/tracer/internal/span_meta.go @@ -357,18 +357,22 @@ func (sm *SpanMeta) EncodeMsg(en *msgp.Writer) error { return msgp.WrapError(err, "Meta", k) } } - if sm.attrs != nil && n > 0 { - for _, d := range Defs { - if v, ok := sm.attrs.Get(d.Key); ok { - if _, inM := sm.m[d.Name]; inM { - continue // already encoded from m - } - if err := en.WriteString(d.Name); err != nil { - return msgp.WrapError(err, "Meta") - } - if err := en.WriteString(v); err != nil { - return msgp.WrapError(err, "Meta", d.Name) - } + if sm.attrs == nil { + return nil + } + if n == 0 { + return nil + } + for _, d := range Defs { + if v, ok := sm.attrs.Get(d.Key); ok { + if _, inM := sm.m[d.Name]; inM { + continue // already encoded from m + } + if err := en.WriteString(d.Name); err != nil { + return msgp.WrapError(err, "Meta") + } + if err := en.WriteString(v); err != nil { + return msgp.WrapError(err, "Meta", d.Name) } } } From 80a9c927542334d45932b9669c933901d6d474a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Fri, 20 Mar 2026 12:04:06 +0000 Subject: [PATCH 40/71] chore(ddtrace/tracer): happy path must be left aligned --- ddtrace/tracer/internal/span_meta.go | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/ddtrace/tracer/internal/span_meta.go b/ddtrace/tracer/internal/span_meta.go index bccde7323c3..111de49a03c 100644 --- a/ddtrace/tracer/internal/span_meta.go +++ b/ddtrace/tracer/internal/span_meta.go @@ -363,17 +363,22 @@ func (sm *SpanMeta) EncodeMsg(en *msgp.Writer) error { if n == 0 { return nil } + var ( + v string + ok bool + ) for _, d := range Defs { - if v, ok := sm.attrs.Get(d.Key); ok { - if _, inM := sm.m[d.Name]; inM { - continue // already encoded from m - } - if err := en.WriteString(d.Name); err != nil { - return msgp.WrapError(err, "Meta") - } - if err := en.WriteString(v); err != nil { - return msgp.WrapError(err, "Meta", d.Name) - } + if v, ok = sm.attrs.Get(d.Key); !ok { + continue + } + if _, inM := sm.m[d.Name]; inM { + continue // already encoded from m + } + if err := en.WriteString(d.Name); err != nil { + return msgp.WrapError(err, "Meta") + } + if err := en.WriteString(v); err != nil { + return msgp.WrapError(err, "Meta", d.Name) } } return nil From 35dd1f0e6c2a3639415c46b5262490fc18e00f64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Fri, 20 Mar 2026 13:28:04 +0000 Subject: [PATCH 41/71] chore(ddtrace/tracer): final touches --- ddtrace/tracer/civisibility_tslv.go | 8 +- .../tracer/internal/span_attributes_test.go | 80 +++++++++++++++++++ ddtrace/tracer/internal/span_meta.go | 15 ++-- ddtrace/tracer/payload_v1.go | 3 + ddtrace/tracer/sampler_test.go | 5 +- 5 files changed, 101 insertions(+), 10 deletions(-) diff --git a/ddtrace/tracer/civisibility_tslv.go b/ddtrace/tracer/civisibility_tslv.go index 56e57ef943d..f4f0c2f11d8 100644 --- a/ddtrace/tracer/civisibility_tslv.go +++ b/ddtrace/tracer/civisibility_tslv.go @@ -164,7 +164,9 @@ type ciVisibilityEvent struct { // +checklocksignore — CI visibility event: reads span fields after SetTag releases lock. func (e *ciVisibilityEvent) SetTag(key string, value any) { e.span.SetTag(key, value) - e.Content.Meta = e.span.meta.Merge() + // No need to call Merge() here — it would allocate on every SetTag call. + // Content.Meta is rebuilt once in Finish(), after span.Finish() has + // called Inline(), so Merge() returns sm.m directly (zero allocation). e.Content.Metrics = e.span.metrics } @@ -210,6 +212,10 @@ func (e *ciVisibilityEvent) SetBaggageItem(key, val string) { // opts - Optional finish options. func (e *ciVisibilityEvent) Finish(opts ...FinishOption) { e.span.Finish(opts...) + // span.Finish() calls Inline(), so Merge() returns sm.m directly here — + // no allocation. Rebuild Content.Meta once with the final span state. + e.Content.Meta = e.span.meta.Merge() + e.Content.Metrics = e.span.metrics } // Context returns the span context of the event's span. diff --git a/ddtrace/tracer/internal/span_attributes_test.go b/ddtrace/tracer/internal/span_attributes_test.go index a38cf18f4ac..8a105d2fe73 100644 --- a/ddtrace/tracer/internal/span_attributes_test.go +++ b/ddtrace/tracer/internal/span_attributes_test.go @@ -214,3 +214,83 @@ func BenchmarkSpanAttributesGet(b *testing.B) { _, _ = s, ok }) } + +// TestSpanMetaSetPromotedEmptyString verifies that Set("env", "") on a span with +// no prior env records the key as present (presence bit set), rather than +// silently no-oping because Val() returns "" for unset keys. +func TestSpanMetaSetPromotedEmptyString(t *testing.T) { + sm := NewSpanMeta(nil) + sm.Set("env", "") + v, ok := sm.Get("env") + if !ok { + t.Fatal("expected env to be present after Set(\"env\", \"\"), got absent") + } + if v != "" { + t.Fatalf("expected empty string, got %q", v) + } +} + +// TestSpanMetaSetPromotedNoOpWhenPresent verifies that Set("env", value) when +// env is already set to the same value leaves the value unchanged, and that +// updating to a different value is observed correctly. +func TestSpanMetaSetPromotedNoOpWhenPresent(t *testing.T) { + var a SpanAttributes + a.Set(AttrEnv, "prod") + a.MarkShared() + sm := NewSpanMeta(&a) + + // Same value: result must still be ("prod", true). + sm.Set("env", "prod") + v, ok := sm.Get("env") + if !ok || v != "prod" { + t.Fatalf("no-op case: expected (prod, true), got (%q, %v)", v, ok) + } + + // Different value: must be updated. + sm.Set("env", "staging") + v, ok = sm.Get("env") + if !ok || v != "staging" { + t.Fatalf("update case: expected (staging, true), got (%q, %v)", v, ok) + } +} + +// BenchmarkMerge measures the allocation cost of Merge() before and after Inline(). +// +// Before Inline(), Merge() must allocate a fresh map to combine m and attrs. +// After Inline(), all promoted attrs are already in m and Merge() returns sm.m +// directly — zero allocation. +func BenchmarkMerge(b *testing.B) { + newMeta := func() SpanMeta { + var a SpanAttributes + a.Set(AttrEnv, "prod") + a.Set(AttrVersion, "1.2.3") + a.Set(AttrComponent, "net/http") + a.Set(AttrSpanKind, "server") + sm := NewSpanMeta(&a) + sm.SetMap("key0", "value0") + sm.SetMap("key1", "value1") + sm.SetMap("key2", "value2") + sm.SetMap("key3", "value3") + sm.SetMap("key4", "value4") + return sm + } + + b.Run("before-Inline", func(b *testing.B) { + sm := newMeta() + b.ReportAllocs() + b.ResetTimer() + for range b.N { + _ = sm.Merge() + } + }) + + b.Run("after-Inline", func(b *testing.B) { + sm := newMeta() + sm.Inline() + b.ReportAllocs() + b.ResetTimer() + for range b.N { + _ = sm.Merge() + } + }) +} diff --git a/ddtrace/tracer/internal/span_meta.go b/ddtrace/tracer/internal/span_meta.go index 111de49a03c..0bbee02aadd 100644 --- a/ddtrace/tracer/internal/span_meta.go +++ b/ddtrace/tracer/internal/span_meta.go @@ -34,7 +34,9 @@ var ( // Hot paths (setMetaInit, setMetricLocked) use SetMap/DeleteMap for direct // flat-map access without promoted-key overhead. Callers that may write // promoted keys use Set/Delete or setMetaTagLocked which route to attrs -// via copy-on-write. This ensures promoted keys never appear in m. +// via copy-on-write. This ensures promoted keys never appear in m until +// Inline() is called at span finish, after which m contains all entries +// and Merge() returns sm.m directly without allocating. type SpanMeta struct { m map[string]string attrs *SpanAttributes @@ -156,8 +158,8 @@ func (sm *SpanMeta) setPromoted(key, value string) bool { if !ok { return false } - if sm.attrs != nil && sm.attrs.Val(ak) == value { - return true // no-op: value already matches + if sm.attrs != nil && sm.attrs.Has(ak) && sm.attrs.Val(ak) == value { + return true // no-op: key is present and value already matches } sm.ensureAttrsLocal() sm.attrs.Set(ak, value) @@ -357,10 +359,7 @@ func (sm *SpanMeta) EncodeMsg(en *msgp.Writer) error { return msgp.WrapError(err, "Meta", k) } } - if sm.attrs == nil { - return nil - } - if n == 0 { + if sm.attrs == nil || n == 0 { return nil } var ( @@ -418,7 +417,7 @@ func (sm *SpanMeta) Msgsize() int { for k, v := range sm.m { size += msgp.StringPrefixSize + len(k) + msgp.StringPrefixSize + len(v) } - if sm.attrs != nil && sm.attrsNotInM() > 0 { + if n := sm.attrsNotInM(); sm.attrs != nil && n > 0 { for _, d := range Defs { if v, ok := sm.attrs.Get(d.Key); ok { if _, inM := sm.m[d.Name]; inM { diff --git a/ddtrace/tracer/payload_v1.go b/ddtrace/tracer/payload_v1.go index 8d5bf03e9b1..e59c23dae37 100644 --- a/ddtrace/tracer/payload_v1.go +++ b/ddtrace/tracer/payload_v1.go @@ -568,6 +568,9 @@ func (p *payloadV1) encodeSpans(bm bitmap, fieldID int, spans spanList, st *stri size := span.meta.Count() - span.meta.AttrCount() + len(span.metrics) + len(span.metaStruct) p.buf = msgp.AppendUint32(p.buf, uint32(9)) // attributes fieldID p.buf = msgp.AppendArrayHeader(p.buf, uint32(size)*3) // number of attributes + // After Inline(), promoted keys are present in both m and attrs. + // All() yields them from m, so we must guard here to avoid + // double-encoding them in fields 13-16. for k, v := range span.meta.All() { if _, ok := tinternal.AttrKeyForTag(k); ok { continue // promoted attrs encoded separately as fields 13-16 diff --git a/ddtrace/tracer/sampler_test.go b/ddtrace/tracer/sampler_test.go index bc247cd3ed0..16a6768149b 100644 --- a/ddtrace/tracer/sampler_test.go +++ b/ddtrace/tracer/sampler_test.go @@ -76,7 +76,10 @@ func TestPrioritySampler(t *testing.T) { assert.Equal("my-service2", s.service) v, ok := s.meta.Get(ext.Environment) assert.Equal("", v) - assert.True(ok) // set to empty string, not absent + // SetTag always sets the presence bit, even for empty string, so ok=true. + // getRate uses only the string value, so "" and absent both fall through + // to the default rate — the behaviour is unchanged from the old code. + assert.True(ok) }) t.Run("ops", func(t *testing.T) { From d4ad3e8d7eefe8ebd81e704b2578a9ab35c395b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Mon, 23 Mar 2026 07:35:03 +0000 Subject: [PATCH 42/71] fix(ddtrace/tracer): run SpanMeta.Inline before SpanContext.finish to avoid race condition --- ddtrace/tracer/span.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/ddtrace/tracer/span.go b/ddtrace/tracer/span.go index 25605234fb3..49dc76e77e9 100644 --- a/ddtrace/tracer/span.go +++ b/ddtrace/tracer/span.go @@ -1047,15 +1047,16 @@ func (s *Span) finish(finishTime int64) { log.Debug("Finished Span: %v, Operation: %s, Resource: %s, Tags: %v, %v", //nolint:gocritic // Debug logging needs full span representation s, s.name, s.resource, s.meta, s.metrics) } + // Inline promoted attrs into m before context.finish() so that by the time + // the trace is flushed to the writer, sm.m is fully populated and the + // serialization path (Msgsize/MarshalMsg) cannot race with Inline(). + s.meta.Inline() + // Call context.finish() which handles trace-level bookkeeping and may modify // this span (to set trace-level tags). // Lock ordering is span.mu -> trace.mu. s.context.finish() - // Inline promoted attrs into m so that Merge() can return sm.m directly - // (zero allocation) once this span is submitted for encoding. - s.meta.Inline() - // compute stats after finishing the span. This ensures any normalization or tag propagation has been applied if hasTracer { tracer.submit(s) From 1bd2a4afb7b0d1fb2438f8a22ffa7828bd750172 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Mon, 23 Mar 2026 07:35:32 +0000 Subject: [PATCH 43/71] fix(ddtrace/tracer): hold span lock on CIVis when reading inner span --- ddtrace/tracer/civisibility_tslv.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ddtrace/tracer/civisibility_tslv.go b/ddtrace/tracer/civisibility_tslv.go index f4f0c2f11d8..00ea71e10a0 100644 --- a/ddtrace/tracer/civisibility_tslv.go +++ b/ddtrace/tracer/civisibility_tslv.go @@ -214,8 +214,11 @@ func (e *ciVisibilityEvent) Finish(opts ...FinishOption) { e.span.Finish(opts...) // span.Finish() calls Inline(), so Merge() returns sm.m directly here — // no allocation. Rebuild Content.Meta once with the final span state. + // Hold the span lock to avoid racing with the serialization worker. + e.span.mu.Lock() e.Content.Meta = e.span.meta.Merge() e.Content.Metrics = e.span.metrics + e.span.mu.Unlock() } // Context returns the span context of the event's span. From 091e0f4226a4155297105bee3982af43dc07717f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Mon, 23 Mar 2026 08:11:24 +0000 Subject: [PATCH 44/71] fix(ddtrace/tracer): adapt TestStatsIncludeServiceSource to new SpanMeta --- ddtrace/tracer/stats_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ddtrace/tracer/stats_test.go b/ddtrace/tracer/stats_test.go index 8d1f7c718a9..479dbfb97b3 100644 --- a/ddtrace/tracer/stats_test.go +++ b/ddtrace/tracer/stats_test.go @@ -360,9 +360,9 @@ func TestStatsIncludeServiceSource(t *testing.T) { start: time.Now().UnixNano(), duration: int64(time.Millisecond), metrics: map[string]float64{keyMeasured: 1}, - meta: map[string]string{ + meta: tinternal.NewSpanMetaFromMap(map[string]string{ ext.KeyServiceSource: "m", - }, + }), } transport := newDummyTransport() c := newConcentrator(newTestConfigWithTransport(t, transport), bucketSize, &statsd.NoOpClientDirect{}) From e6b737fb688691d18ff477c87cf1ca037add9075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Mon, 23 Mar 2026 16:33:07 +0000 Subject: [PATCH 45/71] fix(ddtrace/tracer): remove redundant assert.RWMutextLocked --- ddtrace/tracer/span.go | 1 - 1 file changed, 1 deletion(-) diff --git a/ddtrace/tracer/span.go b/ddtrace/tracer/span.go index 49dc76e77e9..d41e9e8910a 100644 --- a/ddtrace/tracer/span.go +++ b/ddtrace/tracer/span.go @@ -736,7 +736,6 @@ func (s *Span) setMetaLocked(key, v string) { // the key may be any user-supplied string. // +checklocks:s.mu func (s *Span) setMetaTagLocked(key, v string) { - assert.RWMutexLocked(&s.mu) if tinternal.IsPromotedKeyLen(len(key)) { if _, ok := tinternal.AttrKeyForTag(key); ok { delete(s.metrics, key) From 5b45c5ceb4f993584a6d67a74092174df133ac76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Mon, 23 Mar 2026 16:35:25 +0000 Subject: [PATCH 46/71] fix(ddtrace/tracer): add inlined bool, RangeInlined, and Attr to optimize performance --- ddtrace/tracer/internal/span_meta.go | 39 ++++++++++++++++++---------- ddtrace/tracer/payload_v1.go | 34 ++++++++++++++---------- 2 files changed, 45 insertions(+), 28 deletions(-) diff --git a/ddtrace/tracer/internal/span_meta.go b/ddtrace/tracer/internal/span_meta.go index 0bbee02aadd..954df394318 100644 --- a/ddtrace/tracer/internal/span_meta.go +++ b/ddtrace/tracer/internal/span_meta.go @@ -38,8 +38,9 @@ var ( // Inline() is called at span finish, after which m contains all entries // and Merge() returns sm.m directly without allocating. type SpanMeta struct { - m map[string]string - attrs *SpanAttributes + m map[string]string + attrs *SpanAttributes + inlined bool } // NewSpanMeta returns a SpanMeta initialized with shared attrs (used during span creation). @@ -115,6 +116,22 @@ func (sm SpanMeta) Has(key string) bool { return ok } +// Attr returns a promoted attribute value by AttrKey. O(1) array index + bitmask. +func (sm SpanMeta) Attr(key AttrKey) (string, bool) { + return sm.attrs.Get(key) +} + +// RangeInlined calls fn for each entry in the flat map. After Inline(), this includes +// promoted attrs. fn must be a named function, not a closure, to avoid +// heap-allocating the function value. Iteration stops if fn returns false. +func (sm SpanMeta) RangeInlined(fn func(k, v string) bool) { + for k, v := range sm.m { + if !fn(k, v) { + return + } + } +} + // --------------------------------------------------------------------------- // Write methods — fast (map-only) and full (promoted-aware) variants. // --------------------------------------------------------------------------- @@ -232,21 +249,14 @@ func init() { // --------------------------------------------------------------------------- // attrsNotInM counts promoted attrs that are set in attrs but not yet in m. -// Returns 0 after Inline() has been called, since Inline() copies all set -// attrs into m. Cost: up to 4 map lookups on a small map. +// Returns 0 after Inline() has been called. Relies on the invariant that +// promoted keys are never written to m directly (only via Inline), so the +// count equals attrs.Count() until Inline() sets the inlined flag. func (sm SpanMeta) attrsNotInM() int { - if sm.attrs == nil { + if sm.inlined || sm.attrs == nil { return 0 } - count := 0 - for _, d := range Defs { - if sm.attrs.Has(d.Key) { - if _, inM := sm.m[d.Name]; !inM { - count++ - } - } - } - return count + return sm.attrs.Count() } // Inline copies all promoted attr values into m so that Merge() can return @@ -265,6 +275,7 @@ func (sm *SpanMeta) Inline() { sm.m[d.Name] = sm.attrs.vals[d.Key] } } + sm.inlined = true } // Count returns the total number of distinct entries (flat map + promoted attrs diff --git a/ddtrace/tracer/payload_v1.go b/ddtrace/tracer/payload_v1.go index e59c23dae37..20eb5de433e 100644 --- a/ddtrace/tracer/payload_v1.go +++ b/ddtrace/tracer/payload_v1.go @@ -569,16 +569,9 @@ func (p *payloadV1) encodeSpans(bm bitmap, fieldID int, spans spanList, st *stri p.buf = msgp.AppendUint32(p.buf, uint32(9)) // attributes fieldID p.buf = msgp.AppendArrayHeader(p.buf, uint32(size)*3) // number of attributes // After Inline(), promoted keys are present in both m and attrs. - // All() yields them from m, so we must guard here to avoid - // double-encoding them in fields 13-16. - for k, v := range span.meta.All() { - if _, ok := tinternal.AttrKeyForTag(k); ok { - continue // promoted attrs encoded separately as fields 13-16 - } - p.buf = st.serialize(k, p.buf) - p.buf = msgp.AppendUint32(p.buf, uint32(StringValueType)) - p.buf = st.serialize(v, p.buf) - } + // Range iterates m directly; we must skip promoted keys here to + // avoid double-encoding them (they are encoded as fields 13-16). + span.meta.RangeInlined(p.encodeMetaEntry) for k, v := range span.metrics { p.buf = st.serialize(k, p.buf) p.buf = msgp.AppendUint32(p.buf, uint32(FloatValueType)) @@ -603,10 +596,10 @@ func (p *payloadV1) encodeSpans(bm bitmap, fieldID int, spans spanList, st *stri // val() is used: an absent key and an empty value are both encoded as an // empty string, so the "was it set?" distinction is irrelevant for wire encoding. var ( - env, _ = span.meta.Get(ext.Environment) - version, _ = span.meta.Get(ext.Version) - component, _ = span.meta.Get(ext.Component) - spanKind, _ = span.meta.Get(ext.SpanKind) + env, _ = span.meta.Attr(tinternal.AttrEnv) + version, _ = span.meta.Attr(tinternal.AttrVersion) + component, _ = span.meta.Attr(tinternal.AttrComponent) + spanKind, _ = span.meta.Attr(tinternal.AttrSpanKind) ) p.buf = encodeField(p.buf, fullSetBitmap, 13, env, st) p.buf = encodeField(p.buf, fullSetBitmap, 14, version, st) @@ -616,6 +609,19 @@ func (p *payloadV1) encodeSpans(bm bitmap, fieldID int, spans spanList, st *stri return true, nil } +// encodeMetaEntry is a named callback for SpanMeta.Range. It encodes one +// non-promoted meta entry into p.buf using p.st. Promoted keys (env, version, +// component, span.kind) are skipped; they are encoded as dedicated fields 13-16. +func (p *payloadV1) encodeMetaEntry(k, v string) bool { + if _, ok := tinternal.AttrKeyForTag(k); ok { + return true // skip; encoded as fields 13-16 + } + p.buf = p.st.serialize(k, p.buf) + p.buf = msgp.AppendUint32(p.buf, uint32(StringValueType)) + p.buf = p.st.serialize(v, p.buf) + return true +} + // translate a span kind string to its uint32 value func getSpanKindValue(sk string) uint32 { switch sk { From ef0e5638dcc60e9873cf3e0a81c1d31b5df4d676 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Tue, 24 Mar 2026 15:25:27 +0000 Subject: [PATCH 47/71] refactor(ddtrace/tracer): use `tracerinternal` instead of `tinternal` --- ddtrace/tracer/span.go | 4 ++-- ddtrace/tracer/spancontext.go | 4 ++-- ddtrace/tracer/tracer.go | 18 +++++++++--------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/ddtrace/tracer/span.go b/ddtrace/tracer/span.go index d41e9e8910a..8031f1a8e99 100644 --- a/ddtrace/tracer/span.go +++ b/ddtrace/tracer/span.go @@ -25,7 +25,7 @@ import ( "time" "github.com/DataDog/dd-trace-go/v2/ddtrace/ext" - tinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" + traceinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" "github.com/DataDog/dd-trace-go/v2/instrumentation/errortrace" sharedinternal "github.com/DataDog/dd-trace-go/v2/internal" "github.com/DataDog/dd-trace-go/v2/internal/env" @@ -142,7 +142,7 @@ type Span struct { // meta holds string metadata. Promoted attributes (env, version, component, // span.kind) live in meta.attrs and are excluded from meta.m; the custom // msgp codec merges both for wire encoding. - meta tinternal.SpanMeta `msg:"meta,omitempty"` // arbitrary map of metadata + promoted attrs + meta traceinternal.SpanMeta `msg:"meta,omitempty"` // arbitrary map of metadata + promoted attrs // +checklocks:mu metaStruct metaStructMap `msg:"meta_struct,omitempty"` // arbitrary map of metadata with structured values // +checklocks:mu diff --git a/ddtrace/tracer/spancontext.go b/ddtrace/tracer/spancontext.go index 95eacbc9843..63334848904 100644 --- a/ddtrace/tracer/spancontext.go +++ b/ddtrace/tracer/spancontext.go @@ -17,7 +17,7 @@ import ( "github.com/DataDog/dd-trace-go/v2/ddtrace" "github.com/DataDog/dd-trace-go/v2/ddtrace/ext" "github.com/DataDog/dd-trace-go/v2/ddtrace/internal/tracerstats" - tinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" + traceinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" sharedinternal "github.com/DataDog/dd-trace-go/v2/internal" internalconfig "github.com/DataDog/dd-trace-go/v2/internal/config" "github.com/DataDog/dd-trace-go/v2/internal/locking" @@ -907,7 +907,7 @@ The mapping is as follows: - s3: .s3..amazonaws.com (if Bucket param present) s3..amazonaws.com (otherwise) */ -func deriveAWSPeerService(sm tinternal.SpanMeta) string { +func deriveAWSPeerService(sm traceinternal.SpanMeta) string { service, ok := sm.Get(ext.AWSService) if !ok { return "" diff --git a/ddtrace/tracer/tracer.go b/ddtrace/tracer/tracer.go index 2ff6e7165e6..be8332bf7c6 100644 --- a/ddtrace/tracer/tracer.go +++ b/ddtrace/tracer/tracer.go @@ -26,7 +26,7 @@ import ( "github.com/DataDog/dd-trace-go/v2/ddtrace/ext" "github.com/DataDog/dd-trace-go/v2/ddtrace/internal/tracerstats" - tinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" + traceinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" globalinternal "github.com/DataDog/dd-trace-go/v2/internal" "github.com/DataDog/dd-trace-go/v2/internal/appsec" appsecConfig "github.com/DataDog/dd-trace-go/v2/internal/appsec/config" @@ -182,13 +182,13 @@ type tracer struct { // When universalVersion=true it includes env+version; otherwise env only. // All spans start by sharing this pointer; copy-on-write clones it // only when a span needs to set per-span fields (component, spanKind). - sharedAttrs tinternal.SpanAttributes + sharedAttrs traceinternal.SpanAttributes // sharedAttrsForMainSvc is like sharedAttrs but always includes version // (when a version is configured). It is used for main-service spans when // universalVersion=false so that the version write in StartSpan is a // copy-on-write no-op rather than triggering an unnecessary Clone. - sharedAttrsForMainSvc tinternal.SpanAttributes + sharedAttrsForMainSvc traceinternal.SpanAttributes } type dynInstSubscriptions struct { @@ -500,18 +500,18 @@ func newUnstartedTracer(opts ...StartOption) (t *tracer, err error) { // Process-level values (env, version) are set once here; // spans share this pointer and only clone on per-span overrides. if env := c.internalConfig.Env(); env != "" { - t.sharedAttrs.Set(tinternal.AttrEnv, env) - t.sharedAttrsForMainSvc.Set(tinternal.AttrEnv, env) + t.sharedAttrs.Set(traceinternal.AttrEnv, env) + t.sharedAttrsForMainSvc.Set(traceinternal.AttrEnv, env) } if ver := c.internalConfig.Version(); ver != "" { if c.universalVersion { // universalVersion=true: all spans get version via sharedAttrs. - t.sharedAttrs.Set(tinternal.AttrVersion, ver) + t.sharedAttrs.Set(traceinternal.AttrVersion, ver) } // Always pre-populate sharedAttrsForMainSvc with version so that // main-service spans in non-universal mode get a COW no-op rather // than a Clone when StartSpan applies version (see below). - t.sharedAttrsForMainSvc.Set(tinternal.AttrVersion, ver) + t.sharedAttrsForMainSvc.Set(traceinternal.AttrVersion, ver) } t.sharedAttrs.MarkShared() t.sharedAttrsForMainSvc.MarkShared() @@ -768,7 +768,7 @@ func (t *tracer) pushChunk(trace *chunk) { } // +checklocksignore — Initialization time, span not yet shared. -func spanStart(operationName string, sharedAttrs *tinternal.SpanAttributes, options ...StartSpanOption) *Span { +func spanStart(operationName string, sharedAttrs *traceinternal.SpanAttributes, options ...StartSpanOption) *Span { var opts StartSpanConfig for _, fn := range options { if fn == nil { @@ -833,7 +833,7 @@ func spanStart(operationName string, sharedAttrs *tinternal.SpanAttributes, opti traceID: id, start: startTime, integration: "manual", - meta: tinternal.NewSpanMeta(sharedAttrs), // COW: shared until a per-span field is set + meta: traceinternal.NewSpanMeta(sharedAttrs), // COW: shared until a per-span field is set } span.spanLinks = append(span.spanLinks, opts.SpanLinks...) From 5fe440777987968c61a0047c3c4a136e3709939a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Tue, 24 Mar 2026 15:27:42 +0000 Subject: [PATCH 48/71] refactor(ddtrace/tracer): remove `SpanMeta.Inline()` --- ddtrace/tracer/civisibility_tslv.go | 8 ++------ ddtrace/tracer/span.go | 5 ----- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/ddtrace/tracer/civisibility_tslv.go b/ddtrace/tracer/civisibility_tslv.go index 00ea71e10a0..e7b7499b204 100644 --- a/ddtrace/tracer/civisibility_tslv.go +++ b/ddtrace/tracer/civisibility_tslv.go @@ -164,9 +164,6 @@ type ciVisibilityEvent struct { // +checklocksignore — CI visibility event: reads span fields after SetTag releases lock. func (e *ciVisibilityEvent) SetTag(key string, value any) { e.span.SetTag(key, value) - // No need to call Merge() here — it would allocate on every SetTag call. - // Content.Meta is rebuilt once in Finish(), after span.Finish() has - // called Inline(), so Merge() returns sm.m directly (zero allocation). e.Content.Metrics = e.span.metrics } @@ -212,9 +209,8 @@ func (e *ciVisibilityEvent) SetBaggageItem(key, val string) { // opts - Optional finish options. func (e *ciVisibilityEvent) Finish(opts ...FinishOption) { e.span.Finish(opts...) - // span.Finish() calls Inline(), so Merge() returns sm.m directly here — - // no allocation. Rebuild Content.Meta once with the final span state. - // Hold the span lock to avoid racing with the serialization worker. + // Rebuild Content.Meta once with the final span state, under the span + // lock to avoid racing with the serialization worker. e.span.mu.Lock() e.Content.Meta = e.span.meta.Merge() e.Content.Metrics = e.span.metrics diff --git a/ddtrace/tracer/span.go b/ddtrace/tracer/span.go index 8031f1a8e99..f37a31c15bb 100644 --- a/ddtrace/tracer/span.go +++ b/ddtrace/tracer/span.go @@ -1046,11 +1046,6 @@ func (s *Span) finish(finishTime int64) { log.Debug("Finished Span: %v, Operation: %s, Resource: %s, Tags: %v, %v", //nolint:gocritic // Debug logging needs full span representation s, s.name, s.resource, s.meta, s.metrics) } - // Inline promoted attrs into m before context.finish() so that by the time - // the trace is flushed to the writer, sm.m is fully populated and the - // serialization path (Msgsize/MarshalMsg) cannot race with Inline(). - s.meta.Inline() - // Call context.finish() which handles trace-level bookkeeping and may modify // this span (to set trace-level tags). // Lock ordering is span.mu -> trace.mu. From b3d0f7f05d5623659c77539c5208f3fffad411f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Tue, 24 Mar 2026 15:28:43 +0000 Subject: [PATCH 49/71] feat(ddtrace/tracer): simplify API and don't expose internals --- .../tracer/internal/span_attributes_test.go | 55 +++---- ddtrace/tracer/internal/span_meta.go | 141 ++++++++---------- ddtrace/tracer/payload_v1.go | 25 ++-- ddtrace/tracer/span.go | 15 +- 4 files changed, 97 insertions(+), 139 deletions(-) diff --git a/ddtrace/tracer/internal/span_attributes_test.go b/ddtrace/tracer/internal/span_attributes_test.go index 8a105d2fe73..dcec82d1d69 100644 --- a/ddtrace/tracer/internal/span_attributes_test.go +++ b/ddtrace/tracer/internal/span_attributes_test.go @@ -254,43 +254,24 @@ func TestSpanMetaSetPromotedNoOpWhenPresent(t *testing.T) { } } -// BenchmarkMerge measures the allocation cost of Merge() before and after Inline(). -// -// Before Inline(), Merge() must allocate a fresh map to combine m and attrs. -// After Inline(), all promoted attrs are already in m and Merge() returns sm.m -// directly — zero allocation. +// BenchmarkMerge measures the allocation cost of Merge() with both tag store +// entries and promoted attrs set. func BenchmarkMerge(b *testing.B) { - newMeta := func() SpanMeta { - var a SpanAttributes - a.Set(AttrEnv, "prod") - a.Set(AttrVersion, "1.2.3") - a.Set(AttrComponent, "net/http") - a.Set(AttrSpanKind, "server") - sm := NewSpanMeta(&a) - sm.SetMap("key0", "value0") - sm.SetMap("key1", "value1") - sm.SetMap("key2", "value2") - sm.SetMap("key3", "value3") - sm.SetMap("key4", "value4") - return sm - } - - b.Run("before-Inline", func(b *testing.B) { - sm := newMeta() - b.ReportAllocs() - b.ResetTimer() - for range b.N { - _ = sm.Merge() - } - }) + var a SpanAttributes + a.Set(AttrEnv, "prod") + a.Set(AttrVersion, "1.2.3") + a.Set(AttrComponent, "net/http") + a.Set(AttrSpanKind, "server") + sm := NewSpanMeta(&a) + sm.SetMap("key0", "value0") + sm.SetMap("key1", "value1") + sm.SetMap("key2", "value2") + sm.SetMap("key3", "value3") + sm.SetMap("key4", "value4") - b.Run("after-Inline", func(b *testing.B) { - sm := newMeta() - sm.Inline() - b.ReportAllocs() - b.ResetTimer() - for range b.N { - _ = sm.Merge() - } - }) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + _ = sm.Merge() + } } diff --git a/ddtrace/tracer/internal/span_meta.go b/ddtrace/tracer/internal/span_meta.go index 954df394318..eb76795567d 100644 --- a/ddtrace/tracer/internal/span_meta.go +++ b/ddtrace/tracer/internal/span_meta.go @@ -8,7 +8,6 @@ package internal import ( "fmt" "iter" - "maps" "strings" "github.com/tinylib/msgp/msgp" @@ -32,15 +31,12 @@ var ( // transparently so the wire format is unchanged. // // Hot paths (setMetaInit, setMetricLocked) use SetMap/DeleteMap for direct -// flat-map access without promoted-key overhead. Callers that may write -// promoted keys use Set/Delete or setMetaTagLocked which route to attrs -// via copy-on-write. This ensures promoted keys never appear in m until -// Inline() is called at span finish, after which m contains all entries -// and Merge() returns sm.m directly without allocating. +// tag-store access without promoted-key overhead. Callers that may write +// promoted keys use Set/Delete or setMetaTagLocked which route to attrs via +// copy-on-write. Promoted keys never appear in sm.m. type SpanMeta struct { - m map[string]string - attrs *SpanAttributes - inlined bool + m map[string]string + attrs *SpanAttributes } // NewSpanMeta returns a SpanMeta initialized with shared attrs (used during span creation). @@ -121,10 +117,32 @@ func (sm SpanMeta) Attr(key AttrKey) (string, bool) { return sm.attrs.Get(key) } -// RangeInlined calls fn for each entry in the flat map. After Inline(), this includes -// promoted attrs. fn must be a named function, not a closure, to avoid -// heap-allocating the function value. Iteration stops if fn returns false. -func (sm SpanMeta) RangeInlined(fn func(k, v string) bool) { +// Env returns the value of the "env" promoted attribute. +func (sm SpanMeta) Env() (string, bool) { return sm.attrs.Get(AttrEnv) } + +// Version returns the value of the "version" promoted attribute. +func (sm SpanMeta) Version() (string, bool) { return sm.attrs.Get(AttrVersion) } + +// Component returns the value of the "component" promoted attribute. +func (sm SpanMeta) Component() (string, bool) { return sm.attrs.Get(AttrComponent) } + +// SpanKind returns the value of the "span.kind" promoted attribute. +func (sm SpanMeta) SpanKind() (string, bool) { return sm.attrs.Get(AttrSpanKind) } + +// SetIfPromoted stores key→value in the promoted-attribute store if key is a +// promoted attribute name (env, version, component, span.kind). Returns true +// when the key was handled; callers can skip their normal tag-store path. +func (sm *SpanMeta) SetIfPromoted(key, value string) bool { + if !IsPromotedKeyLen(len(key)) { + return false + } + return sm.setPromoted(key, value) +} + +// Range calls fn for each entry in the flat map (sm.m). +// Promoted attrs live in sm.attrs and are not yielded here. +// Iteration stops if fn returns false. +func (sm SpanMeta) Range(fn func(k, v string) bool) { for k, v := range sm.m { if !fn(k, v) { return @@ -166,8 +184,8 @@ func (sm *SpanMeta) Set(key, value string) { sm.m[key] = value } -// setPromoted is the slow path for Set when the key might be a promoted attribute. -// Returns true if the key was handled (set or skipped as no-op). +// setPromoted is the slow path for Set and SetIfPromoted when the key might be +// a promoted attribute. Returns true if the key was handled (set or no-op). // //go:noinline func (sm *SpanMeta) setPromoted(key, value string) bool { @@ -248,41 +266,9 @@ func init() { // Counting / iteration // --------------------------------------------------------------------------- -// attrsNotInM counts promoted attrs that are set in attrs but not yet in m. -// Returns 0 after Inline() has been called. Relies on the invariant that -// promoted keys are never written to m directly (only via Inline), so the -// count equals attrs.Count() until Inline() sets the inlined flag. -func (sm SpanMeta) attrsNotInM() int { - if sm.inlined || sm.attrs == nil { - return 0 - } - return sm.attrs.Count() -} - -// Inline copies all promoted attr values into m so that Merge() can return -// sm.m directly without allocating. Call once at span finish, after all -// mutations and before tracer.submit(). Does not clear attrs — attrs remains -// the authoritative fast source for promoted-key reads via Get(). -func (sm *SpanMeta) Inline() { - if sm.attrs == nil || sm.attrs.Count() == 0 { - return - } - if sm.m == nil { - sm.m = make(map[string]string, sm.attrs.Count()) - } - for _, d := range Defs { - if sm.attrs.Has(d.Key) { - sm.m[d.Name] = sm.attrs.vals[d.Key] - } - } - sm.inlined = true -} - -// Count returns the total number of distinct entries (flat map + promoted attrs -// not yet inlined). After Inline(), all promoted attrs are in m, so this equals -// len(m). +// Count returns the total number of distinct entries (flat map + promoted attrs). func (sm SpanMeta) Count() int { - return len(sm.m) + sm.attrsNotInM() + return len(sm.m) + sm.attrs.Count() } // AttrCount returns the number of promoted attrs currently set. @@ -290,17 +276,25 @@ func (sm SpanMeta) AttrCount() int { return sm.attrs.Count() } -// Merge returns a map containing all flat map entries plus all promoted attrs. -// After Inline() has been called (at span finish), all attrs are already in m -// so sm.m is returned directly — zero allocation. The caller must not mutate -// the returned map. To iterate without allocating, use All(). +// SerializableCount returns the number of flat-map entries that appear in the +// serialized attributes array. Promoted attrs are encoded as dedicated fields +// in the v1 protocol and must not be double-counted. +func (sm SpanMeta) SerializableCount() int { + return len(sm.m) +} + +// Merge returns a new map containing all flat-map entries plus all promoted +// attrs. Always allocates — never returns sm.m directly, avoiding races when +// the result is placed into a pooled struct. func (sm SpanMeta) Merge() map[string]string { - n := sm.attrsNotInM() + n := len(sm.m) + sm.attrs.Count() if n == 0 { - return sm.m + return nil + } + m := make(map[string]string, n) + for k, v := range sm.m { + m[k] = v } - m := make(map[string]string, len(sm.m)+n) - maps.Copy(m, sm.m) for _, d := range Defs { if sm.attrs.Has(d.Key) { m[d.Name] = sm.attrs.vals[d.Key] @@ -319,15 +313,13 @@ func (sm SpanMeta) All() iter.Seq2[string, string] { return } } - if sm.attrs != nil { - for _, d := range Defs { - if sm.attrs.Has(d.Key) { - if _, inM := sm.m[d.Name]; inM { - continue // already yielded from m - } - if !yield(d.Name, sm.attrs.vals[d.Key]) { - return - } + if sm.attrs == nil { + return + } + for _, d := range Defs { + if sm.attrs.Has(d.Key) { + if !yield(d.Name, sm.attrs.vals[d.Key]) { + return } } } @@ -354,11 +346,10 @@ func (sm SpanMeta) String() string { // msgp codec // --------------------------------------------------------------------------- -// EncodeMsg writes the combined map header (m entries + promoted attrs not -// already in m), then emits all map entries followed by any not-yet-inlined -// promoted attribute entries. After Inline(), the attrs loop is skipped. +// EncodeMsg writes the map header (flat map entries + promoted attrs), +// then emits all flat map entries followed by all set promoted attrs. func (sm *SpanMeta) EncodeMsg(en *msgp.Writer) error { - n := sm.attrsNotInM() + n := sm.attrs.Count() if err := en.WriteMapHeader(uint32(len(sm.m) + n)); err != nil { return msgp.WrapError(err, "Meta") } @@ -370,7 +361,7 @@ func (sm *SpanMeta) EncodeMsg(en *msgp.Writer) error { return msgp.WrapError(err, "Meta", k) } } - if sm.attrs == nil || n == 0 { + if n == 0 { return nil } var ( @@ -381,9 +372,6 @@ func (sm *SpanMeta) EncodeMsg(en *msgp.Writer) error { if v, ok = sm.attrs.Get(d.Key); !ok { continue } - if _, inM := sm.m[d.Name]; inM { - continue // already encoded from m - } if err := en.WriteString(d.Name); err != nil { return msgp.WrapError(err, "Meta") } @@ -428,12 +416,9 @@ func (sm *SpanMeta) Msgsize() int { for k, v := range sm.m { size += msgp.StringPrefixSize + len(k) + msgp.StringPrefixSize + len(v) } - if n := sm.attrsNotInM(); sm.attrs != nil && n > 0 { + if n := sm.attrs.Count(); n > 0 { for _, d := range Defs { if v, ok := sm.attrs.Get(d.Key); ok { - if _, inM := sm.m[d.Name]; inM { - continue // already sized from m - } size += msgp.StringPrefixSize + len(d.Name) + msgp.StringPrefixSize + len(v) } } diff --git a/ddtrace/tracer/payload_v1.go b/ddtrace/tracer/payload_v1.go index 20eb5de433e..e6a83edcdda 100644 --- a/ddtrace/tracer/payload_v1.go +++ b/ddtrace/tracer/payload_v1.go @@ -16,7 +16,6 @@ import ( "github.com/tinylib/msgp/msgp" "github.com/DataDog/dd-trace-go/v2/ddtrace/ext" - tinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" "github.com/DataDog/dd-trace-go/v2/internal/log" "github.com/DataDog/dd-trace-go/v2/internal/processtags" ) @@ -565,13 +564,12 @@ func (p *payloadV1) encodeSpans(bm bitmap, fieldID int, spans spanList, st *stri // creating an intermediate map. // Promoted attrs (env, version, component, span.kind) are encoded separately // as fields 13-16 and must not appear in the attributes array. - size := span.meta.Count() - span.meta.AttrCount() + len(span.metrics) + len(span.metaStruct) + size := span.meta.SerializableCount() + len(span.metrics) + len(span.metaStruct) p.buf = msgp.AppendUint32(p.buf, uint32(9)) // attributes fieldID p.buf = msgp.AppendArrayHeader(p.buf, uint32(size)*3) // number of attributes - // After Inline(), promoted keys are present in both m and attrs. - // Range iterates m directly; we must skip promoted keys here to - // avoid double-encoding them (they are encoded as fields 13-16). - span.meta.RangeInlined(p.encodeMetaEntry) + // Promoted keys live only in attrs, never in the tag store, so + // encodeMetaEntry skipping them is a safety guard, not a dedup. + span.meta.Range(p.encodeMetaEntry) for k, v := range span.metrics { p.buf = st.serialize(k, p.buf) p.buf = msgp.AppendUint32(p.buf, uint32(FloatValueType)) @@ -596,10 +594,10 @@ func (p *payloadV1) encodeSpans(bm bitmap, fieldID int, spans spanList, st *stri // val() is used: an absent key and an empty value are both encoded as an // empty string, so the "was it set?" distinction is irrelevant for wire encoding. var ( - env, _ = span.meta.Attr(tinternal.AttrEnv) - version, _ = span.meta.Attr(tinternal.AttrVersion) - component, _ = span.meta.Attr(tinternal.AttrComponent) - spanKind, _ = span.meta.Attr(tinternal.AttrSpanKind) + env, _ = span.meta.Env() + version, _ = span.meta.Version() + component, _ = span.meta.Component() + spanKind, _ = span.meta.SpanKind() ) p.buf = encodeField(p.buf, fullSetBitmap, 13, env, st) p.buf = encodeField(p.buf, fullSetBitmap, 14, version, st) @@ -610,12 +608,9 @@ func (p *payloadV1) encodeSpans(bm bitmap, fieldID int, spans spanList, st *stri } // encodeMetaEntry is a named callback for SpanMeta.Range. It encodes one -// non-promoted meta entry into p.buf using p.st. Promoted keys (env, version, -// component, span.kind) are skipped; they are encoded as dedicated fields 13-16. +// meta entry into p.buf using p.st. Promoted keys never enter the tag store +// so they cannot appear here; they are encoded separately as fields 13-16. func (p *payloadV1) encodeMetaEntry(k, v string) bool { - if _, ok := tinternal.AttrKeyForTag(k); ok { - return true // skip; encoded as fields 13-16 - } p.buf = p.st.serialize(k, p.buf) p.buf = msgp.AppendUint32(p.buf, uint32(StringValueType)) p.buf = p.st.serialize(v, p.buf) diff --git a/ddtrace/tracer/span.go b/ddtrace/tracer/span.go index f37a31c15bb..d795c2a7ad6 100644 --- a/ddtrace/tracer/span.go +++ b/ddtrace/tracer/span.go @@ -731,17 +731,14 @@ func (s *Span) setMetaLocked(key, v string) { s.setMetaInit(key, v) } -// setMetaTagLocked is like setMetaLocked but routes promoted keys to COW attrs -// via meta.Set, keeping them out of the flat map. Used by setTagLocked where -// the key may be any user-supplied string. +// setMetaTagLocked is like setMetaLocked but routes promoted keys to COW attrs, +// keeping them out of the tag store. Used by setTagLocked where the key may be +// any user-supplied string. // +checklocks:s.mu func (s *Span) setMetaTagLocked(key, v string) { - if tinternal.IsPromotedKeyLen(len(key)) { - if _, ok := tinternal.AttrKeyForTag(key); ok { - delete(s.metrics, key) - s.meta.Set(key, v) - return - } + if s.meta.SetIfPromoted(key, v) { + delete(s.metrics, key) + return } s.setMetaInit(key, v) } From 3f89622264b153792dc71389d54511e3f8d551a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Tue, 24 Mar 2026 15:56:24 +0000 Subject: [PATCH 50/71] refactor(ddtrace/tracer): drop `*Map` methods on SpanMeta --- .../tracer/internal/span_attributes_test.go | 10 +++---- ddtrace/tracer/internal/span_meta.go | 28 +++---------------- ddtrace/tracer/span.go | 9 +++--- 3 files changed, 13 insertions(+), 34 deletions(-) diff --git a/ddtrace/tracer/internal/span_attributes_test.go b/ddtrace/tracer/internal/span_attributes_test.go index dcec82d1d69..d925d886621 100644 --- a/ddtrace/tracer/internal/span_attributes_test.go +++ b/ddtrace/tracer/internal/span_attributes_test.go @@ -263,11 +263,11 @@ func BenchmarkMerge(b *testing.B) { a.Set(AttrComponent, "net/http") a.Set(AttrSpanKind, "server") sm := NewSpanMeta(&a) - sm.SetMap("key0", "value0") - sm.SetMap("key1", "value1") - sm.SetMap("key2", "value2") - sm.SetMap("key3", "value3") - sm.SetMap("key4", "value4") + sm.Set("key0", "value0") + sm.Set("key1", "value1") + sm.Set("key2", "value2") + sm.Set("key3", "value3") + sm.Set("key4", "value4") b.ReportAllocs() b.ResetTimer() diff --git a/ddtrace/tracer/internal/span_meta.go b/ddtrace/tracer/internal/span_meta.go index eb76795567d..fe0d374a3f1 100644 --- a/ddtrace/tracer/internal/span_meta.go +++ b/ddtrace/tracer/internal/span_meta.go @@ -6,7 +6,6 @@ package internal import ( - "fmt" "iter" "strings" @@ -112,11 +111,6 @@ func (sm SpanMeta) Has(key string) bool { return ok } -// Attr returns a promoted attribute value by AttrKey. O(1) array index + bitmask. -func (sm SpanMeta) Attr(key AttrKey) (string, bool) { - return sm.attrs.Get(key) -} - // Env returns the value of the "env" promoted attribute. func (sm SpanMeta) Env() (string, bool) { return sm.attrs.Get(AttrEnv) } @@ -151,25 +145,9 @@ func (sm SpanMeta) Range(fn func(k, v string) bool) { } // --------------------------------------------------------------------------- -// Write methods — fast (map-only) and full (promoted-aware) variants. +// Write methods // --------------------------------------------------------------------------- -// SetMap writes key=value directly to the flat map without checking for -// promoted attributes. This method is designed to be inlinable so that -// setMetaInit remains inlinable. -func (sm *SpanMeta) SetMap(key, value string) { - if sm.m == nil { - sm.m = make(map[string]string, metaMapHint) - } - sm.m[key] = value -} - -// DeleteMap removes key from the flat map only. Safe to call on a nil map. -// Like SetMap, this bypasses promoted-key routing for performance. -func (sm *SpanMeta) DeleteMap(key string) { - delete(sm.m, key) -} - // Set sets key→value, routing promoted keys to attrs (with copy-on-write) // and others to the flat map. // +checklocksignore — called both at init time (no lock) and under lock. @@ -336,7 +314,9 @@ func (sm SpanMeta) String() string { b.WriteByte(' ') } first = false - fmt.Fprintf(&b, "%s:%s", k, v) + b.WriteString(k) + b.WriteByte(':') + b.WriteString(v) } b.WriteByte(']') return b.String() diff --git a/ddtrace/tracer/span.go b/ddtrace/tracer/span.go index d795c2a7ad6..2bc0255bd65 100644 --- a/ddtrace/tracer/span.go +++ b/ddtrace/tracer/span.go @@ -722,8 +722,7 @@ func (s *Span) setMeta(key, v string) { } // setMetaLocked sets a string tag. This method assumes the span lock is already held. -// Promoted keys (env, version, component, span.kind) are written to the flat -// map via setMetaInit/SetMap — callers that may receive promoted keys should +// Callers that may receive user-supplied keys (including promoted ones) should // use setMetaTagLocked instead. // +checklocks:s.mu func (s *Span) setMetaLocked(key, v string) { @@ -757,7 +756,7 @@ func (s *Span) setMetaInit(key, v string) { case ext.SpanType: s.spanType = v default: - s.meta.SetMap(key, v) + s.meta.Set(key, v) } } @@ -806,7 +805,7 @@ func (s *Span) setMetricInit(key string, v float64) { if s.metrics == nil { s.metrics = make(map[string]float64, 1) } - s.meta.DeleteMap(key) + s.meta.Delete(key) // Note: We don't handle ManualKeep or _sampling_priority_v1shim during init // because those require modifying trace-level state which needs locking s.metrics[key] = v @@ -826,7 +825,7 @@ func (s *Span) setMetricLocked(key string, v float64) { if s.metrics == nil { s.metrics = make(map[string]float64, 1) } - s.meta.DeleteMap(key) + s.meta.Delete(key) switch key { case ext.ManualKeep: if v == float64(samplernames.AppSec) { From 1b510d88ad3a1112984931bf41f036640b9d8c45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Wed, 25 Mar 2026 10:13:26 +0000 Subject: [PATCH 51/71] fix(ddtrace/tracer): adapt merged code --- ddtrace/tracer/internal/span_meta.go | 5 ++--- ddtrace/tracer/span_test.go | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/ddtrace/tracer/internal/span_meta.go b/ddtrace/tracer/internal/span_meta.go index fe0d374a3f1..7b12de06410 100644 --- a/ddtrace/tracer/internal/span_meta.go +++ b/ddtrace/tracer/internal/span_meta.go @@ -7,6 +7,7 @@ package internal import ( "iter" + "maps" "strings" "github.com/tinylib/msgp/msgp" @@ -270,9 +271,7 @@ func (sm SpanMeta) Merge() map[string]string { return nil } m := make(map[string]string, n) - for k, v := range sm.m { - m[k] = v - } + maps.Copy(m, sm.m) for _, d := range Defs { if sm.attrs.Has(d.Key) { m[d.Name] = sm.attrs.vals[d.Key] diff --git a/ddtrace/tracer/span_test.go b/ddtrace/tracer/span_test.go index b868f9f334a..e6feec1177b 100644 --- a/ddtrace/tracer/span_test.go +++ b/ddtrace/tracer/span_test.go @@ -1810,7 +1810,7 @@ func TestSpanLinksInMeta(t *testing.T) { sp.AddLink(SpanLink{SpanID: 789, TraceID: 012}) sp.Finish() - _, ok := sp.meta["_dd.span_links"] + _, ok := sp.meta.Get("_dd.span_links") assert.False(t, ok, "Expected no _dd.span_links in meta when native links are supported.") assert.Len(t, sp.spanLinks, 2, "Expected spanLinks slice to be preserved for native encoding.") }) From aa55e43ae4589898715d0d532f8e17674b33d2d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Thu, 26 Mar 2026 16:37:22 +0000 Subject: [PATCH 52/71] perf(ddtrace/tracer): reduce SpanMeta overhead on hot paths Add Put() as an inlineable flat-map write for paths wehre promoted keys are already handled. Rename Merge() to Map() and make it transparently inline promoted attrs into sm.m on first call. --- ddtrace/tracer/civisibility_tslv.go | 4 +- .../tracer/internal/span_attributes_test.go | 6 +- ddtrace/tracer/internal/span_meta.go | 87 +++++++++++++------ ddtrace/tracer/span.go | 12 +-- ddtrace/tracer/stats.go | 2 +- 5 files changed, 69 insertions(+), 42 deletions(-) diff --git a/ddtrace/tracer/civisibility_tslv.go b/ddtrace/tracer/civisibility_tslv.go index e7b7499b204..64073e2d067 100644 --- a/ddtrace/tracer/civisibility_tslv.go +++ b/ddtrace/tracer/civisibility_tslv.go @@ -212,7 +212,7 @@ func (e *ciVisibilityEvent) Finish(opts ...FinishOption) { // Rebuild Content.Meta once with the final span state, under the span // lock to avoid racing with the serialization worker. e.span.mu.Lock() - e.Content.Meta = e.span.meta.Merge() + e.Content.Meta = e.span.meta.Map() e.Content.Metrics = e.span.metrics e.span.mu.Unlock() } @@ -416,7 +416,7 @@ func createTslvSpan(span *Span) tslvSpan { Duration: span.duration, ParentID: span.parentID, Error: span.error, - Meta: span.meta.Merge(), + Meta: span.meta.Map(), Metrics: span.metrics, } } diff --git a/ddtrace/tracer/internal/span_attributes_test.go b/ddtrace/tracer/internal/span_attributes_test.go index d925d886621..195db1b9a96 100644 --- a/ddtrace/tracer/internal/span_attributes_test.go +++ b/ddtrace/tracer/internal/span_attributes_test.go @@ -254,9 +254,9 @@ func TestSpanMetaSetPromotedNoOpWhenPresent(t *testing.T) { } } -// BenchmarkMerge measures the allocation cost of Merge() with both tag store +// BenchmarkMap measures the allocation cost of Map() with both tag store // entries and promoted attrs set. -func BenchmarkMerge(b *testing.B) { +func BenchmarkMap(b *testing.B) { var a SpanAttributes a.Set(AttrEnv, "prod") a.Set(AttrVersion, "1.2.3") @@ -272,6 +272,6 @@ func BenchmarkMerge(b *testing.B) { b.ReportAllocs() b.ResetTimer() for range b.N { - _ = sm.Merge() + _ = sm.Map() } } diff --git a/ddtrace/tracer/internal/span_meta.go b/ddtrace/tracer/internal/span_meta.go index 7b12de06410..d5f036f5f92 100644 --- a/ddtrace/tracer/internal/span_meta.go +++ b/ddtrace/tracer/internal/span_meta.go @@ -6,8 +6,8 @@ package internal import ( + "fmt" "iter" - "maps" "strings" "github.com/tinylib/msgp/msgp" @@ -30,13 +30,16 @@ var ( // and are excluded from the map m. The msgp codec merges both sources // transparently so the wire format is unchanged. // -// Hot paths (setMetaInit, setMetricLocked) use SetMap/DeleteMap for direct -// tag-store access without promoted-key overhead. Callers that may write -// promoted keys use Set/Delete or setMetaTagLocked which route to attrs via -// copy-on-write. Promoted keys never appear in sm.m. +// Hot paths (setMetaInit, setMetricLocked) use Put for direct flat-map +// access without promoted-key overhead. Callers that may write promoted +// keys use Set/Delete or setMetaTagLocked which route to attrs via +// copy-on-write. Promoted keys never appear in sm.m until Map() is +// called, which copies them into sm.m and sets the inlined flag so that +// EncodeMsg, Msgsize, All, and Count skip the attrs source. type SpanMeta struct { - m map[string]string - attrs *SpanAttributes + m map[string]string + attrs *SpanAttributes + inlined bool } // NewSpanMeta returns a SpanMeta initialized with shared attrs (used during span creation). @@ -112,6 +115,11 @@ func (sm SpanMeta) Has(key string) bool { return ok } +// Attr returns a promoted attribute value by AttrKey. O(1) array index + bitmask. +func (sm SpanMeta) Attr(key AttrKey) (string, bool) { + return sm.attrs.Get(key) +} + // Env returns the value of the "env" promoted attribute. func (sm SpanMeta) Env() (string, bool) { return sm.attrs.Get(AttrEnv) } @@ -149,6 +157,17 @@ func (sm SpanMeta) Range(fn func(k, v string) bool) { // Write methods // --------------------------------------------------------------------------- +// Put writes key→value directly to the flat map without promoted-key +// routing. Inlineable by design — use on paths where the caller has +// already handled promoted keys (e.g. after SetIfPromoted returned false). +func (sm *SpanMeta) Put(key, value string) { + if sm.m == nil { + sm.initMap(key, value) + return + } + sm.m[key] = value +} + // Set sets key→value, routing promoted keys to attrs (with copy-on-write) // and others to the flat map. // +checklocksignore — called both at init time (no lock) and under lock. @@ -245,9 +264,18 @@ func init() { // Counting / iteration // --------------------------------------------------------------------------- +// attrsNotInM returns the number of promoted attrs not yet present in m. +// After Map() has inlined them, this returns 0. +func (sm SpanMeta) attrsNotInM() int { + if sm.inlined || sm.attrs == nil { + return 0 + } + return sm.attrs.Count() +} + // Count returns the total number of distinct entries (flat map + promoted attrs). func (sm SpanMeta) Count() int { - return len(sm.m) + sm.attrs.Count() + return len(sm.m) + sm.attrsNotInM() } // AttrCount returns the number of promoted attrs currently set. @@ -262,27 +290,33 @@ func (sm SpanMeta) SerializableCount() int { return len(sm.m) } -// Merge returns a new map containing all flat-map entries plus all promoted -// attrs. Always allocates — never returns sm.m directly, avoiding races when -// the result is placed into a pooled struct. -func (sm SpanMeta) Merge() map[string]string { - n := len(sm.m) + sm.attrs.Count() +// Map returns a map containing all flat-map entries plus all promoted +// attrs. On the first call it copies promoted attrs into sm.m and sets the +// inlined flag, so subsequent calls (and EncodeMsg/All/Count) skip the +// attrs source. Returns sm.m directly — zero allocation after the first +// call. Must only be called after the span is finished (no concurrent writes). +func (sm *SpanMeta) Map() map[string]string { + n := sm.attrsNotInM() if n == 0 { - return nil + return sm.m + } + if sm.m == nil { + sm.m = make(map[string]string, n) } - m := make(map[string]string, n) - maps.Copy(m, sm.m) for _, d := range Defs { if sm.attrs.Has(d.Key) { - m[d.Name] = sm.attrs.vals[d.Key] + sm.m[d.Name] = sm.attrs.vals[d.Key] } } - return m + sm.inlined = true + return sm.m } // All returns an iterator over all entries. Flat-map entries are yielded first // (in unspecified order), followed by promoted attributes in definition order -// (env, version, component, span.kind). Returning false from yield stops iteration. +// (env, version, component, span.kind). After Map() has been called, all +// entries are in sm.m and the attrs section is skipped. +// Returning false from yield stops iteration. func (sm SpanMeta) All() iter.Seq2[string, string] { return func(yield func(string, string) bool) { for k, v := range sm.m { @@ -290,7 +324,7 @@ func (sm SpanMeta) All() iter.Seq2[string, string] { return } } - if sm.attrs == nil { + if sm.inlined || sm.attrs == nil { return } for _, d := range Defs { @@ -313,9 +347,7 @@ func (sm SpanMeta) String() string { b.WriteByte(' ') } first = false - b.WriteString(k) - b.WriteByte(':') - b.WriteString(v) + fmt.Fprintf(&b, "%s:%s", k, v) } b.WriteByte(']') return b.String() @@ -325,10 +357,11 @@ func (sm SpanMeta) String() string { // msgp codec // --------------------------------------------------------------------------- -// EncodeMsg writes the map header (flat map entries + promoted attrs), -// then emits all flat map entries followed by all set promoted attrs. +// EncodeMsg writes the map header (flat map entries + promoted attrs not yet +// in m), then emits all flat map entries followed by promoted attrs not yet +// inlined. After Map(), all entries are in sm.m and the attrs loop is skipped. func (sm *SpanMeta) EncodeMsg(en *msgp.Writer) error { - n := sm.attrs.Count() + n := sm.attrsNotInM() if err := en.WriteMapHeader(uint32(len(sm.m) + n)); err != nil { return msgp.WrapError(err, "Meta") } @@ -395,7 +428,7 @@ func (sm *SpanMeta) Msgsize() int { for k, v := range sm.m { size += msgp.StringPrefixSize + len(k) + msgp.StringPrefixSize + len(v) } - if n := sm.attrs.Count(); n > 0 { + if n := sm.attrsNotInM(); n > 0 { for _, d := range Defs { if v, ok := sm.attrs.Get(d.Key); ok { size += msgp.StringPrefixSize + len(d.Name) + msgp.StringPrefixSize + len(v) diff --git a/ddtrace/tracer/span.go b/ddtrace/tracer/span.go index 2bc0255bd65..25c0daeddaf 100644 --- a/ddtrace/tracer/span.go +++ b/ddtrace/tracer/span.go @@ -165,8 +165,6 @@ type Span struct { context *SpanContext `msg:"-"` // span propagation context // +checklocks:mu supportsEvents bool `msg:"-"` // whether the span supports native span events or not - // +checklocks:mu - supportsLinks bool `msg:"-"` // whether the span supports native span links or not // +checklocks:mu finished bool `msg:"-"` // true if the span has been submitted to a tracer. Can only be read/modified if the trace is locked. @@ -722,7 +720,8 @@ func (s *Span) setMeta(key, v string) { } // setMetaLocked sets a string tag. This method assumes the span lock is already held. -// Callers that may receive user-supplied keys (including promoted ones) should +// Promoted keys (env, version, component, span.kind) are written to the flat +// map via setMetaInit/SetMap — callers that may receive promoted keys should // use setMetaTagLocked instead. // +checklocks:s.mu func (s *Span) setMetaLocked(key, v string) { @@ -756,7 +755,7 @@ func (s *Span) setMetaInit(key, v string) { case ext.SpanType: s.spanType = v default: - s.meta.Set(key, v) + s.meta.Put(key, v) } } @@ -864,11 +863,6 @@ func (s *Span) serializeSpanLinksInMeta() { if len(s.spanLinks) == 0 { return } - // if span links are natively supported by the encoder, there's nothing to do - // as the links will be already included when the span is serialized. - if s.supportsLinks { - return - } spanLinkBytes, err := json.Marshal(s.spanLinks) if err != nil { log.Debug("Unable to marshal span links. Not adding span links to span meta.") diff --git a/ddtrace/tracer/stats.go b/ddtrace/tracer/stats.go index 64539cd2f71..2f6d3f3dc65 100644 --- a/ddtrace/tracer/stats.go +++ b/ddtrace/tracer/stats.go @@ -177,7 +177,7 @@ func (c *concentrator) newTracerStatSpan(s *Span, obfuscator *obfuscate.Obfuscat Start: s.start, Duration: s.duration, Error: s.error, - Meta: s.meta.Merge(), + Meta: s.meta.Map(), Metrics: s.metrics, PeerTags: c.cfg.agent.load().peerTags, HTTPMethod: httpMethod, From 35ddce16b3b22faf5ef6b45196e5bcada7fb3112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Thu, 26 Mar 2026 16:39:08 +0000 Subject: [PATCH 53/71] fix(ddtrace/tracer): fix leftovers for supportsLinks --- ddtrace/tracer/span_test.go | 1 - ddtrace/tracer/tracer.go | 1 - 2 files changed, 2 deletions(-) diff --git a/ddtrace/tracer/span_test.go b/ddtrace/tracer/span_test.go index e6feec1177b..680f831f287 100644 --- a/ddtrace/tracer/span_test.go +++ b/ddtrace/tracer/span_test.go @@ -1805,7 +1805,6 @@ func TestSpanLinksInMeta(t *testing.T) { defer tracer.Stop() sp := tracer.StartSpan("test-with-links-native") - sp.supportsLinks = true sp.AddLink(SpanLink{SpanID: 123, TraceID: 456}) sp.AddLink(SpanLink{SpanID: 789, TraceID: 012}) sp.Finish() diff --git a/ddtrace/tracer/tracer.go b/ddtrace/tracer/tracer.go index be8332bf7c6..6ad230a7a4d 100644 --- a/ddtrace/tracer/tracer.go +++ b/ddtrace/tracer/tracer.go @@ -900,7 +900,6 @@ func (t *tracer) StartSpan(operationName string, options ...StartSpanOption) *Sp span.setMetaInit(keyHostname, hostname) } span.supportsEvents = t.config.agent.load().spanEventsAvailable - span.supportsLinks = t.config.internalConfig.TraceProtocol() == traceProtocolV1 // add global tags span.setTags(t.config.globalTags.get()) From 4bab6e14434429149b87a9205c2c7b6d4462d78e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Thu, 26 Mar 2026 17:11:46 +0000 Subject: [PATCH 54/71] feat(ddtrace/tracer): another attempt --- ddtrace/tracer/internal/span_meta.go | 59 ++++++++++------------------ ddtrace/tracer/span_test.go | 17 -------- 2 files changed, 21 insertions(+), 55 deletions(-) diff --git a/ddtrace/tracer/internal/span_meta.go b/ddtrace/tracer/internal/span_meta.go index d5f036f5f92..6e30a2c2cbc 100644 --- a/ddtrace/tracer/internal/span_meta.go +++ b/ddtrace/tracer/internal/span_meta.go @@ -33,13 +33,10 @@ var ( // Hot paths (setMetaInit, setMetricLocked) use Put for direct flat-map // access without promoted-key overhead. Callers that may write promoted // keys use Set/Delete or setMetaTagLocked which route to attrs via -// copy-on-write. Promoted keys never appear in sm.m until Map() is -// called, which copies them into sm.m and sets the inlined flag so that -// EncodeMsg, Msgsize, All, and Count skip the attrs source. +// copy-on-write. Promoted keys never appear in sm.m. type SpanMeta struct { - m map[string]string - attrs *SpanAttributes - inlined bool + m map[string]string + attrs *SpanAttributes } // NewSpanMeta returns a SpanMeta initialized with shared attrs (used during span creation). @@ -264,18 +261,9 @@ func init() { // Counting / iteration // --------------------------------------------------------------------------- -// attrsNotInM returns the number of promoted attrs not yet present in m. -// After Map() has inlined them, this returns 0. -func (sm SpanMeta) attrsNotInM() int { - if sm.inlined || sm.attrs == nil { - return 0 - } - return sm.attrs.Count() -} - // Count returns the total number of distinct entries (flat map + promoted attrs). func (sm SpanMeta) Count() int { - return len(sm.m) + sm.attrsNotInM() + return len(sm.m) + sm.attrs.Count() } // AttrCount returns the number of promoted attrs currently set. @@ -290,33 +278,29 @@ func (sm SpanMeta) SerializableCount() int { return len(sm.m) } -// Map returns a map containing all flat-map entries plus all promoted -// attrs. On the first call it copies promoted attrs into sm.m and sets the -// inlined flag, so subsequent calls (and EncodeMsg/All/Count) skip the -// attrs source. Returns sm.m directly — zero allocation after the first -// call. Must only be called after the span is finished (no concurrent writes). -func (sm *SpanMeta) Map() map[string]string { - n := sm.attrsNotInM() +// Map returns a new map containing all flat-map entries plus all promoted +// attrs. Always allocates — never returns sm.m directly, avoiding races +// when the result outlives the span's serialization window. +func (sm SpanMeta) Map() map[string]string { + n := len(sm.m) + sm.attrs.Count() if n == 0 { - return sm.m + return nil } - if sm.m == nil { - sm.m = make(map[string]string, n) + m := make(map[string]string, n) + for k, v := range sm.m { + m[k] = v } for _, d := range Defs { if sm.attrs.Has(d.Key) { - sm.m[d.Name] = sm.attrs.vals[d.Key] + m[d.Name] = sm.attrs.vals[d.Key] } } - sm.inlined = true - return sm.m + return m } // All returns an iterator over all entries. Flat-map entries are yielded first // (in unspecified order), followed by promoted attributes in definition order -// (env, version, component, span.kind). After Map() has been called, all -// entries are in sm.m and the attrs section is skipped. -// Returning false from yield stops iteration. +// (env, version, component, span.kind). Returning false from yield stops iteration. func (sm SpanMeta) All() iter.Seq2[string, string] { return func(yield func(string, string) bool) { for k, v := range sm.m { @@ -324,7 +308,7 @@ func (sm SpanMeta) All() iter.Seq2[string, string] { return } } - if sm.inlined || sm.attrs == nil { + if sm.attrs == nil { return } for _, d := range Defs { @@ -357,11 +341,10 @@ func (sm SpanMeta) String() string { // msgp codec // --------------------------------------------------------------------------- -// EncodeMsg writes the map header (flat map entries + promoted attrs not yet -// in m), then emits all flat map entries followed by promoted attrs not yet -// inlined. After Map(), all entries are in sm.m and the attrs loop is skipped. +// EncodeMsg writes the map header (flat map entries + promoted attrs), +// then emits all flat map entries followed by all set promoted attrs. func (sm *SpanMeta) EncodeMsg(en *msgp.Writer) error { - n := sm.attrsNotInM() + n := sm.attrs.Count() if err := en.WriteMapHeader(uint32(len(sm.m) + n)); err != nil { return msgp.WrapError(err, "Meta") } @@ -428,7 +411,7 @@ func (sm *SpanMeta) Msgsize() int { for k, v := range sm.m { size += msgp.StringPrefixSize + len(k) + msgp.StringPrefixSize + len(v) } - if n := sm.attrsNotInM(); n > 0 { + if n := sm.attrs.Count(); n > 0 { for _, d := range Defs { if v, ok := sm.attrs.Get(d.Key); ok { size += msgp.StringPrefixSize + len(d.Name) + msgp.StringPrefixSize + len(v) diff --git a/ddtrace/tracer/span_test.go b/ddtrace/tracer/span_test.go index 680f831f287..1e1cd7df5d7 100644 --- a/ddtrace/tracer/span_test.go +++ b/ddtrace/tracer/span_test.go @@ -1796,23 +1796,6 @@ func TestSpanLinksInMeta(t *testing.T) { assert.Equal(t, uint64(789), links[1].SpanID) assert.Equal(t, uint64(012), links[1].TraceID) }) - - t.Run("with_links_native", func(t *testing.T) { - // When the encoder supports native span links (v1 protocol), - // serializeSpanLinksInMeta must not write the JSON fallback tag. - tracer, err := newTracer() - require.NoError(t, err) - defer tracer.Stop() - - sp := tracer.StartSpan("test-with-links-native") - sp.AddLink(SpanLink{SpanID: 123, TraceID: 456}) - sp.AddLink(SpanLink{SpanID: 789, TraceID: 012}) - sp.Finish() - - _, ok := sp.meta.Get("_dd.span_links") - assert.False(t, ok, "Expected no _dd.span_links in meta when native links are supported.") - assert.Len(t, sp.spanLinks, 2, "Expected spanLinks slice to be preserved for native encoding.") - }) } func TestStatsAfterFinish(t *testing.T) { From 7921b27b0027f5be3a2f2fce4f29fc8ac36fedac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Thu, 26 Mar 2026 20:06:51 +0000 Subject: [PATCH 55/71] chore(ddtrace/tracer): move dangling comment to its right place --- ddtrace/tracer/span.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ddtrace/tracer/span.go b/ddtrace/tracer/span.go index 25c0daeddaf..e0e76de02ce 100644 --- a/ddtrace/tracer/span.go +++ b/ddtrace/tracer/span.go @@ -368,8 +368,6 @@ func (s *Span) SetTag(key string, value any) { if s == nil { return } - // To avoid dumping the memory address in case value is a pointer, we dereference it. - // Any pointer value that is a pointer to a pointer will be dumped as a string. s.mu.Lock() defer s.mu.Unlock() @@ -400,6 +398,8 @@ func (s *Span) setTagLocked(key string, value any) { if s.finished { return } + // To avoid dumping the memory address in case value is a pointer, we dereference it. + // Any pointer value that is a pointer to a pointer will be dumped as a string. value = dereference(value) switch key { case ext.Error: From b52bcf079db00beb834fd22a16062816e29cc57a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Thu, 26 Mar 2026 21:15:13 +0000 Subject: [PATCH 56/71] feat(ddtrace/tracer): ensure that Delete is inlineable; simplify API --- ddtrace/tracer/internal/span_meta.go | 63 ++++++++++------------------ ddtrace/tracer/span.go | 27 +++--------- 2 files changed, 29 insertions(+), 61 deletions(-) diff --git a/ddtrace/tracer/internal/span_meta.go b/ddtrace/tracer/internal/span_meta.go index 6e30a2c2cbc..86226d26c26 100644 --- a/ddtrace/tracer/internal/span_meta.go +++ b/ddtrace/tracer/internal/span_meta.go @@ -17,7 +17,14 @@ import ( // A typical span carries "language" plus a handful of internal tags // (_dd.base_service, runtime-id, etc.). 5 accommodates these without // a rehash and matches the pre-refactor initMeta allocation profile. -const metaMapHint = 5 +const ( + // expectedEntries should be the count of tags known at construction time. + expectedEntries = 5 + // loadFactor of 4/3 (≈ inverse of the standard 0.75 load factor) provides + // ~33% slack so small overestimates don't trigger an immediate rehash. + loadFactor = 4 / 3 + metaMapHint = expectedEntries * loadFactor +) var ( _ msgp.Encodable = (*SpanMeta)(nil) @@ -30,10 +37,8 @@ var ( // and are excluded from the map m. The msgp codec merges both sources // transparently so the wire format is unchanged. // -// Hot paths (setMetaInit, setMetricLocked) use Put for direct flat-map -// access without promoted-key overhead. Callers that may write promoted -// keys use Set/Delete or setMetaTagLocked which route to attrs via -// copy-on-write. Promoted keys never appear in sm.m. +// Set routes promoted keys to attrs (with copy-on-write) and others to the +// flat map. Promoted keys never appear in sm.m. type SpanMeta struct { m map[string]string attrs *SpanAttributes @@ -129,16 +134,6 @@ func (sm SpanMeta) Component() (string, bool) { return sm.attrs.Get(AttrComponen // SpanKind returns the value of the "span.kind" promoted attribute. func (sm SpanMeta) SpanKind() (string, bool) { return sm.attrs.Get(AttrSpanKind) } -// SetIfPromoted stores key→value in the promoted-attribute store if key is a -// promoted attribute name (env, version, component, span.kind). Returns true -// when the key was handled; callers can skip their normal tag-store path. -func (sm *SpanMeta) SetIfPromoted(key, value string) bool { - if !IsPromotedKeyLen(len(key)) { - return false - } - return sm.setPromoted(key, value) -} - // Range calls fn for each entry in the flat map (sm.m). // Promoted attrs live in sm.attrs and are not yielded here. // Iteration stops if fn returns false. @@ -154,17 +149,6 @@ func (sm SpanMeta) Range(fn func(k, v string) bool) { // Write methods // --------------------------------------------------------------------------- -// Put writes key→value directly to the flat map without promoted-key -// routing. Inlineable by design — use on paths where the caller has -// already handled promoted keys (e.g. after SetIfPromoted returned false). -func (sm *SpanMeta) Put(key, value string) { - if sm.m == nil { - sm.initMap(key, value) - return - } - sm.m[key] = value -} - // Set sets key→value, routing promoted keys to attrs (with copy-on-write) // and others to the flat map. // +checklocksignore — called both at init time (no lock) and under lock. @@ -179,10 +163,8 @@ func (sm *SpanMeta) Set(key, value string) { sm.m[key] = value } -// setPromoted is the slow path for Set and SetIfPromoted when the key might be -// a promoted attribute. Returns true if the key was handled (set or no-op). -// -//go:noinline +// setPromoted is the slow path for Set when the key might be a promoted +// attribute. Returns true if the key was handled (set or no-op). func (sm *SpanMeta) setPromoted(key, value string) bool { ak, ok := AttrKeyForTag(key) if !ok { @@ -197,9 +179,6 @@ func (sm *SpanMeta) setPromoted(key, value string) bool { } // initMap allocates the flat map and inserts the first entry. -// Separated to keep Set's fast path (map already exists) small and inlinable. -// -//go:noinline func (sm *SpanMeta) initMap(key, value string) { sm.m = make(map[string]string, metaMapHint) sm.m[key] = value @@ -220,16 +199,16 @@ func (sm *SpanMeta) ensureAttrsLocal() { // Delete removes key from both the flat map and (for promoted keys) attrs. // +checklocksignore — called both at init time (no lock) and under lock. func (sm *SpanMeta) Delete(key string) { - delete(sm.m, key) if IsPromotedKeyLen(len(key)) { - sm.deleteFromAttrs(key) + sm.deleteSlow(key) + return } + delete(sm.m, key) } -// deleteFromAttrs handles the promoted-key side of Delete. -// -//go:noinline -func (sm *SpanMeta) deleteFromAttrs(key string) { +// deleteSlow handles the promoted-key path for Delete. +func (sm *SpanMeta) deleteSlow(key string) { + delete(sm.m, key) ak, ok := AttrKeyForTag(key) if !ok { return @@ -246,7 +225,11 @@ func (sm *SpanMeta) deleteFromAttrs(key string) { // This must stay in sync with the Defs table in span_attributes.go; the init // check below enforces this at program start. func IsPromotedKeyLen(n int) bool { - return n == 3 || n == 7 || n == 9 + switch n { + case 3, 7, 9: + return true + } + return false } func init() { diff --git a/ddtrace/tracer/span.go b/ddtrace/tracer/span.go index e0e76de02ce..dfada46693d 100644 --- a/ddtrace/tracer/span.go +++ b/ddtrace/tracer/span.go @@ -439,7 +439,7 @@ func (s *Span) setTagLocked(key string, value any) { s.pprofCtxActive = pprof.WithLabels(s.pprofCtxActive, pprof.Labels(traceprof.TraceEndpoint, v)) pprof.SetGoroutineLabels(s.pprofCtxActive) } - s.setMetaTagLocked(key, v) + s.setMetaLocked(key, v) return } if v, ok := sharedinternal.ToFloat64(value); ok { @@ -447,12 +447,12 @@ func (s *Span) setTagLocked(key string, value any) { return } if v, ok := value.(fmt.Stringer); ok { - s.setMetaTagLocked(key, safeStringerValue(v, value)) + s.setMetaLocked(key, safeStringerValue(v, value)) return } if v, ok := value.([]byte); ok { - s.setMetaTagLocked(key, string(v)) + s.setMetaLocked(key, string(v)) return } @@ -469,7 +469,7 @@ func (s *Span) setTagLocked(key string, value any) { if num, ok := sharedinternal.ToFloat64(v.Interface()); ok { s.setMetricLocked(key, num) } else { - s.setMetaTagLocked(key, fmt.Sprintf("%v", v)) + s.setMetaLocked(key, fmt.Sprintf("%v", v)) } } return @@ -496,7 +496,7 @@ func (s *Span) setTagLocked(key string, value any) { } // not numeric, not a string, not a fmt.Stringer, not a bool, and not an error - s.setMetaTagLocked(key, fmt.Sprint(value)) + s.setMetaLocked(key, fmt.Sprint(value)) } // setSamplingPriority locks the span, then updates the sampling priority. @@ -720,27 +720,12 @@ func (s *Span) setMeta(key, v string) { } // setMetaLocked sets a string tag. This method assumes the span lock is already held. -// Promoted keys (env, version, component, span.kind) are written to the flat -// map via setMetaInit/SetMap — callers that may receive promoted keys should -// use setMetaTagLocked instead. // +checklocks:s.mu func (s *Span) setMetaLocked(key, v string) { assert.RWMutexLocked(&s.mu) s.setMetaInit(key, v) } -// setMetaTagLocked is like setMetaLocked but routes promoted keys to COW attrs, -// keeping them out of the tag store. Used by setTagLocked where the key may be -// any user-supplied string. -// +checklocks:s.mu -func (s *Span) setMetaTagLocked(key, v string) { - if s.meta.SetIfPromoted(key, v) { - delete(s.metrics, key) - return - } - s.setMetaInit(key, v) -} - // setMetaInit sets a string tag without acquiring the lock and asserting the lock is held. // +checklocksignore — Initialization time, span not yet shared. func (s *Span) setMetaInit(key, v string) { @@ -755,7 +740,7 @@ func (s *Span) setMetaInit(key, v string) { case ext.SpanType: s.spanType = v default: - s.meta.Put(key, v) + s.meta.Set(key, v) } } From d50dd487e577a7e267ac0da0062a357969593867 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Fri, 27 Mar 2026 10:13:05 +0000 Subject: [PATCH 57/71] chore(ddtrace/tracer): adapt TestServiceSource --- ddtrace/tracer/span.go | 1 + ddtrace/tracer/srv_src_test.go | 9 ++++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/ddtrace/tracer/span.go b/ddtrace/tracer/span.go index dfada46693d..bde525e7fad 100644 --- a/ddtrace/tracer/span.go +++ b/ddtrace/tracer/span.go @@ -735,6 +735,7 @@ func (s *Span) setMetaInit(key, v string) { s.name = v case ext.ServiceName: s.service = v + s.serviceSource = serviceSourceManual case ext.ResourceName: s.resource = v case ext.SpanType: diff --git a/ddtrace/tracer/srv_src_test.go b/ddtrace/tracer/srv_src_test.go index 75e188e727d..65c30500c34 100644 --- a/ddtrace/tracer/srv_src_test.go +++ b/ddtrace/tracer/srv_src_test.go @@ -50,8 +50,9 @@ func TestServiceSource(t *testing.T) { span.mu.RLock() defer span.mu.RUnlock() + kss, _ := span.meta.Get(ext.KeyServiceSource) assert.Equal(t, "custom-service", span.service) - assert.Equal(t, serviceSourceManual, span.meta[ext.KeyServiceSource]) + assert.Equal(t, serviceSourceManual, kss) }) t.Run("ServiceOverrideTag", func(t *testing.T) { @@ -65,8 +66,9 @@ func TestServiceSource(t *testing.T) { span.mu.RLock() defer span.mu.RUnlock() + kss, _ := span.meta.Get(ext.KeyServiceSource) assert.Equal(t, "my-service", span.service) - assert.Equal(t, serviceSourceManual, span.meta[ext.KeyServiceSource]) + assert.Equal(t, serviceSourceManual, kss) }) t.Run("ChildInheritsSrvSrcFromParent", func(t *testing.T) { @@ -148,7 +150,8 @@ func TestServiceSource(t *testing.T) { span.mu.RLock() defer span.mu.RUnlock() + kss, _ := span.meta.Get(ext.KeyServiceSource) assert.Equal(t, "from-meta-init", span.service) - assert.Equal(t, serviceSourceManual, span.meta[ext.KeyServiceSource]) + assert.Equal(t, serviceSourceManual, kss) }) } From c70a7bb51016cbac975a787910a7fc8845c1f9b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Fri, 27 Mar 2026 10:13:20 +0000 Subject: [PATCH 58/71] chore(ddtrace/tracer): improve samplingPriorityPtr docs --- ddtrace/tracer/spancontext.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ddtrace/tracer/spancontext.go b/ddtrace/tracer/spancontext.go index 63334848904..2f9e6e603ae 100644 --- a/ddtrace/tracer/spancontext.go +++ b/ddtrace/tracer/spancontext.go @@ -522,7 +522,8 @@ var samplingPriorityCache = func() [4]*float64 { }() // samplingPriorityPtr returns a *float64 for p without allocating for the -// standard priority values (ext.PriorityUserReject through ext.PriorityUserKeep). +// standard priority values (ext.PriorityUserReject through ext.PriorityUserKeep); +// for any other value it allocates a new one. func samplingPriorityPtr(p int) *float64 { if p >= -1 && p <= 2 { return samplingPriorityCache[p+1] From aeead6f525d6217e00e089afe15512edc931f736 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Fri, 27 Mar 2026 10:26:22 +0000 Subject: [PATCH 59/71] feat(ddtrace/tracer): promote language to attribute --- ddtrace/tracer/internal/span_attributes.go | 17 +++++++++++------ ddtrace/tracer/internal/span_meta.go | 19 ++++++++++++++----- ddtrace/tracer/tracer.go | 7 ++++++- 3 files changed, 31 insertions(+), 12 deletions(-) diff --git a/ddtrace/tracer/internal/span_attributes.go b/ddtrace/tracer/internal/span_attributes.go index f42bd079249..c027b0097f5 100644 --- a/ddtrace/tracer/internal/span_attributes.go +++ b/ddtrace/tracer/internal/span_attributes.go @@ -19,7 +19,8 @@ const ( AttrVersion AttrKey = 1 AttrComponent AttrKey = 2 AttrSpanKind AttrKey = 3 - numAttrs AttrKey = 4 + AttrLanguage AttrKey = 4 + numAttrs AttrKey = 5 // AttrUnknown is returned by AttrKeyForTag when no promoted tag matches. // Its value is intentionally out of range for vals[] so misuse panics immediately. @@ -30,17 +31,18 @@ const ( // they index both vals[] and setMask bit positions. If any value drifts (e.g. via // iota + reorder), the expression below produces a compile error. var ( - _ = [1]byte{}[AttrEnv] // AttrEnv must be 0 - _ = [1]byte{}[AttrVersion-1] // AttrVersion must be 1 - _ = [1]byte{}[AttrComponent-2] // AttrComponent must be 2 - _ = [1]byte{}[AttrSpanKind-3] // AttrSpanKind must be 3 + _ = [1]byte{}[AttrEnv] // AttrEnv must be 0 + _ = [1]byte{}[AttrVersion-1] // AttrVersion must be 1 + _ = [1]byte{}[AttrComponent-2] // AttrComponent must be 2 + _ = [1]byte{}[AttrSpanKind-3] // AttrSpanKind must be 3 + _ = [1]byte{}[AttrLanguage-4] // AttrLanguage must be 4 ) // SpanAttributes holds the V1-protocol promoted span fields. // Zero value = all fields absent. // Set(key, "") is distinct from never-Set: the bit is set, the string is "". // -// Layout: 1-byte setMask + 1-byte shared + 6B padding + [4]string (64B) = 72 bytes. +// Layout: 1-byte setMask + 1-byte shared + 6B padding + [5]string (80B) = 88 bytes. // // When shared is true, the instance is owned by the tracer and must not be // mutated. Callers must Clone before writing (copy-on-write). @@ -128,6 +130,7 @@ var Defs = [numAttrs]AttrDef{ {AttrVersion, "version"}, {AttrComponent, "component"}, {AttrSpanKind, "span.kind"}, + {AttrLanguage, "language"}, } // All returns an iterator over the set attributes (name, value) pairs. @@ -161,6 +164,8 @@ func AttrKeyForTag(tag string) (AttrKey, bool) { return AttrComponent, true case "span.kind": return AttrSpanKind, true + case "language": + return AttrLanguage, true } return AttrUnknown, false } diff --git a/ddtrace/tracer/internal/span_meta.go b/ddtrace/tracer/internal/span_meta.go index 86226d26c26..185ccfc1723 100644 --- a/ddtrace/tracer/internal/span_meta.go +++ b/ddtrace/tracer/internal/span_meta.go @@ -134,6 +134,9 @@ func (sm SpanMeta) Component() (string, bool) { return sm.attrs.Get(AttrComponen // SpanKind returns the value of the "span.kind" promoted attribute. func (sm SpanMeta) SpanKind() (string, bool) { return sm.attrs.Get(AttrSpanKind) } +// Language returns the value of the "language" promoted attribute. +func (sm SpanMeta) Language() (string, bool) { return sm.attrs.Get(AttrLanguage) } + // Range calls fn for each entry in the flat map (sm.m). // Promoted attrs live in sm.attrs and are not yielded here. // Iteration stops if fn returns false. @@ -198,12 +201,18 @@ func (sm *SpanMeta) ensureAttrsLocal() { // Delete removes key from both the flat map and (for promoted keys) attrs. // +checklocksignore — called both at init time (no lock) and under lock. +// +// The length switch is intentionally duplicated from IsPromotedKeyLen rather +// than calling it. Inlining IsPromotedKeyLen (cost 11) into Delete raises +// Delete's budget from 73 to 81, crossing the 80-unit limit and preventing +// callers from inlining Delete. The direct switch keeps Delete at cost 73. func (sm *SpanMeta) Delete(key string) { - if IsPromotedKeyLen(len(key)) { + switch len(key) { + case 3, 7, 8, 9: sm.deleteSlow(key) - return + default: + delete(sm.m, key) } - delete(sm.m, key) } // deleteSlow handles the promoted-key path for Delete. @@ -221,12 +230,12 @@ func (sm *SpanMeta) deleteSlow(key string) { } // IsPromotedKeyLen reports whether n matches the length of any promoted attribute name. -// Promoted keys: "env"(3), "version"(7), "component"(9), "span.kind"(9). +// Promoted keys: "env"(3), "version"(7), "language"(8), "component"(9), "span.kind"(9). // This must stay in sync with the Defs table in span_attributes.go; the init // check below enforces this at program start. func IsPromotedKeyLen(n int) bool { switch n { - case 3, 7, 9: + case 3, 7, 8, 9: return true } return false diff --git a/ddtrace/tracer/tracer.go b/ddtrace/tracer/tracer.go index 6ad230a7a4d..d95822d013e 100644 --- a/ddtrace/tracer/tracer.go +++ b/ddtrace/tracer/tracer.go @@ -497,8 +497,13 @@ func newUnstartedTracer(opts ...StartOption) (t *tracer, err error) { logFile: logFile, } // Build the shared SpanAttributes that every span will start from. - // Process-level values (env, version) are set once here; + // Process-level values (env, version, language) are set once here; // spans share this pointer and only clone on per-span overrides. + // language="go" is the same for every span — pre-populating it here + // makes the setMetaInit("language", "go") call in spanStart a COW no-op, + // deferring flat-map allocation until a span actually needs it. + t.sharedAttrs.Set(traceinternal.AttrLanguage, "go") + t.sharedAttrsForMainSvc.Set(traceinternal.AttrLanguage, "go") if env := c.internalConfig.Env(); env != "" { t.sharedAttrs.Set(traceinternal.AttrEnv, env) t.sharedAttrsForMainSvc.Set(traceinternal.AttrEnv, env) From d80aad0dacf557ceb0c3769437f2e58ad747f1f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Fri, 27 Mar 2026 11:36:41 +0000 Subject: [PATCH 60/71] feat(ddtrace/tracer): implement locking on SpanMeta --- ddtrace/tracer/internal/span_attributes.go | 10 +- ddtrace/tracer/internal/span_meta.go | 150 ++++++++++++++------- ddtrace/tracer/span.go | 2 +- ddtrace/tracer/spancontext.go | 4 +- ddtrace/tracer/tracer.go | 2 +- ddtrace/tracer/tracer_test.go | 2 +- 6 files changed, 115 insertions(+), 55 deletions(-) diff --git a/ddtrace/tracer/internal/span_attributes.go b/ddtrace/tracer/internal/span_attributes.go index c027b0097f5..e613cfc1d20 100644 --- a/ddtrace/tracer/internal/span_attributes.go +++ b/ddtrace/tracer/internal/span_attributes.go @@ -31,11 +31,11 @@ const ( // they index both vals[] and setMask bit positions. If any value drifts (e.g. via // iota + reorder), the expression below produces a compile error. var ( - _ = [1]byte{}[AttrEnv] // AttrEnv must be 0 - _ = [1]byte{}[AttrVersion-1] // AttrVersion must be 1 - _ = [1]byte{}[AttrComponent-2] // AttrComponent must be 2 - _ = [1]byte{}[AttrSpanKind-3] // AttrSpanKind must be 3 - _ = [1]byte{}[AttrLanguage-4] // AttrLanguage must be 4 + _ = [1]byte{}[AttrEnv] // AttrEnv must be 0 + _ = [1]byte{}[AttrVersion-1] // AttrVersion must be 1 + _ = [1]byte{}[AttrComponent-2] // AttrComponent must be 2 + _ = [1]byte{}[AttrSpanKind-3] // AttrSpanKind must be 3 + _ = [1]byte{}[AttrLanguage-4] // AttrLanguage must be 4 ) // SpanAttributes holds the V1-protocol promoted span fields. diff --git a/ddtrace/tracer/internal/span_meta.go b/ddtrace/tracer/internal/span_meta.go index 185ccfc1723..ace45690e50 100644 --- a/ddtrace/tracer/internal/span_meta.go +++ b/ddtrace/tracer/internal/span_meta.go @@ -11,6 +11,8 @@ import ( "strings" "github.com/tinylib/msgp/msgp" + + "github.com/DataDog/dd-trace-go/v2/internal/locking" ) // metaMapHint is the initial capacity for the flat map m. @@ -33,15 +35,22 @@ var ( ) // SpanMeta replaces a plain map[string]string for the Span.meta field. -// Promoted attributes (env, version, component, span.kind) live in attrs -// and are excluded from the map m. The msgp codec merges both sources +// Promoted attributes (env, version, component, span.kind, language) live in +// attrs and are excluded from the map m. The msgp codec merges both sources // transparently so the wire format is unchanged. // // Set routes promoted keys to attrs (with copy-on-write) and others to the -// flat map. Promoted keys never appear in sm.m. +// flat map. Promoted keys never appear in sm.m until Map() is called. +// +// Map() inlines promoted attrs into sm.m under mu, then returns sm.m directly +// (zero allocation on the hot stats path). EncodeMsg, Msgsize, Range, +// SerializableCount, and IsZero also acquire mu so they see a consistent view +// of sm.m during concurrent serialization. type SpanMeta struct { - m map[string]string - attrs *SpanAttributes + m map[string]string + attrs *SpanAttributes + mu locking.Mutex + inlined bool } // NewSpanMeta returns a SpanMeta initialized with shared attrs (used during span creation). @@ -56,7 +65,9 @@ func NewSpanMetaFromMap(m map[string]string) SpanMeta { // IsZero reports whether the SpanMeta contains no entries (map or promoted). // The msgp generator emits z.meta.IsZero() for the omitempty check. -func (sm SpanMeta) IsZero() bool { +func (sm *SpanMeta) IsZero() bool { + sm.mu.Lock() + defer sm.mu.Unlock() return len(sm.m) == 0 && sm.attrs.Count() == 0 } @@ -87,7 +98,7 @@ func (sm *SpanMeta) Normalize() { // Get returns the value for key. Promoted keys are checked in attrs first // (fast array+bitmask path), then the flat map. Non-promoted keys go directly // to the flat map. -func (sm SpanMeta) Get(key string) (string, bool) { +func (sm *SpanMeta) Get(key string) (string, bool) { if IsPromotedKeyLen(len(key)) { if v, ok, handled := sm.getPromoted(key); handled { return v, ok @@ -102,7 +113,7 @@ func (sm SpanMeta) Get(key string) (string, bool) { // getPromoted is the slow path for Get when the key might be a promoted attribute. // //go:noinline -func (sm SpanMeta) getPromoted(key string) (string, bool, bool) { +func (sm *SpanMeta) getPromoted(key string) (string, bool, bool) { ak, ok := AttrKeyForTag(key) if !ok { return "", false, false @@ -112,42 +123,55 @@ func (sm SpanMeta) getPromoted(key string) (string, bool, bool) { } // Has reports whether key is present. -func (sm SpanMeta) Has(key string) bool { +func (sm *SpanMeta) Has(key string) bool { _, ok := sm.Get(key) return ok } // Attr returns a promoted attribute value by AttrKey. O(1) array index + bitmask. -func (sm SpanMeta) Attr(key AttrKey) (string, bool) { +func (sm *SpanMeta) Attr(key AttrKey) (string, bool) { return sm.attrs.Get(key) } // Env returns the value of the "env" promoted attribute. -func (sm SpanMeta) Env() (string, bool) { return sm.attrs.Get(AttrEnv) } +func (sm *SpanMeta) Env() (string, bool) { return sm.attrs.Get(AttrEnv) } // Version returns the value of the "version" promoted attribute. -func (sm SpanMeta) Version() (string, bool) { return sm.attrs.Get(AttrVersion) } +func (sm *SpanMeta) Version() (string, bool) { return sm.attrs.Get(AttrVersion) } // Component returns the value of the "component" promoted attribute. -func (sm SpanMeta) Component() (string, bool) { return sm.attrs.Get(AttrComponent) } +func (sm *SpanMeta) Component() (string, bool) { return sm.attrs.Get(AttrComponent) } // SpanKind returns the value of the "span.kind" promoted attribute. -func (sm SpanMeta) SpanKind() (string, bool) { return sm.attrs.Get(AttrSpanKind) } +func (sm *SpanMeta) SpanKind() (string, bool) { return sm.attrs.Get(AttrSpanKind) } // Language returns the value of the "language" promoted attribute. -func (sm SpanMeta) Language() (string, bool) { return sm.attrs.Get(AttrLanguage) } - -// Range calls fn for each entry in the flat map (sm.m). -// Promoted attrs live in sm.attrs and are not yielded here. -// Iteration stops if fn returns false. -func (sm SpanMeta) Range(fn func(k, v string) bool) { +func (sm *SpanMeta) Language() (string, bool) { return sm.attrs.Get(AttrLanguage) } + +// Range calls fn for each entry in the flat map (sm.m), skipping any promoted +// keys that were inlined into sm.m by a previous Map() call. Promoted attrs +// live in sm.attrs and are not yielded here (or in v1 they are encoded as +// dedicated fields 13-16). Iteration stops if fn returns false. +func (sm *SpanMeta) Range(fn func(k, v string) bool) { + sm.mu.Lock() + defer sm.mu.Unlock() for k, v := range sm.m { + if sm.inlined && isPromotedKey(k) { + continue + } if !fn(k, v) { return } } } +// isPromotedKey reports whether k is an exact promoted attribute name. +// Only called on the hot path when inlined=true, so the extra check is rare. +func isPromotedKey(k string) bool { + _, ok := AttrKeyForTag(k) + return ok +} + // --------------------------------------------------------------------------- // Write methods // --------------------------------------------------------------------------- @@ -254,46 +278,57 @@ func init() { // --------------------------------------------------------------------------- // Count returns the total number of distinct entries (flat map + promoted attrs). -func (sm SpanMeta) Count() int { +func (sm *SpanMeta) Count() int { return len(sm.m) + sm.attrs.Count() } // AttrCount returns the number of promoted attrs currently set. -func (sm SpanMeta) AttrCount() int { +func (sm *SpanMeta) AttrCount() int { return sm.attrs.Count() } // SerializableCount returns the number of flat-map entries that appear in the // serialized attributes array. Promoted attrs are encoded as dedicated fields -// in the v1 protocol and must not be double-counted. -func (sm SpanMeta) SerializableCount() int { +// in the v1 protocol and must not be double-counted. When Map() has been called, +// promoted keys are also in sm.m, so they are subtracted from the count. +func (sm *SpanMeta) SerializableCount() int { + sm.mu.Lock() + defer sm.mu.Unlock() + if sm.inlined { + return len(sm.m) - sm.attrs.Count() + } return len(sm.m) } -// Map returns a new map containing all flat-map entries plus all promoted -// attrs. Always allocates — never returns sm.m directly, avoiding races -// when the result outlives the span's serialization window. -func (sm SpanMeta) Map() map[string]string { - n := len(sm.m) + sm.attrs.Count() - if n == 0 { - return nil - } - m := make(map[string]string, n) - for k, v := range sm.m { - m[k] = v - } - for _, d := range Defs { - if sm.attrs.Has(d.Key) { - m[d.Name] = sm.attrs.vals[d.Key] +// Map inlines all promoted attrs into sm.m on first call, then returns sm.m +// directly — zero allocation on subsequent calls. Both Map and the serialization +// methods (EncodeMsg, Msgsize, Range, SerializableCount, IsZero) acquire mu so +// they observe a consistent view of sm.m during concurrent access. +func (sm *SpanMeta) Map() map[string]string { + sm.mu.Lock() + defer sm.mu.Unlock() + if !sm.inlined { + if sm.attrs != nil { + if n := sm.attrs.Count(); n > 0 { + if sm.m == nil { + sm.m = make(map[string]string, n) + } + for _, d := range Defs { + if sm.attrs.Has(d.Key) { + sm.m[d.Name] = sm.attrs.vals[d.Key] + } + } + } } + sm.inlined = true } - return m + return sm.m } // All returns an iterator over all entries. Flat-map entries are yielded first // (in unspecified order), followed by promoted attributes in definition order // (env, version, component, span.kind). Returning false from yield stops iteration. -func (sm SpanMeta) All() iter.Seq2[string, string] { +func (sm *SpanMeta) All() iter.Seq2[string, string] { return func(yield func(string, string) bool) { for k, v := range sm.m { if !yield(k, v) { @@ -314,7 +349,7 @@ func (sm SpanMeta) All() iter.Seq2[string, string] { } // String returns a merged map representation (m + promoted attrs) for debug logging. -func (sm SpanMeta) String() string { +func (sm *SpanMeta) String() string { var b strings.Builder b.WriteString("map[") first := true @@ -333,9 +368,27 @@ func (sm SpanMeta) String() string { // msgp codec // --------------------------------------------------------------------------- -// EncodeMsg writes the map header (flat map entries + promoted attrs), -// then emits all flat map entries followed by all set promoted attrs. +// EncodeMsg writes the map header and entries. When Map() has already been +// called (inlined=true), sm.m contains all entries and attrs is skipped to +// avoid double-encoding. Otherwise the flat map and promoted attrs are +// combined as normal. func (sm *SpanMeta) EncodeMsg(en *msgp.Writer) error { + sm.mu.Lock() + defer sm.mu.Unlock() + if sm.inlined { + if err := en.WriteMapHeader(uint32(len(sm.m))); err != nil { + return msgp.WrapError(err, "Meta") + } + for k, v := range sm.m { + if err := en.WriteString(k); err != nil { + return msgp.WrapError(err, "Meta") + } + if err := en.WriteString(v); err != nil { + return msgp.WrapError(err, "Meta", k) + } + } + return nil + } n := sm.attrs.Count() if err := en.WriteMapHeader(uint32(len(sm.m) + n)); err != nil { return msgp.WrapError(err, "Meta") @@ -397,12 +450,19 @@ func (sm *SpanMeta) DecodeMsg(dc *msgp.Reader) error { return nil } -// Msgsize returns an upper bound estimate of the serialized size. +// Msgsize returns an upper bound estimate of the serialized size. When Map() +// has already been called (inlined=true), sm.m contains all entries so attrs +// is not separately sized to avoid double-counting. func (sm *SpanMeta) Msgsize() int { + sm.mu.Lock() + defer sm.mu.Unlock() size := msgp.MapHeaderSize for k, v := range sm.m { size += msgp.StringPrefixSize + len(k) + msgp.StringPrefixSize + len(v) } + if sm.inlined { + return size + } if n := sm.attrs.Count(); n > 0 { for _, d := range Defs { if v, ok := sm.attrs.Get(d.Key); ok { diff --git a/ddtrace/tracer/span.go b/ddtrace/tracer/span.go index bde525e7fad..7a86d21b083 100644 --- a/ddtrace/tracer/span.go +++ b/ddtrace/tracer/span.go @@ -1020,7 +1020,7 @@ func (s *Span) finish(finishTime int64) { if log.DebugEnabled() { // avoid allocating the ...interface{} argument if debug logging is disabled log.Debug("Finished Span: %v, Operation: %s, Resource: %s, Tags: %v, %v", //nolint:gocritic // Debug logging needs full span representation - s, s.name, s.resource, s.meta, s.metrics) + s, s.name, s.resource, &s.meta, s.metrics) } // Call context.finish() which handles trace-level bookkeeping and may modify // this span (to set trace-level tags). diff --git a/ddtrace/tracer/spancontext.go b/ddtrace/tracer/spancontext.go index 2f9e6e603ae..4ab8039478b 100644 --- a/ddtrace/tracer/spancontext.go +++ b/ddtrace/tracer/spancontext.go @@ -858,7 +858,7 @@ func setPeerService(s *Span, tc TracerConf) { } else if isServerless(tc) { // Set peerService only in outbound Lambda requests if isOutboundRequest { - if ps := deriveAWSPeerService(s.meta); ps != "" { + if ps := deriveAWSPeerService(&s.meta); ps != "" { s.setMetaLocked(ext.PeerService, ps) s.setMetaLocked(keyPeerServiceSource, ext.PeerService) } else { @@ -908,7 +908,7 @@ The mapping is as follows: - s3: .s3..amazonaws.com (if Bucket param present) s3..amazonaws.com (otherwise) */ -func deriveAWSPeerService(sm traceinternal.SpanMeta) string { +func deriveAWSPeerService(sm *traceinternal.SpanMeta) string { service, ok := sm.Get(ext.AWSService) if !ok { return "" diff --git a/ddtrace/tracer/tracer.go b/ddtrace/tracer/tracer.go index d95822d013e..c44a5c11190 100644 --- a/ddtrace/tracer/tracer.go +++ b/ddtrace/tracer/tracer.go @@ -931,7 +931,7 @@ func (t *tracer) StartSpan(operationName string, options ...StartSpanOption) *Sp if log.DebugEnabled() { // avoid allocating the ...interface{} argument if debug logging is disabled log.Debug("Started Span: %v, Operation: %s, Resource: %s, Tags: %v, %v", //nolint:gocritic // Debug logging needs full span representation - span, span.name, span.resource, span.meta, span.metrics) + span, span.name, span.resource, &span.meta, span.metrics) } if t.config.internalConfig.ProfilerHotspotsEnabled() || t.config.internalConfig.ProfilerEndpoints() { t.applyPPROFLabels(span.pprofCtxRestore, span) diff --git a/ddtrace/tracer/tracer_test.go b/ddtrace/tracer/tracer_test.go index 5551d5684e1..7bd341d3199 100644 --- a/ddtrace/tracer/tracer_test.go +++ b/ddtrace/tracer/tracer_test.go @@ -2473,7 +2473,7 @@ func cpspan(s *Span) *Span { spanType: s.spanType, start: s.start, duration: s.duration, - meta: s.meta, + meta: s.meta, //nolint:govet // copylocks: cpspan copies for comparison only; mutex is never held metrics: s.metrics, spanID: s.spanID, traceID: s.traceID, From c1a35434cd4d431e0b4b3816909de5e43b49544e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Fri, 27 Mar 2026 13:01:10 +0000 Subject: [PATCH 61/71] fix(ddtrace/tracer): reduce mutex contantion leveraging inline as atomic.Bool in SpanMeta --- ddtrace/tracer/internal/span_meta.go | 82 +++++++++++++++++++++++----- ddtrace/tracer/tracer_test.go | 3 +- 2 files changed, 70 insertions(+), 15 deletions(-) diff --git a/ddtrace/tracer/internal/span_meta.go b/ddtrace/tracer/internal/span_meta.go index ace45690e50..64a4ac94458 100644 --- a/ddtrace/tracer/internal/span_meta.go +++ b/ddtrace/tracer/internal/span_meta.go @@ -9,6 +9,7 @@ import ( "fmt" "iter" "strings" + "sync/atomic" "github.com/tinylib/msgp/msgp" @@ -50,7 +51,7 @@ type SpanMeta struct { m map[string]string attrs *SpanAttributes mu locking.Mutex - inlined bool + inlined atomic.Bool } // NewSpanMeta returns a SpanMeta initialized with shared attrs (used during span creation). @@ -66,6 +67,9 @@ func NewSpanMetaFromMap(m map[string]string) SpanMeta { // IsZero reports whether the SpanMeta contains no entries (map or promoted). // The msgp generator emits z.meta.IsZero() for the omitempty check. func (sm *SpanMeta) IsZero() bool { + if sm.inlined.Load() { + return false // Map() wrote entries to sm.m; always non-empty + } sm.mu.Lock() defer sm.mu.Unlock() return len(sm.m) == 0 && sm.attrs.Count() == 0 @@ -148,15 +152,26 @@ func (sm *SpanMeta) SpanKind() (string, bool) { return sm.attrs.Get(AttrSpanKind // Language returns the value of the "language" promoted attribute. func (sm *SpanMeta) Language() (string, bool) { return sm.attrs.Get(AttrLanguage) } -// Range calls fn for each entry in the flat map (sm.m), skipping any promoted -// keys that were inlined into sm.m by a previous Map() call. Promoted attrs -// live in sm.attrs and are not yielded here (or in v1 they are encoded as -// dedicated fields 13-16). Iteration stops if fn returns false. +// Range calls fn for each flat-map entry. When Map() has been called, +// promoted keys in sm.m are skipped so v1 callers don't double-encode them. +// Iteration stops if fn returns false. func (sm *SpanMeta) Range(fn func(k, v string) bool) { + if sm.inlined.Load() { // fast path: sm.m is finalized, no concurrent writes + for k, v := range sm.m { + if isPromotedKey(k) { + continue + } + if !fn(k, v) { + return + } + } + return + } sm.mu.Lock() defer sm.mu.Unlock() + inlined := sm.inlined.Load() // re-read under lock: Map() may have run while waiting for k, v := range sm.m { - if sm.inlined && isPromotedKey(k) { + if inlined && isPromotedKey(k) { continue } if !fn(k, v) { @@ -292,9 +307,12 @@ func (sm *SpanMeta) AttrCount() int { // in the v1 protocol and must not be double-counted. When Map() has been called, // promoted keys are also in sm.m, so they are subtracted from the count. func (sm *SpanMeta) SerializableCount() int { + if sm.inlined.Load() { // fast path: sm.m is finalized, no concurrent writes + return len(sm.m) - sm.attrs.Count() + } sm.mu.Lock() defer sm.mu.Unlock() - if sm.inlined { + if sm.inlined.Load() { return len(sm.m) - sm.attrs.Count() } return len(sm.m) @@ -305,9 +323,12 @@ func (sm *SpanMeta) SerializableCount() int { // methods (EncodeMsg, Msgsize, Range, SerializableCount, IsZero) acquire mu so // they observe a consistent view of sm.m during concurrent access. func (sm *SpanMeta) Map() map[string]string { + if sm.inlined.Load() { // fast path: already inlined, no lock needed + return sm.m + } sm.mu.Lock() defer sm.mu.Unlock() - if !sm.inlined { + if !sm.inlined.Load() { // double-check under lock if sm.attrs != nil { if n := sm.attrs.Count(); n > 0 { if sm.m == nil { @@ -320,22 +341,34 @@ func (sm *SpanMeta) Map() map[string]string { } } } - sm.inlined = true + sm.inlined.Store(true) // release: all prior writes to sm.m are now visible } return sm.m } // All returns an iterator over all entries. Flat-map entries are yielded first -// (in unspecified order), followed by promoted attributes in definition order -// (env, version, component, span.kind). Returning false from yield stops iteration. +// (in unspecified order), followed by promoted attributes. When Map() has been +// called, all entries are already in sm.m and the attrs loop is skipped. +// Returning false from yield stops iteration. func (sm *SpanMeta) All() iter.Seq2[string, string] { return func(yield func(string, string) bool) { + if sm.inlined.Load() { // fast path: sm.m is finalized, no lock needed + for k, v := range sm.m { + if !yield(k, v) { + return + } + } + return + } + sm.mu.Lock() + defer sm.mu.Unlock() + inlined := sm.inlined.Load() // re-read under lock: Map() may have run while we waited for k, v := range sm.m { if !yield(k, v) { return } } - if sm.attrs == nil { + if inlined || sm.attrs == nil { return } for _, d := range Defs { @@ -373,9 +406,23 @@ func (sm *SpanMeta) String() string { // avoid double-encoding. Otherwise the flat map and promoted attrs are // combined as normal. func (sm *SpanMeta) EncodeMsg(en *msgp.Writer) error { + if sm.inlined.Load() { // fast path: sm.m has everything, no lock needed + if err := en.WriteMapHeader(uint32(len(sm.m))); err != nil { + return msgp.WrapError(err, "Meta") + } + for k, v := range sm.m { + if err := en.WriteString(k); err != nil { + return msgp.WrapError(err, "Meta") + } + if err := en.WriteString(v); err != nil { + return msgp.WrapError(err, "Meta", k) + } + } + return nil + } sm.mu.Lock() defer sm.mu.Unlock() - if sm.inlined { + if sm.inlined.Load() { // double-check: Map() may have run while waiting if err := en.WriteMapHeader(uint32(len(sm.m))); err != nil { return msgp.WrapError(err, "Meta") } @@ -454,13 +501,20 @@ func (sm *SpanMeta) DecodeMsg(dc *msgp.Reader) error { // has already been called (inlined=true), sm.m contains all entries so attrs // is not separately sized to avoid double-counting. func (sm *SpanMeta) Msgsize() int { + if sm.inlined.Load() { // fast path: sm.m has everything, no lock needed + size := msgp.MapHeaderSize + for k, v := range sm.m { + size += msgp.StringPrefixSize + len(k) + msgp.StringPrefixSize + len(v) + } + return size + } sm.mu.Lock() defer sm.mu.Unlock() size := msgp.MapHeaderSize for k, v := range sm.m { size += msgp.StringPrefixSize + len(k) + msgp.StringPrefixSize + len(v) } - if sm.inlined { + if sm.inlined.Load() { // double-check under lock return size } if n := sm.attrs.Count(); n > 0 { diff --git a/ddtrace/tracer/tracer_test.go b/ddtrace/tracer/tracer_test.go index 7bd341d3199..1e86064df2e 100644 --- a/ddtrace/tracer/tracer_test.go +++ b/ddtrace/tracer/tracer_test.go @@ -32,6 +32,7 @@ import ( "github.com/DataDog/dd-trace-go/v2/ddtrace" "github.com/DataDog/dd-trace-go/v2/ddtrace/ext" "github.com/DataDog/dd-trace-go/v2/ddtrace/internal/tracerstats" + traceinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" "github.com/DataDog/dd-trace-go/v2/internal" internalconfig "github.com/DataDog/dd-trace-go/v2/internal/config" "github.com/DataDog/dd-trace-go/v2/internal/globalconfig" @@ -2473,7 +2474,7 @@ func cpspan(s *Span) *Span { spanType: s.spanType, start: s.start, duration: s.duration, - meta: s.meta, //nolint:govet // copylocks: cpspan copies for comparison only; mutex is never held + meta: traceinternal.NewSpanMetaFromMap(s.meta.Map()), // flatten to plain map for comparison metrics: s.metrics, spanID: s.spanID, traceID: s.traceID, From eece436d5442403dd2bd51835adb5526f0ee2643 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Fri, 27 Mar 2026 14:15:57 +0000 Subject: [PATCH 62/71] revert: go back to lock-free implementation; add SpanMeta.Finish to avoid race conditions --- ddtrace/tracer/internal/span_meta.go | 131 ++++++++------------------- ddtrace/tracer/spancontext.go | 6 ++ 2 files changed, 43 insertions(+), 94 deletions(-) diff --git a/ddtrace/tracer/internal/span_meta.go b/ddtrace/tracer/internal/span_meta.go index 64a4ac94458..fad1fc75d4f 100644 --- a/ddtrace/tracer/internal/span_meta.go +++ b/ddtrace/tracer/internal/span_meta.go @@ -9,11 +9,8 @@ import ( "fmt" "iter" "strings" - "sync/atomic" "github.com/tinylib/msgp/msgp" - - "github.com/DataDog/dd-trace-go/v2/internal/locking" ) // metaMapHint is the initial capacity for the flat map m. @@ -41,17 +38,17 @@ var ( // transparently so the wire format is unchanged. // // Set routes promoted keys to attrs (with copy-on-write) and others to the -// flat map. Promoted keys never appear in sm.m until Map() is called. +// flat map. Promoted keys never appear in sm.m until Finish() is called. // -// Map() inlines promoted attrs into sm.m under mu, then returns sm.m directly -// (zero allocation on the hot stats path). EncodeMsg, Msgsize, Range, -// SerializableCount, and IsZero also acquire mu so they see a consistent view -// of sm.m during concurrent serialization. +// Finish() must be called once — after all tag writes and before the span is +// handed to the writer goroutine. It inlines promoted attrs into sm.m and +// publishes an atomic release fence (inlined=true). After that point sm.m is +// read-only, so serialization methods (EncodeMsg, Msgsize, Range, etc.) can +// read it lock-free via the acquire fence in inlined.Load(). type SpanMeta struct { m map[string]string attrs *SpanAttributes - mu locking.Mutex - inlined atomic.Bool + inlined bool } // NewSpanMeta returns a SpanMeta initialized with shared attrs (used during span creation). @@ -67,11 +64,9 @@ func NewSpanMetaFromMap(m map[string]string) SpanMeta { // IsZero reports whether the SpanMeta contains no entries (map or promoted). // The msgp generator emits z.meta.IsZero() for the omitempty check. func (sm *SpanMeta) IsZero() bool { - if sm.inlined.Load() { - return false // Map() wrote entries to sm.m; always non-empty + if sm.inlined { + return false // Finish() wrote entries to sm.m; always non-empty } - sm.mu.Lock() - defer sm.mu.Unlock() return len(sm.m) == 0 && sm.attrs.Count() == 0 } @@ -156,20 +151,7 @@ func (sm *SpanMeta) Language() (string, bool) { return sm.attrs.Get(AttrLanguage // promoted keys in sm.m are skipped so v1 callers don't double-encode them. // Iteration stops if fn returns false. func (sm *SpanMeta) Range(fn func(k, v string) bool) { - if sm.inlined.Load() { // fast path: sm.m is finalized, no concurrent writes - for k, v := range sm.m { - if isPromotedKey(k) { - continue - } - if !fn(k, v) { - return - } - } - return - } - sm.mu.Lock() - defer sm.mu.Unlock() - inlined := sm.inlined.Load() // re-read under lock: Map() may have run while waiting + inlined := sm.inlined for k, v := range sm.m { if inlined && isPromotedKey(k) { continue @@ -307,42 +289,39 @@ func (sm *SpanMeta) AttrCount() int { // in the v1 protocol and must not be double-counted. When Map() has been called, // promoted keys are also in sm.m, so they are subtracted from the count. func (sm *SpanMeta) SerializableCount() int { - if sm.inlined.Load() { // fast path: sm.m is finalized, no concurrent writes - return len(sm.m) - sm.attrs.Count() - } - sm.mu.Lock() - defer sm.mu.Unlock() - if sm.inlined.Load() { + if sm.inlined { return len(sm.m) - sm.attrs.Count() } return len(sm.m) } -// Map inlines all promoted attrs into sm.m on first call, then returns sm.m -// directly — zero allocation on subsequent calls. Both Map and the serialization -// methods (EncodeMsg, Msgsize, Range, SerializableCount, IsZero) acquire mu so -// they observe a consistent view of sm.m during concurrent access. -func (sm *SpanMeta) Map() map[string]string { - if sm.inlined.Load() { // fast path: already inlined, no lock needed - return sm.m - } - sm.mu.Lock() - defer sm.mu.Unlock() - if !sm.inlined.Load() { // double-check under lock - if sm.attrs != nil { - if n := sm.attrs.Count(); n > 0 { - if sm.m == nil { - sm.m = make(map[string]string, n) - } - for _, d := range Defs { - if sm.attrs.Has(d.Key) { - sm.m[d.Name] = sm.attrs.vals[d.Key] - } +// Finish inlines all promoted attrs into sm.m and publishes an atomic release +// fence (inlined=true). Must be called once — after all tag writes and before +// the span is handed to the writer goroutine. After this point sm.m is +// permanently read-only, and serialization methods need no further locking. +func (sm *SpanMeta) Finish() { + if sm.inlined { + return + } + if sm.attrs != nil { + if n := sm.attrs.Count(); n > 0 { + if sm.m == nil { + sm.m = make(map[string]string, n) + } + for _, d := range Defs { + if sm.attrs.Has(d.Key) { + sm.m[d.Name] = sm.attrs.vals[d.Key] } } } - sm.inlined.Store(true) // release: all prior writes to sm.m are now visible } + sm.inlined = true // release: all prior writes to sm.m are now visible +} + +// Map returns sm.m with all entries including promoted attrs. Calls Finish() +// if not already done so the result is always complete. +func (sm *SpanMeta) Map() map[string]string { + sm.Finish() return sm.m } @@ -352,23 +331,12 @@ func (sm *SpanMeta) Map() map[string]string { // Returning false from yield stops iteration. func (sm *SpanMeta) All() iter.Seq2[string, string] { return func(yield func(string, string) bool) { - if sm.inlined.Load() { // fast path: sm.m is finalized, no lock needed - for k, v := range sm.m { - if !yield(k, v) { - return - } - } - return - } - sm.mu.Lock() - defer sm.mu.Unlock() - inlined := sm.inlined.Load() // re-read under lock: Map() may have run while we waited for k, v := range sm.m { if !yield(k, v) { return } } - if inlined || sm.attrs == nil { + if sm.inlined || sm.attrs == nil { return } for _, d := range Defs { @@ -406,23 +374,7 @@ func (sm *SpanMeta) String() string { // avoid double-encoding. Otherwise the flat map and promoted attrs are // combined as normal. func (sm *SpanMeta) EncodeMsg(en *msgp.Writer) error { - if sm.inlined.Load() { // fast path: sm.m has everything, no lock needed - if err := en.WriteMapHeader(uint32(len(sm.m))); err != nil { - return msgp.WrapError(err, "Meta") - } - for k, v := range sm.m { - if err := en.WriteString(k); err != nil { - return msgp.WrapError(err, "Meta") - } - if err := en.WriteString(v); err != nil { - return msgp.WrapError(err, "Meta", k) - } - } - return nil - } - sm.mu.Lock() - defer sm.mu.Unlock() - if sm.inlined.Load() { // double-check: Map() may have run while waiting + if sm.inlined { if err := en.WriteMapHeader(uint32(len(sm.m))); err != nil { return msgp.WrapError(err, "Meta") } @@ -501,20 +453,11 @@ func (sm *SpanMeta) DecodeMsg(dc *msgp.Reader) error { // has already been called (inlined=true), sm.m contains all entries so attrs // is not separately sized to avoid double-counting. func (sm *SpanMeta) Msgsize() int { - if sm.inlined.Load() { // fast path: sm.m has everything, no lock needed - size := msgp.MapHeaderSize - for k, v := range sm.m { - size += msgp.StringPrefixSize + len(k) + msgp.StringPrefixSize + len(v) - } - return size - } - sm.mu.Lock() - defer sm.mu.Unlock() size := msgp.MapHeaderSize for k, v := range sm.m { size += msgp.StringPrefixSize + len(k) + msgp.StringPrefixSize + len(v) } - if sm.inlined.Load() { // double-check under lock + if sm.inlined { return size } if n := sm.attrs.Count(); n > 0 { diff --git a/ddtrace/tracer/spancontext.go b/ddtrace/tracer/spancontext.go index 4ab8039478b..bc4f43f277d 100644 --- a/ddtrace/tracer/spancontext.go +++ b/ddtrace/tracer/spancontext.go @@ -771,6 +771,12 @@ func (t *trace) finishedOneLocked(s *Span) { mtr.FinishSpan(s) } + // Finalise the span's metadata: inlines promoted attrs into sm.m and sets + // inlined=true (atomic release). By the time the writer goroutine reads sm.m + // via EncodeMsg/Msgsize/Range, the acquire fence in inlined.Load() ensures + // all writes are visible — no further locking is needed on the read side. + s.meta.Finish() + // Full flush: all spans finished if len(t.spans) == t.finished { spans := t.spans From 78586fe8f38e05323a6b35f0b46a468a2584d12f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Fri, 27 Mar 2026 14:39:18 +0000 Subject: [PATCH 63/71] fix(ddtrace/tracer): demote component and span.kind; adopt read-only naming for shared attributes, improve internal naming --- ddtrace/tracer/internal/span_attributes.go | 46 ++++------ .../tracer/internal/span_attributes_test.go | 51 +++++------ ddtrace/tracer/internal/span_meta.go | 88 +++++++++---------- ddtrace/tracer/payload_v1.go | 10 +-- ddtrace/tracer/tracer.go | 41 +++++---- 5 files changed, 108 insertions(+), 128 deletions(-) diff --git a/ddtrace/tracer/internal/span_attributes.go b/ddtrace/tracer/internal/span_attributes.go index e613cfc1d20..381e331f6ee 100644 --- a/ddtrace/tracer/internal/span_attributes.go +++ b/ddtrace/tracer/internal/span_attributes.go @@ -15,12 +15,10 @@ import ( type AttrKey uint8 const ( - AttrEnv AttrKey = 0 - AttrVersion AttrKey = 1 - AttrComponent AttrKey = 2 - AttrSpanKind AttrKey = 3 - AttrLanguage AttrKey = 4 - numAttrs AttrKey = 5 + AttrEnv AttrKey = 0 + AttrVersion AttrKey = 1 + AttrLanguage AttrKey = 2 + numAttrs AttrKey = 3 // AttrUnknown is returned by AttrKeyForTag when no promoted tag matches. // Its value is intentionally out of range for vals[] so misuse panics immediately. @@ -31,25 +29,23 @@ const ( // they index both vals[] and setMask bit positions. If any value drifts (e.g. via // iota + reorder), the expression below produces a compile error. var ( - _ = [1]byte{}[AttrEnv] // AttrEnv must be 0 - _ = [1]byte{}[AttrVersion-1] // AttrVersion must be 1 - _ = [1]byte{}[AttrComponent-2] // AttrComponent must be 2 - _ = [1]byte{}[AttrSpanKind-3] // AttrSpanKind must be 3 - _ = [1]byte{}[AttrLanguage-4] // AttrLanguage must be 4 + _ = [1]byte{}[AttrEnv] // AttrEnv must be 0 + _ = [1]byte{}[AttrVersion-1] // AttrVersion must be 1 + _ = [1]byte{}[AttrLanguage-2] // AttrLanguage must be 2 ) // SpanAttributes holds the V1-protocol promoted span fields. // Zero value = all fields absent. // Set(key, "") is distinct from never-Set: the bit is set, the string is "". // -// Layout: 1-byte setMask + 1-byte shared + 6B padding + [5]string (80B) = 88 bytes. +// Layout: 1-byte setMask + 1-byte readOnly + 6B padding + [3]string (48B) = 56 bytes. // -// When shared is true, the instance is owned by the tracer and must not be +// When readOnly is true, the instance is owned by the tracer and must not be // mutated. Callers must Clone before writing (copy-on-write). type SpanAttributes struct { - setMask uint8 - shared bool - vals [numAttrs]string + setMask uint8 + readOnly bool + vals [numAttrs]string } // All read methods are nil-safe so callers holding a *SpanAttributes don't @@ -92,11 +88,11 @@ func (a *SpanAttributes) Count() int { return bits.OnesCount8(a.setMask) } -// MarkShared marks this instance as shared (read-only). Clone before mutating. -func (a *SpanAttributes) MarkShared() { a.shared = true } +// MarkReadOnly marks this instance as readOnly (read-only). Clone before mutating. +func (a *SpanAttributes) MarkReadOnly() { a.readOnly = true } -// IsShared reports whether this is a shared instance requiring COW. -func (a *SpanAttributes) IsShared() bool { return a != nil && a.shared } +// IsReadOnly reports whether this is a readOnly instance requiring COW. +func (a *SpanAttributes) IsReadOnly() bool { return a != nil && a.readOnly } // Reset clears all set attributes, returning the instance to its zero state. // It is nil-safe and does not free the underlying memory, making it suitable @@ -108,13 +104,13 @@ func (a *SpanAttributes) Reset() { *a = SpanAttributes{} } -// Clone returns a mutable (non-shared) shallow copy. +// Clone returns a mutable (non-readOnly) shallow copy. func (a *SpanAttributes) Clone() *SpanAttributes { if a == nil { return &SpanAttributes{} } cp := *a - cp.shared = false + cp.readOnly = false return &cp } @@ -128,8 +124,6 @@ type AttrDef struct { var Defs = [numAttrs]AttrDef{ {AttrEnv, "env"}, {AttrVersion, "version"}, - {AttrComponent, "component"}, - {AttrSpanKind, "span.kind"}, {AttrLanguage, "language"}, } @@ -160,10 +154,6 @@ func AttrKeyForTag(tag string) (AttrKey, bool) { return AttrEnv, true case "version": return AttrVersion, true - case "component": - return AttrComponent, true - case "span.kind": - return AttrSpanKind, true case "language": return AttrLanguage, true } diff --git a/ddtrace/tracer/internal/span_attributes_test.go b/ddtrace/tracer/internal/span_attributes_test.go index 195db1b9a96..6c7550474cf 100644 --- a/ddtrace/tracer/internal/span_attributes_test.go +++ b/ddtrace/tracer/internal/span_attributes_test.go @@ -12,7 +12,7 @@ import ( func TestSpanAttributesZeroValue(t *testing.T) { var a SpanAttributes - for _, key := range []AttrKey{AttrEnv, AttrVersion, AttrComponent, AttrSpanKind} { + for _, key := range []AttrKey{AttrEnv, AttrVersion, AttrLanguage} { if v, ok := a.Get(key); ok || v != "" { t.Errorf("key %d: expected absent zero value, got (%q, %v)", key, v, ok) } @@ -26,8 +26,7 @@ func TestSpanAttributesSetAndGet(t *testing.T) { }{ {AttrEnv, "prod"}, {AttrVersion, "1.2.3"}, - {AttrComponent, "net/http"}, - {AttrSpanKind, "server"}, + {AttrLanguage, "go"}, } var a SpanAttributes for _, tt := range tests { @@ -78,7 +77,7 @@ func TestSpanAttributesIndependentKeys(t *testing.T) { a.Set(AttrEnv, "prod") // Other keys must remain absent. - for _, key := range []AttrKey{AttrVersion, AttrComponent, AttrSpanKind} { + for _, key := range []AttrKey{AttrVersion, AttrLanguage} { if _, ok := a.Get(key); ok { t.Errorf("key %d should be absent after setting only AttrEnv", key) } @@ -96,8 +95,7 @@ func TestSpanAttributesValUnset(t *testing.T) { func TestSpanAttributesForEach(t *testing.T) { var a SpanAttributes a.Set(AttrEnv, "prod") - a.Set(AttrSpanKind, "server") - // AttrVersion and AttrComponent are NOT set + a.Set(AttrVersion, "1.2.3") got := maps.Collect(a.All()) if len(got) != 2 { @@ -106,8 +104,8 @@ func TestSpanAttributesForEach(t *testing.T) { if got["env"] != "prod" { t.Errorf("expected env=prod, got %q", got["env"]) } - if got["span.kind"] != "server" { - t.Errorf("expected span.kind=server, got %q", got["span.kind"]) + if got["version"] != "1.2.3" { + t.Errorf("expected version=1.2.3, got %q", got["version"]) } } @@ -130,8 +128,9 @@ func TestAttrKeyForTag(t *testing.T) { }{ {"env", AttrEnv, true}, {"version", AttrVersion, true}, - {"component", AttrComponent, true}, - {"span.kind", AttrSpanKind, true}, + {"language", AttrLanguage, true}, + {"component", AttrUnknown, false}, + {"span.kind", AttrUnknown, false}, {"unknown", AttrUnknown, false}, {"", AttrUnknown, false}, } @@ -153,34 +152,31 @@ func BenchmarkSpanAttributesSet(b *testing.B) { for i := 0; i < b.N; i++ { a.Set(AttrEnv, "prod") a.Set(AttrVersion, "1.2.3") - a.Set(AttrComponent, "net/http") - a.Set(AttrSpanKind, "server") + a.Set(AttrLanguage, "go") } _ = a }) b.Run("map", func(b *testing.B) { - m := make(map[string]string, 4) + m := make(map[string]string, 3) b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { m["env"] = "prod" m["version"] = "1.2.3" - m["component"] = "net/http" - m["span.kind"] = "server" + m["language"] = "go" } _ = m }) } -// BenchmarkSpanAttributesGet benchmarks reading all four promoted fields. +// BenchmarkSpanAttributesGet benchmarks reading all promoted fields. func BenchmarkSpanAttributesGet(b *testing.B) { b.Run("SpanAttributes", func(b *testing.B) { var a SpanAttributes a.Set(AttrEnv, "prod") a.Set(AttrVersion, "1.2.3") - a.Set(AttrComponent, "net/http") - a.Set(AttrSpanKind, "server") + a.Set(AttrLanguage, "go") b.ReportAllocs() b.ResetTimer() var s string @@ -188,18 +184,16 @@ func BenchmarkSpanAttributesGet(b *testing.B) { for i := 0; i < b.N; i++ { s, ok = a.Get(AttrEnv) s, ok = a.Get(AttrVersion) - s, ok = a.Get(AttrComponent) - s, ok = a.Get(AttrSpanKind) + s, ok = a.Get(AttrLanguage) } _, _ = s, ok }) b.Run("map", func(b *testing.B) { m := map[string]string{ - "env": "prod", - "version": "1.2.3", - "component": "net/http", - "span.kind": "server", + "env": "prod", + "version": "1.2.3", + "language": "go", } b.ReportAllocs() b.ResetTimer() @@ -208,8 +202,8 @@ func BenchmarkSpanAttributesGet(b *testing.B) { for i := 0; i < b.N; i++ { s, ok = m["env"] s, ok = m["version"] - s, ok = m["component"] - s, ok = m["span.kind"] + s, ok = m["env"] + s, ok = m["language"] } _, _ = s, ok }) @@ -236,7 +230,7 @@ func TestSpanMetaSetPromotedEmptyString(t *testing.T) { func TestSpanMetaSetPromotedNoOpWhenPresent(t *testing.T) { var a SpanAttributes a.Set(AttrEnv, "prod") - a.MarkShared() + a.MarkReadOnly() sm := NewSpanMeta(&a) // Same value: result must still be ("prod", true). @@ -260,8 +254,7 @@ func BenchmarkMap(b *testing.B) { var a SpanAttributes a.Set(AttrEnv, "prod") a.Set(AttrVersion, "1.2.3") - a.Set(AttrComponent, "net/http") - a.Set(AttrSpanKind, "server") + a.Set(AttrLanguage, "go") sm := NewSpanMeta(&a) sm.Set("key0", "value0") sm.Set("key1", "value1") diff --git a/ddtrace/tracer/internal/span_meta.go b/ddtrace/tracer/internal/span_meta.go index fad1fc75d4f..78ddd3ac396 100644 --- a/ddtrace/tracer/internal/span_meta.go +++ b/ddtrace/tracer/internal/span_meta.go @@ -46,14 +46,14 @@ var ( // read-only, so serialization methods (EncodeMsg, Msgsize, Range, etc.) can // read it lock-free via the acquire fence in inlined.Load(). type SpanMeta struct { - m map[string]string - attrs *SpanAttributes - inlined bool + m map[string]string + promotedAttrs *SpanAttributes + inlined bool } -// NewSpanMeta returns a SpanMeta initialized with shared attrs (used during span creation). -func NewSpanMeta(attrs *SpanAttributes) SpanMeta { - return SpanMeta{attrs: attrs} +// NewSpanMeta returns a SpanMeta initialized with shared promoted attrs (used during span creation). +func NewSpanMeta(promotedAttrs *SpanAttributes) SpanMeta { + return SpanMeta{promotedAttrs: promotedAttrs} } // NewSpanMetaFromMap returns a SpanMeta pre-loaded with a flat map. Intended for test helpers. @@ -67,15 +67,15 @@ func (sm *SpanMeta) IsZero() bool { if sm.inlined { return false // Finish() wrote entries to sm.m; always non-empty } - return len(sm.m) == 0 && sm.attrs.Count() == 0 + return len(sm.m) == 0 && sm.promotedAttrs.Count() == 0 } // ReplaceSharedAttrs replaces the current attrs pointer with next if it // currently equals prev. Used by the tracer to upgrade a newly-created span // from the base shared attrs to the main-service shared attrs. func (sm *SpanMeta) ReplaceSharedAttrs(prev, next *SpanAttributes) { - if sm.attrs == prev { - sm.attrs = next + if sm.promotedAttrs == prev { + sm.promotedAttrs = next } } @@ -85,8 +85,8 @@ func (sm *SpanMeta) Normalize() { if len(sm.m) == 0 { sm.m = nil } - if sm.attrs != nil && sm.attrs.Count() == 0 { - sm.attrs = nil + if sm.promotedAttrs != nil && sm.promotedAttrs.Count() == 0 { + sm.promotedAttrs = nil } } @@ -117,7 +117,7 @@ func (sm *SpanMeta) getPromoted(key string) (string, bool, bool) { if !ok { return "", false, false } - v, found := sm.attrs.Get(ak) + v, found := sm.promotedAttrs.Get(ak) return v, found, true } @@ -129,23 +129,17 @@ func (sm *SpanMeta) Has(key string) bool { // Attr returns a promoted attribute value by AttrKey. O(1) array index + bitmask. func (sm *SpanMeta) Attr(key AttrKey) (string, bool) { - return sm.attrs.Get(key) + return sm.promotedAttrs.Get(key) } // Env returns the value of the "env" promoted attribute. -func (sm *SpanMeta) Env() (string, bool) { return sm.attrs.Get(AttrEnv) } +func (sm *SpanMeta) Env() (string, bool) { return sm.promotedAttrs.Get(AttrEnv) } // Version returns the value of the "version" promoted attribute. -func (sm *SpanMeta) Version() (string, bool) { return sm.attrs.Get(AttrVersion) } - -// Component returns the value of the "component" promoted attribute. -func (sm *SpanMeta) Component() (string, bool) { return sm.attrs.Get(AttrComponent) } - -// SpanKind returns the value of the "span.kind" promoted attribute. -func (sm *SpanMeta) SpanKind() (string, bool) { return sm.attrs.Get(AttrSpanKind) } +func (sm *SpanMeta) Version() (string, bool) { return sm.promotedAttrs.Get(AttrVersion) } // Language returns the value of the "language" promoted attribute. -func (sm *SpanMeta) Language() (string, bool) { return sm.attrs.Get(AttrLanguage) } +func (sm *SpanMeta) Language() (string, bool) { return sm.promotedAttrs.Get(AttrLanguage) } // Range calls fn for each flat-map entry. When Map() has been called, // promoted keys in sm.m are skipped so v1 callers don't double-encode them. @@ -194,11 +188,11 @@ func (sm *SpanMeta) setPromoted(key, value string) bool { if !ok { return false } - if sm.attrs != nil && sm.attrs.Has(ak) && sm.attrs.Val(ak) == value { + if sm.promotedAttrs != nil && sm.promotedAttrs.Has(ak) && sm.promotedAttrs.Val(ak) == value { return true // no-op: key is present and value already matches } sm.ensureAttrsLocal() - sm.attrs.Set(ak, value) + sm.promotedAttrs.Set(ak, value) return true } @@ -211,12 +205,12 @@ func (sm *SpanMeta) initMap(key, value string) { // ensureAttrsLocal guarantees attrs is a mutable, span-local instance. // If attrs is nil a fresh one is allocated; if shared, it is cloned. func (sm *SpanMeta) ensureAttrsLocal() { - if sm.attrs == nil { - sm.attrs = new(SpanAttributes) + if sm.promotedAttrs == nil { + sm.promotedAttrs = new(SpanAttributes) return } - if sm.attrs.IsShared() { - sm.attrs = sm.attrs.Clone() + if sm.promotedAttrs.IsReadOnly() { + sm.promotedAttrs = sm.promotedAttrs.Clone() } } @@ -229,7 +223,7 @@ func (sm *SpanMeta) ensureAttrsLocal() { // callers from inlining Delete. The direct switch keeps Delete at cost 73. func (sm *SpanMeta) Delete(key string) { switch len(key) { - case 3, 7, 8, 9: + case 3, 7, 8: sm.deleteSlow(key) default: delete(sm.m, key) @@ -243,20 +237,20 @@ func (sm *SpanMeta) deleteSlow(key string) { if !ok { return } - if _, isSet := sm.attrs.Get(ak); !isSet { + if _, isSet := sm.promotedAttrs.Get(ak); !isSet { return } sm.ensureAttrsLocal() - sm.attrs.Unset(ak) + sm.promotedAttrs.Unset(ak) } // IsPromotedKeyLen reports whether n matches the length of any promoted attribute name. -// Promoted keys: "env"(3), "version"(7), "language"(8), "component"(9), "span.kind"(9). +// Promoted keys: "env"(3), "version"(7), "language"(8). // This must stay in sync with the Defs table in span_attributes.go; the init // check below enforces this at program start. func IsPromotedKeyLen(n int) bool { switch n { - case 3, 7, 8, 9: + case 3, 7, 8: return true } return false @@ -276,12 +270,12 @@ func init() { // Count returns the total number of distinct entries (flat map + promoted attrs). func (sm *SpanMeta) Count() int { - return len(sm.m) + sm.attrs.Count() + return len(sm.m) + sm.promotedAttrs.Count() } // AttrCount returns the number of promoted attrs currently set. func (sm *SpanMeta) AttrCount() int { - return sm.attrs.Count() + return sm.promotedAttrs.Count() } // SerializableCount returns the number of flat-map entries that appear in the @@ -290,7 +284,7 @@ func (sm *SpanMeta) AttrCount() int { // promoted keys are also in sm.m, so they are subtracted from the count. func (sm *SpanMeta) SerializableCount() int { if sm.inlined { - return len(sm.m) - sm.attrs.Count() + return len(sm.m) - sm.promotedAttrs.Count() } return len(sm.m) } @@ -303,14 +297,14 @@ func (sm *SpanMeta) Finish() { if sm.inlined { return } - if sm.attrs != nil { - if n := sm.attrs.Count(); n > 0 { + if sm.promotedAttrs != nil { + if n := sm.promotedAttrs.Count(); n > 0 { if sm.m == nil { sm.m = make(map[string]string, n) } for _, d := range Defs { - if sm.attrs.Has(d.Key) { - sm.m[d.Name] = sm.attrs.vals[d.Key] + if sm.promotedAttrs.Has(d.Key) { + sm.m[d.Name] = sm.promotedAttrs.vals[d.Key] } } } @@ -336,12 +330,12 @@ func (sm *SpanMeta) All() iter.Seq2[string, string] { return } } - if sm.inlined || sm.attrs == nil { + if sm.inlined || sm.promotedAttrs == nil { return } for _, d := range Defs { - if sm.attrs.Has(d.Key) { - if !yield(d.Name, sm.attrs.vals[d.Key]) { + if sm.promotedAttrs.Has(d.Key) { + if !yield(d.Name, sm.promotedAttrs.vals[d.Key]) { return } } @@ -388,7 +382,7 @@ func (sm *SpanMeta) EncodeMsg(en *msgp.Writer) error { } return nil } - n := sm.attrs.Count() + n := sm.promotedAttrs.Count() if err := en.WriteMapHeader(uint32(len(sm.m) + n)); err != nil { return msgp.WrapError(err, "Meta") } @@ -408,7 +402,7 @@ func (sm *SpanMeta) EncodeMsg(en *msgp.Writer) error { ok bool ) for _, d := range Defs { - if v, ok = sm.attrs.Get(d.Key); !ok { + if v, ok = sm.promotedAttrs.Get(d.Key); !ok { continue } if err := en.WriteString(d.Name); err != nil { @@ -460,9 +454,9 @@ func (sm *SpanMeta) Msgsize() int { if sm.inlined { return size } - if n := sm.attrs.Count(); n > 0 { + if n := sm.promotedAttrs.Count(); n > 0 { for _, d := range Defs { - if v, ok := sm.attrs.Get(d.Key); ok { + if v, ok := sm.promotedAttrs.Get(d.Key); ok { size += msgp.StringPrefixSize + len(d.Name) + msgp.StringPrefixSize + len(v) } } diff --git a/ddtrace/tracer/payload_v1.go b/ddtrace/tracer/payload_v1.go index e6a83edcdda..edc550c0493 100644 --- a/ddtrace/tracer/payload_v1.go +++ b/ddtrace/tracer/payload_v1.go @@ -562,7 +562,7 @@ func (p *payloadV1) encodeSpans(bm bitmap, fieldID int, spans spanList, st *stri // span attributes combine the meta (tags), metrics and meta_struct. // To avoid increased allocations, we serialize attributes immediately without // creating an intermediate map. - // Promoted attrs (env, version, component, span.kind) are encoded separately + // Promoted attrs (env, version, language) are encoded separately // as fields 13-16 and must not appear in the attributes array. size := span.meta.SerializableCount() + len(span.metrics) + len(span.metaStruct) p.buf = msgp.AppendUint32(p.buf, uint32(9)) // attributes fieldID @@ -596,8 +596,8 @@ func (p *payloadV1) encodeSpans(bm bitmap, fieldID int, spans spanList, st *stri var ( env, _ = span.meta.Env() version, _ = span.meta.Version() - component, _ = span.meta.Component() - spanKind, _ = span.meta.SpanKind() + component, _ = span.meta.Get(ext.Component) + spanKind, _ = span.meta.Get(ext.SpanKind) ) p.buf = encodeField(p.buf, fullSetBitmap, 13, env, st) p.buf = encodeField(p.buf, fullSetBitmap, 14, version, st) @@ -608,8 +608,8 @@ func (p *payloadV1) encodeSpans(bm bitmap, fieldID int, spans spanList, st *stri } // encodeMetaEntry is a named callback for SpanMeta.Range. It encodes one -// meta entry into p.buf using p.st. Promoted keys never enter the tag store -// so they cannot appear here; they are encoded separately as fields 13-16. +// meta entry into p.buf using p.st. env/version/language are encoded separately +// as fields 13-14/language; component and span.kind live in the flat map. func (p *payloadV1) encodeMetaEntry(k, v string) bool { p.buf = p.st.serialize(k, p.buf) p.buf = msgp.AppendUint32(p.buf, uint32(StringValueType)) diff --git a/ddtrace/tracer/tracer.go b/ddtrace/tracer/tracer.go index c44a5c11190..5dd44877210 100644 --- a/ddtrace/tracer/tracer.go +++ b/ddtrace/tracer/tracer.go @@ -496,31 +496,34 @@ func newUnstartedTracer(opts ...StartOption) (t *tracer, err error) { dataStreams: dataStreamsProcessor, logFile: logFile, } - // Build the shared SpanAttributes that every span will start from. - // Process-level values (env, version, language) are set once here; - // spans share this pointer and only clone on per-span overrides. - // language="go" is the same for every span — pre-populating it here - // makes the setMetaInit("language", "go") call in spanStart a COW no-op, - // deferring flat-map allocation until a span actually needs it. - t.sharedAttrs.Set(traceinternal.AttrLanguage, "go") - t.sharedAttrsForMainSvc.Set(traceinternal.AttrLanguage, "go") + buildSharedAttrs(c, &t.sharedAttrs, &t.sharedAttrsForMainSvc) + return t, nil +} + +// buildSharedAttrs populates the tracer's two shared SpanAttributes instances +// with process-level values (language, env, version) and marks them read-only. +// Every new span starts with a pointer to one of these; the copy-on-write +// mechanism in SpanAttributes.Clone() creates a span-specific copy only when +// a per-span override is needed. +func buildSharedAttrs(c *config, base, mainSvc *traceinternal.SpanAttributes) { + // language="go" is the same for every span — pre-populating avoids a + // COW clone on the setMetaInit("language", "go") call in spanStart. + base.Set(traceinternal.AttrLanguage, "go") + mainSvc.Set(traceinternal.AttrLanguage, "go") if env := c.internalConfig.Env(); env != "" { - t.sharedAttrs.Set(traceinternal.AttrEnv, env) - t.sharedAttrsForMainSvc.Set(traceinternal.AttrEnv, env) + base.Set(traceinternal.AttrEnv, env) + mainSvc.Set(traceinternal.AttrEnv, env) } if ver := c.internalConfig.Version(); ver != "" { if c.universalVersion { - // universalVersion=true: all spans get version via sharedAttrs. - t.sharedAttrs.Set(traceinternal.AttrVersion, ver) + base.Set(traceinternal.AttrVersion, ver) } - // Always pre-populate sharedAttrsForMainSvc with version so that - // main-service spans in non-universal mode get a COW no-op rather - // than a Clone when StartSpan applies version (see below). - t.sharedAttrsForMainSvc.Set(traceinternal.AttrVersion, ver) + // Always pre-populate mainSvc with version so that main-service spans + // in non-universal mode get a COW no-op instead of a Clone. + mainSvc.Set(traceinternal.AttrVersion, ver) } - t.sharedAttrs.MarkShared() - t.sharedAttrsForMainSvc.MarkShared() - return t, nil + base.MarkReadOnly() + mainSvc.MarkReadOnly() } // defaultAgentInfoPollInterval is the default interval at which the tracer From 9020723a4138793bde362acf9d062583b9a5304a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Fri, 27 Mar 2026 17:33:11 +0000 Subject: [PATCH 64/71] fix(ddtrace/tracer): use atomic inline; remove misleading comment --- ddtrace/tracer/internal/span_meta.go | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/ddtrace/tracer/internal/span_meta.go b/ddtrace/tracer/internal/span_meta.go index 78ddd3ac396..306126ac726 100644 --- a/ddtrace/tracer/internal/span_meta.go +++ b/ddtrace/tracer/internal/span_meta.go @@ -9,6 +9,7 @@ import ( "fmt" "iter" "strings" + "sync/atomic" "github.com/tinylib/msgp/msgp" ) @@ -48,7 +49,7 @@ var ( type SpanMeta struct { m map[string]string promotedAttrs *SpanAttributes - inlined bool + inlined atomic.Bool } // NewSpanMeta returns a SpanMeta initialized with shared promoted attrs (used during span creation). @@ -64,7 +65,7 @@ func NewSpanMetaFromMap(m map[string]string) SpanMeta { // IsZero reports whether the SpanMeta contains no entries (map or promoted). // The msgp generator emits z.meta.IsZero() for the omitempty check. func (sm *SpanMeta) IsZero() bool { - if sm.inlined { + if sm.inlined.Load() { return false // Finish() wrote entries to sm.m; always non-empty } return len(sm.m) == 0 && sm.promotedAttrs.Count() == 0 @@ -145,7 +146,7 @@ func (sm *SpanMeta) Language() (string, bool) { return sm.promotedAttrs.Get(Attr // promoted keys in sm.m are skipped so v1 callers don't double-encode them. // Iteration stops if fn returns false. func (sm *SpanMeta) Range(fn func(k, v string) bool) { - inlined := sm.inlined + inlined := sm.inlined.Load() for k, v := range sm.m { if inlined && isPromotedKey(k) { continue @@ -283,7 +284,7 @@ func (sm *SpanMeta) AttrCount() int { // in the v1 protocol and must not be double-counted. When Map() has been called, // promoted keys are also in sm.m, so they are subtracted from the count. func (sm *SpanMeta) SerializableCount() int { - if sm.inlined { + if sm.inlined.Load() { return len(sm.m) - sm.promotedAttrs.Count() } return len(sm.m) @@ -294,7 +295,7 @@ func (sm *SpanMeta) SerializableCount() int { // the span is handed to the writer goroutine. After this point sm.m is // permanently read-only, and serialization methods need no further locking. func (sm *SpanMeta) Finish() { - if sm.inlined { + if sm.inlined.Load() { return } if sm.promotedAttrs != nil { @@ -309,7 +310,7 @@ func (sm *SpanMeta) Finish() { } } } - sm.inlined = true // release: all prior writes to sm.m are now visible + sm.inlined.Store(true) } // Map returns sm.m with all entries including promoted attrs. Calls Finish() @@ -330,7 +331,7 @@ func (sm *SpanMeta) All() iter.Seq2[string, string] { return } } - if sm.inlined || sm.promotedAttrs == nil { + if sm.inlined.Load() || sm.promotedAttrs == nil { return } for _, d := range Defs { @@ -368,7 +369,7 @@ func (sm *SpanMeta) String() string { // avoid double-encoding. Otherwise the flat map and promoted attrs are // combined as normal. func (sm *SpanMeta) EncodeMsg(en *msgp.Writer) error { - if sm.inlined { + if sm.inlined.Load() { if err := en.WriteMapHeader(uint32(len(sm.m))); err != nil { return msgp.WrapError(err, "Meta") } @@ -451,7 +452,7 @@ func (sm *SpanMeta) Msgsize() int { for k, v := range sm.m { size += msgp.StringPrefixSize + len(k) + msgp.StringPrefixSize + len(v) } - if sm.inlined { + if sm.inlined.Load() { return size } if n := sm.promotedAttrs.Count(); n > 0 { From 423355dd509eed1715b7a84ecaa667303503cd94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Fri, 27 Mar 2026 18:30:35 +0000 Subject: [PATCH 65/71] fix(ddtrace/tracer): avoid race condition moving meta.Finish before context.finish() --- ddtrace/tracer/span.go | 6 ++++++ ddtrace/tracer/spancontext.go | 6 ------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ddtrace/tracer/span.go b/ddtrace/tracer/span.go index 7a86d21b083..63bd1fe6920 100644 --- a/ddtrace/tracer/span.go +++ b/ddtrace/tracer/span.go @@ -1022,6 +1022,12 @@ func (s *Span) finish(finishTime int64) { log.Debug("Finished Span: %v, Operation: %s, Resource: %s, Tags: %v, %v", //nolint:gocritic // Debug logging needs full span representation s, s.name, s.resource, &s.meta, s.metrics) } + // Finalise the span's metadata: inlines promoted attrs into sm.m and sets + // inlined=true (atomic release). By the time the writer goroutine reads sm.m + // via EncodeMsg/Msgsize/Range, the acquire fence in inlined.Load() ensures + // all writes are visible — no further locking is needed on the read side. + s.meta.Finish() + // Call context.finish() which handles trace-level bookkeeping and may modify // this span (to set trace-level tags). // Lock ordering is span.mu -> trace.mu. diff --git a/ddtrace/tracer/spancontext.go b/ddtrace/tracer/spancontext.go index bc4f43f277d..4ab8039478b 100644 --- a/ddtrace/tracer/spancontext.go +++ b/ddtrace/tracer/spancontext.go @@ -771,12 +771,6 @@ func (t *trace) finishedOneLocked(s *Span) { mtr.FinishSpan(s) } - // Finalise the span's metadata: inlines promoted attrs into sm.m and sets - // inlined=true (atomic release). By the time the writer goroutine reads sm.m - // via EncodeMsg/Msgsize/Range, the acquire fence in inlined.Load() ensures - // all writes are visible — no further locking is needed on the read side. - s.meta.Finish() - // Full flush: all spans finished if len(t.spans) == t.finished { spans := t.spans From 525b29a072d9f4eb0712f0975bec6d5c798f8311 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Wed, 15 Apr 2026 11:48:04 +0200 Subject: [PATCH 66/71] chore(ddtrace/tracer): structural and safety improvements --- ddtrace/tracer/internal/span_attributes.go | 36 ++++++++++++++++- .../tracer/internal/span_attributes_test.go | 39 ++++++++++++++++++- ddtrace/tracer/internal/span_meta.go | 20 ---------- 3 files changed, 71 insertions(+), 24 deletions(-) diff --git a/ddtrace/tracer/internal/span_attributes.go b/ddtrace/tracer/internal/span_attributes.go index 381e331f6ee..3a221230346 100644 --- a/ddtrace/tracer/internal/span_attributes.go +++ b/ddtrace/tracer/internal/span_attributes.go @@ -8,6 +8,7 @@ package internal import ( "iter" "math/bits" + "unsafe" ) // AttrKey is an integer index into a SpanAttributes value array. @@ -32,6 +33,7 @@ var ( _ = [1]byte{}[AttrEnv] // AttrEnv must be 0 _ = [1]byte{}[AttrVersion-1] // AttrVersion must be 1 _ = [1]byte{}[AttrLanguage-2] // AttrLanguage must be 2 + _ = [1]byte{}[numAttrs-3] // numAttrs must be 3 (update when adding attributes) ) // SpanAttributes holds the V1-protocol promoted span fields. @@ -48,10 +50,18 @@ type SpanAttributes struct { vals [numAttrs]string } +// Compile-time layout check: SpanAttributes must be exactly 56 bytes. +// 1B setMask + 1B readOnly + 6B padding + [3]string (48B) = 56B. +var _ = [1]byte{}[56-unsafe.Sizeof(SpanAttributes{})] +var _ = [1]byte{}[unsafe.Sizeof(SpanAttributes{})-56] + // All read methods are nil-safe so callers holding a *SpanAttributes don't // need nil guards. func (a *SpanAttributes) Set(key AttrKey, v string) { + if a == nil { + return + } a.vals[key] = v a.setMask |= 1 << key } @@ -89,6 +99,8 @@ func (a *SpanAttributes) Count() int { } // MarkReadOnly marks this instance as readOnly (read-only). Clone before mutating. +// The receiver must be non-nil; marking a nil SpanAttributes read-only is a +// programming error and panics. func (a *SpanAttributes) MarkReadOnly() { a.readOnly = true } // IsReadOnly reports whether this is a readOnly instance requiring COW. @@ -127,8 +139,28 @@ var Defs = [numAttrs]AttrDef{ {AttrLanguage, "language"}, } -// All returns an iterator over the set attributes (name, value) pairs. -func (a *SpanAttributes) All() iter.Seq2[string, string] { +// IsPromotedKeyLen reports whether n matches the length of any promoted attribute name. +// Promoted keys: "env"(3), "version"(7), "language"(8). +// This must stay in sync with the Defs table; the init check below enforces +// this at program start. +func IsPromotedKeyLen(n int) bool { + switch n { + case 3, 7, 8: + return true + } + return false +} + +func init() { + for _, d := range Defs { + if !IsPromotedKeyLen(len(d.Name)) { + panic("IsPromotedKeyLen out of sync with Defs: missing length " + d.Name) + } + } +} + +// all returns an iterator over the set attributes (name, value) pairs. +func (a *SpanAttributes) all() iter.Seq2[string, string] { return func(yield func(string, string) bool) { if a == nil { return diff --git a/ddtrace/tracer/internal/span_attributes_test.go b/ddtrace/tracer/internal/span_attributes_test.go index 6c7550474cf..ac42e7539b9 100644 --- a/ddtrace/tracer/internal/span_attributes_test.go +++ b/ddtrace/tracer/internal/span_attributes_test.go @@ -84,6 +84,12 @@ func TestSpanAttributesIndependentKeys(t *testing.T) { } } +func TestSpanAttributesSetNilSafe(t *testing.T) { + var a *SpanAttributes + // Must not panic. + a.Set(AttrEnv, "prod") +} + func TestSpanAttributesValUnset(t *testing.T) { var a SpanAttributes // Val on an unset key returns "" without panicking. @@ -97,7 +103,7 @@ func TestSpanAttributesForEach(t *testing.T) { a.Set(AttrEnv, "prod") a.Set(AttrVersion, "1.2.3") - got := maps.Collect(a.All()) + got := maps.Collect(a.all()) if len(got) != 2 { t.Fatalf("expected 2 entries, got %d: %v", len(got), got) } @@ -112,7 +118,7 @@ func TestSpanAttributesForEach(t *testing.T) { func TestSpanAttributesForEachNil(t *testing.T) { var a *SpanAttributes called := false - for range a.All() { + for range a.all() { called = true } if called { @@ -268,3 +274,32 @@ func BenchmarkMap(b *testing.B) { _ = sm.Map() } } + +func BenchmarkAttrKeyForTag(b *testing.B) { + tags := []string{"env", "version", "language", "component", "span.kind", "unknown"} + b.ReportAllocs() + b.ResetTimer() + var k AttrKey + var ok bool + for i := 0; i < b.N; i++ { + for _, tag := range tags { + k, ok = AttrKeyForTag(tag) + } + } + _, _ = k, ok +} + +func BenchmarkSpanAttributesAll(b *testing.B) { + var a SpanAttributes + a.Set(AttrEnv, "prod") + a.Set(AttrVersion, "1.2.3") + a.Set(AttrLanguage, "go") + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + for k, v := range a.all() { + _ = k + _ = v + } + } +} diff --git a/ddtrace/tracer/internal/span_meta.go b/ddtrace/tracer/internal/span_meta.go index 306126ac726..ae637684532 100644 --- a/ddtrace/tracer/internal/span_meta.go +++ b/ddtrace/tracer/internal/span_meta.go @@ -245,26 +245,6 @@ func (sm *SpanMeta) deleteSlow(key string) { sm.promotedAttrs.Unset(ak) } -// IsPromotedKeyLen reports whether n matches the length of any promoted attribute name. -// Promoted keys: "env"(3), "version"(7), "language"(8). -// This must stay in sync with the Defs table in span_attributes.go; the init -// check below enforces this at program start. -func IsPromotedKeyLen(n int) bool { - switch n { - case 3, 7, 8: - return true - } - return false -} - -func init() { - for _, d := range Defs { - if !IsPromotedKeyLen(len(d.Name)) { - panic("IsPromotedKeyLen out of sync with Defs: missing length " + d.Name) - } - } -} - // --------------------------------------------------------------------------- // Counting / iteration // --------------------------------------------------------------------------- From 8fcdf4601efc60edd8406ca2cc85e35b8470f7b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Wed, 15 Apr 2026 12:35:55 +0200 Subject: [PATCH 67/71] fix(ddtrace/tracer): remove map merging code based on wrong assumption on meta consumers --- .../tracer/internal/span_attributes_test.go | 1 - ddtrace/tracer/internal/span_meta.go | 120 +++++------------- ddtrace/tracer/payload_v1.go | 4 +- ddtrace/tracer/span.go | 6 - 4 files changed, 32 insertions(+), 99 deletions(-) diff --git a/ddtrace/tracer/internal/span_attributes_test.go b/ddtrace/tracer/internal/span_attributes_test.go index ac42e7539b9..67bfccd2ea7 100644 --- a/ddtrace/tracer/internal/span_attributes_test.go +++ b/ddtrace/tracer/internal/span_attributes_test.go @@ -208,7 +208,6 @@ func BenchmarkSpanAttributesGet(b *testing.B) { for i := 0; i < b.N; i++ { s, ok = m["env"] s, ok = m["version"] - s, ok = m["env"] s, ok = m["language"] } _, _ = s, ok diff --git a/ddtrace/tracer/internal/span_meta.go b/ddtrace/tracer/internal/span_meta.go index ae637684532..1cd7a3124e1 100644 --- a/ddtrace/tracer/internal/span_meta.go +++ b/ddtrace/tracer/internal/span_meta.go @@ -8,8 +8,8 @@ package internal import ( "fmt" "iter" + "maps" "strings" - "sync/atomic" "github.com/tinylib/msgp/msgp" ) @@ -34,22 +34,15 @@ var ( ) // SpanMeta replaces a plain map[string]string for the Span.meta field. -// Promoted attributes (env, version, component, span.kind, language) live in -// attrs and are excluded from the map m. The msgp codec merges both sources -// transparently so the wire format is unchanged. +// Promoted attributes (env, version, language) live in promotedAttrs and are +// excluded from the flat map m. The msgp codec and iterators merge both +// sources transparently so the wire format is unchanged. // -// Set routes promoted keys to attrs (with copy-on-write) and others to the -// flat map. Promoted keys never appear in sm.m until Finish() is called. -// -// Finish() must be called once — after all tag writes and before the span is -// handed to the writer goroutine. It inlines promoted attrs into sm.m and -// publishes an atomic release fence (inlined=true). After that point sm.m is -// read-only, so serialization methods (EncodeMsg, Msgsize, Range, etc.) can -// read it lock-free via the acquire fence in inlined.Load(). +// Set routes promoted keys to promotedAttrs (with copy-on-write) and others +// to the flat map. Promoted keys never appear in sm.m. type SpanMeta struct { m map[string]string promotedAttrs *SpanAttributes - inlined atomic.Bool } // NewSpanMeta returns a SpanMeta initialized with shared promoted attrs (used during span creation). @@ -65,9 +58,6 @@ func NewSpanMetaFromMap(m map[string]string) SpanMeta { // IsZero reports whether the SpanMeta contains no entries (map or promoted). // The msgp generator emits z.meta.IsZero() for the omitempty check. func (sm *SpanMeta) IsZero() bool { - if sm.inlined.Load() { - return false // Finish() wrote entries to sm.m; always non-empty - } return len(sm.m) == 0 && sm.promotedAttrs.Count() == 0 } @@ -142,28 +132,16 @@ func (sm *SpanMeta) Version() (string, bool) { return sm.promotedAttrs.Get(AttrV // Language returns the value of the "language" promoted attribute. func (sm *SpanMeta) Language() (string, bool) { return sm.promotedAttrs.Get(AttrLanguage) } -// Range calls fn for each flat-map entry. When Map() has been called, -// promoted keys in sm.m are skipped so v1 callers don't double-encode them. -// Iteration stops if fn returns false. +// Range calls fn for each flat-map entry. Promoted attrs are not in sm.m +// and are not yielded. Iteration stops if fn returns false. func (sm *SpanMeta) Range(fn func(k, v string) bool) { - inlined := sm.inlined.Load() for k, v := range sm.m { - if inlined && isPromotedKey(k) { - continue - } if !fn(k, v) { return } } } -// isPromotedKey reports whether k is an exact promoted attribute name. -// Only called on the hot path when inlined=true, so the extra check is rare. -func isPromotedKey(k string) bool { - _, ok := AttrKeyForTag(k) - return ok -} - // --------------------------------------------------------------------------- // Write methods // --------------------------------------------------------------------------- @@ -259,50 +237,32 @@ func (sm *SpanMeta) AttrCount() int { return sm.promotedAttrs.Count() } -// SerializableCount returns the number of flat-map entries that appear in the -// serialized attributes array. Promoted attrs are encoded as dedicated fields -// in the v1 protocol and must not be double-counted. When Map() has been called, -// promoted keys are also in sm.m, so they are subtracted from the count. +// SerializableCount returns the number of flat-map entries. Promoted attrs +// are encoded as dedicated fields in the v1 protocol and are not counted here. func (sm *SpanMeta) SerializableCount() int { - if sm.inlined.Load() { - return len(sm.m) - sm.promotedAttrs.Count() - } return len(sm.m) } -// Finish inlines all promoted attrs into sm.m and publishes an atomic release -// fence (inlined=true). Must be called once — after all tag writes and before -// the span is handed to the writer goroutine. After this point sm.m is -// permanently read-only, and serialization methods need no further locking. -func (sm *SpanMeta) Finish() { - if sm.inlined.Load() { - return +// Map returns a map containing all entries (flat map + promoted attrs). +// When promoted attrs exist, a new merged map is allocated. Called only on +// cold paths (stats, CI visibility, tests). +func (sm *SpanMeta) Map() map[string]string { + n := sm.promotedAttrs.Count() + if n == 0 { + return sm.m } - if sm.promotedAttrs != nil { - if n := sm.promotedAttrs.Count(); n > 0 { - if sm.m == nil { - sm.m = make(map[string]string, n) - } - for _, d := range Defs { - if sm.promotedAttrs.Has(d.Key) { - sm.m[d.Name] = sm.promotedAttrs.vals[d.Key] - } - } + merged := make(map[string]string, len(sm.m)+n) + maps.Copy(merged, sm.m) + for _, d := range Defs { + if sm.promotedAttrs.Has(d.Key) { + merged[d.Name] = sm.promotedAttrs.Val(d.Key) } } - sm.inlined.Store(true) -} - -// Map returns sm.m with all entries including promoted attrs. Calls Finish() -// if not already done so the result is always complete. -func (sm *SpanMeta) Map() map[string]string { - sm.Finish() - return sm.m + return merged } // All returns an iterator over all entries. Flat-map entries are yielded first -// (in unspecified order), followed by promoted attributes. When Map() has been -// called, all entries are already in sm.m and the attrs loop is skipped. +// (in unspecified order), followed by promoted attributes. // Returning false from yield stops iteration. func (sm *SpanMeta) All() iter.Seq2[string, string] { return func(yield func(string, string) bool) { @@ -311,12 +271,12 @@ func (sm *SpanMeta) All() iter.Seq2[string, string] { return } } - if sm.inlined.Load() || sm.promotedAttrs == nil { + if sm.promotedAttrs == nil { return } for _, d := range Defs { if sm.promotedAttrs.Has(d.Key) { - if !yield(d.Name, sm.promotedAttrs.vals[d.Key]) { + if !yield(d.Name, sm.promotedAttrs.Val(d.Key)) { return } } @@ -344,25 +304,9 @@ func (sm *SpanMeta) String() string { // msgp codec // --------------------------------------------------------------------------- -// EncodeMsg writes the map header and entries. When Map() has already been -// called (inlined=true), sm.m contains all entries and attrs is skipped to -// avoid double-encoding. Otherwise the flat map and promoted attrs are -// combined as normal. +// EncodeMsg writes the map header and entries, combining the flat map and +// promoted attrs. func (sm *SpanMeta) EncodeMsg(en *msgp.Writer) error { - if sm.inlined.Load() { - if err := en.WriteMapHeader(uint32(len(sm.m))); err != nil { - return msgp.WrapError(err, "Meta") - } - for k, v := range sm.m { - if err := en.WriteString(k); err != nil { - return msgp.WrapError(err, "Meta") - } - if err := en.WriteString(v); err != nil { - return msgp.WrapError(err, "Meta", k) - } - } - return nil - } n := sm.promotedAttrs.Count() if err := en.WriteMapHeader(uint32(len(sm.m) + n)); err != nil { return msgp.WrapError(err, "Meta") @@ -424,17 +368,13 @@ func (sm *SpanMeta) DecodeMsg(dc *msgp.Reader) error { return nil } -// Msgsize returns an upper bound estimate of the serialized size. When Map() -// has already been called (inlined=true), sm.m contains all entries so attrs -// is not separately sized to avoid double-counting. +// Msgsize returns an upper bound estimate of the serialized size, combining +// the flat map and promoted attrs. func (sm *SpanMeta) Msgsize() int { size := msgp.MapHeaderSize for k, v := range sm.m { size += msgp.StringPrefixSize + len(k) + msgp.StringPrefixSize + len(v) } - if sm.inlined.Load() { - return size - } if n := sm.promotedAttrs.Count(); n > 0 { for _, d := range Defs { if v, ok := sm.promotedAttrs.Get(d.Key); ok { diff --git a/ddtrace/tracer/payload_v1.go b/ddtrace/tracer/payload_v1.go index edc550c0493..2ea4dfe031e 100644 --- a/ddtrace/tracer/payload_v1.go +++ b/ddtrace/tracer/payload_v1.go @@ -567,8 +567,8 @@ func (p *payloadV1) encodeSpans(bm bitmap, fieldID int, spans spanList, st *stri size := span.meta.SerializableCount() + len(span.metrics) + len(span.metaStruct) p.buf = msgp.AppendUint32(p.buf, uint32(9)) // attributes fieldID p.buf = msgp.AppendArrayHeader(p.buf, uint32(size)*3) // number of attributes - // Promoted keys live only in attrs, never in the tag store, so - // encodeMetaEntry skipping them is a safety guard, not a dedup. + // Promoted keys live only in promotedAttrs, never in the flat map, + // so Range iterates only non-promoted entries. span.meta.Range(p.encodeMetaEntry) for k, v := range span.metrics { p.buf = st.serialize(k, p.buf) diff --git a/ddtrace/tracer/span.go b/ddtrace/tracer/span.go index 63bd1fe6920..7a86d21b083 100644 --- a/ddtrace/tracer/span.go +++ b/ddtrace/tracer/span.go @@ -1022,12 +1022,6 @@ func (s *Span) finish(finishTime int64) { log.Debug("Finished Span: %v, Operation: %s, Resource: %s, Tags: %v, %v", //nolint:gocritic // Debug logging needs full span representation s, s.name, s.resource, &s.meta, s.metrics) } - // Finalise the span's metadata: inlines promoted attrs into sm.m and sets - // inlined=true (atomic release). By the time the writer goroutine reads sm.m - // via EncodeMsg/Msgsize/Range, the acquire fence in inlined.Load() ensures - // all writes are visible — no further locking is needed on the read side. - s.meta.Finish() - // Call context.finish() which handles trace-level bookkeeping and may modify // this span (to set trace-level tags). // Lock ordering is span.mu -> trace.mu. From 57a2f2148410c7fe7e5e7f015b4181ce7da6f7c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Wed, 15 Apr 2026 13:45:39 +0200 Subject: [PATCH 68/71] feat(ddtrace/tracer): simplify after merging main --- ddtrace/tracer/internal/span_meta.go | 6 ------ ddtrace/tracer/payload_test.go | 2 +- ddtrace/tracer/tracer_test.go | 3 ++- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/ddtrace/tracer/internal/span_meta.go b/ddtrace/tracer/internal/span_meta.go index 1cd7a3124e1..df575e85a79 100644 --- a/ddtrace/tracer/internal/span_meta.go +++ b/ddtrace/tracer/internal/span_meta.go @@ -237,12 +237,6 @@ func (sm *SpanMeta) AttrCount() int { return sm.promotedAttrs.Count() } -// SerializableCount returns the number of flat-map entries. Promoted attrs -// are encoded as dedicated fields in the v1 protocol and are not counted here. -func (sm *SpanMeta) SerializableCount() int { - return len(sm.m) -} - // Map returns a map containing all entries (flat map + promoted attrs). // When promoted attrs exist, a new merged map is allocated. Called only on // cold paths (stats, CI visibility, tests). diff --git a/ddtrace/tracer/payload_test.go b/ddtrace/tracer/payload_test.go index b0bd3c1354b..bae16525ad9 100644 --- a/ddtrace/tracer/payload_test.go +++ b/ddtrace/tracer/payload_test.go @@ -352,7 +352,7 @@ func TestPayloadV1SpanLinkTraceID(t *testing.T) { assert.Equal(uint64(789), link.SpanID) span = got.chunks[0].spans[0] - assert.Empty(span.meta["_dd.span_links"]) + assert.False(span.meta.Has("_dd.span_links")) } // TestPayloadV1SpanEventArray tests that a span with a span event containing ArrayValue diff --git a/ddtrace/tracer/tracer_test.go b/ddtrace/tracer/tracer_test.go index b625af3305c..b0f842797f3 100644 --- a/ddtrace/tracer/tracer_test.go +++ b/ddtrace/tracer/tracer_test.go @@ -3245,7 +3245,8 @@ func TestStartSpanFromPropagatedContext(t *testing.T) { span, _ := StartSpanFromPropagatedContext(context.Background(), "child-with-tags", carrier, Tag("custom.tag", "hello")) assert.Equal(t, root.spanID, span.parentID) - assert.Equal(t, "hello", span.meta["custom.tag"]) + v, _ := span.meta.Get("custom.tag") + assert.Equal(t, "hello", v) }) t.Run("http headers carrier", func(t *testing.T) { httpCarrier := HTTPHeadersCarrier{} From 7b626d1272f6e90555708832b4f596bd871df836 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Wed, 15 Apr 2026 13:48:35 +0200 Subject: [PATCH 69/71] chore(ddtrace/tracer): go fmt --- ddtrace/tracer/payload_v1.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/ddtrace/tracer/payload_v1.go b/ddtrace/tracer/payload_v1.go index 92827191469..8b8878df5f9 100644 --- a/ddtrace/tracer/payload_v1.go +++ b/ddtrace/tracer/payload_v1.go @@ -645,8 +645,6 @@ func (p *payloadV1) encodeSpans(bm bitmap, fieldID int, spans spanList, st *stri return true, nil } - - // translate a span kind string to its uint32 value func getSpanKindValue(sk string) uint32 { switch sk { From 4ae95553b3959f8db81034470019440a8ff0b216 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Thu, 16 Apr 2026 11:54:17 +0200 Subject: [PATCH 70/71] fix(ddtrace/tracer): allow to adjust which `SpanMeta.Map` a consumer needs --- ddtrace/tracer/civisibility_tslv.go | 4 ++-- .../tracer/internal/span_attributes_test.go | 2 +- ddtrace/tracer/internal/span_meta.go | 19 +++++++++++++++---- ddtrace/tracer/stats.go | 2 +- ddtrace/tracer/tracer_test.go | 2 +- 5 files changed, 20 insertions(+), 9 deletions(-) diff --git a/ddtrace/tracer/civisibility_tslv.go b/ddtrace/tracer/civisibility_tslv.go index 64073e2d067..959d295df06 100644 --- a/ddtrace/tracer/civisibility_tslv.go +++ b/ddtrace/tracer/civisibility_tslv.go @@ -212,7 +212,7 @@ func (e *ciVisibilityEvent) Finish(opts ...FinishOption) { // Rebuild Content.Meta once with the final span state, under the span // lock to avoid racing with the serialization worker. e.span.mu.Lock() - e.Content.Meta = e.span.meta.Map() + e.Content.Meta = e.span.meta.Map(true) e.Content.Metrics = e.span.metrics e.span.mu.Unlock() } @@ -416,7 +416,7 @@ func createTslvSpan(span *Span) tslvSpan { Duration: span.duration, ParentID: span.parentID, Error: span.error, - Meta: span.meta.Map(), + Meta: span.meta.Map(true), Metrics: span.metrics, } } diff --git a/ddtrace/tracer/internal/span_attributes_test.go b/ddtrace/tracer/internal/span_attributes_test.go index 67bfccd2ea7..c098ff4745e 100644 --- a/ddtrace/tracer/internal/span_attributes_test.go +++ b/ddtrace/tracer/internal/span_attributes_test.go @@ -270,7 +270,7 @@ func BenchmarkMap(b *testing.B) { b.ReportAllocs() b.ResetTimer() for range b.N { - _ = sm.Map() + _ = sm.Map(true) } } diff --git a/ddtrace/tracer/internal/span_meta.go b/ddtrace/tracer/internal/span_meta.go index df575e85a79..96090dc11e8 100644 --- a/ddtrace/tracer/internal/span_meta.go +++ b/ddtrace/tracer/internal/span_meta.go @@ -237,10 +237,21 @@ func (sm *SpanMeta) AttrCount() int { return sm.promotedAttrs.Count() } -// Map returns a map containing all entries (flat map + promoted attrs). -// When promoted attrs exist, a new merged map is allocated. Called only on -// cold paths (stats, CI visibility, tests). -func (sm *SpanMeta) Map() map[string]string { +// Map returns a map containing meta entries. +// +// When full is true, promoted attrs (env, version, language) are merged into a +// new map (one allocation). Use this when the caller needs the complete view, +// e.g. CI visibility or test helpers. +// +// When full is false, the underlying flat map is returned directly +// (zero allocation). Promoted keys are excluded. Use this when the caller is +// known to not need promoted keys, e.g. the stats path reads span.kind, +// _dd.svc_src, HTTP/gRPC status codes, and peer tags — none of which are +// promoted attributes. +func (sm *SpanMeta) Map(full bool) map[string]string { + if !full { + return sm.m + } n := sm.promotedAttrs.Count() if n == 0 { return sm.m diff --git a/ddtrace/tracer/stats.go b/ddtrace/tracer/stats.go index 2f6d3f3dc65..8df49bde87c 100644 --- a/ddtrace/tracer/stats.go +++ b/ddtrace/tracer/stats.go @@ -177,7 +177,7 @@ func (c *concentrator) newTracerStatSpan(s *Span, obfuscator *obfuscate.Obfuscat Start: s.start, Duration: s.duration, Error: s.error, - Meta: s.meta.Map(), + Meta: s.meta.Map(false), // stats reads span.kind, _dd.svc_src, status codes, peer tags — no promoted keys needed Metrics: s.metrics, PeerTags: c.cfg.agent.load().peerTags, HTTPMethod: httpMethod, diff --git a/ddtrace/tracer/tracer_test.go b/ddtrace/tracer/tracer_test.go index b0f842797f3..d4428691383 100644 --- a/ddtrace/tracer/tracer_test.go +++ b/ddtrace/tracer/tracer_test.go @@ -2474,7 +2474,7 @@ func cpspan(s *Span) *Span { spanType: s.spanType, start: s.start, duration: s.duration, - meta: traceinternal.NewSpanMetaFromMap(s.meta.Map()), // flatten to plain map for comparison + meta: traceinternal.NewSpanMetaFromMap(s.meta.Map(true)), // flatten to plain map for comparison metrics: s.metrics, spanID: s.spanID, traceID: s.traceID, From 21cbe1d39d3606cb7ce806e03969a358c7938140 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dario=20Casta=C3=B1=C3=A9?= Date: Thu, 16 Apr 2026 13:24:59 +0200 Subject: [PATCH 71/71] fix(ddtrace/tracer): update OTLP exporter code to new promoted fields design --- ddtrace/tracer/otlp_writer_bench_test.go | 4 ++-- ddtrace/tracer/otlp_writer_test.go | 4 ++-- ddtrace/tracer/span_to_otlp.go | 9 +++++---- ddtrace/tracer/span_to_otlp_test.go | 19 +++++++++---------- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/ddtrace/tracer/otlp_writer_bench_test.go b/ddtrace/tracer/otlp_writer_bench_test.go index 8d8a4004c88..07dd08b63e3 100644 --- a/ddtrace/tracer/otlp_writer_bench_test.go +++ b/ddtrace/tracer/otlp_writer_bench_test.go @@ -98,7 +98,7 @@ func BenchmarkOTLPProtoMarshal(b *testing.B) { spans := make([]*otlptrace.Span, sc.n) for i := range sc.n { s := newBasicSpan("bench-span") - s.meta["key"] = "value" + s.meta.Set("key", "value") spans[i] = convertSpan(s, "bench-svc") } tracesData := buildTracesData(spans) @@ -117,7 +117,7 @@ func BenchmarkOTLPProtoMarshal(b *testing.B) { for i := range 100 { s := newSpan("op", "svc", "res", uint64(i+1), 1, 0) for j := range 20 { - s.meta[fmt.Sprintf("key-%d", j)] = fmt.Sprintf("value-%d", j) + s.meta.Set(fmt.Sprintf("key-%d", j), fmt.Sprintf("value-%d", j)) } for j := range 5 { s.metrics[fmt.Sprintf("metric-%d", j)] = float64(j) * 1.5 diff --git a/ddtrace/tracer/otlp_writer_test.go b/ddtrace/tracer/otlp_writer_test.go index 8baf459c19f..648794da584 100644 --- a/ddtrace/tracer/otlp_writer_test.go +++ b/ddtrace/tracer/otlp_writer_test.go @@ -196,7 +196,7 @@ func TestOTLPWriterFlushOnSize(t *testing.T) { w := newTestOTLPWriter(t, srv) bigSpan := newSpan("op", "svc", "res", 1, 1, 0) - bigSpan.meta["big"] = strings.Repeat("X", payloadSizeLimit+1) + bigSpan.meta.Set("big", strings.Repeat("X", payloadSizeLimit+1)) w.add([]*Span{bigSpan}) w.wg.Wait() @@ -216,7 +216,7 @@ func TestOTLPWriterFlushOnSize(t *testing.T) { numSpans := (payloadSizeLimit / spanSize) + 1 for i := range numSpans { s := newSpan("op", "svc", "res", uint64(i+1), 1, 0) - s.meta["data"] = strings.Repeat("X", spanSize) + s.meta.Set("data", strings.Repeat("X", spanSize)) w.add([]*Span{s}) } w.wg.Wait() diff --git a/ddtrace/tracer/span_to_otlp.go b/ddtrace/tracer/span_to_otlp.go index c64417c57f6..4d81b620f40 100644 --- a/ddtrace/tracer/span_to_otlp.go +++ b/ddtrace/tracer/span_to_otlp.go @@ -75,9 +75,10 @@ func convertSpan(s *Span, defaultServiceName string) *otlptrace.Span { // +checklocksignore — Post-finish: reads finished span fields during payload encoding. func convertSpanStatus(s *Span) *otlptrace.Status { + message, _ := s.meta.Get(ext.ErrorMsg) status := &otlptrace.Status{ Code: otlptrace.Status_STATUS_CODE_UNSET, - Message: s.meta[ext.ErrorMsg], + Message: message, } if s.error == 1 { status.Code = otlptrace.Status_STATUS_CODE_ERROR @@ -156,7 +157,7 @@ func convertSpanKind(spanKind string) otlptrace.Span_SpanKind { } // +checklocksignore — Post-finish: reads finished span fields during payload encoding. -func getSpanKind(s *Span) string { return s.meta[ext.SpanKind] } +func getSpanKind(s *Span) string { v, _ := s.meta.Get(ext.SpanKind); return v } // ----------------------------------------------------------------------------- // Attribute conversion (DD → OTLP KeyValue / AnyValue) @@ -173,7 +174,7 @@ func addAttribute(attrs *[]*otlpcommon.KeyValue, key string, val *otlpcommon.Any // +checklocksignore — Post-finish: reads finished span fields during payload encoding. func convertSpanAttributes(s *Span, defaultServiceName string) []*otlpcommon.KeyValue { - n := len(s.meta) + len(s.metrics) + len(s.metaStruct) + 3 + n := s.meta.Count() + len(s.metrics) + len(s.metaStruct) + 3 if s.service != defaultServiceName { n++ } @@ -193,7 +194,7 @@ func convertSpanAttributes(s *Span, defaultServiceName string) []*otlpcommon.Key return attrs } } - for key, value := range s.meta { + for key, value := range s.meta.All() { if !addAttribute(&attrs, key, otlpStringValue(value)) { return attrs } diff --git a/ddtrace/tracer/span_to_otlp_test.go b/ddtrace/tracer/span_to_otlp_test.go index 8446de5176d..d984fc59f44 100644 --- a/ddtrace/tracer/span_to_otlp_test.go +++ b/ddtrace/tracer/span_to_otlp_test.go @@ -16,6 +16,7 @@ import ( otlptrace "go.opentelemetry.io/proto/otlp/trace/v1" "github.com/DataDog/dd-trace-go/v2/ddtrace/ext" + tinternal "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer/internal" internalconfig "github.com/DataDog/dd-trace-go/v2/internal/config" "github.com/DataDog/dd-trace-go/v2/internal/samplernames" "github.com/DataDog/dd-trace-go/v2/internal/version" @@ -64,11 +65,11 @@ func TestConvertSpan(t *testing.T) { s := newSpan("op", "svc", "my-resource", 100, 200, 50) s.start = 1000 s.duration = 100 - s.meta[ext.SpanKind] = ext.SpanKindServer - s.meta["meta.key"] = "meta.val" + s.meta.Set(ext.SpanKind, ext.SpanKindServer) + s.meta.Set("meta.key", "meta.val") s.metrics["metric.key"] = 42.5 s.error = 1 - s.meta[ext.ErrorMsg] = "something failed" + s.meta.Set(ext.ErrorMsg, "something failed") otlp := convertSpan(s, "svc") require.NotNil(t, otlp) @@ -197,7 +198,7 @@ func TestConvertSpanStatus(t *testing.T) { t.Run("error", func(t *testing.T) { s := newBasicSpan("op") s.error = 1 - s.meta = map[string]string{ext.ErrorMsg: "err msg"} + s.meta = tinternal.NewSpanMetaFromMap(map[string]string{ext.ErrorMsg: "err msg"}) st := convertSpanStatus(s) require.NotNil(t, st) assert.Equal(t, otlptrace.Status_STATUS_CODE_ERROR, st.Code) @@ -207,7 +208,7 @@ func TestConvertSpanStatus(t *testing.T) { func TestConvertSpanAttributes(t *testing.T) { s := newBasicSpan("op") - s.meta = map[string]string{"tag": "val", "env": "test"} + s.meta = tinternal.NewSpanMetaFromMap(map[string]string{"tag": "val", "env": "test"}) s.metrics = map[string]float64{"count": 10, "rate": 0.5} attrs := convertSpanAttributes(s, "") @@ -223,7 +224,7 @@ func TestConvertSpanAttributes(t *testing.T) { func TestConvertSpanAttributesWithMetaStruct(t *testing.T) { s := newBasicSpan("op") - s.meta = map[string]string{"tag": "val"} + s.meta = tinternal.NewSpanMetaFromMap(map[string]string{"tag": "val"}) s.metaStruct = map[string]any{ "nested": map[string]any{"a": "b"}, "simple": map[string]string{"x": "y"}, @@ -256,9 +257,8 @@ func TestConvertSpanAttributesWithMetaStruct(t *testing.T) { func TestConvertSpanAttributesMaxLimit(t *testing.T) { s := newBasicSpan("op") - s.meta = make(map[string]string, 200) for i := range 200 { - s.meta[fmt.Sprintf("key-%d", i)] = "val" + s.meta.Set(fmt.Sprintf("key-%d", i), "val") } attrs := convertSpanAttributes(s, "other-service") @@ -267,9 +267,8 @@ func TestConvertSpanAttributesMaxLimit(t *testing.T) { func TestConvertSpanAttributesPriorityOrder(t *testing.T) { s := newBasicSpan("op") - s.meta = make(map[string]string, maxAttributesCount) for i := range maxAttributesCount { - s.meta[fmt.Sprintf("key-%d", i)] = "val" + s.meta.Set(fmt.Sprintf("key-%d", i), "val") } s.metrics = map[string]float64{"should-be-dropped": 1.0}