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 }) } diff --git a/ddtrace/tracer/abandonedspans.go b/ddtrace/tracer/abandonedspans.go index a0aba9dfa2b..d6ff8e53f3b 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.meta[ext.Component]; 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 d7d44a440b7..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["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 bd2e698bd63..959d295df06 100644 --- a/ddtrace/tracer/civisibility_tslv.go +++ b/ddtrace/tracer/civisibility_tslv.go @@ -164,7 +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) - e.Content.Meta = e.span.meta e.Content.Metrics = e.span.metrics } @@ -210,6 +209,12 @@ func (e *ciVisibilityEvent) SetBaggageItem(key, val string) { // opts - Optional finish options. func (e *ciVisibilityEvent) Finish(opts ...FinishOption) { e.span.Finish(opts...) + // 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(true) + e.Content.Metrics = e.span.metrics + e.span.mu.Unlock() } // Context returns the span context of the event's span. @@ -411,7 +416,7 @@ func createTslvSpan(span *Span) tslvSpan { Duration: span.duration, ParentID: span.parentID, Error: span.error, - Meta: span.meta, + Meta: span.meta.Map(true), 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 new file mode 100644 index 00000000000..3a221230346 --- /dev/null +++ b/ddtrace/tracer/internal/span_attributes.go @@ -0,0 +1,193 @@ +// 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 ( + "iter" + "math/bits" + "unsafe" +) + +// 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 + +const ( + 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. + AttrUnknown AttrKey = 0xFF +) + +// 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{}[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. +// 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 readOnly + 6B padding + [3]string (48B) = 56 bytes. +// +// 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 + readOnly bool + 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 +} + +// 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 "" + } + 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) { + return a.Val(key), a.Has(key) +} + +// 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) +} + +// 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. +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 +// 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-readOnly) shallow copy. +func (a *SpanAttributes) Clone() *SpanAttributes { + if a == nil { + return &SpanAttributes{} + } + cp := *a + cp.readOnly = 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"}, + {AttrLanguage, "language"}, +} + +// 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 + } + for _, d := range Defs { + if a.Has(d.Key) { + if !yield(d.Name, a.vals[d.Key]) { + return + } + } + } + } +} + +// 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 !IsPromotedKeyLen(len(tag)) { + return AttrUnknown, false + } + switch tag { + case "env": + return AttrEnv, true + case "version": + return AttrVersion, true + case "language": + return AttrLanguage, true + } + return AttrUnknown, false +} diff --git a/ddtrace/tracer/internal/span_attributes_test.go b/ddtrace/tracer/internal/span_attributes_test.go new file mode 100644 index 00000000000..c098ff4745e --- /dev/null +++ b/ddtrace/tracer/internal/span_attributes_test.go @@ -0,0 +1,304 @@ +// 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 ( + "maps" + "testing" +) + +func TestSpanAttributesZeroValue(t *testing.T) { + var a SpanAttributes + 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) + } + } +} + +func TestSpanAttributesSetAndGet(t *testing.T) { + tests := []struct { + key AttrKey + val string + }{ + {AttrEnv, "prod"}, + {AttrVersion, "1.2.3"}, + {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, AttrLanguage} { + if _, ok := a.Get(key); ok { + t.Errorf("key %d should be absent after setting only AttrEnv", key) + } + } +} + +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. + if v := a.Val(AttrVersion); v != "" { + t.Errorf("expected empty string from unset key, got %q", v) + } +} + +func TestSpanAttributesForEach(t *testing.T) { + var a SpanAttributes + a.Set(AttrEnv, "prod") + a.Set(AttrVersion, "1.2.3") + + got := maps.Collect(a.all()) + 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["version"] != "1.2.3" { + t.Errorf("expected version=1.2.3, got %q", got["version"]) + } +} + +func TestSpanAttributesForEachNil(t *testing.T) { + var a *SpanAttributes + called := false + for range a.all() { + called = true + } + if called { + t.Error("All() 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}, + {"language", AttrLanguage, true}, + {"component", AttrUnknown, false}, + {"span.kind", AttrUnknown, false}, + {"unknown", AttrUnknown, false}, + {"", AttrUnknown, 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) { + 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(AttrLanguage, "go") + } + _ = a + }) + + b.Run("map", func(b *testing.B) { + 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["language"] = "go" + } + _ = m + }) +} + +// 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(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(AttrLanguage) + } + _, _ = s, ok + }) + + b.Run("map", func(b *testing.B) { + m := map[string]string{ + "env": "prod", + "version": "1.2.3", + "language": "go", + } + 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["language"] + } + _, _ = 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.MarkReadOnly() + 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) + } +} + +// BenchmarkMap measures the allocation cost of Map() with both tag store +// entries and promoted attrs set. +func BenchmarkMap(b *testing.B) { + var a SpanAttributes + a.Set(AttrEnv, "prod") + a.Set(AttrVersion, "1.2.3") + a.Set(AttrLanguage, "go") + sm := NewSpanMeta(&a) + sm.Set("key0", "value0") + sm.Set("key1", "value1") + sm.Set("key2", "value2") + sm.Set("key3", "value3") + sm.Set("key4", "value4") + + b.ReportAllocs() + b.ResetTimer() + for range b.N { + _ = sm.Map(true) + } +} + +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 new file mode 100644 index 00000000000..96090dc11e8 --- /dev/null +++ b/ddtrace/tracer/internal/span_meta.go @@ -0,0 +1,391 @@ +// 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 internal + +import ( + "fmt" + "iter" + "maps" + "strings" + + "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 ( + // 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) + _ msgp.Decodable = (*SpanMeta)(nil) + _ msgp.Sizer = (*SpanMeta)(nil) +) + +// SpanMeta replaces a plain map[string]string for the Span.meta field. +// 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 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 +} + +// 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. +func NewSpanMetaFromMap(m map[string]string) SpanMeta { + return SpanMeta{m: m} +} + +// 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.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.promotedAttrs == prev { + sm.promotedAttrs = 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.promotedAttrs != nil && sm.promotedAttrs.Count() == 0 { + sm.promotedAttrs = nil + } +} + +// --------------------------------------------------------------------------- +// Read methods +// --------------------------------------------------------------------------- + +// 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 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 +} + +// 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 + } + v, found := sm.promotedAttrs.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 +} + +// Attr returns a promoted attribute value by AttrKey. O(1) array index + bitmask. +func (sm *SpanMeta) Attr(key AttrKey) (string, bool) { + return sm.promotedAttrs.Get(key) +} + +// Env returns the value of the "env" promoted attribute. +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.promotedAttrs.Get(AttrVersion) } + +// 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. 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) { + for k, v := range sm.m { + if !fn(k, v) { + return + } + } +} + +// --------------------------------------------------------------------------- +// Write methods +// --------------------------------------------------------------------------- + +// 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 IsPromotedKeyLen(len(key)) && sm.setPromoted(key, value) { + return + } + if sm.m == nil { + 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 no-op). +func (sm *SpanMeta) setPromoted(key, value string) bool { + ak, ok := AttrKeyForTag(key) + if !ok { + return false + } + 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.promotedAttrs.Set(ak, value) + return true +} + +// initMap allocates the flat map and inserts the first entry. +func (sm *SpanMeta) initMap(key, value string) { + sm.m = make(map[string]string, metaMapHint) + 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.promotedAttrs == nil { + sm.promotedAttrs = new(SpanAttributes) + return + } + if sm.promotedAttrs.IsReadOnly() { + sm.promotedAttrs = sm.promotedAttrs.Clone() + } +} + +// 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) { + switch len(key) { + case 3, 7, 8: + sm.deleteSlow(key) + default: + delete(sm.m, key) + } +} + +// 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 + } + if _, isSet := sm.promotedAttrs.Get(ak); !isSet { + return + } + sm.ensureAttrsLocal() + sm.promotedAttrs.Unset(ak) +} + +// --------------------------------------------------------------------------- +// Counting / iteration +// --------------------------------------------------------------------------- + +// Count returns the total number of distinct entries (flat map + promoted attrs). +func (sm *SpanMeta) Count() int { + return len(sm.m) + sm.promotedAttrs.Count() +} + +// AttrCount returns the number of promoted attrs currently set. +func (sm *SpanMeta) AttrCount() int { + return sm.promotedAttrs.Count() +} + +// 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 + } + 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) + } + } + return merged +} + +// All returns an iterator over all entries. Flat-map entries are yielded first +// (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) { + for k, v := range sm.m { + if !yield(k, v) { + return + } + } + if sm.promotedAttrs == nil { + return + } + for _, d := range Defs { + if sm.promotedAttrs.Has(d.Key) { + if !yield(d.Name, sm.promotedAttrs.Val(d.Key)) { + 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.All() { + if !first { + b.WriteByte(' ') + } + first = false + fmt.Fprintf(&b, "%s:%s", k, v) + } + b.WriteByte(']') + return b.String() +} + +// --------------------------------------------------------------------------- +// msgp codec +// --------------------------------------------------------------------------- + +// EncodeMsg writes the map header and entries, combining the flat map and +// promoted attrs. +func (sm *SpanMeta) EncodeMsg(en *msgp.Writer) error { + n := sm.promotedAttrs.Count() + if err := en.WriteMapHeader(uint32(len(sm.m) + n)); 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) + } + } + if n == 0 { + return nil + } + var ( + v string + ok bool + ) + for _, d := range Defs { + if v, ok = sm.promotedAttrs.Get(d.Key); !ok { + continue + } + 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 +} + +// 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 if already allocated; otherwise allocate fresh pre-sized. + if sm.m != nil { + clear(sm.m) + } else { + 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) + } + sm.m[key] = val + } + return nil +} + +// 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 n := sm.promotedAttrs.Count(); n > 0 { + for _, d := range Defs { + if v, ok := sm.promotedAttrs.Get(d.Key); ok { + size += msgp.StringPrefixSize + len(d.Name) + msgp.StringPrefixSize + len(v) + } + } + } + return size +} diff --git a/ddtrace/tracer/option_test.go b/ddtrace/tracer/option_test.go index aad3b52c782..3b38fad5462 100644 --- a/ddtrace/tracer/option_test.go +++ b/ddtrace/tracer/option_test.go @@ -1913,7 +1913,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["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) @@ -1962,8 +1963,10 @@ 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"]) + 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/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/payload_test.go b/ddtrace/tracer/payload_test.go index 704fd21eca5..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 @@ -551,7 +551,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[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) } } @@ -559,7 +560,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.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") @@ -656,7 +657,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.Set("key", strings.Repeat("X", 10*1024)) trace := make(spanList, count) for i := range count { trace[i] = s @@ -796,7 +797,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.Set("data", strings.Repeat("x", size.spanSize*1024)) spans[i] = span } @@ -887,7 +888,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.Set("data", strings.Repeat("x", 1024)) spans[i] = span } diff --git a/ddtrace/tracer/payload_v1.go b/ddtrace/tracer/payload_v1.go index 0e535d25c69..8b8878df5f9 100644 --- a/ddtrace/tracer/payload_v1.go +++ b/ddtrace/tracer/payload_v1.go @@ -572,28 +572,25 @@ 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. We also write a placeholder for the array header - // and write the actual count after writing all attributes + // creating an intermediate map. We write a placeholder for the array header + // and write the actual count after writing all attributes. + // Promoted attrs (env, version, language) are encoded separately + // as fields 13-16 and must not appear in the attributes array. p.buf = msgp.AppendUint32(p.buf, uint32(9)) // attributes fieldID off := len(p.buf) count := 0 p.buf = append(p.buf, msgpackArray32, 0, 0, 0, 0) - env, version, component, spanKind := "", "", "", "" - for k, v := range span.meta { + component, spanKind := "", "" + // Range iterates only flat-map entries (promoted attrs are excluded). + span.meta.Range(func(k, v string) bool { // Span links are serialized separately in the payload, so // we skip them here to avoid duplication. if k == "_dd.span_links" { - continue + return true } // Grab common attributes early to avoid map lookups later on. - if k == ext.Environment { - env = v - } - if k == ext.Version { - version = v - } if k == ext.Component { component = v } @@ -604,7 +601,8 @@ func (p *payloadV1) encodeSpans(bm bitmap, fieldID int, spans spanList, st *stri p.buf = st.serialize(k, p.buf) p.buf = msgp.AppendUint32(p.buf, uint32(StringValueType)) p.buf = st.serialize(v, p.buf) - } + return true + }) for k, v := range span.metrics { count++ p.buf = st.serialize(k, p.buf) @@ -633,6 +631,12 @@ func (p *payloadV1) encodeSpans(bm bitmap, fieldID int, spans spanList, st *stri p.buf = encodeField(p.buf, fullSetBitmap, 10, span.spanType, st) p.encodeSpanLinks(fullSetBitmap, 11, span.spanLinks, st) p.encodeSpanEvents(fullSetBitmap, 12, span.spanEvents, st) + + // Promoted attrs (env, version) live in promotedAttrs, not in the flat map, + // so they are retrieved via accessor methods. component and spanKind were + // captured during the Range iteration above. + env, _ := span.meta.Env() + version, _ := span.meta.Version() 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) diff --git a/ddtrace/tracer/sampler.go b/ddtrace/tracer/sampler.go index 3568829ca46..8e197c6d6e5 100644 --- a/ddtrace/tracer/sampler.go +++ b/ddtrace/tracer/sampler.go @@ -275,7 +275,10 @@ 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]} + // 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). + 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 e4d3023590b..6b69dc62382 100644 --- a/ddtrace/tracer/sampler_test.go +++ b/ddtrace/tracer/sampler_test.go @@ -19,6 +19,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" @@ -59,10 +60,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 +69,17 @@ 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[ext.Environment]) + v, _ := s.meta.Get(ext.Environment) + assert.Equal("my-env", v) s = mkSpan("my-service2", "") assert.Equal("my-service2", s.service) - _, ok := s.meta[ext.Environment] - assert.False(ok) + v, ok := s.meta.Get(ext.Environment) + assert.Equal("", v) + // 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) { @@ -378,7 +382,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[ext.Environment] + v, _ := spn.meta.Get(ext.Environment) + key := "service:" + spn.service + ",env:" + v if rate, ok := ops.rates[key]; ok { return rate } @@ -395,10 +400,10 @@ 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.SetTag(ext.Environment, "prod") spnMiss := newSpan("op", "other", "resource", 1, 1, 0) - spnMiss.meta[ext.Environment] = "staging" + spnMiss.SetTag(ext.Environment, "staging") b.ResetTimer() b.Run("old/hit", func(b *testing.B) { @@ -2121,8 +2126,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, keyKnuthSamplingRate) - assert.Equal("0", root.meta[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 +2180,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, keyKnuthSamplingRate) - assert.Equal("0", root.meta[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, 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{})) @@ -2364,11 +2371,9 @@ 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 - } - return s + a := new(tinternal.SpanAttributes) + a.Set(tinternal.AttrEnv, env) + return &Span{service: svc, meta: tinternal.NewSpanMeta(a)} } // Set initial low rate. @@ -2412,11 +2417,9 @@ 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 - } - return s + a := new(tinternal.SpanAttributes) + a.Set(tinternal.AttrEnv, env) + return &Span{service: svc, meta: tinternal.NewSpanMeta(a)} } // Set initial low rate (decrease from default 1.0, applied immediately). @@ -2457,11 +2460,9 @@ 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 - } - return s + a := new(tinternal.SpanAttributes) + a.Set(tinternal.AttrEnv, env) + return &Span{service: svc, meta: tinternal.NewSpanMeta(a)} } // Set initial rate (decrease from default 1.0). @@ -2483,11 +2484,9 @@ 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 - } - return s + a := new(tinternal.SpanAttributes) + a.Set(tinternal.AttrEnv, env) + return &Span{service: svc, meta: tinternal.NewSpanMeta(a)} } // Start at 0.1, target 0.5. @@ -2512,11 +2511,9 @@ 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 - } - return s + a := new(tinternal.SpanAttributes) + a.Set(tinternal.AttrEnv, env) + 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 d7cdceda284..7a86d21b083 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 @@ -24,6 +25,7 @@ import ( "time" "github.com/DataDog/dd-trace-go/v2/ddtrace/ext" + 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" @@ -84,7 +86,7 @@ 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.All() { m[k] = v } for k, v := range s.metrics { @@ -104,7 +106,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["events"] + v, _ := s.meta.Get("events") + return v } if s.spanEvents == nil { return "" @@ -136,7 +139,10 @@ type Span struct { // +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 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 @@ -159,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. @@ -223,11 +227,8 @@ 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 v, ok := s.meta[key]; ok { - delete(s.meta, key) + if v, ok := s.meta.Get(key); ok { + s.meta.Delete(key) delete(s.metrics, key) return v } @@ -291,7 +292,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[ext.Component]; ok { + if v, ok := s.meta.Get(ext.Component); ok { integration = v } else { integration = "manual" @@ -299,16 +300,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)) - maps.Copy(meta, s.meta) - return meta -} - // 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. @@ -317,10 +308,8 @@ 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) { - continue - } + if v, ok := s.meta.Get(k); ok && matchFunc(v) { + continue } if s.metrics != nil { if v, ok := s.metrics[k]; ok { @@ -379,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() @@ -411,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: @@ -567,7 +556,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) + root.meta.Delete(keyUserID) idenc := base64.StdEncoding.EncodeToString([]byte(id)) trace.setPropagatingTag(keyPropagatedUserID, idenc) s.context.updated = true @@ -577,7 +566,7 @@ func (s *Span) SetUser(id string, opts ...UserMonitoringOption) { trace.unsetPropagatingTag(keyPropagatedUserID) s.context.updated = true } - delete(root.meta, keyPropagatedUserID) + root.meta.Delete(keyPropagatedUserID) } usrData := map[string]string{ @@ -740,9 +729,6 @@ 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: @@ -755,7 +741,7 @@ func (s *Span) setMetaInit(key, v string) { case ext.SpanType: s.spanType = v default: - s.meta[key] = v + s.meta.Set(key, v) } } @@ -804,7 +790,7 @@ func (s *Span) setMetricInit(key string, v float64) { if s.metrics == nil { s.metrics = make(map[string]float64, 1) } - delete(s.meta, 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 @@ -824,7 +810,7 @@ func (s *Span) setMetricLocked(key string, v float64) { if s.metrics == nil { s.metrics = make(map[string]float64, 1) } - delete(s.meta, key) + s.meta.Delete(key) switch key { case ext.ManualKeep: if v == float64(samplernames.AppSec) { @@ -863,20 +849,12 @@ 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.") return } - if s.meta == nil { - s.meta = make(map[string]string) - } - s.meta["_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 @@ -900,7 +878,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.Set("events", string(b)) } // Finish closes this Span (but not its children) providing the duration @@ -990,10 +968,7 @@ func (s *Span) enrichServiceSource() { if s.serviceSource == "" || s.service == globalconfig.ServiceName() { return } - if s.meta == nil { - s.meta = make(map[string]string, 1) - } - s.meta[ext.KeyServiceSource] = s.serviceSource + s.meta.Set(ext.KeyServiceSource, s.serviceSource) } func (s *Span) finish(finishTime int64) { @@ -1045,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). @@ -1145,8 +1120,8 @@ func (s *Span) String() string { fmt.Sprintf("Type: %s", s.spanType), "Tags:", } - for key, val := range s.meta { - 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)) } for key, val := range s.metrics { lines = append(lines, fmt.Sprintf("\t%s:%f", key, val)) @@ -1237,25 +1212,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 { - // Unconditionally set meta tags: env, version, component, span.kind, language - const ( - expectedEntries = 5 - 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() - val, ok := s.meta[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 c3c8d0ad9c5..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["events"]) + events, _ := s.meta.Get("events") + assert.NotEmpty(t, events) var spanEvents []spanEvent - err := json.Unmarshal([]byte(s.meta["events"]), &spanEvents) + err := json.Unmarshal([]byte(events), &spanEvents) require.NoError(t, err) require.Len(t, spanEvents, 3) diff --git a/ddtrace/tracer/span_msgp.go b/ddtrace/tracer/span_msgp.go index 4d1a326de9d..c0d6d883af1 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 { @@ -96,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() @@ -148,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 } } @@ -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.IsZero() { 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) @@ -332,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 } } @@ -396,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 } } @@ -415,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 } } @@ -430,27 +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 + msgp.MapHeaderSize - if z.meta != nil { - for za0001, za0002 := range z.meta { - _ = za0002 - s += msgp.StringPrefixSize + len(za0001) + msgp.StringPrefixSize + len(za0002) - } - } - 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/ddtrace/tracer/span_test.go b/ddtrace/tracer/span_test.go index c468d7cabd6..4de292b6266 100644 --- a/ddtrace/tracer/span_test.go +++ b/ddtrace/tracer/span_test.go @@ -17,6 +17,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: map[string]string{}, + meta: tinternal.NewSpanMetaFromMap(map[string]string{}), metrics: map[string]float64{}, spanID: spanID, traceID: traceID, @@ -428,13 +429,15 @@ func TestSpanSetTag(t *testing.T) { assert.Equal("web.request", span.name) span.SetTag("component", "tracer") - assert.Equal("tracer", span.meta["component"]) + 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["tagStruct"]) + v, _ = span.meta.Get("tagStruct") + assert.Equal("{1 2}", v) span.SetTag(ext.Error, true) assert.Equal(int32(1), span.error) @@ -444,9 +447,12 @@ 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]) + 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) @@ -455,24 +461,32 @@ func TestSpanSetTag(t *testing.T) { assert.Equal(int32(0), span.error) span.SetTag("some.bool", true) - assert.Equal("true", span.meta["some.bool"]) + v, _ = span.meta.Get("some.bool") + assert.Equal("true", v) span.SetTag("some.other.bool", false) - assert.Equal("false", span.meta["some.other.bool"]) + v, _ = span.meta.Get("some.other.bool") + assert.Equal("false", v) span.SetTag("time", (*time.Time)(nil)) - assert.Equal("", span.meta["time"]) + v, _ = span.meta.Get("time") + assert.Equal("", v) span.SetTag("nilStringer", (*nilStringer)(nil)) - assert.Equal("", span.meta["nilStringer"]) + v, _ = span.meta.Get("nilStringer") + assert.Equal("", v) span.SetTag("somestrings", []string{"foo", "bar"}) - assert.Equal("foo", span.meta["somestrings.0"]) - assert.Equal("bar", span.meta["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["somebools.0"]) - assert.Equal("false", span.meta["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"]) @@ -480,10 +494,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["someslices.0"]) - assert.Equal("[d]", span.meta["someslices.1"]) - assert.Equal("[]", span.meta["someslices.2"]) - assert.Equal("[e, f]", span.meta["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"}}) @@ -513,10 +531,12 @@ func TestSpanSetTag(t *testing.T) { s := "string" span.SetTag("str_ptr", &s) - assert.Equal(s, span.meta["str_ptr"]) + strPtr, _ := span.meta.Get("str_ptr") + assert.Equal(s, strPtr) span.SetTag("nil_str_ptr", (*string)(nil)) - assert.Equal("", span.meta["nil_str_ptr"]) + nilStrPtr, _ := span.meta.Get("nil_str_ptr") + assert.Equal("", nilStrPtr) assert.Panics(func() { span.SetTag("panicStringer", &panicStringer{}) @@ -537,6 +557,37 @@ func TestSpanTagsStartSpan(t *testing.T) { assert.Equal("operation-name", tags[ext.SpanName]) } +// 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 + }{ + {ext.Environment}, + {ext.Version}, + {ext.Component}, + {ext.SpanKind}, + } { + t.Run(tc.tag, func(t *testing.T) { + span := newBasicSpan("op") + span.SetTag(tc.tag, "value") + 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") + got, ok = span.meta.Get(tc.tag) + assert.True(ok) + assert.Equal("updated", got, "field must be set") + }) + } +} + type testMsgpStruct struct { A string } @@ -550,13 +601,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[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[ext.ErrorHandlingStack]) + v, _ := span.meta.Get(ext.ErrorHandlingStack) + assert.NotEmpty(t, v) }) } @@ -759,9 +812,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["tag"] == "value") + v, _ := span.meta.Get("tag") + assert.Equal(tc.wantTag, v == "value") } else { - assert.Empty(span.meta["tag"]) + v, _ := span.meta.Get("tag") + assert.Empty(v) } }) } @@ -813,12 +868,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["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["bytes"]) + v, _ := span.meta.Get("bytes") + assert.Equal(fmt.Sprint(intLowerLimit), v) }, "finished": func(assert *assert.Assertions, span *Span) { span.Finish() @@ -881,10 +938,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[ext.ErrorMsg]) - assert.Equal("*errortrace.TracerError", span.meta[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[ext.ErrorHandlingStack] + stack, _ := span.meta.Get(ext.ErrorHandlingStack) assert.NotEqual("", stack) span.Finish() @@ -900,10 +959,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[ext.ErrorMsg]) - assert.Equal("*errors.errorString", span.meta[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[ext.ErrorHandlingStack] + stack, _ := span.meta.Get(ext.ErrorHandlingStack) assert.NotEqual("", stack) span.Finish() @@ -922,25 +983,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[ext.ErrorMsg]) - assert.Equal("*errors.errorString", span.meta[ext.ErrorType]) - assert.NotEqual("", span.meta[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) 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+4, 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) { @@ -954,9 +1013,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[ext.ErrorMsg]) - assert.Equal("*tracer.boomError", span.meta[ext.ErrorType]) - assert.NotEqual("", span.meta[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) { @@ -968,10 +1030,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) + n := span.meta.Count() span.SetTag(ext.Error, nil) assert.Equal(int32(0), span.error) - assert.Equal(nMeta, len(span.meta)) + assert.Equal(n, span.meta.Count()) } func TestSpanErrorStackMetrics(t *testing.T) { @@ -1106,14 +1168,15 @@ func TestUniqueTagKeys(t *testing.T) { span.SetTag("foo.bar", "val") assert.NotContains(span.metrics, "foo.bar") - assert.Equal("val", span.meta["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, "foo.bar") + assert.False(span.meta.Has("foo.bar")) } // Prior to a bug fix, this failed when running `go test -race` @@ -1704,7 +1767,7 @@ func TestSpanLinksInMeta(t *testing.T) { sp.Finish() internalSpan := sp - _, ok := internalSpan.meta["_dd.span_links"] + _, ok := internalSpan.meta.Get("_dd.span_links") assert.False(t, ok, "Expected no _dd.span_links in Meta.") }) @@ -1720,7 +1783,7 @@ func TestSpanLinksInMeta(t *testing.T) { sp.Finish() internalSpan := sp - raw, ok := internalSpan.meta["_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 @@ -1733,24 +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.supportsLinks = true - sp.AddLink(SpanLink{SpanID: 123, TraceID: 456}) - sp.AddLink(SpanLink{SpanID: 789, TraceID: 012}) - sp.Finish() - - _, ok := sp.meta["_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) { 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} diff --git a/ddtrace/tracer/spancontext.go b/ddtrace/tracer/spancontext.go index d33eb922ac8..87c2121846f 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" + 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" @@ -549,7 +550,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] @@ -873,15 +875,18 @@ func (t *trace) finishedOneLocked(s *Span) { // +checklocks:s.mu func setPeerService(s *Span, tc TracerConf) { assert.RWMutexLocked(&s.mu) - spanKind := s.meta[ext.SpanKind] + // 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.Get(ext.SpanKind) isOutboundRequest := spanKind == ext.SpanKindClient || spanKind == ext.SpanKindProducer - if _, ok := s.meta[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); ps != "" { + if ps := deriveAWSPeerService(&s.meta); ps != "" { s.setMetaLocked(ext.PeerService, ps) s.setMetaLocked(keyPeerServiceSource, ext.PeerService) } else { @@ -902,7 +907,7 @@ func setPeerService(s *Span, tc TracerConf) { } // Overwrite existing peer.service value if remapped by the user if len(tc.PeerServiceMappings) > 0 { - ps := s.meta[ext.PeerService] + ps, _ := s.meta.Get(ext.PeerService) if to, ok := tc.PeerServiceMappings[ps]; ok { s.setMetaLocked(keyPeerServiceRemappedFrom, ps) s.setMetaLocked(ext.PeerService, to) @@ -933,24 +938,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 *traceinternal.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" } @@ -962,8 +968,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] - return ok + return s.meta.Has(tag) } // setPeerServiceFromSource sets peer.service from the sources determined @@ -975,6 +980,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"): @@ -985,7 +991,7 @@ func setPeerServiceFromSource(s *Span) string { "tablename", "bucketname", } - case s.meta[ext.DBSystem] == ext.DBSystemCassandra: + case dbSys == ext.DBSystemCassandra: sources = []string{ ext.CassandraContactPoints, } @@ -1013,7 +1019,7 @@ func setPeerServiceFromSource(s *Span) string { }...) } for _, source := range sources { - if val, ok := s.meta[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 b997c8602ff..1fee0a724a7 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 { + 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["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["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["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["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, "peer.service") + assert.False(t, s.meta.Has("peer.service")) } else { - assert.Equal(t, tc.wantPeerService, s.meta["peer.service"]) + v, _ := s.meta.Get("peer.service") + assert.Equal(t, tc.wantPeerService, v) } if tc.wantPeerServiceSource == "" { - assert.NotContains(t, s.meta, "_dd.peer.service.source") + assert.False(t, s.meta.Has("_dd.peer.service.source")) } else { - assert.Equal(t, tc.wantPeerServiceSource, s.meta["_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, "_dd.peer.service.remapped_from") + assert.False(t, s.meta.Has("_dd.peer.service.remapped_from")) } else { - assert.Equal(t, tc.wantPeerServiceRemappedFrom, s.meta["_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["_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, "_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, "_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["_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["_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["_dd.tags.process"]) + v, _ := root.meta.Get("_dd.tags.process") + assert.NotEmpty(t, v) } else { - assert.NotContains(t, root.meta, "_dd.tags.process") + assert.False(t, root.meta.Has("_dd.tags.process")) } for _, s := range traces[0][1:] { - assert.NotContains(t, s.meta, "_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 519a42e1cfd..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) { @@ -82,24 +84,8 @@ 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]) + v, _ := child.meta.Get(ext.KeyServiceSource) + assert.Equal(t, "m", v) }) t.Run("NoExplicitServiceNoSrvSrc", func(t *testing.T) { @@ -113,12 +99,29 @@ func TestServiceSource(t *testing.T) { span.mu.RLock() defer span.mu.RUnlock() - _, hasSrvSrc := span.meta[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") }) - 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() + v, _ := child.meta.Get(ext.KeyServiceSource) + assert.Equal(t, "m", v) + }) + + 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 +132,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[ext.KeyServiceSource]) + v, _ := span.meta.Get(ext.KeyServiceSource) + assert.Equal(t, ext.ServiceSourceMapping, v) }) t.Run("SetMetaInitServiceName", func(t *testing.T) { @@ -146,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) }) } diff --git a/ddtrace/tracer/stats.go b/ddtrace/tracer/stats.go index 38a23d1ca0c..bb1fa8fbbaf 100644 --- a/ddtrace/tracer/stats.go +++ b/ddtrace/tracer/stats.go @@ -165,9 +165,8 @@ func (c *concentrator) newTracerStatSpan(s *Span, obfuscator *obfuscate.Obfuscat if c.shouldObfuscate() { resource = obfuscatedResource(obfuscator, s.spanType, s.resource) } - - httpMethod := s.meta[ext.HTTPMethod] - httpEndpoint := s.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, @@ -178,7 +177,7 @@ func (c *concentrator) newTracerStatSpan(s *Span, obfuscator *obfuscate.Obfuscat Start: s.start, Duration: s.duration, Error: s.error, - Meta: s.meta, + 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, @@ -187,7 +186,7 @@ func (c *concentrator) newTracerStatSpan(s *Span, obfuscator *obfuscate.Obfuscat if !ok { return nil, false } - origin := s.meta[keyOrigin] + origin, _ := s.meta.Get(keyOrigin) return &tracerStatSpan{ statSpan: statSpan, origin: origin, diff --git a/ddtrace/tracer/stats_test.go b/ddtrace/tracer/stats_test.go index 8646991cd95..c3b772c82fd 100644 --- a/ddtrace/tracer/stats_test.go +++ b/ddtrace/tracer/stats_test.go @@ -21,6 +21,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" @@ -325,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: tinternal.NewSpanMetaFromMap(map[string]string{ ext.HTTPMethod: uniqueMethod, ext.HTTPEndpoint: uniqueEndpoint, - }, + }), } transport := newDummyTransport() c := newConcentrator(newTestConfigWithTransport(t, transport), bucketSize, &statsd.NoOpClientDirect{}) @@ -358,9 +359,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{}) diff --git a/ddtrace/tracer/textmap_test.go b/ddtrace/tracer/textmap_test.go index 5aa49e453cf..a9d68b95e97 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["_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["_dd.parent_id"]) + v, _ := s.meta.Get("_dd.parent_id") + assert.Empty(v) } else { - assert.Equal(s.meta["_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, 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, 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[keyTraceID128]) + v, _ := root.meta.Get(keyTraceID128) + assert.Equal("640cfd8d00000000", v) }) } diff --git a/ddtrace/tracer/tracer.go b/ddtrace/tracer/tracer.go index 263f77073f4..32c082503e0 100644 --- a/ddtrace/tracer/tracer.go +++ b/ddtrace/tracer/tracer.go @@ -25,6 +25,7 @@ import ( "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" 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" @@ -175,6 +176,18 @@ type tracer struct { // State related to the Dynamic Instrumentation product. dynInstSubscriptions dynInstSubscriptions + + // 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 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 traceinternal.SpanAttributes } type dynInstSubscriptions struct { @@ -512,9 +525,36 @@ func newUnstartedTracer(opts ...StartOption) (t *tracer, err error) { dataStreams: dataStreamsProcessor, logFile: logFile, } + 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 != "" { + base.Set(traceinternal.AttrEnv, env) + mainSvc.Set(traceinternal.AttrEnv, env) + } + if ver := c.internalConfig.Version(); ver != "" { + if c.universalVersion { + base.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) + } + base.MarkReadOnly() + mainSvc.MarkReadOnly() +} + // defaultAgentInfoPollInterval is the default interval at which the tracer // polls the agent's /info endpoint for capability updates. const defaultAgentInfoPollInterval = 5 * time.Second @@ -765,7 +805,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 *traceinternal.SpanAttributes, options ...StartSpanOption) *Span { var opts StartSpanConfig for _, fn := range options { if fn == nil { @@ -830,6 +870,7 @@ func spanStart(operationName string, options ...StartSpanOption) *Span { traceID: id, start: startTime, integration: "manual", + meta: traceinternal.NewSpanMeta(sharedAttrs), // COW: shared until a per-span field is set } span.spanLinks = append(span.spanLinks, opts.SpanLinks...) @@ -878,18 +919,24 @@ 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() } + // 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.ReplaceSharedAttrs(&t.sharedAttrs, &t.sharedAttrsForMainSvc) + } + cfg := t.config.internalConfig span.noDebugStack = !cfg.DebugStack() if hostname := cfg.Hostname(); hostname != "" && cfg.ReportHostname() { 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()) @@ -901,11 +948,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 @@ -914,7 +963,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 8c4cc337f16..3ff52e836cb 100644 --- a/ddtrace/tracer/tracer_test.go +++ b/ddtrace/tracer/tracer_test.go @@ -33,6 +33,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" @@ -324,10 +325,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[keyMeasured] + _, ok := span.meta.Get(keyMeasured) assert.False(ok) - assert.Equal(globalconfig.RuntimeID(), span.meta[ext.RuntimeID]) - assert.NotEqual("", span.meta[ext.RuntimeID]) + v, _ := span.meta.Get(ext.RuntimeID) + assert.Equal(globalconfig.RuntimeID(), v) + assert.NotEqual("", v) }) t.Run("priority", func(t *testing.T) { @@ -818,7 +820,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[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 @@ -840,7 +843,8 @@ func TestTracerStartSpanOptions128(t *testing.T) { // 0001e240 (123456) + 00000000 (zeros) + 00000000000f1206 (987654) assert.Equal("0001e2400000000000000000000f1206", id) s.Finish() - assert.Equal(id[:16], s.meta[keyTraceID128]) + v, _ := s.meta.Get(keyTraceID128) + assert.Equal(id[:16], v) }) } @@ -916,11 +920,13 @@ func TestStartSpanOrigin(t *testing.T) { // first child contains tag child := tracer.StartSpan("child", ChildOf(ctx)) - assert.Equal("synthetics", child.meta[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[keyOrigin]) + v, _ = child2.meta.Get(keyOrigin) + assert.Empty(v) // but injecting its context marks origin carrier2 := TextMapCarrier(map[string]string{}) @@ -1142,7 +1148,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["key"]) + v, _ := span.meta.Get("key") + assert.Equal("value", v) } func TestTracerSpanGlobalTags(t *testing.T) { @@ -1151,9 +1158,11 @@ func TestTracerSpanGlobalTags(t *testing.T) { defer tracer.Stop() assert.Nil(err) s := tracer.StartSpan("web.request") - assert.Equal("value", s.meta["key"]) + v, _ := s.meta.Get("key") + assert.Equal("value", v) child := tracer.StartSpan("db.query", ChildOf(s.Context())) - assert.Equal("value", child.meta["key"]) + v, _ = child.meta.Get("key") + assert.Equal("value", v) } func TestTracerSpanServiceMappings(t *testing.T) { @@ -1212,7 +1221,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[ext.ErrorStack]) + v, _ := s.meta.Get(ext.ErrorStack) + assert.Empty(t, v) }) t.Run("SetTag", func(t *testing.T) { @@ -1222,7 +1232,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[ext.ErrorStack]) + v, _ := s.meta.Get(ext.ErrorStack) + assert.Empty(t, v) }) } @@ -1278,11 +1289,15 @@ 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]) + v, _ := parent.meta.Get(keyTraceID128) + assert.Equal(id[:16], v) + v, _ = child.meta.Get(keyTraceID128) + assert.Empty(v) } else { - assert.Empty(child.meta[keyTraceID128]) - assert.Empty(parent.meta[keyTraceID128]) + v, _ := child.meta.Get(keyTraceID128) + assert.Empty(v) + v, _ = parent.meta.Get(keyTraceID128) + assert.Empty(v) } }) } @@ -1307,7 +1322,8 @@ func TestNewChildHasNoPid(t *testing.T) { root := tracer.newRootSpan("pylons.request", "pylons", "/") child := tracer.newChildSpan("redis.command", root) - assert.Equal("", child.meta[ext.Pid]) + v, _ := child.meta.Get(ext.Pid) + assert.Empty(v) } func TestTracerSampler(t *testing.T) { @@ -1795,8 +1811,7 @@ func TestPushPayload(t *testing.T) { defer stop() s := newBasicSpan("3MB") - s.meta["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) @@ -1906,11 +1921,11 @@ func TestTracerReportsHostname(t *testing.T) { assert := assert.New(t) - name, ok := root.meta[keyHostname] + name, ok := root.meta.Get(keyHostname) assert.True(ok) assert.Equal(name, tracer.config.internalConfig.Hostname()) - name, ok = child.meta[keyHostname] + name, ok = child.meta.Get(keyHostname) assert.True(ok) assert.Equal(name, tracer.config.internalConfig.Hostname()) }) @@ -1932,9 +1947,9 @@ func TestTracerReportsHostname(t *testing.T) { assert := assert.New(t) - _, ok := root.meta[keyHostname] + _, ok := root.meta.Get(keyHostname) assert.False(ok) - _, ok = child.meta[keyHostname] + _, ok = child.meta.Get(keyHostname) assert.False(ok) }) } @@ -1953,11 +1968,11 @@ func TestTracerReportsHostname(t *testing.T) { assert := assert.New(t) - got, ok := root.meta[keyHostname] + got, ok := root.meta.Get(keyHostname) assert.True(ok) assert.Equal(got, hostname) - got, ok = child.meta[keyHostname] + got, ok = child.meta.Get(keyHostname) assert.True(ok) assert.Equal(got, hostname) }) @@ -1976,11 +1991,11 @@ func TestTracerReportsHostname(t *testing.T) { assert := assert.New(t) - got, ok := root.meta[keyHostname] + got, ok := root.meta.Get(keyHostname) assert.True(ok) assert.Equal(got, hostname) - got, ok = child.meta[keyHostname] + got, ok = child.meta.Get(keyHostname) assert.True(ok) assert.Equal(got, hostname) }) @@ -1997,9 +2012,9 @@ func TestTracerReportsHostname(t *testing.T) { assert := assert.New(t) - _, ok := root.meta[keyHostname] + _, ok := root.meta.Get(keyHostname) assert.False(ok) - _, ok = child.meta[keyHostname] + _, ok = child.meta.Get(keyHostname) assert.False(ok) }) } @@ -2012,7 +2027,7 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request") - v := sp.meta[ext.Version] + v, _ := sp.meta.Get(ext.Version) assert.Equal("4.5.6", v) }) t.Run("service", func(t *testing.T) { @@ -2023,7 +2038,8 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request", ServiceName("otherservenv")) - _, ok := sp.meta[ext.Version] + v, ok := sp.meta.Get(ext.Version) + assert.Equal("", v) assert.False(ok) }) t.Run("universal", func(t *testing.T) { @@ -2033,8 +2049,7 @@ 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) + v, _ := sp.meta.Get(ext.Version) assert.Equal("4.5.6", v) }) t.Run("service/universal", func(t *testing.T) { @@ -2045,8 +2060,7 @@ 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) + v, _ := sp.meta.Get(ext.Version) assert.Equal("1.2.3", v) }) t.Run("universal/service", func(t *testing.T) { @@ -2057,7 +2071,8 @@ func TestVersion(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request", ServiceName("otherservenv")) - _, ok := sp.meta[ext.Version] + v, ok := sp.meta.Get(ext.Version) + assert.Equal("", v) assert.False(ok) }) } @@ -2070,7 +2085,7 @@ func TestEnvironment(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request") - v := sp.meta[ext.Environment] + v, _ := sp.meta.Get(ext.Environment) assert.Equal("test", v) }) @@ -2081,7 +2096,8 @@ func TestEnvironment(t *testing.T) { assert := assert.New(t) sp := tracer.StartSpan("http.request") - _, ok := sp.meta[ext.Environment] + v, ok := sp.meta.Get(ext.Environment) + assert.Equal("", v) assert.False(ok) }) } @@ -2099,9 +2115,12 @@ 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]) + 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) { @@ -2116,9 +2135,12 @@ 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]) + 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) { @@ -2137,8 +2159,10 @@ 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]) + 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) { @@ -2154,8 +2178,10 @@ 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]) + 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) { @@ -2171,8 +2197,10 @@ 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]) + 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) { @@ -2191,8 +2219,10 @@ func TestGitMetadata(t *testing.T) { sp := tracer.StartSpan("http.request") sp.Finish() - assert.Equal("", sp.meta[internal.TraceTagCommitSha]) - assert.Equal("", sp.meta[internal.TraceTagRepositoryURL]) + v, _ := sp.meta.Get(internal.TraceTagCommitSha) + assert.Empty(v) + v, _ = sp.meta.Get(internal.TraceTagRepositoryURL) + assert.Empty(v) }) } @@ -2436,9 +2466,7 @@ func cpspan(s *Span) *Span { if len(s.metrics) == 0 { s.metrics = nil } - if len(s.meta) == 0 { - s.meta = nil - } + s.meta.Normalize() return &Span{ name: s.name, service: s.service, @@ -2446,7 +2474,7 @@ func cpspan(s *Span) *Span { spanType: s.spanType, start: s.start, duration: s.duration, - meta: s.meta, + meta: traceinternal.NewSpanMetaFromMap(s.meta.Map(true)), // flatten to plain map for comparison metrics: s.metrics, spanID: s.spanID, traceID: s.traceID, @@ -2608,7 +2636,8 @@ func TestUserMonitoring(t *testing.T) { WithUserRole(role), WithUserSessionID(sessionID)) s.Finish() for _, pair := range expected { - assert.Equal(t, pair.value, s.meta[pair.key]) + v, _ := s.meta.Get(pair.key) + assert.Equal(t, pair.value, v) } }) @@ -2620,7 +2649,8 @@ func TestUserMonitoring(t *testing.T) { child.Finish() root.Finish() for _, pair := range expected { - assert.Equal(t, pair.value, root.meta[pair.key]) + v, _ := root.meta.Get(pair.key) + assert.Equal(t, pair.value, v) } }) @@ -2628,19 +2658,21 @@ func TestUserMonitoring(t *testing.T) { s := tr.newRootSpan("root", "test", "test") SetUser(s, id, WithPropagation()) s.Finish() - assert.Equal(t, id, s.meta[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[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[keyUserID] + _, ok := s.meta.Get(keyUserID) assert.True(t, ok) - _, ok = s.meta[keyPropagatedUserID] + _, ok = s.meta.Get(keyPropagatedUserID) assert.False(t, ok) _, ok = s.context.trace.propagatingTags[keyPropagatedUserID] assert.False(t, ok) @@ -2775,9 +2807,11 @@ 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") + 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) { @@ -3211,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{} diff --git a/ddtrace/tracer/transport_bench_test.go b/ddtrace/tracer/transport_bench_test.go index 4bc2711b921..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["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/transport_test.go b/ddtrace/tracer/transport_test.go index 352f77f6374..a29836e528f 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: 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.go b/ddtrace/tracer/writer.go index 806cf901809..35ef3093734 100644 --- a/ddtrace/tracer/writer.go +++ b/ddtrace/tracer/writer.go @@ -245,11 +245,11 @@ 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.All() { if first { first = false } else { - h.buf.WriteString(`,`) + h.buf.WriteString(",") } h.marshalString(k) h.buf.WriteString(":") diff --git a/ddtrace/tracer/writer_test.go b/ddtrace/tracer/writer_test.go index 80af1cde748..c0cc8c0c1c2 100644 --- a/ddtrace/tracer/writer_test.go +++ b/ddtrace/tracer/writer_test.go @@ -23,6 +23,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" @@ -42,7 +44,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.Set(istr, istr) s.metrics[istr] = float64(i) } return s @@ -186,10 +188,10 @@ func TestLogWriter(t *testing.T) { name: "basicName", service: "basicService", resource: "basicResource", - meta: 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", @@ -247,7 +249,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.Set("query\n", "Select * from \n Where\nvalue") s.metrics["version\n"] = 3 var w logTraceWriter 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) + } +}