Skip to content

Commit ccaad89

Browse files
committed
feat(ffe): Add Feature Flagging and Experimentation support
Add FFE support to dd-trace-php. Flag evaluation is delegated to libdatadog's datadog-ffe Rust crate via FFI. PHP handles orchestration: config lifecycle, exposure dedup, and HTTP transport. Rust FFI layer (components-rs/ffe.rs): - C-callable bridge to datadog-ffe::rules_based - Global config store behind Mutex<FfeState> - Structured attribute passing (no JSON on hot path) C extension (ext/ddtrace.c): - ffe_evaluate, ffe_has_config, ffe_config_changed, ffe_load_config - Marshals PHP arrays to FfeAttribute structs Remote Config (components-rs/remote_config.rs): - Register FfeFlags product + FfeFlagConfigurationRules capability - Handle add/remove of FFE configs via sidecar PHP Provider (src/DDTrace/FeatureFlags/Provider.php): - Singleton checking RC config state - Calls native evaluate, parses JSON results - Reports exposures via LRU-deduplicated writer Exposure pipeline: - LRU cache (65K entries) with length-prefixed composite keys - Batched writer to /evp_proxy/v2/api/v2/exposures (1000 cap) - Auto-flush via register_shutdown_function OpenFeature adapter (src/DDTrace/OpenFeature/DataDogProvider.php): - Implements AbstractProvider for open-feature/sdk Build: - Add datadog-ffe to RUST_FILES in Makefile for PECL packaging - Cargo.lock: minimal additions only (73 new crates, no gratuitous bumps) - Bump libdatadog to ed316b638 (FFE RC support, libdatadog#1532) Tests: - LRU cache unit tests (11 tests) - Exposure cache unit tests (12 tests) - 220 evaluation correctness tests from JSON fixtures Config: DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED (default: false)
1 parent 7d767af commit ccaad89

44 files changed

Lines changed: 7913 additions & 3 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

components-rs/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,15 @@ libdd-telemetry-ffi = { path = "../libdatadog/libdd-telemetry-ffi", default-feat
1515
datadog-live-debugger = { path = "../libdatadog/datadog-live-debugger" }
1616
datadog-live-debugger-ffi = { path = "../libdatadog/datadog-live-debugger-ffi", default-features = false }
1717
datadog-ipc = { path = "../libdatadog/datadog-ipc" }
18-
datadog-remote-config = { path = "../libdatadog/datadog-remote-config" }
18+
datadog-remote-config = { path = "../libdatadog/datadog-remote-config", features = ["ffe"] }
1919
datadog-sidecar = { path = "../libdatadog/datadog-sidecar" }
2020
datadog-sidecar-ffi = { path = "../libdatadog/datadog-sidecar-ffi" }
2121
libdd-tinybytes = { path = "../libdatadog/libdd-tinybytes" }
2222
libdd-trace-utils = { path = "../libdatadog/libdd-trace-utils" }
2323
libdd-crashtracker-ffi = { path = "../libdatadog/libdd-crashtracker-ffi", default-features = false, features = ["collector"] }
2424
libdd-library-config-ffi = { path = "../libdatadog/libdd-library-config-ffi", default-features = false }
2525
spawn_worker = { path = "../libdatadog/spawn_worker" }
26+
datadog-ffe = { path = "../libdatadog/datadog-ffe" }
2627
anyhow = { version = "1.0" }
2728
const-str = "0.5.6"
2829
itertools = "0.11.0"

components-rs/ddtrace.h

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,46 @@ uint32_t ddog_get_logs_count(ddog_CharSlice level);
6161

6262
void ddog_init_remote_config(bool live_debugging_enabled,
6363
bool appsec_activation,
64-
bool appsec_config);
64+
bool appsec_config,
65+
bool ffe_enabled);
6566

6667
struct ddog_RemoteConfigState *ddog_init_remote_config_state(const struct ddog_Endpoint *endpoint);
6768

6869
const char *ddog_remote_config_get_path(const struct ddog_RemoteConfigState *remote_config);
6970

7071
bool ddog_process_remote_configs(struct ddog_RemoteConfigState *remote_config);
7172

