diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 90b79c23e7a..8c90cfd1fc6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -14,6 +14,17 @@ compile_rust.sh @Datadog/libdatadog-apm # APM IDM Team /src/ @DataDog/apm-idm-php +# FFE (Feature Flagging & Experimentation) SDK Team +/components-rs/ffe.rs @Datadog/libdatadog-apm @DataDog/feature-flagging-and-experimentation-sdk +/libdatadog @Datadog/libdatadog-apm @DataDog/feature-flagging-and-experimentation-sdk +/src/api/FeatureFlags/ @DataDog/feature-flagging-and-experimentation-sdk +/src/DDTrace/OpenFeature/ @DataDog/feature-flagging-and-experimentation-sdk +/src/bridge/_files_openfeature.php @DataDog/feature-flagging-and-experimentation-sdk +/tests/FeatureFlags/ @DataDog/feature-flagging-and-experimentation-sdk +/tests/api/Unit/FeatureFlags/ @DataDog/feature-flagging-and-experimentation-sdk +/tests/OpenFeature/ @DataDog/feature-flagging-and-experimentation-sdk +/tests/ext/ffe/ @DataDog/feature-flagging-and-experimentation-sdk + # Release files Cargo.lock @DataDog/apm-php @DataDog/profiling-php @Datadog/libdatadog-apm package.xml @DataDog/apm-php @DataDog/profiling-php @Datadog/asm-php diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 144fd65c722..ccb4a0e2cf1 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,6 +5,13 @@ version: 2 updates: + - package-ecosystem: "gitsubmodule" + directory: "/" + schedule: + interval: "weekly" + cooldown: + default-days: 2 + - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index d5ea7b8eab1..030f1a62b31 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -6,8 +6,8 @@ stages: variables: GIT_SUBMODULE_STRATEGY: recursive - # Only clone libdatadog submodule by default - GIT_SUBMODULE_PATHS: libdatadog + # Only clone submodules required by default test jobs + GIT_SUBMODULE_PATHS: libdatadog tests/FeatureFlags/ffe-system-test-data RELIABILITY_ENV_BRANCH: value: "master" description: "Run a specific datadog-reliability-env branch downstream" @@ -71,6 +71,8 @@ tracer-trigger: strategy: depend variables: PARENT_PIPELINE_ID: $CI_PIPELINE_ID + GIT_SUBMODULE_STRATEGY: recursive + GIT_SUBMODULE_PATHS: libdatadog tests/FeatureFlags/ffe-system-test-data appsec-trigger: stage: tests @@ -119,7 +121,8 @@ package-trigger: pipeline_variables: true variables: PARENT_PIPELINE_ID: $CI_PIPELINE_ID - GIT_SUBMODULE_PATHS: libdatadog appsec/third_party/cpp-base64 appsec/third_party/libddwaf appsec/third_party/libddwaf-rust appsec/third_party/msgpack-c + GIT_SUBMODULE_STRATEGY: recursive + GIT_SUBMODULE_PATHS: libdatadog tests/FeatureFlags/ffe-system-test-data appsec/third_party/cpp-base64 appsec/third_party/libddwaf appsec/third_party/libddwaf-rust appsec/third_party/msgpack-c NIGHTLY_BUILD: $NIGHTLY_BUILD # Runs after the full CI completes. Triggered in two situations: diff --git a/.gitlab/generate-tracer.php b/.gitlab/generate-tracer.php index 42510ff8ce5..eed16d70e36 100644 --- a/.gitlab/generate-tracer.php +++ b/.gitlab/generate-tracer.php @@ -386,6 +386,26 @@ function before_script_steps($with_docker_auth = false) { - make test_unit PHPUNIT_JUNIT="artifacts/tests/php-tests.xml" +=")): ?> +"Feature flags tests: []": + extends: .debug_test + needs: + - job: "compile extension: debug" + parallel: + matrix: + - PHP_MAJOR_MINOR: "" + ARCH: "amd64" + artifacts: true + - job: "Prepare code" + artifacts: true + variables: + PHP_MAJOR_MINOR: "" + ARCH: "amd64" + script: + - make test_featureflags PHPUNIT_JUNIT="artifacts/tests/php-tests.xml" + + + "API unit tests: []": extends: .debug_test needs: diff --git a/.gitmodules b/.gitmodules index 16212a50047..58f2a29bf75 100644 --- a/.gitmodules +++ b/.gitmodules @@ -18,3 +18,6 @@ path = appsec/third_party/libddwaf-rust url = https://github.com/DataDog/libddwaf-rust.git branch = glopes/v2 +[submodule "tests/FeatureFlags/ffe-system-test-data"] + path = tests/FeatureFlags/ffe-system-test-data + url = https://github.com/DataDog/ffe-system-test-data diff --git a/Cargo.lock b/Cargo.lock index db76b71b6bc..7a0fe64ab9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1451,6 +1451,7 @@ dependencies = [ "bincode", "cbindgen 0.27.0", "const-str", + "datadog-ffe", "datadog-ipc", "datadog-live-debugger", "datadog-live-debugger-ffi", diff --git a/Makefile b/Makefile index 1d017c7308b..51db7ad45cd 100644 --- a/Makefile +++ b/Makefile @@ -1328,6 +1328,12 @@ test_distributed_tracing_coverage: test_metrics: global_test_run_dependencies $(call run_tests,--testsuite=metrics $(TESTS)) +test_featureflags: global_test_run_dependencies tests/OpenFeature/composer.lock-php$(PHP_MAJOR_MINOR) + $(eval FEATUREFLAGS_TEST_EXTRA_INI := $(TEST_EXTRA_INI)) + $(eval TEST_EXTRA_INI=$(FEATUREFLAGS_TEST_EXTRA_INI) -d auto_prepend_file=$(PWD)/tests/OpenFeature/vendor/autoload.php) + $(call run_tests,--testsuite=featureflags $(TESTS)) + $(eval TEST_EXTRA_INI=$(FEATUREFLAGS_TEST_EXTRA_INI)) + benchmarks_run_dependencies: global_test_run_dependencies tests/Frameworks/Symfony/Version_5_2/composer.lock-php$(PHP_MAJOR_MINOR) tests/Frameworks/Laravel/Version_10_x/composer.lock-php$(PHP_MAJOR_MINOR) tests/Benchmarks/composer.lock-php$(PHP_MAJOR_MINOR) php tests/Frameworks/Symfony/Version_5_2/bin/console cache:clear --no-warmup --env=prod diff --git a/components-rs/Cargo.toml b/components-rs/Cargo.toml index a6103bcde96..35ba698004f 100644 --- a/components-rs/Cargo.toml +++ b/components-rs/Cargo.toml @@ -15,7 +15,8 @@ libdd-telemetry-ffi = { path = "../libdatadog/libdd-telemetry-ffi", default-feat datadog-live-debugger = { path = "../libdatadog/datadog-live-debugger" } datadog-live-debugger-ffi = { path = "../libdatadog/datadog-live-debugger-ffi", default-features = false } datadog-ipc = { path = "../libdatadog/datadog-ipc" } -datadog-remote-config = { path = "../libdatadog/datadog-remote-config" } +datadog-ffe = { path = "../libdatadog/datadog-ffe" } +datadog-remote-config = { path = "../libdatadog/datadog-remote-config", features = ["ffe"] } datadog-sidecar = { path = "../libdatadog/datadog-sidecar" } datadog-sidecar-ffi = { path = "../libdatadog/datadog-sidecar-ffi" } libdd-data-pipeline = { path = "../libdatadog/libdd-data-pipeline" } diff --git a/components-rs/common.h b/components-rs/common.h index f5a6d674c0a..adf46fad3ec 100644 --- a/components-rs/common.h +++ b/components-rs/common.h @@ -439,6 +439,8 @@ typedef struct ddog_DebuggerPayload ddog_DebuggerPayload; typedef struct ddog_DslString ddog_DslString; +typedef struct ddog_FfeResult ddog_FfeResult; + typedef struct ddog_HashMap_ShmCacheKey__ShmCache ddog_HashMap_ShmCacheKey__ShmCache; /** @@ -478,6 +480,19 @@ typedef struct ddog_SidecarTransport ddog_SidecarTransport; */ typedef struct ddog_SpanConcentrator ddog_SpanConcentrator; +/** + * Flags selecting which Remote Config products/capabilities to subscribe to. + * + * Passed as a single C-ABI struct so call sites can use designated initializers + * and name the flags, instead of a positional sequence of bool args. + */ +typedef struct ddog_DdogRemoteConfigFlags { + bool live_debugging_enabled; + bool appsec_activation; + bool appsec_config; + bool ffe_enabled; +} ddog_DdogRemoteConfigFlags; + /** * Holds the raw parts of a Rust Vec; it should only be created from Rust, * never from C. @@ -679,6 +694,14 @@ typedef struct ddog_Vec_DebuggerPayload { */ typedef uint64_t ddog_QueueId; +typedef struct ddog_FfeAttribute { + const char *key; + int32_t value_type; + const char *string_value; + double number_value; + bool bool_value; +} ddog_FfeAttribute; + /** * A (key, value) pair for peer-service tags, borrowed from PHP/concentrator memory. */ diff --git a/components-rs/ddtrace.h b/components-rs/ddtrace.h index 7c575e34319..da498887283 100644 --- a/components-rs/ddtrace.h +++ b/components-rs/ddtrace.h @@ -61,6 +61,32 @@ int posix_spawn_file_actions_addchdir_np(void *file_actions, const char *path); uint64_t dd_fnv1a_64(const uint8_t *data, uintptr_t len); +bool ddog_ffe_load_config(const char *json); + +bool ddog_ffe_has_config(void); + +uint64_t ddog_ffe_config_version(void); + +struct ddog_FfeResult *ddog_ffe_evaluate(const char *flag_key, + int32_t expected_type, + const char *targeting_key, + const struct ddog_FfeAttribute *attributes, + uintptr_t attributes_count); + +const char *ddog_ffe_result_value(const struct ddog_FfeResult *result); + +const char *ddog_ffe_result_variant(const struct ddog_FfeResult *result); + +const char *ddog_ffe_result_allocation_key(const struct ddog_FfeResult *result); + +int32_t ddog_ffe_result_reason(const struct ddog_FfeResult *result); + +int32_t ddog_ffe_result_error_code(const struct ddog_FfeResult *result); + +bool ddog_ffe_result_do_log(const struct ddog_FfeResult *result); + +void ddog_ffe_free_result(struct ddog_FfeResult *result); + const char *ddog_normalize_process_tag_value(ddog_CharSlice tag_value); void ddog_free_normalized_tag_value(const char *ptr); @@ -118,9 +144,7 @@ void ddog_reset_logger(void); uint32_t ddog_get_logs_count(ddog_CharSlice level); -void ddog_init_remote_config(bool live_debugging_enabled, - bool appsec_activation, - bool appsec_config); +void ddog_init_remote_config(struct ddog_DdogRemoteConfigFlags flags); struct ddog_RemoteConfigState *ddog_init_remote_config_state(const struct ddog_Endpoint *endpoint, bool di_enabled); diff --git a/components-rs/ffe.rs b/components-rs/ffe.rs new file mode 100644 index 00000000000..cf8da11a18c --- /dev/null +++ b/components-rs/ffe.rs @@ -0,0 +1,445 @@ +use datadog_ffe::rules_based::{ + self as ffe, AssignmentReason, AssignmentValue, Attribute, Configuration, EvaluationContext, + EvaluationError, ExpectedFlagType, Str, UniversalFlagConfig, +}; +use std::cell::RefCell; +use std::collections::HashMap; +use std::ffi::{c_char, CStr, CString}; +use std::sync::Arc; + +struct FfeState { + config: Option, + version: u64, +} + +thread_local! { + static FFE_STATE: RefCell = const { RefCell::new(FfeState { + config: None, + version: 0, + }) }; +} + +pub fn store_config(config: Configuration) { + FFE_STATE.with(|state| { + let mut state = state.borrow_mut(); + state.config = Some(config); + state.version = state.version.wrapping_add(1); + }); +} + +pub fn clear_config() { + FFE_STATE.with(|state| { + let mut state = state.borrow_mut(); + state.config = None; + state.version = state.version.wrapping_add(1); + }); +} + +#[no_mangle] +pub extern "C" fn ddog_ffe_load_config(json: *const c_char) -> bool { + if json.is_null() { + return false; + } + + let json = match unsafe { CStr::from_ptr(json) }.to_str() { + Ok(json) => json, + Err(_) => return false, + }; + + match UniversalFlagConfig::from_json(json.as_bytes().to_vec()) { + Ok(ufc) => { + store_config(Configuration::from_server_response(ufc)); + true + } + Err(_) => false, + } +} + +#[no_mangle] +pub extern "C" fn ddog_ffe_has_config() -> bool { + FFE_STATE.with(|state| state.borrow().config.is_some()) +} + +#[no_mangle] +pub extern "C" fn ddog_ffe_config_version() -> u64 { + FFE_STATE.with(|state| state.borrow().version) +} + +const REASON_STATIC: i32 = 0; +const REASON_DEFAULT: i32 = 1; +const REASON_TARGETING_MATCH: i32 = 2; +const REASON_SPLIT: i32 = 3; +const REASON_DISABLED: i32 = 4; +const REASON_ERROR: i32 = 5; + +const ERROR_NONE: i32 = 0; +const ERROR_TYPE_MISMATCH: i32 = 1; +const ERROR_CONFIG_PARSE: i32 = 2; +const ERROR_FLAG_UNRECOGNIZED: i32 = 3; +const ERROR_CONFIG_MISSING: i32 = 6; +const ERROR_GENERAL: i32 = 7; + +const ATTR_TYPE_STRING: i32 = 0; +const ATTR_TYPE_NUMBER: i32 = 1; +const ATTR_TYPE_BOOL: i32 = 2; + +const TYPE_STRING: i32 = 0; +const TYPE_INTEGER: i32 = 1; +const TYPE_FLOAT: i32 = 2; +const TYPE_BOOLEAN: i32 = 3; +const TYPE_OBJECT: i32 = 4; + +pub struct FfeResult { + pub value_json: CString, + pub variant: Option, + pub allocation_key: Option, + pub reason: i32, + pub error_code: i32, + pub do_log: bool, +} + +#[repr(C)] +pub struct FfeAttribute { + pub key: *const c_char, + pub value_type: i32, + pub string_value: *const c_char, + pub number_value: f64, + pub bool_value: bool, +} + +#[no_mangle] +pub extern "C" fn ddog_ffe_evaluate( + flag_key: *const c_char, + expected_type: i32, + targeting_key: *const c_char, + attributes: *const FfeAttribute, + attributes_count: usize, +) -> *mut FfeResult { + if flag_key.is_null() { + return std::ptr::null_mut(); + } + + let flag_key = match unsafe { CStr::from_ptr(flag_key) }.to_str() { + Ok(flag_key) => flag_key, + Err(_) => return std::ptr::null_mut(), + }; + + let expected_type = match expected_type { + TYPE_STRING => ExpectedFlagType::String, + TYPE_INTEGER => ExpectedFlagType::Integer, + TYPE_FLOAT => ExpectedFlagType::Float, + TYPE_BOOLEAN => ExpectedFlagType::Boolean, + TYPE_OBJECT => ExpectedFlagType::Object, + _ => return std::ptr::null_mut(), + }; + + let targeting_key = if targeting_key.is_null() { + None + } else { + match unsafe { CStr::from_ptr(targeting_key) }.to_str() { + Ok(targeting_key) => Some(Str::from(targeting_key)), + _ => None, + } + }; + + let attributes = parse_attributes(attributes, attributes_count); + let context = EvaluationContext::new(targeting_key, Arc::new(attributes)); + + FFE_STATE.with(|state| { + let state = state.borrow(); + let assignment = ffe::get_assignment( + state.config.as_ref(), + flag_key, + &context, + expected_type, + ffe::now(), + ); + + Box::into_raw(Box::new(result_from_assignment(assignment))) + }) +} + +#[no_mangle] +pub extern "C" fn ddog_ffe_result_value(result: *const FfeResult) -> *const c_char { + if result.is_null() { + return std::ptr::null(); + } + + unsafe { &*result }.value_json.as_ptr() +} + +#[no_mangle] +pub extern "C" fn ddog_ffe_result_variant(result: *const FfeResult) -> *const c_char { + if result.is_null() { + return std::ptr::null(); + } + + unsafe { &*result } + .variant + .as_ref() + .map(|value| value.as_ptr()) + .unwrap_or(std::ptr::null()) +} + +#[no_mangle] +pub extern "C" fn ddog_ffe_result_allocation_key(result: *const FfeResult) -> *const c_char { + if result.is_null() { + return std::ptr::null(); + } + + unsafe { &*result } + .allocation_key + .as_ref() + .map(|value| value.as_ptr()) + .unwrap_or(std::ptr::null()) +} + +#[no_mangle] +pub extern "C" fn ddog_ffe_result_reason(result: *const FfeResult) -> i32 { + if result.is_null() { + return REASON_ERROR; + } + + unsafe { &*result }.reason +} + +#[no_mangle] +pub extern "C" fn ddog_ffe_result_error_code(result: *const FfeResult) -> i32 { + if result.is_null() { + return ERROR_GENERAL; + } + + unsafe { &*result }.error_code +} + +#[no_mangle] +pub extern "C" fn ddog_ffe_result_do_log(result: *const FfeResult) -> bool { + if result.is_null() { + return false; + } + + unsafe { &*result }.do_log +} + +#[no_mangle] +pub unsafe extern "C" fn ddog_ffe_free_result(result: *mut FfeResult) { + if !result.is_null() { + drop(Box::from_raw(result)); + } +} + +fn parse_attributes( + attributes: *const FfeAttribute, + attributes_count: usize, +) -> HashMap { + let mut parsed = HashMap::new(); + + if attributes.is_null() || attributes_count == 0 { + return parsed; + } + + let attributes = unsafe { std::slice::from_raw_parts(attributes, attributes_count) }; + for attribute in attributes { + if attribute.key.is_null() { + continue; + } + + let key = match unsafe { CStr::from_ptr(attribute.key) }.to_str() { + Ok(key) => key, + Err(_) => continue, + }; + + let value = match attribute.value_type { + ATTR_TYPE_STRING => { + if attribute.string_value.is_null() { + continue; + } + + match unsafe { CStr::from_ptr(attribute.string_value) }.to_str() { + Ok(value) => Attribute::from(value), + Err(_) => continue, + } + } + ATTR_TYPE_NUMBER => Attribute::from(attribute.number_value), + ATTR_TYPE_BOOL => Attribute::from(attribute.bool_value), + _ => continue, + }; + + parsed.insert(Str::from(key), value); + } + + parsed +} + +fn result_from_assignment(assignment: Result) -> FfeResult { + match assignment { + Ok(assignment) => FfeResult { + value_json: string_to_cstring(assignment_value_to_json(&assignment.value)), + variant: Some(string_to_cstring( + assignment.variation_key.as_str().to_string(), + )), + allocation_key: Some(string_to_cstring( + assignment.allocation_key.as_str().to_string(), + )), + reason: match assignment.reason { + AssignmentReason::Static => REASON_STATIC, + AssignmentReason::TargetingMatch => REASON_TARGETING_MATCH, + AssignmentReason::Split => REASON_SPLIT, + }, + error_code: ERROR_NONE, + do_log: assignment.do_log, + }, + Err(error) => { + let (error_code, reason) = match &error { + EvaluationError::TypeMismatch { .. } => (ERROR_TYPE_MISMATCH, REASON_ERROR), + EvaluationError::ConfigurationParseError => (ERROR_CONFIG_PARSE, REASON_ERROR), + EvaluationError::ConfigurationMissing => (ERROR_CONFIG_MISSING, REASON_ERROR), + EvaluationError::FlagUnrecognizedOrDisabled => { + (ERROR_FLAG_UNRECOGNIZED, REASON_DEFAULT) + } + EvaluationError::FlagDisabled => (ERROR_NONE, REASON_DISABLED), + EvaluationError::DefaultAllocationNull => (ERROR_NONE, REASON_DEFAULT), + _ => (ERROR_GENERAL, REASON_ERROR), + }; + + FfeResult { + value_json: string_to_cstring("null".to_string()), + variant: None, + allocation_key: None, + reason, + error_code, + do_log: false, + } + } + } +} + +fn assignment_value_to_json(value: &AssignmentValue) -> String { + match value { + AssignmentValue::String(value) => serde_json::to_string(value.as_str()).unwrap_or_default(), + AssignmentValue::Integer(value) => value.to_string(), + AssignmentValue::Float(value) => serde_json::Number::from_f64(*value) + .map(|value| value.to_string()) + .unwrap_or_else(|| value.to_string()), + AssignmentValue::Boolean(value) => value.to_string(), + AssignmentValue::Json { raw, .. } => raw.get().to_string(), + } +} + +fn string_to_cstring(value: String) -> CString { + CString::new(value).unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + const EMPTY_CONFIG: &str = r#"{ + "createdAt": "2026-05-22T00:00:00.000Z", + "format": "SERVER", + "environment": { + "name": "Test" + }, + "flags": {} + }"#; + + fn load_empty_config() -> bool { + let json = CString::new(EMPTY_CONFIG).expect("test fixture is valid cstring"); + ddog_ffe_load_config(json.as_ptr()) + } + + const EMPTY_TARGETING_KEY_CONFIG: &str = r#"{ + "createdAt": "2026-05-22T00:00:00.000Z", + "format": "SERVER", + "environment": { + "name": "Test" + }, + "flags": { + "empty.targeting.shard.flag": { + "key": "empty.targeting.shard.flag", + "enabled": true, + "variationType": "STRING", + "variations": { + "empty-target": { + "key": "empty-target", + "value": "empty-targeting-key" + } + }, + "allocations": [{ + "key": "alloc-empty-targeting-key", + "rules": [], + "splits": [{ + "variationKey": "empty-target", + "shards": [{ + "salt": "empty-targeting-key-regression", + "totalShards": 10000, + "ranges": [{"start": 8022, "end": 8023}] + }] + }], + "doLog": true + }] + } + } + }"#; + + #[test] + fn empty_targeting_key_is_not_dropped() { + clear_config(); + let config = + CString::new(EMPTY_TARGETING_KEY_CONFIG).expect("test fixture is valid cstring"); + assert!(ddog_ffe_load_config(config.as_ptr())); + + let flag_key = + CString::new("empty.targeting.shard.flag").expect("test flag key is valid cstring"); + let targeting_key = CString::new("").expect("empty string is a valid cstring"); + let result = ddog_ffe_evaluate( + flag_key.as_ptr(), + TYPE_STRING, + targeting_key.as_ptr(), + std::ptr::null(), + 0, + ); + + assert!(!result.is_null()); + unsafe { + assert_eq!((*result).reason, REASON_SPLIT); + assert_eq!((*result).error_code, ERROR_NONE); + assert_eq!((*result).do_log, true); + assert_eq!( + CStr::from_ptr(ddog_ffe_result_value(result)) + .to_str() + .unwrap(), + r#""empty-targeting-key""# + ); + ddog_ffe_free_result(result); + } + clear_config(); + } + + #[test] + fn configuration_state_is_thread_local() { + clear_config(); + let empty_version = ddog_ffe_config_version(); + assert!(!ddog_ffe_has_config()); + + assert!(load_empty_config()); + assert!(ddog_ffe_has_config()); + let loaded_version = ddog_ffe_config_version(); + assert_eq!(loaded_version, empty_version.wrapping_add(1)); + + let child = std::thread::spawn(|| { + assert!(!ddog_ffe_has_config()); + assert_eq!(ddog_ffe_config_version(), 0); + + assert!(load_empty_config()); + assert!(ddog_ffe_has_config()); + assert_eq!(ddog_ffe_config_version(), 1); + }); + + child.join().expect("child thread should not panic"); + + assert!(ddog_ffe_has_config()); + assert_eq!(ddog_ffe_config_version(), loaded_version); + clear_config(); + } +} diff --git a/components-rs/lib.rs b/components-rs/lib.rs index bf9a2675ff2..560715ff05c 100644 --- a/components-rs/lib.rs +++ b/components-rs/lib.rs @@ -4,6 +4,7 @@ #![allow(static_mut_refs)] // remove with move to Rust 2024 edition pub mod agent_info; +pub mod ffe; pub mod log; pub mod remote_config; pub mod sidecar; diff --git a/components-rs/remote_config.rs b/components-rs/remote_config.rs index 515aede6f36..fc0ec34fb7e 100644 --- a/components-rs/remote_config.rs +++ b/components-rs/remote_config.rs @@ -1,4 +1,5 @@ use crate::sidecar::MaybeShmLimiter; +use datadog_ffe::rules_based::Configuration; use datadog_live_debugger::debugger_defs::{DebuggerData, DebuggerPayload}; use datadog_live_debugger::{FilterList, LiveDebuggingData, ServiceConfiguration}; use datadog_live_debugger_ffi::data::Probe; @@ -116,13 +117,28 @@ pub struct LiveDebuggerState { pub di_enabled: bool, } +/// Flags selecting which Remote Config products/capabilities to subscribe to. +/// +/// Passed as a single C-ABI struct so call sites can use designated initializers +/// and name the flags, instead of a positional sequence of bool args. +#[repr(C)] +pub struct DdogRemoteConfigFlags { + pub live_debugging_enabled: bool, + pub appsec_activation: bool, + pub appsec_config: bool, + pub ffe_enabled: bool, +} + #[no_mangle] #[allow(static_mut_refs)] -pub unsafe extern "C" fn ddog_init_remote_config( - live_debugging_enabled: bool, - appsec_activation: bool, - appsec_config: bool, -) { +pub unsafe extern "C" fn ddog_init_remote_config(flags: DdogRemoteConfigFlags) { + let DdogRemoteConfigFlags { + live_debugging_enabled, + appsec_activation, + appsec_config, + ffe_enabled, + } = flags; + DDTRACE_REMOTE_CONFIG_PRODUCTS.push(RemoteConfigProduct::ApmTracing); DDTRACE_REMOTE_CONFIG_CAPABILITIES.push(RemoteConfigCapabilities::ApmTracingCustomTags); DDTRACE_REMOTE_CONFIG_CAPABILITIES.push(RemoteConfigCapabilities::ApmTracingEnabled); @@ -139,6 +155,11 @@ pub unsafe extern "C" fn ddog_init_remote_config( DDTRACE_REMOTE_CONFIG_CAPABILITIES.push(RemoteConfigCapabilities::AsmActivation); } + if ffe_enabled { + DDTRACE_REMOTE_CONFIG_PRODUCTS.push(RemoteConfigProduct::FfeFlags); + DDTRACE_REMOTE_CONFIG_CAPABILITIES.push(RemoteConfigCapabilities::FfeFlagConfigurationRules); + } + if live_debugging_enabled { DDTRACE_REMOTE_CONFIG_PRODUCTS.push(RemoteConfigProduct::LiveDebugger) } @@ -377,6 +398,10 @@ pub extern "C" fn ddog_process_remote_configs(remote_config: &mut RemoteConfigSt ); } } + RemoteConfigData::FfeFlags(ufc) => { + debug!("Received FFE flags configuration"); + crate::ffe::store_config(Configuration::from_server_response(ufc)); + } RemoteConfigData::Ignored(_) => (), RemoteConfigData::TracerFlareConfig(_) => {} RemoteConfigData::TracerFlareTask(_) => {} @@ -402,6 +427,10 @@ pub extern "C" fn ddog_process_remote_configs(remote_config: &mut RemoteConfigSt } } } + RemoteConfigProduct::FfeFlags => { + debug!("FFE flags configuration removed"); + crate::ffe::clear_config(); + } _ => (), }, } diff --git a/composer.json b/composer.json index eedfbd81763..8823021517f 100644 --- a/composer.json +++ b/composer.json @@ -87,6 +87,14 @@ "create-lockfile": false } }, + "openfeature": { + "require": { + "open-feature/sdk": "^2.1" + }, + "scenario-options": { + "create-lockfile": false + } + }, "opentelemetry1": { "require": { "open-telemetry/sdk": "@stable", diff --git a/ext/autoload_php_files.c b/ext/autoload_php_files.c index 246008d4c19..e698ad083d6 100644 --- a/ext/autoload_php_files.c +++ b/ext/autoload_php_files.c @@ -32,7 +32,9 @@ static zend_class_entry *(*dd_prev_autoloader)(zend_string *name, zend_string *l static zend_bool dd_api_is_preloaded = false; static zend_bool dd_otel_is_preloaded = false; static zend_bool dd_legacy_tracer_is_preloaded = false; +static zend_bool dd_openfeature_is_preloaded = false; #endif +static zend_bool dd_openfeature_is_loaded = false; #if PHP_VERSION_ID < 80000 #define LAST_ERROR_STRING PG(last_error_message) @@ -234,6 +236,18 @@ static zend_class_entry *dd_perform_autoload(zend_string *class_name, zend_strin return ce; } } + if (zend_string_starts_with_literal(lc_name, "ddtrace\\openfeature\\")) { +#if PHP_VERSION_ID >= 80000 + if (!dd_openfeature_is_loaded) { + dd_openfeature_is_loaded = 1; + dd_load_files("openfeature"); + } + if ((ce = zend_hash_find_ptr(EG(class_table), lc_name))) { + return ce; + } +#endif + return NULL; + } if (!DDTRACE_G(legacy_tracer_is_loaded) && !zend_string_starts_with_literal(lc_name, "ddtrace\\integration\\")) { DDTRACE_G(legacy_tracer_is_loaded) = 1; dd_load_files("tracer"); @@ -420,13 +434,16 @@ void ddtrace_autoload_rshutdown(void) { dd_api_is_preloaded = DDTRACE_G(api_is_loaded); dd_otel_is_preloaded = DDTRACE_G(otel_is_loaded); dd_legacy_tracer_is_preloaded = DDTRACE_G(legacy_tracer_is_loaded); + dd_openfeature_is_preloaded = dd_openfeature_is_loaded; } else { DDTRACE_G(api_is_loaded) = dd_api_is_preloaded; DDTRACE_G(otel_is_loaded) = dd_otel_is_preloaded; DDTRACE_G(legacy_tracer_is_loaded) = dd_legacy_tracer_is_preloaded; + dd_openfeature_is_loaded = dd_openfeature_is_preloaded; } #else DDTRACE_G(api_is_loaded) = 0; DDTRACE_G(otel_is_loaded) = 0; + dd_openfeature_is_loaded = 0; #endif } diff --git a/ext/configuration.h b/ext/configuration.h index 1c95a47abda..0fafcc56b1a 100644 --- a/ext/configuration.h +++ b/ext/configuration.h @@ -278,6 +278,7 @@ enum ddtrace_sidecar_connection_mode { CONFIG(INT, DD_CODE_ORIGIN_MAX_USER_FRAMES, "8") \ CONFIG(BOOL, DD_TRACE_RESOURCE_RENAMING_ENABLED, "false") \ CONFIG(BOOL, DD_TRACE_RESOURCE_RENAMING_ALWAYS_SIMPLIFIED_ENDPOINT, "false") \ + CONFIG(BOOL, DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED, "false") \ CONFIG(BOOL, DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED, "true") \ CONFIG(BOOL, DD_TRACE_STATS_COMPUTATION_ENABLED, "false") \ DD_INTEGRATIONS diff --git a/ext/ddtrace.c b/ext/ddtrace.c index 625c9cfa03b..2d93536ea5f 100644 --- a/ext/ddtrace.c +++ b/ext/ddtrace.c @@ -2951,6 +2951,139 @@ PHP_FUNCTION(DDTrace_flush_endpoints) { ddog_sidecar_telemetry_filter_flush(&DDTRACE_G(sidecar), ddtrace_sidecar_instance_id, &DDTRACE_G(sidecar_queue_id), ddtrace_telemetry_buffer(), ddtrace_telemetry_cache(), service_name, env_name)); } +PHP_FUNCTION(DDTrace_ffe_has_config) { + ZEND_PARSE_PARAMETERS_NONE(); + + RETURN_BOOL(ddog_ffe_has_config()); +} + +PHP_FUNCTION(DDTrace_ffe_config_version) { + ZEND_PARSE_PARAMETERS_NONE(); + + RETURN_LONG((zend_long) ddog_ffe_config_version()); +} + +PHP_FUNCTION(DDTrace_Testing_ffe_load_config) { + char *json; + size_t json_len; + + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_STRING(json, json_len) + ZEND_PARSE_PARAMETERS_END(); + + UNUSED(json_len); + RETURN_BOOL(ddog_ffe_load_config(json)); +} + +PHP_FUNCTION(DDTrace_ffe_evaluate) { + char *flag_key; + size_t flag_key_len; + zend_long type_id_zl; + char *targeting_key = NULL; + size_t targeting_key_len = 0; + zval *attrs_zv; + int32_t type_id; + struct ddog_FfeAttribute *c_attrs = NULL; + size_t attrs_count = 0; + const char *tk = NULL; + HashTable *attributes; + size_t idx = 0; + zend_string *key; + zval *value; + struct ddog_FfeResult *result; + const char *value_json; + const char *variant; + const char *allocation_key; + + ZEND_PARSE_PARAMETERS_START(4, 4) + Z_PARAM_STRING(flag_key, flag_key_len) + Z_PARAM_LONG(type_id_zl) + Z_PARAM_STRING_OR_NULL(targeting_key, targeting_key_len) + Z_PARAM_ARRAY(attrs_zv) + ZEND_PARSE_PARAMETERS_END(); + + UNUSED(flag_key_len); + + type_id = (int32_t) type_id_zl; + tk = targeting_key != NULL ? targeting_key : NULL; + attributes = Z_ARRVAL_P(attrs_zv); + attrs_count = zend_hash_num_elements(attributes); + + if (attrs_count > 0) { + c_attrs = ecalloc(attrs_count, sizeof(struct ddog_FfeAttribute)); + ZEND_HASH_FOREACH_STR_KEY_VAL(attributes, key, value) { + if (!key || idx >= attrs_count) { + continue; + } + + c_attrs[idx].key = ZSTR_VAL(key); + switch (Z_TYPE_P(value)) { + case IS_STRING: + c_attrs[idx].value_type = 0; + c_attrs[idx].string_value = Z_STRVAL_P(value); + break; + case IS_LONG: + c_attrs[idx].value_type = 1; + c_attrs[idx].number_value = (double) Z_LVAL_P(value); + break; + case IS_DOUBLE: + c_attrs[idx].value_type = 1; + c_attrs[idx].number_value = Z_DVAL_P(value); + break; + case IS_TRUE: + c_attrs[idx].value_type = 2; + c_attrs[idx].bool_value = true; + break; + case IS_FALSE: + c_attrs[idx].value_type = 2; + c_attrs[idx].bool_value = false; + break; + default: + continue; + } + + idx++; + } ZEND_HASH_FOREACH_END(); + attrs_count = idx; + } + + ddtrace_process_remote_config_now(); + result = ddog_ffe_evaluate(flag_key, type_id, tk, c_attrs, attrs_count); + if (c_attrs) { + efree(c_attrs); + } + + if (!result) { + RETURN_NULL(); + } + + value_json = ddog_ffe_result_value(result); + variant = ddog_ffe_result_variant(result); + allocation_key = ddog_ffe_result_allocation_key(result); + + array_init(return_value); + if (value_json) { + add_assoc_string(return_value, "value_json", (char *) value_json); + } else { + add_assoc_null(return_value, "value_json"); + } + if (variant) { + add_assoc_string(return_value, "variant", (char *) variant); + } else { + add_assoc_null(return_value, "variant"); + } + if (allocation_key) { + add_assoc_string(return_value, "allocation_key", (char *) allocation_key); + } else { + add_assoc_null(return_value, "allocation_key"); + } + add_assoc_long(return_value, "reason", ddog_ffe_result_reason(result)); + add_assoc_long(return_value, "error_code", ddog_ffe_result_error_code(result)); + add_assoc_bool(return_value, "do_log", ddog_ffe_result_do_log(result)); + + ddog_ffe_free_result(result); +} + PHP_FUNCTION(dd_trace_send_traces_via_thread) { char *payload = NULL; ddtrace_zpplong_t num_traces = 0; diff --git a/ext/ddtrace.stub.php b/ext/ddtrace.stub.php index e4a388e157d..460cd6d9402 100644 --- a/ext/ddtrace.stub.php +++ b/ext/ddtrace.stub.php @@ -845,6 +845,38 @@ function add_endpoint(string $path, string $operation_name, string $resource_nam * Call this once after batching all add_endpoint() calls. */ function flush_endpoints(): void {} + + /** + * Evaluate a feature flag using the stored UFC configuration. + * + * @param string $flagKey The flag key to evaluate. + * @param int $expectedType The expected flag type (0=string, 1=int, 2=float, 3=bool, 4=object). + * @param string|null $targetingKey The targeting key for evaluation context. + * @param array $attributes Flat key-value map of evaluation context attributes (string keys, primitive values). + * @return array|null Associative array with keys: value_json, variant, allocation_key, reason, error_code, do_log. Null only if evaluation engine is unavailable. + * + * @internal Used by the Datadog feature flag client. + */ + function ffe_evaluate(string $flagKey, int $expectedType, ?string $targetingKey, array $attributes): ?array {} + + /** + * Check if FFE (Feature Flag Evaluation) configuration is loaded. + * + * @return bool True if a flag configuration has been loaded. + * + * @internal Used by the Datadog feature flag client. + */ + function ffe_has_config(): bool {} + + /** + * Return the current FFE configuration version counter. + * + * @return int Monotonically-increasing version counter. + * + * @internal Used by the Datadog feature flag client. + */ + function ffe_config_version(): int {} + } namespace DDTrace\System { @@ -924,6 +956,16 @@ function set_blocking_function(\DDTrace\RootSpanData $span, callable $blockingFu } namespace DDTrace\Testing { + /** + * Load a UFC JSON configuration string into the FFE engine. + * + * @param string $json UFC JSON configuration string. + * @return bool True if the configuration was parsed and loaded successfully. + * + * @internal Used by extension tests only. + */ + function ffe_load_config(string $json): bool {} + /** * Overrides PHP's default error handling. * @@ -975,6 +1017,7 @@ function add_span_flag(\DDTrace\SpanData $span, int $flag): void {} * @internal */ function handle_fork(): void {} + } namespace datadog\appsec\v2 { diff --git a/ext/ddtrace_arginfo.h b/ext/ddtrace_arginfo.h index a6e618143b5..313515a9862 100644 --- a/ext/ddtrace_arginfo.h +++ b/ext/ddtrace_arginfo.h @@ -176,6 +176,23 @@ ZEND_END_ARG_INFO() #define arginfo_DDTrace_flush_endpoints arginfo_DDTrace_flush +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_ffe_evaluate, 0, 4, IS_ARRAY, 1) + ZEND_ARG_TYPE_INFO(0, flagKey, IS_STRING, 0) + ZEND_ARG_TYPE_INFO(0, expectedType, IS_LONG, 0) + ZEND_ARG_TYPE_INFO(0, targetingKey, IS_STRING, 1) + ZEND_ARG_TYPE_INFO(0, attributes, IS_ARRAY, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_ffe_has_config, 0, 0, _IS_BOOL, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_ffe_config_version, 0, 0, IS_LONG, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_Testing_ffe_load_config, 0, 1, _IS_BOOL, 0) + ZEND_ARG_TYPE_INFO(0, json, IS_STRING, 0) +ZEND_END_ARG_INFO() + ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_System_container_id, 0, 0, IS_STRING, 1) ZEND_END_ARG_INFO() @@ -394,6 +411,10 @@ ZEND_FUNCTION(DDTrace_resource_weak_get); ZEND_FUNCTION(DDTrace_are_endpoints_collected); ZEND_FUNCTION(DDTrace_add_endpoint); ZEND_FUNCTION(DDTrace_flush_endpoints); +ZEND_FUNCTION(DDTrace_ffe_evaluate); +ZEND_FUNCTION(DDTrace_ffe_has_config); +ZEND_FUNCTION(DDTrace_ffe_config_version); +ZEND_FUNCTION(DDTrace_Testing_ffe_load_config); ZEND_FUNCTION(DDTrace_System_container_id); ZEND_FUNCTION(DDTrace_System_process_tags_base_hash); ZEND_FUNCTION(DDTrace_Config_integration_analytics_enabled); @@ -489,6 +510,9 @@ static const zend_function_entry ext_functions[] = { ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace", "are_endpoints_collected"), zif_DDTrace_are_endpoints_collected, arginfo_DDTrace_are_endpoints_collected, 0, NULL, NULL) ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace", "add_endpoint"), zif_DDTrace_add_endpoint, arginfo_DDTrace_add_endpoint, 0, NULL, NULL) ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace", "flush_endpoints"), zif_DDTrace_flush_endpoints, arginfo_DDTrace_flush_endpoints, 0, NULL, NULL) + ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace", "ffe_evaluate"), zif_DDTrace_ffe_evaluate, arginfo_DDTrace_ffe_evaluate, 0, NULL, NULL) + ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace", "ffe_has_config"), zif_DDTrace_ffe_has_config, arginfo_DDTrace_ffe_has_config, 0, NULL, NULL) + ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace", "ffe_config_version"), zif_DDTrace_ffe_config_version, arginfo_DDTrace_ffe_config_version, 0, NULL, NULL) ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace\\System", "container_id"), zif_DDTrace_System_container_id, arginfo_DDTrace_System_container_id, 0, NULL, NULL) ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace\\System", "process_tags_base_hash"), zif_DDTrace_System_process_tags_base_hash, arginfo_DDTrace_System_process_tags_base_hash, 0, NULL, NULL) ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace\\Config", "integration_analytics_enabled"), zif_DDTrace_Config_integration_analytics_enabled, arginfo_DDTrace_Config_integration_analytics_enabled, 0, NULL, NULL) @@ -497,6 +521,7 @@ static const zend_function_entry ext_functions[] = { ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace\\UserRequest", "notify_start"), zif_DDTrace_UserRequest_notify_start, arginfo_DDTrace_UserRequest_notify_start, 0, NULL, NULL) ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace\\UserRequest", "notify_commit"), zif_DDTrace_UserRequest_notify_commit, arginfo_DDTrace_UserRequest_notify_commit, 0, NULL, NULL) ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace\\UserRequest", "set_blocking_function"), zif_DDTrace_UserRequest_set_blocking_function, arginfo_DDTrace_UserRequest_set_blocking_function, 0, NULL, NULL) + ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace\\Testing", "ffe_load_config"), zif_DDTrace_Testing_ffe_load_config, arginfo_DDTrace_Testing_ffe_load_config, 0, NULL, NULL) ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace\\Testing", "trigger_error"), zif_DDTrace_Testing_trigger_error, arginfo_DDTrace_Testing_trigger_error, 0, NULL, NULL) ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace\\Testing", "emit_asm_event"), zif_DDTrace_Testing_emit_asm_event, arginfo_DDTrace_Testing_emit_asm_event, 0, NULL, NULL) ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace\\Testing", "normalize_tag_value"), zif_DDTrace_Testing_normalize_tag_value, arginfo_DDTrace_Testing_normalize_tag_value, 0, NULL, NULL) diff --git a/ext/remote_config.c b/ext/remote_config.c index a6600b6f620..ed9bbec929f 100644 --- a/ext/remote_config.c +++ b/ext/remote_config.c @@ -64,6 +64,17 @@ void ddtrace_check_for_new_config_now(void) { } } +void ddtrace_process_remote_config_now(void) { + if (!DDTRACE_G(remote_config_state)) { + return; + } + + DDTRACE_G(reread_remote_configuration) = 0; + if (ddog_process_remote_configs(DDTRACE_G(remote_config_state))) { + ddtrace_set_all_thread_vm_interrupt(); + } +} + #ifndef _WIN32 static void dd_sigvtalarm_handler(int signal, siginfo_t *siginfo, void *ctx) { UNUSED(signal, siginfo, ctx); diff --git a/ext/remote_config.h b/ext/remote_config.h index 88e04808c5b..521909c7d55 100644 --- a/ext/remote_config.h +++ b/ext/remote_config.h @@ -8,6 +8,7 @@ void ddtrace_mshutdown_remote_config(void); void ddtrace_rinit_remote_config(void); void ddtrace_rshutdown_remote_config(void); void ddtrace_check_for_new_config_now(void); +void ddtrace_process_remote_config_now(void); DDTRACE_PUBLIC void ddtrace_set_all_thread_vm_interrupt(void); diff --git a/ext/sidecar.c b/ext/sidecar.c index 9ea81ab2220..1713e86691e 100644 --- a/ext/sidecar.c +++ b/ext/sidecar.c @@ -381,7 +381,8 @@ bool ddtrace_sidecar_should_enable(bool *appsec_activation, bool *appsec_config) bool enable_sidecar = ddtrace_sidecar_maybe_enable_appsec(appsec_activation, appsec_config); if (!enable_sidecar) { enable_sidecar = get_global_DD_INSTRUMENTATION_TELEMETRY_ENABLED() || - get_global_DD_TRACE_SIDECAR_TRACE_SENDER(); + get_global_DD_TRACE_SIDECAR_TRACE_SENDER() || + get_global_DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED(); } return enable_sidecar; } @@ -390,7 +391,12 @@ void ddtrace_sidecar_setup(bool appsec_activation, bool appsec_config) { ddtrace_set_non_resettable_sidecar_globals(); ddtrace_set_resettable_sidecar_globals(); - ddog_init_remote_config(get_global_DD_INSTRUMENTATION_TELEMETRY_ENABLED(), appsec_activation, appsec_config); + ddog_init_remote_config((struct ddog_DdogRemoteConfigFlags){ + .live_debugging_enabled = get_global_DD_INSTRUMENTATION_TELEMETRY_ENABLED(), + .appsec_activation = appsec_activation, + .appsec_config = appsec_config, + .ffe_enabled = get_global_DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED(), + }); zend_long mode = get_global_DD_TRACE_SIDECAR_CONNECTION_MODE(); diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index e65fd5c4c98..b146be36e05 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -382,6 +382,13 @@ "default": "false" } ], + "DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED": [ + { + "implementation": "A", + "type": "boolean", + "default": "false" + } + ], "DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED": [ { "implementation": "B", diff --git a/src/DDTrace/OpenFeature/DataDogProvider.php b/src/DDTrace/OpenFeature/DataDogProvider.php new file mode 100644 index 00000000000..fcc534ab92a --- /dev/null +++ b/src/DDTrace/OpenFeature/DataDogProvider.php @@ -0,0 +1,211 @@ +client = FeatureFlagsClient::createWithDependencies(null, new NoopWarningEmitter()); + $this->warningEmitter = new TriggerErrorWarningEmitter(); + } + + /** + * @internal Tests and Datadog-owned bridge adapters only. + */ + public static function createWithDependencies( + ?FeatureFlagsClient $client = null, + ?WarningEmitter $warningEmitter = null + ): self { + $provider = new self(); + if ($client !== null) { + $provider->client = $client; + } + if ($warningEmitter !== null) { + $provider->warningEmitter = $warningEmitter; + } + + return $provider; + } + + public function resolveBooleanValue( + string $flagKey, + bool $defaultValue, + ?EvaluationContext $context = null + ): ResolutionDetailsInterface { + return $this->resolve($flagKey, FlagValueType::BOOLEAN, $defaultValue, $context); + } + + public function resolveStringValue( + string $flagKey, + string $defaultValue, + ?EvaluationContext $context = null + ): ResolutionDetailsInterface { + return $this->resolve($flagKey, FlagValueType::STRING, $defaultValue, $context); + } + + public function resolveIntegerValue( + string $flagKey, + int $defaultValue, + ?EvaluationContext $context = null + ): ResolutionDetailsInterface { + return $this->resolve($flagKey, FlagValueType::INTEGER, $defaultValue, $context); + } + + public function resolveFloatValue( + string $flagKey, + float $defaultValue, + ?EvaluationContext $context = null + ): ResolutionDetailsInterface { + return $this->resolve($flagKey, FlagValueType::FLOAT, $defaultValue, $context); + } + + /** + * @param array $defaultValue + */ + public function resolveObjectValue( + string $flagKey, + array $defaultValue, + ?EvaluationContext $context = null + ): ResolutionDetailsInterface { + return $this->resolve($flagKey, FlagValueType::OBJECT, $defaultValue, $context); + } + + private function resolve( + string $flagKey, + string $expectedType, + mixed $defaultValue, + ?EvaluationContext $context + ): ResolutionDetailsInterface { + $details = $this->evaluate($flagKey, $expectedType, $defaultValue, $this->normalizeContext($context)); + $this->warnIfNonProductionRuntime($details); + + $builder = (new ResolutionDetailsBuilder()) + ->withValue($details->getValue()) + ->withReason($this->mapReason($details->getReason())); + + $variant = $details->getVariant(); + if ($variant !== null && $variant !== '') { + $builder->withVariant($variant); + } + + if ($details->getErrorCode() !== null) { + $builder->withError(new ResolutionError( + $this->mapErrorCode($details->getErrorCode()), + $details->getErrorMessage() + )); + } + + return $builder->build(); + } + + /** + * @param bool|string|int|float|array $defaultValue + * @param array $context + */ + private function evaluate( + string $flagKey, + string $expectedType, + mixed $defaultValue, + array $context + ): EvaluationDetails { + return match ($expectedType) { + FlagValueType::BOOLEAN => $this->client->getBooleanDetails($flagKey, $defaultValue, $context), + FlagValueType::STRING => $this->client->getStringDetails($flagKey, $defaultValue, $context), + FlagValueType::INTEGER => $this->client->getIntegerDetails($flagKey, $defaultValue, $context), + FlagValueType::FLOAT => $this->client->getFloatDetails($flagKey, $defaultValue, $context), + FlagValueType::OBJECT => $this->client->getObjectDetails($flagKey, $defaultValue, $context), + default => throw new \InvalidArgumentException('Unknown OpenFeature flag value type: ' . $expectedType), + }; + } + + /** + * @return array{targetingKey?: ?string, attributes?: array} + */ + private function normalizeContext(?EvaluationContext $context): array + { + if ($context === null) { + return []; + } + + $attributes = []; + foreach ($context->getAttributes()->toArray() as $key => $value) { + if (is_bool($value) || is_int($value) || is_float($value) || is_string($value)) { + $attributes[(string) $key] = $value; + } + } + + return [ + 'targetingKey' => $context->getTargetingKey(), + 'attributes' => $attributes, + ]; + } + + private function warnIfNonProductionRuntime(EvaluationDetails $details): void + { + if ($this->warnedAboutNonProductionRuntime) { + return; + } + + $providerState = $details->getProviderState(); + if (!array_key_exists('productionRuntime', $providerState) || $providerState['productionRuntime'] !== false) { + return; + } + + $message = $details->getErrorMessage(); + if (!is_string($message) || $message === '') { + $message = 'Datadog-backed PHP OpenFeature evaluation is not fully enabled yet.'; + } + + $this->warningEmitter->warning($message); + $this->warnedAboutNonProductionRuntime = true; + } + + private function mapReason(string $reason): string + { + return match ($reason) { + EvaluationReason::STATIC_REASON => EvaluationReason::STATIC_REASON, + EvaluationReason::DEFAULT_REASON => OpenFeatureReason::DEFAULT, + EvaluationReason::TARGETING_MATCH => OpenFeatureReason::TARGETING_MATCH, + EvaluationReason::SPLIT => OpenFeatureReason::SPLIT, + EvaluationReason::DISABLED => OpenFeatureReason::DISABLED, + EvaluationReason::ERROR => OpenFeatureReason::ERROR, + default => OpenFeatureReason::UNKNOWN, + }; + } + + private function mapErrorCode(string $errorCode): ErrorCode + { + return match ($errorCode) { + EvaluationErrorCode::PROVIDER_NOT_READY => ErrorCode::PROVIDER_NOT_READY(), + EvaluationErrorCode::FLAG_NOT_FOUND => ErrorCode::FLAG_NOT_FOUND(), + EvaluationErrorCode::PARSE_ERROR => ErrorCode::PARSE_ERROR(), + EvaluationErrorCode::TYPE_MISMATCH => ErrorCode::TYPE_MISMATCH(), + default => ErrorCode::GENERAL(), + }; + } +} diff --git a/src/api/FeatureFlags/Client.php b/src/api/FeatureFlags/Client.php new file mode 100644 index 00000000000..8bc2cdc6fc9 --- /dev/null +++ b/src/api/FeatureFlags/Client.php @@ -0,0 +1,201 @@ +evaluator = $evaluator; + $this->warningEmitter = $warningEmitter; + } + + public static function create() + { + return self::createWithDependencies(); + } + + /** + * @internal Tests and Datadog-owned bridge adapters only. + */ + public static function createWithDependencies( + $evaluator = null, + $warningEmitter = null + ) { + if ($evaluator !== null && !$evaluator instanceof Evaluator) { + throw new \InvalidArgumentException('Expected an Evaluator instance'); + } + + if ($warningEmitter !== null && !$warningEmitter instanceof WarningEmitter) { + throw new \InvalidArgumentException('Expected a WarningEmitter instance'); + } + + return new self( + $evaluator ?: NativeEvaluator::createOrUnavailable(), + $warningEmitter ?: new TriggerErrorWarningEmitter() + ); + } + + public function getBooleanValue($flagKey, $defaultValue, array $context = array()) + { + return $this->getBooleanDetails($flagKey, $defaultValue, $context)->getValue(); + } + + public function getStringValue($flagKey, $defaultValue, array $context = array()) + { + return $this->getStringDetails($flagKey, $defaultValue, $context)->getValue(); + } + + public function getIntegerValue($flagKey, $defaultValue, array $context = array()) + { + return $this->getIntegerDetails($flagKey, $defaultValue, $context)->getValue(); + } + + public function getFloatValue($flagKey, $defaultValue, array $context = array()) + { + return $this->getFloatDetails($flagKey, $defaultValue, $context)->getValue(); + } + + public function getObjectValue($flagKey, array $defaultValue, array $context = array()) + { + return $this->getObjectDetails($flagKey, $defaultValue, $context)->getValue(); + } + + public function getBooleanDetails($flagKey, $defaultValue, array $context = array()) + { + return $this->evaluate($flagKey, EvaluationType::BOOLEAN, $this->expectBoolean($defaultValue), $context); + } + + public function getStringDetails($flagKey, $defaultValue, array $context = array()) + { + return $this->evaluate($flagKey, EvaluationType::STRING, $this->expectString($defaultValue), $context); + } + + public function getIntegerDetails($flagKey, $defaultValue, array $context = array()) + { + return $this->evaluate($flagKey, EvaluationType::INTEGER, $this->expectInteger($defaultValue), $context); + } + + public function getFloatDetails($flagKey, $defaultValue, array $context = array()) + { + return $this->evaluate($flagKey, EvaluationType::FLOAT, $this->expectFloat($defaultValue), $context); + } + + public function getObjectDetails($flagKey, array $defaultValue, array $context = array()) + { + return $this->evaluate($flagKey, EvaluationType::OBJECT, $defaultValue, $context); + } + + private function evaluate($flagKey, $expectedType, $defaultValue, array $context) + { + $flagKey = $this->expectFlagKey($flagKey); + list($targetingKey, $attributes) = $this->normalizeContext($context); + + $details = $this->evaluator->evaluate( + $flagKey, + $expectedType, + $defaultValue, + $targetingKey, + $attributes + ); + + $this->warnIfNonProductionRuntime($details); + + return $details; + } + + private function normalizeContext(array $context) + { + $targetingKey = null; + if (array_key_exists('targetingKey', $context) && $context['targetingKey'] !== null) { + $targetingKey = (string) $context['targetingKey']; + } + + $attributes = array(); + if (isset($context['attributes']) && is_array($context['attributes'])) { + foreach ($context['attributes'] as $key => $value) { + if (is_bool($value) || is_int($value) || is_float($value) || is_string($value)) { + $attributes[(string) $key] = $value; + } + } + } + + return array($targetingKey, $attributes); + } + + private function warnIfNonProductionRuntime(EvaluationDetails $details) + { + if ($this->warnedAboutNonProductionRuntime) { + return; + } + + $providerState = $details->getProviderState(); + if (!array_key_exists('productionRuntime', $providerState) || $providerState['productionRuntime'] !== false) { + return; + } + + $message = $details->getErrorMessage(); + if (!is_string($message) || $message === '') { + $message = 'Datadog-backed PHP feature flag evaluation is not fully enabled yet.'; + } + + $this->warningEmitter->warning($message); + $this->warnedAboutNonProductionRuntime = true; + } + + private function expectFlagKey($flagKey) + { + if (!is_string($flagKey) || $flagKey === '') { + throw new \InvalidArgumentException('Feature flag key must be a non-empty string'); + } + + return $flagKey; + } + + private function expectBoolean($value) + { + if (!is_bool($value)) { + throw new \InvalidArgumentException('Boolean flag default value must be a bool'); + } + + return $value; + } + + private function expectString($value) + { + if (!is_string($value)) { + throw new \InvalidArgumentException('String flag default value must be a string'); + } + + return $value; + } + + private function expectInteger($value) + { + if (!is_int($value)) { + throw new \InvalidArgumentException('Integer flag default value must be an int'); + } + + return $value; + } + + private function expectFloat($value) + { + if (!is_int($value) && !is_float($value)) { + throw new \InvalidArgumentException('Float flag default value must be a number'); + } + + return (float) $value; + } +} diff --git a/src/api/FeatureFlags/EvaluationDetails.php b/src/api/FeatureFlags/EvaluationDetails.php new file mode 100644 index 00000000000..cc788a1a1ee --- /dev/null +++ b/src/api/FeatureFlags/EvaluationDetails.php @@ -0,0 +1,111 @@ + $flagMetadata + * @param array $exposureData + * @param array $providerState + */ + public function __construct( + $value, + $valueType, + $reason, + $variant = null, + $errorCode = null, + $errorMessage = null, + array $flagMetadata = array(), + array $exposureData = array(), + array $providerState = array() + ) { + if (!EvaluationType::isValid($valueType)) { + throw new \InvalidArgumentException('Unknown feature flag value type: ' . (string) $valueType); + } + + if (!EvaluationReason::isValid($reason)) { + throw new \InvalidArgumentException('Unknown feature flag evaluation reason: ' . (string) $reason); + } + + if (!EvaluationErrorCode::isValid($errorCode)) { + throw new \InvalidArgumentException('Unknown feature flag evaluation error code: ' . (string) $errorCode); + } + + $this->value = $value; + $this->valueType = $valueType; + $this->reason = $reason; + $this->variant = $variant; + $this->errorCode = $errorCode; + $this->errorMessage = $errorMessage; + $this->flagMetadata = $flagMetadata; + $this->exposureData = $exposureData; + $this->providerState = $providerState; + } + + public function getValue() + { + return $this->value; + } + + public function getValueType() + { + return $this->valueType; + } + + public function getReason() + { + return $this->reason; + } + + public function getVariant() + { + return $this->variant; + } + + public function getErrorCode() + { + return $this->errorCode; + } + + public function getErrorMessage() + { + return $this->errorMessage; + } + + public function getFlagMetadata() + { + return $this->flagMetadata; + } + + public function getExposureData() + { + return $this->exposureData; + } + + public function getProviderState() + { + return $this->providerState; + } + + public function isError() + { + return $this->errorCode !== null; + } +} diff --git a/src/api/FeatureFlags/EvaluationErrorCode.php b/src/api/FeatureFlags/EvaluationErrorCode.php new file mode 100644 index 00000000000..a8f9a722a22 --- /dev/null +++ b/src/api/FeatureFlags/EvaluationErrorCode.php @@ -0,0 +1,29 @@ + true, + self::PARSE_ERROR => true, + self::TYPE_MISMATCH => true, + self::GENERAL => true, + self::PROVIDER_NOT_READY => true, + ); + + private function __construct() + { + } + + public static function isValid($errorCode) + { + return $errorCode === null || isset(self::$valid[$errorCode]); + } +} diff --git a/src/api/FeatureFlags/EvaluationReason.php b/src/api/FeatureFlags/EvaluationReason.php new file mode 100644 index 00000000000..7983797b9bf --- /dev/null +++ b/src/api/FeatureFlags/EvaluationReason.php @@ -0,0 +1,31 @@ + true, + self::DEFAULT_REASON => true, + self::TARGETING_MATCH => true, + self::SPLIT => true, + self::DISABLED => true, + self::ERROR => true, + ); + + private function __construct() + { + } + + public static function isValid($reason) + { + return isset(self::$valid[$reason]); + } +} diff --git a/src/api/FeatureFlags/EvaluationType.php b/src/api/FeatureFlags/EvaluationType.php new file mode 100644 index 00000000000..fa20632137c --- /dev/null +++ b/src/api/FeatureFlags/EvaluationType.php @@ -0,0 +1,54 @@ + true, + self::STRING => true, + self::INTEGER => true, + self::FLOAT => true, + self::OBJECT => true, + ); + + private function __construct() + { + } + + public static function isValid($valueType) + { + return isset(self::$valid[$valueType]); + } + + public static function fromDefaultValue($defaultValue) + { + if (is_bool($defaultValue)) { + return self::BOOLEAN; + } + + if (is_string($defaultValue)) { + return self::STRING; + } + + if (is_int($defaultValue)) { + return self::INTEGER; + } + + if (is_float($defaultValue)) { + return self::FLOAT; + } + + if (is_array($defaultValue)) { + return self::OBJECT; + } + + throw new \InvalidArgumentException('Unsupported feature flag default value type'); + } +} diff --git a/src/api/FeatureFlags/Internal/Evaluator.php b/src/api/FeatureFlags/Internal/Evaluator.php new file mode 100644 index 00000000000..132e26cbcb2 --- /dev/null +++ b/src/api/FeatureFlags/Internal/Evaluator.php @@ -0,0 +1,18 @@ + $attributes + * @return EvaluationDetails + */ + public function evaluate($flagKey, $expectedType, $defaultValue, $targetingKey = null, array $attributes = array()); +} diff --git a/src/api/FeatureFlags/Internal/NativeEvaluator.php b/src/api/FeatureFlags/Internal/NativeEvaluator.php new file mode 100644 index 00000000000..3096d576d82 --- /dev/null +++ b/src/api/FeatureFlags/Internal/NativeEvaluator.php @@ -0,0 +1,134 @@ +mapper = $mapper ?: new ResultMapper(); + $this->unavailableEvaluator = $unavailableEvaluator ?: new UnavailableEvaluator(); + $this->remoteConfig = $remoteConfig ?: new RemoteConfigClient(); + } + + public static function isAvailable() + { + return function_exists('DDTrace\\ffe_evaluate') + && RemoteConfigClient::isAvailable(); + } + + public static function createOrUnavailable() + { + return self::isAvailable() ? new self() : new UnavailableEvaluator(); + } + + public function evaluate( + $flagKey, + $expectedType, + $defaultValue, + $targetingKey = null, + array $attributes = array() + ) { + if (!self::isAvailable()) { + return $this->unavailableEvaluator->evaluate( + $flagKey, + $expectedType, + $defaultValue, + $targetingKey, + $attributes + ); + } + + $rawResult = \DDTrace\ffe_evaluate( + $flagKey, + $this->typeId($expectedType), + $targetingKey, + $this->normalizeAttributes($attributes) + ); + + if (is_array($rawResult)) { + $rawResult = $this->withProviderState($rawResult); + } + + return $this->mapper->map($rawResult, $expectedType, $defaultValue); + } + + private function typeId($expectedType) + { + switch ($expectedType) { + case EvaluationType::STRING: + return 0; + case EvaluationType::INTEGER: + return 1; + case EvaluationType::FLOAT: + return 2; + case EvaluationType::BOOLEAN: + return 3; + case EvaluationType::OBJECT: + return 4; + } + + throw new \InvalidArgumentException('Unknown feature flag value type: ' . (string) $expectedType); + } + + private function normalizeAttributes(array $attributes) + { + $normalized = array(); + foreach ($attributes as $key => $value) { + if (is_bool($value) || is_int($value) || is_float($value) || is_string($value)) { + $normalized[(string) $key] = $value; + } + } + + return $normalized; + } + + private function withProviderState(array $rawResult) + { + $hasConfig = $this->remoteConfig->hasConfig(); + $configVersion = $this->remoteConfig->configVersion(); + + $providerState = array( + 'ready' => $hasConfig, + 'hasConfig' => $hasConfig, + 'configVersion' => $configVersion, + 'productionRuntime' => false, + 'mode' => 'native_remote_config', + 'reason' => $hasConfig ? 'metrics_delivery_pending' : 'configuration_missing', + ); + + if (isset($rawResult['provider_state']) && is_array($rawResult['provider_state'])) { + $providerState = array_merge($providerState, $rawResult['provider_state']); + } + + if (!$hasConfig) { + $rawResult['error_message'] = self::WARNING_MESSAGE; + } + + $rawResult['provider_state'] = $providerState; + $rawResult['has_config'] = $hasConfig; + $rawResult['config_version'] = $configVersion; + + return $rawResult; + } +} diff --git a/src/api/FeatureFlags/Internal/NoopWarningEmitter.php b/src/api/FeatureFlags/Internal/NoopWarningEmitter.php new file mode 100644 index 00000000000..efeb57f0046 --- /dev/null +++ b/src/api/FeatureFlags/Internal/NoopWarningEmitter.php @@ -0,0 +1,12 @@ +hasConfig = $hasConfig ?: function () { + return function_exists('DDTrace\\ffe_has_config') && \DDTrace\ffe_has_config(); + }; + $this->configVersion = $configVersion ?: function () { + return function_exists('DDTrace\\ffe_config_version') ? \DDTrace\ffe_config_version() : 0; + }; + } + + public static function isAvailable() + { + return function_exists('DDTrace\\ffe_has_config') + && function_exists('DDTrace\\ffe_config_version'); + } + + public function hasConfig() + { + return (bool) call_user_func($this->hasConfig); + } + + public function configVersion() + { + $version = call_user_func($this->configVersion); + return is_int($version) ? $version : (int) $version; + } +} diff --git a/src/api/FeatureFlags/Internal/ResultMapper.php b/src/api/FeatureFlags/Internal/ResultMapper.php new file mode 100644 index 00000000000..9a971192bb3 --- /dev/null +++ b/src/api/FeatureFlags/Internal/ResultMapper.php @@ -0,0 +1,316 @@ +|EvaluationDetails|null $rawResult + * @param string $expectedType One of EvaluationType::*. + * @param mixed $defaultValue + * @return EvaluationDetails + */ + public function map($rawResult, $expectedType, $defaultValue) + { + if (!EvaluationType::isValid($expectedType)) { + throw new \InvalidArgumentException('Unknown feature flag value type: ' . (string) $expectedType); + } + + if ($rawResult instanceof EvaluationDetails) { + return $rawResult; + } + + if ($rawResult === null) { + return $this->errorDetails( + $defaultValue, + $expectedType, + EvaluationErrorCode::PROVIDER_NOT_READY, + 'FFE evaluator is not ready', + array('ready' => false) + ); + } + + if (!is_array($rawResult)) { + return $this->errorDetails( + $defaultValue, + $expectedType, + EvaluationErrorCode::GENERAL, + 'FFE evaluator returned an invalid result' + ); + } + + $errorCode = $this->mapErrorCode( + $this->read($rawResult, array('error_code', 'errorCode'), self::BRIDGE_ERROR_GENERAL) + ); + if ($errorCode !== null) { + return $this->errorDetails( + $defaultValue, + $expectedType, + $errorCode, + $this->read($rawResult, array('error_message', 'errorMessage'), null), + $this->readArray($rawResult, array('provider_state', 'providerState')) + ); + } + + $reason = $this->mapReason($this->read($rawResult, array('reason'), self::BRIDGE_REASON_DEFAULT)); + if ($this->isDefaultReturn($rawResult, $reason)) { + return $this->defaultDetails($defaultValue, $expectedType, $reason, $rawResult); + } + + $decoded = null; + $decodeError = $this->decodeValue($rawResult, $expectedType, $decoded); + if ($decodeError !== null) { + return $this->errorDetails( + $defaultValue, + $expectedType, + $decodeError, + $decodeError === EvaluationErrorCode::PARSE_ERROR + ? 'FFE evaluator returned invalid JSON' + : 'FFE evaluator returned a value with the wrong type', + $this->readArray($rawResult, array('provider_state', 'providerState')) + ); + } + + return new EvaluationDetails( + $decoded, + $expectedType, + $reason, + $this->read($rawResult, array('variant'), null), + null, + null, + $this->readArray($rawResult, array('flag_metadata', 'flagMetadata', 'metadata')), + $this->exposureData($rawResult), + $this->providerState($rawResult) + ); + } + + private function defaultDetails($defaultValue, $expectedType, $reason, array $rawResult) + { + return new EvaluationDetails( + $defaultValue, + $expectedType, + $reason, + null, + null, + null, + $this->readArray($rawResult, array('flag_metadata', 'flagMetadata', 'metadata')), + array(), + $this->providerState($rawResult) + ); + } + + private function errorDetails( + $defaultValue, + $expectedType, + $errorCode, + $errorMessage = null, + array $providerState = array() + ) { + return new EvaluationDetails( + $defaultValue, + $expectedType, + EvaluationReason::ERROR, + null, + $errorCode, + $errorMessage, + array(), + array(), + $providerState + ); + } + + private function decodeValue(array $rawResult, $expectedType, &$decoded) + { + if (array_key_exists('value', $rawResult)) { + $value = $rawResult['value']; + } else { + $valueJson = $this->read($rawResult, array('value_json', 'valueJson'), null); + if (!is_string($valueJson) || $valueJson === '') { + return EvaluationErrorCode::PARSE_ERROR; + } + + $value = json_decode($valueJson, true); + if (json_last_error() !== JSON_ERROR_NONE) { + return EvaluationErrorCode::PARSE_ERROR; + } + } + + if (!$this->coerceValue($value, $expectedType, $decoded)) { + return EvaluationErrorCode::TYPE_MISMATCH; + } + + return null; + } + + private function isDefaultReturn(array $rawResult, $reason) + { + if ($reason !== EvaluationReason::DEFAULT_REASON && $reason !== EvaluationReason::DISABLED) { + return false; + } + + if (array_key_exists('value', $rawResult)) { + return $rawResult['value'] === null; + } + + $valueJson = $this->read($rawResult, array('value_json', 'valueJson'), null); + + return is_string($valueJson) && trim($valueJson) === 'null'; + } + + private function coerceValue($value, $expectedType, &$coerced) + { + switch ($expectedType) { + case EvaluationType::BOOLEAN: + if (is_bool($value)) { + $coerced = $value; + return true; + } + return false; + + case EvaluationType::STRING: + if (is_string($value)) { + $coerced = $value; + return true; + } + return false; + + case EvaluationType::INTEGER: + if (is_int($value)) { + $coerced = $value; + return true; + } + return false; + + case EvaluationType::FLOAT: + if (is_int($value) || is_float($value)) { + $coerced = (float) $value; + return true; + } + return false; + + case EvaluationType::OBJECT: + if (is_array($value)) { + $coerced = $value; + return true; + } + return false; + } + + return false; + } + + private function mapErrorCode($errorCode) + { + if ($errorCode === null || $errorCode === self::BRIDGE_ERROR_NONE || $errorCode === '0') { + return null; + } + + if (is_string($errorCode) && EvaluationErrorCode::isValid($errorCode)) { + return $errorCode; + } + + switch ((int) $errorCode) { + case self::BRIDGE_ERROR_TYPE_MISMATCH: + return EvaluationErrorCode::TYPE_MISMATCH; + case self::BRIDGE_ERROR_CONFIG_PARSE: + return EvaluationErrorCode::PARSE_ERROR; + case self::BRIDGE_ERROR_FLAG_UNRECOGNIZED: + return EvaluationErrorCode::FLAG_NOT_FOUND; + case self::BRIDGE_ERROR_CONFIG_MISSING: + return EvaluationErrorCode::PROVIDER_NOT_READY; + case self::BRIDGE_ERROR_GENERAL: + default: + return EvaluationErrorCode::GENERAL; + } + } + + private function mapReason($reason) + { + if (is_string($reason) && EvaluationReason::isValid($reason)) { + return $reason; + } + + switch ((int) $reason) { + case self::BRIDGE_REASON_STATIC: + return EvaluationReason::STATIC_REASON; + case self::BRIDGE_REASON_TARGETING_MATCH: + return EvaluationReason::TARGETING_MATCH; + case self::BRIDGE_REASON_SPLIT: + return EvaluationReason::SPLIT; + case self::BRIDGE_REASON_DISABLED: + return EvaluationReason::DISABLED; + case self::BRIDGE_REASON_ERROR: + return EvaluationReason::ERROR; + case self::BRIDGE_REASON_DEFAULT: + default: + return EvaluationReason::DEFAULT_REASON; + } + } + + private function exposureData(array $rawResult) + { + $exposureData = $this->readArray($rawResult, array('exposure_data', 'exposureData')); + + if (array_key_exists('allocation_key', $rawResult)) { + $exposureData['allocationKey'] = $rawResult['allocation_key']; + } + + if (array_key_exists('do_log', $rawResult)) { + $exposureData['doLog'] = (bool) $rawResult['do_log']; + } + + return $exposureData; + } + + private function providerState(array $rawResult) + { + $providerState = $this->readArray($rawResult, array('provider_state', 'providerState')); + + if (array_key_exists('has_config', $rawResult)) { + $providerState['hasConfig'] = (bool) $rawResult['has_config']; + } + + if (array_key_exists('config_version', $rawResult)) { + $providerState['configVersion'] = $rawResult['config_version']; + } + + return $providerState; + } + + private function readArray(array $rawResult, array $keys) + { + $value = $this->read($rawResult, $keys, array()); + + return is_array($value) ? $value : array(); + } + + private function read(array $rawResult, array $keys, $default) + { + foreach ($keys as $key) { + if (array_key_exists($key, $rawResult)) { + return $rawResult[$key]; + } + } + + return $default; + } +} diff --git a/src/api/FeatureFlags/Internal/TriggerErrorWarningEmitter.php b/src/api/FeatureFlags/Internal/TriggerErrorWarningEmitter.php new file mode 100644 index 00000000000..0831b91dd29 --- /dev/null +++ b/src/api/FeatureFlags/Internal/TriggerErrorWarningEmitter.php @@ -0,0 +1,11 @@ + false, + 'productionRuntime' => false, + 'reason' => 'runtime_unavailable', + ) + ); + } +} diff --git a/src/api/FeatureFlags/Internal/WarningEmitter.php b/src/api/FeatureFlags/Internal/WarningEmitter.php new file mode 100644 index 00000000000..0a2d6c1eb74 --- /dev/null +++ b/src/api/FeatureFlags/Internal/WarningEmitter.php @@ -0,0 +1,12 @@ +getMetadata()->getName()); + } + + public function testOpenFeatureClientResolvesTypedValuesThroughDatadogClient(): void + { + $evaluator = new OpenFeatureTestEvaluator(); + $evaluator + ->setSuccess('bool.flag', true, EvaluationReason::TARGETING_MATCH, 'on') + ->setSuccess('string.flag', 'blue') + ->setSuccess('integer.flag', 42) + ->setSuccess('float.flag', 3.5) + ->setSuccess('object.flag', ['enabled' => true]); + + $client = $this->openFeatureClientFor(DataDogProvider::createWithDependencies($this->clientForEvaluator($evaluator))); + + self::assertTrue($client->getBooleanValue('bool.flag', false)); + self::assertSame('blue', $client->getStringValue('string.flag', 'red')); + self::assertSame(42, $client->getIntegerValue('integer.flag', 0)); + self::assertSame(3.5, $client->getFloatValue('float.flag', 0.0)); + self::assertSame(['enabled' => true], $client->getObjectValue('object.flag', [])); + + $details = $client->getBooleanDetails('bool.flag', false); + self::assertSame('bool.flag', $details->getFlagKey()); + self::assertTrue($details->getValue()); + self::assertSame(Reason::TARGETING_MATCH, $details->getReason()); + self::assertSame('on', $details->getVariant()); + self::assertNull($details->getError()); + } + + public function testStaticReasonIsPreservedAsDatadogReason(): void + { + $evaluator = new OpenFeatureTestEvaluator(); + $evaluator->setSuccess('static.flag', 'value', EvaluationReason::STATIC_REASON); + + $client = $this->openFeatureClientFor(DataDogProvider::createWithDependencies($this->clientForEvaluator($evaluator))); + $details = $client->getStringDetails('static.flag', 'fallback'); + + self::assertSame(EvaluationReason::STATIC_REASON, $details->getReason()); + } + + public function testEvaluationContextIsNormalizedForDatadogClient(): void + { + $evaluator = new OpenFeatureTestEvaluator(); + $evaluator->setSuccess('context.flag', 'on'); + + $provider = DataDogProvider::createWithDependencies($this->clientForEvaluator($evaluator)); + $provider->resolveStringValue('context.flag', 'off', new EvaluationContext( + 'user-123', + new Attributes([ + 'plan' => 'pro', + 'age' => 41, + 'rate' => 1.5, + 'beta' => true, + 'nested' => ['drop'], + 'null' => null, + 'date' => new \DateTimeImmutable(), + ]) + )); + + $calls = $evaluator->getCalls(); + self::assertCount(1, $calls); + self::assertSame('user-123', $calls[0]['targetingKey']); + self::assertSame([ + 'plan' => 'pro', + 'age' => 41, + 'rate' => 1.5, + 'beta' => true, + ], $calls[0]['attributes']); + } + + public function testUnavailableRuntimeReturnsDefaultDetailsAndOneWarning(): void + { + $warnings = new OpenFeatureRecordingWarningEmitter(); + $client = $this->openFeatureClientFor(DataDogProvider::createWithDependencies(null, $warnings)); + + $value = $client->getBooleanValue('checkout.enabled', true); + $details = $client->getStringDetails('checkout.copy', 'fallback'); + + self::assertTrue($value); + self::assertSame('fallback', $details->getValue()); + self::assertSame(Reason::ERROR, $details->getReason()); + self::assertSame(ErrorCode::PROVIDER_NOT_READY()->getValue(), $details->getError()->getResolutionErrorCode()->getValue()); + self::assertContains($details->getError()->getResolutionErrorMessage(), [ + NativeEvaluator::WARNING_MESSAGE, + UnavailableEvaluator::WARNING_MESSAGE, + ]); + self::assertSame([$details->getError()->getResolutionErrorMessage()], $warnings->warnings()); + } + + public function testProviderWarningIsEmittedOncePerProvider(): void + { + $warnings = new OpenFeatureRecordingWarningEmitter(); + $evaluator = new OpenFeatureTestEvaluator(); + $evaluator + ->setUnavailable('first.flag', true, 'temporary unavailable') + ->setUnavailable('second.flag', true, 'temporary unavailable'); + + $client = $this->openFeatureClientFor(DataDogProvider::createWithDependencies($this->clientForEvaluator($evaluator), $warnings)); + + $client->getBooleanValue('first.flag', false); + $client->getBooleanValue('second.flag', false); + + self::assertSame(['temporary unavailable'], $warnings->warnings()); + } + + public function testProviderErrorsMapToOpenFeatureDetails(): void + { + $evaluator = new OpenFeatureTestEvaluator(); + $evaluator->setFlagNotFound('missing.flag'); + + $client = $this->openFeatureClientFor(DataDogProvider::createWithDependencies($this->clientForEvaluator($evaluator))); + $details = $client->getStringDetails('missing.flag', 'fallback'); + + self::assertSame('fallback', $details->getValue()); + self::assertSame(Reason::ERROR, $details->getReason()); + self::assertSame(EvaluationErrorCode::FLAG_NOT_FOUND, $details->getError()->getResolutionErrorCode()->getValue()); + self::assertSame('Feature flag "missing.flag" was not found', $details->getError()->getResolutionErrorMessage()); + } + + public function testTypeMismatchReturnsDefaultWithOpenFeatureError(): void + { + $evaluator = new OpenFeatureTestEvaluator(); + $evaluator->setSuccess('integer.flag', 'not-an-int'); + + $client = $this->openFeatureClientFor(DataDogProvider::createWithDependencies($this->clientForEvaluator($evaluator))); + $details = $client->getIntegerDetails('integer.flag', 7); + + self::assertSame(7, $details->getValue()); + self::assertSame(Reason::ERROR, $details->getReason()); + self::assertSame(EvaluationErrorCode::TYPE_MISMATCH, $details->getError()->getResolutionErrorCode()->getValue()); + } + + private function clientForEvaluator(Evaluator $evaluator): FeatureFlagsClient + { + return FeatureFlagsClient::createWithDependencies($evaluator, new NoopWarningEmitter()); + } + + private function openFeatureClientFor(DataDogProvider $provider) + { + $api = OpenFeatureAPI::getInstance(); + $api->setProvider($provider); + + return $api->getClient('datadog-test'); + } +} + +final class OpenFeatureTestEvaluator implements Evaluator +{ + /** @var array */ + private array $details = []; + + /** @var list> */ + private array $calls = []; + + public function setSuccess( + string $flagKey, + mixed $value, + string $reason = EvaluationReason::STATIC_REASON, + ?string $variant = null + ): self { + $this->details[$flagKey] = new EvaluationDetails( + $value, + $this->typeForValue($value), + $reason, + $variant + ); + + return $this; + } + + public function setUnavailable(string $flagKey, mixed $defaultValue, string $message): self + { + $this->details[$flagKey] = new EvaluationDetails( + $defaultValue, + $this->typeForValue($defaultValue), + EvaluationReason::ERROR, + null, + EvaluationErrorCode::PROVIDER_NOT_READY, + $message, + [], + [], + ['ready' => false, 'productionRuntime' => false, 'reason' => 'test_unavailable'] + ); + + return $this; + } + + public function setFlagNotFound(string $flagKey): self + { + $this->details[$flagKey] = new EvaluationDetails( + 'fallback', + EvaluationType::STRING, + EvaluationReason::ERROR, + null, + EvaluationErrorCode::FLAG_NOT_FOUND, + 'Feature flag "' . $flagKey . '" was not found' + ); + + return $this; + } + + public function evaluate($flagKey, $expectedType, $defaultValue, $targetingKey = null, array $attributes = []) + { + $this->calls[] = [ + 'flagKey' => $flagKey, + 'expectedType' => $expectedType, + 'targetingKey' => $targetingKey, + 'attributes' => $attributes, + ]; + + if (array_key_exists($flagKey, $this->details)) { + $details = $this->details[$flagKey]; + if ($this->matchesExpectedType($details->getValue(), $expectedType)) { + return $details; + } + + return new EvaluationDetails( + $defaultValue, + $expectedType, + EvaluationReason::ERROR, + null, + EvaluationErrorCode::TYPE_MISMATCH, + 'Expected ' . $expectedType . ' flag value' + ); + } + + return new EvaluationDetails( + $defaultValue, + $expectedType, + EvaluationReason::ERROR, + null, + EvaluationErrorCode::PROVIDER_NOT_READY, + UnavailableEvaluator::WARNING_MESSAGE, + [], + [], + ['ready' => false, 'productionRuntime' => false, 'reason' => 'test_missing_result'] + ); + } + + /** + * @return list> + */ + public function getCalls(): array + { + return $this->calls; + } + + private function typeForValue(mixed $value): string + { + if (is_bool($value)) { + return EvaluationType::BOOLEAN; + } + if (is_int($value)) { + return EvaluationType::INTEGER; + } + if (is_float($value)) { + return EvaluationType::FLOAT; + } + if (is_array($value)) { + return EvaluationType::OBJECT; + } + return EvaluationType::STRING; + } + + private function matchesExpectedType(mixed $value, string $expectedType): bool + { + return match ($expectedType) { + EvaluationType::BOOLEAN => is_bool($value), + EvaluationType::STRING => is_string($value), + EvaluationType::INTEGER => is_int($value), + EvaluationType::FLOAT => is_float($value) || is_int($value), + EvaluationType::OBJECT => is_array($value), + default => false, + }; + } +} + +final class OpenFeatureRecordingWarningEmitter implements WarningEmitter +{ + /** @var string[] */ + private array $warnings = []; + + public function warning($message) + { + $this->warnings[] = $message; + } + + /** + * @return string[] + */ + public function warnings(): array + { + return $this->warnings; + } +} +} diff --git a/tests/OpenFeature/composer.json b/tests/OpenFeature/composer.json new file mode 100644 index 00000000000..faab284a54c --- /dev/null +++ b/tests/OpenFeature/composer.json @@ -0,0 +1,7 @@ +{ + "name": "datadog/dd-trace-tests-openfeature", + "require": { + "open-feature/sdk": "^2.1" + }, + "minimum-stability": "stable" +} diff --git a/tests/api/Unit/FeatureFlags/ClientTest.php b/tests/api/Unit/FeatureFlags/ClientTest.php new file mode 100644 index 00000000000..b5fc53b72e1 --- /dev/null +++ b/tests/api/Unit/FeatureFlags/ClientTest.php @@ -0,0 +1,250 @@ +assertInstanceOf(Client::class, Client::create()); + } + + public function testValueMethodsReturnEvaluatedValues() + { + $evaluator = new ClientTestEvaluator(); + $evaluator + ->setSuccess('bool.flag', true) + ->setSuccess('string.flag', 'blue') + ->setSuccess('integer.flag', 42) + ->setSuccess('float.flag', 3.5) + ->setSuccess('object.flag', array('enabled' => true)); + + $client = Client::createWithDependencies($evaluator, new RecordingWarningEmitter()); + + $this->assertTrue($client->getBooleanValue('bool.flag', false)); + $this->assertSame('blue', $client->getStringValue('string.flag', 'red')); + $this->assertSame(42, $client->getIntegerValue('integer.flag', 0)); + $this->assertSame(3.5, $client->getFloatValue('float.flag', 0.0)); + $this->assertSame(array('enabled' => true), $client->getObjectValue('object.flag', array())); + } + + public function testDetailsMethodsExposeEvaluationDetails() + { + $evaluator = new ClientTestEvaluator(); + $evaluator->setSuccess( + 'checkout-redesign', + true, + EvaluationReason::SPLIT, + 'treatment', + array('owner' => 'ffe'), + array('allocationKey' => 'alloc-1'), + array('runtime' => 'test', 'hasConfig' => true) + ); + + $client = Client::createWithDependencies($evaluator, new RecordingWarningEmitter()); + + $details = $client->getBooleanDetails('checkout-redesign', false); + + $this->assertTrue($details->getValue()); + $this->assertSame(EvaluationType::BOOLEAN, $details->getValueType()); + $this->assertSame(EvaluationReason::SPLIT, $details->getReason()); + $this->assertSame('treatment', $details->getVariant()); + $this->assertSame(array('owner' => 'ffe'), $details->getFlagMetadata()); + $this->assertSame(array('allocationKey' => 'alloc-1'), $details->getExposureData()); + $this->assertSame(array('runtime' => 'test', 'hasConfig' => true), $details->getProviderState()); + } + + public function testContextNormalizesTargetingKeyAndPrimitiveAttributes() + { + $evaluator = new ClientTestEvaluator(); + $evaluator->setSuccess('flag.context', 'on'); + + $client = Client::createWithDependencies($evaluator, new RecordingWarningEmitter()); + $client->getStringValue('flag.context', 'off', array( + 'targetingKey' => 123, + 'attributes' => array( + 'plan' => 'pro', + 'age' => 41, + 'rate' => 1.5, + 'beta' => true, + 'nested' => array('drop'), + 'null' => null, + 'object' => new \stdClass(), + ), + )); + + $calls = $evaluator->getCalls(); + $this->assertCount(1, $calls); + $this->assertSame('123', $calls[0]['targetingKey']); + $this->assertSame(array( + 'plan' => 'pro', + 'age' => 41, + 'rate' => 1.5, + 'beta' => true, + ), $calls[0]['attributes']); + } + + public function testUnavailableRuntimeReturnsDefaultWithProviderNotReadyDetailsAndWarning() + { + $warnings = new RecordingWarningEmitter(); + $client = Client::createWithDependencies(null, $warnings); + + $value = $client->getBooleanValue('checkout-redesign', true); + $details = $client->getStringDetails('checkout-copy', 'fallback'); + + $this->assertTrue($value); + $this->assertSame('fallback', $details->getValue()); + $this->assertSame(EvaluationReason::ERROR, $details->getReason()); + $this->assertSame(EvaluationErrorCode::PROVIDER_NOT_READY, $details->getErrorCode()); + $this->assertContains($details->getErrorMessage(), array( + NativeEvaluator::WARNING_MESSAGE, + UnavailableEvaluator::WARNING_MESSAGE, + )); + + $providerState = $details->getProviderState(); + $this->assertSame(false, $providerState['ready']); + $this->assertSame(false, $providerState['productionRuntime']); + $this->assertTrue(in_array($providerState['reason'], array( + 'configuration_missing', + 'runtime_unavailable', + ), true)); + $this->assertSame(array($details->getErrorMessage()), $warnings->warnings()); + } + + public function testWarningIsEmittedOncePerClientNotOncePerEvaluation() + { + $warnings = new RecordingWarningEmitter(); + $client = Client::createWithDependencies(null, $warnings); + + $client->getBooleanValue('flag-1', false); + $client->getBooleanValue('flag-2', false); + $client->getStringDetails('flag-3', 'fallback'); + + $this->assertCount(1, $warnings->warnings()); + } + + /** + * @dataProvider invalidDefaultProvider + */ + public function testTypedMethodsRejectInvalidDefaults($method, $defaultValue) + { + $client = Client::createWithDependencies(new ClientTestEvaluator(), new RecordingWarningEmitter()); + + $this->expectException(\InvalidArgumentException::class); + + $client->$method('flag.invalid', $defaultValue); + } + + public function invalidDefaultProvider() + { + return array( + 'boolean' => array('getBooleanDetails', 'false'), + 'string' => array('getStringDetails', false), + 'integer' => array('getIntegerDetails', 1.2), + 'float' => array('getFloatDetails', '1.2'), + ); + } +} + +final class ClientTestEvaluator implements Evaluator +{ + private $details = array(); + private $calls = array(); + + public function setSuccess( + $flagKey, + $value, + $reason = EvaluationReason::STATIC_REASON, + $variant = null, + array $metadata = array(), + array $exposureData = array(), + array $providerState = array() + ) { + $this->details[$flagKey] = new EvaluationDetails( + $value, + $this->typeForValue($value), + $reason, + $variant, + null, + null, + $metadata, + $exposureData, + $providerState + ); + + return $this; + } + + public function evaluate($flagKey, $expectedType, $defaultValue, $targetingKey = null, array $attributes = array()) + { + $this->calls[] = array( + 'flagKey' => $flagKey, + 'targetingKey' => $targetingKey, + 'attributes' => $attributes, + ); + + if (array_key_exists($flagKey, $this->details)) { + return $this->details[$flagKey]; + } + + return new EvaluationDetails( + $defaultValue, + $expectedType, + EvaluationReason::ERROR, + null, + EvaluationErrorCode::PROVIDER_NOT_READY, + UnavailableEvaluator::WARNING_MESSAGE, + array(), + array(), + array('ready' => false, 'productionRuntime' => false, 'reason' => 'test_missing_result') + ); + } + + public function getCalls() + { + return $this->calls; + } + + private function typeForValue($value) + { + if (is_bool($value)) { + return EvaluationType::BOOLEAN; + } + if (is_int($value)) { + return EvaluationType::INTEGER; + } + if (is_float($value)) { + return EvaluationType::FLOAT; + } + if (is_array($value)) { + return EvaluationType::OBJECT; + } + return EvaluationType::STRING; + } +} + +final class RecordingWarningEmitter implements WarningEmitter +{ + private $warnings = array(); + + public function warning($message) + { + $this->warnings[] = $message; + } + + public function warnings() + { + return $this->warnings; + } +} diff --git a/tests/api/Unit/FeatureFlags/RemoteConfigClientTest.php b/tests/api/Unit/FeatureFlags/RemoteConfigClientTest.php new file mode 100644 index 00000000000..176c4ae4aed --- /dev/null +++ b/tests/api/Unit/FeatureFlags/RemoteConfigClientTest.php @@ -0,0 +1,39 @@ +assertTrue($client->hasConfig()); + $this->assertSame(42, $client->configVersion()); + } + + public function testUnavailableConfigIsReportedWithoutBlocking() + { + $client = new RemoteConfigClient( + function () { + return false; + }, + function () { + return 0; + } + ); + + $this->assertFalse($client->hasConfig()); + $this->assertSame(0, $client->configVersion()); + } +} diff --git a/tests/api/Unit/FeatureFlags/ResultMapperTest.php b/tests/api/Unit/FeatureFlags/ResultMapperTest.php new file mode 100644 index 00000000000..4fad4147783 --- /dev/null +++ b/tests/api/Unit/FeatureFlags/ResultMapperTest.php @@ -0,0 +1,219 @@ +map(array( + 'value_json' => '"blue"', + 'variant' => 'variant-a', + 'allocation_key' => 'alloc-1', + 'reason' => ResultMapper::BRIDGE_REASON_TARGETING_MATCH, + 'error_code' => ResultMapper::BRIDGE_ERROR_NONE, + 'do_log' => true, + 'flag_metadata' => array('owner' => 'ffe'), + 'provider_state' => array('ready' => true), + 'has_config' => true, + 'config_version' => 42, + ), EvaluationType::STRING, 'red'); + + $this->assertSame('blue', $details->getValue()); + $this->assertSame(EvaluationType::STRING, $details->getValueType()); + $this->assertSame(EvaluationReason::TARGETING_MATCH, $details->getReason()); + $this->assertSame('variant-a', $details->getVariant()); + $this->assertNull($details->getErrorCode()); + $this->assertFalse($details->isError()); + $this->assertSame(array('owner' => 'ffe'), $details->getFlagMetadata()); + $this->assertSame(array('allocationKey' => 'alloc-1', 'doLog' => true), $details->getExposureData()); + $this->assertSame( + array('ready' => true, 'hasConfig' => true, 'configVersion' => 42), + $details->getProviderState() + ); + } + + public function testNonZeroErrorReturnsDefaultAndForcesErrorReason() + { + $details = (new ResultMapper())->map(array( + 'value_json' => '"ignored"', + 'variant' => 'ignored-variant', + 'reason' => ResultMapper::BRIDGE_REASON_TARGETING_MATCH, + 'error_code' => ResultMapper::BRIDGE_ERROR_FLAG_UNRECOGNIZED, + 'error_message' => 'Unknown flag', + ), EvaluationType::STRING, 'fallback'); + + $this->assertSame('fallback', $details->getValue()); + $this->assertSame(EvaluationReason::ERROR, $details->getReason()); + $this->assertNull($details->getVariant()); + $this->assertSame(EvaluationErrorCode::FLAG_NOT_FOUND, $details->getErrorCode()); + $this->assertSame('Unknown flag', $details->getErrorMessage()); + $this->assertTrue($details->isError()); + } + + public function testNullResultMapsToProviderNotReady() + { + $details = (new ResultMapper())->map(null, EvaluationType::BOOLEAN, true); + + $this->assertTrue($details->getValue()); + $this->assertSame(EvaluationReason::ERROR, $details->getReason()); + $this->assertSame(EvaluationErrorCode::PROVIDER_NOT_READY, $details->getErrorCode()); + $this->assertSame('FFE evaluator is not ready', $details->getErrorMessage()); + $this->assertSame(array('ready' => false), $details->getProviderState()); + } + + public function testConfigMissingErrorMapsToProviderNotReady() + { + $details = (new ResultMapper())->map(array( + 'value_json' => 'null', + 'reason' => ResultMapper::BRIDGE_REASON_ERROR, + 'error_code' => ResultMapper::BRIDGE_ERROR_CONFIG_MISSING, + 'provider_state' => array('hasConfig' => false), + ), EvaluationType::BOOLEAN, false); + + $this->assertFalse($details->getValue()); + $this->assertSame(EvaluationErrorCode::PROVIDER_NOT_READY, $details->getErrorCode()); + $this->assertSame(array('hasConfig' => false), $details->getProviderState()); + } + + public function testInvalidJsonMapsToParseError() + { + $details = (new ResultMapper())->map(array( + 'value_json' => '{bad-json', + 'reason' => ResultMapper::BRIDGE_REASON_TARGETING_MATCH, + 'error_code' => ResultMapper::BRIDGE_ERROR_NONE, + ), EvaluationType::OBJECT, array('fallback' => true)); + + $this->assertSame(array('fallback' => true), $details->getValue()); + $this->assertSame(EvaluationReason::ERROR, $details->getReason()); + $this->assertSame(EvaluationErrorCode::PARSE_ERROR, $details->getErrorCode()); + } + + public function testDecodedTypeMismatchMapsToTypeMismatch() + { + $details = (new ResultMapper())->map(array( + 'value_json' => '"not-a-bool"', + 'reason' => ResultMapper::BRIDGE_REASON_TARGETING_MATCH, + 'error_code' => ResultMapper::BRIDGE_ERROR_NONE, + ), EvaluationType::BOOLEAN, false); + + $this->assertFalse($details->getValue()); + $this->assertSame(EvaluationReason::ERROR, $details->getReason()); + $this->assertSame(EvaluationErrorCode::TYPE_MISMATCH, $details->getErrorCode()); + } + + public function testDisabledResultReturnsDefaultWithoutTypeMismatch() + { + $details = (new ResultMapper())->map(array( + 'value_json' => 'null', + 'variant' => null, + 'allocation_key' => null, + 'reason' => ResultMapper::BRIDGE_REASON_DISABLED, + 'error_code' => ResultMapper::BRIDGE_ERROR_NONE, + 'do_log' => false, + 'has_config' => true, + 'config_version' => 7, + ), EvaluationType::BOOLEAN, true); + + $this->assertTrue($details->getValue()); + $this->assertSame(EvaluationReason::DISABLED, $details->getReason()); + $this->assertNull($details->getErrorCode()); + $this->assertNull($details->getVariant()); + $this->assertSame(array(), $details->getExposureData()); + $this->assertSame(array('hasConfig' => true, 'configVersion' => 7), $details->getProviderState()); + $this->assertFalse($details->isError()); + } + + public function testDefaultNullResultReturnsDefaultWithoutTypeMismatch() + { + $details = (new ResultMapper())->map(array( + 'value_json' => 'null', + 'reason' => ResultMapper::BRIDGE_REASON_DEFAULT, + 'error_code' => ResultMapper::BRIDGE_ERROR_NONE, + ), EvaluationType::STRING, 'fallback'); + + $this->assertSame('fallback', $details->getValue()); + $this->assertSame(EvaluationReason::DEFAULT_REASON, $details->getReason()); + $this->assertNull($details->getErrorCode()); + $this->assertFalse($details->isError()); + } + + public function testIntegerJsonCanMapToFloat() + { + $details = (new ResultMapper())->map(array( + 'value_json' => '10', + 'reason' => ResultMapper::BRIDGE_REASON_SPLIT, + 'error_code' => ResultMapper::BRIDGE_ERROR_NONE, + ), EvaluationType::FLOAT, 0.0); + + $this->assertSame(10.0, $details->getValue()); + $this->assertSame(EvaluationReason::SPLIT, $details->getReason()); + } + + public function testJsonObjectMapsToObjectDetails() + { + $details = (new ResultMapper())->map(array( + 'value_json' => '{"enabled":true,"threshold":2,"labels":["a","b"]}', + 'variant' => 'json-a', + 'allocation_key' => 'alloc-json', + 'reason' => ResultMapper::BRIDGE_REASON_SPLIT, + 'error_code' => ResultMapper::BRIDGE_ERROR_NONE, + 'do_log' => true, + ), EvaluationType::OBJECT, array('fallback' => true)); + + $this->assertSame(array( + 'enabled' => true, + 'threshold' => 2, + 'labels' => array('a', 'b'), + ), $details->getValue()); + $this->assertSame(EvaluationReason::SPLIT, $details->getReason()); + $this->assertSame('json-a', $details->getVariant()); + $this->assertSame(array('allocationKey' => 'alloc-json', 'doLog' => true), $details->getExposureData()); + } + + /** + * @dataProvider reasonProvider + */ + public function testReasonMapping($bridgeReason, $expectedReason) + { + $details = (new ResultMapper())->map(array( + 'value_json' => 'true', + 'reason' => $bridgeReason, + 'error_code' => ResultMapper::BRIDGE_ERROR_NONE, + ), EvaluationType::BOOLEAN, false); + + $this->assertSame($expectedReason, $details->getReason()); + } + + public function reasonProvider() + { + return array( + 'static' => array(ResultMapper::BRIDGE_REASON_STATIC, EvaluationReason::STATIC_REASON), + 'default' => array(ResultMapper::BRIDGE_REASON_DEFAULT, EvaluationReason::DEFAULT_REASON), + 'targeting match' => array(ResultMapper::BRIDGE_REASON_TARGETING_MATCH, EvaluationReason::TARGETING_MATCH), + 'split' => array(ResultMapper::BRIDGE_REASON_SPLIT, EvaluationReason::SPLIT), + 'disabled' => array(ResultMapper::BRIDGE_REASON_DISABLED, EvaluationReason::DISABLED), + 'error' => array(ResultMapper::BRIDGE_REASON_ERROR, EvaluationReason::ERROR), + ); + } + + public function testExistingEvaluationDetailsPassThrough() + { + $existing = new EvaluationDetails( + 'kept', + EvaluationType::STRING, + EvaluationReason::DEFAULT_REASON + ); + + $details = (new ResultMapper())->map($existing, EvaluationType::STRING, 'fallback'); + + $this->assertSame($existing, $details); + } +} diff --git a/tests/ext/ffe/native_bridge_evaluate.phpt b/tests/ext/ffe/native_bridge_evaluate.phpt new file mode 100644 index 00000000000..18cb8ec5775 --- /dev/null +++ b/tests/ext/ffe/native_bridge_evaluate.phpt @@ -0,0 +1,115 @@ +--TEST-- +FFE native bridge evaluates through libdatadog +--FILE-- + 'US', + 'age' => 42, + 'ignored' => array('drop'), +))); +$object = \DDTrace\ffe_evaluate('object.flag', 4, 'user-1', array()); +show('object_success_value', json_decode($object['value_json'], true)); +show('object_success_metadata', array( + 'variant' => $object['variant'], + 'allocation_key' => $object['allocation_key'], + 'reason' => $object['reason'], + 'error_code' => $object['error_code'], + 'do_log' => $object['do_log'], +)); +show('empty_targeting_key', \DDTrace\ffe_evaluate('empty.targeting.shard.flag', 0, '', array())); +show('missing', \DDTrace\ffe_evaluate('missing.flag', 0, 'user-1', array())); +show('type_mismatch', \DDTrace\ffe_evaluate('string.flag', 3, 'user-1', array())); +show('parse_error', \DDTrace\ffe_evaluate('bad.flag', 0, 'user-1', array())); +?> +--EXPECT-- +has_config_before=false +provider_not_ready={"value_json":"null","variant":null,"allocation_key":null,"reason":5,"error_code":6,"do_log":false} +load=true +has_config_after=true +success={"value_json":"\"blue\"","variant":"blue","allocation_key":"alloc-string","reason":0,"error_code":0,"do_log":true} +object_success_value={"enabled":true,"threshold":2} +object_success_metadata={"variant":"json-a","allocation_key":"alloc-json","reason":0,"error_code":0,"do_log":true} +empty_targeting_key={"value_json":"\"empty-targeting-key\"","variant":"empty-target","allocation_key":"alloc-empty-targeting-key","reason":3,"error_code":0,"do_log":true} +missing={"value_json":"null","variant":null,"allocation_key":null,"reason":1,"error_code":3,"do_log":false} +type_mismatch={"value_json":"null","variant":null,"allocation_key":null,"reason":5,"error_code":1,"do_log":false} +parse_error={"value_json":"null","variant":null,"allocation_key":null,"reason":5,"error_code":2,"do_log":false} diff --git a/tests/ext/ffe/remote_config_lifecycle.phpt b/tests/ext/ffe/remote_config_lifecycle.phpt new file mode 100644 index 00000000000..bfcb6351e89 --- /dev/null +++ b/tests/ext/ffe/remote_config_lifecycle.phpt @@ -0,0 +1,90 @@ +--TEST-- +FFE Remote Config loads and removes UFC config +--SKIPIF-- + +--ENV-- +DD_AGENT_HOST=request-replayer +DD_TRACE_AGENT_PORT=80 +DD_TRACE_GENERATE_ROOT_SPAN=0 +DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS=0.01 +DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED=1 +--INI-- +datadog.trace.agent_test_session_token=ffe/remote_config_lifecycle +--FILE-- + $version); +?> +--CLEAN-- + +--EXPECT-- +before=false +loaded=true +has_config_after_add=true +success={"value_json":"\"blue\"","variant":"blue","allocation_key":"alloc-string","reason":0,"error_code":0,"do_log":true} +removed=true +has_config_after_remove=false +version_increased=true diff --git a/tests/ext/ffe/system_test_data_evaluate.phpt b/tests/ext/ffe/system_test_data_evaluate.phpt new file mode 100644 index 00000000000..7cf653f947b --- /dev/null +++ b/tests/ext/ffe/system_test_data_evaluate.phpt @@ -0,0 +1,250 @@ +--TEST-- +FFE canonical system test data evaluates through the Datadog client +--SKIPIF-- + +--ENV-- +DD_TRACE_GENERATE_ROOT_SPAN=0 +--FILE-- + $case) { + $caseCount++; + try { + run_fixture_case($client, basename($caseFile), $index, $case, $failures); + } catch (\Throwable $exception) { + $failures[] = basename($caseFile) . '#' . $index . ': ' . $exception->getMessage(); + } + } +} + +foreach ($failures as $failure) { + echo "failure=" . $failure . "\n"; +} + +show('fixture_files', count($caseFiles)); +show('cases', $caseCount); +show('failures', count($failures)); + +function require_feature_flag_api($root) +{ + $apiRoot = $root . '/src/api/FeatureFlags'; + foreach (array( + 'EvaluationType', + 'EvaluationReason', + 'EvaluationErrorCode', + 'EvaluationDetails', + ) as $classFile) { + require_once $apiRoot . '/' . $classFile . '.php'; + } + + $internalRoot = $apiRoot . '/Internal'; + foreach (array( + 'Evaluator', + 'WarningEmitter', + 'ResultMapper', + 'RemoteConfigClient', + 'UnavailableEvaluator', + 'TriggerErrorWarningEmitter', + 'NativeEvaluator', + ) as $classFile) { + require_once $internalRoot . '/' . $classFile . '.php'; + } + + require_once $apiRoot . '/Client.php'; +} + +function run_fixture_case($client, $fileName, $index, array $case, array &$failures) +{ + foreach (array('flag', 'variationType', 'defaultValue', 'targetingKey', 'attributes', 'result') as $requiredKey) { + if (!array_key_exists($requiredKey, $case)) { + $failures[] = $fileName . '#' . $index . ': missing key ' . $requiredKey; + return; + } + } + + $context = array( + 'targetingKey' => $case['targetingKey'], + 'attributes' => is_array($case['attributes']) ? $case['attributes'] : array(), + ); + + $details = evaluate_fixture_case( + $client, + $case['variationType'], + $case['flag'], + $case['defaultValue'], + $context + ); + + if (!array_key_exists('value', $case['result'])) { + $failures[] = $fileName . '#' . $index . ': result must include value'; + return; + } + + if (!values_match($details->getValue(), $case['result']['value'], $case['variationType'])) { + $failures[] = $fileName . '#' . $index + . ': value got=' . encode_value($details->getValue()) + . ' want=' . encode_value($case['result']['value']); + } +} + +function evaluate_fixture_case($client, $variationType, $flag, $defaultValue, array $context) +{ + switch ($variationType) { + case 'BOOLEAN': + return $client->getBooleanDetails($flag, $defaultValue, $context); + case 'STRING': + return $client->getStringDetails($flag, $defaultValue, $context); + case 'INTEGER': + return $client->getIntegerDetails($flag, $defaultValue, $context); + case 'NUMERIC': + return $client->getFloatDetails($flag, $defaultValue, $context); + case 'JSON': + return $client->getObjectDetails($flag, $defaultValue, $context); + } + + throw new \RuntimeException('unsupported variationType ' . encode_value($variationType)); +} + +function values_match($actual, $expected, $variationType) +{ + if ($variationType === 'NUMERIC') { + return is_numeric($actual) + && is_numeric($expected) + && abs((float) $actual - (float) $expected) < 0.000001; + } + + if (is_array($actual) || is_array($expected)) { + return arrays_match($actual, $expected); + } + + return $actual === $expected; +} + +function arrays_match($actual, $expected) +{ + if (!is_array($actual) || !is_array($expected)) { + return false; + } + + if (count($actual) !== count($expected)) { + return false; + } + + foreach ($expected as $key => $expectedValue) { + if (!array_key_exists($key, $actual)) { + return false; + } + + $actualValue = $actual[$key]; + if (is_array($actualValue) || is_array($expectedValue)) { + if (!arrays_match($actualValue, $expectedValue)) { + return false; + } + continue; + } + + if (is_float($actualValue) || is_float($expectedValue)) { + if (!is_float($actualValue) || !is_float($expectedValue)) { + return false; + } + if (abs($actualValue - $expectedValue) >= 0.000001) { + return false; + } + continue; + } + + if ($actualValue !== $expectedValue) { + return false; + } + } + + return true; +} + +function decode_json_file($path) +{ + $json = file_get_contents($path); + if ($json === false) { + throw new \RuntimeException('failed to read ' . $path); + } + + $decoded = json_decode($json, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \RuntimeException('failed to decode ' . $path . ': ' . json_last_error_msg()); + } + + return $decoded; +} + +function encode_value($value) +{ + return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION); +} + +function show($label, $value) +{ + echo $label . '=' . encode_value($value) . "\n"; +} +?> +--EXPECTF-- +config_loaded=true +fixture_files=%d +cases=%d +failures=0 diff --git a/tests/internal-api-stress-test.php b/tests/internal-api-stress-test.php index 671bd0e03ce..8f881d821ec 100644 --- a/tests/internal-api-stress-test.php +++ b/tests/internal-api-stress-test.php @@ -131,7 +131,9 @@ function ($hook = null) { return $garbage; } -$minFunctionArgs = []; +$minFunctionArgs = [ + 'DDTrace\ffe_evaluate' => 4, +]; function call_function(ReflectionFunction $function) { diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 399e4f8273d..2a064f1b294 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -141,6 +141,9 @@ ./Unit/ + + ./OpenFeature/ + @@ -151,4 +154,4 @@ ../src/dogstatsd - \ No newline at end of file + diff --git a/tooling/generation/composer.json b/tooling/generation/composer.json index 0bf4f24cc0f..4e8a7316659 100644 --- a/tooling/generation/composer.json +++ b/tooling/generation/composer.json @@ -8,9 +8,11 @@ "vendor/bin/classpreloader compile --config=../../src/bridge/_files_api.php --output=../../src/bridge/_generated_api.php", "vendor/bin/classpreloader compile --config=../../src/bridge/_files_tracer.php --output=../../src/bridge/_generated_tracer.php", "vendor/bin/classpreloader compile --config=../../src/bridge/_files_opentelemetry.php --output=../../src/bridge/_generated_opentelemetry.php", + "vendor/bin/classpreloader compile --config=../../src/bridge/_files_openfeature.php --output=../../src/bridge/_generated_openfeature.php", "sed -i \"s/'[^']\\+bridge\\/\\.\\./__DIR__ . '\\/../g;s/\\s*\\(^\\|\\s\\)\\/\\/.*//g;s/\\/\\*\\([^*]\\|\\*[^/]\\)*\\*\\///g;/\\/\\*/,/\\*\\//d;/^\\s*$/d\" ../../src/bridge/_generated_api.php", "sed -i \"s/'[^']\\+bridge\\/\\.\\./__DIR__ . '\\/../g;s/\\s*\\(^\\|\\s\\)\\/\\/.*//g;s/\\/\\*\\([^*]\\|\\*[^/]\\)*\\*\\///g;/\\/\\*/,/\\*\\//d;/^\\s*$/d\" ../../src/bridge/_generated_tracer.php", - "sed -i \"s/'[^']\\+bridge\\/\\.\\./__DIR__ . '\\/../g;s/\\s*\\(^\\|\\s\\)\\/\\/.*//g;s/\\/\\*\\([^*]\\|\\*[^/]\\)*\\*\\///g;/\\/\\*/,/\\*\\//d;/^\\s*$/d\" ../../src/bridge/_generated_opentelemetry.php" + "sed -i \"s/'[^']\\+bridge\\/\\.\\./__DIR__ . '\\/../g;s/\\s*\\(^\\|\\s\\)\\/\\/.*//g;s/\\/\\*\\([^*]\\|\\*[^/]\\)*\\*\\///g;/\\/\\*/,/\\*\\//d;/^\\s*$/d\" ../../src/bridge/_generated_opentelemetry.php", + "sed -i \"s/'[^']\\+bridge\\/\\.\\./__DIR__ . '\\/../g;s/\\s*\\(^\\|\\s\\)\\/\\/.*//g;s/\\/\\*\\([^*]\\|\\*[^/]\\)*\\*\\///g;/\\/\\*/,/\\*\\//d;/^\\s*$/d\" ../../src/bridge/_generated_openfeature.php" ], "verify": "php -r 'require \"../../src/bridge/_files_api.php\"; require \"../../src/bridge/_files_tracer.php\";'" }