Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
0d3a4d2
test(01-01): add failing test stubs + signature-only production stubs…
leoromanovsky Jun 12, 2026
495f4c7
feat(01-01): add BenchmarkFlagEvaluationNoop/OTelOnly/OTelPlusEVP ben…
leoromanovsky Jun 12, 2026
5dac07f
feat(01-02): implement flagEvaluationAggregator + writer + transport …
leoromanovsky Jun 12, 2026
35d5d9e
feat(01-02): implement flagEvaluationHook Finally stage + extraction …
leoromanovsky Jun 12, 2026
4b44f57
feat(01-02): wire EVP flagevaluation hook + writer into provider (Tas…
leoromanovsky Jun 12, 2026
4b75012
feat(01-03): wire real EVP hook in OTelPlusEVP benchmark (no-flush wr…
leoromanovsky Jun 12, 2026
83848c3
fix(openfeature): deterministic context hash + drop dead targeting_ru…
leoromanovsky Jun 12, 2026
4dd9408
fix(openfeature): count cancelled-context evaluations in Finally hook…
leoromanovsky Jun 12, 2026
58a96c3
fix(openfeature): rotate benchmark targeting key per iteration (WR-04)
leoromanovsky Jun 12, 2026
ff23b7c
chore(env): set DD_FLAGGING_EVALUATION_COUNTS_ENABLED type/default an…
leoromanovsky Jun 12, 2026
81da9e1
fix(openfeature): preserve count signal on global-cap overflow; align…
leoromanovsky Jun 12, 2026
cf520aa
refactor(openfeature): async best-effort EVP flagevaluation recording…
leoromanovsky Jun 12, 2026
140d657
fix(openfeature): correct EVP flagevaluation context hashing, ultra-d…
leoromanovsky Jun 12, 2026
439029a
fix(openfeature): derive runtime-default from absent variant only (PR…
leoromanovsky Jun 12, 2026
20f8b9c
test(openfeature): table-ify EVP flagevaluation tests and benches
leoromanovsky Jun 12, 2026
432002c
refactor(openfeature): tighten EVP flagevaluation writer/hook in place
leoromanovsky Jun 12, 2026
0e12a5c
feat(openfeature): 2-tier EVP aggregation (full -> degraded -> drop),…
leoromanovsky Jun 12, 2026
a699b77
test(openfeature): add 2,500-flag scale + parallel benchmarks and agg…
leoromanovsky Jun 12, 2026
80824d9
test(openfeature): modernize range-over-int loops in EVP scale harnes…
leoromanovsky Jun 12, 2026
1c648c4
fix(openfeature): use comparable canonical-context key for EVP full-t…
leoromanovsky Jun 12, 2026
4fad72f
perf(openfeature): merge flatten+prune into one EVP worker-path trave…
leoromanovsky Jun 12, 2026
5422dfe
feat(openfeature): record EVP eval-time from provider metadata, not h…
leoromanovsky Jun 12, 2026
e2441ed
fix(openfeature): align EVP flagevaluation keys with worker schema
leoromanovsky Jun 16, 2026
2329fca
chore(openfeature): mark flagevaluation schema validator direct
leoromanovsky Jun 16, 2026
273cf23
openfeature: drop copied flageval schema
leoromanovsky Jun 16, 2026
b2a93e4
ci: isolate new flagevaluation benchmarks
leoromanovsky Jun 16, 2026
5e9b8b0
refactor: share openfeature evp transport
leoromanovsky Jun 16, 2026
5b75720
Disambiguate flag evaluation context fallback types
leoromanovsky Jun 17, 2026
924712f
Address flag evaluation benchmark review feedback
leoromanovsky Jun 17, 2026
57232d8
fix(openfeature): use singular flagevaluation EVP endpoint
leoromanovsky Jun 19, 2026
a597556
chore(openfeature): document flag evaluation cap sizing
leoromanovsky Jun 19, 2026
218c409
test(openfeature): cover out-of-order evaluation timestamps
leoromanovsky Jun 19, 2026
b1d4ea5
refactor(openfeature): clarify evaluation timestamp naming
leoromanovsky Jun 19, 2026
01e9672
Merge remote-tracking branch 'origin/main' into workspace/flag-evalua…
leoromanovsky Jun 19, 2026
f86a222
Merge remote-tracking branch 'origin/main' into workspace/flag-evalua…
leoromanovsky Jun 19, 2026
d1c04c8
Limit OpenFeature benchmark gate to baseline-backed cases
leoromanovsky Jun 20, 2026
3fb4e80
fix(openfeature): bound flagevaluation enqueue work
leoromanovsky Jun 20, 2026
7b43059
fix(openfeature): reduce flagevaluation hot-path work
leoromanovsky Jun 20, 2026
f2a6df0
fix(openfeature): keep flagevaluation benchmarks informational
leoromanovsky Jun 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/env/supported_configurations.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions internal/env/supported_configurations.json
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,13 @@
"default": null
}
],
"DD_FLAGGING_EVALUATION_COUNTS_ENABLED": [
{
"implementation": "A",
"type": "boolean",
"default": "true"
}
],
"DD_GIT_BRANCH": [
{
"implementation": "A",
Expand Down
12 changes: 7 additions & 5 deletions openfeature/evaluator.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,12 @@ type evaluationResult struct {
Metadata map[string]any
}

// evaluateFlag evaluates a feature flag with the given context.
// evaluateFlag evaluates a feature flag with the given context. The caller supplies the
// evaluation time (now) so a single timestamp is shared between the allocation time-window
// checks here and the EVP eval-time metadata stamped by DatadogProvider.evaluate — avoiding a
// second time.Now() on the evaluation path.
// It returns the variant value, reason, and any error that occurred.
func evaluateFlag(flag *flag, defaultValue any, context map[string]any) evaluationResult {
func evaluateFlag(flag *flag, defaultValue any, context map[string]any, now time.Time) evaluationResult {
if flag == nil {
return evaluationResult{Value: defaultValue, Reason: of.DefaultReason}
}
Expand All @@ -48,8 +51,7 @@ func evaluateFlag(flag *flag, defaultValue any, context map[string]any) evaluati
}
}

// Evaluate allocations in order - first match wins
now := time.Now()
// Evaluate allocations in order - first match wins (using the caller-supplied eval time)
for _, allocation := range flag.Allocations {
split, matched, err := evaluateAllocation(allocation, context, now)
if err != nil {
Expand Down Expand Up @@ -80,7 +82,7 @@ func evaluateFlag(flag *flag, defaultValue any, context map[string]any) evaluati
}

// Build metadata for exposure tracking
metadata := make(map[string]any)
metadata := make(map[string]any, 3)
metadata[metadataAllocationKey] = allocation.Key

// Get doLog value (defaults to true if not specified)
Expand Down
4 changes: 2 additions & 2 deletions openfeature/evaluator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -703,7 +703,7 @@ func TestEvaluateFlag_VariantTypeMismatchReturnsParseError(t *testing.T) {
},
}

result := evaluateFlag(flag, nil, map[string]any{"targetingKey": "user-123"})
result := evaluateFlag(flag, nil, map[string]any{"targetingKey": "user-123"}, time.Now())

if result.Reason != of.ErrorReason {
t.Errorf("expected ErrorReason, got %s", result.Reason)
Expand Down Expand Up @@ -768,7 +768,7 @@ func TestEvaluateFlag_JSONFixtures(t *testing.T) {
ctx["targetingKey"] = *tc.TargetingKey
}

result := evaluateFlag(cfg.Flags[tc.Flag], tc.DefaultValue, ctx)
result := evaluateFlag(cfg.Flags[tc.Flag], tc.DefaultValue, ctx, time.Now())

if fmt.Sprintf("%v", result.Value) != fmt.Sprintf("%v", tc.Result.Value) {
t.Errorf("value: got %v, want %v", result.Value, tc.Result.Value)
Expand Down
81 changes: 81 additions & 0 deletions openfeature/evp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// 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 openfeature

import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/url"

jsoniter "github.com/json-iterator/go"

"github.com/DataDog/dd-trace-go/v2/internal"
"github.com/DataDog/dd-trace-go/v2/internal/log"
)

type evpClient struct {
httpClient *http.Client
agentURL *url.URL
jsonConfig jsoniter.API
}

func newEVPClient() *evpClient {
agentURL := internal.AgentURLFromEnv()
var httpClient *http.Client
if agentURL.Scheme == "unix" {
httpClient = internal.UDSClient(agentURL.Path, defaultHTTPTimeout)
agentURL = internal.UnixDataSocketURL(agentURL.Path)
} else {
httpClient = internal.DefaultHTTPClient(defaultHTTPTimeout, false)
}

return &evpClient{
httpClient: httpClient,
agentURL: agentURL,
jsonConfig: jsoniter.Config{}.Froze(),
}
}

func (c *evpClient) post(endpoint, eventName string, payload any) error {
if c == nil {
return fmt.Errorf("EVP client is not configured")
}

var bytesBuffer bytes.Buffer
encoder := c.jsonConfig.NewEncoder(&bytesBuffer)
if err := encoder.Encode(payload); err != nil {
return fmt.Errorf("failed to encode %s payload: %w", eventName, err)
}

u := *c.agentURL
u.Path = endpoint
requestURL := u.String()

req, err := http.NewRequestWithContext(context.Background(), "POST", requestURL, &bytesBuffer)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}

req.Header.Set("Content-Type", "application/json")
req.Header.Set(evpSubdomainHeader, evpSubdomainValue)

log.Debug("openfeature: sending %s events to %s", eventName, requestURL)

resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
return fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body))
}
return nil
}
67 changes: 6 additions & 61 deletions openfeature/exposure.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,13 @@
package openfeature

