Skip to content

Commit f54cb51

Browse files
authored
feat(openfeature): add datadog openfeature exposure (#15073)
## Description This PR adds **Feature Flag Exposure reporting** to the Datadog OpenFeature Provider, along with centralized configuration management for OpenFeature settings. ### Key Changes #### **Feature Flag Exposure Reporting** - Implements exposure event tracking for feature flag evaluations - Sends exposure events to Datadog Agent's EVP proxy endpoint (`/evp_proxy/v2/api/v2/exposures`) - Tracks flag key, variant, allocation, and user context for each evaluation - Includes automatic retry mechanism with fibonacci backoff #### **Centralized Configuration System** Created [ddtrace/settings/openfeature.py](cci:7://file:///home/alberto.vara/projects/dd-python/dd-trace-py/ddtrace/settings/openfeature.py:0:0-0:0) with three new environment variables: - **`DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED`** (default: `false`) - Controls whether the experimental OpenFeature provider is enabled - Must be explicitly enabled to use the provider - **`DD_FFE_INTAKE_ENABLED`** (default: `true`) - Enables/disables exposure event reporting to Datadog - Allows disabling telemetry without disabling the provider - **`DD_FFE_INTAKE_HEARTBEAT_INTERVAL`** (default: `1.0`) - Controls the flush interval for exposure events in seconds - Configurable for performance tuning ## Additional Notes Related PR: #15051
1 parent e4d2c2f commit f54cb51

19 files changed

+1923
-121
lines changed
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""
2-
Feature Flagging and Experimentation (FFAndE) product module.
2+
Feature Flagging and Experimentation (FFE) product module.
33
44
This module handles Feature Flag configuration rules from Remote Configuration
5-
and forwards the raw bytes to the native FFAndE processor.
5+
and forwards the raw bytes to the native FFE processor.
66
"""
Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1-
import contextvars
2-
from typing import Optional
1+
from typing import Mapping
32

43

5-
FFE_CONFIG: contextvars.ContextVar[Optional[int]] = contextvars.ContextVar("ffe_config", default=None)
4+
FFE_CONFIG: Mapping = {}
65

76

87
def _get_ffe_config():
98
"""Retrieve the current IAST context identifier from the ContextVar."""
10-
return FFE_CONFIG.get()
9+
return FFE_CONFIG
1110

1211

1312
def _set_ffe_config(data):
13+
global FFE_CONFIG
1414
"""Retrieve the current IAST context identifier from the ContextVar."""
15-
return FFE_CONFIG.set(data)
15+
FFE_CONFIG = data
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""
2+
Exposure event building for Feature Flag evaluation results.
3+
"""
4+
5+
import time
6+
from typing import Any
7+
from typing import Dict
8+
from typing import Optional
9+
10+
from openfeature.evaluation_context import EvaluationContext
11+
12+
from ddtrace.internal.logger import get_logger
13+
from ddtrace.internal.openfeature.writer import ExposureEvent
14+
15+
16+
logger = get_logger(__name__)
17+
18+
19+
def build_exposure_event(
20+
flag_key: str,
21+
variant_key: Optional[str],
22+
allocation_key: Optional[str],
23+
evaluation_context: Optional[EvaluationContext],
24+
) -> Optional[ExposureEvent]:
25+
"""
26+
Build an exposure event that will be batched and sent with context.
27+
28+
Individual events are collected and sent in batches with shared context
29+
(service, env, version) to the EVP proxy intake endpoint.
30+
31+
Args:
32+
flag_key: The feature flag key
33+
variant_key: The variant key returned by the evaluation
34+
allocation_key: The allocation key (same as variant_key in basic cases)
35+
evaluation_context: The evaluation context with subject information
36+
"""
37+
# Validate required fields
38+
if not flag_key:
39+
logger.debug("Cannot build exposure event: flag_key is required")
40+
return None
41+
42+
if variant_key is None:
43+
variant_key = ""
44+
45+
# Build subject from evaluation context
46+
subject = _build_subject(evaluation_context)
47+
if not subject:
48+
logger.debug("Cannot build exposure event: valid subject is required")
49+
return None
50+
51+
# Use variant_key as allocation_key if not explicitly provided
52+
if allocation_key is None:
53+
allocation_key = variant_key
54+
55+
# Build the exposure event
56+
exposure_event: ExposureEvent = {
57+
"timestamp": int(time.time() * 1000), # milliseconds since epoch
58+
"allocation": {"key": allocation_key},
59+
"flag": {"key": flag_key},
60+
"variant": {"key": variant_key},
61+
"subject": subject,
62+
}
63+
64+
return exposure_event
65+
66+
67+
def _build_subject(evaluation_context: Optional[EvaluationContext]) -> Optional[Dict[str, Any]]:
68+
"""
69+
Build subject object from OpenFeature EvaluationContext.
70+
71+
The subject must have at minimum an 'id' field.
72+
73+
Args:
74+
evaluation_context: The OpenFeature evaluation context
75+
76+
Returns:
77+
Dictionary with subject information, or None if id cannot be determined
78+
"""
79+
if evaluation_context is None:
80+
return None
81+
82+
# Get targeting_key as the subject id
83+
subject_id = evaluation_context.targeting_key
84+
if not subject_id:
85+
logger.debug("evaluation_context missing targeting_key for subject.id")
86+
return None
87+
88+
subject: Dict[str, Any] = {"id": subject_id}
89+
90+
# Add optional subject type if available in attributes
91+
attributes = evaluation_context.attributes or {}
92+
if "subject_type" in attributes:
93+
subject["type"] = str(attributes["subject_type"])
94+
95+
# Add remaining attributes (excluding subject_type which we already handled)
96+
remaining_attrs = {k: v for k, v in attributes.items() if k != "subject_type"}
97+
if remaining_attrs:
98+
subject["attributes"] = remaining_attrs
99+
100+
return subject

ddtrace/internal/openfeature/_ffe_mock.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,7 @@ def mock_get_assignment(
9797
if not flag or not flag.get("enabled", True):
9898
return None
9999

100-
# Handle both enum objects and string values for variation_type
101-
variation_type_raw = flag["variation_type"]
100+
variation_type_raw = flag["variationType"]
102101
if isinstance(variation_type_raw, str):
103102
found_type = VariationType(variation_type_raw)
104103
else:
@@ -111,15 +110,13 @@ def mock_get_assignment(
111110
found=found_type,
112111
)
113112

114-
# Handle both enum objects and string values for reason
115113
reason_raw = flag.get("reason", AssignmentReason.STATIC)
116114
if isinstance(reason_raw, str):
117115
reason = AssignmentReason(reason_raw)
118116
else:
119117
reason = reason_raw
120118

121-
# Build assignment
122-
value = flag["value"]
119+
value = list(flag["variations"].values())[0]["value"]
123120
assignment_value = AssignmentValue(variation_type=found_type, value=value)
124121
return Assignment(
125122
value=assignment_value,

ddtrace/internal/openfeature/_native.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
# Provide a no-op fallback
2323
def ffande_process_config(config_bytes: bytes) -> Optional[bool]:
2424
"""Fallback implementation when native module is not available."""
25-
log.warning("FFAndE native module not available, ignoring configuration")
25+
log.warning("FFE native module not available, ignoring configuration")
2626
return None
2727

2828

@@ -47,5 +47,5 @@ def process_ffe_configuration(config_bytes: bytes) -> bool:
4747
return False
4848
return result
4949
except Exception as e:
50-
log.error("Error processing FFE configuration: %s", e, exc_info=True)
50+
log.debug("Error processing FFE configuration: %s", e, exc_info=True)
5151
return False

ddtrace/internal/openfeature/_provider.py

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
"""
2-
Feature Flagging and Experimentation (FFAndE) product module.
2+
Feature Flagging and Experimentation (FFE) product module.
33
44
This module handles Feature Flag configuration rules from Remote Configuration
5-
and forwards the raw bytes to the native FFAndE processor.
5+
and forwards the raw bytes to the native FFE processor.
66
"""
77

88
import datetime
@@ -16,13 +16,20 @@
1616
from openfeature.flag_evaluation import Reason
1717
from openfeature.provider import Metadata
1818

19+
from ddtrace.internal.logger import get_logger
1920
from ddtrace.internal.openfeature._config import _get_ffe_config
21+
from ddtrace.internal.openfeature._exposure import build_exposure_event
2022
from ddtrace.internal.openfeature._ffe_mock import AssignmentReason
2123
from ddtrace.internal.openfeature._ffe_mock import EvaluationError
2224
from ddtrace.internal.openfeature._ffe_mock import VariationType
2325
from ddtrace.internal.openfeature._ffe_mock import mock_get_assignment
2426
from ddtrace.internal.openfeature._remoteconfiguration import disable_featureflags_rc
2527
from ddtrace.internal.openfeature._remoteconfiguration import enable_featureflags_rc
28+
from ddtrace.internal.openfeature.writer import get_exposure_writer
29+
from ddtrace.internal.openfeature.writer import start_exposure_writer
30+
from ddtrace.internal.openfeature.writer import stop_exposure_writer
31+
from ddtrace.internal.service import ServiceStatusError
32+
from ddtrace.settings.openfeature import config as ffe_config
2633

2734

2835
# Handle different import paths between openfeature-sdk versions
@@ -35,20 +42,29 @@
3542

3643

3744
T = typing.TypeVar("T", covariant=True)
45+
logger = get_logger(__name__)
3846

3947

4048
class DataDogProvider(AbstractProvider):
4149
"""
4250
Datadog OpenFeature Provider.
4351
4452
Implements the OpenFeature provider interface for Datadog's
45-
Feature Flags and Experimentation (FFAndE) product.
53+
Feature Flags and Experimentation (FFE) product.
4654
"""
4755

4856
def __init__(self, *args: typing.Any, **kwargs: typing.Any):
4957
super().__init__(*args, **kwargs)
5058
self._metadata = Metadata(name="Datadog")
5159

60+
# Check if experimental flagging provider is enabled
61+
self._enabled = ffe_config.experimental_flagging_provider_enabled
62+
if not self._enabled:
63+
logger.warning(
64+
"openfeature: experimental flagging provider is not enabled, "
65+
"please set DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED=true to enable it",
66+
)
67+
5268
def get_metadata(self) -> Metadata:
5369
"""Returns provider metadata."""
5470
return self._metadata
@@ -59,15 +75,32 @@ def initialize(self, evaluation_context: EvaluationContext) -> None:
5975
6076
Called by the OpenFeature SDK when the provider is set.
6177
"""
78+
if not self._enabled:
79+
return
80+
6281
enable_featureflags_rc()
6382

83+
try:
84+
# Start the exposure writer for reporting
85+
start_exposure_writer()
86+
except ServiceStatusError:
87+
logger.debug("Exposure writer is already running", exc_info=True)
88+
6489
def shutdown(self) -> None:
6590
"""
6691
Shutdown the provider and disable remote configuration.
6792
6893
Called by the OpenFeature SDK when the provider is being replaced or shutdown.
6994
"""
95+
if not self._enabled:
96+
return
97+
7098
disable_featureflags_rc()
99+
try:
100+
# Stop the exposure writer
101+
stop_exposure_writer()
102+
except ServiceStatusError:
103+
logger.debug("Exposure writer has already stopped", exc_info=True)
71104

72105
def resolve_boolean_details(
73106
self,
@@ -124,6 +157,14 @@ def _resolve_details(
124157
- Returns default value with DEFAULT reason when flag not found
125158
- Returns error with error_code and error_message on errors
126159
"""
160+
# If provider is not enabled, return default value
161+
if not self._enabled:
162+
return FlagResolutionDetails(
163+
value=default_value,
164+
reason=Reason.DISABLED,
165+
variant=None,
166+
)
167+
127168
try:
128169
config_raw = _get_ffe_config()
129170
# Parse JSON config if it's a string
@@ -142,6 +183,12 @@ def _resolve_details(
142183

143184
# Flag not found or disabled - return default
144185
if result is None:
186+
self._report_exposure(
187+
flag_key=flag_key,
188+
variant_key=None,
189+
allocation_key=None,
190+
evaluation_context=evaluation_context,
191+
)
145192
return FlagResolutionDetails(
146193
value=default_value,
147194
reason=Reason.DEFAULT,
@@ -156,6 +203,14 @@ def _resolve_details(
156203
}
157204
reason = reason_map.get(result.reason, Reason.UNKNOWN)
158205

206+
# Report exposure event
207+
self._report_exposure(
208+
flag_key=flag_key,
209+
variant_key=result.variation_key,
210+
allocation_key=result.variation_key,
211+
evaluation_context=evaluation_context,
212+
)
213+
159214
# Success - return resolved value
160215
return FlagResolutionDetails(
161216
value=result.value.value,
@@ -187,3 +242,27 @@ def _resolve_details(
187242
error_code=ErrorCode.GENERAL,
188243
error_message=f"Unexpected error during flag evaluation: {str(e)}",
189244
)
245+
246+
def _report_exposure(
247+
self,
248+
flag_key: str,
249+
variant_key: typing.Optional[str],
250+
allocation_key: typing.Optional[str],
251+
evaluation_context: typing.Optional[EvaluationContext],
252+
) -> None:
253+
"""
254+
Report a feature flag exposure event to the EVP proxy intake.
255+
"""
256+
try:
257+
exposure_event = build_exposure_event(
258+
flag_key=flag_key,
259+
variant_key=variant_key,
260+
allocation_key=allocation_key,
261+
evaluation_context=evaluation_context,
262+
)
263+
264+
if exposure_event:
265+
writer = get_exposure_writer()
266+
writer.enqueue(exposure_event)
267+
except Exception as e:
268+
logger.debug("Failed to report exposure event: %s", e, exc_info=True)

ddtrace/internal/openfeature/_remoteconfiguration.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
"""
2-
FFAndE (Feature Flagging and Experimentation) product implementation.
2+
FFE (Feature Flagging and Experimentation) product implementation.
33
44
This product receives feature flag configuration rules from Remote Configuration
5-
and processes them through the native FFAndE processor.
5+
and processes them through the native FFE processor.
66
"""
77
import enum
88
import json
@@ -24,15 +24,15 @@
2424
FFE_FLAGS_PRODUCT = "FFE_FLAGS"
2525

2626

27-
class FFAndECapabilities(enum.IntFlag):
28-
"""FFAndE Remote Configuration capabilities."""
27+
class FFECapabilities(enum.IntFlag):
28+
"""FFE Remote Configuration capabilities."""
2929

3030
FFE_FLAG_CONFIGURATION_RULES = 1 << 46
3131

3232

33-
class FFAndEAdapter(PubSub):
33+
class FFEAdapter(PubSub):
3434
"""
35-
FFAndE Remote Configuration adapter.
35+
FFE Remote Configuration adapter.
3636
3737
Receives feature flag configuration rules and forwards raw bytes to native processor.
3838
"""
@@ -78,17 +78,17 @@ def featureflag_rc_callback(payloads: t.Sequence[Payload]) -> None:
7878
mock_process_ffe_configuration(payload.content)
7979
log.debug("Processing FFE config ID: %s, size: %d bytes", payload.metadata.id, len(config_bytes))
8080
except Exception as e:
81-
log.error("Error processing FFE config payload: %s", e, exc_info=True)
81+
log.debug("Error processing FFE config payload: %s", e, exc_info=True)
8282

8383

8484
def enable_featureflags_rc() -> None:
8585
log.debug("[%s][P: %s] Register ASM Remote Config Callback", os.getpid(), os.getppid())
86-
feature_flag_rc = FFAndEAdapter(featureflag_rc_callback)
86+
feature_flag_rc = FFEAdapter(featureflag_rc_callback)
8787
remoteconfig_poller.register(
8888
FFE_FLAGS_PRODUCT,
8989
feature_flag_rc,
9090
restart_on_fork=True,
91-
capabilities=[FFAndECapabilities.FFE_FLAG_CONFIGURATION_RULES],
91+
capabilities=[FFECapabilities.FFE_FLAG_CONFIGURATION_RULES],
9292
)
9393

9494

0 commit comments

Comments
 (0)