diff --git a/Cargo.lock b/Cargo.lock index 5a4da7a5413..ef0fcab5319 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -803,6 +803,16 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + [[package]] name = "combine" version = "4.6.7" @@ -1256,6 +1266,8 @@ dependencies = [ "static_assertions", "tempfile", "thiserror 2.0.18", + "tokio", + "tokio-util", "tracing", "tracing-core", "tracing-subscriber", @@ -1324,6 +1336,8 @@ dependencies = [ name = "datadog-sidecar-ffi" version = "0.0.1" dependencies = [ + "build_common", + "cc", "datadog-sidecar", "http 1.4.2", "libc", @@ -1341,6 +1355,8 @@ dependencies = [ "libdd-trace-utils", "rmp-serde", "serde_json", + "tempfile", + "tools", "tracing", ] @@ -2787,6 +2803,8 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tower-service", + "webpki-root-certs", + "webpki-roots", "windows-sys 0.52.0", ] @@ -2966,12 +2984,19 @@ dependencies = [ name = "libdd-ffe" version = "2.0.0" dependencies = [ + "base64 0.22.1", "bincode", "chrono", "derive_more", + "fastrand", "faststr", + "flate2", + "futures-util", "http 1.4.2", + "http-body-util", + "httpdate", "httpmock", + "hyper", "libdd-capabilities", "libdd-capabilities-impl", "libdd-common", @@ -2980,6 +3005,7 @@ dependencies = [ "log", "lru", "md5", + "percent-encoding", "prost", "pyo3", "semver", @@ -2990,6 +3016,8 @@ dependencies = [ "strum_macros", "thiserror 2.0.18", "tokio", + "tokio-util", + "url", ] [[package]] @@ -3290,6 +3318,7 @@ name = "libdd-tinybytes" version = "1.1.5" dependencies = [ "libdd-tinybytes", + "libdd-trace-model", "once_cell", "pretty_assertions", "proptest", @@ -3299,6 +3328,10 @@ dependencies = [ "test-case", ] +[[package]] +name = "libdd-trace-model" +version = "0.1.0" + [[package]] name = "libdd-trace-normalization" version = "4.2.0" @@ -3378,6 +3411,7 @@ dependencies = [ name = "libdd-trace-utils" version = "14.0.0" dependencies = [ + "allocator-api2", "anyhow", "base64 0.22.1", "bolero", @@ -3389,6 +3423,7 @@ dependencies = [ "flate2", "futures", "getrandom 0.2.15", + "hashbrown 0.17.1", "hex", "http 1.4.2", "http-body", @@ -3401,6 +3436,7 @@ dependencies = [ "libdd-capabilities-impl", "libdd-common", "libdd-tinybytes", + "libdd-trace-model", "libdd-trace-normalization", "libdd-trace-protobuf", "libdd-trace-utils", @@ -4536,6 +4572,15 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.46" @@ -6073,6 +6118,20 @@ dependencies = [ "tonic", ] +[[package]] +name = "tools" +version = "0.0.1" +dependencies = [ + "anyhow", + "cargo_metadata", + "clap", + "colored", + "libdd-common", + "quick-xml", + "toml", + "wait-timeout", +] + [[package]] name = "tower" version = "0.5.2" @@ -6519,18 +6578,18 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.6" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb" dependencies = [ "rustls-pki-types", ] [[package]] name = "webpki-roots" -version = "1.0.6" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] diff --git a/Cargo.toml b/Cargo.toml index b789d59ef89..c4ee26619d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,14 +50,14 @@ tracing-subscriber = ["tracing"] trigger_time_sample = ["profiling"] [dependencies] -libdd-common = { path = "libdatadog/libdd-common", features = ["cgroup_testing"] } +libdd-common = { path = "libdatadog/libdd-common", features = ["cgroup_testing", "hyper-proxy"] } libdd-common-ffi = { path = "libdatadog/libdd-common-ffi", default-features = false, features = ["std"] } libdd-telemetry = { path = "libdatadog/libdd-telemetry" } libdd-telemetry-ffi = { path = "libdatadog/libdd-telemetry-ffi", default-features = false } libdd-live-debugger = { path = "libdatadog/libdd-live-debugger" } libdd-live-debugger-ffi = { path = "libdatadog/libdd-live-debugger-ffi", default-features = false } libdd-ipc = { path = "libdatadog/libdd-ipc" } -datadog-ffe = { package = "libdd-ffe", path = "libdatadog/libdd-ffe" } +datadog-ffe = { package = "libdd-ffe", path = "libdatadog/libdd-ffe", features = ["cdn"] } libdd-remote-config = { path = "libdatadog/libdd-remote-config" } datadog-sidecar = { path = "libdatadog/datadog-sidecar" } datadog-sidecar-ffi = { path = "libdatadog/datadog-sidecar-ffi" } @@ -87,7 +87,9 @@ tempfile = "3.3" tracing = { version = "0.1", default-features = false, features = ["std", "attributes"] } tracing-core = "0.1.32" tracing-subscriber = { version = "0.3", default-features = false, features = ["std", "fmt", "env-filter", "smallvec"] } -serde_json = "1.0.113" +tokio = { version = "1.36", features = ["macros", "rt", "sync", "time"] } +tokio-util = "0.7.11" +serde_json = { version = "1.0.113", features = ["raw_value"] } regex = "1.10.5" regex-automata = "0.4.5" http = "1.4" @@ -217,6 +219,7 @@ hex = { version = "0.4", default-features = false } http = { version = "1.1", default-features = false } http-body = { version = "1", default-features = false } http-body-util = { version = "0.1", default-features = false } +httpdate = { version = "1.0", default-features = false } # allow(workspace-deps-features): httpmock 0.8.0-alpha.1 does not compile # without the `cookies` feature: its own `readers.rs` calls `req.cookies()` # unconditionally while that method is gated behind `cookies`. Keep it as a @@ -341,6 +344,8 @@ walkdir = { version = "2.4", default-features = false } wasm-bindgen-futures = { version = "0.4", default-features = false } wasm-bindgen-test = { version = "=0.3.50", default-features = false } web-time = { version = "1", default-features = false } +webpki-roots = { version = "1.0.10", default-features = false } +webpki-root-certs = { version = "1.0.10", default-features = false } windows = { version = "0.59.0", default-features = false } windows-sys = { version = "0.52", default-features = false } winver = { version = "1.0.0", default-features = false } diff --git a/components-rs/common.h b/components-rs/common.h index 0c64a22cac1..595c54d671f 100644 --- a/components-rs/common.h +++ b/components-rs/common.h @@ -293,6 +293,18 @@ typedef struct _zend_string _zend_string; #define ddog_DYANMIC_CONFIG_UPDATE_UNMODIFIED (_zend_string*)1 +#define ddog_DEFAULT_POLL_INTERVAL_SECONDS 30 + +#define ddog_MAX_POLL_INTERVAL_SECONDS 3600 + +#define ddog_DEFAULT_REQUEST_TIMEOUT_SECONDS 5 + +#define ddog_MAX_REQUEST_TIMEOUT_SECONDS 300 + +#define ddog_DEFAULT_INITIALIZATION_TIMEOUT_MS 10000 + +#define ddog_MAX_INITIALIZATION_TIMEOUT_MS (int64_t)INT32_MAX + #define ddog_LOG_ONCE (1 << 3) /** @@ -317,6 +329,8 @@ typedef enum ddog_ConfigurationOrigin { DDOG_CONFIGURATION_ORIGIN_UNKNOWN, } ddog_ConfigurationOrigin; +typedef uint8_t ddog_DeliveryState; + typedef enum ddog_DynamicConfigUpdateMode { DDOG_DYNAMIC_CONFIG_UPDATE_MODE_READ, DDOG_DYNAMIC_CONFIG_UPDATE_MODE_READ_WRITE, @@ -329,6 +343,12 @@ typedef enum ddog_EvaluateAt { DDOG_EVALUATE_AT_EXIT, } ddog_EvaluateAt; +typedef enum ddog_FfeConfigurationSource { + DDOG_FFE_CONFIGURATION_SOURCE_DISABLED, + DDOG_FFE_CONFIGURATION_SOURCE_REMOTE_CONFIG, + DDOG_FFE_CONFIGURATION_SOURCE_AGENTLESS, +} ddog_FfeConfigurationSource; + typedef enum ddog_InBodyLocation { DDOG_IN_BODY_LOCATION_NONE, DDOG_IN_BODY_LOCATION_START, @@ -466,6 +486,7 @@ typedef enum ddog_RemoteConfigProduct { DDOG_REMOTE_CONFIG_PRODUCT_LIVE_DEBUGGING, DDOG_REMOTE_CONFIG_PRODUCT_LIVE_DEBUGGING_SYMBOL_DB, DDOG_REMOTE_CONFIG_PRODUCT_DEBUG, + DDOG_REMOTE_CONFIG_PRODUCT_APM_PROFILING, } ddog_RemoteConfigProduct; typedef enum ddog_SpanProbeTarget { @@ -549,6 +570,35 @@ typedef struct ddog_FfeAttribute { bool bool_value; } ddog_FfeAttribute; +typedef struct ddog_FfeRuntimeConfig { + enum ddog_FfeConfigurationSource source; + bool enabled; + bool endpoint_valid; +} ddog_FfeRuntimeConfig; + +/** + * Borrowed process settings. Strings are copied; credentials are never logged. + * The optional cleanup callback must remain valid until shutdown joins workers. + */ +typedef struct ddog_FfeSettingsInput { + bool enabled; + bool enabled_set; + ddog_CharSlice source; + bool source_set; + bool legacy_enabled; + bool legacy_enabled_set; + ddog_CharSlice agentless_base_url; + int64_t poll_interval_seconds; + int64_t request_timeout_seconds; + int64_t initialization_timeout_ms; + ddog_CharSlice site; + ddog_CharSlice api_key; + ddog_CharSlice environment; + bool sidecar_subprocess_enabled; + bool sidecar_self_telemetry; + void (*thread_cleanup)(void*); +} ddog_FfeSettingsInput; + /** * Flags selecting which Remote Config products/capabilities to subscribe to. * @@ -1206,6 +1256,15 @@ typedef enum ddog_DynamicInstrumentationConfigState { DDOG_DYNAMIC_INSTRUMENTATION_CONFIG_STATE_NOT_SET, } ddog_DynamicInstrumentationConfigState; +/** + * Controls whether an EVP client stays fixed to the Agent or opts into local + * discovery and direct-intake fallback. + */ +typedef enum ddog_EvpTransportMode { + DDOG_EVP_TRANSPORT_MODE_AGENT_ONLY, + DDOG_EVP_TRANSPORT_MODE_PREFER_LOCAL_THEN_DIRECT, +} ddog_EvpTransportMode; + typedef struct ddog_AgentRemoteConfigReader ddog_AgentRemoteConfigReader; typedef struct ddog_AgentRemoteConfigWriter_ShmHandle ddog_AgentRemoteConfigWriter_ShmHandle; @@ -1267,12 +1326,29 @@ typedef struct ddog_TracerHeaderTags { bool client_computed_stats; } ddog_TracerHeaderTags; +/** + * Logical tracer identity attached to EVP requests. + * + * Both fields must be non-empty, valid HTTP header values no longer than 256 + * bytes. This identifies the producing SDK, not the sidecar transport. + */ +typedef struct ddog_EvpProducerIdentity { + ddog_CharSlice origin; + ddog_CharSlice version; +} ddog_EvpProducerIdentity; + +/** + * C representation of the context attached to Feature Flags telemetry. + */ typedef struct ddog_FfeTelemetryContext { ddog_CharSlice service; ddog_CharSlice env; ddog_CharSlice version; } ddog_FfeTelemetryContext; +/** + * C representation of a Feature Flags exposure event. + */ typedef struct ddog_FfeExposure { uint64_t timestamp_ms; ddog_CharSlice flag_key; @@ -1301,6 +1377,9 @@ typedef struct ddog_Slice_FfeExposure { */ uintptr_t len; } ddog_Slice_FfeExposure; +/** + * C representation of a Feature Flags evaluation event. + */ typedef struct ddog_FfeFlagEvaluation { int64_t timestamp_ms; ddog_CharSlice flag_key; @@ -1339,6 +1418,9 @@ typedef struct ddog_Slice_FfeFlagEvaluation { */ uintptr_t len; } ddog_Slice_FfeFlagEvaluation; +/** + * C representation of a Feature Flags evaluation metric. + */ typedef struct ddog_FfeEvaluationMetric { ddog_CharSlice flag_key; ddog_CharSlice variant; diff --git a/components-rs/datadog.h b/components-rs/datadog.h index be764525f34..7bbd0de93ee 100644 --- a/components-rs/datadog.h +++ b/components-rs/datadog.h @@ -8,6 +8,14 @@ struct _zend_string; #include "common.h" #include "telemetry.h" #include "sidecar.h" +enum ddog_DeliveryState { + DDOG_DELIVERY_STATE_INACTIVE, + DDOG_DELIVERY_STATE_STARTING, + DDOG_DELIVERY_STATE_READY, + DDOG_DELIVERY_STATE_STALE, + DDOG_DELIVERY_STATE_STOPPED, + DDOG_DELIVERY_STATE_PERMANENT_ERROR, +}; extern void (*ddog_log_callback)(ddog_CharSlice); @@ -132,6 +140,8 @@ void ddog_add_zstr_span_meta_struct_CharSlice(ddog_SpanBytes *ptr, struct _zend_string *key, ddog_CharSlice val); +ddog_DeliveryState ddog_ffe_delivery_state(void); + bool ddog_ffe_load_config(ddog_CharSlice json); bool ddog_ffe_has_config(void); @@ -144,6 +154,33 @@ struct ddog_FfeResult ddog_ffe_evaluate(ddog_CharSlice flag_key, const struct ddog_FfeAttribute *attributes, uintptr_t attributes_count); +/** + * Configure once during PHP's ordinary process setup, before sidecar startup. + * This does not start network polling; the first evaluation activates it. + */ +struct ddog_FfeRuntimeConfig ddog_ffe_configure(const struct ddog_FfeSettingsInput *input); + +struct ddog_FfeRuntimeConfig ddog_ffe_runtime_config(void); + +/** + * Static, redacted configuration failure. Empty when initialization can retry. + */ +ddog_CharSlice ddog_ffe_initialization_error_reason(void); + +ddog_CharSlice ddog_ffe_initialization_error_message(void); + +/** + * First-use compatibility path for PHP providers without an initialize API. + * Concurrent first callers share one deadline; later calls never renew it. + */ +void ddog_ffe_ensure_initialized(void); + +void ddog_ffe_shutdown(void); + +void ddog_ffe_prepare_for_fork(void); + +void ddog_ffe_resume_after_fork(bool child); + bool ddog_shall_log(enum ddog_Log category); void ddog_set_error_log_level(bool once); diff --git a/components-rs/ffe.rs b/components-rs/ffe.rs index 4964bc7b0b9..3f45f4c1120 100644 --- a/components-rs/ffe.rs +++ b/components-rs/ffe.rs @@ -1,4 +1,5 @@ use crate::bytes::MaybeOwnedZendString; +use arc_swap::ArcSwapOption; use datadog_ffe::rules_based::{ self as ffe, AssignmentReason, AssignmentValue, Attribute, Configuration, EvaluationContext, EvaluationError, ExpectedFlagType, Str, UniversalFlagConfig, @@ -6,34 +7,149 @@ use datadog_ffe::rules_based::{ use libdd_common_ffi::slice::{AsBytes, CharSlice}; use std::cell::RefCell; use std::collections::HashMap; -use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering}; +use std::sync::{Arc, LazyLock}; + +#[allow(dead_code)] +pub(crate) mod agentless; +pub(crate) mod lifecycle; +pub(crate) mod remote_config; +#[allow(dead_code)] +pub(crate) mod settings; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u8)] +#[allow(dead_code)] +pub enum DeliveryState { + Inactive, + Starting, + Ready, + Stale, + Stopped, + PermanentError, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ConfigurationTransition { + Ready, + Recovered, + Changed, + Stale, + Unchanged, +} -struct FfeState { - config: Option, +static FFE_CONFIG: LazyLock> = + LazyLock::new(|| ArcSwapOption::new(None)); +static FFE_VERSION: AtomicU64 = AtomicU64::new(0); +static DELIVERY_STATE: AtomicU8 = AtomicU8::new(DeliveryState::Inactive as u8); +static AGENTLESS_CONFIG: AtomicBool = AtomicBool::new(false); + +// RC readers belong to PHP threads and can target different services or +// environments. Only the process-owned CDN worker publishes shared state. +#[derive(Default)] +struct RemoteConfigState { + config: Option>, version: u64, } thread_local! { - static FFE_STATE: RefCell = const { RefCell::new(FfeState { - config: None, - version: 0, - }) }; + static REMOTE_CONFIG: RefCell = RefCell::new(RemoteConfigState::default()); +} + +pub(super) fn use_agentless_config(enabled: bool) { + AGENTLESS_CONFIG.store(enabled, Ordering::Release); } -pub fn store_config(config: Configuration) { - FFE_STATE.with(|state| { - let mut state = state.borrow_mut(); - state.config = Some(config); - state.version = state.version.wrapping_add(1); - }); +fn agentless_config() -> bool { + AGENTLESS_CONFIG.load(Ordering::Acquire) } -pub fn clear_config() { - FFE_STATE.with(|state| { - let mut state = state.borrow_mut(); - state.config = None; - state.version = state.version.wrapping_add(1); - }); +fn current_config() -> Option> { + if agentless_config() { + FFE_CONFIG.load_full() + } else { + REMOTE_CONFIG.with(|state| state.borrow().config.clone()) + } +} + +#[allow(dead_code)] +pub(crate) fn delivery_state() -> DeliveryState { + if !agentless_config() { + return if ddog_ffe_has_config() { + DeliveryState::Ready + } else { + DeliveryState::Inactive + }; + } + match DELIVERY_STATE.load(Ordering::Acquire) { + value if value == DeliveryState::Starting as u8 => DeliveryState::Starting, + value if value == DeliveryState::Ready as u8 => DeliveryState::Ready, + value if value == DeliveryState::Stale as u8 => DeliveryState::Stale, + value if value == DeliveryState::Stopped as u8 => DeliveryState::Stopped, + value if value == DeliveryState::PermanentError as u8 => DeliveryState::PermanentError, + _ => DeliveryState::Inactive, + } +} + +#[no_mangle] +pub extern "C" fn ddog_ffe_delivery_state() -> DeliveryState { + delivery_state() +} + +#[allow(dead_code)] +pub(crate) fn set_delivery_state(state: DeliveryState) { + DELIVERY_STATE.store(state as u8, Ordering::Release); +} + +pub(crate) fn store_config(config: Configuration) -> ConfigurationTransition { + if !agentless_config() { + return REMOTE_CONFIG.with(|state| { + let mut state = state.borrow_mut(); + let transition = if state.config.is_some() { + ConfigurationTransition::Changed + } else { + ConfigurationTransition::Ready + }; + state.config = Some(Arc::new(config)); + state.version = state.version.wrapping_add(1); + transition + }); + } + FFE_CONFIG.store(Some(Arc::new(config))); + FFE_VERSION.fetch_add(1, Ordering::AcqRel); + lifecycle::notify_configuration(); + + let previous = DELIVERY_STATE.swap(DeliveryState::Ready as u8, Ordering::AcqRel); + if previous == DeliveryState::Ready as u8 { + ConfigurationTransition::Changed + } else if previous == DeliveryState::Stale as u8 { + ConfigurationTransition::Recovered + } else { + ConfigurationTransition::Ready + } +} + +pub(crate) fn clear_config() -> ConfigurationTransition { + if !agentless_config() { + return REMOTE_CONFIG.with(|state| { + let mut state = state.borrow_mut(); + let had_config = state.config.take().is_some(); + state.version = state.version.wrapping_add(1); + if had_config { + ConfigurationTransition::Stale + } else { + ConfigurationTransition::Unchanged + } + }); + } + FFE_CONFIG.store(None); + FFE_VERSION.fetch_add(1, Ordering::AcqRel); + let previous = DELIVERY_STATE.swap(DeliveryState::Stale as u8, Ordering::AcqRel); + if previous == DeliveryState::Stale as u8 { + ConfigurationTransition::Unchanged + } else { + ConfigurationTransition::Stale + } } #[no_mangle] @@ -58,12 +174,16 @@ pub extern "C" fn ddog_ffe_load_config(json: CharSlice<'_>) -> bool { #[no_mangle] pub extern "C" fn ddog_ffe_has_config() -> bool { - FFE_STATE.with(|state| state.borrow().config.is_some()) + current_config().is_some() } #[no_mangle] pub extern "C" fn ddog_ffe_config_version() -> u64 { - FFE_STATE.with(|state| state.borrow().version) + if agentless_config() { + FFE_VERSION.load(Ordering::Acquire) + } else { + REMOTE_CONFIG.with(|state| state.borrow().version) + } } const REASON_STATIC: i32 = 0; @@ -156,18 +276,16 @@ pub extern "C" fn ddog_ffe_evaluate( let attributes = parse_attributes(attributes, attributes_count); let context = EvaluationContext::new(targeting_key, Arc::new(attributes)); - FFE_STATE.with(|state| { - let state = state.borrow(); - let assignment = ffe::get_assignment( - state.config.as_ref(), - flag_key, - &context, - expected_type, - ffe::now(), - ); + let config = current_config(); + let assignment = ffe::get_assignment( + config.as_deref(), + flag_key, + &context, + expected_type, + ffe::now(), + ); - result_from_assignment(assignment) - }) + result_from_assignment(assignment) } fn parse_attributes( @@ -298,9 +416,10 @@ mod tests { use std::mem; use std::ptr; use std::ptr::NonNull; - use std::sync::Once; + use std::sync::{Mutex, Once}; static INIT_ZEND_STRING_FUNCTIONS: Once = Once::new(); + pub(super) static FFE_TEST_LOCK: Mutex<()> = Mutex::new(()); fn setup_zend_string_functions() { INIT_ZEND_STRING_FUNCTIONS.call_once(|| unsafe { @@ -406,6 +525,7 @@ mod tests { #[test] fn empty_targeting_key_is_not_dropped() { + let _guard = FFE_TEST_LOCK.lock().expect("FFE test lock"); setup_zend_string_functions(); clear_config(); let config = @@ -434,7 +554,33 @@ mod tests { } #[test] - fn configuration_state_is_thread_local() { + fn remote_config_remains_thread_local() { + let _guard = FFE_TEST_LOCK.lock().expect("FFE test lock"); + clear_config(); + assert!(load_empty_config()); + let version = ddog_ffe_config_version(); + std::thread::spawn(|| { + assert!( + !ddog_ffe_has_config(), + "another RC thread must not inherit configuration" + ); + assert!(load_empty_config()); + clear_config(); + }) + .join() + .expect("RC thread should remain isolated"); + assert!( + ddog_ffe_has_config(), + "another RC thread must not clear this configuration" + ); + assert_eq!(ddog_ffe_config_version(), version); + clear_config(); + } + + #[test] + fn configuration_state_is_process_wide() { + let _guard = FFE_TEST_LOCK.lock().expect("FFE test lock"); + use_agentless_config(true); clear_config(); let empty_version = ddog_ffe_config_version(); assert!(!ddog_ffe_has_config()); @@ -444,19 +590,55 @@ mod tests { let loaded_version = ddog_ffe_config_version(); assert_eq!(loaded_version, empty_version.wrapping_add(1)); - let child = std::thread::spawn(|| { - assert!(!ddog_ffe_has_config()); - assert_eq!(ddog_ffe_config_version(), 0); + let child = std::thread::spawn(move || { + assert!(ddog_ffe_has_config()); + assert_eq!(ddog_ffe_config_version(), loaded_version); assert!(load_empty_config()); assert!(ddog_ffe_has_config()); - assert_eq!(ddog_ffe_config_version(), 1); + assert_eq!(ddog_ffe_config_version(), loaded_version.wrapping_add(1)); }); child.join().expect("child thread should not panic"); assert!(ddog_ffe_has_config()); - assert_eq!(ddog_ffe_config_version(), loaded_version); + assert_eq!(ddog_ffe_config_version(), loaded_version.wrapping_add(1)); + clear_config(); + use_agentless_config(false); + } + + #[test] + fn configuration_transitions_track_ready_stale_and_recovery() { + let _guard = FFE_TEST_LOCK.lock().expect("FFE test lock"); + use_agentless_config(true); + set_delivery_state(DeliveryState::Inactive); + clear_config(); + assert_eq!(delivery_state(), DeliveryState::Stale); + assert_eq!(clear_config(), ConfigurationTransition::Unchanged); + + let json = UniversalFlagConfig::from_json(EMPTY_CONFIG.as_bytes().to_vec()) + .expect("test configuration"); + assert_eq!( + store_config(Configuration::from_server_response(json)), + ConfigurationTransition::Recovered + ); + assert_eq!(delivery_state(), DeliveryState::Ready); + + let json = UniversalFlagConfig::from_json(EMPTY_CONFIG.as_bytes().to_vec()) + .expect("test configuration"); + assert_eq!( + store_config(Configuration::from_server_response(json)), + ConfigurationTransition::Changed + ); + + set_delivery_state(DeliveryState::Starting); + let json = UniversalFlagConfig::from_json(EMPTY_CONFIG.as_bytes().to_vec()) + .expect("test configuration"); + assert_eq!( + store_config(Configuration::from_server_response(json)), + ConfigurationTransition::Ready + ); clear_config(); + use_agentless_config(false); } } diff --git a/components-rs/ffe/agentless.rs b/components-rs/ffe/agentless.rs new file mode 100644 index 00000000000..a94d592c157 --- /dev/null +++ b/components-rs/ffe/agentless.rs @@ -0,0 +1,314 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use super::remote_config::ConfigurationUpdate; +use super::settings::AgentlessEndpoint; +use super::{ddog_ffe_has_config, set_delivery_state, DeliveryState}; +use crate::log::{self, Log}; +use datadog_ffe::cdn::CdnConfig; +use datadog_ffe::rules_based::UniversalFlagConfig; +use datadog_sidecar::service::blocking::{ping, subscribe_cdn_configuration}; +use datadog_sidecar::shm_remote_config::{ + RemoteConfigDeliveryState, RemoteConfigManager, RemoteConfigUpdate, +}; +use libdd_remote_config::fetch::ConfigInvariants; +use libdd_remote_config::{default_registry, RemoteConfigProduct}; +use std::ffi::c_void; +use std::sync::Arc; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; +use tokio_util::sync::CancellationToken; + +#[derive(Clone)] +pub(crate) struct AgentlessWorkerConfig { + subscription: CdnConfig, + pub(crate) sidecar_self_telemetry: bool, + pub(crate) thread_cleanup: Option, +} + +impl AgentlessWorkerConfig { + pub(crate) fn new( + endpoint: AgentlessEndpoint, + poll_interval: Duration, + request_timeout: Duration, + ) -> Self { + Self { + subscription: CdnConfig { + endpoint, + poll_interval, + request_timeout, + language: "php".to_owned(), + version: include_str!("../../VERSION").trim().to_owned(), + }, + thread_cleanup: None, + sidecar_self_telemetry: false, + } + } +} + +// PHP intercepts Rust TLS destructors to permit extension unloading. Threads +// without PHP GSHUTDOWN must explicitly drain that registry after their runtime +// and logging guards are dropped, including on an early return or unwind. +struct ThreadCleanup(Option); + +impl Drop for ThreadCleanup { + fn drop(&mut self) { + if let Some(cleanup) = self.0 { + cleanup(std::ptr::null_mut()); + } + } +} + +struct RunningWorker { + cancellation: CancellationToken, + thread: JoinHandle<()>, +} + +#[derive(Debug, Eq, PartialEq)] +pub(crate) enum WorkerStartError { + ThreadUnavailable, +} + +/// Owns a local IPC/shared-memory reader. CDN HTTP runs in the sidecar subprocess. +pub(crate) struct AgentlessWorker { + config: AgentlessWorkerConfig, + running: Option, + restart_after_fork: bool, + permanently_stopped: bool, +} + +impl AgentlessWorker { + pub(crate) fn new(config: AgentlessWorkerConfig) -> Self { + Self { + config, + running: None, + restart_after_fork: false, + permanently_stopped: false, + } + } + + pub(crate) fn start(&mut self) -> Result { + // A reader that exits unexpectedly must not suppress later retries. + if self + .running + .as_ref() + .is_some_and(|worker| worker.thread.is_finished()) + { + self.stop_running(); + } + if self.running.is_some() || self.permanently_stopped { + return Ok(false); + } + + let cancellation = CancellationToken::new(); + let thread_cancellation = cancellation.clone(); + let config = self.config.clone(); + let log_dispatch = tracing::dispatcher::get_default(Clone::clone); + let thread = std::thread::Builder::new() + .name("ddtrace-ffe-agentless".to_owned()) + .spawn(move || { + let _thread_cleanup = ThreadCleanup(config.thread_cleanup); + let _log_guard = tracing::dispatcher::set_default(&log_dispatch); + let mut warned = false; + while !thread_cancellation.is_cancelled() { + if read_configuration(&config, &thread_cancellation).is_err() { + set_delivery_state(if ddog_ffe_has_config() { + DeliveryState::Stale + } else { + DeliveryState::Starting + }); + if !warned { + log::log(Log::Warn, "Feature Flags CDN sidecar is unavailable; retaining the last configuration"); + warned = true; + } + } + if !thread_cancellation.is_cancelled() { + std::thread::park_timeout(Duration::from_secs(1)); + } + } + }) + .map_err(|_| WorkerStartError::ThreadUnavailable)?; + + self.running = Some(RunningWorker { + cancellation, + thread, + }); + Ok(true) + } + + pub(crate) fn shutdown(&mut self) { + self.permanently_stopped = true; + self.stop_running(); + } + + pub(crate) fn prepare_for_fork(&mut self) { + self.restart_after_fork = self.running.is_some() && !self.permanently_stopped; + self.stop_running(); + } + + pub(crate) fn resume_after_fork_parent(&mut self) -> Result<(), WorkerStartError> { + self.restart_after_fork() + } + + pub(crate) fn reset_after_fork_child(&mut self) -> Result<(), WorkerStartError> { + // `prepare_for_fork` joined the only worker before the process split, + // so no synchronization primitive can be inherited while held. + self.restart_after_fork() + } + + fn restart_after_fork(&mut self) -> Result<(), WorkerStartError> { + if self.restart_after_fork && !self.permanently_stopped { + self.start()?; + self.restart_after_fork = false; + } + Ok(()) + } + + fn stop_running(&mut self) { + if let Some(running) = self.running.take() { + running.cancellation.cancel(); + running.thread.thread().unpark(); + let _ = running.thread.join(); + } + } +} + +/// Establish an independently owned connection so request/thread teardown does +/// not invalidate the process-wide CDN subscription. Always use subprocess +/// startup: a connection failure must never move HTTP into the PHP process. +fn read_configuration( + config: &AgentlessWorkerConfig, + cancellation: &CancellationToken, +) -> anyhow::Result<()> { + if cancellation.is_cancelled() { + return Ok(()); + } + let mut sidecar = datadog_sidecar::config::FromEnv::config(); + sidecar.self_telemetry = config.sidecar_self_telemetry; + let mut transport = crate::sidecar::run_sidecar(sidecar)?; + transport.set_read_timeout(Some(Duration::from_millis(200)))?; + transport.set_write_timeout(Some(Duration::from_millis(200)))?; + let handle = subscribe_cdn_configuration(&mut transport, Some(config.subscription.clone()))? + .ok_or_else(|| anyhow::anyhow!("CDN subscription unavailable"))?; + let mut manager = RemoteConfigManager::new_with_registry( + ConfigInvariants { + language: config.subscription.language.clone(), + tracer_version: config.subscription.version.clone(), + // This manager has only the explicitly attached local source: it never + // tracks an agent target or makes requests to this endpoint. + endpoint: libdd_common::Endpoint::default(), + agentless: None, + }, + Arc::new(default_registry().with::()?), + ); + manager.set_local_configuration(Some(handle))?; + let mut heartbeat = Instant::now(); + while !cancellation.is_cancelled() { + apply_configuration_updates(&mut manager)?; + if let Some(state) = manager.local_configuration_state() { + set_delivery_state(match state { + RemoteConfigDeliveryState::Ready if ddog_ffe_has_config() => DeliveryState::Ready, + RemoteConfigDeliveryState::Stale | RemoteConfigDeliveryState::Stopped + if ddog_ffe_has_config() => + { + DeliveryState::Stale + } + _ => DeliveryState::Starting, + }); + } + // Detect a dead sidecar even when the last shared-memory snapshot is + // unchanged. A fresh connection obtains a fresh subscription and name. + if heartbeat.elapsed() >= Duration::from_secs(1) { + ping(&mut transport)?; + heartbeat = Instant::now(); + } + manager.wait_for_change(Duration::from_millis(50)); + } + Ok(()) +} + +fn apply_configuration_updates(manager: &mut RemoteConfigManager) -> anyhow::Result<()> { + let mut update = ConfigurationUpdate::default(); + loop { + match manager.fetch_update() { + RemoteConfigUpdate::None => break, + RemoteConfigUpdate::Add { value, .. } + if value.path.product() == RemoteConfigProduct::FfeFlags => + { + let data = value + .data + .ok_or_else(|| anyhow::anyhow!("FFE parser unavailable"))?; + let flags = data + .downcast::() + .ok_or_else(|| anyhow::anyhow!("Unexpected FFE configuration type"))?; + update.add(flags)?; + } + RemoteConfigUpdate::Remove(path) if path.product() == RemoteConfigProduct::FfeFlags => { + update.remove(); + } + _ => {} + } + } + update.apply(); + Ok(()) +} + +impl Drop for AgentlessWorker { + fn drop(&mut self) { + self.stop_running(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[test] + fn worker_cleanup_runs_on_its_thread_after_reader_shutdown() { + static CLEANUPS: AtomicUsize = AtomicUsize::new(0); + extern "C" fn cleanup(_: *mut c_void) { + // Runtime and tracing guards must have exited before TLS is drained. + assert!(tokio::runtime::Handle::try_current().is_err()); + assert_eq!(std::thread::current().name(), Some("ddtrace-ffe-agentless")); + CLEANUPS.fetch_add(1, Ordering::SeqCst); + } + let endpoint = AgentlessEndpoint::build("http://127.0.0.1:1/config", "", "", "").unwrap(); + let mut config = + AgentlessWorkerConfig::new(endpoint, Duration::from_secs(30), Duration::from_secs(30)); + config.thread_cleanup = Some(cleanup); + let mut worker = AgentlessWorker::new(config); + worker.start().unwrap(); + worker.prepare_for_fork(); + assert_eq!(CLEANUPS.load(Ordering::SeqCst), 1); + worker.resume_after_fork_parent().unwrap(); + worker.shutdown(); + assert_eq!(CLEANUPS.load(Ordering::SeqCst), 2); + worker.shutdown(); + assert_eq!(CLEANUPS.load(Ordering::SeqCst), 2); + } + + #[test] + fn a_finished_worker_can_restart_but_shutdown_is_permanent() { + let endpoint = AgentlessEndpoint::build("http://127.0.0.1:1/config", "", "", "").unwrap(); + let mut worker = AgentlessWorker::new(AgentlessWorkerConfig::new( + endpoint, + Duration::from_secs(30), + Duration::from_secs(30), + )); + // Model a reader returning unexpectedly. Its handle must not suppress retries. + let thread = std::thread::spawn(|| {}); + while !thread.is_finished() { + std::thread::yield_now(); + } + worker.running = Some(RunningWorker { + cancellation: CancellationToken::new(), + thread, + }); + assert_eq!(worker.start(), Ok(true)); + assert_eq!(worker.start(), Ok(false)); + worker.shutdown(); + assert!(worker.running.is_none()); + assert_eq!(worker.start(), Ok(false)); + } +} diff --git a/components-rs/ffe/lifecycle.rs b/components-rs/ffe/lifecycle.rs new file mode 100644 index 00000000000..0b9aceec129 --- /dev/null +++ b/components-rs/ffe/lifecycle.rs @@ -0,0 +1,408 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use super::agentless::{AgentlessWorker, AgentlessWorkerConfig}; +use super::settings::{ + ConfigurationSource, DisableReason, EndpointError, FeatureFlagsSettings, SettingsInput, + SettingsIssue, SourceInput, +}; +use super::{ddog_ffe_has_config, set_delivery_state, DeliveryState}; +use crate::log::{self, Log}; +use arc_swap::ArcSwap; +use libdd_common_ffi::slice::{AsBytes, CharSlice}; +use std::cell::RefCell; +use std::ffi::c_void; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Condvar, LazyLock, Mutex, MutexGuard}; +use std::time::Instant; + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum FfeConfigurationSource { + #[default] + Disabled, + RemoteConfig, + Agentless, +} + +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct FfeRuntimeConfig { + pub source: FfeConfigurationSource, + pub enabled: bool, + pub endpoint_valid: bool, +} + +/// Borrowed process settings. Strings are copied; credentials are never logged. +/// The optional cleanup callback must remain valid until shutdown joins workers. +#[repr(C)] +pub struct FfeSettingsInput<'a> { + pub enabled: bool, + pub enabled_set: bool, + pub source: CharSlice<'a>, + pub source_set: bool, + pub legacy_enabled: bool, + pub legacy_enabled_set: bool, + pub agentless_base_url: CharSlice<'a>, + pub poll_interval_seconds: i64, + pub request_timeout_seconds: i64, + pub initialization_timeout_ms: i64, + pub site: CharSlice<'a>, + pub api_key: CharSlice<'a>, + pub environment: CharSlice<'a>, + pub sidecar_subprocess_enabled: bool, + pub sidecar_self_telemetry: bool, + pub thread_cleanup: Option, +} + +#[derive(Default)] +struct Lifecycle { + settings: Option, + config: FfeRuntimeConfig, + worker: Option, + initialization_deadline: Option, + stopped: bool, + failure: Option, + diagnostics_reported: bool, +} + +#[derive(Clone, Copy)] +enum InitializationFailure { + InvalidSource, + Endpoint(EndpointError), + SidecarMode, +} + +impl InitializationFailure { + fn reason(self) -> &'static str { + match self { + Self::InvalidSource => "invalid_source", + Self::Endpoint(EndpointError::MissingApiKey) => "missing_api_key", + Self::Endpoint(EndpointError::InvalidSite) => "invalid_site", + Self::Endpoint(_) => "invalid_endpoint", + Self::SidecarMode => "unsupported_sidecar_mode", + } + } + + fn message(self) -> &'static str { + match self { + Self::InvalidSource => "Feature Flags configuration source is invalid; use agentless, remote_config or offline", + Self::Endpoint(EndpointError::MissingApiKey) => "Feature Flags CDN configuration requires DD_API_KEY for the managed endpoint", + Self::Endpoint(EndpointError::InvalidSite) => "Feature Flags CDN configuration requires a valid DD_SITE hostname", + Self::Endpoint(_) => "Feature Flags CDN base URL must be an absolute HTTP or HTTPS URL without whitespace or a fragment", + Self::SidecarMode => "Feature Flags CDN configuration requires sidecar subprocess mode", + } + } +} + +impl Lifecycle { + fn report_diagnostics(&mut self) { + if self.diagnostics_reported { + return; + } + self.diagnostics_reported = true; + if let Some(settings) = &self.settings { + if settings.resolution.legacy_key_decided { + log::log(Log::Warn, "DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED is deprecated; use DD_FEATURE_FLAGS_ENABLED and DD_FEATURE_FLAGS_CONFIGURATION_SOURCE"); + } + for issue in &settings.issues { + let message = match issue { + SettingsIssue::PollInterval => "Invalid DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS; using 30 seconds (valid range: 1-3600)", + SettingsIssue::RequestTimeout => "Invalid DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS; using 5 seconds (valid range: 1-300)", + SettingsIssue::InitializationTimeout => "Invalid DD_EXPERIMENTAL_FLAGGING_PROVIDER_INITIALIZATION_TIMEOUT_MS; using 10000 milliseconds (valid range: 1-2147483647)", + }; + log::log(Log::Warn, message); + } + } + if let Some(failure) = self.failure { + log::log(Log::Warn, failure.message()); + } + } +} + +static LIFECYCLE: LazyLock> = LazyLock::new(|| Mutex::new(Lifecycle::default())); +type ConfigurationSignal = (Mutex<()>, Condvar); +static CONFIGURATION_CHANGED: LazyLock> = + LazyLock::new(|| ArcSwap::from_pointee((Mutex::new(()), Condvar::new()))); +static INITIALIZATION_STOPPED: AtomicBool = AtomicBool::new(false); + +thread_local! { + // Keep lifecycle operations excluded across PHP's fork boundary. The + // forking thread releases this guard in both parent and child callbacks. + static FORK_GUARD: RefCell>> = const { RefCell::new(None) }; +} + +fn lifecycle() -> MutexGuard<'static, Lifecycle> { + LIFECYCLE.lock().unwrap_or_else(|error| error.into_inner()) +} + +pub(super) fn notify_configuration() { + let signal = CONFIGURATION_CHANGED.load(); + let _guard = signal.0.lock().unwrap_or_else(|error| error.into_inner()); + signal.1.notify_all(); +} + +/// Configure once during PHP's ordinary process setup, before sidecar startup. +/// This does not start network polling; the first evaluation activates it. +#[no_mangle] +pub extern "C" fn ddog_ffe_configure(input: &FfeSettingsInput<'_>) -> FfeRuntimeConfig { + let mut state = lifecycle(); + if state.settings.is_some() || state.stopped { + return state.config; + } + let settings = FeatureFlagsSettings::resolve(SettingsInput { + source: SourceInput { + enabled: input.enabled, + enabled_set: input.enabled_set, + source: input.source.try_to_utf8().unwrap_or("invalid"), + source_set: input.source_set, + legacy_enabled: input.legacy_enabled, + legacy_enabled_set: input.legacy_enabled_set, + }, + agentless_base_url: input.agentless_base_url.try_to_utf8().unwrap_or("invalid"), + poll_interval_seconds: input.poll_interval_seconds, + request_timeout_seconds: input.request_timeout_seconds, + initialization_timeout_ms: input.initialization_timeout_ms, + site: input.site.try_to_utf8().unwrap_or("invalid"), + api_key: input.api_key.try_to_utf8().unwrap_or(""), + environment: input.environment.try_to_utf8().unwrap_or(""), + }); + let source = match settings.resolution.source { + ConfigurationSource::RemoteConfig => FfeConfigurationSource::RemoteConfig, + ConfigurationSource::Agentless => FfeConfigurationSource::Agentless, + ConfigurationSource::Offline | ConfigurationSource::Invalid => { + FfeConfigurationSource::Disabled + } + }; + super::use_agentless_config(source == FfeConfigurationSource::Agentless); + if settings.resolution.disable_reason == Some(DisableReason::InvalidSource) { + state.failure = Some(InitializationFailure::InvalidSource); + } + let mut endpoint_valid = false; + if settings.resolution.enabled && source == FfeConfigurationSource::Agentless { + match settings.agentless_endpoint() { + Ok(endpoint) => { + endpoint_valid = true; + let mut worker_config = AgentlessWorkerConfig::new( + endpoint, + settings.poll_interval, + settings.request_timeout, + ); + worker_config.thread_cleanup = input.thread_cleanup; + worker_config.sidecar_self_telemetry = input.sidecar_self_telemetry; + if input.sidecar_subprocess_enabled { + state.worker = Some(AgentlessWorker::new(worker_config)); + } else { + state.failure = Some(InitializationFailure::SidecarMode); + set_delivery_state(DeliveryState::PermanentError); + } + } + Err(error) => { + state.failure = Some(InitializationFailure::Endpoint(error)); + set_delivery_state(DeliveryState::PermanentError); + } + } + } + state.config = FfeRuntimeConfig { + source, + enabled: settings.resolution.enabled, + endpoint_valid, + }; + state.settings = Some(settings); + state.config +} + +#[no_mangle] +pub extern "C" fn ddog_ffe_runtime_config() -> FfeRuntimeConfig { + lifecycle().config +} + +/// Static, redacted configuration failure. Empty when initialization can retry. +#[no_mangle] +pub extern "C" fn ddog_ffe_initialization_error_reason() -> CharSlice<'static> { + lifecycle() + .failure + .map_or("", InitializationFailure::reason) + .into() +} + +#[no_mangle] +pub extern "C" fn ddog_ffe_initialization_error_message() -> CharSlice<'static> { + lifecycle() + .failure + .map_or("", InitializationFailure::message) + .into() +} + +/// First-use compatibility path for PHP providers without an initialize API. +/// Concurrent first callers share one deadline; later calls never renew it. +#[no_mangle] +pub extern "C" fn ddog_ffe_ensure_initialized() { + let deadline = { + let mut state = lifecycle(); + state.report_diagnostics(); + if state.stopped || !state.config.enabled { + return; + } + if state.config.source != FfeConfigurationSource::Agentless { + return; + } + if state.worker.is_none() { + set_delivery_state(DeliveryState::PermanentError); + return; + } + if state.worker.as_mut().unwrap().start().is_err() { + log::log( + Log::Warn, + "Feature Flags agentless worker could not be started", + ); + return; + } + if state.initialization_deadline.is_none() { + if !ddog_ffe_has_config() { + set_delivery_state(DeliveryState::Starting); + } + state.initialization_deadline = + Some(Instant::now() + state.settings.as_ref().unwrap().initialization_timeout); + } + state.initialization_deadline.unwrap() + }; + let signal = CONFIGURATION_CHANGED.load_full(); + let mut guard = signal.0.lock().unwrap_or_else(|error| error.into_inner()); + while !ddog_ffe_has_config() && !INITIALIZATION_STOPPED.load(Ordering::Acquire) { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + guard = signal + .1 + .wait_timeout(guard, remaining) + .unwrap_or_else(|error| error.into_inner()) + .0; + } +} + +#[no_mangle] +pub extern "C" fn ddog_ffe_shutdown() { + let mut state = lifecycle(); + state.stopped = true; + INITIALIZATION_STOPPED.store(true, Ordering::Release); + if let Some(mut worker) = state.worker.take() { + worker.shutdown(); + } + state.settings = None; + // Static configuration has no automatic destructor at module shutdown. + // Release its owned evaluator data only after the publisher has joined. + super::clear_config(); + set_delivery_state(DeliveryState::Stopped); + notify_configuration(); +} + +#[no_mangle] +pub extern "C" fn ddog_ffe_prepare_for_fork() { + FORK_GUARD.with(|slot| { + let mut slot = slot.borrow_mut(); + if slot.is_none() { + let mut state = lifecycle(); + if let Some(worker) = &mut state.worker { + worker.prepare_for_fork(); + } + *slot = Some(state); + } + }); +} + +#[no_mangle] +pub extern "C" fn ddog_ffe_resume_after_fork(child: bool) { + FORK_GUARD.with(|slot| { + if let Some(mut state) = slot.borrow_mut().take() { + if child { + // Other parent threads may have been waiting on the old + // condition variable. Never reuse that synchronization state. + CONFIGURATION_CHANGED.store(Arc::new((Mutex::new(()), Condvar::new()))); + } + if let Some(worker) = &mut state.worker { + let result = if child { + worker.reset_after_fork_child() + } else { + worker.resume_after_fork_parent() + }; + if result.is_err() { + log::log( + Log::Warn, + "Feature Flags agentless worker could not restart after fork", + ); + } + } + if child && !ddog_ffe_has_config() { + state.initialization_deadline = None; + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{mpsc, Barrier}; + use std::time::Duration; + + #[test] + fn publishing_configuration_wakes_concurrent_initializers() { + let _test_guard = crate::ffe::tests::FFE_TEST_LOCK.lock().unwrap(); + crate::ffe::clear_config(); + let input = FfeSettingsInput { + enabled: true, + enabled_set: true, + source: CharSlice::from("agentless"), + source_set: true, + legacy_enabled: false, + legacy_enabled_set: false, + agentless_base_url: CharSlice::from("http://127.0.0.1:1/config"), + poll_interval_seconds: 30, + request_timeout_seconds: 30, + initialization_timeout_ms: 3000, + site: CharSlice::from(""), + api_key: CharSlice::from(""), + environment: CharSlice::from(""), + sidecar_subprocess_enabled: true, + sidecar_self_telemetry: false, + thread_cleanup: None, + }; + assert!(ddog_ffe_configure(&input).endpoint_valid); + let barrier = Arc::new(Barrier::new(5)); + let (tx, rx) = mpsc::channel(); + let callers: Vec<_> = (0..4) + .map(|_| { + let barrier = Arc::clone(&barrier); + let tx = tx.clone(); + std::thread::spawn(move || { + barrier.wait(); + ddog_ffe_ensure_initialized(); + tx.send(()).unwrap(); + }) + }) + .collect(); + barrier.wait(); + // No caller may finish without a config while its deadline is open. + assert!(rx.recv_timeout(Duration::from_millis(100)).is_err()); + assert!(crate::ffe::ddog_ffe_load_config(CharSlice::from( + r#"{"createdAt":"2026-05-22T00:00:00Z","environment":{"name":"test"},"flags":{}}"#, + ))); + for _ in 0..4 { + rx.recv_timeout(Duration::from_secs(1)).unwrap(); + } + for caller in callers { + caller.join().unwrap(); + } + ddog_ffe_shutdown(); + assert_eq!(crate::ffe::delivery_state(), DeliveryState::Stopped); + assert!(!ddog_ffe_has_config()); + assert!(lifecycle().worker.is_none()); + assert!(lifecycle().settings.is_none()); + *lifecycle() = Lifecycle::default(); + INITIALIZATION_STOPPED.store(false, Ordering::Release); + crate::ffe::clear_config(); + crate::ffe::use_agentless_config(false); + } +} diff --git a/components-rs/ffe/remote_config.rs b/components-rs/ffe/remote_config.rs new file mode 100644 index 00000000000..37257e5e830 --- /dev/null +++ b/components-rs/ffe/remote_config.rs @@ -0,0 +1,147 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use datadog_ffe::rules_based::{Configuration, UniversalFlagConfig}; + +/// Stage a complete RC update batch before changing the evaluator's configuration. +/// RC replaces a path with Remove followed by Add. Publishing the Remove immediately +/// would expose an empty configuration to concurrent CDN evaluations. +#[derive(Default)] +pub(crate) struct ConfigurationUpdate { + pending: Option>, +} + +impl ConfigurationUpdate { + pub(crate) fn add(&mut self, flags: &UniversalFlagConfig) -> anyhow::Result<()> { + // If preparing the replacement fails, retain the installed configuration. + self.pending = None; + let flags = UniversalFlagConfig::from_json(flags.to_json().to_vec())?; + self.pending = Some(Some(Configuration::from_server_response(flags))); + Ok(()) + } + + pub(crate) fn remove(&mut self) { + self.pending = Some(None); + } + + /// Apply only after the manager has returned None. Dropping an unfinished batch + /// leaves the installed configuration untouched. + pub(crate) fn apply(self) { + match self.pending { + Some(Some(config)) => { + super::store_config(config); + } + Some(None) => { + super::clear_config(); + } + None => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ffe::{self, DeliveryState}; + use datadog_ffe::rules_based::{ + get_assignment, AssignmentValue, EvaluationContext, EvaluationError, ExpectedFlagType, + }; + use std::collections::HashMap; + use std::sync::Arc; + + struct SharedConfiguration; + + impl SharedConfiguration { + fn new() -> Self { + ffe::use_agentless_config(true); + ffe::clear_config(); + Self + } + } + + impl Drop for SharedConfiguration { + fn drop(&mut self) { + ffe::clear_config(); + ffe::use_agentless_config(false); + } + } + + fn flags(value: &str) -> UniversalFlagConfig { + UniversalFlagConfig::from_json( + serde_json::to_vec(&serde_json::json!({ + "createdAt": "2026-10-21T00:00:00Z", "environment": {"name": "test"}, + "flags": {"flag": { + "key": "flag", "enabled": true, "variationType": "STRING", + "variations": {"v": {"key": "v", "value": value}}, + "allocations": [{"key": "static", "splits": [{"variationKey": "v", "shards": []}]}] + }} + })) + .unwrap(), + ) + .unwrap() + } + + fn evaluate_on_another_thread() -> Result { + std::thread::spawn(|| { + let configuration = ffe::current_config(); + get_assignment( + configuration.as_deref(), + "flag", + &EvaluationContext::new(None, Arc::new(HashMap::new())), + ExpectedFlagType::String, + chrono::Utc::now(), + ) + .map(|assignment| match assignment.value { + AssignmentValue::String(value) => value.as_str().to_owned(), + other => panic!("unexpected assignment {other:?}"), + }) + }) + .join() + .unwrap() + } + + fn install(value: &str) { + let mut update = ConfigurationUpdate::default(); + update.add(&flags(value)).unwrap(); + update.apply(); + } + + #[test] + fn cdn_replacement_keeps_evaluations_available_until_atomic_publication() { + let _lock = ffe::tests::FFE_TEST_LOCK.lock().unwrap(); + let _configuration = SharedConfiguration::new(); + install("old"); + let version = ffe::ddog_ffe_config_version(); + + let mut update = ConfigurationUpdate::default(); + update.remove(); + assert_eq!(evaluate_on_another_thread().unwrap(), "old"); + update.add(&flags("new")).unwrap(); + assert_eq!(evaluate_on_another_thread().unwrap(), "old"); + assert_eq!(ffe::ddog_ffe_config_version(), version); + update.apply(); + assert_eq!(evaluate_on_another_thread().unwrap(), "new"); + assert_eq!(ffe::ddog_ffe_config_version(), version + 1); + assert_eq!(ffe::delivery_state(), DeliveryState::Ready); + } + + #[test] + fn a_completed_removal_clears_configuration_but_an_abandoned_batch_does_not() { + let _lock = ffe::tests::FFE_TEST_LOCK.lock().unwrap(); + let _configuration = SharedConfiguration::new(); + install("old"); + let mut abandoned = ConfigurationUpdate::default(); + abandoned.remove(); + drop(abandoned); + assert_eq!(evaluate_on_another_thread().unwrap(), "old"); + + let mut removal = ConfigurationUpdate::default(); + removal.remove(); + removal.apply(); + assert_eq!( + evaluate_on_another_thread(), + Err(EvaluationError::ConfigurationMissing) + ); + assert_eq!(ffe::delivery_state(), DeliveryState::Stale); + } +} diff --git a/components-rs/ffe/settings.rs b/components-rs/ffe/settings.rs new file mode 100644 index 00000000000..0e1656e7265 --- /dev/null +++ b/components-rs/ffe/settings.rs @@ -0,0 +1,377 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +pub use datadog_ffe::cdn::{CdnEndpoint as AgentlessEndpoint, EndpointError}; +use std::time::Duration; + +pub const DEFAULT_POLL_INTERVAL_SECONDS: i64 = 30; +pub const MAX_POLL_INTERVAL_SECONDS: i64 = 3_600; +pub const DEFAULT_REQUEST_TIMEOUT_SECONDS: i64 = 5; +pub const MAX_REQUEST_TIMEOUT_SECONDS: i64 = 300; +pub const DEFAULT_INITIALIZATION_TIMEOUT_MS: i64 = 10_000; +pub const MAX_INITIALIZATION_TIMEOUT_MS: i64 = i32::MAX as i64; + +/// The configured source is retained separately from enablement. In particular, +/// `Offline` is a reserved source whose delivery is disabled today, rather than +/// an alias for the absence of a configured source. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ConfigurationSource { + Agentless, + RemoteConfig, + Offline, + Invalid, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DisableReason { + KillSwitch, + InvalidSource, + LegacyDisabled, + Offline, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SourceResolution { + pub enabled: bool, + pub source: ConfigurationSource, + pub disable_reason: Option, + pub legacy_key_decided: bool, +} + +impl SourceResolution { + pub fn delivery_source(self) -> Option { + self.enabled.then_some(self.source) + } +} + +#[derive(Clone, Copy)] +pub struct SourceInput<'a> { + pub enabled: bool, + pub enabled_set: bool, + pub source: &'a str, + pub source_set: bool, + pub legacy_enabled: bool, + pub legacy_enabled_set: bool, +} + +pub fn resolve_source(input: SourceInput<'_>) -> SourceResolution { + let explicit_source = input + .source_set + .then(|| input.source.trim()) + .filter(|s| !s.is_empty()); + + let (mut enabled, source, disable_reason, legacy_key_decided) = + if let Some(source) = explicit_source { + match source.to_ascii_lowercase().as_str() { + "agentless" => (true, ConfigurationSource::Agentless, None, false), + "remote_config" => (true, ConfigurationSource::RemoteConfig, None, false), + "offline" => ( + false, + ConfigurationSource::Offline, + Some(DisableReason::Offline), + false, + ), + _ => ( + false, + ConfigurationSource::Invalid, + Some(DisableReason::InvalidSource), + false, + ), + } + } else if input.enabled_set { + (true, ConfigurationSource::Agentless, None, false) + } else if input.legacy_enabled_set { + if input.legacy_enabled { + (true, ConfigurationSource::RemoteConfig, None, true) + } else { + ( + false, + ConfigurationSource::RemoteConfig, + Some(DisableReason::LegacyDisabled), + true, + ) + } + } else { + (true, ConfigurationSource::Agentless, None, false) + }; + + let disable_reason = if input.enabled_set && !input.enabled { + enabled = false; + Some(DisableReason::KillSwitch) + } else { + disable_reason + }; + + SourceResolution { + enabled, + source, + disable_reason, + legacy_key_decided, + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SettingsIssue { + PollInterval, + RequestTimeout, + InitializationTimeout, +} + +pub struct SettingsInput<'a> { + pub source: SourceInput<'a>, + /// Sensitive: may contain credentials and must never be logged. + pub agentless_base_url: &'a str, + pub poll_interval_seconds: i64, + pub request_timeout_seconds: i64, + pub initialization_timeout_ms: i64, + pub site: &'a str, + /// Sensitive: must never be logged. + pub api_key: &'a str, + pub environment: &'a str, +} + +/// A validated, process-stable settings snapshot. Deliberately does not derive +/// `Debug`, because it owns the API key and potentially credential-bearing URL. +pub struct FeatureFlagsSettings { + pub resolution: SourceResolution, + agentless_base_url: Option, + pub poll_interval: Duration, + pub request_timeout: Duration, + pub initialization_timeout: Duration, + site: String, + api_key: String, + environment: String, + pub issues: Vec, +} + +impl FeatureFlagsSettings { + pub fn resolve(input: SettingsInput<'_>) -> Self { + let mut issues = Vec::new(); + let poll_interval_seconds = validated_numeric( + input.poll_interval_seconds, + DEFAULT_POLL_INTERVAL_SECONDS, + MAX_POLL_INTERVAL_SECONDS, + SettingsIssue::PollInterval, + &mut issues, + ); + let request_timeout_seconds = validated_numeric( + input.request_timeout_seconds, + DEFAULT_REQUEST_TIMEOUT_SECONDS, + MAX_REQUEST_TIMEOUT_SECONDS, + SettingsIssue::RequestTimeout, + &mut issues, + ); + let initialization_timeout_ms = validated_numeric( + input.initialization_timeout_ms, + DEFAULT_INITIALIZATION_TIMEOUT_MS, + MAX_INITIALIZATION_TIMEOUT_MS, + SettingsIssue::InitializationTimeout, + &mut issues, + ); + + Self { + resolution: resolve_source(input.source), + // PHP INI uses an empty string for the optional URL's unset value. + agentless_base_url: (!input.agentless_base_url.trim().is_empty()) + .then(|| input.agentless_base_url.to_owned()), + poll_interval: Duration::from_secs(poll_interval_seconds as u64), + request_timeout: Duration::from_secs(request_timeout_seconds as u64), + initialization_timeout: Duration::from_millis(initialization_timeout_ms as u64), + site: input.site.to_owned(), + api_key: input.api_key.to_owned(), + environment: input.environment.to_owned(), + issues, + } + } + + pub fn agentless_endpoint(&self) -> Result { + AgentlessEndpoint::build( + self.agentless_base_url.as_deref().unwrap_or_default(), + &self.site, + &self.environment, + &self.api_key, + ) + } +} + +fn validated_numeric( + value: i64, + default: i64, + maximum: i64, + issue: SettingsIssue, + issues: &mut Vec, +) -> i64 { + if value > 0 && value <= maximum { + value + } else { + issues.push(issue); + default + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn source_input() -> SourceInput<'static> { + SourceInput { + enabled: true, + enabled_set: false, + source: "", + source_set: false, + legacy_enabled: false, + legacy_enabled_set: false, + } + } + + #[test] + fn source_precedence_matches_the_cross_sdk_contract() { + let cases = [ + ( + source_input(), + true, + ConfigurationSource::Agentless, + None, + false, + ), + ( + SourceInput { + enabled: false, + enabled_set: true, + source: "remote_config", + source_set: true, + legacy_enabled: true, + legacy_enabled_set: true, + }, + false, + ConfigurationSource::RemoteConfig, + Some(DisableReason::KillSwitch), + false, + ), + ( + SourceInput { + source: " ReMoTe_CoNfIg ", + source_set: true, + legacy_enabled: false, + legacy_enabled_set: true, + ..source_input() + }, + true, + ConfigurationSource::RemoteConfig, + None, + false, + ), + ( + SourceInput { + source: "offline", + source_set: true, + ..source_input() + }, + false, + ConfigurationSource::Offline, + Some(DisableReason::Offline), + false, + ), + ( + SourceInput { + source: "typo", + source_set: true, + ..source_input() + }, + false, + ConfigurationSource::Invalid, + Some(DisableReason::InvalidSource), + false, + ), + ( + SourceInput { + enabled: true, + enabled_set: true, + legacy_enabled: false, + legacy_enabled_set: true, + ..source_input() + }, + true, + ConfigurationSource::Agentless, + None, + false, + ), + ( + SourceInput { + legacy_enabled: true, + legacy_enabled_set: true, + ..source_input() + }, + true, + ConfigurationSource::RemoteConfig, + None, + true, + ), + ( + SourceInput { + legacy_enabled: false, + legacy_enabled_set: true, + ..source_input() + }, + false, + ConfigurationSource::RemoteConfig, + Some(DisableReason::LegacyDisabled), + true, + ), + ]; + + for (input, enabled, source, disable_reason, legacy_key_decided) in cases { + let resolution = resolve_source(input); + assert_eq!(resolution.enabled, enabled); + assert_eq!(resolution.source, source); + assert_eq!(resolution.disable_reason, disable_reason); + assert_eq!(resolution.legacy_key_decided, legacy_key_decided); + } + } + + #[test] + fn blank_explicit_source_is_semantically_unset() { + for source in ["", " ", "\t\r\n"] { + let resolution = resolve_source(SourceInput { + source, + source_set: true, + legacy_enabled: true, + legacy_enabled_set: true, + ..source_input() + }); + assert_eq!( + resolution.delivery_source(), + Some(ConfigurationSource::RemoteConfig) + ); + assert!(resolution.legacy_key_decided); + } + } + + #[test] + fn invalid_numeric_values_use_defaults_and_report_issues() { + let settings = FeatureFlagsSettings::resolve(SettingsInput { + source: source_input(), + agentless_base_url: "", + poll_interval_seconds: 3_601, + request_timeout_seconds: 0, + initialization_timeout_ms: i64::MAX, + site: "", + api_key: "key", + environment: "", + }); + + assert!(settings.agentless_base_url.is_none()); + assert!(settings.agentless_endpoint().unwrap().is_managed()); + assert_eq!(settings.poll_interval, Duration::from_secs(30)); + assert_eq!(settings.request_timeout, Duration::from_secs(5)); + assert_eq!(settings.initialization_timeout, Duration::from_secs(10)); + assert_eq!( + settings.issues, + vec![ + SettingsIssue::PollInterval, + SettingsIssue::RequestTimeout, + SettingsIssue::InitializationTimeout, + ] + ); + } +} diff --git a/components-rs/library-config.h b/components-rs/library-config.h index c29b0cf3e55..fe1e026585f 100644 --- a/components-rs/library-config.h +++ b/components-rs/library-config.h @@ -24,32 +24,8 @@ void ddog_library_configurator_with_fleet_path(struct ddog_Configurator *c, void ddog_library_configurator_with_process_info(struct ddog_Configurator *c, struct ddog_ProcessInfo p); -void ddog_library_configurator_with_detect_process_info(struct ddog_Configurator *c); - void ddog_library_configurator_drop(struct ddog_Configurator*); -struct ddog_LibraryConfigLoggedResult ddog_library_configurator_get(const struct ddog_Configurator *configurator); - -/** - * Returns a static null-terminated string, containing the name of the environment variable - * associated with the library configuration - */ -struct ddog_CStr ddog_library_config_source_to_string(enum ddog_LibraryConfigSource name); - -/** - * Returns a static null-terminated string with the path to the managed stable config yaml config - * file - */ -struct ddog_CStr ddog_library_config_fleet_stable_config_path(void); - -/** - * Returns a static null-terminated string with the path to the local stable config yaml config - * file - */ -struct ddog_CStr ddog_library_config_local_stable_config_path(void); - -void ddog_library_config_drop(struct ddog_LibraryConfigLoggedResult config_result); - /** * Allocates and returns a pointer to a new `TracerMetadata` object on the heap. * @@ -89,6 +65,29 @@ void ddog_tracer_metadata_set(struct ddog_TracerMetadata *ptr, enum ddog_MetadataKind kind, const char *value); +/** + * Includes thread-context discovery metadata in the `TracerMetadata` object pointed to by `ptr`. + * + * If the builder has no thread-context metadata, configures it to publish + * the `tlsdesc_v1_dev` schema and a key map containing `datadog.local_root_span_id`. + * If the thread-context metadata is already set, leaves it unchanged. + * + * This function only updates the builder. Call `ddog_tracer_metadata_store` to publish it. + * + * # Arguments + * - `ptr`: Pointer to a `TracerMetadata` instance. + * + * # Safety + * - If non-null, `ptr` must point to a valid, properly aligned, live `TracerMetadata`. + * - The caller must ensure exclusive access to the instance for the duration of the call. + * - Ownership remains with the caller + * + * # Returns + * - On success: `VoidResult::Ok`, also when thread-context metadata is already present + * - An error if `ptr` is null. + */ +struct ddog_VoidResult ddog_tracer_metadata_include_otel_thread_context(struct ddog_TracerMetadata *ptr); + /** * Serializes the `TracerMetadata` into a platform-specific memory handle (e.g., memfd on Linux). * This function also attempts to publish the tracer metadata as an OTel process context @@ -108,6 +107,30 @@ void ddog_tracer_metadata_set(struct ddog_TracerMetadata *ptr, */ struct ddog_Result_TracerMemfdHandle ddog_tracer_metadata_store(struct ddog_TracerMetadata *ptr); +void ddog_library_configurator_with_detect_process_info(struct ddog_Configurator *c); + +struct ddog_LibraryConfigLoggedResult ddog_library_configurator_get(const struct ddog_Configurator *configurator); + +/** + * Returns a static null-terminated string, containing the name of the environment variable + * associated with the library configuration + */ +struct ddog_CStr ddog_library_config_source_to_string(enum ddog_LibraryConfigSource name); + +/** + * Returns a static null-terminated string with the path to the managed stable config yaml + * config file + */ +struct ddog_CStr ddog_library_config_fleet_stable_config_path(void); + +/** + * Returns a static null-terminated string with the path to the local stable config yaml config + * file + */ +struct ddog_CStr ddog_library_config_local_stable_config_path(void); + +void ddog_library_config_drop(struct ddog_LibraryConfigLoggedResult config_result); + #ifdef __cplusplus } // extern "C" #endif // __cplusplus diff --git a/components-rs/remote_config.rs b/components-rs/remote_config.rs index 299c1d2bd80..9c961d9eba9 100644 --- a/components-rs/remote_config.rs +++ b/components-rs/remote_config.rs @@ -1,6 +1,6 @@ use crate::bytes::{dangling_zend_string, OwnedZendString, ZendString}; use crate::sidecar::MaybeShmLimiter; -use datadog_ffe::rules_based::{Configuration, UniversalFlagConfig}; +use datadog_ffe::rules_based::UniversalFlagConfig; use datadog_sidecar::service::blocking::SidecarTransport; use datadog_sidecar::service::InstanceId; use datadog_sidecar::shm_remote_config::{RemoteConfigManager, RemoteConfigUpdate}; @@ -423,6 +423,7 @@ pub extern "C" fn ddog_remote_config_get_path(remote_config: &RemoteConfigState) #[no_mangle] pub extern "C" fn ddog_process_remote_configs(remote_config: &mut RemoteConfigState) -> bool { let mut has_updates = false; + let mut ffe_update = crate::ffe::remote_config::ConfigurationUpdate::default(); loop { match remote_config.manager.fetch_update() { RemoteConfigUpdate::None => break, @@ -479,12 +480,8 @@ pub extern "C" fn ddog_process_remote_configs(remote_config: &mut RemoteConfigSt RemoteConfigProduct::FfeFlags => { debug!("Received FFE flags configuration"); if let Some(ufc) = data.downcast::() { - if let Ok(ufc_owned) = - UniversalFlagConfig::from_json(ufc.to_json().to_vec()) - { - crate::ffe::store_config(Configuration::from_server_response( - ufc_owned, - )); + if let Err(error) = ffe_update.add(ufc) { + debug!("Failed preparing FFE configuration: {error}"); } } } @@ -524,13 +521,14 @@ pub extern "C" fn ddog_process_remote_configs(remote_config: &mut RemoteConfigSt } RemoteConfigProduct::FfeFlags => { debug!("FFE flags configuration removed"); - crate::ffe::clear_config(); + ffe_update.remove(); } _ => (), }, } has_updates = true } + ffe_update.apply(); has_updates } diff --git a/components-rs/sidecar.h b/components-rs/sidecar.h index a6036f56feb..bb0d20f9601 100644 --- a/components-rs/sidecar.h +++ b/components-rs/sidecar.h @@ -292,10 +292,33 @@ ddog_MaybeError ddog_sidecar_send_debugger_datum(struct ddog_SidecarTransport ** struct ddog_DebuggerPayload *payload); /** - * Send structured FFE exposure events to the sidecar. The sidecar owns - * deduplication, JSON serialization, and Agent EVP delivery. This function is - * caller-driven; shared libdatadog evaluator calls do not log unless an SDK - * explicitly sends this action. + * Configure one shared EVP intake target in this connection's sidecar session. + * `AgentOnly` preserves the historical fixed EVP v2 route. + * `PreferLocalThenDirect` explicitly opts the client into local discovery and + * authenticated direct fallback. + * + * Call after setting the connection configuration. Other intake targets are + * unaffected. Identical configuration preserves routing and deduplication + * state; changed configuration replaces this target's transport. The client + * retains the latest configuration per target across sidecar reconnects and + * forks. Each new connection or forked instance starts with fresh route state. + * + * # Safety + * `direct_endpoint` must be null or point to a valid `Endpoint`, and all + * string slices must remain valid for the duration of this call. + */ +ddog_MaybeError ddog_sidecar_session_set_evp_transport(struct ddog_SidecarTransport **transport, + enum ddog_EvpTransportMode mode, + const struct ddog_Endpoint *agent_endpoint, + const struct ddog_Endpoint *direct_endpoint, + ddog_CharSlice intake_subdomain, + const struct ddog_EvpProducerIdentity *producer); + +/** + * Send structured Feature Flags exposure events to the sidecar. The sidecar owns + * route selection, deduplication, JSON serialization, and EVP delivery. This + * function is caller-driven; shared libdatadog evaluator calls do not log unless + * an SDK explicitly sends this action. * * # Safety * `context` and every element in `exposures` must contain valid UTF-8 @@ -306,9 +329,10 @@ ddog_MaybeError ddog_sidecar_send_ffe_exposure_batch(struct ddog_SidecarTranspor struct ddog_Slice_FfeExposure exposures); /** - * Send structured FFE flag evaluation events to the sidecar. The sidecar owns - * JSON serialization and Agent EVP delivery. This function is caller-driven; - * callers must aggregate and bound event cardinality before passing a batch. + * Send structured Feature Flags evaluation events to the sidecar. The sidecar owns + * route selection, JSON serialization, and EVP delivery. This function is + * caller-driven; callers must aggregate and bound event cardinality before passing + * a batch. * * # Safety * All slices must reference valid memory under the CharSlice contract. Context @@ -322,7 +346,7 @@ ddog_MaybeError ddog_sidecar_send_ffe_flag_evaluation_batch(struct ddog_SidecarT struct ddog_Slice_FfeFlagEvaluation flag_evaluations); /** - * Send structured FFE evaluation metric events to the sidecar. The sidecar + * Send structured Feature Flags evaluation metric events to the sidecar. The sidecar * owns aggregation, OTLP/protobuf serialization, and OTLP HTTP delivery. This * function is caller-driven so SDKs with existing host-language hooks can * safely coexist until they explicitly migrate. diff --git a/components-rs/sidecar.rs b/components-rs/sidecar.rs index bd6b09e50bc..0be514b9476 100644 --- a/components-rs/sidecar.rs +++ b/components-rs/sidecar.rs @@ -25,7 +25,7 @@ use std::sync::Mutex; use std::time::Duration; #[cfg(php_shared_build)] -fn run_sidecar(mut cfg: config::Config) -> anyhow::Result { +pub(crate) fn run_sidecar(mut cfg: config::Config) -> anyhow::Result { #[cfg(target_os = "linux")] if std::env::var_os("DD_SIDECAR_DISABLE_DIRECT_EXEC") .map(|s| s.is_empty()) @@ -40,7 +40,7 @@ fn run_sidecar(mut cfg: config::Config) -> anyhow::Result { } #[cfg(not(any(windows, php_shared_build)))] -fn run_sidecar(cfg: config::Config) -> anyhow::Result { +pub(crate) fn run_sidecar(cfg: config::Config) -> anyhow::Result { ddtrace_sidecar::start_or_connect_to_sidecar(cfg) } @@ -49,7 +49,7 @@ fn run_sidecar(cfg: config::Config) -> anyhow::Result { pub static mut DDOG_PHP_FUNCTION: *const u8 = std::ptr::null(); #[cfg(windows)] -fn run_sidecar(mut cfg: config::Config) -> anyhow::Result { +pub(crate) fn run_sidecar(mut cfg: config::Config) -> anyhow::Result { let php_dll = get_trampoline_target_data(unsafe { DDOG_PHP_FUNCTION })?; cfg.library_dependencies .push(LibDependency::Path(php_dll.into())); @@ -338,6 +338,7 @@ pub extern "C" fn ddog_sidecar_reconnect_readers( /// The fd of the transport's current connection; it changes on reconnect. #[cfg(unix)] #[no_mangle] +#[allow(clippy::borrowed_box)] // The C ABI takes a pointer to the transport handle. pub extern "C" fn ddog_sidecar_transport_raw_fd(transport: &Box) -> i32 { transport.as_raw_fd() } diff --git a/ext/configuration.h b/ext/configuration.h index 667c9c49766..c1b06ff3818 100644 --- a/ext/configuration.h +++ b/ext/configuration.h @@ -46,6 +46,7 @@ enum datadog_sidecar_connection_mode { CONFIG(STRING, DD_DOGSTATSD_URL, "http://localhost:8125") \ CONFIG(STRING, DD_DOGSTATSD_HOST, "localhost") \ CONFIG(STRING, DD_API_KEY, "", .ini_change = zai_config_system_ini_change, .sensitive = true) \ + CONFIG(STRING, DD_SITE, "datadoghq.com") \ CONFIG(INT, DD_DOGSTATSD_PORT, "8125") \ CONFIG(STRING, DD_ENV, "", .ini_change = datadog_alter_dd_env, \ .env_config_fallback = ddtrace_conf_otel_resource_attributes_env) \ diff --git a/ext/datadog.c b/ext/datadog.c index b64ef163880..8352e613438 100644 --- a/ext/datadog.c +++ b/ext/datadog.c @@ -722,6 +722,9 @@ static PHP_MINFO_FUNCTION(datadog) { void datadog_internal_handle_fork(void) { // CHILD PROCESS +#ifdef DDTRACE + ddog_ffe_resume_after_fork(true); +#endif datadog_force_new_instance_id(); datadog_sidecar_handle_fork(); #ifdef __linux__ diff --git a/libdatadog b/libdatadog index ed81443e10c..765058a0f67 160000 --- a/libdatadog +++ b/libdatadog @@ -1 +1 @@ -Subproject commit ed81443e10c4642c5a952a0a1b36596f7df576d1 +Subproject commit 765058a0f6747709dba2edcb990e2a517f315f07 diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index 72de20ca32f..5214138f216 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -397,6 +397,13 @@ "default": "false" } ], + "DD_EXPERIMENTAL_FLAGGING_PROVIDER_INITIALIZATION_TIMEOUT_MS": [ + { + "implementation": "B", + "type": "int", + "default": "10000" + } + ], "DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED": [ { "implementation": "A", @@ -411,6 +418,42 @@ "default": "true" } ], + "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE": [ + { + "implementation": "A", + "type": "string", + "default": "agentless" + } + ], + "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL": [ + { + "implementation": "A", + "type": "string", + "default": null, + "sensitive": true + } + ], + "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS": [ + { + "implementation": "A", + "type": "int", + "default": "30" + } + ], + "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS": [ + { + "implementation": "A", + "type": "int", + "default": "5" + } + ], + "DD_FEATURE_FLAGS_ENABLED": [ + { + "implementation": "A", + "type": "boolean", + "default": "true" + } + ], "DD_GIT_COMMIT_SHA": [ { "implementation": "B", @@ -652,6 +695,13 @@ "default": "" } ], + "DD_SITE": [ + { + "implementation": "C", + "type": "string", + "default": "datadoghq.com" + } + ], "DD_SPAN_SAMPLING_RULES": [ { "implementation": "A", diff --git a/src/api/FeatureFlags/Internal/NativeEvaluator.php b/src/api/FeatureFlags/Internal/NativeEvaluator.php index 929f058f733..9c1a46966b2 100644 --- a/src/api/FeatureFlags/Internal/NativeEvaluator.php +++ b/src/api/FeatureFlags/Internal/NativeEvaluator.php @@ -8,6 +8,7 @@ final class NativeEvaluator implements Evaluator { const WARNING_MESSAGE = 'Datadog-backed PHP feature flag evaluation has no Remote Configuration data loaded for this request. Returning default values.'; + const AGENTLESS_WARNING_MESSAGE = 'Datadog-backed PHP feature flag evaluation has no agentless configuration loaded. Returning default values.'; private $mapper; private $recordMetrics; @@ -107,6 +108,14 @@ private function withProviderState($rawResult) 'mode' => 'native_remote_config', 'reason' => $hasConfig ? 'ready' : 'configuration_missing', ); + if (function_exists('DDTrace\\Internal\\ffe_provider_state')) { + $providerState = array_merge($providerState, \DDTrace\Internal\ffe_provider_state()); + } + $warningMessage = $providerState['mode'] === 'native_agentless' + ? self::AGENTLESS_WARNING_MESSAGE : self::WARNING_MESSAGE; + if (!empty($providerState['errorMessage'])) { + $warningMessage = $providerState['errorMessage']; + } if (is_array($rawResult)) { if (isset($rawResult['provider_state']) && is_array($rawResult['provider_state'])) { @@ -114,7 +123,7 @@ private function withProviderState($rawResult) } if (!$hasConfig) { - $rawResult['error_message'] = self::WARNING_MESSAGE; + $rawResult['error_message'] = $warningMessage; } $rawResult['provider_state'] = $providerState; @@ -129,7 +138,7 @@ private function withProviderState($rawResult) } if (!$hasConfig) { - $rawResult->errorMessage = self::WARNING_MESSAGE; + $rawResult->errorMessage = $warningMessage; } $rawResult->providerState = $providerState; diff --git a/tests/ext/ffe/agentless_fork.phpt b/tests/ext/ffe/agentless_fork.phpt new file mode 100644 index 00000000000..1bab2317f26 --- /dev/null +++ b/tests/ext/ffe/agentless_fork.phpt @@ -0,0 +1,35 @@ +--TEST-- +FFE agentless fork restarts parent and child shared-memory configuration readers +--SKIPIF-- + +--ENV-- +DD_FEATURE_FLAGS_ENABLED=1 +DD_FEATURE_FLAGS_CONFIGURATION_SOURCE=agentless +DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS=1 +DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS=30 +DD_EXPERIMENTAL_FLAGGING_PROVIDER_INITIALIZATION_TIMEOUT_MS=10000 +DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED=0 +DD_TRACE_ENABLED=0 +DD_TRACE_CLI_ENABLED=1 +DD_REMOTE_CONFIG_ENABLED=0 +DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 +DD_METRICS_OTEL_ENABLED=0 +DD_TRACE_AGENT_URL=http://127.0.0.1:1 +DD_API_KEY=custom-endpoint-must-not-receive-this +NO_PROXY=127.0.0.1,localhost +--FILE-- + +--EXPECT-- +child_forked +child_recovered +fork_clean_exit +parent_forked +parent_recovered diff --git a/tests/ext/ffe/agentless_lifecycle.phpt b/tests/ext/ffe/agentless_lifecycle.phpt new file mode 100644 index 00000000000..4c37f549323 --- /dev/null +++ b/tests/ext/ffe/agentless_lifecycle.phpt @@ -0,0 +1,37 @@ +--TEST-- +FFE agentless first evaluation, 304, last-good configuration and recovery with tracing disabled +--SKIPIF-- + +--ENV-- +DD_FEATURE_FLAGS_ENABLED=1 +DD_FEATURE_FLAGS_CONFIGURATION_SOURCE=agentless +DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS=1 +DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS=1 +DD_EXPERIMENTAL_FLAGGING_PROVIDER_INITIALIZATION_TIMEOUT_MS=1500 +DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED=0 +DD_TRACE_ENABLED=0 +DD_TRACE_CLI_ENABLED=1 +DD_REMOTE_CONFIG_ENABLED=0 +DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 +DD_METRICS_OTEL_ENABLED=0 +DD_TRACE_AGENT_URL=http://127.0.0.1:1 +DD_API_KEY=custom-endpoint-must-not-receive-this +NO_PROXY=127.0.0.1,localhost +--FILE-- + +--EXPECT-- +first=blue +changed=green +stale_preserved=true +recovered_on_304=true +version_advanced_once=true +source=native_agentless +ready=true +request_protocol=true diff --git a/tests/ext/ffe/agentless_openfeature_lifecycle.phpt b/tests/ext/ffe/agentless_openfeature_lifecycle.phpt new file mode 100644 index 00000000000..961cdb5eccc --- /dev/null +++ b/tests/ext/ffe/agentless_openfeature_lifecycle.phpt @@ -0,0 +1,38 @@ +--TEST-- +FFE OpenFeature agentless first evaluation, 304, last-good configuration and recovery with tracing disabled +--SKIPIF-- + +--ENV-- +DD_FEATURE_FLAGS_ENABLED=1 +DD_FEATURE_FLAGS_CONFIGURATION_SOURCE=agentless +DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS=1 +DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS=1 +DD_EXPERIMENTAL_FLAGGING_PROVIDER_INITIALIZATION_TIMEOUT_MS=1500 +DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED=0 +DD_TRACE_ENABLED=0 +DD_TRACE_CLI_ENABLED=1 +DD_REMOTE_CONFIG_ENABLED=0 +DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 +DD_METRICS_OTEL_ENABLED=0 +DD_TRACE_AGENT_URL=http://127.0.0.1:1 +DD_API_KEY=custom-endpoint-must-not-receive-this +NO_PROXY=127.0.0.1,localhost +--FILE-- + +--EXPECT-- +first=blue +changed=green +stale_preserved=true +recovered_on_304=true +version_advanced_once=true +source=native_agentless +ready=true +request_protocol=true diff --git a/tests/ext/ffe/agentless_process_isolation.phpt b/tests/ext/ffe/agentless_process_isolation.phpt new file mode 100644 index 00000000000..743c034c879 --- /dev/null +++ b/tests/ext/ffe/agentless_process_isolation.phpt @@ -0,0 +1,28 @@ +--TEST-- +FFE agentless configuration HTTP runs outside the PHP application process +--SKIPIF-- + +--ENV-- +DD_FEATURE_FLAGS_ENABLED=1 +DD_FEATURE_FLAGS_CONFIGURATION_SOURCE=agentless +DD_EXPERIMENTAL_FLAGGING_PROVIDER_INITIALIZATION_TIMEOUT_MS=10000 +DD_TRACE_ENABLED=0 +DD_TRACE_CLI_ENABLED=1 +DD_REMOTE_CONFIG_ENABLED=0 +DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 +DD_METRICS_OTEL_ENABLED=0 +DD_TRACE_AGENT_URL=http://127.0.0.1:1 +NO_PROXY=127.0.0.1,localhost +--FILE-- + +--EXPECT-- +cdn_configuration_evaluated +cdn_request_outside_php diff --git a/tests/ext/ffe/agentless_recovery.phpt b/tests/ext/ffe/agentless_recovery.phpt new file mode 100644 index 00000000000..005c0d6c57f --- /dev/null +++ b/tests/ext/ffe/agentless_recovery.phpt @@ -0,0 +1,32 @@ +--TEST-- +FFE agentless initialization deadline is shared across evaluations and failed initial polling recovers +--SKIPIF-- + +--ENV-- +DD_FEATURE_FLAGS_ENABLED=1 +DD_FEATURE_FLAGS_CONFIGURATION_SOURCE=agentless +DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS=1 +DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS=30 +DD_EXPERIMENTAL_FLAGGING_PROVIDER_INITIALIZATION_TIMEOUT_MS=200 +DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED=0 +DD_TRACE_ENABLED=0 +DD_TRACE_CLI_ENABLED=1 +DD_REMOTE_CONFIG_ENABLED=0 +DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 +DD_METRICS_OTEL_ENABLED=0 +DD_TRACE_AGENT_URL=http://127.0.0.1:1 +DD_API_KEY=custom-endpoint-must-not-receive-this +NO_PROXY=127.0.0.1,localhost +--FILE-- + +--EXPECT-- +initial_deadline_bounded +initial_failure_recovered +later_evaluations_do_not_wait diff --git a/tests/ext/ffe/agentless_settings_diagnostics.phpt b/tests/ext/ffe/agentless_settings_diagnostics.phpt new file mode 100644 index 00000000000..ac81f81e6bb --- /dev/null +++ b/tests/ext/ffe/agentless_settings_diagnostics.phpt @@ -0,0 +1,81 @@ +--TEST-- +FFE reports redacted configuration failures and once-only diagnostics on first use +--SKIPIF-- + +--ENV-- +DD_TRACE_ENABLED=0 +DD_TRACE_CLI_ENABLED=1 +DD_REMOTE_CONFIG_ENABLED=0 +DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 +DD_METRICS_OTEL_ENABLED=0 +DD_TRACE_STARTUP_LOGS=0 +DD_TRACE_AGENT_URL=http://127.0.0.1:1 +--FILE-- + array(array(), 'missing_api_key', array('requires DD_API_KEY')), + 'source' => array(array('DD_FEATURE_FLAGS_CONFIGURATION_SOURCE' => 'secret-source'), 'invalid_source', array('configuration source is invalid')), + 'endpoint' => array(array('DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL' => 'ftp://secret-user:secret-password@example.com/path?key=secret-query'), 'invalid_endpoint', array('CDN base URL must')), + 'site' => array(array('DD_API_KEY' => 'secret-key', 'DD_SITE' => 'secret-site/path'), 'invalid_site', array('requires a valid DD_SITE')), + 'timings' => array(array('DD_FEATURE_FLAGS_CONFIGURATION_SOURCE' => 'offline', 'DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS' => '0', 'DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS' => '-1', 'DD_EXPERIMENTAL_FLAGGING_PROVIDER_INITIALIZATION_TIMEOUT_MS' => '0'), 'disabled', array('using 30 seconds', 'using 5 seconds', 'using 10000 milliseconds')), + 'legacy' => array(array('DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED' => '0'), 'disabled', array('DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED is deprecated')), +); +foreach ($cases as $name => $case) { + $log = tempnam(sys_get_temp_dir(), 'ffe-settings-'); + $environment = array_merge($base, $case[0], array('DD_TRACE_LOG_FILE' => $log)); + $process = ffeTestProcess(__DIR__ . '/stubs/agentless_settings_app.php', $pipes, $environment); + $stdout = $stderr = ''; + $deadline = microtime(true) + 60; + do { + $stdout .= ffeTestProcessRead($pipes[1]); + if (strpos($stdout, "fixture_child_ready\n") !== false) break; + usleep(10000); + } while (microtime(true) < $deadline); + if (strpos($stdout, "fixture_child_ready\n") === false) throw new RuntimeException('child startup timed out'); + foreach ($case[2] as $message) { + if (strpos(file_get_contents($log), $message) !== false) throw new RuntimeException('diagnostic emitted before feature use'); + } + fwrite($pipes[0], "evaluate\n"); + fclose($pipes[0]); + do { + $stdout .= ffeTestProcessRead($pipes[1]); + $stderr .= ffeTestProcessRead($pipes[2]); + $status = proc_get_status($process); + if (!$status['running']) break; + usleep(10000); + } while (microtime(true) < $deadline); + $stdout .= ffeTestProcessRead($pipes[1]); + if ($status['running']) proc_terminate($process); + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($process); + if ($status['running'] || $status['exitcode'] !== 0) throw new RuntimeException($name . ' failed: ' . $stderr); + $result = json_decode(str_replace("fixture_child_ready\n", '', $stdout), true); + if ($result['state']['reason'] !== $case[1] || $result['value'] !== 'fallback') throw new RuntimeException($name . ' wrong result: ' . $stdout); + if ($case[1] !== 'disabled' && $result['error'] !== $result['state']['errorMessage']) throw new RuntimeException('caller lost the failure reason'); + $diagnostics = file_get_contents($log); + unlink($log); + foreach ($case[2] as $message) { + if (substr_count($diagnostics, $message) !== 1) throw new RuntimeException($name . ' diagnostic missing or repeated: ' . $diagnostics); + } + if (strpos($diagnostics . $stdout . $stderr, 'secret-') !== false) throw new RuntimeException('configuration secret leaked'); + echo $name, "=ok\n"; +} +?> +--EXPECT-- +missing_key=ok +source=ok +endpoint=ok +site=ok +timings=ok +legacy=ok diff --git a/tests/ext/ffe/agentless_shutdown.phpt b/tests/ext/ffe/agentless_shutdown.phpt new file mode 100644 index 00000000000..6b1cbe8567b --- /dev/null +++ b/tests/ext/ffe/agentless_shutdown.phpt @@ -0,0 +1,32 @@ +--TEST-- +FFE agentless shutdown cancels a configuration request before its HTTP deadline +--SKIPIF-- + +--ENV-- +DD_FEATURE_FLAGS_ENABLED=1 +DD_FEATURE_FLAGS_CONFIGURATION_SOURCE=agentless +DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS=1 +DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS=30 +DD_EXPERIMENTAL_FLAGGING_PROVIDER_INITIALIZATION_TIMEOUT_MS=200 +DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED=0 +DD_TRACE_ENABLED=0 +DD_TRACE_CLI_ENABLED=1 +DD_REMOTE_CONFIG_ENABLED=0 +DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 +DD_METRICS_OTEL_ENABLED=0 +DD_TRACE_AGENT_URL=http://127.0.0.1:1 +DD_API_KEY=custom-endpoint-must-not-receive-this +NO_PROXY=127.0.0.1,localhost +--FILE-- + +--EXPECT-- +initial_deadline_bounded +later_evaluations_do_not_wait +shutdown_cancelled_inflight_request diff --git a/tests/ext/ffe/agentless_sidecar_restart.phpt b/tests/ext/ffe/agentless_sidecar_restart.phpt new file mode 100644 index 00000000000..1cf685d7f85 --- /dev/null +++ b/tests/ext/ffe/agentless_sidecar_restart.phpt @@ -0,0 +1,32 @@ +--TEST-- +FFE agentless retains configuration and resubscribes after a sidecar subprocess restart +--SKIPIF-- + +--ENV-- +_DD_DEBUG_SIDECAR_IPC_MODE=instance_per_process +_DD_DEBUG_SIDECAR_IDLE_LINGER_TIME_SECS=1 +DD_FEATURE_FLAGS_ENABLED=1 +DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS=1 +DD_FEATURE_FLAGS_CONFIGURATION_SOURCE=agentless +DD_EXPERIMENTAL_FLAGGING_PROVIDER_INITIALIZATION_TIMEOUT_MS=10000 +DD_TRACE_ENABLED=0 +DD_TRACE_CLI_ENABLED=1 +DD_REMOTE_CONFIG_ENABLED=0 +DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 +DD_METRICS_OTEL_ENABLED=0 +DD_TRACE_AGENT_URL=http://127.0.0.1:1 +NO_PROXY=127.0.0.1,localhost +--FILE-- + +--EXPECT-- +last_configuration_retained +sidecar_restarted_and_refreshed diff --git a/tests/ext/ffe/agentless_source_selection.phpt b/tests/ext/ffe/agentless_source_selection.phpt new file mode 100644 index 00000000000..2011337ccc1 --- /dev/null +++ b/tests/ext/ffe/agentless_source_selection.phpt @@ -0,0 +1,34 @@ +--TEST-- +FFE stable and legacy source settings control native activation without configuration traffic +--SKIPIF-- + +--ENV-- +DD_TRACE_ENABLED=0 +DD_TRACE_CLI_ENABLED=1 +DD_REMOTE_CONFIG_ENABLED=0 +DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 +DD_METRICS_OTEL_ENABLED=0 +DD_TRACE_AGENT_URL=http://127.0.0.1:1 +--FILE-- + array('0', 'agentless', '1', false, 'native_agentless'), + 'legacy_kill' => array(null, null, '0', false, 'native_remote_config'), + 'legacy_remote_config' => array(null, null, '1', true, 'native_remote_config'), + 'explicit_remote_config' => array('1', 'remote_config', '0', true, 'native_remote_config'), + 'offline' => array('1', 'offline', '1', false, 'native_disabled'), + 'invalid' => array('1', 'unknown', '1', false, 'native_disabled'), +); +require __DIR__ . '/stubs/agentless_source_selection.inc'; +?> +--EXPECT-- +stable_kill=ok +legacy_kill=ok +legacy_remote_config=ok +explicit_remote_config=ok +offline=ok +invalid=ok diff --git a/tests/ext/ffe/agentless_thread_mode.phpt b/tests/ext/ffe/agentless_thread_mode.phpt new file mode 100644 index 00000000000..1fd0c87e9ac --- /dev/null +++ b/tests/ext/ffe/agentless_thread_mode.phpt @@ -0,0 +1,24 @@ +--TEST-- +FFE thread mode fails closed without CDN traffic +--SKIPIF-- + + + +--ENV-- +DD_TRACE_ENABLED=0 +DD_TRACE_CLI_ENABLED=1 +DD_REMOTE_CONFIG_ENABLED=0 +DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 +DD_METRICS_OTEL_ENABLED=0 +DD_TRACE_AGENT_URL=http://127.0.0.1:1 +--FILE-- + array('1', 'agentless', '0', true, 'native_agentless')); +require __DIR__ . '/stubs/agentless_source_selection.inc'; +?> +--EXPECT-- +thread_mode=ok diff --git a/tests/ext/ffe/stubs/agentless_lifecycle.inc b/tests/ext/ffe/stubs/agentless_lifecycle.inc new file mode 100644 index 00000000000..eb184f11fb6 --- /dev/null +++ b/tests/ext/ffe/stubs/agentless_lifecycle.inc @@ -0,0 +1,99 @@ + '2026-05-22T00:00:00Z', + 'environment' => array('name' => 'test'), + 'flags' => array('flag' => array( + 'key' => 'flag', 'enabled' => true, 'variationType' => 'STRING', + 'variations' => array('on' => array('key' => 'on', 'value' => $value)), + 'allocations' => array(array('key' => 'allocation', 'rules' => array(), 'doLog' => false, + 'splits' => array(array('variationKey' => 'on', 'shards' => array())))), + )), + ); + $configuration['format'] = 'SERVER'; + $body = json_encode(array('data' => array( + 'type' => 'universal-flag-configuration', 'attributes' => $configuration, + ))); + if ($notModified) $body = ''; + if ($phase === 3) $body = '{malformed'; + fwrite($connection, "HTTP/1.1 $code\r\nContent-Type: application/json\r\nETag: $etag\r\nContent-Length: " + . strlen($body) . "\r\nConnection: close\r\n\r\n" . $body); + fclose($connection); + if ($phase === 0) { $expectedEtag = '"blue"'; $phase = 1; } + elseif ($phase === 1) $phase = 3; + elseif ($phase === 5) { $expectedEtag = '"green"'; $phase = 6; } + } while (microtime(true) - ($readyAt === null ? $startedAt : $readyAt) < ($readyAt === null || getenv('USE_ZEND_ALLOC') === '0' ? 60 : 35)); + $stdout .= ffeTestProcessRead($pipes[1]); + $stderr .= ffeTestProcessRead($pipes[2]); + if ($exitCode !== 0) throw new RuntimeException('child failed or timed out: ' . $stdout . ' ' . $stderr); + echo str_replace(array("fixture_child_ready\n", "fixture_stale_observed\n", "fixture_304_observed\n"), '', $stdout); + if (count($requests) < 5) throw new RuntimeException('polling did not recover'); + $protocol = true; + foreach ($requests as $i => $request) { + list($route, $headers, $expectedEtag) = $request; + $protocol = $protocol && $route === 'GET /configuration?fixture=1 HTTP/1.1' + && !isset($headers['dd-api-key']) + && $headers['dd-client-library-language'] === 'php' + && $headers['dd-client-library-version'] === phpversion('ddtrace') + && $headers['dd-internal-untraced-request'] === '1'; + if ($expectedEtag === null) $protocol = $protocol && !isset($headers['if-none-match']); + else $protocol = $protocol && $headers['if-none-match'] === $expectedEtag; + } + echo 'request_protocol=', json_encode($protocol), "\n"; +} finally { + if ($running) proc_terminate($process); + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($process); + fclose($server); +} diff --git a/tests/ext/ffe/stubs/agentless_lifecycle_app.php b/tests/ext/ffe/stubs/agentless_lifecycle_app.php new file mode 100644 index 00000000000..5baf38e7699 --- /dev/null +++ b/tests/ext/ffe/stubs/agentless_lifecycle_app.php @@ -0,0 +1,50 @@ +setProvider(new \DDTrace\OpenFeature\DataDogProvider()); + $client = $api->getClient('php-agentless-lifecycle'); +} +// No testing loader or Agent supplies this configuration. +echo "fixture_child_ready\n"; +$first = $client->getStringValue('flag', 'fallback'); +echo 'first=', $first, "\n"; +$version = \DDTrace\ffe_config_version(); +$stale = false; +$recoveredOn304 = false; +$deadline = microtime(true) + 30; +do { + $before = \DDTrace\ffe_config_version(); + $value = $client->getStringValue('flag', 'fallback'); + $state = \DDTrace\Internal\ffe_provider_state(); + $after = \DDTrace\ffe_config_version(); + // A concurrent replacement may change the version, but must never expose + // an empty configuration between the old and new accepted values. + if ($value !== 'blue' && $value !== 'green') { + throw new RuntimeException('configuration missing during CDN replacement'); + } + // These APIs are independent reads, not an atomic snapshot. A poll can + // publish green after the blue evaluation and before the version read. + if ($before !== $after) continue; + if ($value === 'green') break; + if ($value !== 'blue') throw new RuntimeException('last known configuration lost'); + if ($after !== $version) throw new RuntimeException('304 or malformed response changed config version'); + if (!$stale && $state['reason'] === 'stale') { + $stale = true; + echo "fixture_stale_observed\n"; + } + if ($stale && !$recoveredOn304 && $state['reason'] === 'ready') { + $recoveredOn304 = true; + echo "fixture_304_observed\n"; + } + usleep(20000); +} while (microtime(true) < $deadline); +echo 'changed=', $value, "\n"; +echo 'stale_preserved=', json_encode($stale), "\n"; +echo 'recovered_on_304=', json_encode($recoveredOn304), "\n"; +echo 'version_advanced_once=', json_encode(\DDTrace\ffe_config_version() === $version + 1), "\n"; +echo 'source=', $state['mode'], "\n"; +echo 'ready=', json_encode($state['ready']), "\n"; diff --git a/tests/ext/ffe/stubs/agentless_process.inc b/tests/ext/ffe/stubs/agentless_process.inc new file mode 100644 index 00000000000..7fc341e32cb --- /dev/null +++ b/tests/ext/ffe/stubs/agentless_process.inc @@ -0,0 +1,176 @@ + '2026-05-22T00:00:00Z', 'environment' => array('name' => 'test'), + 'flags' => array('flag' => array( + 'key' => 'flag', 'enabled' => true, 'variationType' => 'STRING', + 'variations' => array('on' => array('key' => 'on', 'value' => $value)), + 'allocations' => array(array('key' => 'allocation', 'rules' => array(), 'doLog' => false, + 'splits' => array(array('variationKey' => 'on', 'shards' => array())))), + )), + ); + $configuration['format'] = 'SERVER'; + $body = json_encode(array('data' => array( + 'type' => 'universal-flag-configuration', 'attributes' => $configuration, + ))); + } + fwrite($connection, "HTTP/1.1 $code\r\nContent-Type: application/json\r\nContent-Length: " + . strlen($body) . "\r\nConnection: close\r\n\r\n" . $body); + fclose($connection); + } while (microtime(true) - ($readyAt === null ? $start : $readyAt) < ($readyAt === null ? 60 : ($instrumented ? 45 : 20))); + $stdout .= ffeTestProcessRead($pipes[1]); + $stderr .= ffeTestProcessRead($pipes[2]); + if ($exitCode !== 0) throw new RuntimeException('child failed or timed out: ' . $stdout . ' ' . $stderr); + if ($requests === 0) throw new RuntimeException('no configuration request'); + if ($scenario === 'shutdown' && microtime(true) - $shutdownStart > ($instrumented ? 10 : 3)) { + throw new RuntimeException('shutdown waited on HTTP timeout'); + } + // Fork output is intentionally concurrent; compare the complete set. + $lines = explode("\n", trim(str_replace(array("fixture_child_ready\n", "fixture_restart_ready\n"), '', $stdout))); + sort($lines); + echo implode("\n", $lines), "\n"; + if ($scenario === 'shutdown') echo "shutdown_cancelled_inflight_request\n"; + if ($scenario === 'isolation') echo "cdn_request_outside_php\n"; +} finally { + if ($running) proc_terminate($process); + if (is_resource($pipes[0])) fclose($pipes[0]); + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($process); + foreach ($heldConnections as $connection) fclose($connection); + fclose($server); +} diff --git a/tests/ext/ffe/stubs/agentless_process_app.php b/tests/ext/ffe/stubs/agentless_process_app.php new file mode 100644 index 00000000000..d970379652b --- /dev/null +++ b/tests/ext/ffe/stubs/agentless_process_app.php @@ -0,0 +1,106 @@ +getStringValue('flag', 'fallback'); +$elapsed = microtime(true) - $start; + +if ($scenario === 'isolation') { + if ($first !== 'blue') throw new RuntimeException('configuration did not reach the PHP evaluator'); + echo "cdn_configuration_evaluated\n"; + exit; +} + +if ($scenario === 'restart') { + // Establish a last-good snapshot before testing its survival. Instrumented + // subprocess startup may exceed the first-use budget and legitimately + // return the default; initialization timing is covered by recovery/shutdown. + $readyDeadline = microtime(true) + ($instrumented ? 20 : 5); + while ($first !== 'blue' && microtime(true) < $readyDeadline) { + usleep(20000); + $first = $client->getStringValue('flag', 'fallback'); + } + if ($first !== 'blue') throw new RuntimeException('initial configuration unavailable: ' . json_encode(\DDTrace\Internal\ffe_provider_state())); + echo "fixture_restart_ready\n"; + $retained = false; + $deadline = microtime(true) + 12; + do { + $value = $client->getStringValue('flag', 'fallback'); + $state = \DDTrace\Internal\ffe_provider_state(); + if (!$retained && $value === 'blue' && $state['reason'] === 'stale') { + $retained = true; + echo "last_configuration_retained\n"; + } + if ($value === 'green' && $state['ready']) break; + if ($value !== 'blue') throw new RuntimeException('sidecar restart lost configuration'); + usleep(20000); + } while (microtime(true) < $deadline); + if (!$retained || $value !== 'green') throw new RuntimeException('sidecar restart did not recover'); + echo "sidecar_restarted_and_refreshed\n"; + exit; +} + +if ($scenario === 'fork') { + if ($first !== 'blue') throw new RuntimeException('initial fetch failed'); + $version = \DDTrace\ffe_config_version(); + $pid = pcntl_fork(); + if ($pid < 0) throw new RuntimeException('fork failed'); + // The HTTP fixture only serves green after this marker. Both copies must + // read a new configuration after the split to observe it. + echo $pid === 0 ? "child_forked\n" : "parent_forked\n"; + $deadline = microtime(true) + 7; + $value = $first; + do { + // Merely evaluating would invoke first-use activation again and hide + // a broken fork callback. Require the background reader to publish a + // new snapshot before another evaluation is allowed to run. + $currentVersion = \DDTrace\ffe_config_version(); + if ($currentVersion !== $version) { + $version = $currentVersion; + $value = $client->getStringValue('flag', 'fallback'); + } + if ($value === 'green') break; + if ($value !== 'blue') throw new RuntimeException('fork lost configuration'); + usleep(20000); + } while (microtime(true) < $deadline); + if ($value !== 'green') throw new RuntimeException('forked worker did not resume'); + echo $pid === 0 ? "child_recovered\n" : "parent_recovered\n"; + if ($pid > 0) { + pcntl_waitpid($pid, $status); + if (!pcntl_wifexited($status) || pcntl_wexitstatus($status) !== 0) { + throw new RuntimeException('child failed'); + } + echo "fork_clean_exit\n"; + } + exit; +} + +if ($first !== 'fallback') throw new RuntimeException('unexpected initial value'); +if ($elapsed < ($instrumented ? 1.5 : 0.15) || $elapsed > ($instrumented ? 10 : 1)) throw new RuntimeException('initialization deadline not honored: elapsed=' . $elapsed); +echo "initial_deadline_bounded\n"; +$start = microtime(true); +for ($i = 0; $i < 10; $i++) $client->getStringValue('flag', 'fallback'); +if (microtime(true) - $start > ($instrumented ? 3 : 0.5)) throw new RuntimeException('initialization budget was renewed: elapsed=' . (microtime(true) - $start)); +echo "later_evaluations_do_not_wait\n"; +if ($scenario === 'shutdown') { + // The initialization deadline can expire before the worker is scheduled. + // Confirm the fixture has a request before measuring its cancellation. + stream_set_timeout(STDIN, $instrumented ? 20 : 5); + if (fgets(STDIN) !== "request_received\n") { + throw new RuntimeException('fixture did not observe an in-flight request'); + } + // Shutdown must not wait for the configured 30-second request timeout. + exit; +} +$deadline = microtime(true) + ($instrumented ? 20 : 7); +do { + $value = $client->getStringValue('flag', 'fallback'); + if ($value === 'blue') break; + usleep(20000); +} while (microtime(true) < $deadline); +if ($value !== 'blue') throw new RuntimeException('initial failure did not recover'); +echo "initial_failure_recovered\n"; diff --git a/tests/ext/ffe/stubs/agentless_settings_app.php b/tests/ext/ffe/stubs/agentless_settings_app.php new file mode 100644 index 00000000000..920a2088fc7 --- /dev/null +++ b/tests/ext/ffe/stubs/agentless_settings_app.php @@ -0,0 +1,7 @@ +getStringDetails('flag', 'fallback'); +echo json_encode(array('value' => $details->getValue(), 'state' => $details->getProviderState(), 'error' => $details->getErrorMessage())); diff --git a/tests/ext/ffe/stubs/agentless_source_selection.inc b/tests/ext/ffe/stubs/agentless_source_selection.inc new file mode 100644 index 00000000000..31bc3bd5d24 --- /dev/null +++ b/tests/ext/ffe/stubs/agentless_source_selection.inc @@ -0,0 +1,49 @@ + $case) { + $environment['DD_TRACE_SIDECAR_CONNECTION_MODE'] = $name === 'thread_mode' ? 'thread' : 'auto'; + foreach ($keys as $i => $key) { + unset($environment[$key]); + if ($case[$i] !== null) $environment[$key] = $case[$i]; + } + $process = ffeTestProcess(__DIR__ . '/agentless_source_selection_app.php', $pipes, $environment); + if (!is_resource($process)) throw new RuntimeException('cannot launch PHP'); + fclose($pipes[0]); + $stdout = $stderr = ''; + $start = microtime(true); + $readyAt = null; + do { + $stdout .= ffeTestProcessRead($pipes[1]); + $stderr .= ffeTestProcessRead($pipes[2]); + if ($readyAt === null && strpos($stdout, "fixture_child_ready\n") !== false) $readyAt = microtime(true); + $status = proc_get_status($process); + if (!$status['running']) break; + if ($connection = @stream_socket_accept($server, 0.05)) { + fclose($connection); + proc_terminate($process); + throw new RuntimeException($name . ' unexpectedly polled agentless configuration'); + } + } while (microtime(true) - ($readyAt === null ? $start : $readyAt) < ($readyAt === null ? 60 : 3)); + $stdout .= ffeTestProcessRead($pipes[1]); + $stderr .= ffeTestProcessRead($pipes[2]); + if ($status['running']) proc_terminate($process); + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($process); + if ($status['running'] || $status['exitcode'] !== 0) throw new RuntimeException($name . ' failed: ' . $stderr); + $state = json_decode(str_replace("fixture_child_ready\n", '', $stdout), true); + if ($state['enabled'] !== $case[3] || $state['mode'] !== $case[4] || $state['ready']) { + throw new RuntimeException($name . ' wrong source: ' . $stdout); + } + if ($name === 'thread_mode' && $state['reason'] !== 'unsupported_sidecar_mode') { + throw new RuntimeException('thread mode did not report its permanent failure'); + } + echo $name, "=ok\n"; +} +fclose($server); diff --git a/tests/ext/ffe/stubs/agentless_source_selection_app.php b/tests/ext/ffe/stubs/agentless_source_selection_app.php new file mode 100644 index 00000000000..dba027f8229 --- /dev/null +++ b/tests/ext/ffe/stubs/agentless_source_selection_app.php @@ -0,0 +1,5 @@ + true)); + if (!is_resource($process)) throw new RuntimeException('cannot launch PHP child'); + $pipes[1] = fopen($logs[0], 'r'); + $pipes[2] = fopen($logs[1], 'r'); + return $process; +} + +function ffeTestProcessRead($stream) +{ + // Clear PHP's EOF state before checking whether the child appended output. + if (fseek($stream, ftell($stream)) !== 0) throw new RuntimeException('cannot read child output'); + return stream_get_contents($stream); +} diff --git a/tests/ext/telemetry/config.phpt b/tests/ext/telemetry/config.phpt index c3a6c4aac80..1284c4d80e8 100644 --- a/tests/ext/telemetry/config.phpt +++ b/tests/ext/telemetry/config.phpt @@ -18,6 +18,7 @@ DD_TRACE_GENERATE_ROOT_SPAN=0 DD_TRACE_AUTOFINISH_SPANS=1 DD_INSTRUMENTATION_TELEMETRY_ENABLED=1 DD_AGENT_HOST=127.0.0.2 +DD_SITE=datadoghq.eu DD_TRACE_AGENT_PORT=3 DD_AUTOLOAD_NO_COMPILE= DD_TRACE_AGENT_TIMEOUT=200 @@ -98,6 +99,15 @@ Array ) [2] => Array + ( + [name] => DD_SITE + [value] => datadoghq.eu + [origin] => env_var + [config_id] =>%w + [seq_id] =>%w + ) + + [3] => Array ( [name] => DD_TRACE_AGENT_PORT [value] => 3 @@ -106,7 +116,7 @@ Array [seq_id] => ) - [3] => Array + [4] => Array ( [name] => DD_INSTRUMENTATION_TELEMETRY_ENABLED [value] => 1 @@ -115,7 +125,7 @@ Array [seq_id] => ) - [4] => Array + [5] => Array ( [name] => DD_TRACE_GIT_METADATA_ENABLED [value] => 0 @@ -124,7 +134,7 @@ Array [seq_id] => ) - [5] => Array + [6] => Array ( [name] => DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED [value] => 0 @@ -133,7 +143,7 @@ Array [seq_id] => ) - [6] => Array + [7] => Array ( [name] => DD_TRACE_RATE_LIMIT [value] => 1000000 @@ -142,7 +152,7 @@ Array [seq_id] => ) - [7] => Array + [8] => Array ( [name] => DD_TRACE_IGNORE_AGENT_SAMPLING_RATES [value] => 1 @@ -151,7 +161,7 @@ Array [seq_id] => ) - [8] => Array + [9] => Array ( [name] => DD_TRACE_GENERATE_ROOT_SPAN [value] => 0 @@ -160,7 +170,7 @@ Array [seq_id] => ) - [9] => Array + [10] => Array ( [name] => ssi_forced_injection_enabled [value] => False diff --git a/tooling/generate-supported-configurations.sh b/tooling/generate-supported-configurations.sh index 0a70d2c2614..0ce7071c8b0 100755 --- a/tooling/generate-supported-configurations.sh +++ b/tooling/generate-supported-configurations.sh @@ -63,6 +63,12 @@ function map_type($raw) { } function normalize_default($v, $type, $name) { + // PHP INI represents an unset optional URL as an empty string. The FFE + // settings resolver converts it to None and selects the managed endpoint; + // report that semantic default rather than an empty custom endpoint. + if ($name === 'DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL' && $v === '') { + return null; + } if (strtoupper($v) === 'NULL') { // OTEL env vars are string-typed and use "" (not null) as their "unset" default. if (strpos($name, 'OTEL_') === 0) { diff --git a/tracer/configuration.h b/tracer/configuration.h index ca83da9d11e..9e7291fb493 100644 --- a/tracer/configuration.h +++ b/tracer/configuration.h @@ -163,7 +163,13 @@ CONFIG(BOOL, DD_TRACE_RESOURCE_RENAMING_ENABLED, "false") \ CONFIG(BOOL, DD_TRACE_RESOURCE_RENAMING_ALWAYS_SIMPLIFIED_ENDPOINT, "false") \ CONFIG(BOOL, DD_TRACE_STATS_COMPUTATION_ENABLED, "false") \ + CONFIG(BOOL, DD_FEATURE_FLAGS_ENABLED, "true") \ + CONFIG(STRING, DD_FEATURE_FLAGS_CONFIGURATION_SOURCE, "agentless") \ + CONFIG(STRING, DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL, "", .sensitive = true) \ + CONFIG(INT, DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS, "30") \ + CONFIG(INT, DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS, "5") \ CONFIG(BOOL, DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED, "false") \ + CONFIG(INT, DD_EXPERIMENTAL_FLAGGING_PROVIDER_INITIALIZATION_TIMEOUT_MS, "10000") \ DD_INTEGRATIONS #ifndef DDTRACE_CONFIGURATION diff --git a/tracer/ddtrace.c b/tracer/ddtrace.c index bcd418d0d4d..0ed76b93d60 100644 --- a/tracer/ddtrace.c +++ b/tracer/ddtrace.c @@ -375,6 +375,7 @@ void ddtrace_minit_late() { } void ddtrace_mshutdown() { + ddog_ffe_shutdown(); zai_uhook_mshutdown(); zai_hook_mshutdown(); @@ -696,10 +697,11 @@ bool datadog_alter_dd_trace_disabled_config(zval *old_value, zval *new_value, ze } bool ddtrace_update_remote_config_flags(ddog_RemoteConfigFlags *flags) { - flags->ffe_enabled = get_global_DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED(); + ddog_FfeRuntimeConfig ffe = ddtrace_ffe_configure(); + flags->ffe_enabled = ffe.enabled && ffe.source == DDOG_FFE_CONFIGURATION_SOURCE_REMOTE_CONFIG; flags->live_debugging_enabled = get_global_DD_DYNAMIC_INSTRUMENTATION_ENABLED(); return get_global_DD_TRACE_SIDECAR_TRACE_SENDER() - || get_global_DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED() + || (ffe.enabled && (flags->ffe_enabled || ffe.endpoint_valid)) || get_global_DD_METRICS_OTEL_ENABLED(); } @@ -713,9 +715,11 @@ void ddtrace_internal_handle_prefork(void) { ddtrace_coms_flush_shutdown_writer_synchronous(); } #endif + ddog_ffe_prepare_for_fork(); } void ddtrace_internal_handle_postfork(void) { + ddog_ffe_resume_after_fork(false); #if JOIN_BGS_BEFORE_FORK if (!get_global_DD_TRACE_SIDECAR_TRACE_SENDER()) { ddtrace_coms_restart_writer(); diff --git a/tracer/ddtrace.stub.php b/tracer/ddtrace.stub.php index adac5b1f10c..891661e8fac 100644 --- a/tracer/ddtrace.stub.php +++ b/tracer/ddtrace.stub.php @@ -1093,6 +1093,8 @@ function record_ffe_evaluation_metric(string $flagKey, ?string $variant, ?string */ function flush_ffe_evaluation_metrics(): bool {} + function ffe_provider_state(): array {} + } namespace datadog\appsec\v2 { diff --git a/tracer/ddtrace_arginfo.h b/tracer/ddtrace_arginfo.h index afa0f62d9f7..8571f7b8a16 100644 --- a/tracer/ddtrace_arginfo.h +++ b/tracer/ddtrace_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit ddtrace.stub.php instead. - * Stub hash: b3087c1f239d5aa8ea38f875b7236f47e56a1ca7 */ + * Stub hash: 81bf7c5fbfff4290cefd664ad1895e765a5ec54a */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_trace_method, 0, 3, _IS_BOOL, 0) ZEND_ARG_TYPE_INFO(0, className, IS_STRING, 0) @@ -258,6 +258,8 @@ ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_Internal_flush_ffe_evaluation_metrics, 0, 0, _IS_BOOL, 0) ZEND_END_ARG_INFO() +#define arginfo_DDTrace_Internal_ffe_provider_state arginfo_DDTrace_current_context + ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_datadog_appsec_v2_track_user_login_success, 0, 1, IS_VOID, 0) ZEND_ARG_TYPE_INFO(0, login, IS_STRING, 0) ZEND_ARG_TYPE_MASK(0, user, MAY_BE_STRING|MAY_BE_ARRAY|MAY_BE_NULL, "null") @@ -445,6 +447,7 @@ ZEND_FUNCTION(DDTrace_Internal_add_span_flag); ZEND_FUNCTION(DDTrace_Internal_handle_fork); ZEND_FUNCTION(DDTrace_Internal_record_ffe_evaluation_metric); ZEND_FUNCTION(DDTrace_Internal_flush_ffe_evaluation_metrics); +ZEND_FUNCTION(DDTrace_Internal_ffe_provider_state); ZEND_FUNCTION(datadog_appsec_v2_track_user_login_success); ZEND_FUNCTION(datadog_appsec_v2_track_user_login_failure); ZEND_FUNCTION(dd_trace_env_config); @@ -547,6 +550,7 @@ static const zend_function_entry ext_functions[] = { ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace\\Internal", "handle_fork"), zif_DDTrace_Internal_handle_fork, arginfo_DDTrace_Internal_handle_fork, 0, NULL, NULL) ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace\\Internal", "record_ffe_evaluation_metric"), zif_DDTrace_Internal_record_ffe_evaluation_metric, arginfo_DDTrace_Internal_record_ffe_evaluation_metric, 0, NULL, NULL) ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace\\Internal", "flush_ffe_evaluation_metrics"), zif_DDTrace_Internal_flush_ffe_evaluation_metrics, arginfo_DDTrace_Internal_flush_ffe_evaluation_metrics, 0, NULL, NULL) + ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace\\Internal", "ffe_provider_state"), zif_DDTrace_Internal_ffe_provider_state, arginfo_DDTrace_Internal_ffe_provider_state, 0, NULL, NULL) ZEND_RAW_FENTRY(ZEND_NS_NAME("datadog\\appsec\\v2", "track_user_login_success"), zif_datadog_appsec_v2_track_user_login_success, arginfo_datadog_appsec_v2_track_user_login_success, 0, NULL, NULL) ZEND_RAW_FENTRY(ZEND_NS_NAME("datadog\\appsec\\v2", "track_user_login_failure"), zif_datadog_appsec_v2_track_user_login_failure, arginfo_datadog_appsec_v2_track_user_login_failure, 0, NULL, NULL) ZEND_FE(dd_trace_env_config, arginfo_dd_trace_env_config) diff --git a/tracer/ffe.c b/tracer/ffe.c index 884b49bdf11..74a1bdab1e4 100644 --- a/tracer/ffe.c +++ b/tracer/ffe.c @@ -16,6 +16,35 @@ ZEND_EXTERN_MODULE_GLOBALS(datadog); #define DD_FFE_METRIC_BUFFER_LIMIT 1000 #define DD_FFE_EXPOSURE_BUFFER_LIMIT 1000 +ddog_FfeRuntimeConfig ddtrace_ffe_configure(void) { +#ifdef _WIN32 + // CDN can own a subprocess connection even when other products are disabled. + DDOG_PHP_FUNCTION = (const uint8_t *)zend_hash_func; +#endif + const ddog_FfeSettingsInput settings = { + .enabled = get_global_DD_FEATURE_FLAGS_ENABLED(), + .enabled_set = zai_config_memoized_entries[DATADOG_CONFIG_DD_FEATURE_FLAGS_ENABLED].name_index != ZAI_CONFIG_ORIGIN_DEFAULT, + .source = dd_zend_string_to_CharSlice(get_global_DD_FEATURE_FLAGS_CONFIGURATION_SOURCE()), + .source_set = zai_config_memoized_entries[DATADOG_CONFIG_DD_FEATURE_FLAGS_CONFIGURATION_SOURCE].name_index != ZAI_CONFIG_ORIGIN_DEFAULT, + .legacy_enabled = get_global_DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED(), + .legacy_enabled_set = zai_config_memoized_entries[DATADOG_CONFIG_DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED].name_index != ZAI_CONFIG_ORIGIN_DEFAULT, + .agentless_base_url = dd_zend_string_to_CharSlice(get_global_DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL()), + .poll_interval_seconds = get_global_DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS(), + .request_timeout_seconds = get_global_DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS(), + .initialization_timeout_ms = get_global_DD_EXPERIMENTAL_FLAGGING_PROVIDER_INITIALIZATION_TIMEOUT_MS(), + .site = dd_zend_string_to_CharSlice(get_global_DD_SITE()), + .api_key = dd_zend_string_to_CharSlice(get_global_DD_API_KEY()), + .environment = dd_zend_string_to_CharSlice(get_global_DD_ENV()), + .sidecar_subprocess_enabled = get_global_DD_TRACE_SIDECAR_CONNECTION_MODE() != DD_TRACE_SIDECAR_CONNECTION_MODE_THREAD, + .sidecar_self_telemetry = get_global_DD_INSTRUMENTATION_TELEMETRY_ENABLED(), +#ifdef CXA_THREAD_ATEXIT_WRAPPER + .thread_cleanup = dd_run_rust_thread_destructors, +#endif + }; + return ddog_ffe_configure(&settings); +} + + typedef struct { zend_string *flag_key; zend_string *variant; diff --git a/tracer/ffe.h b/tracer/ffe.h index 4da7f951f28..d3f4678c267 100644 --- a/tracer/ffe.h +++ b/tracer/ffe.h @@ -5,6 +5,9 @@ #include #include #include +#include + +ddog_FfeRuntimeConfig ddtrace_ffe_configure(void); bool ddtrace_ffe_record_evaluation_metric(zend_string *flag_key, zend_string *variant, const char *reason, const char *error_type, zend_string *allocation_key); bool ddtrace_ffe_flush_evaluation_metrics(void); diff --git a/tracer/functions.c b/tracer/functions.c index 5657740cb11..0741a830e20 100644 --- a/tracer/functions.c +++ b/tracer/functions.c @@ -1874,6 +1874,7 @@ PHP_FUNCTION(DDTrace_ffe_evaluate) { attrs_count = idx; } + ddog_ffe_ensure_initialized(); result = ddog_ffe_evaluate( dd_zend_string_to_CharSlice(flag_key), type_id, @@ -2933,6 +2934,33 @@ PHP_FUNCTION(DDTrace_Internal_flush_ffe_evaluation_metrics) { RETURN_BOOL(ddtrace_ffe_flush_evaluation_metrics()); } +PHP_FUNCTION(DDTrace_Internal_ffe_provider_state) { + ZEND_PARSE_PARAMETERS_NONE(); + ddog_FfeRuntimeConfig config = ddog_ffe_runtime_config(); + bool has_config = ddog_ffe_has_config(); + array_init(return_value); + add_assoc_bool(return_value, "ready", config.enabled && has_config); + add_assoc_bool(return_value, "hasConfig", has_config); + add_assoc_long(return_value, "configVersion", ddog_ffe_config_version()); + add_assoc_bool(return_value, "productionRuntime", true); + add_assoc_bool(return_value, "enabled", config.enabled); + const char *mode = config.source == DDOG_FFE_CONFIGURATION_SOURCE_AGENTLESS + ? "native_agentless" : config.source == DDOG_FFE_CONFIGURATION_SOURCE_REMOTE_CONFIG + ? "native_remote_config" : "native_disabled"; + add_assoc_string(return_value, "mode", (char *) mode); + ddog_CharSlice failure_reason = ddog_ffe_initialization_error_reason(); + ddog_CharSlice failure_message = ddog_ffe_initialization_error_message(); + if (!has_config && failure_reason.len) { + add_assoc_stringl(return_value, "reason", (char *)failure_reason.ptr, failure_reason.len); + add_assoc_stringl(return_value, "errorMessage", (char *)failure_message.ptr, failure_message.len); + } else { + add_assoc_string(return_value, "reason", !config.enabled ? "disabled" + : has_config ? (ddog_ffe_delivery_state() == DDOG_DELIVERY_STATE_STALE ? "stale" : "ready") + : "configuration_missing"); + } +} + + /* {{{ proto array generate_distributed_tracing_headers() */ PHP_FUNCTION(DDTrace_generate_distributed_tracing_headers) { zend_array *inject = NULL; diff --git a/zend_abstract_interface/config/config.h b/zend_abstract_interface/config/config.h index 41ea02e2fbb..ba5cd28e1d2 100644 --- a/zend_abstract_interface/config/config.h +++ b/zend_abstract_interface/config/config.h @@ -20,7 +20,9 @@ typedef uint16_t zai_config_id; #define ZAI_CONFIG_ENTRIES_COUNT_MAX 320 #define ZAI_CONFIG_NAMES_COUNT_MAX 4 -#define ZAI_CONFIG_NAME_BUFSIZ 72 +// Accommodate the longest supported environment name after its `DD_` prefix is +// expanded to the `datadog.` INI prefix. +#define ZAI_CONFIG_NAME_BUFSIZ 80 #define ZAI_CONFIG_ENTRY(_id, _name, _type, default, ...) \ { \