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..fc536e72792 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,6 +5,13 @@ version: 2 updates: + - package-ecosystem: "gitsubmodule" + directory: "/" + schedule: + interval: "weekly" + allow: + - dependency-name: "tests/FeatureFlags/ffe-system-test-data" + - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 21e4fe10911..b7d512b1dc1 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" @@ -74,6 +74,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 @@ -122,7 +124,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..a39a3bd0fbd 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. @@ -495,6 +510,16 @@ typedef struct ddog_Tag { typedef struct _zend_string *ddog_OwnedZendString; +struct ddog_FfeResult { + ddog_OwnedZendString value_json; + ddog_OwnedZendString variant; + ddog_OwnedZendString allocation_key; + int32_t reason; + int32_t error_code; + bool do_log; + bool valid; +}; + typedef struct _zend_string *(*ddog_DynamicConfigUpdate)(ddog_CharSlice config, ddog_OwnedZendString value, enum ddog_DynamicConfigUpdateMode mode); @@ -679,6 +704,14 @@ typedef struct ddog_Vec_DebuggerPayload { */ typedef uint64_t ddog_QueueId; +typedef struct ddog_FfeAttribute { + ddog_CharSlice key; + int32_t value_type; + ddog_CharSlice 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..cbcd6a9d02b 100644 --- a/components-rs/ddtrace.h +++ b/components-rs/ddtrace.h @@ -61,6 +61,18 @@ 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(ddog_CharSlice json); + +bool ddog_ffe_has_config(void); + +uint64_t ddog_ffe_config_version(void); + +struct ddog_FfeResult ddog_ffe_evaluate(ddog_CharSlice flag_key, + int32_t expected_type, + ddog_CharSlice targeting_key, + const struct ddog_FfeAttribute *attributes, + uintptr_t attributes_count); + const char *ddog_normalize_process_tag_value(ddog_CharSlice tag_value); void ddog_free_normalized_tag_value(const char *ptr); @@ -118,9 +130,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..33b132af7a0 --- /dev/null +++ b/components-rs/ffe.rs @@ -0,0 +1,446 @@ +use crate::bytes::OwnedZendString; +use datadog_ffe::rules_based::{ + self as ffe, AssignmentReason, AssignmentValue, Attribute, Configuration, EvaluationContext, + EvaluationError, ExpectedFlagType, Str, UniversalFlagConfig, +}; +use libdd_common_ffi::slice::{AsBytes, CharSlice}; +use std::cell::RefCell; +use std::collections::HashMap; +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: CharSlice<'_>) -> bool { + if json.as_raw_parts().0.is_null() { + return false; + } + + let json = match json.try_to_utf8() { + 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; + +#[repr(C)] +pub struct FfeResult { + pub value_json: Option, + pub variant: Option, + pub allocation_key: Option, + pub reason: i32, + pub error_code: i32, + pub do_log: bool, + pub valid: bool, +} + +#[repr(C)] +pub struct FfeAttribute<'a> { + pub key: CharSlice<'a>, + pub value_type: i32, + pub string_value: CharSlice<'a>, + pub number_value: f64, + pub bool_value: bool, +} + +#[no_mangle] +pub extern "C" fn ddog_ffe_evaluate( + flag_key: CharSlice<'_>, + expected_type: i32, + targeting_key: CharSlice<'_>, + attributes: *const FfeAttribute<'_>, + attributes_count: usize, +) -> FfeResult { + if flag_key.as_raw_parts().0.is_null() { + return invalid_result(); + } + + let flag_key = match flag_key.try_to_utf8() { + Ok(flag_key) => flag_key, + Err(_) => return invalid_result(), + }; + + 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 invalid_result(), + }; + + let targeting_key = if targeting_key.as_raw_parts().0.is_null() { + None + } else { + match targeting_key.try_to_utf8() { + 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(), + ); + + result_from_assignment(assignment) + }) +} + +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.as_raw_parts().0.is_null() { + continue; + } + + let key = match attribute.key.try_to_utf8() { + Ok(key) => key, + Err(_) => continue, + }; + + let value = match attribute.value_type { + ATTR_TYPE_STRING => { + if attribute.string_value.as_raw_parts().0.is_null() { + continue; + } + + match attribute.string_value.try_to_utf8() { + 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) => { + let value_json = assignment_value_to_json(&assignment.value); + FfeResult { + value_json: Some(value_json.as_str().into()), + variant: Some(assignment.variation_key.as_str().into()), + allocation_key: Some(assignment.allocation_key.as_str().into()), + 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, + valid: true, + } + } + 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: Some("null".into()), + variant: None, + allocation_key: None, + reason, + error_code, + do_log: false, + valid: true, + } + } + } +} + +fn invalid_result() -> FfeResult { + FfeResult { + value_json: None, + variant: None, + allocation_key: None, + reason: REASON_ERROR, + error_code: ERROR_GENERAL, + do_log: false, + valid: 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(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bytes::ZendString; + use std::alloc::{alloc_zeroed, dealloc, Layout}; + use std::ffi::CString; + use std::mem; + use std::ptr; + use std::ptr::NonNull; + use std::sync::Once; + + static INIT_ZEND_STRING_FUNCTIONS: Once = Once::new(); + + fn setup_zend_string_functions() { + INIT_ZEND_STRING_FUNCTIONS.call_once(|| unsafe { + crate::bytes::ddog_init_span_func( + test_free_zend_string, + test_addref_zend_string, + test_init_zend_string, + ); + }); + } + + extern "C" fn test_addref_zend_string(value: &mut ZendString) { + value.refcount = value.refcount.saturating_add(1); + } + + extern "C" fn test_init_zend_string(value: CharSlice<'_>) -> OwnedZendString { + let bytes = value.as_bytes(); + let layout = zend_string_layout(bytes.len()); + let raw = unsafe { alloc_zeroed(layout) as *mut ZendString }; + let raw = NonNull::new(raw).expect("test allocation should succeed"); + + unsafe { + let zend_string = raw.as_ptr(); + (*zend_string).refcount = 1; + (*zend_string).type_info = 0; + (*zend_string).h = 0; + (*zend_string).len = bytes.len(); + ptr::copy_nonoverlapping(bytes.as_ptr(), (*zend_string).val.as_mut_ptr(), bytes.len()); + *(*zend_string).val.as_mut_ptr().add(bytes.len()) = 0; + } + + OwnedZendString(raw) + } + + extern "C" fn test_free_zend_string(value: OwnedZendString) { + unsafe { + let raw = value.0.as_ptr(); + let layout = zend_string_layout((*raw).len); + dealloc(raw as *mut u8, layout); + } + mem::forget(value); + } + + fn zend_string_layout(len: usize) -> Layout { + Layout::from_size_align( + mem::size_of::() + len, + mem::align_of::(), + ) + .expect("test zend_string layout should be valid") + } + + fn char_slice(value: &CString) -> CharSlice<'_> { + unsafe { CharSlice::from_raw_parts(value.as_ptr(), value.as_bytes().len()) } + } + + 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(char_slice(&json)) + } + + 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() { + setup_zend_string_functions(); + clear_config(); + let config = + CString::new(EMPTY_TARGETING_KEY_CONFIG).expect("test fixture is valid cstring"); + assert!(ddog_ffe_load_config(char_slice(&config))); + + let flag_key = + CString::new("empty.targeting.shard.flag").expect("test flag key is valid cstring"); + let result = ddog_ffe_evaluate( + char_slice(&flag_key), + TYPE_STRING, + CharSlice::from(""), + std::ptr::null(), + 0, + ); + + assert!(result.valid); + assert_eq!(result.reason, REASON_SPLIT); + assert_eq!(result.error_code, ERROR_NONE); + assert_eq!(result.do_log, true); + assert_eq!( + std::str::from_utf8(result.value_json.as_ref().unwrap().as_ref()).unwrap(), + r#""empty-targeting-key""# + ); + 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..33022f29587 100644 --- a/ext/autoload_php_files.c +++ b/ext/autoload_php_files.c @@ -32,6 +32,7 @@ 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 #if PHP_VERSION_ID < 80000 @@ -234,6 +235,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 (!DDTRACE_G(openfeature_is_loaded)) { + DDTRACE_G(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 +433,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 = DDTRACE_G(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; + DDTRACE_G(openfeature_is_loaded) = dd_openfeature_is_preloaded; } #else DDTRACE_G(api_is_loaded) = 0; DDTRACE_G(otel_is_loaded) = 0; + DDTRACE_G(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..08cabc95e29 100644 --- a/ext/ddtrace.c +++ b/ext/ddtrace.c @@ -934,6 +934,7 @@ zend_class_entry *ddtrace_ce_root_span_data; HashTable dd_root_span_data_duplicated_properties_table; #endif zend_class_entry *ddtrace_ce_span_stack; +static zend_class_entry *ddtrace_ce_ffe_result; zend_object_handlers ddtrace_span_data_handlers; zend_object_handlers ddtrace_inferred_span_data_handlers; zend_object_handlers ddtrace_root_span_data_handlers; @@ -1578,6 +1579,7 @@ static PHP_MINIT_FUNCTION(ddtrace) { dd_register_span_data_ce(); dd_register_fatal_error_ce(); ddtrace_ce_integration = register_class_DDTrace_Integration(); + ddtrace_ce_ffe_result = register_class_DDTrace_FfeResult(); ddtrace_ce_span_link = register_class_DDTrace_SpanLink(php_json_serializable_ce); ddtrace_ce_span_event = register_class_DDTrace_SpanEvent(php_json_serializable_ce); ddtrace_ce_exception_span_event = register_class_DDTrace_ExceptionSpanEvent(ddtrace_ce_span_event); @@ -2951,6 +2953,180 @@ 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) { + zend_string *json; + + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_STR(json) + ZEND_PARSE_PARAMETERS_END(); + + RETURN_BOOL(ddog_ffe_load_config(dd_zend_string_to_CharSlice(json))); +} + +static void ddtrace_ffe_update_property(zval *object, const char *name, size_t name_len, zval *value) { + zend_string *property_name = zend_string_init(name, name_len, 0); + zend_update_property_ex(ddtrace_ce_ffe_result, Z_OBJ_P(object), property_name, value); + zend_string_release(property_name); +} + +static void ddtrace_ffe_update_nullable_string_property(zval *object, const char *name, size_t name_len, zend_string *value) { + zval property_value; + + if (value == NULL) { + ZVAL_NULL(&property_value); + ddtrace_ffe_update_property(object, name, name_len, &property_value); + return; + } + + ZVAL_STR(&property_value, value); + ddtrace_ffe_update_property(object, name, name_len, &property_value); + zval_ptr_dtor(&property_value); +} + +static void ddtrace_ffe_update_long_property(zval *object, const char *name, size_t name_len, zend_long value) { + zval property_value; + + ZVAL_LONG(&property_value, value); + ddtrace_ffe_update_property(object, name, name_len, &property_value); +} + +static void ddtrace_ffe_update_bool_property(zval *object, const char *name, size_t name_len, bool value) { + zval property_value; + + ZVAL_BOOL(&property_value, value); + ddtrace_ffe_update_property(object, name, name_len, &property_value); +} + +static void ddtrace_ffe_update_empty_array_property(zval *object, const char *name, size_t name_len) { + zval property_value; + + array_init(&property_value); + ddtrace_ffe_update_property(object, name, name_len, &property_value); + zval_ptr_dtor(&property_value); +} + +PHP_FUNCTION(DDTrace_ffe_evaluate) { + zend_string *flag_key; + zend_long type_id_zl; + zend_string *targeting_key = NULL; + zval *attrs_zv; + int32_t type_id; + struct ddog_FfeAttribute *c_attrs = NULL; + zend_string **owned_attr_keys = NULL; + size_t attrs_count = 0; + HashTable *attributes; + size_t idx = 0; + zend_ulong num_key; + zend_string *key; + zval *value; + struct ddog_FfeResult result; + + ZEND_PARSE_PARAMETERS_START(4, 4) + Z_PARAM_STR(flag_key) + Z_PARAM_LONG(type_id_zl) + Z_PARAM_STR_OR_NULL(targeting_key) + Z_PARAM_ARRAY(attrs_zv) + ZEND_PARSE_PARAMETERS_END(); + + type_id = (int32_t) type_id_zl; + 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)); + owned_attr_keys = ecalloc(attrs_count, sizeof(zend_string *)); + ZEND_HASH_FOREACH_KEY_VAL(attributes, num_key, key, value) { + zend_string *owned_key = NULL; + + if (idx >= attrs_count) { + continue; + } + + if (!key) { + owned_key = zend_long_to_str((zend_long) num_key); + key = owned_key; + } + + switch (Z_TYPE_P(value)) { + case IS_STRING: + c_attrs[idx].value_type = 0; + c_attrs[idx].string_value = dd_zend_string_to_CharSlice(Z_STR_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: + if (owned_key) { + zend_string_release(owned_key); + } + continue; + } + + c_attrs[idx].key = dd_zend_string_to_CharSlice(key); + owned_attr_keys[idx] = owned_key; + idx++; + } ZEND_HASH_FOREACH_END(); + attrs_count = idx; + } + + result = ddog_ffe_evaluate( + dd_zend_string_to_CharSlice(flag_key), + type_id, + dd_zend_string_to_CharSlice(targeting_key), + c_attrs, + attrs_count + ); + if (c_attrs) { + efree(c_attrs); + } + if (owned_attr_keys) { + for (size_t i = 0; i < attrs_count; i++) { + if (owned_attr_keys[i]) { + zend_string_release(owned_attr_keys[i]); + } + } + efree(owned_attr_keys); + } + + if (!result.valid) { + RETURN_NULL(); + } + + object_init_ex(return_value, ddtrace_ce_ffe_result); + ddtrace_ffe_update_nullable_string_property(return_value, ZEND_STRL("valueJson"), result.value_json); + ddtrace_ffe_update_nullable_string_property(return_value, ZEND_STRL("variant"), result.variant); + ddtrace_ffe_update_nullable_string_property(return_value, ZEND_STRL("allocationKey"), result.allocation_key); + ddtrace_ffe_update_long_property(return_value, ZEND_STRL("reason"), result.reason); + ddtrace_ffe_update_long_property(return_value, ZEND_STRL("errorCode"), result.error_code); + ddtrace_ffe_update_bool_property(return_value, ZEND_STRL("doLog"), result.do_log); + ddtrace_ffe_update_empty_array_property(return_value, ZEND_STRL("providerState")); +} + PHP_FUNCTION(dd_trace_send_traces_via_thread) { char *payload = NULL; ddtrace_zpplong_t num_traces = 0; diff --git a/ext/ddtrace.h b/ext/ddtrace.h index ca47bc94e35..6f9e5cc08cf 100644 --- a/ext/ddtrace.h +++ b/ext/ddtrace.h @@ -19,6 +19,12 @@ #include "git.h" #include "threads.h" +#define DDTRACE_FFE_TYPE_STRING 0 +#define DDTRACE_FFE_TYPE_INT 1 +#define DDTRACE_FFE_TYPE_FLOAT 2 +#define DDTRACE_FFE_TYPE_BOOL 3 +#define DDTRACE_FFE_TYPE_OBJECT 4 + extern zend_module_entry ddtrace_module_entry; extern zend_class_entry *ddtrace_ce_span_data; extern zend_class_entry *ddtrace_ce_inferred_span_data; @@ -108,6 +114,7 @@ ZEND_BEGIN_MODULE_GLOBALS(ddtrace) zend_bool api_is_loaded; zend_bool otel_is_loaded; zend_bool legacy_tracer_is_loaded; + zend_bool openfeature_is_loaded; uint32_t traces_group_id; zend_array *additional_global_tags; diff --git a/ext/ddtrace.stub.php b/ext/ddtrace.stub.php index e4a388e157d..ee00a8ee3b2 100644 --- a/ext/ddtrace.stub.php +++ b/ext/ddtrace.stub.php @@ -23,6 +23,49 @@ */ const DBM_PROPAGATION_FULL = UNKNOWN; + /** + * @var int + * @cvalue DDTRACE_FFE_TYPE_STRING + */ + const FFE_STRING = UNKNOWN; + + /** + * @var int + * @cvalue DDTRACE_FFE_TYPE_INT + */ + const FFE_INT = UNKNOWN; + + /** + * @var int + * @cvalue DDTRACE_FFE_TYPE_FLOAT + */ + const FFE_FLOAT = UNKNOWN; + + /** + * @var int + * @cvalue DDTRACE_FFE_TYPE_BOOL + */ + const FFE_BOOL = UNKNOWN; + + /** + * @var int + * @cvalue DDTRACE_FFE_TYPE_OBJECT + */ + const FFE_OBJECT = UNKNOWN; + + final class FfeResult { + public ?string $valueJson = null; + public ?string $variant = null; + public ?string $allocationKey = null; + public int $reason = 0; + public int $errorCode = 0; + public bool $doLog = false; + public array $providerState = []; + public ?string $errorMessage = null; + public ?bool $hasConfig = null; + public ?int $configVersion = null; + } + class SpanEvent implements \JsonSerializable { /** * SpanEvent constructor. @@ -845,6 +888,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 One of the DDTrace\FFE_* constants. + * @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 FfeResult|null Object with the native evaluation result fields. 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): ?FfeResult {} + + /** + * 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 +999,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 +1060,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..9722e64a323 100644 --- a/ext/ddtrace_arginfo.h +++ b/ext/ddtrace_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit ddtrace.stub.php instead. - * Stub hash: b7ca6da3e6dff3aa5fdb4bf9ea0811b3456030fc */ + * Stub hash: 8d552cbb5a25472ccb510275ab86276a29e85d7a */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_trace_method, 0, 3, _IS_BOOL, 0) ZEND_ARG_TYPE_INFO(0, className, IS_STRING, 0) @@ -176,6 +176,23 @@ ZEND_END_ARG_INFO() #define arginfo_DDTrace_flush_endpoints arginfo_DDTrace_flush +ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_DDTrace_ffe_evaluate, 0, 4, DDTrace\\FfeResult, 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) @@ -568,6 +593,11 @@ static void register_ddtrace_symbols(int module_number) REGISTER_LONG_CONSTANT("DDTrace\\DBM_PROPAGATION_DISABLED", DD_TRACE_DBM_PROPAGATION_DISABLED, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("DDTrace\\DBM_PROPAGATION_SERVICE", DD_TRACE_DBM_PROPAGATION_SERVICE, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("DDTrace\\DBM_PROPAGATION_FULL", DD_TRACE_DBM_PROPAGATION_FULL, CONST_PERSISTENT); + REGISTER_LONG_CONSTANT("DDTrace\\FFE_STRING", DDTRACE_FFE_TYPE_STRING, CONST_PERSISTENT); + REGISTER_LONG_CONSTANT("DDTrace\\FFE_INT", DDTRACE_FFE_TYPE_INT, CONST_PERSISTENT); + REGISTER_LONG_CONSTANT("DDTrace\\FFE_FLOAT", DDTRACE_FFE_TYPE_FLOAT, CONST_PERSISTENT); + REGISTER_LONG_CONSTANT("DDTrace\\FFE_BOOL", DDTRACE_FFE_TYPE_BOOL, CONST_PERSISTENT); + REGISTER_LONG_CONSTANT("DDTrace\\FFE_OBJECT", DDTRACE_FFE_TYPE_OBJECT, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("DDTrace\\Internal\\SPAN_FLAG_OPENTELEMETRY", DDTRACE_SPAN_FLAG_OPENTELEMETRY, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("DDTrace\\Internal\\SPAN_FLAG_OPENTRACING", DDTRACE_SPAN_FLAG_OPENTRACING, CONST_PERSISTENT); REGISTER_STRING_CONSTANT("DD_TRACE_VERSION", PHP_DDTRACE_VERSION, CONST_PERSISTENT); @@ -579,6 +609,76 @@ static void register_ddtrace_symbols(int module_number) REGISTER_LONG_CONSTANT("DD_TRACE_PRIORITY_SAMPLING_UNSET", DDTRACE_PRIORITY_SAMPLING_UNSET, CONST_PERSISTENT); } +static zend_class_entry *register_class_DDTrace_FfeResult(void) +{ + zend_class_entry ce, *class_entry; + + INIT_NS_CLASS_ENTRY(ce, "DDTrace", "FfeResult", NULL); + class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL); + + zval property_valueJson_default_value; + ZVAL_NULL(&property_valueJson_default_value); + zend_string *property_valueJson_name = zend_string_init("valueJson", sizeof("valueJson") - 1, true); + zend_declare_typed_property(class_entry, property_valueJson_name, &property_valueJson_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING|MAY_BE_NULL)); + zend_string_release_ex(property_valueJson_name, true); + + zval property_variant_default_value; + ZVAL_NULL(&property_variant_default_value); + zend_string *property_variant_name = zend_string_init("variant", sizeof("variant") - 1, true); + zend_declare_typed_property(class_entry, property_variant_name, &property_variant_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING|MAY_BE_NULL)); + zend_string_release_ex(property_variant_name, true); + + zval property_allocationKey_default_value; + ZVAL_NULL(&property_allocationKey_default_value); + zend_string *property_allocationKey_name = zend_string_init("allocationKey", sizeof("allocationKey") - 1, true); + zend_declare_typed_property(class_entry, property_allocationKey_name, &property_allocationKey_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING|MAY_BE_NULL)); + zend_string_release_ex(property_allocationKey_name, true); + + zval property_reason_default_value; + ZVAL_LONG(&property_reason_default_value, 0); + zend_string *property_reason_name = zend_string_init("reason", sizeof("reason") - 1, true); + zend_declare_typed_property(class_entry, property_reason_name, &property_reason_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_LONG)); + zend_string_release_ex(property_reason_name, true); + + zval property_errorCode_default_value; + ZVAL_LONG(&property_errorCode_default_value, 0); + zend_string *property_errorCode_name = zend_string_init("errorCode", sizeof("errorCode") - 1, true); + zend_declare_typed_property(class_entry, property_errorCode_name, &property_errorCode_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_LONG)); + zend_string_release_ex(property_errorCode_name, true); + + zval property_doLog_default_value; + ZVAL_FALSE(&property_doLog_default_value); + zend_string *property_doLog_name = zend_string_init("doLog", sizeof("doLog") - 1, true); + zend_declare_typed_property(class_entry, property_doLog_name, &property_doLog_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_BOOL)); + zend_string_release_ex(property_doLog_name, true); + + zval property_providerState_default_value; + ZVAL_EMPTY_ARRAY(&property_providerState_default_value); + zend_string *property_providerState_name = zend_string_init("providerState", sizeof("providerState") - 1, true); + zend_declare_typed_property(class_entry, property_providerState_name, &property_providerState_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_ARRAY)); + zend_string_release_ex(property_providerState_name, true); + + zval property_errorMessage_default_value; + ZVAL_NULL(&property_errorMessage_default_value); + zend_string *property_errorMessage_name = zend_string_init("errorMessage", sizeof("errorMessage") - 1, true); + zend_declare_typed_property(class_entry, property_errorMessage_name, &property_errorMessage_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING|MAY_BE_NULL)); + zend_string_release_ex(property_errorMessage_name, true); + + zval property_hasConfig_default_value; + ZVAL_NULL(&property_hasConfig_default_value); + zend_string *property_hasConfig_name = zend_string_init("hasConfig", sizeof("hasConfig") - 1, true); + zend_declare_typed_property(class_entry, property_hasConfig_name, &property_hasConfig_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_BOOL|MAY_BE_NULL)); + zend_string_release_ex(property_hasConfig_name, true); + + zval property_configVersion_default_value; + ZVAL_NULL(&property_configVersion_default_value); + zend_string *property_configVersion_name = zend_string_init("configVersion", sizeof("configVersion") - 1, true); + zend_declare_typed_property(class_entry, property_configVersion_name, &property_configVersion_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_LONG|MAY_BE_NULL)); + zend_string_release_ex(property_configVersion_name, true); + + return class_entry; +} + static zend_class_entry *register_class_DDTrace_SpanEvent(zend_class_entry *class_entry_JsonSerializable) { zend_class_entry ce, *class_entry; 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..257790a8b77 --- /dev/null +++ b/src/DDTrace/OpenFeature/DataDogProvider.php @@ -0,0 +1,168 @@ +client = new FeatureFlagsClient($logger ?: new TriggerErrorLogger()); + } + + 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)); + + $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 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..b42bbfcbcdd --- /dev/null +++ b/src/api/FeatureFlags/Client.php @@ -0,0 +1,206 @@ +evaluator = NativeEvaluator::create(); + $this->logger = $logger ?: new TriggerErrorLogger(); + } + + /** + * @return bool + */ + public function getBooleanValue($flagKey, $defaultValue, array $context = array()) + { + return $this->getBooleanDetails($flagKey, $defaultValue, $context)->getValue(); + } + + /** + * @return string + */ + public function getStringValue($flagKey, $defaultValue, array $context = array()) + { + return $this->getStringDetails($flagKey, $defaultValue, $context)->getValue(); + } + + /** + * @return int + */ + public function getIntegerValue($flagKey, $defaultValue, array $context = array()) + { + return $this->getIntegerDetails($flagKey, $defaultValue, $context)->getValue(); + } + + /** + * @return float + */ + public function getFloatValue($flagKey, $defaultValue, array $context = array()) + { + return $this->getFloatDetails($flagKey, $defaultValue, $context)->getValue(); + } + + /** + * @return array + */ + public function getObjectValue($flagKey, array $defaultValue, array $context = array()) + { + return $this->getObjectDetails($flagKey, $defaultValue, $context)->getValue(); + } + + /** + * @return EvaluationDetails + */ + public function getBooleanDetails($flagKey, $defaultValue, array $context = array()) + { + return $this->evaluate($flagKey, EvaluationType::BOOLEAN, $this->expectBoolean($defaultValue), $context); + } + + /** + * @return EvaluationDetails + */ + public function getStringDetails($flagKey, $defaultValue, array $context = array()) + { + return $this->evaluate($flagKey, EvaluationType::STRING, $this->expectString($defaultValue), $context); + } + + /** + * @return EvaluationDetails + */ + public function getIntegerDetails($flagKey, $defaultValue, array $context = array()) + { + return $this->evaluate($flagKey, EvaluationType::INTEGER, $this->expectInteger($defaultValue), $context); + } + + /** + * @return EvaluationDetails + */ + public function getFloatDetails($flagKey, $defaultValue, array $context = array()) + { + return $this->evaluate($flagKey, EvaluationType::FLOAT, $this->expectFloat($defaultValue), $context); + } + + /** + * @return EvaluationDetails + */ + 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 running without exposure and metric reporting in this milestone.'; + } + + $this->logger->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..d034044ac78 --- /dev/null +++ b/src/api/FeatureFlags/EvaluationDetails.php @@ -0,0 +1,141 @@ + $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; + } + + /** + * @return mixed + */ + public function getValue() + { + return $this->value; + } + + /** + * @return string One of EvaluationType::*. + */ + public function getValueType() + { + return $this->valueType; + } + + /** + * @return string One of EvaluationReason::*. + */ + public function getReason() + { + return $this->reason; + } + + /** + * @return string|null + */ + public function getVariant() + { + return $this->variant; + } + + /** + * @return string|null One of EvaluationErrorCode::* or null on success. + */ + public function getErrorCode() + { + return $this->errorCode; + } + + /** + * @return string|null + */ + public function getErrorMessage() + { + return $this->errorMessage; + } + + /** + * @return array + */ + public function getFlagMetadata() + { + return $this->flagMetadata; + } + + /** + * @return array + */ + public function getExposureData() + { + return $this->exposureData; + } + + /** + * @return array + */ + public function getProviderState() + { + return $this->providerState; + } + + /** + * @return bool + */ + 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..483c86d0a1d --- /dev/null +++ b/src/api/FeatureFlags/Internal/NativeEvaluator.php @@ -0,0 +1,127 @@ +mapper = $mapper ?: new ResultMapper(); + } + + public static function isAvailable() + { + return function_exists('DDTrace\\ffe_evaluate'); + } + + public static function create() + { + return self::isAvailable() ? new self() : new UnavailableEvaluator(); + } + + public function evaluate( + $flagKey, + $expectedType, + $defaultValue, + $targetingKey = null, + array $attributes = array() + ) { + $rawResult = \DDTrace\ffe_evaluate( + $flagKey, + $this->typeId($expectedType), + $targetingKey, + $this->normalizeAttributes($attributes) + ); + + if (is_array($rawResult) || is_object($rawResult)) { + $rawResult = $this->withProviderState($rawResult); + } + + return $this->mapper->map($rawResult, $expectedType, $defaultValue); + } + + private function typeId($expectedType) + { + switch ($expectedType) { + case EvaluationType::STRING: + return \DDTrace\FFE_STRING; + case EvaluationType::INTEGER: + return \DDTrace\FFE_INT; + case EvaluationType::FLOAT: + return \DDTrace\FFE_FLOAT; + case EvaluationType::BOOLEAN: + return \DDTrace\FFE_BOOL; + case EvaluationType::OBJECT: + return \DDTrace\FFE_OBJECT; + } + + 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($rawResult) + { + $hasConfig = \DDTrace\ffe_has_config(); + $configVersion = \DDTrace\ffe_config_version(); + + $providerState = array( + 'ready' => $hasConfig, + 'hasConfig' => $hasConfig, + 'configVersion' => $configVersion, + 'productionRuntime' => false, + 'mode' => 'native_remote_config', + 'reason' => $hasConfig ? 'metrics_delivery_pending' : 'configuration_missing', + ); + + if (is_array($rawResult)) { + 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; + } + + if (isset($rawResult->providerState) && is_array($rawResult->providerState)) { + $providerState = array_merge($providerState, $rawResult->providerState); + } + + if (!$hasConfig) { + $rawResult->errorMessage = self::WARNING_MESSAGE; + } + + $rawResult->providerState = $providerState; + $rawResult->hasConfig = $hasConfig; + $rawResult->configVersion = $configVersion; + + return $rawResult; + } +} diff --git a/src/api/FeatureFlags/Internal/ResultMapper.php b/src/api/FeatureFlags/Internal/ResultMapper.php new file mode 100644 index 00000000000..4dd30853dc5 --- /dev/null +++ b/src/api/FeatureFlags/Internal/ResultMapper.php @@ -0,0 +1,335 @@ +|object|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 === null) { + return $this->errorDetails( + $defaultValue, + $expectedType, + EvaluationErrorCode::PROVIDER_NOT_READY, + 'FFE evaluator is not ready', + array('ready' => false) + ); + } + + if (!is_array($rawResult) && !is_object($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, $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($rawResult, $expectedType, &$decoded) + { + if ($this->has($rawResult, 'value')) { + $value = $this->read($rawResult, array('value'), null); + } 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($rawResult, $reason) + { + if ($reason !== EvaluationReason::DEFAULT_REASON && $reason !== EvaluationReason::DISABLED) { + return false; + } + + if ($this->has($rawResult, 'value')) { + return $this->read($rawResult, array('value'), null) === 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($rawResult) + { + $exposureData = $this->readArray($rawResult, array('exposure_data', 'exposureData')); + + if ($this->hasAny($rawResult, array('allocation_key', 'allocationKey'))) { + $exposureData['allocationKey'] = $this->read($rawResult, array('allocation_key', 'allocationKey'), null); + } + + if ($this->hasAny($rawResult, array('do_log', 'doLog'))) { + $exposureData['doLog'] = (bool) $this->read($rawResult, array('do_log', 'doLog'), false); + } + + return $exposureData; + } + + private function providerState($rawResult) + { + $providerState = $this->readArray($rawResult, array('provider_state', 'providerState')); + + if ($this->hasAny($rawResult, array('has_config', 'hasConfig'))) { + $providerState['hasConfig'] = (bool) $this->read($rawResult, array('has_config', 'hasConfig'), false); + } + + if ($this->hasAny($rawResult, array('config_version', 'configVersion'))) { + $providerState['configVersion'] = $this->read($rawResult, array('config_version', 'configVersion'), null); + } + + return $providerState; + } + + private function readArray($rawResult, array $keys) + { + $value = $this->read($rawResult, $keys, array()); + + return is_array($value) ? $value : array(); + } + + private function read($rawResult, array $keys, $default) + { + foreach ($keys as $key) { + if (is_array($rawResult) && array_key_exists($key, $rawResult)) { + return $rawResult[$key]; + } + if (is_object($rawResult) && property_exists($rawResult, $key)) { + return $rawResult->$key; + } + } + + return $default; + } + + private function hasAny($rawResult, array $keys) + { + foreach ($keys as $key) { + if ($this->has($rawResult, $key)) { + return true; + } + } + + return false; + } + + private function has($rawResult, $key) + { + if (is_array($rawResult)) { + return array_key_exists($key, $rawResult); + } + + return is_object($rawResult) && property_exists($rawResult, $key); + } +} diff --git a/src/api/FeatureFlags/Internal/UnavailableEvaluator.php b/src/api/FeatureFlags/Internal/UnavailableEvaluator.php new file mode 100644 index 00000000000..fe1fde0c7d9 --- /dev/null +++ b/src/api/FeatureFlags/Internal/UnavailableEvaluator.php @@ -0,0 +1,32 @@ + false, + 'productionRuntime' => false, + 'reason' => 'runtime_unavailable', + ) + ); + } +} diff --git a/src/api/Log/TriggerErrorLogger.php b/src/api/Log/TriggerErrorLogger.php new file mode 100644 index 00000000000..9e60f34502f --- /dev/null +++ b/src/api/Log/TriggerErrorLogger.php @@ -0,0 +1,37 @@ +emit(LogLevel::DEBUG, $message, $context, E_USER_NOTICE); + } + + public function warning($message, array $context = array()) + { + $this->emit(LogLevel::WARNING, $message, $context, E_USER_WARNING); + } + + public function error($message, array $context = array()) + { + $this->emit(LogLevel::ERROR, $message, $context, E_USER_WARNING); + } + + private function emit($level, $message, array $context, $severity) + { + if (!$this->isLevelActive($level)) { + return; + } + + trigger_error($this->interpolate($message, $context), $severity); + } +} diff --git a/src/bridge/_files_openfeature.php b/src/bridge/_files_openfeature.php new file mode 100644 index 00000000000..d46c20f4164 --- /dev/null +++ b/src/bridge/_files_openfeature.php @@ -0,0 +1,5 @@ +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($this->providerForEvaluator($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($this->providerForEvaluator($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 = $this->providerForEvaluator($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 + { + $logger = new OpenFeatureRecordingLogger(); + $client = $this->openFeatureClientFor(new DataDogProvider($logger)); + + $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()], $logger->warnings()); + } + + public function testProviderWarningIsEmittedOncePerProvider(): void + { + $logger = new OpenFeatureRecordingLogger(); + $evaluator = new OpenFeatureTestEvaluator(); + $evaluator + ->setUnavailable('first.flag', true, 'temporary unavailable') + ->setUnavailable('second.flag', true, 'temporary unavailable'); + + $client = $this->openFeatureClientFor($this->providerForEvaluator($evaluator, $logger)); + + $client->getBooleanValue('first.flag', false); + $client->getBooleanValue('second.flag', false); + + self::assertSame(['temporary unavailable'], $logger->warnings()); + } + + public function testProviderErrorsMapToOpenFeatureDetails(): void + { + $evaluator = new OpenFeatureTestEvaluator(); + $evaluator->setFlagNotFound('missing.flag'); + + $client = $this->openFeatureClientFor($this->providerForEvaluator($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($this->providerForEvaluator($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 providerForEvaluator(Evaluator $evaluator, ?LoggerInterface $logger = null): DataDogProvider + { + $logger = $logger ?: new NullLogger(LogLevel::EMERGENCY); + $provider = new DataDogProvider($logger); + $client = $this->clientForEvaluator($evaluator, $logger); + + (function () use ($client): void { + $this->client = $client; + })->call($provider); + + return $provider; + } + + private function clientForEvaluator(Evaluator $evaluator, LoggerInterface $logger): FeatureFlagsClient + { + $client = new FeatureFlagsClient($logger); + (function () use ($evaluator): void { + $this->evaluator = $evaluator; + })->call($client); + + return $client; + } + + 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 OpenFeatureRecordingLogger implements LoggerInterface +{ + /** @var string[] */ + private array $warnings = []; + + public function debug($message, array $context = []) + { + } + + public function warning($message, array $context = []) + { + $this->warnings[] = $message; + } + + public function error($message, array $context = []) + { + } + + public function isLevelActive($level) + { + return true; + } + + /** + * @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..b2bc83d8321 --- /dev/null +++ b/tests/api/Unit/FeatureFlags/ClientTest.php @@ -0,0 +1,268 @@ +setSuccess('bool.flag', true) + ->setSuccess('string.flag', 'blue') + ->setSuccess('integer.flag', 42) + ->setSuccess('float.flag', 3.5) + ->setSuccess('object.flag', array('enabled' => true)); + + $client = $this->clientForEvaluator($evaluator, new RecordingLogger()); + + $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 = $this->clientForEvaluator($evaluator, new RecordingLogger()); + + $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 = $this->clientForEvaluator($evaluator, new RecordingLogger()); + $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() + { + $logger = new RecordingLogger(); + $client = new Client($logger); + + $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()), $logger->warnings()); + } + + public function testWarningIsEmittedOncePerClientNotOncePerEvaluation() + { + $logger = new RecordingLogger(); + $client = new Client($logger); + + $client->getBooleanValue('flag-1', false); + $client->getBooleanValue('flag-2', false); + $client->getStringDetails('flag-3', 'fallback'); + + $this->assertCount(1, $logger->warnings()); + } + + /** + * @dataProvider invalidDefaultProvider + */ + public function testTypedMethodsRejectInvalidDefaults($method, $defaultValue) + { + $client = $this->clientForEvaluator(new ClientTestEvaluator(), new RecordingLogger()); + + $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'), + ); + } + + private function clientForEvaluator(Evaluator $evaluator, LoggerInterface $logger) + { + $client = new Client($logger); + (function () use ($evaluator) { + $this->evaluator = $evaluator; + })->call($client); + + return $client; + } +} + +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 RecordingLogger implements LoggerInterface +{ + private $warnings = array(); + + public function debug($message, array $context = array()) + { + } + + public function warning($message, array $context = array()) + { + $this->warnings[] = $message; + } + + public function error($message, array $context = array()) + { + } + + public function isLevelActive($level) + { + return true; + } + + public function warnings() + { + return $this->warnings; + } +} diff --git a/tests/api/Unit/FeatureFlags/ResultMapperTest.php b/tests/api/Unit/FeatureFlags/ResultMapperTest.php new file mode 100644 index 00000000000..1f9d4f81e93 --- /dev/null +++ b/tests/api/Unit/FeatureFlags/ResultMapperTest.php @@ -0,0 +1,230 @@ +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 testMapsObjectBridgeResultToEvaluationDetails() + { + $rawResult = new \stdClass(); + $rawResult->valueJson = '"green"'; + $rawResult->variant = 'variant-b'; + $rawResult->allocationKey = 'alloc-2'; + $rawResult->reason = ResultMapper::BRIDGE_REASON_SPLIT; + $rawResult->errorCode = ResultMapper::BRIDGE_ERROR_NONE; + $rawResult->doLog = true; + $rawResult->providerState = array('ready' => true); + $rawResult->hasConfig = true; + $rawResult->configVersion = 43; + + $details = (new ResultMapper())->map($rawResult, EvaluationType::STRING, 'fallback'); + + $this->assertSame('green', $details->getValue()); + $this->assertSame(EvaluationReason::SPLIT, $details->getReason()); + $this->assertSame('variant-b', $details->getVariant()); + $this->assertSame(array('allocationKey' => 'alloc-2', 'doLog' => true), $details->getExposureData()); + $this->assertSame( + array('ready' => true, 'hasConfig' => true, 'configVersion' => 43), + $details->getProviderState() + ); + } +} diff --git a/tests/ext/ffe/native_bridge_evaluate.phpt b/tests/ext/ffe/native_bridge_evaluate.phpt new file mode 100644 index 00000000000..a136623d27f --- /dev/null +++ b/tests/ext/ffe/native_bridge_evaluate.phpt @@ -0,0 +1,139 @@ +--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->valueJson, true)); +show('object_success_metadata', array( + 'variant' => $object->variant, + 'allocation_key' => $object->allocationKey, + 'reason' => $object->reason, + 'error_code' => $object->errorCode, + 'do_log' => $object->doLog, +)); +show('numeric_attribute_key', \DDTrace\ffe_evaluate('numeric.attribute.flag', 0, 'user-1', array( + '1234' => 'numeric-match', +))); +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={"valueJson":"null","variant":null,"allocationKey":null,"reason":5,"errorCode":6,"doLog":false,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} +load=true +has_config_after=true +success={"valueJson":"\"blue\"","variant":"blue","allocationKey":"alloc-string","reason":0,"errorCode":0,"doLog":true,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} +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} +numeric_attribute_key={"valueJson":"\"numeric-attribute-name\"","variant":"numeric-key","allocationKey":"alloc-numeric-attribute","reason":2,"errorCode":0,"doLog":true,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} +empty_targeting_key={"valueJson":"\"empty-targeting-key\"","variant":"empty-target","allocationKey":"alloc-empty-targeting-key","reason":3,"errorCode":0,"doLog":true,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} +missing={"valueJson":"null","variant":null,"allocationKey":null,"reason":1,"errorCode":3,"doLog":false,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} +type_mismatch={"valueJson":"null","variant":null,"allocationKey":null,"reason":5,"errorCode":1,"doLog":false,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} +parse_error={"valueJson":"null","variant":null,"allocationKey":null,"reason":5,"errorCode":2,"doLog":false,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} diff --git a/tests/ext/ffe/remote_config_lifecycle.phpt b/tests/ext/ffe/remote_config_lifecycle.phpt new file mode 100644 index 00000000000..1af6f2d41f3 --- /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={"valueJson":"\"blue\"","variant":"blue","allocationKey":"alloc-string","reason":0,"errorCode":0,"doLog":true,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} +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..a673af26ce1 --- /dev/null +++ b/tests/ext/ffe/system_test_data_evaluate.phpt @@ -0,0 +1,269 @@ +--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) +{ + $logRoot = $root . '/src/api/Log'; + foreach (array( + 'LoggerInterface', + 'LogLevel', + 'AbstractLogger', + 'NullLogger', + 'InterpolateTrait', + 'TriggerErrorLogger', + ) as $classFile) { + require_once $logRoot . '/' . $classFile . '.php'; + } + + $apiRoot = $root . '/src/api/FeatureFlags'; + foreach (array( + 'EvaluationType', + 'EvaluationReason', + 'EvaluationErrorCode', + 'EvaluationDetails', + ) as $classFile) { + require_once $apiRoot . '/' . $classFile . '.php'; + } + + $internalRoot = $apiRoot . '/Internal'; + foreach (array( + 'Evaluator', + 'ResultMapper', + 'UnavailableEvaluator', + '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\";'" }