73+
bool ddog_ffe_load_config(const char *json);
74+
75+
bool ddog_ffe_has_config(void);
76+
77+
bool ddog_ffe_config_changed(void);
78+
79+
struct FfeResult;
80+
81+
struct FfeAttribute {
82+
const char *key;
83+
int32_t value_type; /* 0=string, 1=number, 2=bool */
84+
const char *string_value;
85+
double number_value;
86+
bool bool_value;
87+
};
88+
89+
struct FfeResult *ddog_ffe_evaluate(
90+
const char *flag_key,
91+
int32_t expected_type,
92+
const char *targeting_key,
93+
const struct FfeAttribute *attributes,
94+
size_t attributes_count);
95+
96+
const char *ddog_ffe_result_value(const struct FfeResult *r);
97+
const char *ddog_ffe_result_variant(const struct FfeResult *r);
98+
const char *ddog_ffe_result_allocation_key(const struct FfeResult *r);
99+
int32_t ddog_ffe_result_reason(const struct FfeResult *r);
100+
int32_t ddog_ffe_result_error_code(const struct FfeResult *r);
101+
bool ddog_ffe_result_do_log(const struct FfeResult *r);
102+
void ddog_ffe_free_result(struct FfeResult *r);
103+
72104
bool ddog_type_can_be_instrumented(const struct ddog_RemoteConfigState *remote_config,
73105
ddog_CharSlice typename_);
74106

