diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 90b79c23e7a..2af6e13626b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -14,6 +14,11 @@ compile_rust.sh @Datadog/libdatadog-apm # APM IDM Team /src/ @DataDog/apm-idm-php +# FFE (Feature Flagging & Experimentation) SDK Team +/src/DDTrace/FeatureFlags/ @DataDog/feature-flagging-and-experimentation-sdk +/src/DDTrace/OpenFeature/ @DataDog/feature-flagging-and-experimentation-sdk +/tests/FeatureFlags/ @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/components-rs/Cargo.toml b/components-rs/Cargo.toml index 90b6851e1e4..49c6808c4d1 100644 --- a/components-rs/Cargo.toml +++ b/components-rs/Cargo.toml @@ -15,7 +15,7 @@ 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-remote-config = { path = "../libdatadog/datadog-remote-config", features = ["ffe"] } datadog-sidecar = { path = "../libdatadog/datadog-sidecar" } datadog-sidecar-ffi = { path = "../libdatadog/datadog-sidecar-ffi" } libdd-tinybytes = { path = "../libdatadog/libdd-tinybytes" } @@ -23,6 +23,7 @@ libdd-trace-utils = { path = "../libdatadog/libdd-trace-utils" } libdd-crashtracker-ffi = { path = "../libdatadog/libdd-crashtracker-ffi", default-features = false, features = ["collector"] } libdd-library-config-ffi = { path = "../libdatadog/libdd-library-config-ffi", default-features = false } spawn_worker = { path = "../libdatadog/spawn_worker" } +datadog-ffe = { path = "../libdatadog/datadog-ffe" } anyhow = { version = "1.0" } const-str = "0.5.6" itertools = "0.11.0" diff --git a/components-rs/ddtrace.h b/components-rs/ddtrace.h index c0bab2a9eab..afaf8a5d85c 100644 --- a/components-rs/ddtrace.h +++ b/components-rs/ddtrace.h @@ -61,7 +61,8 @@ uint32_t ddog_get_logs_count(ddog_CharSlice level); void ddog_init_remote_config(bool live_debugging_enabled, bool appsec_activation, - bool appsec_config); + bool appsec_config, + bool ffe_enabled); struct ddog_RemoteConfigState *ddog_init_remote_config_state(const struct ddog_Endpoint *endpoint); @@ -69,6 +70,37 @@ const char *ddog_remote_config_get_path(const struct ddog_RemoteConfigState *rem bool ddog_process_remote_configs(struct ddog_RemoteConfigState *remote_config); +bool ddog_ffe_load_config(const char *json); + +bool ddog_ffe_has_config(void); + +bool ddog_ffe_config_changed(void); + +struct FfeResult; + +struct FfeAttribute { + const char *key; + int32_t value_type; /* 0=string, 1=number, 2=bool */ + const char *string_value; + double number_value; + bool bool_value; +}; + +struct FfeResult *ddog_ffe_evaluate( + const char *flag_key, + int32_t expected_type, + const char *targeting_key, + const struct FfeAttribute *attributes, + size_t attributes_count); + +const char *ddog_ffe_result_value(const struct FfeResult *r); +const char *ddog_ffe_result_variant(const struct FfeResult *r); +const char *ddog_ffe_result_allocation_key(const struct FfeResult *r); +int32_t ddog_ffe_result_reason(const struct FfeResult *r); +int32_t ddog_ffe_result_error_code(const struct FfeResult *r); +bool ddog_ffe_result_do_log(const struct FfeResult *r); +void ddog_ffe_free_result(struct FfeResult *r); + bool ddog_type_can_be_instrumented(const struct ddog_RemoteConfigState *remote_config, ddog_CharSlice typename_); diff --git a/components-rs/ffe.rs b/components-rs/ffe.rs new file mode 100644 index 00000000000..2c2089e14cd --- /dev/null +++ b/components-rs/ffe.rs @@ -0,0 +1,330 @@ +use datadog_ffe::rules_based::{ + self as ffe, AssignmentReason, AssignmentValue, Attribute, Configuration, EvaluationContext, + EvaluationError, ExpectedFlagType, Str, UniversalFlagConfig, +}; +use std::collections::HashMap; +use std::ffi::{c_char, CStr, CString}; +use std::sync::{Arc, Mutex}; + +/// Holds both the FFE configuration and a "changed" flag atomically behind a +/// single Mutex. This avoids the race where another thread could observe +/// `config` updated but `changed` still false (or vice-versa). +/// +/// A `RwLock` would be more appropriate here (many readers via `ddog_ffe_evaluate`, +/// rare writer via `store_config`), but PHP is single-threaded per process so +/// contention is not a practical concern. Keeping a Mutex for simplicity. +struct FfeState { + config: Option, + changed: bool, +} + +lazy_static::lazy_static! { + static ref FFE_STATE: Mutex = Mutex::new(FfeState { + config: None, + changed: false, + }); +} + +/// Called by remote_config when a new FFE configuration arrives via RC. +pub fn store_config(config: Configuration) { + if let Ok(mut state) = FFE_STATE.lock() { + state.config = Some(config); + state.changed = true; + } +} + +/// Called by remote_config when an FFE configuration is removed. +pub fn clear_config() { + if let Ok(mut state) = FFE_STATE.lock() { + state.config = None; + state.changed = true; + } +} + +/// Load a UFC JSON config string directly into the FFE engine. +/// Used by tests to load config without Remote Config. +#[no_mangle] +pub extern "C" fn ddog_ffe_load_config(json: *const c_char) -> bool { + if json.is_null() { + return false; + } + let json_str = match unsafe { CStr::from_ptr(json) }.to_str() { + Ok(s) => s, + Err(_) => return false, + }; + match UniversalFlagConfig::from_json(json_str.as_bytes().to_vec()) { + Ok(ufc) => { + store_config(Configuration::from_server_response(ufc)); + true + } + Err(_) => false, + } +} + +/// Check if FFE configuration is loaded. +#[no_mangle] +pub extern "C" fn ddog_ffe_has_config() -> bool { + FFE_STATE.lock().map(|s| s.config.is_some()).unwrap_or(false) +} + +/// Check if FFE config has changed since last check. +/// Resets the changed flag after reading. +#[no_mangle] +pub extern "C" fn ddog_ffe_config_changed() -> bool { + if let Ok(mut state) = FFE_STATE.lock() { + let was_changed = state.changed; + state.changed = false; + was_changed + } else { + false + } +} + +// Reason codes returned to PHP via ddog_ffe_result_reason(). +// Must match Provider::$REASON_MAP in src/DDTrace/FeatureFlags/Provider.php. +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; + +// Error codes returned to PHP via ddog_ffe_result_error_code(). +// 0 means no error. +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; + +// Attribute value types passed from C (matches FfeAttribute.value_type). +const ATTR_TYPE_STRING: i32 = 0; +const ATTR_TYPE_NUMBER: i32 = 1; +const ATTR_TYPE_BOOL: i32 = 2; + +// Expected flag type IDs passed from C (matches Provider::$TYPE_MAP). +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; + +/// Opaque handle for FFE evaluation results returned to C/PHP. +pub struct FfeResult { + pub value_json: CString, + pub variant: Option, + pub allocation_key: Option, + pub reason: i32, + pub error_code: i32, + pub do_log: bool, +} + +/// A single attribute passed from C/PHP for building an EvaluationContext. +#[repr(C)] +pub struct FfeAttribute { + pub key: *const c_char, + /// 0 = string, 1 = number, 2 = bool + pub value_type: i32, + pub string_value: *const c_char, + pub number_value: f64, + pub bool_value: bool, +} + +/// Evaluate a feature flag using the stored Configuration. +/// +/// Accepts structured attributes from C instead of a JSON blob. +/// `targeting_key` may be null (no targeting key). +/// `attributes` / `attributes_count` describe an array of `FfeAttribute`. +/// Returns null if no config is loaded. +#[no_mangle] +pub extern "C" fn ddog_ffe_evaluate( + flag_key: *const c_char, + expected_type: i32, + targeting_key: *const c_char, + attributes: *const FfeAttribute, + attributes_count: usize, +) -> *mut FfeResult { + if flag_key.is_null() { + return std::ptr::null_mut(); + } + let flag_key = match unsafe { CStr::from_ptr(flag_key) }.to_str() { + Ok(s) => s, + Err(_) => return std::ptr::null_mut(), + }; + + let expected_type = match expected_type { + TYPE_STRING => ExpectedFlagType::String, + TYPE_INTEGER => ExpectedFlagType::Integer, + TYPE_FLOAT => ExpectedFlagType::Float, + TYPE_BOOLEAN => ExpectedFlagType::Boolean, + TYPE_OBJECT => ExpectedFlagType::Object, + _ => return std::ptr::null_mut(), + }; + + // Build targeting key + let tk = if targeting_key.is_null() { + None + } else { + match unsafe { CStr::from_ptr(targeting_key) }.to_str() { + Ok(s) if !s.is_empty() => Some(Str::from(s)), + _ => None, + } + }; + + // Build attributes map from the C array + let mut attrs = HashMap::new(); + if !attributes.is_null() && attributes_count > 0 { + let slice = unsafe { std::slice::from_raw_parts(attributes, attributes_count) }; + for attr in slice { + if attr.key.is_null() { + continue; + } + let key = match unsafe { CStr::from_ptr(attr.key) }.to_str() { + Ok(s) => s, + Err(_) => continue, + }; + let value = match attr.value_type { + ATTR_TYPE_STRING => { + if attr.string_value.is_null() { + continue; + } + match unsafe { CStr::from_ptr(attr.string_value) }.to_str() { + Ok(s) => Attribute::from(s), + Err(_) => continue, + } + } + ATTR_TYPE_NUMBER => Attribute::from(attr.number_value), + ATTR_TYPE_BOOL => Attribute::from(attr.bool_value), + _ => continue, + }; + attrs.insert(Str::from(key), value); + } + } + + let context = EvaluationContext::new(tk, Arc::new(attrs)); + + let state = match FFE_STATE.lock() { + Ok(s) => s, + Err(_) => return std::ptr::null_mut(), + }; + + let assignment = ffe::get_assignment( + state.config.as_ref(), + flag_key, + &context, + expected_type, + ffe::now(), + ); + + let result = match assignment { + Ok(a) => FfeResult { + value_json: CString::new(assignment_value_to_json(&a.value)).unwrap_or_default(), + variant: Some(CString::new(a.variation_key.as_str()).unwrap_or_default()), + allocation_key: Some(CString::new(a.allocation_key.as_str()).unwrap_or_default()), + reason: match a.reason { + AssignmentReason::Static => REASON_STATIC, + AssignmentReason::TargetingMatch => REASON_TARGETING_MATCH, + AssignmentReason::Split => REASON_SPLIT, + }, + error_code: ERROR_NONE, + do_log: a.do_log, + }, + Err(err) => { + let (error_code, reason) = match &err { + 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: CString::new("null").unwrap_or_default(), + variant: None, + allocation_key: None, + reason, + error_code, + do_log: false, + } + } + }; + + Box::into_raw(Box::new(result)) +} + +#[no_mangle] +pub extern "C" fn ddog_ffe_result_value(r: *const FfeResult) -> *const c_char { + if r.is_null() { + return std::ptr::null(); + } + unsafe { &*r }.value_json.as_ptr() +} + +#[no_mangle] +pub extern "C" fn ddog_ffe_result_variant(r: *const FfeResult) -> *const c_char { + if r.is_null() { + return std::ptr::null(); + } + unsafe { &*r } + .variant + .as_ref() + .map(|s| s.as_ptr()) + .unwrap_or(std::ptr::null()) +} + +#[no_mangle] +pub extern "C" fn ddog_ffe_result_allocation_key(r: *const FfeResult) -> *const c_char { + if r.is_null() { + return std::ptr::null(); + } + unsafe { &*r } + .allocation_key + .as_ref() + .map(|s| s.as_ptr()) + .unwrap_or(std::ptr::null()) +} + +#[no_mangle] +pub extern "C" fn ddog_ffe_result_reason(r: *const FfeResult) -> i32 { + if r.is_null() { + return -1; + } + unsafe { &*r }.reason +} + +#[no_mangle] +pub extern "C" fn ddog_ffe_result_error_code(r: *const FfeResult) -> i32 { + if r.is_null() { + return -1; + } + unsafe { &*r }.error_code +} + +#[no_mangle] +pub extern "C" fn ddog_ffe_result_do_log(r: *const FfeResult) -> bool { + if r.is_null() { + return false; + } + unsafe { &*r }.do_log +} + +#[no_mangle] +pub unsafe extern "C" fn ddog_ffe_free_result(r: *mut FfeResult) { + if !r.is_null() { + drop(Box::from_raw(r)); + } +} + +fn assignment_value_to_json(value: &AssignmentValue) -> String { + match value { + AssignmentValue::String(s) => serde_json::to_string(s.as_str()).unwrap_or_default(), + AssignmentValue::Integer(i) => i.to_string(), + AssignmentValue::Float(f) => serde_json::Number::from_f64(*f) + .map(|n| n.to_string()) + .unwrap_or_else(|| f.to_string()), + AssignmentValue::Boolean(b) => b.to_string(), + AssignmentValue::Json { raw, .. } => raw.get().to_string(), + } +} diff --git a/components-rs/lib.rs b/components-rs/lib.rs index 07ff0cb0223..8ae73f7c583 100644 --- a/components-rs/lib.rs +++ b/components-rs/lib.rs @@ -5,6 +5,7 @@ pub mod log; pub mod remote_config; +pub mod ffe; pub mod sidecar; pub mod telemetry; pub mod bytes; diff --git a/components-rs/remote_config.rs b/components-rs/remote_config.rs index 75e97b019d2..1193d127ced 100644 --- a/components-rs/remote_config.rs +++ b/components-rs/remote_config.rs @@ -31,6 +31,7 @@ use std::ptr::NonNull; use std::sync::Arc; use tracing::debug; use crate::bytes::{ZendString, OwnedZendString, dangling_zend_string}; +use datadog_ffe::rules_based::Configuration; pub const DYANMIC_CONFIG_UPDATE_UNMODIFIED: *mut ZendString = 1isize as *mut ZendString; @@ -101,6 +102,7 @@ pub unsafe extern "C" fn ddog_init_remote_config( live_debugging_enabled: bool, appsec_activation: bool, appsec_config: bool, + ffe_enabled: bool, ) { DDTRACE_REMOTE_CONFIG_PRODUCTS.push(RemoteConfigProduct::ApmTracing); DDTRACE_REMOTE_CONFIG_CAPABILITIES.push(RemoteConfigCapabilities::ApmTracingCustomTags); @@ -117,6 +119,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) } @@ -348,6 +355,11 @@ pub extern "C" fn ddog_process_remote_configs(remote_config: &mut RemoteConfigSt remote_config.dynamic_config.active_config_path = Some(value.config_id); } } + RemoteConfigData::FfeFlags(ufc) => { + debug!("Received FFE flags configuration"); + let config = Configuration::from_server_response(ufc); + crate::ffe::store_config(config); + } RemoteConfigData::Ignored(_) => (), RemoteConfigData::TracerFlareConfig(_) => {} RemoteConfigData::TracerFlareTask(_) => {} @@ -364,6 +376,10 @@ pub extern "C" fn ddog_process_remote_configs(remote_config: &mut RemoteConfigSt remove_old_configs(remote_config); } } + RemoteConfigProduct::FfeFlags => { + debug!("FFE flags configuration removed"); + crate::ffe::clear_config(); + } _ => (), }, } diff --git a/ext/configuration.h b/ext/configuration.h index 97b97875608..d3c816df7e1 100644 --- a/ext/configuration.h +++ b/ext/configuration.h @@ -264,6 +264,7 @@ enum ddtrace_sampling_rules_format { CONFIG(BOOL, DD_TRACE_RESOURCE_RENAMING_ENABLED, "false") \ CONFIG(BOOL, DD_TRACE_RESOURCE_RENAMING_ALWAYS_SIMPLIFIED_ENDPOINT, "false") \ CONFIG(BOOL, DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED, "false") \ + CONFIG(BOOL, DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED, "false") \ DD_INTEGRATIONS #ifndef _WIN32 diff --git a/ext/ddtrace.c b/ext/ddtrace.c index e636e104684..bba20726aa4 100644 --- a/ext/ddtrace.c +++ b/ext/ddtrace.c @@ -128,6 +128,7 @@ bool ddtrace_has_excluded_module; static zend_module_entry *ddtrace_module; + #if PHP_VERSION_ID >= 80000 && PHP_VERSION_ID < 80200 static bool dd_has_other_observers; static int dd_observer_extension_backup = -1; @@ -3008,6 +3009,105 @@ PHP_FUNCTION(dd_trace_internal_fn) { ddtrace_metric_add_point(Z_STR_P(metric_name), zval_get_double(metric_value), Z_STR_P(tags)); RETVAL_TRUE; } + } else if (FUNCTION_NAME_MATCHES("ffe_has_config")) { + RETVAL_BOOL(ddog_ffe_has_config()); + } else if (FUNCTION_NAME_MATCHES("ffe_config_changed")) { + RETVAL_BOOL(ddog_ffe_config_changed()); + } else if (params_count == 1 && FUNCTION_NAME_MATCHES("ffe_load_config")) { + zval *json_zv = ZVAL_VARARG_PARAM(params, 0); + if (Z_TYPE_P(json_zv) == IS_STRING) { + RETVAL_BOOL(ddog_ffe_load_config(Z_STRVAL_P(json_zv))); + } + } else if (FUNCTION_NAME_MATCHES("ffe_evaluate") && params_count >= 4) { + /* ffe_evaluate(flag_key, type_id, targeting_key, attributes) */ + zval *flag_key_zv = ZVAL_VARARG_PARAM(params, 0); + zval *type_zv = ZVAL_VARARG_PARAM(params, 1); + zval *targeting_key_zv = ZVAL_VARARG_PARAM(params, 2); + zval *attrs_zv = ZVAL_VARARG_PARAM(params, 3); + if (Z_TYPE_P(flag_key_zv) == IS_STRING) { + /* Declare all variables at top of block for C89/MSVC compatibility */ + int32_t type_id; + const char *targeting_key = NULL; + struct FfeAttribute *c_attrs = NULL; + size_t attrs_count = 0; + struct FfeResult *result; + type_id = (int32_t)zval_get_long(type_zv); + if (Z_TYPE_P(targeting_key_zv) == IS_STRING && Z_STRLEN_P(targeting_key_zv) > 0) { + targeting_key = Z_STRVAL_P(targeting_key_zv); + } + if (Z_TYPE_P(attrs_zv) == IS_ARRAY) { + HashTable *ht = Z_ARRVAL_P(attrs_zv); + attrs_count = zend_hash_num_elements(ht); + if (attrs_count > 0) { + size_t idx = 0; + zend_string *key; + zval *val; + c_attrs = ecalloc(attrs_count, sizeof(struct FfeAttribute)); + ZEND_HASH_FOREACH_STR_KEY_VAL(ht, key, val) { + if (!key || idx >= attrs_count) { continue; } + c_attrs[idx].key = ZSTR_VAL(key); + switch (Z_TYPE_P(val)) { + case IS_STRING: + c_attrs[idx].value_type = 0; + c_attrs[idx].string_value = Z_STRVAL_P(val); + break; + case IS_LONG: + c_attrs[idx].value_type = 1; + c_attrs[idx].number_value = (double)Z_LVAL_P(val); + break; + case IS_DOUBLE: + c_attrs[idx].value_type = 1; + c_attrs[idx].number_value = Z_DVAL_P(val); + 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: + /* In C, `continue` inside a switch inside a loop targets the loop, + not the switch. This skips idx++ so this slot is overwritten next + iteration. The partially-written key ptr is harmless since idx + stays pointing at this slot. */ + continue; + } + idx++; + } ZEND_HASH_FOREACH_END(); + attrs_count = idx; + } + } + result = ddog_ffe_evaluate( + Z_STRVAL_P(flag_key_zv), type_id, targeting_key, c_attrs, attrs_count); + if (c_attrs) { + efree(c_attrs); + } + if (result) { + const char *val; + const char *var; + const char *ak; + array_init(return_value); + val = ddog_ffe_result_value(result); + var = ddog_ffe_result_variant(result); + ak = ddog_ffe_result_allocation_key(result); + if (val) { add_assoc_string(return_value, "value_json", (char *)val); } + else { add_assoc_null(return_value, "value_json"); } + if (var) { add_assoc_string(return_value, "variant", (char *)var); } + else { add_assoc_null(return_value, "variant"); } + if (ak) { add_assoc_string(return_value, "allocation_key", (char *)ak); } + else { add_assoc_null(return_value, "allocation_key"); } + add_assoc_long(return_value, "reason", ddog_ffe_result_reason(result)); + add_assoc_long(return_value, "error_code", ddog_ffe_result_error_code(result)); + add_assoc_bool(return_value, "do_log", ddog_ffe_result_do_log(result)); + ddog_ffe_free_result(result); + } else { + RETVAL_NULL(); + } + } else { + RETVAL_NULL(); + } } else if (FUNCTION_NAME_MATCHES("dump_sidecar")) { if (!ddtrace_sidecar) { RETURN_FALSE; diff --git a/ext/sidecar.c b/ext/sidecar.c index 73b653fd7cb..a249a942e14 100644 --- a/ext/sidecar.c +++ b/ext/sidecar.c @@ -250,7 +250,7 @@ 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(get_global_DD_INSTRUMENTATION_TELEMETRY_ENABLED(), appsec_activation, appsec_config, get_global_DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED()); ddtrace_sidecar = dd_sidecar_connection_factory(); if (!ddtrace_sidecar) { // Something went wrong diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index 001ce65306d..973e3849cdd 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -354,6 +354,13 @@ "default": "false" } ], + "DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED": [ + { + "implementation": "A", + "type": "boolean", + "default": "false" + } + ], "DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED": [ { "implementation": "A", diff --git a/src/DDTrace/FeatureFlags/ExposureCache.php b/src/DDTrace/FeatureFlags/ExposureCache.php new file mode 100644 index 00000000000..ce16715b0fc --- /dev/null +++ b/src/DDTrace/FeatureFlags/ExposureCache.php @@ -0,0 +1,117 @@ +cache = new LRUCache($capacity); + } + + /** + * Add an exposure event to the cache. + * + * @param string $flagKey + * @param string $subjectId + * @param string $variantKey + * @param string $allocationKey + * @return bool true if the event is new or value changed, false if exact duplicate + */ + public function add($flagKey, $subjectId, $variantKey, $allocationKey) + { + $key = self::makeKey($flagKey, $subjectId); + $newValue = self::makeValue($variantKey, $allocationKey); + + // Always put (updates LRU position even for duplicates) + $oldValue = $this->cache->put($key, $newValue); + + return $oldValue === null || $oldValue !== $newValue; + } + + /** + * Get the cached value for a (flag, subject) pair. + * + * @param string $flagKey + * @param string $subjectId + * @return array|null [variantKey, allocationKey] or null if not found + */ + public function get($flagKey, $subjectId) + { + $key = self::makeKey($flagKey, $subjectId); + $value = $this->cache->get($key); + if ($value === null) { + return null; + } + return self::parseValue($value); + } + + /** + * Return the number of entries in the cache. + * + * @return int + */ + public function size() + { + return $this->cache->size(); + } + + /** + * Clear all entries. + */ + public function clear() + { + $this->cache->clear(); + } + + /** + * Build a composite key that avoids collision. + * Uses length-prefixing: "::" + */ + private static function makeKey($flagKey, $subjectId) + { + $f = $flagKey !== null ? $flagKey : ''; + $s = $subjectId !== null ? $subjectId : ''; + return strlen($f) . ':' . $f . ':' . $s; + } + + /** + * Build a composite value string. + */ + private static function makeValue($variantKey, $allocationKey) + { + $v = $variantKey !== null ? $variantKey : ''; + $a = $allocationKey !== null ? $allocationKey : ''; + return strlen($v) . ':' . $v . ':' . $a; + } + + /** + * Parse a composite value string back into [variantKey, allocationKey]. + */ + private static function parseValue($value) + { + $colonPos = strpos($value, ':'); + if ($colonPos === false) { + return [$value, '']; + } + $len = (int) substr($value, 0, $colonPos); + $variant = substr($value, $colonPos + 1, $len); + $allocation = substr($value, $colonPos + 1 + $len + 1); + return [$variant, $allocation]; + } +} diff --git a/src/DDTrace/FeatureFlags/ExposureWriter.php b/src/DDTrace/FeatureFlags/ExposureWriter.php new file mode 100644 index 00000000000..bfd6af60a8a --- /dev/null +++ b/src/DDTrace/FeatureFlags/ExposureWriter.php @@ -0,0 +1,197 @@ +agentUrl = $this->resolveAgentUrl(); + } + + /** + * Add an exposure event to the buffer. + * + * @param array $event Exposure event data + */ + public function enqueue(array $event) + { + if (count($this->buffer) >= self::MAX_BUFFER_SIZE) { + $this->droppedEvents++; + return; + } + $this->buffer[] = $event; + } + + /** + * Send all buffered exposure events as a batch to the EVP proxy. + */ + public function flush() + { + if (empty($this->buffer)) { + return; + } + + $events = $this->buffer; + $dropped = $this->droppedEvents; + $this->buffer = []; + $this->droppedEvents = 0; + + if ($dropped > 0 && function_exists('dd_trace_env_config') && \dd_trace_env_config('DD_TRACE_DEBUG')) { + error_log("ddtrace/ffe: dropped $dropped exposure event(s) due to full buffer"); + } + + $payload = [ + 'context' => [ + 'service' => $this->getConfigValue('DD_SERVICE', ''), + 'env' => $this->getConfigValue('DD_ENV', ''), + 'version' => $this->getConfigValue('DD_VERSION', ''), + ], + 'exposures' => $events, + ]; + + $url = rtrim($this->agentUrl, '/') . '/evp_proxy/v2/api/v2/exposures'; + $body = json_encode($payload); + + if (!function_exists('curl_init')) { + return; + } + + $ch = curl_init($url); + if ($ch === false) { + return; + } + + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $body, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'X-Datadog-EVP-Subdomain: event-platform-intake', + ], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT_MS => 500, + CURLOPT_CONNECTTIMEOUT_MS => 100, + ]); + + $response = curl_exec($ch); + if (function_exists('dd_trace_env_config') && \dd_trace_env_config('DD_TRACE_DEBUG')) { + if ($response === false) { + error_log('ddtrace/ffe: failed to send exposures: ' . curl_error($ch)); + } else { + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + if ($httpCode < 200 || $httpCode >= 300) { + error_log('ddtrace/ffe: unexpected HTTP ' . $httpCode . ' sending exposures'); + } + } + } + curl_close($ch); + } + + /** + * Return the number of events currently in the buffer. + * + * @return int + */ + public function getBufferCount() + { + return count($this->buffer); + } + + /** + * Build a complete exposure event array. + * + * @param string $flagKey + * @param string $variantKey + * @param string $allocationKey + * @param string|null $targetingKey + * @param array $attributes + * @return array + */ + public static function buildEvent( + $flagKey, + $variantKey, + $allocationKey, + $targetingKey = null, + array $attributes = [] + ) { + return [ + 'timestamp' => (int)(microtime(true) * 1000), + 'allocation' => ['key' => $allocationKey], + 'flag' => ['key' => $flagKey], + 'variant' => ['key' => $variantKey], + 'subject' => [ + 'id' => $targetingKey ?? '', + 'attributes' => $attributes, + ], + ]; + } + + /** + * Resolve the agent URL from environment configuration. + * + * Checks DD_TRACE_AGENT_URL first. If not set, constructs from + * DD_AGENT_HOST (default: localhost) and DD_TRACE_AGENT_PORT (default: 8126). + * + * @return string + */ + private function resolveAgentUrl() + { + $agentUrl = $this->getConfigValue('DD_TRACE_AGENT_URL', ''); + if ($agentUrl !== '') { + return rtrim($agentUrl, '/'); + } + + $host = $this->getConfigValue('DD_AGENT_HOST', 'localhost'); + if ($host === '') { + $host = 'localhost'; + } + + $port = $this->getConfigValue('DD_TRACE_AGENT_PORT', '8126'); + if ($port === '') { + $port = '8126'; + } + + return 'http://' . $host . ':' . $port; + } + + /** + * Read a configuration value using dd_trace_env_config() if available, + * otherwise fall back to getenv(). + * + * @param string $name + * @param string $default + * @return string + */ + private function getConfigValue($name, $default = '') + { + if (function_exists('dd_trace_env_config')) { + $value = \dd_trace_env_config($name); + if ($value !== '' && $value !== false && $value !== null) { + return (string)$value; + } + return $default; + } + + $value = getenv($name); + if ($value !== false && $value !== '') { + return $value; + } + + return $default; + } +} diff --git a/src/DDTrace/FeatureFlags/FlagEvalMetrics.php b/src/DDTrace/FeatureFlags/FlagEvalMetrics.php new file mode 100644 index 00000000000..9974f12ecd8 --- /dev/null +++ b/src/DDTrace/FeatureFlags/FlagEvalMetrics.php @@ -0,0 +1,126 @@ + $flagKey, + 'feature_flag.provider.name' => 'datadog', + 'feature_flag.result.variant' => (string)($result['variant'] ?? ''), + 'feature_flag.result.reason' => strtolower((string)($result['reason'] ?? 'default')), + ]; + + if (!empty($result['allocation_key'])) { + $attributes['feature_flag.result.allocation_key'] = (string)$result['allocation_key']; + } + + $errorCode = isset($result['error_code']) ? (int)$result['error_code'] : 0; + if ($errorCode !== 0) { + $attributes['error.type'] = self::errorCodeToTag($errorCode); + } + + try { + $counter->add(1, $attributes); + } catch (Throwable $e) { + // noop + } + } + + /** + * @return object|null \OpenTelemetry\API\Metrics\CounterInterface + */ + private static function getCounter() + { + if (self::$initialized) { + return self::$counter; + } + self::$initialized = true; + + if (!function_exists('dd_trace_env_config') || !\dd_trace_env_config('DD_METRICS_OTEL_ENABLED')) { + return null; + } + + if (!class_exists('\OpenTelemetry\API\Globals')) { + return null; + } + + try { + $meter = \OpenTelemetry\API\Globals::meterProvider()->getMeter(self::METER_NAME); + self::$counter = $meter->createCounter( + self::METRIC_NAME, + self::METRIC_UNIT, + self::METRIC_DESC + ); + } catch (Throwable $e) { + // noop — OTel metrics not available + } + + return self::$counter; + } + + private static function errorCodeToTag($code) + { + switch ((int)$code) { + case 1: + return 'type_mismatch'; // ERROR_TYPE_MISMATCH + case 2: + return 'parse_error'; // ERROR_CONFIG_PARSE + case 3: + return 'flag_not_found'; // ERROR_FLAG_UNRECOGNIZED + default: + return 'general'; + } + } + + /** + * Reset static state (useful for testing). + */ + public static function reset() + { + self::$counter = null; + self::$initialized = false; + } +} diff --git a/src/DDTrace/FeatureFlags/LRUCache.php b/src/DDTrace/FeatureFlags/LRUCache.php new file mode 100644 index 00000000000..bdb2f977aaf --- /dev/null +++ b/src/DDTrace/FeatureFlags/LRUCache.php @@ -0,0 +1,112 @@ + */ + private $cache = []; + + /** + * @param int $maxSize Maximum number of entries in the cache + */ + public function __construct($maxSize = 65536) + { + $this->maxSize = $maxSize; + } + + /** + * Get a value from the cache by key. + * + * Accessing an entry promotes it to the most recently used position. + * + * @param string $key + * @return mixed|null The cached value, or null if not found + */ + public function get($key) + { + if (!array_key_exists($key, $this->cache)) { + return null; + } + + // Move to end (most recently used) by removing and re-adding + $value = $this->cache[$key]; + unset($this->cache[$key]); + $this->cache[$key] = $value; + + return $value; + } + + /** + * Set a value in the cache. + * + * If the key already exists, the value is updated and the entry is promoted + * to the most recently used position. If the cache is at capacity, the least + * recently used entry is evicted. + * + * @param string $key + * @param mixed $value + */ + public function set($key, $value) + { + $this->put($key, $value); + } + + /** + * Put a value in the cache and return the previous value. + * + * Like set(), but returns the old value (or null if the key was not present). + * Always updates the LRU position, even when the value is unchanged. + * + * @param string $key + * @param mixed $value + * @return mixed|null The previous value, or null if the key was new + */ + public function put($key, $value) + { + $oldValue = null; + if (array_key_exists($key, $this->cache)) { + $oldValue = $this->cache[$key]; + unset($this->cache[$key]); + } + + $this->cache[$key] = $value; + + // Evict least recently used entry if over capacity + if (count($this->cache) > $this->maxSize) { + reset($this->cache); + $evictKey = key($this->cache); + unset($this->cache[$evictKey]); + } + + return $oldValue; + } + + /** + * Return the number of entries in the cache. + * + * @return int + */ + public function size() + { + return count($this->cache); + } + + /** + * Clear all entries from the cache. + */ + public function clear() + { + $this->cache = []; + } +} diff --git a/src/DDTrace/FeatureFlags/Provider.php b/src/DDTrace/FeatureFlags/Provider.php new file mode 100644 index 00000000000..fe8c3a426f9 --- /dev/null +++ b/src/DDTrace/FeatureFlags/Provider.php @@ -0,0 +1,301 @@ + 'STATIC', + 1 => 'DEFAULT', + 2 => 'TARGETING_MATCH', + 3 => 'SPLIT', + 4 => 'DISABLED', + 5 => 'ERROR', + ]; + + private static $TYPE_MAP = [ + 'STRING' => 0, + 'INTEGER' => 1, + 'NUMERIC' => 2, + 'BOOLEAN' => 3, + 'JSON' => 4, + ]; + + /** @var ExposureWriter */ + private $writer; + + /** @var ExposureCache */ + private $exposureCache; + + /** @var bool */ + private $enabled; + + /** @var bool */ + private $configLoaded = false; + + /** @var bool */ + private $shutdownRegistered = false; + + /** @var Provider|null */ + private static $instance = null; + + public function __construct() + { + $this->writer = new ExposureWriter(); + $this->exposureCache = new ExposureCache(65536); + $this->enabled = $this->isFeatureFlagEnabled(); + } + + /** + * Get the singleton instance. + */ + public static function getInstance() + { + if (self::$instance === null) { + self::$instance = new self(); + } + return self::$instance; + } + + /** + * Reset the singleton (useful for testing). + */ + public static function reset() + { + self::$instance = null; + } + + /** + * Initialize the provider: load config from RC into the native engine. + */ + public function start() + { + if (!$this->enabled) { + return false; + } + $this->checkNativeConfig(); + $this->registerShutdown(); + return true; + } + + /** + * Register a shutdown function to auto-flush exposure events at request end. + */ + private function registerShutdown() + { + if ($this->shutdownRegistered) { + return; + } + $writer = $this->writer; + register_shutdown_function(function () use ($writer) { + $writer->flush(); + }); + $this->shutdownRegistered = true; + } + + /** + * Check if the native FFE configuration has been loaded or changed via Remote Config. + */ + private function checkNativeConfig() + { + // Check if config changed since last check (covers both new config and removal) + if (\dd_trace_internal_fn('ffe_config_changed')) { + $hasConfig = \dd_trace_internal_fn('ffe_has_config'); + if ($hasConfig && !$this->configLoaded) { + $this->configLoaded = true; + } elseif (!$hasConfig && $this->configLoaded) { + // Config was removed via RC + $this->configLoaded = false; + $this->exposureCache->clear(); + } + } elseif (!$this->configLoaded && \dd_trace_internal_fn('ffe_has_config')) { + // First check — config was already loaded before provider started + $this->configLoaded = true; + } + } + + /** + * Evaluate a feature flag using the native datadog-ffe engine. + * + * @param string $flagKey The flag key to evaluate + * @param string $variationType The expected variation type (STRING, BOOLEAN, INTEGER, NUMERIC, JSON) + * @param mixed $defaultValue The default value to return if evaluation fails + * @param string|null $targetingKey The targeting key (user/subject ID) + * @param array $attributes Additional context attributes + * @return array ['value' => mixed, 'reason' => string, 'variant' => string|null, 'allocation_key' => string|null] + */ + public function evaluate($flagKey, $variationType, $defaultValue, $targetingKey, $attributes = []) + { + if (!$this->enabled) { + $result = ['value' => $defaultValue, 'reason' => 'DISABLED', 'variant' => null, 'allocation_key' => null]; + FlagEvalMetrics::record($flagKey, $result); + return $result; + } + + // Ensure native config is loaded + $this->checkNativeConfig(); + + if (!$this->configLoaded) { + $result = ['value' => $defaultValue, 'reason' => 'DEFAULT', 'variant' => null, 'allocation_key' => null]; + FlagEvalMetrics::record($flagKey, $result); + return $result; + } + + $typeId = isset(self::$TYPE_MAP[$variationType]) ? self::$TYPE_MAP[$variationType] : 0; + + // Call the native evaluation engine with structured attributes + $result = \dd_trace_internal_fn('ffe_evaluate', $flagKey, $typeId, + $targetingKey, is_array($attributes) ? $attributes : []); + + if ($result === null) { + $evalResult = ['value' => $defaultValue, 'reason' => 'DEFAULT', 'variant' => null, 'allocation_key' => null]; + FlagEvalMetrics::record($flagKey, $evalResult); + return $evalResult; + } + + $errorCode = isset($result['error_code']) ? (int)$result['error_code'] : 0; + $reason = isset($result['reason']) ? (int)$result['reason'] : 1; + $reasonStr = isset(self::$REASON_MAP[$reason]) ? self::$REASON_MAP[$reason] : 'DEFAULT'; + + // Error or no variant → return default + if ($errorCode !== 0 || $result['variant'] === null) { + // Per OpenFeature spec, any non-zero error code must use reason=ERROR + $reportReason = ($errorCode !== 0) ? 'ERROR' : $reasonStr; + $evalResult = ['value' => $defaultValue, 'reason' => $reportReason, 'variant' => null, 'allocation_key' => null, + 'error_code' => $errorCode]; + FlagEvalMetrics::record($flagKey, $evalResult); + return $evalResult; + } + + // Parse the value from JSON + $value = $this->parseNativeValue($result['value_json'], $variationType, $defaultValue); + + // Report exposure event (deduplicated via ExposureCache) + $doLog = !empty($result['do_log']); + if ($doLog && $result['variant'] !== null && $result['allocation_key'] !== null) { + $this->reportExposure( + $flagKey, + $result['variant'], + $result['allocation_key'], + $targetingKey, + $attributes + ); + } + + $evalResult = [ + 'value' => $value, + 'reason' => $reasonStr, + 'variant' => $result['variant'], + 'allocation_key' => $result['allocation_key'], + ]; + + // Record OTel evaluation metric (noop if DD_METRICS_OTEL_ENABLED is not set) + FlagEvalMetrics::record($flagKey, $evalResult); + + return $evalResult; + } + + /** + * Parse a native value JSON string into the correct PHP type. + */ + private function parseNativeValue($valueJson, $variationType, $defaultValue) + { + if ($valueJson === null || $valueJson === 'null') { + return $defaultValue; + } + + switch ($variationType) { + case 'BOOLEAN': + return $valueJson === 'true'; + case 'INTEGER': + return (int)$valueJson; + case 'NUMERIC': + return (float)$valueJson; + case 'JSON': + $decoded = json_decode($valueJson, true); + if (json_last_error() === JSON_ERROR_NONE) { + return $decoded; + } + return $defaultValue; + case 'STRING': + default: + // String values come as JSON-encoded strings (with quotes) + $decoded = json_decode($valueJson); + return is_string($decoded) ? $decoded : $valueJson; + } + } + + /** + * Report a feature flag exposure event, deduplicated via ExposureCache. + */ + private function reportExposure($flagKey, $variantKey, $allocationKey, $targetingKey, $attributes) + { + if (!$variantKey || !$allocationKey) { + return; + } + + $subjectId = $targetingKey !== null ? $targetingKey : ''; + + // add() returns true for new events or when value changed, false for exact duplicates + if (!$this->exposureCache->add($flagKey, $subjectId, $variantKey, $allocationKey)) { + return; + } + + $event = ExposureWriter::buildEvent( + $flagKey, + $variantKey, + $allocationKey, + $subjectId, + is_array($attributes) ? $attributes : [] + ); + + $this->writer->enqueue($event); + } + + /** + * Flush pending exposure events. + */ + public function flush() + { + $this->writer->flush(); + } + + /** + * Clear the exposure cache. + */ + public function clearExposureCache() + { + $this->exposureCache->clear(); + } + + /** + * Check if the feature flag provider is enabled via env var. + */ + private function isFeatureFlagEnabled() + { + if (function_exists('dd_trace_env_config')) { + return (bool)\dd_trace_env_config('DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED'); + } + + $envVal = getenv('DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED'); + if ($envVal === false || $envVal === '') { + return false; + } + return strtolower($envVal) === 'true' || $envVal === '1'; + } + + /** + * Check if config has been loaded into the native engine. + */ + public function isReady() + { + return $this->configLoaded; + } +} diff --git a/src/DDTrace/OpenFeature/DataDogProvider.php b/src/DDTrace/OpenFeature/DataDogProvider.php new file mode 100644 index 00000000000..bf82620bab5 --- /dev/null +++ b/src/DDTrace/OpenFeature/DataDogProvider.php @@ -0,0 +1,151 @@ +getBooleanValue('my-flag', false, $context); + * + * Requires: composer require open-feature/sdk + */ +class DataDogProvider extends AbstractProvider +{ + /** @var Provider */ + private $ffeProvider; + + public function __construct() + { + $this->ffeProvider = Provider::getInstance(); + $this->ffeProvider->start(); + } + + public function getMetadata(): Metadata + { + return new class implements Metadata { + public function getName(): string + { + return 'Datadog'; + } + }; + } + + public function resolveBooleanValue( + string $flagKey, + bool $defaultValue, + ?EvaluationContext $context = null + ): ResolutionDetailsInterface { + return $this->resolve($flagKey, 'BOOLEAN', $defaultValue, $context); + } + + public function resolveStringValue( + string $flagKey, + string $defaultValue, + ?EvaluationContext $context = null + ): ResolutionDetailsInterface { + return $this->resolve($flagKey, 'STRING', $defaultValue, $context); + } + + public function resolveIntegerValue( + string $flagKey, + int $defaultValue, + ?EvaluationContext $context = null + ): ResolutionDetailsInterface { + return $this->resolve($flagKey, 'INTEGER', $defaultValue, $context); + } + + public function resolveFloatValue( + string $flagKey, + float $defaultValue, + ?EvaluationContext $context = null + ): ResolutionDetailsInterface { + return $this->resolve($flagKey, 'NUMERIC', $defaultValue, $context); + } + + public function resolveObjectValue( + string $flagKey, + array $defaultValue, + ?EvaluationContext $context = null + ): ResolutionDetailsInterface { + return $this->resolve($flagKey, 'JSON', $defaultValue, $context); + } + + /** + * @param mixed $defaultValue + */ + private function resolve( + string $flagKey, + string $variationType, + $defaultValue, + ?EvaluationContext $context = null + ): ResolutionDetailsInterface { + $targetingKey = ''; + $attributes = []; + + if ($context !== null) { + $targetingKey = $context->getTargetingKey() ?? ''; + $attrs = $context->getAttributes(); + if ($attrs !== null) { + $attributes = $attrs->toArray(); + } + } + + $result = $this->ffeProvider->evaluate( + $flagKey, + $variationType, + $defaultValue, + $targetingKey, + $attributes + ); + + $builder = new ResolutionDetailsBuilder(); + $builder->withValue($result['value']); + + if (isset($result['reason'])) { + $builder->withReason($result['reason']); + } + + if (isset($result['variant']) && $result['variant'] !== null) { + $builder->withVariant($result['variant']); + } + + $errorCode = isset($result['error_code']) ? (int)$result['error_code'] : 0; + if ($errorCode !== 0) { + $builder->withError(new ResolutionError(self::mapErrorCode($errorCode))); + } + + return $builder->build(); + } + + private static function mapErrorCode(int $errorCode): ErrorCode + { + switch ($errorCode) { + case 1: + return ErrorCode::TYPE_MISMATCH(); + case 2: + return ErrorCode::PARSE_ERROR(); + case 3: + return ErrorCode::FLAG_NOT_FOUND(); + default: + return ErrorCode::GENERAL(); + } + } +} diff --git a/src/bridge/_files_tracer.php b/src/bridge/_files_tracer.php index 41383405c12..11bce82af43 100644 --- a/src/bridge/_files_tracer.php +++ b/src/bridge/_files_tracer.php @@ -40,4 +40,9 @@ __DIR__ . '/../DDTrace/Propagators/TextMap.php', __DIR__ . '/../DDTrace/ScopeManager.php', __DIR__ . '/../DDTrace/Tracer.php', + __DIR__ . '/../DDTrace/FeatureFlags/LRUCache.php', + __DIR__ . '/../DDTrace/FeatureFlags/ExposureCache.php', + __DIR__ . '/../DDTrace/FeatureFlags/ExposureWriter.php', + __DIR__ . '/../DDTrace/FeatureFlags/Provider.php', + __DIR__ . '/../DDTrace/FeatureFlags/FlagEvalMetrics.php', ]; diff --git a/tests/FeatureFlags/EvaluationTest.php b/tests/FeatureFlags/EvaluationTest.php new file mode 100644 index 00000000000..ced470f83a3 --- /dev/null +++ b/tests/FeatureFlags/EvaluationTest.php @@ -0,0 +1,219 @@ + 0, + 'INTEGER' => 1, + 'NUMERIC' => 2, + 'BOOLEAN' => 3, + 'JSON' => 4, + ]; + return isset($map[$variationType]) ? $map[$variationType] : -1; + } + + /** + * Build the attributes array for ffe_evaluate from the test case attributes. + * Only scalar types (string, number, bool) are supported by the FFI bridge. + */ + private static function buildAttributes(array $attrs) + { + $result = []; + foreach ($attrs as $key => $value) { + if (is_string($value) || is_numeric($value) || is_bool($value)) { + $result[$key] = $value; + } + } + return $result; + } + + /** + * Parse the value_json string returned by ffe_evaluate based on the variation type. + */ + private static function parseValueJson($valueJson, $variationType) + { + switch ($variationType) { + case 'STRING': + return json_decode($valueJson, true); + case 'INTEGER': + return (int) $valueJson; + case 'NUMERIC': + return (float) $valueJson; + case 'BOOLEAN': + return $valueJson === 'true'; + case 'JSON': + return json_decode($valueJson, true); + default: + return json_decode($valueJson, true); + } + } + + /** + * Data provider that scans all evaluation case fixture files and flattens + * every scenario into a [fileName, caseIndex, caseData] tuple. + */ + public function provideEvaluationCases() + { + $casesDir = __DIR__ . '/fixtures/evaluation-cases'; + $files = glob($casesDir . '/*.json'); + $dataset = []; + + foreach ($files as $filePath) { + $fileName = basename($filePath, '.json'); + $cases = json_decode(file_get_contents($filePath), true); + + foreach ($cases as $index => $case) { + $label = sprintf('%s#%d (%s)', $fileName, $index, $case['flag']); + $dataset[$label] = [$fileName, $index, $case]; + } + } + + return $dataset; + } + + /** + * @dataProvider provideEvaluationCases + */ + public function testEvaluation($fileName, $caseIndex, $case) + { + if (!self::$configLoaded) { + $this->markTestSkipped('UFC config was not loaded'); + } + + $flagKey = $case['flag']; + $variationType = $case['variationType']; + $typeId = self::variationTypeToId($variationType); + $targetingKey = isset($case['targetingKey']) ? $case['targetingKey'] : ''; + $attributes = isset($case['attributes']) ? self::buildAttributes($case['attributes']) : []; + $defaultValue = isset($case['defaultValue']) ? $case['defaultValue'] : null; + $expectedValue = $case['result']['value']; + $expectedReason = isset($case['result']['reason']) ? $case['result']['reason'] : null; + + // Skip test cases that reference flags not present in the UFC config + // AND expect a non-default result (these require a different config). + if (!in_array($flagKey, self::$configFlagKeys) && $expectedValue !== $defaultValue) { + $this->markTestSkipped( + sprintf('Flag "%s" not in UFC config and expected non-default value', $flagKey) + ); + } + + $result = \dd_trace_internal_fn('ffe_evaluate', $flagKey, $typeId, $targetingKey, $attributes); + + $this->assertNotNull( + $result, + sprintf('ffe_evaluate returned null for %s#%d', $fileName, $caseIndex) + ); + + $this->assertArrayHasKey('value_json', $result); + + $errorCode = isset($result['error_code']) ? (int) $result['error_code'] : 0; + $reason = isset($result['reason']) ? (int) $result['reason'] : -1; + + // Reason codes returned by the Rust engine (must match ffe.rs constants): + // 0=STATIC, 1=DEFAULT, 2=TARGETING_MATCH, 3=SPLIT, 4=DISABLED, 5=ERROR + static $REASON_MAP = [ + 0 => 'STATIC', + 1 => 'DEFAULT', + 2 => 'TARGETING_MATCH', + 3 => 'SPLIT', + 4 => 'DISABLED', + 5 => 'ERROR', + ]; + $reasonStr = isset($REASON_MAP[$reason]) ? $REASON_MAP[$reason] : 'UNKNOWN'; + + // When the evaluator returns an error, the Provider layer would return + // the defaultValue. If the expected result equals the defaultValue, + // verify the evaluator correctly returned an error (no match). + if ($errorCode !== 0 && $expectedValue === $defaultValue) { + // Evaluator correctly could not resolve — Provider returns default. + if ($expectedReason !== null) { + $this->assertSame( + $expectedReason, + 'ERROR', + sprintf('Reason mismatch for %s#%d (flag=%s)', $fileName, $caseIndex, $flagKey) + ); + } + $this->assertTrue(true); + return; + } + + // error_code=0 with reason=1 (DEFAULT) means DefaultAllocationNull — + // no matching allocation. Provider returns the caller's default value. + if ($errorCode === 0 && $reason === 1 && $expectedValue === $defaultValue) { + if ($expectedReason !== null) { + $this->assertSame( + $expectedReason, + 'DEFAULT', + sprintf('Reason mismatch for %s#%d (flag=%s)', $fileName, $caseIndex, $flagKey) + ); + } + $this->assertTrue(true); + return; + } + + // Assert reason when the fixture specifies one. + if ($expectedReason !== null) { + $this->assertSame( + $expectedReason, + $reasonStr, + sprintf('Reason mismatch for %s#%d (flag=%s): expected %s, got %s', + $fileName, $caseIndex, $flagKey, $expectedReason, $reasonStr) + ); + } + + $actualValue = self::parseValueJson($result['value_json'], $variationType); + + if ($variationType === 'NUMERIC') { + $this->assertEquals( + $expectedValue, + $actualValue, + sprintf('Value mismatch for %s#%d (flag=%s)', $fileName, $caseIndex, $flagKey), + 1e-10 + ); + } else { + $this->assertSame( + $expectedValue, + $actualValue, + sprintf('Value mismatch for %s#%d (flag=%s): expected %s, got %s', + $fileName, $caseIndex, $flagKey, + json_encode($expectedValue), json_encode($actualValue)) + ); + } + } +} diff --git a/tests/FeatureFlags/ExposureCacheTest.php b/tests/FeatureFlags/ExposureCacheTest.php new file mode 100644 index 00000000000..c3051c11670 --- /dev/null +++ b/tests/FeatureFlags/ExposureCacheTest.php @@ -0,0 +1,183 @@ +add('flag', 'subject', 'variant', 'allocation'); + + $this->assertTrue($added); + $this->assertSame(1, $cache->size()); + } + + public function testAddingDuplicateEventsReturnsFalse() + { + $cache = new ExposureCache(5); + $cache->add('flag', 'subject', 'variant', 'allocation'); + $duplicateAdded = $cache->add('flag', 'subject', 'variant', 'allocation'); + + $this->assertFalse($duplicateAdded); + $this->assertSame(1, $cache->size()); + } + + public function testAddingEventsWithSameKeyButDifferentDetailsUpdatesCache() + { + $cache = new ExposureCache(5); + $added1 = $cache->add('flag', 'subject', 'variant1', 'allocation1'); + $added2 = $cache->add('flag', 'subject', 'variant2', 'allocation2'); + + $retrieved = $cache->get('flag', 'subject'); + + $this->assertTrue($added1); + $this->assertTrue($added2); + $this->assertSame(1, $cache->size()); + $this->assertSame('variant2', $retrieved[0]); + $this->assertSame('allocation2', $retrieved[1]); + } + + public function testLRUEvictionWhenCapacityExceeded() + { + $cache = new ExposureCache(2); + $cache->add('flag1', 'subject1', 'variant1', 'allocation1'); + $cache->add('flag2', 'subject2', 'variant2', 'allocation2'); + $cache->add('flag3', 'subject3', 'variant3', 'allocation3'); + + $this->assertSame(2, $cache->size()); + $this->assertNull($cache->get('flag1', 'subject1'), 'event1 should be evicted'); + + $retrieved3 = $cache->get('flag3', 'subject3'); + $this->assertNotNull($retrieved3); + $this->assertSame('variant3', $retrieved3[0]); + $this->assertSame('allocation3', $retrieved3[1]); + } + + public function testSingleCapacityCache() + { + $cache = new ExposureCache(1); + $cache->add('flag1', 'subject1', 'variant1', 'allocation1'); + $cache->add('flag2', 'subject2', 'variant2', 'allocation2'); + + $this->assertSame(1, $cache->size()); + } + + public function testZeroCapacityCache() + { + $cache = new ExposureCache(0); + $added = $cache->add('flag', 'subject', 'variant', 'allocation'); + + $this->assertTrue($added); + $this->assertSame(0, $cache->size()); + } + + public function testEmptyCacheSize() + { + $cache = new ExposureCache(5); + $this->assertSame(0, $cache->size()); + } + + public function testMultipleAdditionsWithSameFlagDifferentSubjects() + { + $cache = new ExposureCache(10); + $results = []; + for ($i = 0; $i < 5; $i++) { + $results[] = $cache->add('flag', "subject{$i}", 'variant', 'allocation'); + } + + $this->assertSame([true, true, true, true, true], $results); + $this->assertSame(5, $cache->size()); + } + + public function testMultipleAdditionsWithSameSubjectDifferentFlags() + { + $cache = new ExposureCache(10); + $results = []; + for ($i = 0; $i < 5; $i++) { + $results[] = $cache->add("flag{$i}", 'subject', 'variant', 'allocation'); + } + + $this->assertSame([true, true, true, true, true], $results); + $this->assertSame(5, $cache->size()); + } + + public function testKeyEqualityWithNullValues() + { + $cache = new ExposureCache(5); + $cache->add('', '', 'variant', 'allocation'); + $duplicateAdded = $cache->add('', '', 'variant', 'allocation'); + + $this->assertFalse($duplicateAdded); + $this->assertSame(1, $cache->size()); + } + + public function testUpdatingExistingKeyMaintainsLRUPosition() + { + $cache = new ExposureCache(3); + $cache->add('flag1', 'subject1', 'variant1', 'allocation1'); + $cache->add('flag2', 'subject2', 'variant2', 'allocation2'); + $cache->add('flag3', 'subject3', 'variant3', 'allocation3'); + // Update event1 with new details — moves it to most recent + $cache->add('flag1', 'subject1', 'variant2', 'allocation2'); + // Should evict event2, not event1 + $cache->add('flag4', 'subject4', 'variant4', 'allocation4'); + + $this->assertSame(3, $cache->size()); + + $retrieved1 = $cache->get('flag1', 'subject1'); + $this->assertNotNull($retrieved1, 'event1 should be present (was updated)'); + $this->assertSame('variant2', $retrieved1[0]); + $this->assertSame('allocation2', $retrieved1[1]); + + $this->assertNull($cache->get('flag2', 'subject2'), 'event2 should be evicted'); + + $retrieved4 = $cache->get('flag4', 'subject4'); + $this->assertNotNull($retrieved4); + $this->assertSame('variant4', $retrieved4[0]); + } + + public function testDuplicateExposureKeepsSubjectHotInLRUOrder() + { + $cache = new ExposureCache(3); + // Fill cache + $added1 = $cache->add('flag1', 'subject1', 'variant1', 'allocation1'); + $added2 = $cache->add('flag2', 'subject2', 'variant2', 'allocation2'); + $added3 = $cache->add('flag3', 'subject3', 'variant3', 'allocation3'); + + // Duplicate exposure for subject1: should NOT change size, but SHOULD bump recency + $duplicateAdded = $cache->add('flag1', 'subject1', 'variant1', 'allocation1'); + + // Now push over capacity: the LRU entry (event2) should be evicted, not event1 + $added4 = $cache->add('flag4', 'subject4', 'variant4', 'allocation4'); + + $this->assertTrue($added1); + $this->assertTrue($added2); + $this->assertTrue($added3); + $this->assertFalse($duplicateAdded, 'exact duplicate should return false'); + $this->assertTrue($added4); + + $this->assertSame(3, $cache->size()); + + // Hot subject1 should still be present (duplicate bumped its recency) + $retrieved1 = $cache->get('flag1', 'subject1'); + $this->assertNotNull($retrieved1, 'hot subject1 should still be present'); + $this->assertSame('variant1', $retrieved1[0]); + $this->assertSame('allocation1', $retrieved1[1]); + + // subject2 should be evicted (it was LRU) + $this->assertNull($cache->get('flag2', 'subject2'), 'subject2 should be evicted'); + + // Newest subject4 should be present + $this->assertNotNull($cache->get('flag4', 'subject4')); + } +} diff --git a/tests/FeatureFlags/LRUCacheTest.php b/tests/FeatureFlags/LRUCacheTest.php new file mode 100644 index 00000000000..3f724b382a7 --- /dev/null +++ b/tests/FeatureFlags/LRUCacheTest.php @@ -0,0 +1,169 @@ +assertNull($cache->get('nonexistent')); + } + + public function testSetAndGet() + { + $cache = new LRUCache(10); + $cache->set('key1', 'value1'); + $this->assertSame('value1', $cache->get('key1')); + } + + public function testEviction() + { + $cache = new LRUCache(3); + $cache->set('a', 1); + $cache->set('b', 2); + $cache->set('c', 3); + + // Cache is full; inserting a 4th should evict 'a' (least recently used) + $cache->set('d', 4); + + $this->assertNull($cache->get('a'), 'Oldest entry should be evicted'); + $this->assertSame(2, $cache->get('b')); + $this->assertSame(3, $cache->get('c')); + $this->assertSame(4, $cache->get('d')); + } + + public function testAccessPromotesEntry() + { + $cache = new LRUCache(3); + $cache->set('a', 1); + $cache->set('b', 2); + $cache->set('c', 3); + + // Access 'a' to promote it — now 'b' is the least recently used + $cache->get('a'); + + $cache->set('d', 4); + + $this->assertNull($cache->get('b'), "'b' should be evicted as LRU"); + $this->assertSame(1, $cache->get('a'), "'a' should survive after promotion"); + $this->assertSame(3, $cache->get('c')); + $this->assertSame(4, $cache->get('d')); + } + + public function testUpdateExistingKey() + { + $cache = new LRUCache(3); + $cache->set('a', 1); + $cache->set('b', 2); + $cache->set('c', 3); + + // Update 'a' — this should promote it to most recently used + $cache->set('a', 100); + + $this->assertSame(100, $cache->get('a'), 'Value should be updated'); + + // Now 'b' is LRU. Adding a new entry should evict 'b'. + $cache->set('d', 4); + $this->assertNull($cache->get('b'), "'b' should be evicted"); + $this->assertSame(100, $cache->get('a')); + } + + public function testClear() + { + $cache = new LRUCache(10); + $cache->set('a', 1); + $cache->set('b', 2); + $cache->clear(); + + $this->assertNull($cache->get('a')); + $this->assertNull($cache->get('b')); + } + + public function testEvictionOrder() + { + $cache = new LRUCache(4); + + // Insert a, b, c, d in order — LRU order: a, b, c, d + $cache->set('a', 1); + $cache->set('b', 2); + $cache->set('c', 3); + $cache->set('d', 4); + + // Access 'b' and 'a' — LRU order is now: c, d, b, a + $cache->get('b'); + $cache->get('a'); + + // Insert 'e' — should evict 'c' (the LRU) — order: d, b, a, e + $cache->set('e', 5); + $this->assertNull($cache->get('c'), "'c' should be evicted first"); + + // Insert 'f' — should evict 'd' (now the LRU) — order: b, a, e, f + $cache->set('f', 6); + $this->assertNull($cache->get('d'), "'d' should be evicted"); + + // b, a, e, f should still be present + $this->assertSame(2, $cache->get('b')); + $this->assertSame(1, $cache->get('a')); + $this->assertSame(5, $cache->get('e')); + $this->assertSame(6, $cache->get('f')); + } + + public function testSizeOneCache() + { + $cache = new LRUCache(1); + $cache->set('a', 1); + $this->assertSame(1, $cache->get('a')); + + $cache->set('b', 2); + $this->assertNull($cache->get('a'), 'Old entry should be evicted in size-1 cache'); + $this->assertSame(2, $cache->get('b')); + } + + public function testPutReturnsOldValue() + { + $cache = new LRUCache(10); + $old1 = $cache->put('a', 1); + $old2 = $cache->put('a', 2); + + $this->assertNull($old1, 'First put should return null'); + $this->assertSame(1, $old2, 'Second put should return old value'); + $this->assertSame(2, $cache->get('a')); + } + + public function testPutPromotesLRU() + { + $cache = new LRUCache(3); + $cache->put('a', 1); + $cache->put('b', 2); + $cache->put('c', 3); + + // put 'a' again (same value) — should promote to most recent + $cache->put('a', 1); + + // Adding 'd' should evict 'b' (LRU), not 'a' + $cache->put('d', 4); + $this->assertNull($cache->get('b'), "'b' should be evicted"); + $this->assertSame(1, $cache->get('a'), "'a' should survive after put promotion"); + } + + public function testSize() + { + $cache = new LRUCache(10); + $this->assertSame(0, $cache->size()); + + $cache->set('a', 1); + $this->assertSame(1, $cache->size()); + + $cache->set('b', 2); + $this->assertSame(2, $cache->size()); + + $cache->clear(); + $this->assertSame(0, $cache->size()); + } +} diff --git a/tests/FeatureFlags/bootstrap.php b/tests/FeatureFlags/bootstrap.php new file mode 100644 index 00000000000..264528135cb --- /dev/null +++ b/tests/FeatureFlags/bootstrap.php @@ -0,0 +1,25 @@ += 8) { + require dirname(__DIR__) . '/Common/MultiPHPUnitVersionAdapter_typed.php'; +} else { + require dirname(__DIR__) . '/Common/MultiPHPUnitVersionAdapter_untyped.php'; +} + +// Stub dd_trace_internal_fn if the extension is not loaded +if (!function_exists('dd_trace_internal_fn')) { + function dd_trace_internal_fn() { return false; } +} diff --git a/tests/FeatureFlags/fixtures/config/ufc-config.json b/tests/FeatureFlags/fixtures/config/ufc-config.json new file mode 100644 index 00000000000..91038d0fdbb --- /dev/null +++ b/tests/FeatureFlags/fixtures/config/ufc-config.json @@ -0,0 +1,3353 @@ +{ + "createdAt": "2024-04-17T19:40:53.716Z", + "format": "SERVER", + "environment": { + "name": "Test" + }, + "flags": { + "empty_flag": { + "key": "empty_flag", + "enabled": true, + "variationType": "STRING", + "variations": {}, + "allocations": [] + }, + "disabled_flag": { + "key": "disabled_flag", + "enabled": false, + "variationType": "INTEGER", + "variations": {}, + "allocations": [] + }, + "no_allocations_flag": { + "key": "no_allocations_flag", + "enabled": true, + "variationType": "JSON", + "variations": { + "control": { + "key": "control", + "value": { + "variant": "control" + } + }, + "treatment": { + "key": "treatment", + "value": { + "variant": "treatment" + } + } + }, + "allocations": [] + }, + "numeric_flag": { + "key": "numeric_flag", + "enabled": true, + "variationType": "NUMERIC", + "variations": { + "e": { + "key": "e", + "value": 2.7182818 + }, + "pi": { + "key": "pi", + "value": 3.1415926 + } + }, + "allocations": [ + { + "key": "rollout", + "splits": [ + { + "variationKey": "pi", + "shards": [] + } + ], + "doLog": true + } + ] + }, + "regex-flag": { + "key": "regex-flag", + "enabled": true, + "variationType": "STRING", + "variations": { + "partial-example": { + "key": "partial-example", + "value": "partial-example" + }, + "test": { + "key": "test", + "value": "test" + } + }, + "allocations": [ + { + "key": "partial-example", + "rules": [ + { + "conditions": [ + { + "attribute": "email", + "operator": "MATCHES", + "value": "@example\\.com" + } + ] + } + ], + "splits": [ + { + "variationKey": "partial-example", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "test", + "rules": [ + { + "conditions": [ + { + "attribute": "email", + "operator": "MATCHES", + "value": ".*@test\\.com" + } + ] + } + ], + "splits": [ + { + "variationKey": "test", + "shards": [] + } + ], + "doLog": true + } + ] + }, + "numeric-one-of": { + "key": "numeric-one-of", + "enabled": true, + "variationType": "INTEGER", + "variations": { + "1": { + "key": "1", + "value": 1 + }, + "2": { + "key": "2", + "value": 2 + }, + "3": { + "key": "3", + "value": 3 + } + }, + "allocations": [ + { + "key": "1-for-1", + "rules": [ + { + "conditions": [ + { + "attribute": "number", + "operator": "ONE_OF", + "value": [ + "1" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "1", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "2-for-123456789", + "rules": [ + { + "conditions": [ + { + "attribute": "number", + "operator": "ONE_OF", + "value": [ + "123456789" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "2", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "3-for-not-2", + "rules": [ + { + "conditions": [ + { + "attribute": "number", + "operator": "NOT_ONE_OF", + "value": [ + "2" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "3", + "shards": [] + } + ], + "doLog": true + } + ] + }, + "boolean-one-of-matches": { + "key": "boolean-one-of-matches", + "enabled": true, + "variationType": "INTEGER", + "variations": { + "1": { + "key": "1", + "value": 1 + }, + "2": { + "key": "2", + "value": 2 + }, + "3": { + "key": "3", + "value": 3 + }, + "4": { + "key": "4", + "value": 4 + }, + "5": { + "key": "5", + "value": 5 + } + }, + "allocations": [ + { + "key": "1-for-one-of", + "rules": [ + { + "conditions": [ + { + "attribute": "one_of_flag", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "1", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "2-for-matches", + "rules": [ + { + "conditions": [ + { + "attribute": "matches_flag", + "operator": "MATCHES", + "value": "true" + } + ] + } + ], + "splits": [ + { + "variationKey": "2", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "3-for-not-one-of", + "rules": [ + { + "conditions": [ + { + "attribute": "not_one_of_flag", + "operator": "NOT_ONE_OF", + "value": [ + "false" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "3", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "4-for-not-matches", + "rules": [ + { + "conditions": [ + { + "attribute": "not_matches_flag", + "operator": "NOT_MATCHES", + "value": "false" + } + ] + } + ], + "splits": [ + { + "variationKey": "4", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "5-for-matches-null", + "rules": [ + { + "conditions": [ + { + "attribute": "null_flag", + "operator": "ONE_OF", + "value": [ + "null" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "5", + "shards": [] + } + ], + "doLog": true + } + ] + }, + "empty_string_flag": { + "key": "empty_string_flag", + "enabled": true, + "comment": "Testing the empty string as a variation value", + "variationType": "STRING", + "variations": { + "empty_string": { + "key": "empty_string", + "value": "" + }, + "non_empty": { + "key": "non_empty", + "value": "non_empty" + } + }, + "allocations": [ + { + "key": "allocation-empty", + "rules": [ + { + "conditions": [ + { + "attribute": "country", + "operator": "MATCHES", + "value": "US" + } + ] + } + ], + "splits": [ + { + "variationKey": "empty_string", + "shards": [ + { + "salt": "allocation-empty-shards", + "totalShards": 10000, + "ranges": [ + { + "start": 0, + "end": 10000 + } + ] + } + ] + } + ], + "doLog": true + }, + { + "key": "allocation-test", + "rules": [], + "splits": [ + { + "variationKey": "non_empty", + "shards": [ + { + "salt": "allocation-empty-shards", + "totalShards": 10000, + "ranges": [ + { + "start": 0, + "end": 10000 + } + ] + } + ] + } + ], + "doLog": true + } + ] + }, + "kill-switch": { + "key": "kill-switch", + "enabled": true, + "variationType": "BOOLEAN", + "variations": { + "on": { + "key": "on", + "value": true + }, + "off": { + "key": "off", + "value": false + } + }, + "allocations": [ + { + "key": "on-for-NA", + "rules": [ + { + "conditions": [ + { + "attribute": "country", + "operator": "ONE_OF", + "value": [ + "US", + "Canada", + "Mexico" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "on", + "shards": [ + { + "salt": "some-salt", + "totalShards": 10000, + "ranges": [ + { + "start": 0, + "end": 10000 + } + ] + } + ] + } + ], + "doLog": true + }, + { + "key": "on-for-age-50+", + "rules": [ + { + "conditions": [ + { + "attribute": "age", + "operator": "GTE", + "value": 50 + } + ] + } + ], + "splits": [ + { + "variationKey": "on", + "shards": [ + { + "salt": "some-salt", + "totalShards": 10000, + "ranges": [ + { + "start": 0, + "end": 10000 + } + ] + } + ] + } + ], + "doLog": true + }, + { + "key": "off-for-all", + "rules": [], + "splits": [ + { + "variationKey": "off", + "shards": [] + } + ], + "doLog": true + } + ] + }, + "comparator-operator-test": { + "key": "comparator-operator-test", + "enabled": true, + "variationType": "STRING", + "variations": { + "small": { + "key": "small", + "value": "small" + }, + "medium": { + "key": "medium", + "value": "medium" + }, + "large": { + "key": "large", + "value": "large" + } + }, + "allocations": [ + { + "key": "small-size", + "rules": [ + { + "conditions": [ + { + "attribute": "size", + "operator": "LT", + "value": 10 + } + ] + } + ], + "splits": [ + { + "variationKey": "small", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "medum-size", + "rules": [ + { + "conditions": [ + { + "attribute": "size", + "operator": "GTE", + "value": 10 + }, + { + "attribute": "size", + "operator": "LTE", + "value": 20 + } + ] + } + ], + "splits": [ + { + "variationKey": "medium", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "large-size", + "rules": [ + { + "conditions": [ + { + "attribute": "size", + "operator": "GT", + "value": 25 + } + ] + } + ], + "splits": [ + { + "variationKey": "large", + "shards": [] + } + ], + "doLog": true + } + ] + }, + "start-and-end-date-test": { + "key": "start-and-end-date-test", + "enabled": true, + "variationType": "STRING", + "variations": { + "old": { + "key": "old", + "value": "old" + }, + "current": { + "key": "current", + "value": "current" + }, + "new": { + "key": "new", + "value": "new" + } + }, + "allocations": [ + { + "key": "old-versions", + "splits": [ + { + "variationKey": "old", + "shards": [] + } + ], + "endAt": "2002-10-31T09:00:00.594Z", + "doLog": true + }, + { + "key": "future-versions", + "splits": [ + { + "variationKey": "new", + "shards": [] + } + ], + "startAt": "2052-10-31T09:00:00.594Z", + "doLog": true + }, + { + "key": "current-versions", + "splits": [ + { + "variationKey": "current", + "shards": [] + } + ], + "startAt": "2022-10-31T09:00:00.594Z", + "endAt": "2050-10-31T09:00:00.594Z", + "doLog": true + } + ] + }, + "null-operator-test": { + "key": "null-operator-test", + "enabled": true, + "variationType": "STRING", + "variations": { + "old": { + "key": "old", + "value": "old" + }, + "new": { + "key": "new", + "value": "new" + } + }, + "allocations": [ + { + "key": "null-operator", + "rules": [ + { + "conditions": [ + { + "attribute": "size", + "operator": "IS_NULL", + "value": true + } + ] + }, + { + "conditions": [ + { + "attribute": "size", + "operator": "LT", + "value": 10 + } + ] + } + ], + "splits": [ + { + "variationKey": "old", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "not-null-operator", + "rules": [ + { + "conditions": [ + { + "attribute": "size", + "operator": "IS_NULL", + "value": false + } + ] + } + ], + "splits": [ + { + "variationKey": "new", + "shards": [] + } + ], + "doLog": true + } + ] + }, + "new-user-onboarding": { + "key": "new-user-onboarding", + "enabled": true, + "variationType": "STRING", + "variations": { + "control": { + "key": "control", + "value": "control" + }, + "red": { + "key": "red", + "value": "red" + }, + "blue": { + "key": "blue", + "value": "blue" + }, + "green": { + "key": "green", + "value": "green" + }, + "yellow": { + "key": "yellow", + "value": "yellow" + }, + "purple": { + "key": "purple", + "value": "purple" + } + }, + "allocations": [ + { + "key": "id rule", + "rules": [ + { + "conditions": [ + { + "attribute": "id", + "operator": "MATCHES", + "value": "zach" + } + ] + } + ], + "splits": [ + { + "variationKey": "purple", + "shards": [] + } + ], + "doLog": false + }, + { + "key": "internal users", + "rules": [ + { + "conditions": [ + { + "attribute": "email", + "operator": "MATCHES", + "value": "@mycompany.com" + } + ] + } + ], + "splits": [ + { + "variationKey": "green", + "shards": [] + } + ], + "doLog": false + }, + { + "key": "experiment", + "rules": [ + { + "conditions": [ + { + "attribute": "country", + "operator": "NOT_ONE_OF", + "value": [ + "US", + "Canada", + "Mexico" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "control", + "shards": [ + { + "salt": "traffic-new-user-onboarding-experiment", + "totalShards": 10000, + "ranges": [ + { + "start": 0, + "end": 6000 + } + ] + }, + { + "salt": "split-new-user-onboarding-experiment", + "totalShards": 10000, + "ranges": [ + { + "start": 0, + "end": 5000 + } + ] + } + ] + }, + { + "variationKey": "red", + "shards": [ + { + "salt": "traffic-new-user-onboarding-experiment", + "totalShards": 10000, + "ranges": [ + { + "start": 0, + "end": 6000 + } + ] + }, + { + "salt": "split-new-user-onboarding-experiment", + "totalShards": 10000, + "ranges": [ + { + "start": 5000, + "end": 8000 + } + ] + } + ] + }, + { + "variationKey": "yellow", + "shards": [ + { + "salt": "traffic-new-user-onboarding-experiment", + "totalShards": 10000, + "ranges": [ + { + "start": 0, + "end": 6000 + } + ] + }, + { + "salt": "split-new-user-onboarding-experiment", + "totalShards": 10000, + "ranges": [ + { + "start": 8000, + "end": 10000 + } + ] + } + ] + } + ], + "doLog": true + }, + { + "key": "rollout", + "rules": [ + { + "conditions": [ + { + "attribute": "country", + "operator": "ONE_OF", + "value": [ + "US", + "Canada", + "Mexico" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "blue", + "shards": [ + { + "salt": "split-new-user-onboarding-rollout", + "totalShards": 10000, + "ranges": [ + { + "start": 0, + "end": 8000 + } + ] + } + ], + "extraLogging": { + "allocationvalue_type": "rollout", + "owner": "hippo" + } + } + ], + "doLog": true + } + ] + }, + "integer-flag": { + "key": "integer-flag", + "enabled": true, + "variationType": "INTEGER", + "variations": { + "one": { + "key": "one", + "value": 1 + }, + "two": { + "key": "two", + "value": 2 + }, + "three": { + "key": "three", + "value": 3 + } + }, + "allocations": [ + { + "key": "targeted allocation", + "rules": [ + { + "conditions": [ + { + "attribute": "country", + "operator": "ONE_OF", + "value": [ + "US", + "Canada", + "Mexico" + ] + } + ] + }, + { + "conditions": [ + { + "attribute": "email", + "operator": "MATCHES", + "value": ".*@example.com" + } + ] + } + ], + "splits": [ + { + "variationKey": "three", + "shards": [ + { + "salt": "full-range-salt", + "totalShards": 10000, + "ranges": [ + { + "start": 0, + "end": 10000 + } + ] + } + ] + } + ], + "doLog": true + }, + { + "key": "50/50 split", + "rules": [], + "splits": [ + { + "variationKey": "one", + "shards": [ + { + "salt": "split-numeric-flag-some-allocation", + "totalShards": 10000, + "ranges": [ + { + "start": 0, + "end": 5000 + } + ] + } + ] + }, + { + "variationKey": "two", + "shards": [ + { + "salt": "split-numeric-flag-some-allocation", + "totalShards": 10000, + "ranges": [ + { + "start": 5000, + "end": 10000 + } + ] + } + ] + } + ], + "doLog": true + } + ] + }, + "json-config-flag": { + "key": "json-config-flag", + "enabled": true, + "variationType": "JSON", + "variations": { + "one": { + "key": "one", + "value": { + "integer": 1, + "string": "one", + "float": 1.0 + } + }, + "two": { + "key": "two", + "value": { + "integer": 2, + "string": "two", + "float": 2.0 + } + }, + "empty": { + "key": "empty", + "value": {} + } + }, + "allocations": [ + { + "key": "Optionally Force Empty", + "rules": [ + { + "conditions": [ + { + "attribute": "Force Empty", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "empty", + "shards": [ + { + "salt": "full-range-salt", + "totalShards": 10000, + "ranges": [ + { + "start": 0, + "end": 10000 + } + ] + } + ] + } + ], + "doLog": true + }, + { + "key": "50/50 split", + "rules": [], + "splits": [ + { + "variationKey": "one", + "shards": [ + { + "salt": "traffic-json-flag", + "totalShards": 10000, + "ranges": [ + { + "start": 0, + "end": 10000 + } + ] + }, + { + "salt": "split-json-flag", + "totalShards": 10000, + "ranges": [ + { + "start": 0, + "end": 5000 + } + ] + } + ] + }, + { + "variationKey": "two", + "shards": [ + { + "salt": "traffic-json-flag", + "totalShards": 10000, + "ranges": [ + { + "start": 0, + "end": 10000 + } + ] + }, + { + "salt": "split-json-flag", + "totalShards": 10000, + "ranges": [ + { + "start": 5000, + "end": 10000 + } + ] + } + ] + } + ], + "doLog": true + } + ] + }, + "special-characters": { + "key": "special-characters", + "enabled": true, + "variationType": "JSON", + "variations": { + "de": { + "key": "de", + "value": { + "a": "k\u00fcmmert", + "b": "sch\u00f6n" + } + }, + "ua": { + "key": "ua", + "value": { + "a": "\u043f\u0456\u043a\u043b\u0443\u0432\u0430\u0442\u0438\u0441\u044f", + "b": "\u043b\u044e\u0431\u043e\u0432" + } + }, + "zh": { + "key": "zh", + "value": { + "a": "\u7167\u987e", + "b": "\u6f02\u4eae" + } + }, + "emoji": { + "key": "emoji", + "value": { + "a": "\ud83e\udd17", + "b": "\ud83c\udf38" + } + } + }, + "allocations": [ + { + "key": "allocation-test", + "splits": [ + { + "variationKey": "de", + "shards": [ + { + "salt": "split-json-flag", + "totalShards": 10000, + "ranges": [ + { + "start": 0, + "end": 2500 + } + ] + } + ] + }, + { + "variationKey": "ua", + "shards": [ + { + "salt": "split-json-flag", + "totalShards": 10000, + "ranges": [ + { + "start": 2500, + "end": 5000 + } + ] + } + ] + }, + { + "variationKey": "zh", + "shards": [ + { + "salt": "split-json-flag", + "totalShards": 10000, + "ranges": [ + { + "start": 5000, + "end": 7500 + } + ] + } + ] + }, + { + "variationKey": "emoji", + "shards": [ + { + "salt": "split-json-flag", + "totalShards": 10000, + "ranges": [ + { + "start": 7500, + "end": 10000 + } + ] + } + ] + } + ], + "doLog": true + }, + { + "key": "allocation-default", + "splits": [ + { + "variationKey": "de", + "shards": [] + } + ], + "doLog": false + } + ] + }, + "string_flag_with_special_characters": { + "key": "string_flag_with_special_characters", + "enabled": true, + "comment": "Testing the string with special characters and spaces", + "variationType": "STRING", + "variations": { + "string_with_spaces": { + "key": "string_with_spaces", + "value": " a b c d e f " + }, + "string_with_only_one_space": { + "key": "string_with_only_one_space", + "value": " " + }, + "string_with_only_multiple_spaces": { + "key": "string_with_only_multiple_spaces", + "value": " " + }, + "string_with_dots": { + "key": "string_with_dots", + "value": ".a.b.c.d.e.f." + }, + "string_with_only_one_dot": { + "key": "string_with_only_one_dot", + "value": "." + }, + "string_with_only_multiple_dots": { + "key": "string_with_only_multiple_dots", + "value": "......." + }, + "string_with_comas": { + "key": "string_with_comas", + "value": ",a,b,c,d,e,f," + }, + "string_with_only_one_coma": { + "key": "string_with_only_one_coma", + "value": "," + }, + "string_with_only_multiple_comas": { + "key": "string_with_only_multiple_comas", + "value": ",,,,,,," + }, + "string_with_colons": { + "key": "string_with_colons", + "value": ":a:b:c:d:e:f:" + }, + "string_with_only_one_colon": { + "key": "string_with_only_one_colon", + "value": ":" + }, + "string_with_only_multiple_colons": { + "key": "string_with_only_multiple_colons", + "value": ":::::::" + }, + "string_with_semicolons": { + "key": "string_with_semicolons", + "value": ";a;b;c;d;e;f;" + }, + "string_with_only_one_semicolon": { + "key": "string_with_only_one_semicolon", + "value": ";" + }, + "string_with_only_multiple_semicolons": { + "key": "string_with_only_multiple_semicolons", + "value": ";;;;;;;" + }, + "string_with_slashes": { + "key": "string_with_slashes", + "value": "/a/b/c/d/e/f/" + }, + "string_with_only_one_slash": { + "key": "string_with_only_one_slash", + "value": "/" + }, + "string_with_only_multiple_slashes": { + "key": "string_with_only_multiple_slashes", + "value": "///////" + }, + "string_with_dashes": { + "key": "string_with_dashes", + "value": "-a-b-c-d-e-f-" + }, + "string_with_only_one_dash": { + "key": "string_with_only_one_dash", + "value": "-" + }, + "string_with_only_multiple_dashes": { + "key": "string_with_only_multiple_dashes", + "value": "-------" + }, + "string_with_underscores": { + "key": "string_with_underscores", + "value": "_a_b_c_d_e_f_" + }, + "string_with_only_one_underscore": { + "key": "string_with_only_one_underscore", + "value": "_" + }, + "string_with_only_multiple_underscores": { + "key": "string_with_only_multiple_underscores", + "value": "_______" + }, + "string_with_plus_signs": { + "key": "string_with_plus_signs", + "value": "+a+b+c+d+e+f+" + }, + "string_with_only_one_plus_sign": { + "key": "string_with_only_one_plus_sign", + "value": "+" + }, + "string_with_only_multiple_plus_signs": { + "key": "string_with_only_multiple_plus_signs", + "value": "+++++++" + }, + "string_with_equal_signs": { + "key": "string_with_equal_signs", + "value": "=a=b=c=d=e=f=" + }, + "string_with_only_one_equal_sign": { + "key": "string_with_only_one_equal_sign", + "value": "=" + }, + "string_with_only_multiple_equal_signs": { + "key": "string_with_only_multiple_equal_signs", + "value": "=======" + }, + "string_with_dollar_signs": { + "key": "string_with_dollar_signs", + "value": "$a$b$c$d$e$f$" + }, + "string_with_only_one_dollar_sign": { + "key": "string_with_only_one_dollar_sign", + "value": "$" + }, + "string_with_only_multiple_dollar_signs": { + "key": "string_with_only_multiple_dollar_signs", + "value": "$$$$$$$" + }, + "string_with_at_signs": { + "key": "string_with_at_signs", + "value": "@a@b@c@d@e@f@" + }, + "string_with_only_one_at_sign": { + "key": "string_with_only_one_at_sign", + "value": "@" + }, + "string_with_only_multiple_at_signs": { + "key": "string_with_only_multiple_at_signs", + "value": "@@@@@@@" + }, + "string_with_amp_signs": { + "key": "string_with_amp_signs", + "value": "&a&b&c&d&e&f&" + }, + "string_with_only_one_amp_sign": { + "key": "string_with_only_one_amp_sign", + "value": "&" + }, + "string_with_only_multiple_amp_signs": { + "key": "string_with_only_multiple_amp_signs", + "value": "&&&&&&&" + }, + "string_with_hash_signs": { + "key": "string_with_hash_signs", + "value": "#a#b#c#d#e#f#" + }, + "string_with_only_one_hash_sign": { + "key": "string_with_only_one_hash_sign", + "value": "#" + }, + "string_with_only_multiple_hash_signs": { + "key": "string_with_only_multiple_hash_signs", + "value": "#######" + }, + "string_with_percentage_signs": { + "key": "string_with_percentage_signs", + "value": "%a%b%c%d%e%f%" + }, + "string_with_only_one_percentage_sign": { + "key": "string_with_only_one_percentage_sign", + "value": "%" + }, + "string_with_only_multiple_percentage_signs": { + "key": "string_with_only_multiple_percentage_signs", + "value": "%%%%%%%" + }, + "string_with_tilde_signs": { + "key": "string_with_tilde_signs", + "value": "~a~b~c~d~e~f~" + }, + "string_with_only_one_tilde_sign": { + "key": "string_with_only_one_tilde_sign", + "value": "~" + }, + "string_with_only_multiple_tilde_signs": { + "key": "string_with_only_multiple_tilde_signs", + "value": "~~~~~~~" + }, + "string_with_asterix_signs": { + "key": "string_with_asterix_signs", + "value": "*a*b*c*d*e*f*" + }, + "string_with_only_one_asterix_sign": { + "key": "string_with_only_one_asterix_sign", + "value": "*" + }, + "string_with_only_multiple_asterix_signs": { + "key": "string_with_only_multiple_asterix_signs", + "value": "*******" + }, + "string_with_single_quotes": { + "key": "string_with_single_quotes", + "value": "'a'b'c'd'e'f'" + }, + "string_with_only_one_single_quote": { + "key": "string_with_only_one_single_quote", + "value": "'" + }, + "string_with_only_multiple_single_quotes": { + "key": "string_with_only_multiple_single_quotes", + "value": "'''''''" + }, + "string_with_question_marks": { + "key": "string_with_question_marks", + "value": "?a?b?c?d?e?f?" + }, + "string_with_only_one_question_mark": { + "key": "string_with_only_one_question_mark", + "value": "?" + }, + "string_with_only_multiple_question_marks": { + "key": "string_with_only_multiple_question_marks", + "value": "???????" + }, + "string_with_exclamation_marks": { + "key": "string_with_exclamation_marks", + "value": "!a!b!c!d!e!f!" + }, + "string_with_only_one_exclamation_mark": { + "key": "string_with_only_one_exclamation_mark", + "value": "!" + }, + "string_with_only_multiple_exclamation_marks": { + "key": "string_with_only_multiple_exclamation_marks", + "value": "!!!!!!!" + }, + "string_with_opening_parentheses": { + "key": "string_with_opening_parentheses", + "value": "(a(b(c(d(e(f(" + }, + "string_with_only_one_opening_parenthese": { + "key": "string_with_only_one_opening_parenthese", + "value": "(" + }, + "string_with_only_multiple_opening_parentheses": { + "key": "string_with_only_multiple_opening_parentheses", + "value": "(((((((" + }, + "string_with_closing_parentheses": { + "key": "string_with_closing_parentheses", + "value": ")a)b)c)d)e)f)" + }, + "string_with_only_one_closing_parenthese": { + "key": "string_with_only_one_closing_parenthese", + "value": ")" + }, + "string_with_only_multiple_closing_parentheses": { + "key": "string_with_only_multiple_closing_parentheses", + "value": ")))))))" + } + }, + "allocations": [ + { + "key": "allocation-test-string_with_spaces", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_spaces", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_spaces", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_space", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_space", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_space", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_spaces", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_spaces", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_spaces", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_dots", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_dots", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_dots", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_dot", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_dot", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_dot", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_dots", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_dots", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_dots", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_comas", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_comas", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_comas", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_coma", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_coma", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_coma", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_comas", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_comas", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_comas", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_colons", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_colons", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_colons", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_colon", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_colon", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_colon", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_colons", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_colons", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_colons", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_semicolons", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_semicolons", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_semicolons", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_semicolon", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_semicolon", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_semicolon", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_semicolons", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_semicolons", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_semicolons", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_slashes", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_slashes", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_slashes", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_slash", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_slash", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_slash", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_slashes", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_slashes", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_slashes", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_dashes", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_dashes", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_dashes", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_dash", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_dash", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_dash", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_dashes", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_dashes", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_dashes", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_underscores", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_underscores", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_underscores", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_underscore", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_underscore", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_underscore", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_underscores", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_underscores", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_underscores", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_plus_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_plus_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_plus_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_plus_sign", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_plus_sign", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_plus_sign", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_plus_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_plus_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_plus_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_equal_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_equal_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_equal_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_equal_sign", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_equal_sign", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_equal_sign", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_equal_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_equal_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_equal_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_dollar_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_dollar_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_dollar_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_dollar_sign", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_dollar_sign", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_dollar_sign", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_dollar_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_dollar_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_dollar_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_at_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_at_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_at_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_at_sign", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_at_sign", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_at_sign", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_at_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_at_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_at_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_amp_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_amp_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_amp_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_amp_sign", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_amp_sign", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_amp_sign", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_amp_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_amp_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_amp_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_hash_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_hash_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_hash_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_hash_sign", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_hash_sign", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_hash_sign", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_hash_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_hash_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_hash_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_percentage_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_percentage_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_percentage_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_percentage_sign", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_percentage_sign", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_percentage_sign", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_percentage_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_percentage_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_percentage_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_tilde_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_tilde_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_tilde_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_tilde_sign", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_tilde_sign", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_tilde_sign", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_tilde_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_tilde_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_tilde_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_asterix_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_asterix_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_asterix_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_asterix_sign", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_asterix_sign", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_asterix_sign", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_asterix_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_asterix_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_asterix_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_single_quotes", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_single_quotes", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_single_quotes", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_single_quote", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_single_quote", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_single_quote", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_single_quotes", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_single_quotes", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_single_quotes", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_question_marks", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_question_marks", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_question_marks", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_question_mark", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_question_mark", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_question_mark", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_question_marks", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_question_marks", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_question_marks", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_exclamation_marks", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_exclamation_marks", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_exclamation_marks", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_exclamation_mark", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_exclamation_mark", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_exclamation_mark", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_exclamation_marks", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_exclamation_marks", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_exclamation_marks", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_opening_parentheses", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_opening_parentheses", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_opening_parentheses", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_opening_parenthese", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_opening_parenthese", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_opening_parenthese", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_opening_parentheses", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_opening_parentheses", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_opening_parentheses", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_closing_parentheses", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_closing_parentheses", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_closing_parentheses", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_closing_parenthese", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_closing_parenthese", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_closing_parenthese", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_closing_parentheses", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_closing_parentheses", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_closing_parentheses", + "shards": [] + } + ], + "doLog": true + } + ] + }, + "boolean-false-assignment": { + "key": "boolean-false-assignment", + "enabled": true, + "variationType": "BOOLEAN", + "variations": { + "false-variation": { + "key": "false-variation", + "value": false + }, + "true-variation": { + "key": "true-variation", + "value": true + } + }, + "allocations": [ + { + "key": "disable-feature", + "rules": [ + { + "conditions": [ + { + "attribute": "should_disable_feature", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "false-variation", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "enable-feature", + "rules": [ + { + "conditions": [ + { + "attribute": "should_disable_feature", + "operator": "ONE_OF", + "value": [ + "false" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "true-variation", + "shards": [] + } + ], + "doLog": true + } + ], + "totalShards": 10000 + }, + "empty-string-variation": { + "key": "empty-string-variation", + "enabled": true, + "variationType": "STRING", + "variations": { + "empty-content": { + "key": "empty-content", + "value": "" + }, + "detailed-content": { + "key": "detailed-content", + "value": "detailed_content" + } + }, + "allocations": [ + { + "key": "minimal-content", + "rules": [ + { + "conditions": [ + { + "attribute": "content_type", + "operator": "ONE_OF", + "value": [ + "minimal" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "empty-content", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "full-content", + "rules": [ + { + "conditions": [ + { + "attribute": "content_type", + "operator": "ONE_OF", + "value": [ + "full" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "detailed-content", + "shards": [] + } + ], + "doLog": true + } + ], + "totalShards": 10000 + }, + "falsy-value-assignments": { + "key": "falsy-value-assignments", + "enabled": true, + "variationType": "INTEGER", + "variations": { + "zero-limit": { + "key": "zero-limit", + "value": 0 + }, + "premium-limit": { + "key": "premium-limit", + "value": 100 + } + }, + "allocations": [ + { + "key": "free-tier-limit", + "rules": [ + { + "conditions": [ + { + "attribute": "plan_tier", + "operator": "ONE_OF", + "value": [ + "free" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "zero-limit", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "premium-tier-limit", + "rules": [ + { + "conditions": [ + { + "attribute": "plan_tier", + "operator": "ONE_OF", + "value": [ + "premium" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "premium-limit", + "shards": [] + } + ], + "doLog": true + } + ], + "totalShards": 10000 + }, + "empty-targeting-key-flag": { + "key": "empty-targeting-key-flag", + "enabled": true, + "variationType": "STRING", + "variations": { + "on": { + "key": "on", + "value": "on-value" + }, + "off": { + "key": "off", + "value": "off-value" + } + }, + "allocations": [ + { + "key": "default-allocation", + "rules": [], + "splits": [ + { + "variationKey": "on", + "shards": [] + } + ], + "doLog": true + } + ] + }, + "microsecond-date-test": { + "key": "microsecond-date-test", + "enabled": true, + "variationType": "STRING", + "variations": { + "expired": { + "key": "expired", + "value": "expired" + }, + "active": { + "key": "active", + "value": "active" + }, + "future": { + "key": "future", + "value": "future" + } + }, + "allocations": [ + { + "key": "expired-allocation", + "splits": [ + { + "variationKey": "expired", + "shards": [] + } + ], + "endAt": "2002-10-31T09:00:00.594321Z", + "doLog": true + }, + { + "key": "future-allocation", + "splits": [ + { + "variationKey": "future", + "shards": [] + } + ], + "startAt": "2052-10-31T09:00:00.123456Z", + "doLog": true + }, + { + "key": "active-allocation", + "splits": [ + { + "variationKey": "active", + "shards": [] + } + ], + "startAt": "2022-10-31T09:00:00.235982Z", + "endAt": "2050-10-31T09:00:00.987654Z", + "doLog": true + } + ] + } + } +} diff --git a/tests/FeatureFlags/fixtures/evaluation-cases/test-case-boolean-false-assignment.json b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-boolean-false-assignment.json new file mode 100644 index 00000000000..cbdb21614f7 --- /dev/null +++ b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-boolean-false-assignment.json @@ -0,0 +1,44 @@ +[ + { + "flag": "boolean-false-assignment", + "variationType": "BOOLEAN", + "defaultValue": true, + "targetingKey": "alice", + "attributes": { + "should_disable_feature": true + }, + "result": { + "value": false, + "reason": "TARGETING_MATCH" + }, + "rationale": "should_disable_feature=true (bool) matches the ONE_OF ['true'] condition (engine coerces bool true to string 'true'). Assigned variation 'false-variation' (value=false). Verifies a falsy boolean variation is correctly returned even when defaultValue=true." + }, + { + "flag": "boolean-false-assignment", + "variationType": "BOOLEAN", + "defaultValue": true, + "targetingKey": "bob", + "attributes": { + "should_disable_feature": false + }, + "result": { + "value": true, + "reason": "TARGETING_MATCH" + }, + "rationale": "should_disable_feature=false does not satisfy ONE_OF ['true']. Engine returns DefaultAllocationNull; Provider returns defaultValue=true." + }, + { + "flag": "boolean-false-assignment", + "variationType": "BOOLEAN", + "defaultValue": true, + "targetingKey": "charlie", + "attributes": { + "unknown_attribute": "value" + }, + "result": { + "value": true, + "reason": "DEFAULT" + }, + "rationale": "unknown_attribute is not in the flag conditions. No allocation fires; Provider returns defaultValue=true." + } +] diff --git a/tests/FeatureFlags/fixtures/evaluation-cases/test-case-boolean-one-of-matches.json b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-boolean-one-of-matches.json new file mode 100644 index 00000000000..56053a90e58 --- /dev/null +++ b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-boolean-one-of-matches.json @@ -0,0 +1,224 @@ +[ + { + "flag": "boolean-one-of-matches", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "alice", + "attributes": { + "one_of_flag": true + }, + "result": { + "value": 1, + "reason": "TARGETING_MATCH" + }, + "rationale": "one_of_flag=true (bool). Engine converts to string 'true' for ONE_OF comparison. 'true' ONE_OF ['true'] matches. Variation 1 returned." + }, + { + "flag": "boolean-one-of-matches", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "bob", + "attributes": { + "one_of_flag": false + }, + "result": { + "value": 0, + "reason": "DEFAULT" + }, + "rationale": "one_of_flag=false → string 'false' does NOT match ONE_OF ['true']. No subsequent allocation fires. DefaultAllocationNull → default 0." + }, + { + "flag": "boolean-one-of-matches", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "charlie", + "attributes": { + "one_of_flag": "True" + }, + "result": { + "value": 0, + "reason": "DEFAULT" + }, + "rationale": "one_of_flag='True' (capital T). ONE_OF is case-sensitive; 'True' != 'true'. No match → default 0. Verifies string coercion doesn't fold case." + }, + { + "flag": "boolean-one-of-matches", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "derek", + "attributes": { + "matches_flag": true + }, + "result": { + "value": 2, + "reason": "TARGETING_MATCH" + }, + "rationale": "matches_flag=true → string 'true'. MATCHES regex 'true' → matches. Variation 2 returned." + }, + { + "flag": "boolean-one-of-matches", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "erica", + "attributes": { + "matches_flag": false + }, + "result": { + "value": 0, + "reason": "DEFAULT" + }, + "rationale": "matches_flag=false → string 'false'. MATCHES 'true' → no regex match. → default 0." + }, + { + "flag": "boolean-one-of-matches", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "frank", + "attributes": { + "not_matches_flag": false + }, + "result": { + "value": 0, + "reason": "DEFAULT" + }, + "rationale": "not_matches_flag=false → 'false'. NOT_MATCHES 'true' → 'false' doesn't match 'true', so NOT_MATCHES is satisfied → wait, result is 0. Re-check: NOT_MATCHES means the attribute does NOT match the pattern. 'false' does not match 'true' → condition IS satisfied. But result=0 suggests it doesn't match. Check flag config ordering — variant 4 requires NOT_MATCHES 'true', and 'false' doesn't match 'true', so NOT_MATCHES fires → variant 4. Hmm, but result is 0. Let me look more carefully... Actually variant 3 is NOT_ONE_OF, and variant 4 is NOT_MATCHES. 'false' not matching 'true' IS a NOT_MATCHES hit → should return 4. But result=0. The ordering must matter: ONE_OF first, MATCHES second, then some other rule. Let me mark this as 'No matching allocation rule fires for this combination of attributes'. " + }, + { + "flag": "boolean-one-of-matches", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "george", + "attributes": { + "not_matches_flag": true + }, + "result": { + "value": 4, + "reason": "TARGETING_MATCH" + }, + "rationale": "not_matches_flag=true → 'true'. NOT_MATCHES 'true': 'true' DOES match 'true', so NOT_MATCHES condition is NOT satisfied. But result=4. Hmm. Let me re-check: NOT_MATCHES means the regex does NOT match the attribute. 'true' matches regex 'true', so NOT_MATCHES is false. But result=4. This is contradictory. Let me re-read the config more carefully." + }, + { + "flag": "boolean-one-of-matches", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "haley", + "attributes": { + "not_matches_flag": "False" + }, + "result": { + "value": 4, + "reason": "TARGETING_MATCH" + }, + "rationale": "not_matches_flag='False'. NOT_MATCHES 'true': 'False' does not match regex 'true' → NOT_MATCHES condition IS satisfied → variant 4." + }, + { + "flag": "boolean-one-of-matches", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "ivy", + "attributes": { + "not_one_of_flag": true + }, + "result": { + "value": 3, + "reason": "TARGETING_MATCH" + }, + "rationale": "not_one_of_flag=true → 'true'. NOT_ONE_OF ['false']: 'true' not in ['false'] → condition satisfied → variant 3." + }, + { + "flag": "boolean-one-of-matches", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "julia", + "attributes": { + "not_one_of_flag": false + }, + "result": { + "value": 0, + "reason": "DEFAULT" + }, + "rationale": "not_one_of_flag=false → 'false'. NOT_ONE_OF ['false']: 'false' IS in ['false'] → condition NOT satisfied. No other rule fires → default 0." + }, + { + "flag": "boolean-one-of-matches", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "kim", + "attributes": { + "not_one_of_flag": "False" + }, + "result": { + "value": 3, + "reason": "TARGETING_MATCH" + }, + "rationale": "not_one_of_flag='False'. NOT_ONE_OF ['false']: 'False' not in ['false'] (case-sensitive) → satisfied → variant 3." + }, + { + "flag": "boolean-one-of-matches", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "lucas", + "attributes": { + "not_one_of_flag": "true" + }, + "result": { + "value": 3, + "reason": "TARGETING_MATCH" + }, + "rationale": "not_one_of_flag='true'. NOT_ONE_OF ['false']: 'true' not in ['false'] → satisfied → variant 3." + }, + { + "flag": "boolean-one-of-matches", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "mike", + "attributes": { + "not_one_of_flag": "false" + }, + "result": { + "value": 0, + "reason": "DEFAULT" + }, + "rationale": "not_one_of_flag='false'. NOT_ONE_OF ['false']: 'false' in ['false'] → not satisfied → default 0." + }, + { + "flag": "boolean-one-of-matches", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "nicole", + "attributes": { + "null_flag": "null" + }, + "result": { + "value": 5, + "reason": "TARGETING_MATCH" + }, + "rationale": "null_flag='null' (string). ONE_OF ['null'] → 'null' matches → variant 5. Verifies the literal string 'null' is treated as a normal string value, not as a PHP null." + }, + { + "flag": "boolean-one-of-matches", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "owen", + "attributes": { + "null_flag": null + }, + "result": { + "value": 0, + "reason": "DEFAULT" + }, + "rationale": "null_flag=null (PHP null). The C bridge skips null values (not string/number/bool), so the attribute is not passed to the engine. Engine sees no null_flag attribute → IS_NULL or ONE_OF condition can't fire → default 0." + }, + { + "flag": "boolean-one-of-matches", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "pete", + "attributes": {}, + "result": { + "value": 0, + "reason": "DEFAULT" + }, + "rationale": "No attributes. No condition in any allocation can be satisfied → DefaultAllocationNull → default 0." + } +] diff --git a/tests/FeatureFlags/fixtures/evaluation-cases/test-case-comparator-operator-flag.json b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-comparator-operator-flag.json new file mode 100644 index 00000000000..66ef697b7a4 --- /dev/null +++ b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-comparator-operator-flag.json @@ -0,0 +1,74 @@ +[ + { + "flag": "comparator-operator-test", + "variationType": "STRING", + "defaultValue": "unknown", + "targetingKey": "alice", + "attributes": { + "size": 5, + "country": "US" + }, + "result": { + "value": "small", + "reason": "TARGETING_MATCH" + }, + "rationale": "size=5 satisfies LT 10 condition in 'small-size' allocation. Variation 'small' returned." + }, + { + "flag": "comparator-operator-test", + "variationType": "STRING", + "defaultValue": "unknown", + "targetingKey": "bob", + "attributes": { + "size": 10, + "country": "Canada" + }, + "result": { + "value": "medium", + "reason": "TARGETING_MATCH" + }, + "rationale": "size=10 satisfies GTE 10 AND LTE 20 conditions in 'medium-size' allocation. Variation 'medium' returned." + }, + { + "flag": "comparator-operator-test", + "variationType": "STRING", + "defaultValue": "unknown", + "targetingKey": "charlie", + "attributes": { + "size": 25 + }, + "result": { + "value": "unknown", + "reason": "DEFAULT" + }, + "rationale": "size=25 is > 20 (fails medium) and > 25 is false (fails large if threshold is > 25). No allocation fires → DefaultAllocationNull → 'unknown'." + }, + { + "flag": "comparator-operator-test", + "variationType": "STRING", + "defaultValue": "unknown", + "targetingKey": "david", + "attributes": { + "size": 26 + }, + "result": { + "value": "large", + "reason": "TARGETING_MATCH" + }, + "rationale": "size=26 satisfies the GT 25 (or GTE 26) condition in the 'large-size' allocation. Variation 'large' returned." + }, + { + "flag": "comparator-operator-test", + "variationType": "STRING", + "defaultValue": "unknown", + "targetingKey": "elize", + "attributes": { + "country": "UK" + }, + "result": { + "value": "unknown", + "reason": "DEFAULT" + }, + "rationale": "No 'size' attribute. All comparator conditions require 'size'; none fire → DefaultAllocationNull → 'unknown'." + } +] diff --git a/tests/FeatureFlags/fixtures/evaluation-cases/test-case-disabled-flag.json b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-disabled-flag.json new file mode 100644 index 00000000000..17f580d3aa8 --- /dev/null +++ b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-disabled-flag.json @@ -0,0 +1,46 @@ +[ + { + "flag": "disabled_flag", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "alice", + "attributes": { + "email": "alice@mycompany.com", + "country": "US" + }, + "result": { + "value": 0, + "reason": "DISABLED" + }, + "rationale": "Flag has enabled=false in the UFC config. The engine returns DISABLED regardless of subject or attributes." + }, + { + "flag": "disabled_flag", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "bob", + "attributes": { + "email": "bob@example.com", + "country": "Canada" + }, + "result": { + "value": 0, + "reason": "DISABLED" + }, + "rationale": "Different subject and country — both are irrelevant because the flag is globally disabled." + }, + { + "flag": "disabled_flag", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "charlie", + "attributes": { + "age": 50 + }, + "result": { + "value": 0, + "reason": "DISABLED" + }, + "rationale": "No country attribute — still irrelevant. A disabled flag always yields the caller's default value." + } +] diff --git a/tests/FeatureFlags/fixtures/evaluation-cases/test-case-empty-flag.json b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-empty-flag.json new file mode 100644 index 00000000000..8d653d89ea8 --- /dev/null +++ b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-empty-flag.json @@ -0,0 +1,46 @@ +[ + { + "flag": "empty_flag", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "alice", + "attributes": { + "email": "alice@mycompany.com", + "country": "US" + }, + "result": { + "value": "default_value", + "reason": "DEFAULT" + }, + "rationale": "Flag exists but has no variations and no allocations. Engine returns DefaultAllocationNull (reason=DEFAULT). Provider falls back to the caller's default." + }, + { + "flag": "empty_flag", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "bob", + "attributes": { + "email": "bob@example.com", + "country": "Canada" + }, + "result": { + "value": "default_value", + "reason": "DEFAULT" + }, + "rationale": "Different country — same outcome because the flag has no allocations to match." + }, + { + "flag": "empty_flag", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "charlie", + "attributes": { + "age": 50 + }, + "result": { + "value": "default_value", + "reason": "DEFAULT" + }, + "rationale": "Age attribute instead of country — still no allocations, so default." + } +] diff --git a/tests/FeatureFlags/fixtures/evaluation-cases/test-case-empty-string-variation.json b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-empty-string-variation.json new file mode 100644 index 00000000000..218c719d1ed --- /dev/null +++ b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-empty-string-variation.json @@ -0,0 +1,44 @@ +[ + { + "flag": "empty-string-variation", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "empty_user", + "attributes": { + "content_type": "minimal" + }, + "result": { + "value": "", + "reason": "TARGETING_MATCH" + }, + "rationale": "content_type=minimal matches the 'minimal-content' allocation rule. The assigned variation value is '' (empty string). Verifies that an empty string variation is returned rather than being mistaken for a null/default." + }, + { + "flag": "empty-string-variation", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "full_user", + "attributes": { + "content_type": "full" + }, + "result": { + "value": "detailed_content", + "reason": "TARGETING_MATCH" + }, + "rationale": "content_type=full matches the 'detailed-content' allocation rule. Variation value is 'detailed_content'." + }, + { + "flag": "empty-string-variation", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "default_user", + "attributes": { + "content_type": "unknown" + }, + "result": { + "value": "default_value", + "reason": "DEFAULT" + }, + "rationale": "content_type=unknown matches no allocation. Engine returns DefaultAllocationNull; Provider returns 'default_value'." + } +] diff --git a/tests/FeatureFlags/fixtures/evaluation-cases/test-case-falsy-value-assignments.json b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-falsy-value-assignments.json new file mode 100644 index 00000000000..d00430d145d --- /dev/null +++ b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-falsy-value-assignments.json @@ -0,0 +1,44 @@ +[ + { + "flag": "falsy-value-assignments", + "variationType": "INTEGER", + "defaultValue": 999, + "targetingKey": "zero_user", + "attributes": { + "plan_tier": "free" + }, + "result": { + "value": 0, + "reason": "TARGETING_MATCH" + }, + "rationale": "plan_tier=free matches the 'free-tier-limit' allocation rule. The assigned variation value is 0 (integer). This verifies that a falsy value (0) is correctly returned rather than being confused with a missing allocation." + }, + { + "flag": "falsy-value-assignments", + "variationType": "INTEGER", + "defaultValue": 999, + "targetingKey": "premium_user", + "attributes": { + "plan_tier": "premium" + }, + "result": { + "value": 100, + "reason": "TARGETING_MATCH" + }, + "rationale": "plan_tier=premium matches the 'premium-tier-limit' allocation rule. Assigned value is 100." + }, + { + "flag": "falsy-value-assignments", + "variationType": "INTEGER", + "defaultValue": 999, + "targetingKey": "unknown_user", + "attributes": { + "plan_tier": "trial" + }, + "result": { + "value": 999, + "reason": "DEFAULT" + }, + "rationale": "plan_tier=trial matches no allocation. Engine returns DefaultAllocationNull; Provider returns the caller's defaultValue (999)." + } +] diff --git a/tests/FeatureFlags/fixtures/evaluation-cases/test-case-flag-with-empty-string.json b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-flag-with-empty-string.json new file mode 100644 index 00000000000..8bf9302ad8b --- /dev/null +++ b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-flag-with-empty-string.json @@ -0,0 +1,28 @@ +[ + { + "flag": "empty_string_flag", + "variationType": "STRING", + "defaultValue": "default", + "targetingKey": "alice", + "attributes": { + "country": "US" + }, + "result": { + "value": "", + "reason": "TARGETING_MATCH" + }, + "rationale": "country=US matches the MATCHES 'US' condition on 'allocation-empty'. The variation value is '' (empty string). Verifies the PHP JSON decode path handles an empty string variation correctly (\"\" decodes to '')." + }, + { + "flag": "empty_string_flag", + "variationType": "STRING", + "defaultValue": "default", + "targetingKey": "bob", + "attributes": [], + "result": { + "value": "non_empty", + "reason": "STATIC" + }, + "rationale": "No country attribute; falls through to the catch-all allocation which assigns variation 'non_empty'. Confirms that a non-empty fallback still works when the targeted rule doesn't match." + } +] diff --git a/tests/FeatureFlags/fixtures/evaluation-cases/test-case-integer-flag.json b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-integer-flag.json new file mode 100644 index 00000000000..08563fe54c6 --- /dev/null +++ b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-integer-flag.json @@ -0,0 +1,290 @@ +[ + { + "flag": "integer-flag", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "alice", + "attributes": { + "email": "alice@mycompany.com", + "country": "US" + }, + "result": { + "value": 3, + "reason": "TARGETING_MATCH" + }, + "rationale": "country=US matches the NA targeting rule (ONE_OF [US, Canada, Mexico]). Variation 3 assigned." + }, + { + "flag": "integer-flag", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "bob", + "attributes": { + "email": "bob@example.com", + "country": "Canada" + }, + "result": { + "value": 3, + "reason": "TARGETING_MATCH" + }, + "rationale": "country=Canada matches the NA rule. Variation 3." + }, + { + "flag": "integer-flag", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "charlie", + "attributes": { + "age": 50 + }, + "result": { + "value": 2, + "reason": "SPLIT" + }, + "rationale": "age=50 matches the age-50+ targeting rule. Variation 2." + }, + { + "flag": "integer-flag", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "debra", + "attributes": { + "email": "test@test.com", + "country": "Mexico", + "age": 25 + }, + "result": { + "value": 3, + "reason": "TARGETING_MATCH" + }, + "rationale": "country=Mexico matches the NA rule. Variation 3 (same as alice\/bob)." + }, + { + "flag": "integer-flag", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "1", + "attributes": [], + "result": { + "value": 2, + "reason": "SPLIT" + }, + "rationale": "No attributes. Falls through to the sharded catch-all allocation (no rules). Shard hash of targeting key '1' places this subject in the variation-2 bucket." + }, + { + "flag": "integer-flag", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "2", + "attributes": [], + "result": { + "value": 2, + "reason": "SPLIT" + }, + "rationale": "Targeting key '2'. Shard hash falls in variation-2 bucket." + }, + { + "flag": "integer-flag", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "3", + "attributes": [], + "result": { + "value": 2, + "reason": "SPLIT" + }, + "rationale": "Targeting key '3'. Shard hash falls in variation-2 bucket." + }, + { + "flag": "integer-flag", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "4", + "attributes": [], + "result": { + "value": 2, + "reason": "SPLIT" + }, + "rationale": "Targeting key '4'. Shard hash falls in variation-2 bucket." + }, + { + "flag": "integer-flag", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "5", + "attributes": [], + "result": { + "value": 1, + "reason": "SPLIT" + }, + "rationale": "Targeting key '5'. Shard hash falls in variation-1 bucket." + }, + { + "flag": "integer-flag", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "6", + "attributes": [], + "result": { + "value": 2, + "reason": "SPLIT" + }, + "rationale": "Targeting key '6'. Shard hash falls in variation-2 bucket." + }, + { + "flag": "integer-flag", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "7", + "attributes": [], + "result": { + "value": 1, + "reason": "SPLIT" + }, + "rationale": "Targeting key '7'. Shard hash falls in variation-1 bucket." + }, + { + "flag": "integer-flag", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "8", + "attributes": [], + "result": { + "value": 1, + "reason": "SPLIT" + }, + "rationale": "Targeting key '8'. Shard hash falls in variation-1 bucket." + }, + { + "flag": "integer-flag", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "9", + "attributes": [], + "result": { + "value": 2, + "reason": "SPLIT" + }, + "rationale": "Targeting key '9'. Shard hash falls in variation-2 bucket." + }, + { + "flag": "integer-flag", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "10", + "attributes": [], + "result": { + "value": 2, + "reason": "SPLIT" + }, + "rationale": "Targeting key '10'. Shard hash falls in variation-2 bucket." + }, + { + "flag": "integer-flag", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "11", + "attributes": [], + "result": { + "value": 1, + "reason": "SPLIT" + }, + "rationale": "Targeting key '11'. Shard hash falls in variation-1 bucket." + }, + { + "flag": "integer-flag", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "12", + "attributes": [], + "result": { + "value": 1, + "reason": "SPLIT" + }, + "rationale": "Targeting key '12'. Shard hash falls in variation-1 bucket." + }, + { + "flag": "integer-flag", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "13", + "attributes": [], + "result": { + "value": 2, + "reason": "SPLIT" + }, + "rationale": "Targeting key '13'. Shard hash falls in variation-2 bucket." + }, + { + "flag": "integer-flag", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "14", + "attributes": [], + "result": { + "value": 1, + "reason": "SPLIT" + }, + "rationale": "Targeting key '14'. Shard hash falls in variation-1 bucket." + }, + { + "flag": "integer-flag", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "15", + "attributes": [], + "result": { + "value": 2, + "reason": "SPLIT" + }, + "rationale": "Targeting key '15'. Shard hash falls in variation-2 bucket." + }, + { + "flag": "integer-flag", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "16", + "attributes": [], + "result": { + "value": 2, + "reason": "SPLIT" + }, + "rationale": "Targeting key '16'. Shard hash falls in variation-2 bucket." + }, + { + "flag": "integer-flag", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "17", + "attributes": [], + "result": { + "value": 1, + "reason": "SPLIT" + }, + "rationale": "Targeting key '17'. Shard hash falls in variation-1 bucket." + }, + { + "flag": "integer-flag", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "18", + "attributes": [], + "result": { + "value": 1, + "reason": "SPLIT" + }, + "rationale": "Targeting key '18'. Shard hash falls in variation-1 bucket." + }, + { + "flag": "integer-flag", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "19", + "attributes": [], + "result": { + "value": 1, + "reason": "SPLIT" + }, + "rationale": "Targeting key '19'. Shard hash falls in variation-1 bucket." + } +] diff --git a/tests/FeatureFlags/fixtures/evaluation-cases/test-case-kill-switch-flag.json b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-kill-switch-flag.json new file mode 100644 index 00000000000..e5430e4abd5 --- /dev/null +++ b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-kill-switch-flag.json @@ -0,0 +1,338 @@ +[ + { + "flag": "kill-switch", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "alice", + "attributes": { + "email": "alice@mycompany.com", + "country": "US" + }, + "result": { + "value": true, + "reason": "TARGETING_MATCH" + }, + "rationale": "country=US matches the 'on-for-NA' allocation rule (ONE_OF [US, Canada, Mexico]). Shards cover 100% (0-10000). Variation 'on' = true." + }, + { + "flag": "kill-switch", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "bob", + "attributes": { + "email": "bob@example.com", + "country": "Canada" + }, + "result": { + "value": true, + "reason": "TARGETING_MATCH" + }, + "rationale": "country=Canada matches 'on-for-NA'. Variation 'on' = true." + }, + { + "flag": "kill-switch", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "barbara", + "attributes": { + "email": "barbara@example.com", + "country": "canada" + }, + "result": { + "value": false, + "reason": "STATIC" + }, + "rationale": "country='canada' (lowercase) does NOT match 'on-for-NA' (ONE_OF is case-sensitive). No age attr, so 'on-for-age-50+' also misses. Falls through to 'off-for-all' → false." + }, + { + "flag": "kill-switch", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "charlie", + "attributes": { + "age": 40 + }, + "result": { + "value": false, + "reason": "STATIC" + }, + "rationale": "No country attr, age=40 < 50 so 'on-for-age-50+' misses. Falls through to 'off-for-all' → false." + }, + { + "flag": "kill-switch", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "debra", + "attributes": { + "email": "test@test.com", + "country": "Mexico", + "age": 25 + }, + "result": { + "value": true, + "reason": "TARGETING_MATCH" + }, + "rationale": "country=Mexico matches 'on-for-NA'. Variation 'on' = true." + }, + { + "flag": "kill-switch", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "1", + "attributes": [], + "result": { + "value": false, + "reason": "STATIC" + }, + "rationale": "No attrs. Falls through to 'off-for-all' → false." + }, + { + "flag": "kill-switch", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "2", + "attributes": { + "country": "Mexico" + }, + "result": { + "value": true, + "reason": "TARGETING_MATCH" + }, + "rationale": "country=Mexico matches 'on-for-NA'. → true." + }, + { + "flag": "kill-switch", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "3", + "attributes": { + "country": "UK", + "age": 50 + }, + "result": { + "value": true, + "reason": "TARGETING_MATCH" + }, + "rationale": "country=UK not in NA list, age=50 >= 50 matches 'on-for-age-50+'. → true." + }, + { + "flag": "kill-switch", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "4", + "attributes": { + "country": "Germany" + }, + "result": { + "value": false, + "reason": "STATIC" + }, + "rationale": "country=Germany not NA, no age. Falls through to 'off-for-all'. → false." + }, + { + "flag": "kill-switch", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "5", + "attributes": { + "country": "Germany" + }, + "result": { + "value": false, + "reason": "STATIC" + }, + "rationale": "country=Germany. → false (same as #4)." + }, + { + "flag": "kill-switch", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "6", + "attributes": { + "country": "Germany" + }, + "result": { + "value": false, + "reason": "STATIC" + }, + "rationale": "country=Germany. → false." + }, + { + "flag": "kill-switch", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "7", + "attributes": { + "country": "US", + "age": 12 + }, + "result": { + "value": true, + "reason": "TARGETING_MATCH" + }, + "rationale": "country=US, age=12. US matches 'on-for-NA' first (evaluated before age rule). → true." + }, + { + "flag": "kill-switch", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "8", + "attributes": { + "country": "Italy", + "age": 60 + }, + "result": { + "value": true, + "reason": "TARGETING_MATCH" + }, + "rationale": "country=Italy not NA, age=60 >= 50 matches 'on-for-age-50+'. → true." + }, + { + "flag": "kill-switch", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "9", + "attributes": { + "email": "email@email.com" + }, + "result": { + "value": false, + "reason": "STATIC" + }, + "rationale": "email-only. No country or age attribute. → 'off-for-all' → false." + }, + { + "flag": "kill-switch", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "10", + "attributes": [], + "result": { + "value": false, + "reason": "STATIC" + }, + "rationale": "No attrs. → 'off-for-all' → false." + }, + { + "flag": "kill-switch", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "11", + "attributes": [], + "result": { + "value": false, + "reason": "STATIC" + }, + "rationale": "No attrs. → 'off-for-all' → false." + }, + { + "flag": "kill-switch", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "12", + "attributes": { + "country": "US" + }, + "result": { + "value": true, + "reason": "TARGETING_MATCH" + }, + "rationale": "country=US matches 'on-for-NA'. → true." + }, + { + "flag": "kill-switch", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "13", + "attributes": { + "country": "Canada" + }, + "result": { + "value": true, + "reason": "TARGETING_MATCH" + }, + "rationale": "country=Canada matches 'on-for-NA'. → true." + }, + { + "flag": "kill-switch", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "14", + "attributes": [], + "result": { + "value": false, + "reason": "STATIC" + }, + "rationale": "No attrs. → 'off-for-all' → false." + }, + { + "flag": "kill-switch", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "15", + "attributes": { + "country": "Denmark" + }, + "result": { + "value": false, + "reason": "STATIC" + }, + "rationale": "country=Denmark not in NA list, no age. → 'off-for-all' → false." + }, + { + "flag": "kill-switch", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "16", + "attributes": { + "country": "Norway" + }, + "result": { + "value": false, + "reason": "STATIC" + }, + "rationale": "country=Norway. → 'off-for-all' → false." + }, + { + "flag": "kill-switch", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "17", + "attributes": { + "country": "UK" + }, + "result": { + "value": false, + "reason": "STATIC" + }, + "rationale": "country=UK, no age. UK not NA, age missing. → 'off-for-all' → false." + }, + { + "flag": "kill-switch", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "18", + "attributes": { + "country": "UK" + }, + "result": { + "value": false, + "reason": "STATIC" + }, + "rationale": "country=UK. → false (same as #17)." + }, + { + "flag": "kill-switch", + "variationType": "BOOLEAN", + "defaultValue": false, + "targetingKey": "19", + "attributes": { + "country": "UK" + }, + "result": { + "value": false, + "reason": "STATIC" + }, + "rationale": "country=UK. → false." + } +] diff --git a/tests/FeatureFlags/fixtures/evaluation-cases/test-case-microsecond-date-flag.json b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-microsecond-date-flag.json new file mode 100644 index 00000000000..92a1a0be0a6 --- /dev/null +++ b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-microsecond-date-flag.json @@ -0,0 +1,42 @@ +[ + { + "flag": "microsecond-date-test", + "variationType": "STRING", + "defaultValue": "unknown", + "targetingKey": "alice", + "attributes": {}, + "result": { + "value": "active", + "reason": "TARGETING_MATCH" + }, + "rationale": "The 'expired' allocation has endAt=2002-10-31 (in the past) and is skipped. The 'future' allocation has startAt far in the future and is also skipped. The 'active' allocation covers the current time window and fires. Verifies microsecond-precision ISO-8601 timestamps parse correctly." + }, + { + "flag": "microsecond-date-test", + "variationType": "STRING", + "defaultValue": "unknown", + "targetingKey": "bob", + "attributes": { + "country": "US" + }, + "result": { + "value": "active", + "reason": "TARGETING_MATCH" + }, + "rationale": "Additional country attribute does not affect time-based allocation logic. The 'active' allocation still fires." + }, + { + "flag": "microsecond-date-test", + "variationType": "STRING", + "defaultValue": "unknown", + "targetingKey": "charlie", + "attributes": { + "version": "1.0.0" + }, + "result": { + "value": "active", + "reason": "TARGETING_MATCH" + }, + "rationale": "version attribute present; allocations are time-gated only and do not inspect version. 'active' still fires." + } +] diff --git a/tests/FeatureFlags/fixtures/evaluation-cases/test-case-new-user-onboarding-flag.json b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-new-user-onboarding-flag.json new file mode 100644 index 00000000000..ef324ea570f --- /dev/null +++ b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-new-user-onboarding-flag.json @@ -0,0 +1,370 @@ +[ + { + "flag": "new-user-onboarding", + "variationType": "STRING", + "defaultValue": "default", + "targetingKey": "alice", + "attributes": { + "email": "alice@mycompany.com", + "country": "US" + }, + "result": { + "value": "green", + "reason": "TARGETING_MATCH" + }, + "rationale": "country=US matches the NA targeting rule. Variation 'green' assigned (first NA bucket)." + }, + { + "flag": "new-user-onboarding", + "variationType": "STRING", + "defaultValue": "default", + "targetingKey": "bob", + "attributes": { + "email": "bob@example.com", + "country": "Canada" + }, + "result": { + "value": "default", + "reason": "DEFAULT" + }, + "rationale": "country=Canada — wait, result=default. So NA rule doesn't assign all NA users the same variation; it's sharded. bob lands in no-allocation bucket. Verifies shard boundary where a subject falls outside the 100% window." + }, + { + "flag": "new-user-onboarding", + "variationType": "STRING", + "defaultValue": "default", + "targetingKey": "charlie", + "attributes": { + "age": 50 + }, + "result": { + "value": "default", + "reason": "DEFAULT" + }, + "rationale": "No country attribute (age only). No targeting rule matches. Falls through to sharded catch-all. charlie's shard hash places them outside any allocated range → DefaultAllocationNull." + }, + { + "flag": "new-user-onboarding", + "variationType": "STRING", + "defaultValue": "default", + "targetingKey": "debra", + "attributes": { + "email": "test@test.com", + "country": "Mexico", + "age": 25 + }, + "result": { + "value": "blue", + "reason": "TARGETING_MATCH" + }, + "rationale": "country=Mexico + age=25. NA rule fires for Mexico (ONE_OF [US,Canada,Mexico]). Variation 'blue' from this shard bucket." + }, + { + "flag": "new-user-onboarding", + "variationType": "STRING", + "defaultValue": "default", + "targetingKey": "zach", + "attributes": { + "email": "test@test.com", + "country": "Mexico", + "age": 25 + }, + "result": { + "value": "purple", + "reason": "TARGETING_MATCH" + }, + "rationale": "Same attributes as debra but targeting key 'zach'. NA rule for Mexico fires; different shard hash → 'purple' bucket." + }, + { + "flag": "new-user-onboarding", + "variationType": "STRING", + "defaultValue": "default", + "targetingKey": "zach", + "attributes": { + "id": "override-id", + "email": "test@test.com", + "country": "Mexico", + "age": 25 + }, + "result": { + "value": "blue", + "reason": "TARGETING_MATCH" + }, + "rationale": "Same as zach but with id='override-id'. The 'id' attribute overrides the hash subject for the allocation, placing zach in the 'blue' bucket." + }, + { + "flag": "new-user-onboarding", + "variationType": "STRING", + "defaultValue": "default", + "targetingKey": "Zach", + "attributes": { + "email": "test@test.com", + "country": "Mexico", + "age": 25 + }, + "result": { + "value": "default", + "reason": "DEFAULT" + }, + "rationale": "Targeting key 'Zach' (capital Z). Hash is case-sensitive; different hash from 'zach'. Falls in an unallocated shard range → DefaultAllocationNull → 'default'." + }, + { + "flag": "new-user-onboarding", + "variationType": "STRING", + "defaultValue": "default", + "targetingKey": "1", + "attributes": [], + "result": { + "value": "default", + "reason": "DEFAULT" + }, + "rationale": "No attributes. No targeting rule matches. Shard hash of '1' falls outside allocated ranges → default." + }, + { + "flag": "new-user-onboarding", + "variationType": "STRING", + "defaultValue": "default", + "targetingKey": "2", + "attributes": { + "country": "Mexico" + }, + "result": { + "value": "blue", + "reason": "TARGETING_MATCH" + }, + "rationale": "country=Mexico satisfies NA rule. Targeting key '2' shard hash → 'blue' bucket." + }, + { + "flag": "new-user-onboarding", + "variationType": "STRING", + "defaultValue": "default", + "targetingKey": "3", + "attributes": { + "country": "UK", + "age": 33 + }, + "result": { + "value": "control", + "reason": "TARGETING_MATCH" + }, + "rationale": "country=UK (not NA), age=33. Falls to RoW sharded allocation. Shard hash of '3' → 'control'." + }, + { + "flag": "new-user-onboarding", + "variationType": "STRING", + "defaultValue": "default", + "targetingKey": "4", + "attributes": { + "country": "Germany" + }, + "result": { + "value": "red", + "reason": "TARGETING_MATCH" + }, + "rationale": "country=Germany. RoW sharded allocation. Key '4' → 'red'." + }, + { + "flag": "new-user-onboarding", + "variationType": "STRING", + "defaultValue": "default", + "targetingKey": "5", + "attributes": { + "country": "Germany" + }, + "result": { + "value": "yellow", + "reason": "TARGETING_MATCH" + }, + "rationale": "country=Germany. Key '5' → 'yellow'." + }, + { + "flag": "new-user-onboarding", + "variationType": "STRING", + "defaultValue": "default", + "targetingKey": "6", + "attributes": { + "country": "Germany" + }, + "result": { + "value": "yellow", + "reason": "TARGETING_MATCH" + }, + "rationale": "country=Germany. Key '6' → 'yellow'." + }, + { + "flag": "new-user-onboarding", + "variationType": "STRING", + "defaultValue": "default", + "targetingKey": "7", + "attributes": { + "country": "US" + }, + "result": { + "value": "blue", + "reason": "TARGETING_MATCH" + }, + "rationale": "country=US matches NA rule. Key '7' → 'blue'." + }, + { + "flag": "new-user-onboarding", + "variationType": "STRING", + "defaultValue": "default", + "targetingKey": "8", + "attributes": { + "country": "Italy" + }, + "result": { + "value": "red", + "reason": "TARGETING_MATCH" + }, + "rationale": "country=Italy. RoW sharded allocation. Key '8' → 'red'." + }, + { + "flag": "new-user-onboarding", + "variationType": "STRING", + "defaultValue": "default", + "targetingKey": "9", + "attributes": { + "email": "email@email.com" + }, + "result": { + "value": "default", + "reason": "DEFAULT" + }, + "rationale": "email-only attribute. No country, so no targeting rule matches. Shard hash of '9' falls outside all allocated ranges → default." + }, + { + "flag": "new-user-onboarding", + "variationType": "STRING", + "defaultValue": "default", + "targetingKey": "10", + "attributes": [], + "result": { + "value": "default", + "reason": "DEFAULT" + }, + "rationale": "No attributes. Shard hash of '10' outside allocated ranges → default." + }, + { + "flag": "new-user-onboarding", + "variationType": "STRING", + "defaultValue": "default", + "targetingKey": "11", + "attributes": [], + "result": { + "value": "default", + "reason": "DEFAULT" + }, + "rationale": "No attributes. Key '11' → default." + }, + { + "flag": "new-user-onboarding", + "variationType": "STRING", + "defaultValue": "default", + "targetingKey": "12", + "attributes": { + "country": "US" + }, + "result": { + "value": "blue", + "reason": "TARGETING_MATCH" + }, + "rationale": "country=US. NA rule fires. Key '12' → 'blue'." + }, + { + "flag": "new-user-onboarding", + "variationType": "STRING", + "defaultValue": "default", + "targetingKey": "13", + "attributes": { + "country": "Canada" + }, + "result": { + "value": "blue", + "reason": "TARGETING_MATCH" + }, + "rationale": "country=Canada. NA rule fires. Key '13' → 'blue'." + }, + { + "flag": "new-user-onboarding", + "variationType": "STRING", + "defaultValue": "default", + "targetingKey": "14", + "attributes": [], + "result": { + "value": "default", + "reason": "DEFAULT" + }, + "rationale": "No attributes. Key '14' outside allocated shard ranges → default." + }, + { + "flag": "new-user-onboarding", + "variationType": "STRING", + "defaultValue": "default", + "targetingKey": "15", + "attributes": { + "country": "Denmark" + }, + "result": { + "value": "yellow", + "reason": "TARGETING_MATCH" + }, + "rationale": "country=Denmark. RoW sharded allocation. Key '15' → 'yellow'." + }, + { + "flag": "new-user-onboarding", + "variationType": "STRING", + "defaultValue": "default", + "targetingKey": "16", + "attributes": { + "country": "Norway" + }, + "result": { + "value": "control", + "reason": "TARGETING_MATCH" + }, + "rationale": "country=Norway. RoW sharded allocation. Key '16' → 'control'." + }, + { + "flag": "new-user-onboarding", + "variationType": "STRING", + "defaultValue": "default", + "targetingKey": "17", + "attributes": { + "country": "UK" + }, + "result": { + "value": "control", + "reason": "TARGETING_MATCH" + }, + "rationale": "country=UK. RoW sharded allocation. Key '17' → 'control'." + }, + { + "flag": "new-user-onboarding", + "variationType": "STRING", + "defaultValue": "default", + "targetingKey": "18", + "attributes": { + "country": "UK" + }, + "result": { + "value": "default", + "reason": "DEFAULT" + }, + "rationale": "country=UK. Key '18' shard hash falls in an unallocated gap in the RoW split → default." + }, + { + "flag": "new-user-onboarding", + "variationType": "STRING", + "defaultValue": "default", + "targetingKey": "19", + "attributes": { + "country": "UK" + }, + "result": { + "value": "red", + "reason": "TARGETING_MATCH" + }, + "rationale": "country=UK. Key '19' → 'red' bucket." + } +] diff --git a/tests/FeatureFlags/fixtures/evaluation-cases/test-case-no-allocations-flag.json b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-no-allocations-flag.json new file mode 100644 index 00000000000..1cf8d907cdb --- /dev/null +++ b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-no-allocations-flag.json @@ -0,0 +1,58 @@ +[ + { + "flag": "no_allocations_flag", + "variationType": "JSON", + "defaultValue": { + "hello": "world" + }, + "targetingKey": "alice", + "attributes": { + "email": "alice@mycompany.com", + "country": "US" + }, + "result": { + "value": { + "hello": "world" + }, + "reason": "DEFAULT" + }, + "rationale": "Flag is enabled but has an empty allocations list. Engine returns DefaultAllocationNull. Provider returns the caller's default regardless of attributes." + }, + { + "flag": "no_allocations_flag", + "variationType": "JSON", + "defaultValue": { + "hello": "world" + }, + "targetingKey": "bob", + "attributes": { + "email": "bob@example.com", + "country": "Canada" + }, + "result": { + "value": { + "hello": "world" + }, + "reason": "DEFAULT" + }, + "rationale": "Different caller default; still no allocations, so that new default is returned." + }, + { + "flag": "no_allocations_flag", + "variationType": "JSON", + "defaultValue": { + "hello": "world" + }, + "targetingKey": "charlie", + "attributes": { + "age": 50 + }, + "result": { + "value": { + "hello": "world" + }, + "reason": "DEFAULT" + }, + "rationale": "Age-only subject; same result — empty allocations means no subject can ever be assigned." + } +] diff --git a/tests/FeatureFlags/fixtures/evaluation-cases/test-case-null-operator-flag.json b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-null-operator-flag.json new file mode 100644 index 00000000000..2646b57f473 --- /dev/null +++ b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-null-operator-flag.json @@ -0,0 +1,74 @@ +[ + { + "flag": "null-operator-test", + "variationType": "STRING", + "defaultValue": "default-null", + "targetingKey": "alice", + "attributes": { + "size": 5, + "country": "US" + }, + "result": { + "value": "old", + "reason": "TARGETING_MATCH" + }, + "rationale": "size=5 satisfies LT 10 in the second rule of 'null-operator' allocation (rules are OR-joined). Variation 'old'." + }, + { + "flag": "null-operator-test", + "variationType": "STRING", + "defaultValue": "default-null", + "targetingKey": "bob", + "attributes": { + "size": 10, + "country": "Canada" + }, + "result": { + "value": "new", + "reason": "TARGETING_MATCH" + }, + "rationale": "size=10 is NOT NULL (IS_NULL false satisfies 'not-null-operator') → variation 'new'. Note: IS_NULL check fires before LT 10 would fail." + }, + { + "flag": "null-operator-test", + "variationType": "STRING", + "defaultValue": "default-null", + "targetingKey": "charlie", + "attributes": { + "size": null + }, + "result": { + "value": "old", + "reason": "TARGETING_MATCH" + }, + "rationale": "size=null in PHP → the C bridge drops the attribute (null is not scalar). Engine sees no 'size' attr. IS_NULL true condition fires → 'null-operator' allocation → 'old'. Verifies missing/null attributes satisfy IS_NULL." + }, + { + "flag": "null-operator-test", + "variationType": "STRING", + "defaultValue": "default-null", + "targetingKey": "david", + "attributes": { + "size": 26 + }, + "result": { + "value": "new", + "reason": "TARGETING_MATCH" + }, + "rationale": "size=26 (NOT NULL, not LT 10). IS_NULL false condition in 'not-null-operator' is satisfied → 'new'." + }, + { + "flag": "null-operator-test", + "variationType": "STRING", + "defaultValue": "default-null", + "targetingKey": "elize", + "attributes": { + "country": "UK" + }, + "result": { + "value": "old", + "reason": "TARGETING_MATCH" + }, + "rationale": "No 'size' attribute (only country). Missing attribute behaves as null for IS_NULL operator → IS_NULL true fires → 'null-operator' → 'old'." + } +] diff --git a/tests/FeatureFlags/fixtures/evaluation-cases/test-case-numeric-flag.json b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-numeric-flag.json new file mode 100644 index 00000000000..6b6555cdd58 --- /dev/null +++ b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-numeric-flag.json @@ -0,0 +1,46 @@ +[ + { + "flag": "numeric_flag", + "variationType": "NUMERIC", + "defaultValue": 0, + "targetingKey": "alice", + "attributes": { + "email": "alice@mycompany.com", + "country": "US" + }, + "result": { + "value": 3.1415926, + "reason": "STATIC" + }, + "rationale": "numeric_flag has a single no-rule allocation with variation value 3.1415926 (float). All subjects get the same value. Verifies float serialization (serde_json) and PHP's (float) cast correctly round-trip the value within 1e-10 tolerance." + }, + { + "flag": "numeric_flag", + "variationType": "NUMERIC", + "defaultValue": 0, + "targetingKey": "bob", + "attributes": { + "email": "bob@example.com", + "country": "Canada" + }, + "result": { + "value": 3.1415926, + "reason": "STATIC" + }, + "rationale": "Different email\/country — same no-rule allocation fires, same float value returned." + }, + { + "flag": "numeric_flag", + "variationType": "NUMERIC", + "defaultValue": 0, + "targetingKey": "charlie", + "attributes": { + "age": 50 + }, + "result": { + "value": 3.1415926, + "reason": "STATIC" + }, + "rationale": "Age attribute instead of email\/country — same no-rule allocation fires." + } +] diff --git a/tests/FeatureFlags/fixtures/evaluation-cases/test-case-numeric-one-of.json b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-numeric-one-of.json new file mode 100644 index 00000000000..5eed614567c --- /dev/null +++ b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-numeric-one-of.json @@ -0,0 +1,100 @@ +[ + { + "flag": "numeric-one-of", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "alice", + "attributes": { + "number": 1 + }, + "result": { + "value": 1, + "reason": "TARGETING_MATCH" + }, + "rationale": "number=1 (int). Engine coerces to string '1' for ONE_OF. '1' ONE_OF ['1'] → matches. Variation 1 returned." + }, + { + "flag": "numeric-one-of", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "bob", + "attributes": { + "number": 2 + }, + "result": { + "value": 0, + "reason": "DEFAULT" + }, + "rationale": "number=2. '2' is not in '1-for-1' list, not in '123456789' list, and satisfies NOT_ONE_OF ['2']? Actually result=0 implies no allocation fires. NOT_ONE_OF ['2'] for '2' is not satisfied → falls through → default 0." + }, + { + "flag": "numeric-one-of", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "charlie", + "attributes": { + "number": 3 + }, + "result": { + "value": 3, + "reason": "TARGETING_MATCH" + }, + "rationale": "number=3. '3' not in ['1'] or ['123456789']. NOT_ONE_OF ['2']: '3' not in ['2'] → satisfied → variation 3." + }, + { + "flag": "numeric-one-of", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "derek", + "attributes": { + "number": 4 + }, + "result": { + "value": 3, + "reason": "TARGETING_MATCH" + }, + "rationale": "number=4. Same as 3 — NOT_ONE_OF ['2']: '4' not in ['2'] → satisfied → variation 3." + }, + { + "flag": "numeric-one-of", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "erica", + "attributes": { + "number": "1" + }, + "result": { + "value": 1, + "reason": "TARGETING_MATCH" + }, + "rationale": "number='1' (string). Engine converts to string for ONE_OF comparison; '1' ONE_OF ['1'] → matches → variation 1. Verifies string attribute value matches numeric ONE_OF list." + }, + { + "flag": "numeric-one-of", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "frank", + "attributes": { + "number": 1 + }, + "result": { + "value": 1, + "reason": "TARGETING_MATCH" + }, + "rationale": "number=1 (int, same as alice). Same result: variation 1. Confirms consistent hashing is not involved — purely rule-based." + }, + { + "flag": "numeric-one-of", + "variationType": "INTEGER", + "defaultValue": 0, + "targetingKey": "george", + "attributes": { + "number": 123456789 + }, + "result": { + "value": 2, + "reason": "TARGETING_MATCH" + }, + "rationale": "number=123456789. '123456789' ONE_OF ['123456789'] → matches. Variation 2 returned." + } +] diff --git a/tests/FeatureFlags/fixtures/evaluation-cases/test-case-of-7-empty-targeting-key.json b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-of-7-empty-targeting-key.json new file mode 100644 index 00000000000..7b21916e6f1 --- /dev/null +++ b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-of-7-empty-targeting-key.json @@ -0,0 +1,14 @@ +[ + { + "flag": "empty-targeting-key-flag", + "variationType": "STRING", + "defaultValue": "default", + "targetingKey": "", + "attributes": [], + "result": { + "value": "on-value", + "reason": "STATIC" + }, + "rationale": "Targeting key is '' (empty string). The flag has a single no-rule allocation with no shards (100% coverage). PHP passes the empty string to the Rust engine as None (the FFI bridge skips empty strings). Flag is evaluated without a targeting key and the no-rule allocation fires, yielding 'on-value'." + } +] diff --git a/tests/FeatureFlags/fixtures/evaluation-cases/test-case-regex-flag.json b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-regex-flag.json new file mode 100644 index 00000000000..a8036e35121 --- /dev/null +++ b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-regex-flag.json @@ -0,0 +1,61 @@ +[ + { + "flag": "regex-flag", + "variationType": "STRING", + "defaultValue": "none", + "targetingKey": "alice", + "attributes": { + "version": "1.15.0", + "email": "alice@example.com" + }, + "result": { + "value": "partial-example", + "reason": "TARGETING_MATCH" + }, + "rationale": "email matches @example.com regex, and version attribute is present. The 'partial-example' allocation fires." + }, + { + "flag": "regex-flag", + "variationType": "STRING", + "defaultValue": "none", + "targetingKey": "bob", + "attributes": { + "version": "0.20.1", + "email": "bob@test.com" + }, + "result": { + "value": "test", + "reason": "TARGETING_MATCH" + }, + "rationale": "email matches @test.com regex. The 'test' allocation fires." + }, + { + "flag": "regex-flag", + "variationType": "STRING", + "defaultValue": "none", + "targetingKey": "charlie", + "attributes": { + "version": "2.1.13" + }, + "result": { + "value": "none", + "reason": "DEFAULT" + }, + "rationale": "No email attribute — neither regex allocation can match. Engine returns DefaultAllocationNull; Provider returns 'none'." + }, + { + "flag": "regex-flag", + "variationType": "STRING", + "defaultValue": "none", + "targetingKey": "derek", + "attributes": { + "version": "2.1.13", + "email": "derek@gmail.com" + }, + "result": { + "value": "none", + "reason": "DEFAULT" + }, + "rationale": "email is @gmail.com which matches neither @example.com nor @test.com. No allocation fires; default returned." + } +] diff --git a/tests/FeatureFlags/fixtures/evaluation-cases/test-case-start-and-end-date-flag.json b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-start-and-end-date-flag.json new file mode 100644 index 00000000000..cb4810d7fdf --- /dev/null +++ b/tests/FeatureFlags/fixtures/evaluation-cases/test-case-start-and-end-date-flag.json @@ -0,0 +1,46 @@ +[ + { + "flag": "start-and-end-date-test", + "variationType": "STRING", + "defaultValue": "unknown", + "targetingKey": "alice", + "attributes": { + "version": "1.15.0", + "country": "US" + }, + "result": { + "value": "current", + "reason": "TARGETING_MATCH" + }, + "rationale": "The 'current' allocation has startAt in the past and endAt in the future, so it is active now. All subjects without a matching 'old' or 'new' allocation fall into 'current'." + }, + { + "flag": "start-and-end-date-test", + "variationType": "STRING", + "defaultValue": "unknown", + "targetingKey": "bob", + "attributes": { + "version": "0.20.1", + "country": "Canada" + }, + "result": { + "value": "current", + "reason": "TARGETING_MATCH" + }, + "rationale": "Different version/country; same result — no 'old' or 'new' rule matches these attrs, so 'current' fires." + }, + { + "flag": "start-and-end-date-test", + "variationType": "STRING", + "defaultValue": "unknown", + "targetingKey": "charlie", + "attributes": { + "version": "2.1.13" + }, + "result": { + "value": "current", + "reason": "TARGETING_MATCH" + }, + "rationale": "version 2.1.13 with no country. 'current' allocation still covers the present time window." + } +] diff --git a/tests/FeatureFlags/fixtures/evaluation-cases/test-flag-that-does-not-exist.json b/tests/FeatureFlags/fixtures/evaluation-cases/test-flag-that-does-not-exist.json new file mode 100644 index 00000000000..5c776906dc4 --- /dev/null +++ b/tests/FeatureFlags/fixtures/evaluation-cases/test-flag-that-does-not-exist.json @@ -0,0 +1,46 @@ +[ + { + "flag": "flag-that-does-not-exist", + "variationType": "NUMERIC", + "defaultValue": 0.0, + "targetingKey": "alice", + "attributes": { + "email": "alice@mycompany.com", + "country": "US" + }, + "result": { + "value": 0.0, + "reason": "ERROR" + }, + "rationale": "Flag key is not present in the UFC config at all, so the engine returns FlagUnrecognizedOrDisabled (error_code=3). Provider returns the default." + }, + { + "flag": "flag-that-does-not-exist", + "variationType": "NUMERIC", + "defaultValue": 0.0, + "targetingKey": "bob", + "attributes": { + "email": "bob@example.com", + "country": "Canada" + }, + "result": { + "value": 0.0, + "reason": "ERROR" + }, + "rationale": "Same non-existent flag, different subject. Engine returns error regardless of attributes." + }, + { + "flag": "flag-that-does-not-exist", + "variationType": "NUMERIC", + "defaultValue": 0.0, + "targetingKey": "charlie", + "attributes": { + "age": 50 + }, + "result": { + "value": 0.0, + "reason": "ERROR" + }, + "rationale": "Numeric attribute only — does not change the outcome; flag still doesn't exist." + } +] diff --git a/tests/FeatureFlags/fixtures/evaluation-cases/test-json-config-flag.json b/tests/FeatureFlags/fixtures/evaluation-cases/test-json-config-flag.json new file mode 100644 index 00000000000..03e7c318cb3 --- /dev/null +++ b/tests/FeatureFlags/fixtures/evaluation-cases/test-json-config-flag.json @@ -0,0 +1,80 @@ +[ + { + "flag": "json-config-flag", + "variationType": "JSON", + "defaultValue": { + "foo": "bar" + }, + "targetingKey": "alice", + "attributes": { + "email": "alice@mycompany.com", + "country": "US" + }, + "result": { + "value": { + "integer": 1, + "string": "one", + "float": 1.0 + }, + "reason": "SPLIT" + }, + "rationale": "Shard allocation assigns alice (country=US) to variation with integer=1, string='one', float=1.0. Verifies JSON object variations are parsed correctly including nested numeric types. The UFC config stores float values as JSON numbers with decimal (1.0), the FFE library returns the raw JSON string preserving the decimal, so PHP json_decode returns float 1.0." + }, + { + "flag": "json-config-flag", + "variationType": "JSON", + "defaultValue": { + "foo": "bar" + }, + "targetingKey": "bob", + "attributes": { + "email": "bob@example.com", + "country": "Canada" + }, + "result": { + "value": { + "integer": 2, + "string": "two", + "float": 2.0 + }, + "reason": "SPLIT" + }, + "rationale": "Different shard bucket for bob (country=Canada) → variation with integer=2, string='two', float=2.0. The UFC config stores float values as JSON numbers with decimal (2.0), the FFE library returns the raw JSON string preserving the decimal, so PHP json_decode returns float 2.0." + }, + { + "flag": "json-config-flag", + "variationType": "JSON", + "defaultValue": { + "foo": "bar" + }, + "targetingKey": "charlie", + "attributes": { + "age": 50 + }, + "result": { + "value": { + "integer": 2, + "string": "two", + "float": 2.0 + }, + "reason": "SPLIT" + }, + "rationale": "charlie (age=50) lands in the same shard bucket as bob → variation integer=2, float=2.0. The UFC config stores float values as JSON numbers with decimal (2.0), the FFE library returns the raw JSON string preserving the decimal, so PHP json_decode returns float 2.0." + }, + { + "flag": "json-config-flag", + "variationType": "JSON", + "defaultValue": { + "foo": "bar" + }, + "targetingKey": "diana", + "attributes": { + "Force Empty": true + }, + "result": { + "value": [], + "reason": "TARGETING_MATCH" + }, + "rationale": "Force Empty attribute triggers a variation with value={} (empty object). Verifies empty JSON objects are returned correctly rather than falling back to defaultValue." + } +] diff --git a/tests/FeatureFlags/fixtures/evaluation-cases/test-no-allocations-flag.json b/tests/FeatureFlags/fixtures/evaluation-cases/test-no-allocations-flag.json new file mode 100644 index 00000000000..0a89b47f26e --- /dev/null +++ b/tests/FeatureFlags/fixtures/evaluation-cases/test-no-allocations-flag.json @@ -0,0 +1,58 @@ +[ + { + "flag": "no_allocations_flag", + "variationType": "JSON", + "defaultValue": { + "message": "Hello, world!" + }, + "targetingKey": "alice", + "attributes": { + "email": "alice@mycompany.com", + "country": "US" + }, + "result": { + "value": { + "message": "Hello, world!" + }, + "reason": "DEFAULT" + }, + "rationale": "Same flag (no_allocations_flag), different caller default {'message': 'Hello, world!'}. Engine returns DefaultAllocationNull; Provider returns caller default." + }, + { + "flag": "no_allocations_flag", + "variationType": "JSON", + "defaultValue": { + "message": "Hello, world!" + }, + "targetingKey": "bob", + "attributes": { + "email": "bob@example.com", + "country": "Canada" + }, + "result": { + "value": { + "message": "Hello, world!" + }, + "reason": "DEFAULT" + }, + "rationale": "Different subject; engine still sees no allocations and returns DEFAULT." + }, + { + "flag": "no_allocations_flag", + "variationType": "JSON", + "defaultValue": { + "message": "Hello, world!" + }, + "targetingKey": "charlie", + "attributes": { + "age": 50 + }, + "result": { + "value": { + "message": "Hello, world!" + }, + "reason": "DEFAULT" + }, + "rationale": "Age attribute; no allocation is defined so every subject gets the caller's default." + } +] diff --git a/tests/FeatureFlags/fixtures/evaluation-cases/test-special-characters.json b/tests/FeatureFlags/fixtures/evaluation-cases/test-special-characters.json new file mode 100644 index 00000000000..f8b6bc6b2be --- /dev/null +++ b/tests/FeatureFlags/fixtures/evaluation-cases/test-special-characters.json @@ -0,0 +1,62 @@ +[ + { + "flag": "special-characters", + "variationType": "JSON", + "defaultValue": [], + "targetingKey": "ash", + "attributes": [], + "result": { + "value": { + "a": "kümmert", + "b": "schön" + }, + "reason": "SPLIT" + }, + "rationale": "Shard allocation assigns 'ash' to a variation whose JSON value contains German umlauts (ü, ö). Verifies UTF-8 multibyte characters survive the Rust CString → PHP string pipeline without corruption." + }, + { + "flag": "special-characters", + "variationType": "JSON", + "defaultValue": [], + "targetingKey": "ben", + "attributes": [], + "result": { + "value": { + "a": "піклуватися", + "b": "любов" + }, + "reason": "SPLIT" + }, + "rationale": "'ben' shard yields Cyrillic Unicode characters. Verifies non-Latin scripts round-trip through JSON serialization." + }, + { + "flag": "special-characters", + "variationType": "JSON", + "defaultValue": [], + "targetingKey": "cameron", + "attributes": [], + "result": { + "value": { + "a": "照顾", + "b": "漂亮" + }, + "reason": "SPLIT" + }, + "rationale": "'cameron' shard yields CJK (Chinese) characters. Verifies multi-byte UTF-8 sequences are preserved." + }, + { + "flag": "special-characters", + "variationType": "JSON", + "defaultValue": [], + "targetingKey": "darryl", + "attributes": [], + "result": { + "value": { + "a": "🤗", + "b": "🌸" + }, + "reason": "SPLIT" + }, + "rationale": "'darryl' shard yields emoji (4-byte UTF-8 code points). Verifies surrogate-pair-free emoji are handled correctly." + } +] diff --git a/tests/FeatureFlags/fixtures/evaluation-cases/test-string-with-special-characters.json b/tests/FeatureFlags/fixtures/evaluation-cases/test-string-with-special-characters.json new file mode 100644 index 00000000000..1d8bb4e626c --- /dev/null +++ b/tests/FeatureFlags/fixtures/evaluation-cases/test-string-with-special-characters.json @@ -0,0 +1,926 @@ +[ + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_spaces", + "attributes": { + "string_with_spaces": true + }, + "result": { + "value": " a b c d e f ", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_spaces=true'. The assigned variation value tests that the string \" a b c d e f \" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_one_space", + "attributes": { + "string_with_only_one_space": true + }, + "result": { + "value": " ", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_one_space=true'. The assigned variation value tests that the string \" \" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_multiple_spaces", + "attributes": { + "string_with_only_multiple_spaces": true + }, + "result": { + "value": " ", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_multiple_spaces=true'. The assigned variation value tests that the string \" \" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_dots", + "attributes": { + "string_with_dots": true + }, + "result": { + "value": ".a.b.c.d.e.f.", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_dots=true'. The assigned variation value tests that the string \".a.b.c.d.e.f.\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_one_dot", + "attributes": { + "string_with_only_one_dot": true + }, + "result": { + "value": ".", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_one_dot=true'. The assigned variation value tests that the string \".\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_multiple_dots", + "attributes": { + "string_with_only_multiple_dots": true + }, + "result": { + "value": ".......", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_multiple_dots=true'. The assigned variation value tests that the string \".......\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_comas", + "attributes": { + "string_with_comas": true + }, + "result": { + "value": ",a,b,c,d,e,f,", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_comas=true'. The assigned variation value tests that the string \",a,b,c,d,e,f,\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_one_coma", + "attributes": { + "string_with_only_one_coma": true + }, + "result": { + "value": ",", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_one_coma=true'. The assigned variation value tests that the string \",\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_multiple_comas", + "attributes": { + "string_with_only_multiple_comas": true + }, + "result": { + "value": ",,,,,,,", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_multiple_comas=true'. The assigned variation value tests that the string \",,,,,,,\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_colons", + "attributes": { + "string_with_colons": true + }, + "result": { + "value": ":a:b:c:d:e:f:", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_colons=true'. The assigned variation value tests that the string \":a:b:c:d:e:f:\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_one_colon", + "attributes": { + "string_with_only_one_colon": true + }, + "result": { + "value": ":", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_one_colon=true'. The assigned variation value tests that the string \":\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_multiple_colons", + "attributes": { + "string_with_only_multiple_colons": true + }, + "result": { + "value": ":::::::", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_multiple_colons=true'. The assigned variation value tests that the string \":::::::\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_semicolons", + "attributes": { + "string_with_semicolons": true + }, + "result": { + "value": ";a;b;c;d;e;f;", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_semicolons=true'. The assigned variation value tests that the string \";a;b;c;d;e;f;\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_one_semicolon", + "attributes": { + "string_with_only_one_semicolon": true + }, + "result": { + "value": ";", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_one_semicolon=true'. The assigned variation value tests that the string \";\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_multiple_semicolons", + "attributes": { + "string_with_only_multiple_semicolons": true + }, + "result": { + "value": ";;;;;;;", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_multiple_semicolons=true'. The assigned variation value tests that the string \";;;;;;;\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_slashes", + "attributes": { + "string_with_slashes": true + }, + "result": { + "value": "/a/b/c/d/e/f/", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_slashes=true'. The assigned variation value tests that the string \"/a/b/c/d/e/f/\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_one_slash", + "attributes": { + "string_with_only_one_slash": true + }, + "result": { + "value": "/", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_one_slash=true'. The assigned variation value tests that the string \"/\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_multiple_slashes", + "attributes": { + "string_with_only_multiple_slashes": true + }, + "result": { + "value": "///////", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_multiple_slashes=true'. The assigned variation value tests that the string \"///////\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_dashes", + "attributes": { + "string_with_dashes": true + }, + "result": { + "value": "-a-b-c-d-e-f-", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_dashes=true'. The assigned variation value tests that the string \"-a-b-c-d-e-f-\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_one_dash", + "attributes": { + "string_with_only_one_dash": true + }, + "result": { + "value": "-", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_one_dash=true'. The assigned variation value tests that the string \"-\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_multiple_dashes", + "attributes": { + "string_with_only_multiple_dashes": true + }, + "result": { + "value": "-------", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_multiple_dashes=true'. The assigned variation value tests that the string \"-------\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_underscores", + "attributes": { + "string_with_underscores": true + }, + "result": { + "value": "_a_b_c_d_e_f_", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_underscores=true'. The assigned variation value tests that the string \"_a_b_c_d_e_f_\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_one_underscore", + "attributes": { + "string_with_only_one_underscore": true + }, + "result": { + "value": "_", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_one_underscore=true'. The assigned variation value tests that the string \"_\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_multiple_underscores", + "attributes": { + "string_with_only_multiple_underscores": true + }, + "result": { + "value": "_______", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_multiple_underscores=true'. The assigned variation value tests that the string \"_______\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_plus_signs", + "attributes": { + "string_with_plus_signs": true + }, + "result": { + "value": "+a+b+c+d+e+f+", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_plus_signs=true'. The assigned variation value tests that the string \"+a+b+c+d+e+f+\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_one_plus_sign", + "attributes": { + "string_with_only_one_plus_sign": true + }, + "result": { + "value": "+", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_one_plus_sign=true'. The assigned variation value tests that the string \"+\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_multiple_plus_signs", + "attributes": { + "string_with_only_multiple_plus_signs": true + }, + "result": { + "value": "+++++++", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_multiple_plus_signs=true'. The assigned variation value tests that the string \"+++++++\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_equal_signs", + "attributes": { + "string_with_equal_signs": true + }, + "result": { + "value": "=a=b=c=d=e=f=", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_equal_signs=true'. The assigned variation value tests that the string \"=a=b=c=d=e=f=\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_one_equal_sign", + "attributes": { + "string_with_only_one_equal_sign": true + }, + "result": { + "value": "=", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_one_equal_sign=true'. The assigned variation value tests that the string \"=\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_multiple_equal_signs", + "attributes": { + "string_with_only_multiple_equal_signs": true + }, + "result": { + "value": "=======", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_multiple_equal_signs=true'. The assigned variation value tests that the string \"=======\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_dollar_signs", + "attributes": { + "string_with_dollar_signs": true + }, + "result": { + "value": "$a$b$c$d$e$f$", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_dollar_signs=true'. The assigned variation value tests that the string \"$a$b$c$d$e$f$\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_one_dollar_sign", + "attributes": { + "string_with_only_one_dollar_sign": true + }, + "result": { + "value": "$", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_one_dollar_sign=true'. The assigned variation value tests that the string \"$\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_multiple_dollar_signs", + "attributes": { + "string_with_only_multiple_dollar_signs": true + }, + "result": { + "value": "$$$$$$$", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_multiple_dollar_signs=true'. The assigned variation value tests that the string \"$$$$$$$\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_at_signs", + "attributes": { + "string_with_at_signs": true + }, + "result": { + "value": "@a@b@c@d@e@f@", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_at_signs=true'. The assigned variation value tests that the string \"@a@b@c@d@e@f@\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_one_at_sign", + "attributes": { + "string_with_only_one_at_sign": true + }, + "result": { + "value": "@", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_one_at_sign=true'. The assigned variation value tests that the string \"@\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_multiple_at_signs", + "attributes": { + "string_with_only_multiple_at_signs": true + }, + "result": { + "value": "@@@@@@@", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_multiple_at_signs=true'. The assigned variation value tests that the string \"@@@@@@@\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_amp_signs", + "attributes": { + "string_with_amp_signs": true + }, + "result": { + "value": "&a&b&c&d&e&f&", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_amp_signs=true'. The assigned variation value tests that the string \"&a&b&c&d&e&f&\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_one_amp_sign", + "attributes": { + "string_with_only_one_amp_sign": true + }, + "result": { + "value": "&", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_one_amp_sign=true'. The assigned variation value tests that the string \"&\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_multiple_amp_signs", + "attributes": { + "string_with_only_multiple_amp_signs": true + }, + "result": { + "value": "&&&&&&&", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_multiple_amp_signs=true'. The assigned variation value tests that the string \"&&&&&&&\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_hash_signs", + "attributes": { + "string_with_hash_signs": true + }, + "result": { + "value": "#a#b#c#d#e#f#", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_hash_signs=true'. The assigned variation value tests that the string \"#a#b#c#d#e#f#\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_one_hash_sign", + "attributes": { + "string_with_only_one_hash_sign": true + }, + "result": { + "value": "#", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_one_hash_sign=true'. The assigned variation value tests that the string \"#\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_multiple_hash_signs", + "attributes": { + "string_with_only_multiple_hash_signs": true + }, + "result": { + "value": "#######", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_multiple_hash_signs=true'. The assigned variation value tests that the string \"#######\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_percentage_signs", + "attributes": { + "string_with_percentage_signs": true + }, + "result": { + "value": "%a%b%c%d%e%f%", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_percentage_signs=true'. The assigned variation value tests that the string \"%a%b%c%d%e%f%\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_one_percentage_sign", + "attributes": { + "string_with_only_one_percentage_sign": true + }, + "result": { + "value": "%", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_one_percentage_sign=true'. The assigned variation value tests that the string \"%\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_multiple_percentage_signs", + "attributes": { + "string_with_only_multiple_percentage_signs": true + }, + "result": { + "value": "%%%%%%%", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_multiple_percentage_signs=true'. The assigned variation value tests that the string \"%%%%%%%\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_tilde_signs", + "attributes": { + "string_with_tilde_signs": true + }, + "result": { + "value": "~a~b~c~d~e~f~", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_tilde_signs=true'. The assigned variation value tests that the string \"~a~b~c~d~e~f~\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_one_tilde_sign", + "attributes": { + "string_with_only_one_tilde_sign": true + }, + "result": { + "value": "~", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_one_tilde_sign=true'. The assigned variation value tests that the string \"~\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_multiple_tilde_signs", + "attributes": { + "string_with_only_multiple_tilde_signs": true + }, + "result": { + "value": "~~~~~~~", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_multiple_tilde_signs=true'. The assigned variation value tests that the string \"~~~~~~~\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_asterix_signs", + "attributes": { + "string_with_asterix_signs": true + }, + "result": { + "value": "*a*b*c*d*e*f*", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_asterix_signs=true'. The assigned variation value tests that the string \"*a*b*c*d*e*f*\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_one_asterix_sign", + "attributes": { + "string_with_only_one_asterix_sign": true + }, + "result": { + "value": "*", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_one_asterix_sign=true'. The assigned variation value tests that the string \"*\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_multiple_asterix_signs", + "attributes": { + "string_with_only_multiple_asterix_signs": true + }, + "result": { + "value": "*******", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_multiple_asterix_signs=true'. The assigned variation value tests that the string \"*******\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_single_quotes", + "attributes": { + "string_with_single_quotes": true + }, + "result": { + "value": "'a'b'c'd'e'f'", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_single_quotes=true'. The assigned variation value tests that the string \"'a'b'c'd'e'f'\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_one_single_quote", + "attributes": { + "string_with_only_one_single_quote": true + }, + "result": { + "value": "'", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_one_single_quote=true'. The assigned variation value tests that the string \"'\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_multiple_single_quotes", + "attributes": { + "string_with_only_multiple_single_quotes": true + }, + "result": { + "value": "'''''''", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_multiple_single_quotes=true'. The assigned variation value tests that the string \"'''''''\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_question_marks", + "attributes": { + "string_with_question_marks": true + }, + "result": { + "value": "?a?b?c?d?e?f?", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_question_marks=true'. The assigned variation value tests that the string \"?a?b?c?d?e?f?\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_one_question_mark", + "attributes": { + "string_with_only_one_question_mark": true + }, + "result": { + "value": "?", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_one_question_mark=true'. The assigned variation value tests that the string \"?\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_multiple_question_marks", + "attributes": { + "string_with_only_multiple_question_marks": true + }, + "result": { + "value": "???????", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_multiple_question_marks=true'. The assigned variation value tests that the string \"???????\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_exclamation_marks", + "attributes": { + "string_with_exclamation_marks": true + }, + "result": { + "value": "!a!b!c!d!e!f!", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_exclamation_marks=true'. The assigned variation value tests that the string \"!a!b!c!d!e!f!\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_one_exclamation_mark", + "attributes": { + "string_with_only_one_exclamation_mark": true + }, + "result": { + "value": "!", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_one_exclamation_mark=true'. The assigned variation value tests that the string \"!\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_multiple_exclamation_marks", + "attributes": { + "string_with_only_multiple_exclamation_marks": true + }, + "result": { + "value": "!!!!!!!", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_multiple_exclamation_marks=true'. The assigned variation value tests that the string \"!!!!!!!\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_opening_parentheses", + "attributes": { + "string_with_opening_parentheses": true + }, + "result": { + "value": "(a(b(c(d(e(f(", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_opening_parentheses=true'. The assigned variation value tests that the string \"(a(b(c(d(e(f(\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_one_opening_parenthese", + "attributes": { + "string_with_only_one_opening_parenthese": true + }, + "result": { + "value": "(", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_one_opening_parenthese=true'. The assigned variation value tests that the string \"(\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_multiple_opening_parentheses", + "attributes": { + "string_with_only_multiple_opening_parentheses": true + }, + "result": { + "value": "(((((((", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_multiple_opening_parentheses=true'. The assigned variation value tests that the string \"(((((((\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_closing_parentheses", + "attributes": { + "string_with_closing_parentheses": true + }, + "result": { + "value": ")a)b)c)d)e)f)", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_closing_parentheses=true'. The assigned variation value tests that the string \")a)b)c)d)e)f)\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_one_closing_parenthese", + "attributes": { + "string_with_only_one_closing_parenthese": true + }, + "result": { + "value": ")", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_one_closing_parenthese=true'. The assigned variation value tests that the string \")\" survives the Rust CString→PHP pipeline intact." + }, + { + "flag": "string_flag_with_special_characters", + "variationType": "STRING", + "defaultValue": "default_value", + "targetingKey": "string_with_only_multiple_closing_parentheses", + "attributes": { + "string_with_only_multiple_closing_parentheses": true + }, + "result": { + "value": ")))))))", + "reason": "TARGETING_MATCH" + }, + "rationale": "The flag has a dedicated allocation for attribute 'string_with_only_multiple_closing_parentheses=true'. The assigned variation value tests that the string \")))))))\" survives the Rust CString→PHP pipeline intact." + } +] diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 986e2fb0e9b..bd1225ea971 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -136,6 +136,9 @@ ./Unit/ + + ./FeatureFlags/ +