Skip to content

Commit 4c3c5dc

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

11 files changed

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

openfeature/evaluator.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ type evaluationResult struct {
3434
Metadata map[string]any
3535
}
3636

37+
const (
38+
metadataAllocationKey = "dd.allocation.key"
39+
metadataDoLogKey = "dd.doLog"
40+
metadataSerialIDKey = "dd.serialId"
41+
)
42+
3743
// evaluateFlag evaluates a feature flag with the given context.
3844
// It returns the variant value, reason, and any error that occurred.
3945
func evaluateFlag(flag *flag, defaultValue any, context map[string]any) evaluationResult {
@@ -90,6 +96,10 @@ func evaluateFlag(flag *flag, defaultValue any, context map[string]any) evaluati
9096
}
9197
metadata[metadataDoLogKey] = doLog
9298

99+
if split.SerialID != nil {
100+
metadata[metadataSerialIDKey] = *split.SerialID
101+
}
102+
93103
// Determine reason:
94104
// rules matched → TARGETING_MATCH
95105
// no rules, shards used → SPLIT

0 commit comments

Comments
 (0)