components-rs/ffe.rs

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
use datadog_ffe::rules_based::{
2+
self as ffe, AssignmentReason, AssignmentValue, Attribute, Configuration, EvaluationContext,
3+
EvaluationError, ExpectedFlagType, Str, UniversalFlagConfig,
4+
};
5+
use std::collections::HashMap;
6+
use std::ffi::{c_char, CStr, CString};
7+
use std::sync::{Arc, Mutex};
8+
9+
/// Holds both the FFE configuration and a "changed" flag atomically behind a
10+
/// single Mutex. This avoids the race where another thread could observe
11+
/// `config` updated but `changed` still false (or vice-versa).
12+
///
13+
/// A `RwLock` would be more appropriate here (many readers via `ddog_ffe_evaluate`,
14+
/// rare writer via `store_config`), but PHP is single-threaded per process so
15+
/// contention is not a practical concern. Keeping a Mutex for simplicity.
16+
struct FfeState {
17+
config: Option<Configuration>,
18+
changed: bool,
19+
}
20+
21+
lazy_static::lazy_static! {
22+
static ref FFE_STATE: Mutex<FfeState> = Mutex::new(FfeState {
23+
config: None,
24+
changed: false,
25+
});
26+
}
27+
28+
/// Called by remote_config when a new FFE configuration arrives via RC.
29+
pub fn store_config(config: Configuration) {
30+
if let Ok(mut state) = FFE_STATE.lock() {
31+
state.config = Some(config);
32+
state.changed = true;
33+
}
34+
}
35+
36+
/// Called by remote_config when an FFE configuration is removed.
37+
pub fn clear_config() {
38+
if let Ok(mut state) = FFE_STATE.lock() {
39+
state.config = None;
40+
state.changed = true;
41+
}
42+
}
43+
44+
/// Load a UFC JSON config string directly into the FFE engine.
45+
/// Used by tests to load config without Remote Config.
46+
#[no_mangle]
47+
pub extern "C" fn ddog_ffe_load_config(json: *const c_char) -> bool {
48+
if json.is_null() {
49+
return false;
50+
}
51+
let json_str = match unsafe { CStr::from_ptr(json) }.to_str() {
52+
Ok(s) => s,
53+
Err(_) => return false,
54+
};
55+
match UniversalFlagConfig::from_json(json_str.as_bytes().to_vec()) {
56+
Ok(ufc) => {
57+
store_config(Configuration::from_server_response(ufc));
58+
true
59+
}
60+
Err(_) => false,
61+
}
62+
}
63+
64+
/// Check if FFE configuration is loaded.
65+
#[no_mangle]
66+
pub extern "C" fn ddog_ffe_has_config() -> bool {
67+
FFE_STATE.lock().map(|s| s.config.is_some()).unwrap_or(false)
68+
}
69+
70+
/// Check if FFE config has changed since last check.
71+
/// Resets the changed flag after reading.
72+
#[no_mangle]
73+
pub extern "C" fn ddog_ffe_config_changed() -> bool {
74+
if let Ok(mut state) = FFE_STATE.lock() {
75+
let was_changed = state.changed;
76+
state.changed = false;
77+
was_changed
78+
} else {
79+
false
80+
}
81+
}
82+
83+
/// Opaque handle for FFE evaluation results returned to C/PHP.
84+
pub struct FfeResult {
85+
pub value_json: CString,
86+
pub variant: Option<CString>,
87+
pub allocation_key: Option<CString>,
88+
pub reason: i32,
89+
pub error_code: i32,
90+
pub do_log: bool,
91+
}
92+
93+
/// A single attribute passed from C/PHP for building an EvaluationContext.
94+
#[repr(C)]
95+
pub struct FfeAttribute {
96+
pub key: *const c_char,
97+
/// 0 = string, 1 = number, 2 = bool
98+
pub value_type: i32,
99+
pub string_value: *const c_char,
100+
pub number_value: f64,
101+
pub bool_value: bool,
102+
}
103+
104+
/// Evaluate a feature flag using the stored Configuration.
105+
///
106+
/// Accepts structured attributes from C instead of a JSON blob.
107+
/// `targeting_key` may be null (no targeting key).
108+
/// `attributes` / `attributes_count` describe an array of `FfeAttribute`.
109+
/// Returns null if no config is loaded.
110+
#[no_mangle]
111+
pub extern "C" fn ddog_ffe_evaluate(
112+
flag_key: *const c_char,
113+
expected_type: i32,
114+
targeting_key: *const c_char,
115+
attributes: *const FfeAttribute,
116+
attributes_count: usize,
117+
) -> *mut FfeResult {
118+
let flag_key = match unsafe { CStr::from_ptr(flag_key) }.to_str() {
119+
Ok(s) => s,
120+
Err(_) => return std::ptr::null_mut(),
121+
};
122+
123+
let expected_type = match expected_type {
124+
0 => ExpectedFlagType::String,
125+
1 => ExpectedFlagType::Integer,
126+
2 => ExpectedFlagType::Float,
127+
3 => ExpectedFlagType::Boolean,
128+
4 => ExpectedFlagType::Object,
129+
_ => return std::ptr::null_mut(),
130+
};
131+
132+
// Build targeting key
133+
let tk = if targeting_key.is_null() {
134+
None
135+
} else {
136+
match unsafe { CStr::from_ptr(targeting_key) }.to_str() {
137+
Ok(s) if !s.is_empty() => Some(Str::from(s)),
138+
_ => None,
139+
}
140+
};
141+
142+
// Build attributes map from the C array
143+
let mut attrs = HashMap::new();
144+
if !attributes.is_null() && attributes_count > 0 {
145+
let slice = unsafe { std::slice::from_raw_parts(attributes, attributes_count) };
146+
for attr in slice {
147+
if attr.key.is_null() {
148+
continue;
149+
}
150+
let key = match unsafe { CStr::from_ptr(attr.key) }.to_str() {
151+
Ok(s) => s,
152+
Err(_) => continue,
153+
};
154+
let value = match attr.value_type {
155+
0 => {
156+
// string
157+
if attr.string_value.is_null() {
158+
continue;
159+
}
160+
match unsafe { CStr::from_ptr(attr.string_value) }.to_str() {
161+
Ok(s) => Attribute::from(s),
162+
Err(_) => continue,
163+
}
164+
}
165+
1 => {
166+
// number
167+
Attribute::from(attr.number_value)
168+
}
169+
2 => {
170+
// bool
171+
Attribute::from(attr.bool_value)
172+
}
173+
_ => continue,
174+
};
175+
attrs.insert(Str::from(key), value);
176+
}
177+
}
178+
179+
let context = EvaluationContext::new(tk, Arc::new(attrs));
180+
181+
let state = match FFE_STATE.lock() {
182+
Ok(s) => s,
183+
Err(_) => return std::ptr::null_mut(),
184+
};
185+
186+
let assignment = ffe::get_assignment(
187+
state.config.as_ref(),
188+
flag_key,
189+
&context,
190+
expected_type,
191+
ffe::now(),
192+
);
193+
194+
let result = match assignment {
195+
Ok(a) => FfeResult {
196+
value_json: CString::new(assignment_value_to_json(&a.value)).unwrap_or_default(),
197+
variant: Some(CString::new(a.variation_key.as_str()).unwrap_or_default()),
198+
allocation_key: Some(CString::new(a.allocation_key.as_str()).unwrap_or_default()),
199+
reason: match a.reason {
200+
AssignmentReason::Static => 0,
201+
AssignmentReason::TargetingMatch => 2,
202+
AssignmentReason::Split => 3,
203+
},
204+
error_code: 0,
205+
do_log: a.do_log,
206+
},
207+
Err(err) => {
208+
let (error_code, reason) = match &err {
209+
EvaluationError::TypeMismatch { .. } => (1, 5),
210+
EvaluationError::ConfigurationParseError => (2, 5),
211+
EvaluationError::ConfigurationMissing => (6, 5),
212+
EvaluationError::FlagUnrecognizedOrDisabled => (3, 1),
213+
EvaluationError::FlagDisabled => (0, 4),
214+
EvaluationError::DefaultAllocationNull => (0, 1),
215+
_ => (7, 5),
216+
};
217+
FfeResult {
218+
value_json: CString::new("null").unwrap_or_default(),
219+
variant: None,
220+
allocation_key: None,
221+
reason,
222+
error_code,
223+
do_log: false,
224+
}
225+
}
226+
};
227+
228+
Box::into_raw(Box::new(result))
229+
}
230+
231+
#[no_mangle]
232+
pub extern "C" fn ddog_ffe_result_value(r: *const FfeResult) -> *const c_char {
233+
if r.is_null() {
234+
return std::ptr::null();
235+
}
236+
unsafe { &*r }.value_json.as_ptr()
237+
}
238+
239+
#[no_mangle]
240+
pub extern "C" fn ddog_ffe_result_variant(r: *const FfeResult) -> *const c_char {
241+
if r.is_null() {
242+
return std::ptr::null();
243+
}
244+
unsafe { &*r }
245+
.variant
246+
.as_ref()
247+
.map(|s| s.as_ptr())
248+
.unwrap_or(std::ptr::null())
249+
}
250+
251+
#[no_mangle]
252+
pub extern "C" fn ddog_ffe_result_allocation_key(r: *const FfeResult) -> *const c_char {
253+
if r.is_null() {
254+
return std::ptr::null();
255+
}
256+
unsafe { &*r }
257+
.allocation_key
258+
.as_ref()
259+
.map(|s| s.as_ptr())
260+
.unwrap_or(std::ptr::null())
261+
}
262+
263+
#[no_mangle]
264+
pub extern "C" fn ddog_ffe_result_reason(r: *const FfeResult) -> i32 {
265+
if r.is_null() {
266+
return -1;
267+
}
268+
unsafe { &*r }.reason
269+
}
270+
271+
#[no_mangle]
272+
pub extern "C" fn ddog_ffe_result_error_code(r: *const FfeResult) -> i32 {
273+
if r.is_null() {
274+
return -1;
275+
}
276+
unsafe { &*r }.error_code
277+
}
278+
279+
#[no_mangle]
280+
pub extern "C" fn ddog_ffe_result_do_log(r: *const FfeResult) -> bool {
281+
if r.is_null() {
282+
return false;
283+
}
284+
unsafe { &*r }.do_log
285+
}
286+
287+
#[no_mangle]
288+
pub unsafe extern "C" fn ddog_ffe_free_result(r: *mut FfeResult) {
289+
if !r.is_null() {
290+
drop(Box::from_raw(r));
291+
}
292+
}
293+
294+
fn assignment_value_to_json(value: &AssignmentValue) -> String {
295+
match value {
296+
AssignmentValue::String(s) => serde_json::to_string(s.as_str()).unwrap_or_default(),
297+
AssignmentValue::Integer(i) => i.to_string(),
298+
AssignmentValue::Float(f) => serde_json::Number::from_f64(*f)
299+
.map(|n| n.to_string())
300+
.unwrap_or_else(|| f.to_string()),
301+
AssignmentValue::Boolean(b) => b.to_string(),
302+
AssignmentValue::Json { raw, .. } => raw.get().to_string(),
303+
}
304+
}

components-rs/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
pub mod log;
77
pub mod remote_config;
8+
pub mod ffe;
89
pub mod sidecar;
910
pub mod telemetry;
1011
pub mod bytes;

0 commit comments

Comments
 (0)