Skip to content

Commit 858ebd6

Browse files
committed
feat(openfeature): add span enrichment
1 parent 31d85a6 commit 858ebd6

11 files changed

Lines changed: 740 additions & 27 deletions

ddtrace/tracer/span.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import (
3434
"github.com/DataDog/dd-trace-go/v2/internal/locking"
3535
"github.com/DataDog/dd-trace-go/v2/internal/locking/assert"
3636
"github.com/DataDog/dd-trace-go/v2/internal/log"
37+
iof "github.com/DataDog/dd-trace-go/v2/internal/openfeature"
3738
"github.com/DataDog/dd-trace-go/v2/internal/orchestrion"
3839
"github.com/DataDog/dd-trace-go/v2/internal/samplernames"
3940
"github.com/DataDog/dd-trace-go/v2/internal/stacktrace"
@@ -180,6 +181,9 @@ type Span struct {
180181

181182
// +checklocks:mu
182183
taskEnd func() // ends execution tracer (runtime/trace) task, if started
184+
185+
// +checklocks:mu
186+
ffeEvaluations *iof.SpanEnrichment `msg:"-"`
183187
}
184188

185189
func (s *Span) clear() {
@@ -956,6 +960,40 @@ func (s *Span) serializeSpanEvents() {
956960
s.meta.Set("events", string(b))
957961
}
958962

963+
// recordFFEEvaluation records a feature flag evaluation in span enrichment.
964+
// Used by github.com/DataDog/dd-trace-go/v2/openfeature via go:linkname.
965+
func recordFFEEvaluation(s *Span, eval *iof.FeatureFlagEvaluation) {
966+
if s == nil || eval == nil {
967+
return
968+
}
969+
970+
s.mu.Lock()
971+
defer s.mu.Unlock()
972+
973+
if s.finished {
974+
return
975+
}
976+
977+
if s.ffeEvaluations == nil {
978+
s.ffeEvaluations = iof.NewSpanEnrichment()
979+
}
980+
s.ffeEvaluations.AddEvaluation(eval)
981+
}
982+
983+
// serializeFFEEvaluations sets the openfeature feature flag evaluation tags.
984+
// +checklocks:s.mu
985+
func (s *Span) serializeFFEEvaluations() {
986+
assert.RWMutexLocked(&s.mu)
987+
if s.ffeEvaluations == nil {
988+
return
989+
}
990+
tags := s.ffeEvaluations.GetSpanTags()
991+
for tag, value := range tags {
992+
s.meta.Set(tag, value)
993+
}
994+
s.ffeEvaluations = nil
995+
}
996+
959997
// Finish closes this Span (but not its children) providing the duration
960998
// of its part of the tracing session.
961999
func (s *Span) Finish(opts ...FinishOption) {
@@ -1060,6 +1098,7 @@ func (s *Span) finish(finishTime int64) {
10601098

10611099
s.serializeSpanLinksInMeta()
10621100
s.serializeSpanEvents()
1101+
s.serializeFFEEvaluations()
10631102
s.enrichServiceSource()
10641103

10651104
if s.duration == 0 {

internal/env/supported_configurations.gen.go

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/env/supported_configurations.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,13 @@
392392
"default": "false"
393393
}
394394
],
395+
"DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED": [
396+
{
397+
"implementation": "A",
398+
"type": "boolean",
399+
"default": "false"
400+
}
401+
],
395402
"DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED": [
396403
{
397404
"implementation": "B",
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed
2+
// under the Apache License Version 2.0.
3+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
// Copyright 2026 Datadog, Inc.
5+
6+
package openfeature
7+
8+
import (
9+
"crypto/sha256"
10+
"encoding/base64"
11+
"encoding/hex"
12+
"encoding/json"
13+
"slices"
14+
"sync"
15+
16+
"github.com/DataDog/dd-trace-go/v2/internal/log"
17+
)
18+
19+
// FeatureFlagEvaluation represents a single feature flag evaluation to be
20+
// recorded in span enrichment.
21+
type FeatureFlagEvaluation struct {
22+
FlagKey string
23+
// SerialId is the optional serial ID from flag metadata. Nil for runtime defaults.
24+
SerialID *uint32
25+
// Subject is the targeting key / subject of experiment. Non-empty only when evaluation
26+
// should be logged (doLog=true).
27+
Subject string
28+
// DefaultValue is the default value used. Non-nil only when a runtime default was used.
29+
DefaultValue any
30+
}
31+
32+
const (
33+
spanEnrichmentMaxSerialIDs = 200
34+
spanEnrichmentMaxSubjects = 10
35+
spanEnrichmentMaxSerialIDsPerSubject = 20
36+
spanEnrichmentMaxRuntimeDefaults = 5
37+
spanEnrichmentMaxDefaultValueLen = 64
38+
)
39+
40+
// SpanEnrichment accumulates feature flag evaluations to be encoded as span tags.
41+
type SpanEnrichment struct {
42+
mu sync.Mutex
43+
serialIDs map[uint32]struct{}
44+
subjects map[string]map[uint32]struct{}
45+
runtimeDefaults map[string]string
46+
}
47+
48+
func NewSpanEnrichment() *SpanEnrichment {
49+
return &SpanEnrichment{
50+
serialIDs: make(map[uint32]struct{}, spanEnrichmentMaxSerialIDs),
51+
subjects: make(map[string]map[uint32]struct{}, spanEnrichmentMaxSubjects),
52+
runtimeDefaults: make(map[string]string, spanEnrichmentMaxRuntimeDefaults),
53+
}
54+
}
55+
56+
func (se *SpanEnrichment) AddEvaluation(eval *FeatureFlagEvaluation) {
57+
if eval == nil {
58+
return
59+
}
60+
61+
se.mu.Lock()
62+
defer se.mu.Unlock()
63+
64+
if eval.SerialID != nil {
65+
se.addSerialID(*eval.SerialID)
66+
if eval.Subject != "" {
67+
se.addSubject(eval.Subject, *eval.SerialID)
68+
}
69+
} else if eval.DefaultValue != nil {
70+
se.addRuntimeDefault(eval.FlagKey, eval.DefaultValue)
71+
}
72+
}
73+
74+
// Must be called with se.mu held.
75+
func (se *SpanEnrichment) addSerialID(sid uint32) {
76+
if _, exists := se.serialIDs[sid]; !exists {
77+
if len(se.serialIDs) >= spanEnrichmentMaxSerialIDs {
78+
log.Debug("openfeature: span enrichment: too many flag serial IDs, dropping (max %d)", spanEnrichmentMaxSerialIDs)
79+
return
80+
}
81+
se.serialIDs[sid] = struct{}{}
82+
}
83+
}
84+
85+
// Must be called with se.mu held.
86+
func (se *SpanEnrichment) addSubject(subject string, sid uint32) {
87+
subjectIDs, ok := se.subjects[subject]
88+
if !ok {
89+
if len(se.subjects) >= spanEnrichmentMaxSubjects {
90+
log.Debug("openfeature: span enrichment: too many targeting keys, dropping (max %d)", spanEnrichmentMaxSubjects)
91+
return
92+
}
93+
subjectIDs = make(map[uint32]struct{}, spanEnrichmentMaxSerialIDsPerSubject)
94+
se.subjects[subject] = subjectIDs
95+
}
96+
if len(subjectIDs) >= spanEnrichmentMaxSerialIDsPerSubject {
97+
log.Debug("openfeature: span enrichment: too many experiments for subject %q, dropping (max %d)", subject, spanEnrichmentMaxSerialIDsPerSubject)
98+
return
99+
}
100+
subjectIDs[sid] = struct{}{}
101+
}
102+
103+
// Must be called with se.mu held.
104+
func (se *SpanEnrichment) addRuntimeDefault(flagKey string, defaultValue any) {
105+
if _, exists := se.runtimeDefaults[flagKey]; exists {
106+
return
107+
}
108+
if len(se.runtimeDefaults) >= spanEnrichmentMaxRuntimeDefaults {
109+
log.Debug("openfeature: span enrichment: too many runtime defaults, dropping (max %d)", spanEnrichmentMaxRuntimeDefaults)
110+
return
111+
}
112+
var valueStr string
113+
if v, ok := defaultValue.(string); ok {
114+
valueStr = v
115+
} else if b, err := json.Marshal(defaultValue); err == nil {
116+
valueStr = string(b)
117+
} else {
118+
log.Debug("openfeature: span enrichment: failed to marshal runtime default value for key %q: %v", flagKey, err.Error())
119+
return
120+
}
121+
if len(valueStr) > spanEnrichmentMaxDefaultValueLen {
122+
log.Debug("openfeature: span enrichment: runtime default value for key %q exceeds max length (%d), truncating", flagKey, spanEnrichmentMaxDefaultValueLen)
123+
valueStr = valueStr[:spanEnrichmentMaxDefaultValueLen]
124+
}
125+
se.runtimeDefaults[flagKey] = valueStr
126+
}
127+
128+
// GetSpanTags returns span tags encoding accumulated feature flag evaluations.
129+
func (se *SpanEnrichment) GetSpanTags() map[string]string {
130+
se.mu.Lock()
131+
defer se.mu.Unlock()
132+
133+
tags := make(map[string]string, 3)
134+
135+
if len(se.serialIDs) > 0 {
136+
tags["ffe_flags_enc"] = encodeSerialIDs(se.serialIDs)
137+
}
138+
139+
if len(se.subjects) > 0 {
140+
subjects := make(map[string]string, len(se.subjects))
141+
for key, ids := range se.subjects {
142+
sum := sha256.Sum256([]byte(key))
143+
hashKey := hex.EncodeToString(sum[:])
144+
subjects[hashKey] = encodeSerialIDs(ids)
145+
}
146+
if b, err := json.Marshal(subjects); err == nil {
147+
tags["ffe_subjects_enc"] = string(b)
148+
} else {
149+
log.Debug("openfeature: span enrichment: failed to marshal subjects: %v", err.Error())
150+
}
151+
}
152+
153+
if len(se.runtimeDefaults) > 0 {
154+
defaults := se.runtimeDefaults
155+
if b, err := json.Marshal(defaults); err == nil {
156+
tags["ffe_runtime_defaults"] = string(b)
157+
} else {
158+
log.Debug("openfeature: span enrichment: failed to marshal runtime defaults: %v", err.Error())
159+
}
160+
}
161+
162+
return tags
163+
}
164+
165+
// Encode a set of serial ids using unsigned LEB128 delta encoding, wrapped in base64.
166+
func encodeSerialIDs(ids map[uint32]struct{}) string {
167+
seq := make([]uint32, 0, len(ids))
168+
for id := range ids {
169+
seq = append(seq, id)
170+
}
171+
172+
slices.Sort(seq)
173+
174+
b := make([]byte, 0, 5*len(seq)) // 5 bytes is absolute max to encode an id
175+
var prevID uint32 = 0
176+
for _, id := range seq {
177+
diff := id - prevID
178+
prevID = id
179+
180+
// Unsigned LEB128 encoding.
181+
for diff > 0x7f {
182+
b = append(b, byte((diff&0x7f)|0x80))
183+
diff >>= 7
184+
}
185+
b = append(b, byte(diff&0x7f))
186+
}
187+
188+
return base64.StdEncoding.EncodeToString(b)
189+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed
2+
// under the Apache License Version 2.0.
3+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
// Copyright 2026 Datadog, Inc.
5+
6+
package openfeature
7+
8+
import (
9+
"crypto/sha256"
10+
"encoding/base64"
11+
"encoding/hex"
12+
"encoding/json"
13+
"fmt"
14+
"testing"
15+
16+
"github.com/stretchr/testify/assert"
17+
"github.com/stretchr/testify/require"
18+
)
19+
20+
func TestSpanEnrichment_GetSpanTags(t *testing.T) {
21+
se := NewSpanEnrichment()
22+
23+
sid42 := uint32(42)
24+
sid101 := uint32(101)
25+
se.AddEvaluation(&FeatureFlagEvaluation{FlagKey: "experiment-b", SerialID: &sid101})
26+
se.AddEvaluation(&FeatureFlagEvaluation{FlagKey: "experiment-a", SerialID: &sid42, Subject: "user-123"})
27+
se.AddEvaluation(&FeatureFlagEvaluation{FlagKey: "experiment-a-dup", SerialID: &sid42, Subject: "user-123"})
28+
se.AddEvaluation(&FeatureFlagEvaluation{FlagKey: "default-flag", DefaultValue: "default-val"})
29+
30+
tags := se.GetSpanTags()
31+
assert.Equal(t, []uint32{42, 101}, decodeSerialIDs(t, tags["ffe_flags_enc"]))
32+
33+
var subjects map[string]string
34+
require.NoError(t, json.Unmarshal([]byte(tags["ffe_subjects_enc"]), &subjects))
35+
user123HashBytes := sha256.Sum256([]byte("user-123"))
36+
user123Hash := hex.EncodeToString(user123HashBytes[:])
37+
assert.Equal(t, map[string]string{user123Hash: encodeSerialIDs(map[uint32]struct{}{42: {}})}, subjects)
38+
39+
var defaults map[string]string
40+
require.NoError(t, json.Unmarshal([]byte(tags["ffe_runtime_defaults"]), &defaults))
41+
assert.Equal(t, map[string]string{"default-flag": "default-val"}, defaults)
42+
}
43+
44+
func TestSpanEnrichment_OmitEmpty(t *testing.T) {
45+
se := NewSpanEnrichment()
46+
tags := se.GetSpanTags()
47+
assert.Empty(t, tags)
48+
}
49+
50+
func TestSpanEnrichment_Limits(t *testing.T) {
51+
t.Run("serial ids", func(t *testing.T) {
52+
se := NewSpanEnrichment()
53+
for i := range spanEnrichmentMaxSerialIDs + 1 {
54+
sid := uint32(i + 1)
55+
se.AddEvaluation(&FeatureFlagEvaluation{FlagKey: fmt.Sprintf("flag-%d", i), SerialID: &sid})
56+
}
57+
58+
ids := decodeSerialIDs(t, se.GetSpanTags()["ffe_flags_enc"])
59+
require.Len(t, ids, spanEnrichmentMaxSerialIDs)
60+
assert.Equal(t, uint32(spanEnrichmentMaxSerialIDs), ids[len(ids)-1])
61+
})
62+
63+
t.Run("subjects", func(t *testing.T) {
64+
se := NewSpanEnrichment()
65+
for i := range spanEnrichmentMaxSubjects + 1 {
66+
sid := uint32(i + 1)
67+
se.AddEvaluation(&FeatureFlagEvaluation{
68+
FlagKey: fmt.Sprintf("flag-%d", i),
69+
SerialID: &sid,
70+
Subject: fmt.Sprintf("user-%d", i),
71+
})
72+
}
73+
74+
var subjects map[string]string
75+
require.NoError(t, json.Unmarshal([]byte(se.GetSpanTags()["ffe_subjects_enc"]), &subjects))
76+
require.Len(t, subjects, spanEnrichmentMaxSubjects)
77+
})
78+
79+
t.Run("runtime defaults", func(t *testing.T) {
80+
se := NewSpanEnrichment()
81+
for i := range spanEnrichmentMaxRuntimeDefaults + 1 {
82+
se.AddEvaluation(&FeatureFlagEvaluation{FlagKey: fmt.Sprintf("flag-%d", i), DefaultValue: []int{i}})
83+
}
84+
85+
var defaults map[string]string
86+
require.NoError(t, json.Unmarshal([]byte(se.GetSpanTags()["ffe_runtime_defaults"]), &defaults))
87+
require.Len(t, defaults, spanEnrichmentMaxRuntimeDefaults)
88+
})
89+
}
90+
91+
func decodeSerialIDs(t *testing.T, enc string) []uint32 {
92+
t.Helper()
93+
94+
b, err := base64.StdEncoding.DecodeString(enc)
95+
require.NoError(t, err)
96+
97+
ids := make([]uint32, 0)
98+
var id uint32
99+
var shift uint
100+
var diff uint32
101+
for _, v := range b {
102+
diff |= uint32(v&0x7f) << shift
103+
if v&0x80 != 0 {
104+
shift += 7
105+
continue
106+
}
107+
id += diff
108+
ids = append(ids, id)
109+
diff = 0
110+
shift = 0
111+
}
112+
return ids
113+
}

0 commit comments

Comments
 (0)