import (
"bytes"
"cmp"
"container/list"
"context"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"sync"
"time"

jsoniter "github.com/json-iterator/go"

"github.com/DataDog/dd-trace-go/v2/internal"
"github.com/DataDog/dd-trace-go/v2/internal/env"
"github.com/DataDog/dd-trace-go/v2/internal/globalconfig"
"github.com/DataDog/dd-trace-go/v2/internal/log"
Expand Down Expand Up @@ -176,36 +167,27 @@ type exposureWriter struct {
buffer []exposureEvent // Buffer for exposure events
cache *exposureLRUCache // LRU deduplication cache
flushInterval time.Duration
httpClient *http.Client
agentURL *url.URL
evp *evpClient
context exposureContext
ticker *time.Ticker
stopChan chan struct{}
stopped bool
jsonConfig jsoniter.API
}

// newExposureWriter creates a new exposure writer with the given configuration
func newExposureWriter(config ProviderConfig) *exposureWriter {
agentURL := internal.AgentURLFromEnv()
var httpClient *http.Client
if agentURL.Scheme == "unix" {
httpClient = internal.UDSClient(agentURL.Path, defaultHTTPTimeout)
agentURL = internal.UnixDataSocketURL(agentURL.Path)
} else {
httpClient = internal.DefaultHTTPClient(defaultHTTPTimeout, false)
}
return newExposureWriterWithEVP(config, newEVPClient())
}

func newExposureWriterWithEVP(config ProviderConfig, evp *evpClient) *exposureWriter {
executable, _ := os.Executable()

return &exposureWriter{
buffer: make([]exposureEvent, 0, 1<<8), // Initial capacity of 256
cache: newExposureLRUCache(defaultExposureCacheCapacity),
flushInterval: cmp.Or(config.ExposureFlushInterval, defaultExposureFlushInterval),
httpClient: httpClient,
agentURL: agentURL,
evp: evp,
stopChan: make(chan struct{}),
jsonConfig: jsoniter.Config{}.Froze(),
context: exposureContext{
Service: cmp.Or(env.Get("DD_SERVICE"), globalconfig.ServiceName(), executable),
Version: env.Get("DD_VERSION"),
Expand Down Expand Up @@ -298,44 +280,7 @@ func (w *exposureWriter) flush() {

// sendToAgent sends the exposure payload to the Datadog Agent via EVP proxy
func (w *exposureWriter) sendToAgent(payload exposurePayload) error {
// Serialize payload
var bytesBuffer bytes.Buffer
encoder := w.jsonConfig.NewEncoder(&bytesBuffer)
if err := encoder.Encode(payload); err != nil {
return fmt.Errorf("failed to encode exposure payload: %w", err)
}

// Build request URL
u := *w.agentURL
u.Path = exposureEndpoint
requestURL := u.String()

// Create HTTP request
req, err := http.NewRequestWithContext(context.Background(), "POST", requestURL, &bytesBuffer)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}

// Set headers
req.Header.Set("Content-Type", "application/json")
req.Header.Set(evpSubdomainHeader, evpSubdomainValue)

log.Debug("openfeature: sending exposure events to %s", requestURL)

// Send request
resp, err := w.httpClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()

// Check response status
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
return fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body))
}

return nil
return w.evp.post(exposureEndpoint, "exposure", payload)
}

// stop stops the exposure writer and flushes any remaining events
Expand Down
3 changes: 3 additions & 0 deletions openfeature/exposure_hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ const (
// Metadata keys for exposure tracking
metadataAllocationKey = "dd.allocation.key"
metadataDoLogKey = "dd.doLog"
// metadataEvalTimeKey carries the evaluation timestamp (UnixMilli, int64). It is stamped in
// DatadogProvider.evaluate at evaluation entry so EVP first/last bounds use eval-time.
metadataEvalTimeKey = "dd.eval.timestamp_ms"
)

// exposureHook implements the OpenFeature Hook interface to track feature flag exposures.
Expand Down
Loading
Loading