diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 26a9fb21f9..bd705e8c88 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -16,6 +16,7 @@ compile_rust.sh @Datadog/libdatadog-apm # FFE (Feature Flagging & Experimentation) SDK Team /components-rs/ffe.rs @Datadog/libdatadog-apm @DataDog/feature-flagging-and-experimentation-sdk +/libdatadog @Datadog/libdatadog-apm @DataDog/feature-flagging-and-experimentation-sdk /src/api/FeatureFlags/ @DataDog/feature-flagging-and-experimentation-sdk /tests/api/Unit/FeatureFlags/ @DataDog/feature-flagging-and-experimentation-sdk /tests/ext/ffe/ @DataDog/feature-flagging-and-experimentation-sdk diff --git a/Cargo.lock b/Cargo.lock index 7a0fe64ab9..ce7aba7a65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1475,6 +1475,7 @@ dependencies = [ "libdd-trace-stats", "libdd-trace-utils", "log", + "lru", "paste", "regex", "regex-automata", @@ -2082,6 +2083,11 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hdrhistogram" @@ -3287,6 +3293,15 @@ dependencies = [ "value-bag", ] +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "mach2" version = "0.5.0" diff --git a/components-rs/Cargo.toml b/components-rs/Cargo.toml index 35ba698004..264348ab8a 100644 --- a/components-rs/Cargo.toml +++ b/components-rs/Cargo.toml @@ -33,6 +33,7 @@ serde = "1.0.196" simd-json = "0.14.1" serde_with = "3.6.0" lazy_static = "1.4" +lru = "0.16.4" log = "0.4.20" env_logger = "0.10.1" zwohash = "0.1.2" diff --git a/components-rs/ddtrace.h b/components-rs/ddtrace.h index da49888728..a4410f50ba 100644 --- a/components-rs/ddtrace.h +++ b/components-rs/ddtrace.h @@ -87,6 +87,22 @@ bool ddog_ffe_result_do_log(const struct ddog_FfeResult *result); void ddog_ffe_free_result(struct ddog_FfeResult *result); +void ddog_ffe_set_service_context(const char *service, + const char *env, + const char *version); + +bool ddog_ffe_enqueue_exposure(const char *event_json, + const char *flag_key, + const char *allocation_key, + const char *targeting_key, + const char *variant_key); + +ddog_CharSlice ddog_ffe_flush_exposures(void); + +void ddog_ffe_free_flush_result(ddog_CharSlice slice); + +void ddog_ffe_reset_exposure_state(void); + const char *ddog_normalize_process_tag_value(ddog_CharSlice tag_value); void ddog_free_normalized_tag_value(const char *ptr); diff --git a/components-rs/ffe.rs b/components-rs/ffe.rs index 2f20ad3409..9fee4a176f 100644 --- a/components-rs/ffe.rs +++ b/components-rs/ffe.rs @@ -2,8 +2,11 @@ use datadog_ffe::rules_based::{ self as ffe, AssignmentReason, AssignmentValue, Attribute, Configuration, EvaluationContext, EvaluationError, ExpectedFlagType, Str, UniversalFlagConfig, }; +use libdd_common_ffi::CharSlice; +use lru::LruCache; use std::collections::HashMap; use std::ffi::{c_char, CStr, CString}; +use std::num::NonZeroUsize; use std::sync::{Arc, Mutex}; struct FfeState { @@ -331,3 +334,224 @@ fn assignment_value_to_json(value: &AssignmentValue) -> String { fn string_to_cstring(value: String) -> CString { CString::new(value).unwrap_or_default() } + +struct ServiceContext { + service: String, + env: String, + version: String, +} + +struct ExposureState { + dedup_cache: LruCache<(String, String), (String, String)>, + batch_buffer: Vec, + service_context: Option, +} + +const EXPOSURE_DEDUP_LIMIT: usize = 65_536; +const EXPOSURE_BATCH_LIMIT: usize = 1_000; + +lazy_static::lazy_static! { + static ref EXPOSURE_STATE: Mutex = Mutex::new(ExposureState { + dedup_cache: LruCache::new(NonZeroUsize::new(EXPOSURE_DEDUP_LIMIT).unwrap()), + batch_buffer: Vec::new(), + service_context: None, + }); +} + +#[no_mangle] +pub unsafe extern "C" fn ddog_ffe_set_service_context( + service: *const c_char, + env: *const c_char, + version: *const c_char, +) { + if let Ok(mut state) = EXPOSURE_STATE.lock() { + state.service_context = Some(ServiceContext { + service: optional_cstr_to_string(service), + env: optional_cstr_to_string(env), + version: optional_cstr_to_string(version), + }); + } +} + +#[no_mangle] +pub unsafe extern "C" fn ddog_ffe_enqueue_exposure( + event_json: *const c_char, + flag_key: *const c_char, + allocation_key: *const c_char, + targeting_key: *const c_char, + variant_key: *const c_char, +) -> bool { + if event_json.is_null() || flag_key.is_null() || variant_key.is_null() { + return false; + } + + let event = match required_cstr_to_string(event_json) { + Some(event) => event, + None => return false, + }; + let flag = match required_cstr_to_string(flag_key) { + Some(flag) => flag, + None => return false, + }; + let variant = match required_cstr_to_string(variant_key) { + Some(variant) => variant, + None => return false, + }; + let allocation = optional_cstr_to_string(allocation_key); + let targeting = optional_cstr_to_string(targeting_key); + + let dedup_key = (flag, targeting); + let dedup_value = (allocation, variant); + + if let Ok(mut state) = EXPOSURE_STATE.lock() { + if let Some(cached) = state.dedup_cache.get(&dedup_key) { + if *cached == dedup_value { + return false; + } + } + + state.dedup_cache.put(dedup_key, dedup_value); + if state.batch_buffer.len() >= EXPOSURE_BATCH_LIMIT { + return false; + } + + state.batch_buffer.push(event); + return true; + } + + false +} + +#[no_mangle] +pub extern "C" fn ddog_ffe_flush_exposures() -> CharSlice<'static> { + if let Ok(mut state) = EXPOSURE_STATE.lock() { + if state.batch_buffer.is_empty() { + return CharSlice::default(); + } + + let events = state.batch_buffer.drain(..).collect::>(); + let context = match &state.service_context { + Some(context) => serde_json::json!({ + "service": context.service.as_str(), + "env": context.env.as_str(), + "version": context.version.as_str(), + }), + None => serde_json::json!({ + "service": "", + "env": "", + "version": "", + }), + }; + + let payload = format!( + r#"{{"context":{},"exposures":[{}]}}"#, + context, + events.join(",") + ); + let mut bytes = payload.into_bytes().into_boxed_slice(); + let ptr = bytes.as_mut_ptr(); + let len = bytes.len(); + std::mem::forget(bytes); + + return unsafe { CharSlice::from_raw_parts(ptr as *const c_char, len) }; + } + + CharSlice::default() +} + +#[no_mangle] +pub unsafe extern "C" fn ddog_ffe_free_flush_result(slice: CharSlice<'static>) { + use libdd_common_ffi::slice::AsBytes; + + let bytes = slice.as_bytes(); + let len = bytes.len(); + let ptr = bytes.as_ptr() as *mut u8; + if !ptr.is_null() && len > 0 { + let _ = Box::from_raw(std::slice::from_raw_parts_mut(ptr, len) as *mut [u8]); + } +} + +#[no_mangle] +pub extern "C" fn ddog_ffe_reset_exposure_state() { + if let Ok(mut state) = EXPOSURE_STATE.lock() { + state.dedup_cache.clear(); + state.batch_buffer.clear(); + state.service_context = None; + } +} + +unsafe fn required_cstr_to_string(value: *const c_char) -> Option { + CStr::from_ptr(value) + .to_str() + .ok() + .map(|value| value.to_string()) +} + +unsafe fn optional_cstr_to_string(value: *const c_char) -> String { + if value.is_null() { + return String::new(); + } + + required_cstr_to_string(value).unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use libdd_common_ffi::slice::AsBytes; + + #[test] + fn exposure_flush_drains_buffer_and_keeps_context() { + ddog_ffe_reset_exposure_state(); + + let service = CString::new("svc-flush").unwrap(); + let env = CString::new("test").unwrap(); + let version = CString::new("1.2.3").unwrap(); + let event = CString::new( + r#"{"timestamp":1,"flag":{"key":"demo"},"allocation":{"key":"alloc-a"},"variant":{"key":"on"},"subject":{"id":"user-1","attributes":{}}}"#, + ) + .unwrap(); + let flag = CString::new("demo").unwrap(); + let allocation = CString::new("alloc-a").unwrap(); + let targeting = CString::new("user-1").unwrap(); + let on = CString::new("on").unwrap(); + let off = CString::new("off").unwrap(); + + unsafe { + ddog_ffe_set_service_context(service.as_ptr(), env.as_ptr(), version.as_ptr()); + assert!(ddog_ffe_enqueue_exposure( + event.as_ptr(), + flag.as_ptr(), + allocation.as_ptr(), + targeting.as_ptr(), + on.as_ptr(), + )); + assert!(!ddog_ffe_enqueue_exposure( + event.as_ptr(), + flag.as_ptr(), + allocation.as_ptr(), + targeting.as_ptr(), + on.as_ptr(), + )); + assert!(ddog_ffe_enqueue_exposure( + event.as_ptr(), + flag.as_ptr(), + allocation.as_ptr(), + targeting.as_ptr(), + off.as_ptr(), + )); + } + + let payload = ddog_ffe_flush_exposures(); + assert!(!payload.as_bytes().is_empty()); + let decoded: serde_json::Value = serde_json::from_slice(payload.as_bytes()).unwrap(); + assert_eq!(decoded["context"]["service"], "svc-flush"); + assert_eq!(decoded["context"]["env"], "test"); + assert_eq!(decoded["context"]["version"], "1.2.3"); + assert_eq!(decoded["exposures"].as_array().unwrap().len(), 2); + unsafe { ddog_ffe_free_flush_result(payload) }; + + let empty = ddog_ffe_flush_exposures(); + assert!(empty.as_bytes().is_empty()); + } +} diff --git a/components-rs/sidecar.h b/components-rs/sidecar.h index 4a3aa61741..45fb1f8e84 100644 --- a/components-rs/sidecar.h +++ b/components-rs/sidecar.h @@ -298,6 +298,11 @@ ddog_MaybeError ddog_sidecar_send_debugger_datum(struct ddog_SidecarTransport ** ddog_QueueId queue_id, struct ddog_DebuggerPayload *payload); +ddog_MaybeError ddog_sidecar_send_ffe_exposures(struct ddog_SidecarTransport **transport, + const struct ddog_InstanceId *instance_id, + const ddog_QueueId *queue_id, + ddog_CharSlice payload); + ddog_MaybeError ddog_sidecar_send_debugger_diagnostics(struct ddog_SidecarTransport **transport, const struct ddog_InstanceId *instance_id, ddog_QueueId queue_id, diff --git a/ext/ddtrace.c b/ext/ddtrace.c index 1d231a98bd..3362c0e003 100644 --- a/ext/ddtrace.c +++ b/ext/ddtrace.c @@ -3083,6 +3083,71 @@ PHP_FUNCTION(DDTrace_ffe_evaluate) { ddog_ffe_free_result(result); } +PHP_FUNCTION(DDTrace_ffe_send_exposure) { + char *event_json, *flag_key, *variant_key; + size_t event_json_len, flag_key_len, variant_key_len; + char *allocation_key = NULL; + size_t allocation_key_len = 0; + char *targeting_key = NULL; + size_t targeting_key_len = 0; + + ZEND_PARSE_PARAMETERS_START(5, 5) + Z_PARAM_STRING(event_json, event_json_len) + Z_PARAM_STRING(flag_key, flag_key_len) + Z_PARAM_STRING_OR_NULL(allocation_key, allocation_key_len) + Z_PARAM_STRING_OR_NULL(targeting_key, targeting_key_len) + Z_PARAM_STRING(variant_key, variant_key_len) + ZEND_PARSE_PARAMETERS_END(); + + UNUSED(event_json_len); + UNUSED(flag_key_len); + UNUSED(variant_key_len); + + RETURN_BOOL(ddog_ffe_enqueue_exposure( + event_json, + flag_key, + allocation_key_len > 0 ? allocation_key : NULL, + targeting_key_len > 0 ? targeting_key : NULL, + variant_key)); +} + +PHP_FUNCTION(DDTrace_ffe_flush_exposures) { + ddog_CharSlice payload; + + ZEND_PARSE_PARAMETERS_NONE(); + + payload = ddog_ffe_flush_exposures(); + if (payload.ptr == NULL || payload.len == 0) { + RETURN_NULL(); + } + + RETVAL_STRINGL(payload.ptr, payload.len); + ddog_ffe_free_flush_result(payload); +} + +PHP_FUNCTION(DDTrace_ffe_set_service_context) { + char *service, *env, *version; + size_t service_len, env_len, version_len; + + ZEND_PARSE_PARAMETERS_START(3, 3) + Z_PARAM_STRING(service, service_len) + Z_PARAM_STRING(env, env_len) + Z_PARAM_STRING(version, version_len) + ZEND_PARSE_PARAMETERS_END(); + + UNUSED(service_len); + UNUSED(env_len); + UNUSED(version_len); + + ddog_ffe_set_service_context(service, env, version); +} + +PHP_FUNCTION(DDTrace_ffe_reset_exposure_state) { + ZEND_PARSE_PARAMETERS_NONE(); + + ddog_ffe_reset_exposure_state(); +} + PHP_FUNCTION(dd_trace_send_traces_via_thread) { char *payload = NULL; ddtrace_zpplong_t num_traces = 0; diff --git a/ext/ddtrace.stub.php b/ext/ddtrace.stub.php index a089c9322c..c1da1c442e 100644 --- a/ext/ddtrace.stub.php +++ b/ext/ddtrace.stub.php @@ -887,6 +887,37 @@ function ffe_config_version(): int {} * @internal Used by tests. */ function ffe_load_config(string $json): bool {} + + /** + * Enqueue a serialized FFE exposure event for native deduplication and batched delivery. + * + * @internal Used by the Datadog feature flag client. + */ + function ffe_send_exposure(string $eventJson, string $flagKey, ?string $allocationKey, ?string $targetingKey, string $variantKey): bool {} + + /** + * Drain buffered FFE exposure events and return the native batch payload. + * Used by tests; production delivery is owned by request/module shutdown. + * + * @return string|null Serialized batch payload, or null when the buffer is empty. + * + * @internal Used by tests. + */ + function ffe_flush_exposures(): ?string {} + + /** + * Set service/env/version context for native FFE exposure batch payloads. + * + * @internal Used by the Datadog feature flag client. + */ + function ffe_set_service_context(string $service, string $env, string $version): void {} + + /** + * Reset native FFE exposure state. + * + * @internal Used by tests and fork handling. + */ + function ffe_reset_exposure_state(): void {} } namespace DDTrace\System { diff --git a/ext/ddtrace_arginfo.h b/ext/ddtrace_arginfo.h index d782a8fba2..1424feeb91 100644 --- a/ext/ddtrace_arginfo.h +++ b/ext/ddtrace_arginfo.h @@ -193,6 +193,25 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_ffe_load_config, 0, 1, _ ZEND_ARG_TYPE_INFO(0, json, IS_STRING, 0) ZEND_END_ARG_INFO() +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_ffe_send_exposure, 0, 5, _IS_BOOL, 0) + ZEND_ARG_TYPE_INFO(0, eventJson, IS_STRING, 0) + ZEND_ARG_TYPE_INFO(0, flagKey, IS_STRING, 0) + ZEND_ARG_TYPE_INFO(0, allocationKey, IS_STRING, 1) + ZEND_ARG_TYPE_INFO(0, targetingKey, IS_STRING, 1) + ZEND_ARG_TYPE_INFO(0, variantKey, IS_STRING, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_ffe_flush_exposures, 0, 0, IS_STRING, 1) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_ffe_set_service_context, 0, 3, IS_VOID, 0) + ZEND_ARG_TYPE_INFO(0, service, IS_STRING, 0) + ZEND_ARG_TYPE_INFO(0, env, IS_STRING, 0) + ZEND_ARG_TYPE_INFO(0, version, IS_STRING, 0) +ZEND_END_ARG_INFO() + +#define arginfo_DDTrace_ffe_reset_exposure_state arginfo_DDTrace_flush + ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_System_container_id, 0, 0, IS_STRING, 1) ZEND_END_ARG_INFO() @@ -415,6 +434,10 @@ ZEND_FUNCTION(DDTrace_ffe_evaluate); ZEND_FUNCTION(DDTrace_ffe_has_config); ZEND_FUNCTION(DDTrace_ffe_config_version); ZEND_FUNCTION(DDTrace_ffe_load_config); +ZEND_FUNCTION(DDTrace_ffe_send_exposure); +ZEND_FUNCTION(DDTrace_ffe_flush_exposures); +ZEND_FUNCTION(DDTrace_ffe_set_service_context); +ZEND_FUNCTION(DDTrace_ffe_reset_exposure_state); ZEND_FUNCTION(DDTrace_System_container_id); ZEND_FUNCTION(DDTrace_System_process_tags_base_hash); ZEND_FUNCTION(DDTrace_Config_integration_analytics_enabled); @@ -514,6 +537,10 @@ static const zend_function_entry ext_functions[] = { ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace", "ffe_has_config"), zif_DDTrace_ffe_has_config, arginfo_DDTrace_ffe_has_config, 0, NULL, NULL) ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace", "ffe_config_version"), zif_DDTrace_ffe_config_version, arginfo_DDTrace_ffe_config_version, 0, NULL, NULL) ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace", "ffe_load_config"), zif_DDTrace_ffe_load_config, arginfo_DDTrace_ffe_load_config, 0, NULL, NULL) + ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace", "ffe_send_exposure"), zif_DDTrace_ffe_send_exposure, arginfo_DDTrace_ffe_send_exposure, 0, NULL, NULL) + ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace", "ffe_flush_exposures"), zif_DDTrace_ffe_flush_exposures, arginfo_DDTrace_ffe_flush_exposures, 0, NULL, NULL) + ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace", "ffe_set_service_context"), zif_DDTrace_ffe_set_service_context, arginfo_DDTrace_ffe_set_service_context, 0, NULL, NULL) + ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace", "ffe_reset_exposure_state"), zif_DDTrace_ffe_reset_exposure_state, arginfo_DDTrace_ffe_reset_exposure_state, 0, NULL, NULL) ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace\\System", "container_id"), zif_DDTrace_System_container_id, arginfo_DDTrace_System_container_id, 0, NULL, NULL) ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace\\System", "process_tags_base_hash"), zif_DDTrace_System_process_tags_base_hash, arginfo_DDTrace_System_process_tags_base_hash, 0, NULL, NULL) ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace\\Config", "integration_analytics_enabled"), zif_DDTrace_Config_integration_analytics_enabled, arginfo_DDTrace_Config_integration_analytics_enabled, 0, NULL, NULL) diff --git a/ext/sidecar.c b/ext/sidecar.c index 15675daa56..99e03846a6 100644 --- a/ext/sidecar.c +++ b/ext/sidecar.c @@ -381,7 +381,8 @@ bool ddtrace_sidecar_should_enable(bool *appsec_activation, bool *appsec_config) bool enable_sidecar = ddtrace_sidecar_maybe_enable_appsec(appsec_activation, appsec_config); if (!enable_sidecar) { enable_sidecar = get_global_DD_INSTRUMENTATION_TELEMETRY_ENABLED() || - get_global_DD_TRACE_SIDECAR_TRACE_SENDER(); + get_global_DD_TRACE_SIDECAR_TRACE_SENDER() || + get_global_DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED(); } return enable_sidecar; } @@ -443,6 +444,8 @@ void ddtrace_sidecar_handle_fork(void) { bool appsec_config = false; bool enable_sidecar = ddtrace_sidecar_should_enable(&appsec_activation, &appsec_config); + ddog_ffe_reset_exposure_state(); + if (!enable_sidecar) { return; } @@ -534,7 +537,11 @@ void ddtrace_sidecar_finalize(bool clear_id) { } } +static void dd_flush_ffe_exposures(void); + void ddtrace_sidecar_shutdown(void) { + dd_flush_ffe_exposures(); + ddtrace_sidecar_for_signal = NULL; // In thread mode, drop the main thread's connection before shutting down the @@ -877,9 +884,28 @@ void ddtrace_sidecar_rinit(void) { } void ddtrace_sidecar_rshutdown(void) { + dd_flush_ffe_exposures(); ddog_Vec_Tag_drop(DDTRACE_G(active_global_tags)); } +static void dd_flush_ffe_exposures(void) { + if (!DDTRACE_G(sidecar) || !ddtrace_sidecar_instance_id) { + return; + } + + ddog_CharSlice payload = ddog_ffe_flush_exposures(); + if (payload.ptr == NULL || payload.len == 0) { + return; + } + + ddtrace_ffi_try("Failed forwarding FFE exposures to sidecar", + ddog_sidecar_send_ffe_exposures(&DDTRACE_G(sidecar), + ddtrace_sidecar_instance_id, + &DDTRACE_G(sidecar_queue_id), + payload)); + ddog_ffe_free_flush_result(payload); +} + void ddtrace_sidecar_gshutdown(zend_ddtrace_globals *ddtrace_globals) { // NOTE: do not use DDTRACE_G() in this function; it may be called from the // main thread via ts_free_id() diff --git a/libdatadog b/libdatadog index cea1e44edd..a1afaae423 160000 --- a/libdatadog +++ b/libdatadog @@ -1 +1 @@ -Subproject commit cea1e44edddd9124f75d5095f31026904a1f58d8 +Subproject commit a1afaae4238a4a7faa3fca3a9cddb9a84e5c44e2 diff --git a/src/api/FeatureFlags/Client.php b/src/api/FeatureFlags/Client.php index 11f50b46bb..b248371e56 100644 --- a/src/api/FeatureFlags/Client.php +++ b/src/api/FeatureFlags/Client.php @@ -47,7 +47,7 @@ public static function create( return new self( $evaluator ?: NativeEvaluator::createOrUnavailable(), $warningEmitter ?: new TriggerErrorWarningEmitter(), - $exposureWriter, + $exposureWriter ?: NativeExposureWriter::createOrNoop(), $metricsRecorder ); } diff --git a/src/api/FeatureFlags/NativeEvaluator.php b/src/api/FeatureFlags/NativeEvaluator.php index 0e22c0c8b3..3d3c885e03 100644 --- a/src/api/FeatureFlags/NativeEvaluator.php +++ b/src/api/FeatureFlags/NativeEvaluator.php @@ -4,7 +4,7 @@ final class NativeEvaluator implements Evaluator { - const WARNING_MESSAGE = 'Datadog-backed PHP feature flag evaluation is using the native bridge before Remote Config and exposure delivery are fully enabled.'; + const WARNING_MESSAGE = 'Datadog-backed PHP feature flag evaluation is not fully production-ready yet.'; private $mapper; private $unavailableEvaluator; @@ -107,7 +107,7 @@ private function withProviderState(array $rawResult) 'configVersion' => $configVersion, 'productionRuntime' => false, 'mode' => 'manual_native_bridge', - 'reason' => $hasConfig ? 'remote_config_lifecycle_pending' : 'configuration_missing', + 'reason' => $hasConfig ? 'metrics_delivery_pending' : 'configuration_missing', ); if (isset($rawResult['provider_state']) && is_array($rawResult['provider_state'])) { diff --git a/src/api/FeatureFlags/NativeExposureWriter.php b/src/api/FeatureFlags/NativeExposureWriter.php new file mode 100644 index 0000000000..9cf1930822 --- /dev/null +++ b/src/api/FeatureFlags/NativeExposureWriter.php @@ -0,0 +1,126 @@ +setServiceContext(); + } + + public static function isAvailable() + { + return function_exists('DDTrace\\ffe_send_exposure') + && function_exists('DDTrace\\ffe_set_service_context'); + } + + public static function createOrNoop() + { + return self::isAvailable() ? new self() : new NoopExposureWriter(); + } + + public function write(array $event) + { + if (!self::isAvailable()) { + return; + } + + if (array_key_exists('doLog', $event) && $event['doLog'] === false) { + return; + } + + $flagKey = $this->stringValue($event, 'flagKey'); + if ($flagKey === '') { + return; + } + + $variantKey = $this->stringValue($event, 'variant'); + $allocationKey = $this->nullableStringValue($event, 'allocationKey'); + $targetingKey = $this->nullableStringValue($event, 'targetingKey'); + $eventJson = $this->eventJson($event, $flagKey, $allocationKey, $targetingKey, $variantKey); + if ($eventJson === null) { + return; + } + + \DDTrace\ffe_send_exposure($eventJson, $flagKey, $allocationKey, $targetingKey, $variantKey); + } + + public function flush() + { + // Native exposure delivery is owned by the extension lifecycle. Calling + // DDTrace\ffe_flush_exposures() here would drain the buffer without + // forwarding it to the sidecar. + } + + private function setServiceContext() + { + \DDTrace\ffe_set_service_context( + $this->configValue('DD_SERVICE', 'datadog.service'), + $this->configValue('DD_ENV', 'datadog.env'), + $this->configValue('DD_VERSION', 'datadog.version') + ); + } + + private function eventJson(array $event, $flagKey, $allocationKey, $targetingKey, $variantKey) + { + $attributes = array(); + if (array_key_exists('attributes', $event) && is_array($event['attributes'])) { + foreach ($event['attributes'] as $key => $value) { + if (is_bool($value) || is_int($value) || is_float($value) || is_string($value)) { + $attributes[(string) $key] = $value; + } + } + } + + $json = json_encode(array( + 'timestamp' => (int) floor(microtime(true) * 1000), + 'flag' => array('key' => $flagKey), + 'allocation' => array('key' => $allocationKey === null ? '' : $allocationKey), + 'variant' => array('key' => $variantKey), + 'subject' => array( + 'id' => $targetingKey === null ? '' : $targetingKey, + 'attributes' => empty($attributes) ? new \stdClass() : (object) $attributes, + ), + ), JSON_UNESCAPED_SLASHES); + + return is_string($json) ? $json : null; + } + + private function configValue($envName, $iniName) + { + if (function_exists('dd_trace_env_config')) { + $value = \dd_trace_env_config($envName); + if (is_scalar($value)) { + return (string) $value; + } + } + + $value = ini_get($iniName); + return is_string($value) ? $value : ''; + } + + private function stringValue(array $event, $key) + { + $value = $this->nullableStringValue($event, $key); + return $value === null ? '' : $value; + } + + private function nullableStringValue(array $event, $key) + { + if (!array_key_exists($key, $event) || $event[$key] === null) { + return null; + } + + if (is_scalar($event[$key])) { + return (string) $event[$key]; + } + + $json = json_encode($event[$key]); + return is_string($json) ? $json : null; + } +} diff --git a/tests/ext/ffe/flush_drains_buffer.phpt b/tests/ext/ffe/flush_drains_buffer.phpt new file mode 100644 index 0000000000..ef5d306c88 --- /dev/null +++ b/tests/ext/ffe/flush_drains_buffer.phpt @@ -0,0 +1,49 @@ +--TEST-- +FFE: native exposure flush drains the batch buffer +--SKIPIF-- + +--ENV-- +DD_TRACE_ENABLED=0 +--INI-- +datadog.trace.generate_root_span=0 +--FILE-- + 1713382853716, + 'flag' => ['key' => 'demo-flag'], + 'allocation' => ['key' => 'alloc-a'], + 'variant' => ['key' => 'on'], + 'subject' => ['id' => 'user-1', 'attributes' => new stdClass()], +]); + +var_dump(DDTrace\ffe_send_exposure($event, 'demo-flag', 'alloc-a', 'user-1', 'on')); +var_dump(DDTrace\ffe_send_exposure($event, 'demo-flag', 'alloc-a', 'user-1', 'on')); +var_dump(DDTrace\ffe_send_exposure($event, 'demo-flag', 'alloc-a', 'user-1', 'off')); + +$payload = DDTrace\ffe_flush_exposures(); +var_dump(is_string($payload) && strlen($payload) > 0); + +$decoded = json_decode($payload, true); +var_dump($decoded['context']['service'] === 'svc-flush'); +var_dump($decoded['context']['env'] === 'test'); +var_dump($decoded['context']['version'] === '9.9.9'); +var_dump(count($decoded['exposures'])); +var_dump(DDTrace\ffe_flush_exposures()); + +?> +--EXPECT-- +bool(true) +bool(false) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +int(2) +NULL diff --git a/tests/ext/ffe/fork_resets_dedup.phpt b/tests/ext/ffe/fork_resets_dedup.phpt new file mode 100644 index 0000000000..42c755470c --- /dev/null +++ b/tests/ext/ffe/fork_resets_dedup.phpt @@ -0,0 +1,51 @@ +--TEST-- +FFE: fork handler resets exposure dedup in child +--SKIPIF-- + +--ENV-- +DD_TRACE_ENABLED=0 +--INI-- +datadog.trace.generate_root_span=0 +--FILE-- + 1, + 'flag' => ['key' => 'f'], + 'allocation' => ['key' => 'a'], + 'variant' => ['key' => 'on'], + 'subject' => ['id' => 'u', 'attributes' => new stdClass()], +]); + +$parentFirst = DDTrace\ffe_send_exposure($event, 'f', 'a', 'u', 'on'); +echo 'parent_first=' . ($parentFirst ? 'true' : 'false') . "\n"; + +$pid = pcntl_fork(); +if ($pid === -1) { + die('fork failed'); +} + +if ($pid === 0) { + DDTrace\Internal\handle_fork(); + $child = DDTrace\ffe_send_exposure($event, 'f', 'a', 'u', 'on'); + echo 'child=' . ($child ? 'true' : 'false') . "\n"; + exit(0); +} + +pcntl_wait($status); + +$parentSecond = DDTrace\ffe_send_exposure($event, 'f', 'a', 'u', 'on'); +echo 'parent_second=' . ($parentSecond ? 'true' : 'false') . "\n"; + +?> +--EXPECTF-- +parent_first=true +child=true +parent_second=false