Skip to content

Commit e5e23b0

Browse files
committed
openfeature: add span enrichment
1 parent 6a6154a commit e5e23b0

12 files changed

Lines changed: 970 additions & 8 deletions

ddtrace/tracer/span.go

Lines changed: 43 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/samplernames"
3839
"github.com/DataDog/dd-trace-go/v2/internal/stacktrace"
3940
"github.com/DataDog/dd-trace-go/v2/internal/telemetry"
@@ -949,6 +950,47 @@ func (s *Span) serializeSpanEvents() {
949950
s.meta.Set("events", string(b))
950951
}
951952

953+
// recordFFEEvaluation records a feature flag evaluation for FFE span enrichment.
954+
// Used by github.com/DataDog/dd-trace-go/v2/openfeature via go:linkname.
955+
//
956+
// Feature flag evaluations are collected while a request is in progress and
957+
// copied onto the root span as tags when the span finishes. The temporary
958+
// storage lives in internal/openfeature, but access to it must be coordinated
959+
// with the span lifecycle here.
960+
//
961+
// The OpenFeature hook reaches this function instead of writing to the store
962+
// directly because it cannot safely inspect or synchronize with Span.finished.
963+
// Holding s.mu serializes recording an evaluation with Finish draining the
964+
// stored evaluations. The s.finished check prevents recording evaluations after
965+
// the span has already finished; Finish drains the stored evaluations exactly
966+
// once while holding the same lock.
967+
func recordFFEEvaluation(s *Span, eval *iof.FeatureFlagEvaluation) {
968+
if s == nil || eval == nil {
969+
return
970+
}
971+
s.mu.Lock()
972+
defer s.mu.Unlock()
973+
974+
if s.finished {
975+
return
976+
}
977+
iof.AddSpanEnrichment(s, eval)
978+
}
979+
980+
// serializeFFEEvaluations writes the accumulated OpenFeature evaluation tags onto
981+
// the span and removes the pending FFE span enrichment from the temporary store.
982+
// +checklocks:s.mu
983+
func (s *Span) serializeFFEEvaluations() {
984+
assert.RWMutexLocked(&s.mu)
985+
enrichment := iof.DrainSpanEnrichment(s)
986+
if enrichment == nil {
987+
return
988+
}
989+
for tag, value := range enrichment.GetSpanTags() {
990+
s.meta.Set(tag, value)
991+
}
992+
}
993+
952994
// Finish closes this Span (but not its children) providing the duration
953995
// of its part of the tracing session.
954996
func (s *Span) Finish(opts ...FinishOption) {
@@ -1052,6 +1094,7 @@ func (s *Span) finish(finishTime int64) {
10521094

10531095
s.serializeSpanLinksInMeta()
10541096
s.serializeSpanEvents()
1097+
s.serializeFFEEvaluations()
10551098
s.enrichServiceSource()
10561099

10571100
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
@@ -399,6 +399,13 @@
399399
"default": "false"
400400
}
401401
],
402+
"DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED": [
403+
{
404+
"implementation": "A",
405+
"type": "boolean",
406+
"default": "false"
407+
}
408+
],
402409
"DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED": [
403410
{
404411
"implementation": "B",
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
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+
"strings"
15+
"sync"
16+
17+
"github.com/DataDog/dd-trace-go/v2/internal/log"
18+
)
19+
20+
// This file implements temporary span enrichment for feature flag evaluations.
21+
// Evaluations are collected while a traced request is in progress, stored here
22+
// keyed by the owning span, and drained by the tracer when that span finishes.
23+
// The drained enrichment is then encoded as span tags.
24+
//
25+
// The store is global rather than tracer-owned so evaluations survive tracer
26+
// swaps between evaluation and span finish. The key is intentionally typed as
27+
// any to avoid importing ddtrace/tracer and creating an import cycle; callers use
28+
// *ddtrace/tracer.Span as the key.
29+
//
30+
// SpanEnrichment is not safe for concurrent use. Callers must synchronize calls
31+
// to AddSpanEnrichment and DrainSpanEnrichment with the key owner's lifecycle
32+
// lock. For tracer spans, ddtrace/tracer/span.go holds Span.mu and checks
33+
// Span.finished before adding, then drains while holding the same lock during
34+
// Finish.
35+
36+
// FeatureFlagEvaluation represents a single feature flag evaluation to be
37+
// recorded in span enrichment.
38+
type FeatureFlagEvaluation struct {
39+
FlagKey string
40+
// SerialId is the optional serial ID from flag metadata. Nil for runtime defaults.
41+
SerialID *uint32
42+
// Subject is the targeting key / subject of experiment. Non-empty only when evaluation
43+
// should be logged (doLog=true).
44+
Subject string
45+
// DefaultValue is the default value used. Non-nil only when a runtime default was used.
46+
DefaultValue any
47+
}
48+
49+
const (
50+
spanEnrichmentMaxSerialIDs = 200
51+
spanEnrichmentMaxSubjects = 10
52+
spanEnrichmentMaxSerialIDsPerSubject = 20
53+
spanEnrichmentMaxRuntimeDefaults = 5
54+
spanEnrichmentMaxDefaultValueLen = 64
55+
)
56+
57+
// spanEnrichments stores pending span enrichments keyed by an owner
58+
// object (*ddtrace/tracer.Span).
59+
var spanEnrichments sync.Map
60+
61+
// AddSpanEnrichment records eval in the global span enrichment store.
62+
func AddSpanEnrichment(key any, eval *FeatureFlagEvaluation) {
63+
if key == nil || eval == nil {
64+
return
65+
}
66+
67+
// Two phase Load+LoadOrStore to avoid allocating a
68+
// SpanEnrichment on the hot path (entry already present) that
69+
// LoadOrStore would require.
70+
var enrichment *SpanEnrichment
71+
if actual, ok := spanEnrichments.Load(key); ok {
72+
enrichment = actual.(*SpanEnrichment)
73+
} else {
74+
e, _ := spanEnrichments.LoadOrStore(key, newSpanEnrichment())
75+
enrichment = e.(*SpanEnrichment)
76+
}
77+
enrichment.addEvaluation(eval)
78+
}
79+
80+
// DrainSpanEnrichment removes and returns enrichment from the global span
81+
// enrichment store.
82+
func DrainSpanEnrichment(key any) *SpanEnrichment {
83+
if key == nil {
84+
return nil
85+
}
86+
v, ok := spanEnrichments.LoadAndDelete(key)
87+
if !ok {
88+
return nil
89+
}
90+
return v.(*SpanEnrichment)
91+
}
92+
93+
// SpanEnrichment accumulates feature flag evaluations to be encoded as span tags.
94+
//
95+
// SpanEnrichment is not safe for concurrent use on its own. Thread safety is
96+
// provided externally: every caller must hold the owning span's mu
97+
// (ddtrace/tracer.Span.mu) before calling any method.
98+
type SpanEnrichment struct {
99+
serialIDs map[uint32]struct{}
100+
subjects map[string]map[uint32]struct{}
101+
runtimeDefaults map[string]string
102+
}
103+
104+
func newSpanEnrichment() *SpanEnrichment {
105+
return &SpanEnrichment{
106+
serialIDs: make(map[uint32]struct{}),
107+
subjects: make(map[string]map[uint32]struct{}),
108+
runtimeDefaults: make(map[string]string),
109+
}
110+
}
111+
112+
// addEvaluation records a single feature flag evaluation.
113+
// The caller must hold the owning span's mu.
114+
func (se *SpanEnrichment) addEvaluation(eval *FeatureFlagEvaluation) {
115+
if eval == nil {
116+
return
117+
}
118+
119+
if eval.SerialID != nil {
120+
se.addSerialID(*eval.SerialID)
121+
if eval.Subject != "" {
122+
se.addSubject(eval.Subject, *eval.SerialID)
123+
}
124+
} else if eval.DefaultValue != nil {
125+
se.addRuntimeDefault(eval.FlagKey, eval.DefaultValue)
126+
}
127+
}
128+
129+
// Must be called with the owning span's mu held.
130+
func (se *SpanEnrichment) addSerialID(sid uint32) {
131+
if _, exists := se.serialIDs[sid]; !exists {
132+
if len(se.serialIDs) >= spanEnrichmentMaxSerialIDs {
133+
log.Debug("openfeature: span enrichment: too many flag serial IDs, dropping (max %d)", spanEnrichmentMaxSerialIDs)
134+
return
135+
}
136+
se.serialIDs[sid] = struct{}{}
137+
}
138+
}
139+
140+
// Must be called with the owning span's mu held.
141+
func (se *SpanEnrichment) addSubject(subject string, sid uint32) {
142+
subjectIDs, ok := se.subjects[subject]
143+
if !ok {
144+
if len(se.subjects) >= spanEnrichmentMaxSubjects {
145+
log.Debug("openfeature: span enrichment: too many targeting keys, dropping (max %d)", spanEnrichmentMaxSubjects)
146+
return
147+
}
148+
subjectIDs = make(map[uint32]struct{})
149+
se.subjects[subject] = subjectIDs
150+
}
151+
if len(subjectIDs) >= spanEnrichmentMaxSerialIDsPerSubject {
152+
log.Debug("openfeature: span enrichment: too many experiments for subject %q, dropping (max %d)", subject, spanEnrichmentMaxSerialIDsPerSubject)
153+
return
154+
}
155+
subjectIDs[sid] = struct{}{}
156+
}
157+
158+
// Must be called with the owning span's mu held.
159+
func (se *SpanEnrichment) addRuntimeDefault(flagKey string, defaultValue any) {
160+
if _, exists := se.runtimeDefaults[flagKey]; exists {
161+
return
162+
}
163+
if len(se.runtimeDefaults) >= spanEnrichmentMaxRuntimeDefaults {
164+
log.Debug("openfeature: span enrichment: too many runtime defaults, dropping (max %d)", spanEnrichmentMaxRuntimeDefaults)
165+
return
166+
}
167+
var valueStr string
168+
if v, ok := defaultValue.(string); ok {
169+
valueStr = v
170+
} else if b, err := json.Marshal(defaultValue); err == nil {
171+
valueStr = string(b)
172+
} else {
173+
log.Debug("openfeature: span enrichment: failed to marshal runtime default value for key %q: %v", flagKey, err.Error())
174+
return
175+
}
176+
if len(valueStr) > spanEnrichmentMaxDefaultValueLen {
177+
log.Debug("openfeature: span enrichment: runtime default value for key %q exceeds max length (%d), truncating", flagKey, spanEnrichmentMaxDefaultValueLen)
178+
valueStr = valueStr[:spanEnrichmentMaxDefaultValueLen]
179+
}
180+
se.runtimeDefaults[flagKey] = strings.ToValidUTF8(valueStr, "")
181+
}
182+
183+
// GetSpanTags returns span tags encoding accumulated feature flag evaluations.
184+
// The caller must hold the owning span's mu.
185+
func (se *SpanEnrichment) GetSpanTags() map[string]string {
186+
tags := make(map[string]string, 3)
187+
188+
if len(se.serialIDs) > 0 {
189+
tags["ffe_flags_enc"] = encodeSerialIDs(se.serialIDs)
190+
}
191+
192+
if len(se.subjects) > 0 {
193+
subjects := make(map[string]string, len(se.subjects))
194+
for key, ids := range se.subjects {
195+
sum := sha256.Sum256([]byte(key))
196+
hashKey := hex.EncodeToString(sum[:])
197+
subjects[hashKey] = encodeSerialIDs(ids)
198+
}
199+
if b, err := json.Marshal(subjects); err == nil {
200+
tags["ffe_subjects_enc"] = string(b)
201+
} else {
202+
log.Debug("openfeature: span enrichment: failed to marshal subjects: %v", err.Error())
203+
}
204+
}
205+
206+
if len(se.runtimeDefaults) > 0 {
207+
defaults := se.runtimeDefaults
208+
if b, err := json.Marshal(defaults); err == nil {
209+
tags["ffe_runtime_defaults"] = string(b)
210+
} else {
211+
log.Debug("openfeature: span enrichment: failed to marshal runtime defaults: %v", err.Error())
212+
}
213+
}
214+
215+
return tags
216+
}
217+
218+
// Encode a set of serial ids using unsigned LEB128 delta encoding, wrapped in base64.
219+
func encodeSerialIDs(ids map[uint32]struct{}) string {
220+
seq := make([]uint32, 0, len(ids))
221+
for id := range ids {
222+
seq = append(seq, id)
223+
}
224+
225+
slices.Sort(seq)
226+
227+
b := make([]byte, 0, 5*len(seq)) // 5 bytes is absolute max to encode an id
228+
var prevID uint32 = 0
229+
for _, id := range seq {
230+
diff := id - prevID
231+
prevID = id
232+
233+
// Unsigned LEB128 encoding.
234+
for diff > 0x7f {
235+
b = append(b, byte((diff&0x7f)|0x80))
236+
diff >>= 7
237+
}
238+
b = append(b, byte(diff&0x7f))
239+
}
240+
241+
return base64.StdEncoding.EncodeToString(b)
242+
}

0 commit comments

Comments
 (0)