From 4ac8205b19e89bfac82133b224f698b644ad352c Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 8 Oct 2026 03:23:30 +0000 Subject: [PATCH 01/15] Add bounded feature flag evaluation EVP reporting --- Cargo.lock | 37 ++ components-rs/common.h | 324 ++++++++++++------ components-rs/ffe.rs | 51 ++- components-rs/library-config.h | 71 ++-- components-rs/sidecar.h | 83 ++++- ext/sidecar.c | 13 + libdatadog | 2 +- metadata/supported-configurations.json | 7 + .../FeatureFlags/Internal/NativeEvaluator.php | 32 +- tests/ext/ffe/evaluation_metrics_native.phpt | 4 +- .../ffe/flag_evaluation_final_outcome.phpt | 65 ++++ .../ext/ffe/flag_evaluation_kill_switch.phpt | 17 + .../ffe/flag_evaluation_openfeature_wire.phpt | 38 ++ tests/ext/ffe/flag_evaluation_wire.phpt | 37 ++ tests/ext/ffe/native_bridge_evaluate.phpt | 14 +- tests/ext/ffe/remote_config_lifecycle.phpt | 2 +- tests/ext/ffe/stubs/flag_evaluation_wire.inc | 122 +++++++ .../ffe/stubs/flag_evaluation_wire_app.php | 60 ++++ tracer/configuration.h | 1 + tracer/ddtrace.stub.php | 4 + tracer/ddtrace_arginfo.h | 258 +++++++------- tracer/ffe.c | 92 +++++ tracer/ffe.h | 4 + tracer/functions.c | 19 + 24 files changed, 1093 insertions(+), 264 deletions(-) create mode 100644 tests/ext/ffe/flag_evaluation_final_outcome.phpt create mode 100644 tests/ext/ffe/flag_evaluation_kill_switch.phpt create mode 100644 tests/ext/ffe/flag_evaluation_openfeature_wire.phpt create mode 100644 tests/ext/ffe/flag_evaluation_wire.phpt create mode 100644 tests/ext/ffe/stubs/flag_evaluation_wire.inc create mode 100644 tests/ext/ffe/stubs/flag_evaluation_wire_app.php diff --git a/Cargo.lock b/Cargo.lock index 01fe34104d0..8306e8b007b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -809,6 +809,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" @@ -1343,6 +1353,8 @@ dependencies = [ name = "datadog-sidecar-ffi" version = "0.0.1" dependencies = [ + "build_common", + "cc", "datadog-sidecar", "http 1.4.2", "libc 0.2.186", @@ -1360,6 +1372,8 @@ dependencies = [ "libdd-trace-utils", "rmp-serde", "serde_json", + "tempfile", + "tools", "tracing", ] @@ -4638,6 +4652,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" @@ -6246,6 +6269,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" diff --git a/components-rs/common.h b/components-rs/common.h index 3466696c884..60b9c5a93f8 100644 --- a/components-rs/common.h +++ b/components-rs/common.h @@ -61,6 +61,11 @@ typedef struct ddog_Endpoint ddog_Endpoint; */ typedef struct ddog_MutableMetadataHandle ddog_MutableMetadataHandle; +typedef struct ddog_ArrayQueue { + struct ddog_ArrayQueue *inner; + void (*item_delete_fn)(void*); +} ddog_ArrayQueue; + /** * Holds the raw parts of a Rust Vec; it should only be created from Rust, * never from C. @@ -84,46 +89,6 @@ typedef struct ddog_Error { struct ddog_Vec_U8 message; } ddog_Error; -typedef struct ddog_Slice_CChar { - /** - * Should be non-null and suitably aligned for the underlying type. It is - * allowed but not recommended for the pointer to be null when the len is - * zero. - */ - const char *ptr; - /** - * The number of elements (not bytes) that `.ptr` points to. Must be less - * than or equal to [isize::MAX]. - */ - uintptr_t len; -} ddog_Slice_CChar; - -/** - * Use to represent strings -- should be valid UTF-8. - */ -typedef struct ddog_Slice_CChar ddog_CharSlice; - -typedef enum ddog_Option_Error_Tag { - DDOG_OPTION_ERROR_SOME_ERROR, - DDOG_OPTION_ERROR_NONE_ERROR, -} ddog_Option_Error_Tag; - -typedef struct ddog_Option_Error { - ddog_Option_Error_Tag tag; - union { - struct { - struct ddog_Error some; - }; - }; -} ddog_Option_Error; - -typedef struct ddog_Option_Error ddog_MaybeError; - -typedef struct ddog_ArrayQueue { - struct ddog_ArrayQueue *inner; - void (*item_delete_fn)(void*); -} ddog_ArrayQueue; - typedef enum ddog_ArrayQueue_NewResult_Tag { DDOG_ARRAY_QUEUE_NEW_RESULT_OK, DDOG_ARRAY_QUEUE_NEW_RESULT_ERR, @@ -217,6 +182,41 @@ typedef struct ddog_ArrayQueue_UsizeResult { }; } ddog_ArrayQueue_UsizeResult; +typedef struct ddog_Slice_CChar { + /** + * Should be non-null and suitably aligned for the underlying type. It is + * allowed but not recommended for the pointer to be null when the len is + * zero. + */ + const char *ptr; + /** + * The number of elements (not bytes) that `.ptr` points to. Must be less + * than or equal to [isize::MAX]. + */ + uintptr_t len; +} ddog_Slice_CChar; + +/** + * Use to represent strings -- should be valid UTF-8. + */ +typedef struct ddog_Slice_CChar ddog_CharSlice; + +typedef enum ddog_Option_Error_Tag { + DDOG_OPTION_ERROR_SOME_ERROR, + DDOG_OPTION_ERROR_NONE_ERROR, +} ddog_Option_Error_Tag; + +typedef struct ddog_Option_Error { + ddog_Option_Error_Tag tag; + union { + struct { + struct ddog_Error some; + }; + }; +} ddog_Option_Error; + +typedef struct ddog_Option_Error ddog_MaybeError; + /** * A generic result type for when an operation may fail, * but there's nothing to return in the case of success. @@ -510,7 +510,8 @@ typedef struct ddog_SidecarActionsBuffer ddog_SidecarActionsBuffer; * `SidecarTransport` wraps a [`SidecarSender`] with transparent reconnection support. * * This transport is used for communication between different parts of the sidecar service. - * It is a blocking transport (all operations block the current thread). + * Most operations block; the FFE check/try-submit functions explicitly do not + * wait for the sender lock, capacity, or reconnection. */ typedef struct ddog_SidecarTransport ddog_SidecarTransport; @@ -536,6 +537,7 @@ typedef struct ddog_FfeResult { int32_t serial_id; bool has_serial_id; bool do_log; + bool observe_full_evaluation_data; bool valid; } ddog_FfeResult; @@ -1210,6 +1212,67 @@ 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; + +/** + * A successful readiness check reserves nothing; submission rechecks admission. + * Count a rejected observation once, not once for each check. These outcomes + * are not metric names and must not be reported through the rejected EVP path. + */ +typedef enum ddog_FfeSubmissionStatus { + /** + * Advisory only; no snapshot or message has been accepted. + */ + DDOG_FFE_SUBMISSION_STATUS_READY, + /** + * Accepted by the local transport, not necessarily delivered to the sidecar or intake. + */ + DDOG_FFE_SUBMISSION_STATUS_ACCEPTED, + /** + * Absent, closed, or poisoned transport. Recovery belongs to ordinary lifecycle code. + */ + DDOG_FFE_SUBMISSION_STATUS_UNAVAILABLE, + /** + * Another caller owns the sender; no waiting was attempted. + */ + DDOG_FFE_SUBMISSION_STATUS_BUSY, + /** + * Shared outstanding-message ceiling reached before snapshot construction. + */ + DDOG_FFE_SUBMISSION_STATUS_QUEUE_FULL, + /** + * Rejected by the shared low-priority shedding policy before snapshot construction. + */ + DDOG_FFE_SUBMISSION_STATUS_LOAD_SHED, + /** + * Required priority messages could not be sent first. + */ + DDOG_FFE_SUBMISSION_STATUS_PRIORITY_PENDING, + /** + * The observation could not be accepted immediately after admission. + */ + DDOG_FFE_SUBMISSION_STATUS_WOULD_BLOCK, + /** + * Invalid required input or a request other than one FFE observation. + */ + DDOG_FFE_SUBMISSION_STATUS_INVALID_INPUT, + /** + * Exceeds the existing IPC packet ceiling; the connection remains usable. + */ + DDOG_FFE_SUBMISSION_STATUS_PAYLOAD_TOO_LARGE, + /** + * Request encoding failed before any bytes were sent. + */ + DDOG_FFE_SUBMISSION_STATUS_ENCODING_ERROR, +} ddog_FfeSubmissionStatus; + typedef struct ddog_AgentRemoteConfigReader ddog_AgentRemoteConfigReader; typedef struct ddog_AgentRemoteConfigWriter_ShmHandle ddog_AgentRemoteConfigWriter_ShmHandle; @@ -1271,12 +1334,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; @@ -1305,6 +1385,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; @@ -1316,13 +1399,18 @@ typedef struct ddog_FfeFlagEvaluation { ddog_CharSlice targeting_rule_key; ddog_CharSlice targeting_key; /** - * UTF-8 JSON object. Empty, invalid, or non-object JSON is omitted. Object - * values are pruned to 256 leaf fields, 256-byte string values, and four - * levels of nested context depth. + * UTF-8 JSON object, ignored without consent. Empty, invalid, or non-object + * JSON is omitted without rejecting the evaluation. Retained context has + * at most 256 leaves, 256-character keys/strings, 256 entries per container, + * and depth four. Legacy JSON parsing is not a bounded-cost producer API. */ ddog_CharSlice evaluation_context_json; ddog_CharSlice error_message; bool runtime_default_used; + /** + * Consent captured from the configuration used for this evaluation. + */ + bool observe_full_evaluation_data; } ddog_FfeFlagEvaluation; typedef struct ddog_Slice_FfeFlagEvaluation { @@ -1338,6 +1426,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; @@ -1407,6 +1498,47 @@ typedef struct ddog_AppsecCResponse { bool disconnect; } ddog_AppsecCResponse; +/** + * One borrowed scalar from the context used for evaluation. Only the first 256 + * entries are inspected, in input order, including entries subsequently omitted. + * Only the value field selected by `kind` is read. Nothing is retained by reference. + */ +typedef struct ddog_FfeScalarAttribute { + ddog_CharSlice key; + /** + * 0 = string, 1 = boolean, 2 = signed integer, 3 = double. + * Other values are omitted and recorded as snapshot errors. + */ + uint32_t kind; + ddog_CharSlice string_value; + bool bool_value; + int64_t integer_value; + double double_value; +} ddog_FfeScalarAttribute; + +typedef struct ddog_Slice_FfeScalarAttribute { + /** + * Should be non-null and suitably aligned for the underlying type. It is + * allowed but not recommended for the pointer to be null when the len is + * zero. + */ + const struct ddog_FfeScalarAttribute *ptr; + /** + * The number of elements (not bytes) that `.ptr` points to. Must be less + * than or equal to [isize::MAX]. + */ + uintptr_t len; +} ddog_Slice_FfeScalarAttribute; +/** + * Information lost before this native boundary. Context flags are ignored in + * protected mode; invalid original identity is never legitimized by coercion. + */ +typedef struct ddog_FfeSnapshotState { + bool context_truncated; + bool snapshot_error; + bool targeting_key_invalid; +} ddog_FfeSnapshotState; + typedef enum ddog_crasht_BuildIdType { DDOG_CRASHT_BUILD_ID_TYPE_GNU, DDOG_CRASHT_BUILD_ID_TYPE_GO, @@ -1985,6 +2117,37 @@ typedef struct ddog_ProcessInfo { ddog_CharSlice language; } ddog_ProcessInfo; +/** + * C-compatible representation of an anonymous file handle + */ +typedef struct ddog_TracerMemfdHandle { + /** + * File descriptor (relevant only on Linux) + */ + int fd; +} ddog_TracerMemfdHandle; + +/** + * A generic result type for when an operation may fail, + * or may return in case of success. + */ +typedef enum ddog_Result_TracerMemfdHandle_Tag { + DDOG_RESULT_TRACER_MEMFD_HANDLE_OK_TRACER_MEMFD_HANDLE, + DDOG_RESULT_TRACER_MEMFD_HANDLE_ERR_TRACER_MEMFD_HANDLE, +} ddog_Result_TracerMemfdHandle_Tag; + +typedef struct ddog_Result_TracerMemfdHandle { + ddog_Result_TracerMemfdHandle_Tag tag; + union { + struct { + struct ddog_TracerMemfdHandle ok; + }; + struct { + struct ddog_Error err; + }; + }; +} ddog_Result_TracerMemfdHandle; + /** * Ffi safe type representing an owned null-terminated C array * Equivalent to a std::ffi::CString @@ -2042,62 +2205,10 @@ typedef struct ddog_LibraryConfigLoggedResult { }; } ddog_LibraryConfigLoggedResult; -/** - * C-compatible representation of an anonymous file handle - */ -typedef struct ddog_TracerMemfdHandle { - /** - * File descriptor (relevant only on Linux) - */ - int fd; -} ddog_TracerMemfdHandle; - -/** - * A generic result type for when an operation may fail, - * or may return in case of success. - */ -typedef enum ddog_Result_TracerMemfdHandle_Tag { - DDOG_RESULT_TRACER_MEMFD_HANDLE_OK_TRACER_MEMFD_HANDLE, - DDOG_RESULT_TRACER_MEMFD_HANDLE_ERR_TRACER_MEMFD_HANDLE, -} ddog_Result_TracerMemfdHandle_Tag; - -typedef struct ddog_Result_TracerMemfdHandle { - ddog_Result_TracerMemfdHandle_Tag tag; - union { - struct { - struct ddog_TracerMemfdHandle ok; - }; - struct { - struct ddog_Error err; - }; - }; -} ddog_Result_TracerMemfdHandle; - #ifdef __cplusplus extern "C" { #endif // __cplusplus -/** - * Drops the error. It should not be used after this, though the - * implementation tries to limit the damage in the case of use-after-free and - * double-free scenarios. - * - * # Safety - * - * Only pass null or a pointer to a valid, mutable `ddog_Error`. - */ -void ddog_Error_drop(struct ddog_Error *error); - -/** - * Returns a CharSlice of the error's message that is valid until the error - * is dropped. - * # Safety - * Only pass null or a valid reference to a `ddog_Error`. - */ -ddog_CharSlice ddog_Error_message(const struct ddog_Error *error); - -void ddog_MaybeError_drop(ddog_MaybeError); - /** * Creates a new ArrayQueue with the given capacity and item_delete_fn. * The item_delete_fn is called when an item is dropped from the queue. @@ -2192,6 +2303,27 @@ void ddog_endpoint_set_use_system_resolver(struct ddog_Endpoint *endpoint, void ddog_endpoint_drop(struct ddog_Endpoint*); +/** + * Drops the error. It should not be used after this, though the + * implementation tries to limit the damage in the case of use-after-free and + * double-free scenarios. + * + * # Safety + * + * Only pass null or a pointer to a valid, mutable `ddog_Error`. + */ +void ddog_Error_drop(struct ddog_Error *error); + +/** + * Returns a CharSlice of the error's message that is valid until the error + * is dropped. + * # Safety + * Only pass null or a valid reference to a `ddog_Error`. + */ +ddog_CharSlice ddog_Error_message(const struct ddog_Error *error); + +void ddog_MaybeError_drop(ddog_MaybeError); + /** * Creates a shared, updatable metadata handle initialized with default values. * diff --git a/components-rs/ffe.rs b/components-rs/ffe.rs index 4964bc7b0b9..b3296bfb94a 100644 --- a/components-rs/ffe.rs +++ b/components-rs/ffe.rs @@ -106,6 +106,8 @@ pub struct FfeResult { pub serial_id: i32, pub has_serial_id: bool, pub do_log: bool, + // Consent belongs to the configuration snapshot used for this evaluation. + pub observe_full_evaluation_data: bool, pub valid: bool, } @@ -166,7 +168,12 @@ pub extern "C" fn ddog_ffe_evaluate( ffe::now(), ); - result_from_assignment(assignment) + let mut result = result_from_assignment(assignment); + result.observe_full_evaluation_data = state + .config + .as_ref() + .is_some_and(Configuration::observe_full_evaluation_data); + result }) } @@ -231,6 +238,7 @@ fn result_from_assignment(assignment: Result) serial_id: assignment.serial_id.unwrap_or(0), has_serial_id: assignment.serial_id.is_some(), do_log: assignment.do_log, + observe_full_evaluation_data: false, valid: true, } } @@ -257,6 +265,7 @@ fn result_from_assignment(assignment: Result) serial_id: 0, has_serial_id: false, do_log: false, + observe_full_evaluation_data: false, valid: true, } } @@ -273,6 +282,7 @@ fn invalid_result() -> FfeResult { serial_id: 0, has_serial_id: false, do_log: false, + observe_full_evaluation_data: false, valid: false, } } @@ -433,6 +443,45 @@ mod tests { clear_config(); } + #[test] + fn evaluation_captures_consent_from_its_configuration() { + setup_zend_string_functions(); + clear_config(); + let flag_key = CString::new("empty.targeting.shard.flag").unwrap(); + let evaluate = |value_type| { + ddog_ffe_evaluate( + char_slice(&flag_key), + value_type, + CharSlice::from(""), + std::ptr::null(), + 0, + ) + }; + assert!(!evaluate(TYPE_STRING).observe_full_evaluation_data); + + let mut config: serde_json::Value = + serde_json::from_str(EMPTY_TARGETING_KEY_CONFIG).unwrap(); + config["observeFullEvaluationData"] = true.into(); + let json = CString::new(config.to_string()).unwrap(); + assert!(ddog_ffe_load_config(char_slice(&json))); + let allowed = evaluate(TYPE_STRING); + assert!(allowed.valid); + assert!(allowed.observe_full_evaluation_data); + // Error observations use the same consent as successful assignments. + let mismatch = evaluate(TYPE_BOOLEAN); + assert_ne!(mismatch.error_code, ERROR_NONE); + assert!(mismatch.observe_full_evaluation_data); + + config["observeFullEvaluationData"] = false.into(); + let json = CString::new(config.to_string()).unwrap(); + assert!(ddog_ffe_load_config(char_slice(&json))); + assert!(!evaluate(TYPE_STRING).observe_full_evaluation_data); + // Reloading config must not retroactively change the prior outcome. + assert!(allowed.observe_full_evaluation_data); + clear_config(); + assert!(!evaluate(TYPE_STRING).observe_full_evaluation_data); + } + #[test] fn configuration_state_is_thread_local() { clear_config(); 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/sidecar.h b/components-rs/sidecar.h index 392ba92912a..00655d3b174 100644 --- a/components-rs/sidecar.h +++ b/components-rs/sidecar.h @@ -25,6 +25,7 @@ + #if defined(_WIN32) bool ddog_setup_crashtracking(const struct ddog_Endpoint *endpoint, ddog_crasht_Metadata metadata); #endif @@ -367,10 +368,32 @@ 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 the current 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 session 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. + * + * # 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 @@ -383,13 +406,17 @@ 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 - * `context` and every element in `flag_evaluations` must contain valid UTF-8 - * `CharSlice` values. Empty `flag_evaluations` is a no-op. + * All slices must reference valid memory under the CharSlice contract. Context + * metadata and flag keys must be UTF-8. Malformed optional variant, allocation, + * rule, targeting, and context text is omitted; malformed error text becomes GENERAL. + * A null/zero targeting slice means missing; a non-null empty slice means empty. + * Empty `flag_evaluations` is a no-op. Use headers and library from the same build. */ ddog_MaybeError ddog_sidecar_send_ffe_flag_evaluation_batch(struct ddog_SidecarTransport **transport, const struct ddog_InstanceId *instance_id, @@ -398,7 +425,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. @@ -561,6 +588,42 @@ struct ddog_AppsecCResponse datadog_sidecar_send_appsec_message_without_reconnec */ void ddog_sidecar_appsec_response_drop(struct ddog_AppsecCResponse response); +/** + * Advisory, non-reconnecting check before preparing borrowed descriptors. + * The caller must first honor its track kill switch and check required identity. + * `Ready` reserves nothing: submission repeats admission. Count rejection once + * per evaluation, not once per API call. A null transport is unavailable. + */ +enum ddog_FfeSubmissionStatus ddog_sidecar_check_ffe_submission(const struct ddog_SidecarTransport *transport); + +/** + * Try to submit one evaluation; never wait for the sender, reconnect, or retain + * rejected input. `Accepted` means local transport acceptance, not delivery. + * Oversized observations are retried once without targeting key or event context; + * a successfully accepted reduced observation also returns `Accepted`. Required + * metadata and optional flag dimensions must still fit the IPC packet limit. + * The sidecar owns hashing, aggregation, final EVP encoding and HTTP delivery. + * + * Context comes exclusively from `attributes`; `evaluation_context_json` is + * ignored. The row must have count one and equal first/last/evaluation timestamps. + * Protected mode does not inspect context. Invalid optional fields are omitted; + * a snapshot failure retains the evaluation without context. Use matching headers + * and library. No diagnostic is recursively submitted through this EVP path. + * + * # Safety + * All references must be valid for the call. Non-null slices that are read must + * point to live, aligned backing storage of their stated lengths. String bytes + * need not be UTF-8: malformed optional text is omitted. All C booleans must be + * valid boolean values. No pointers survive this call. + */ +enum ddog_FfeSubmissionStatus ddog_sidecar_try_submit_ffe_flag_evaluation(const struct ddog_SidecarTransport *transport, + const struct ddog_InstanceId *instance_id, + const ddog_QueueId *queue_id, + const struct ddog_FfeTelemetryContext *context, + const struct ddog_FfeFlagEvaluation *event, + struct ddog_Slice_FfeScalarAttribute attributes, + const struct ddog_FfeSnapshotState *snapshot); + #if defined(_WIN32) /** * Create a Windows notification that invokes `callback(context)` when remote configuration may diff --git a/ext/sidecar.c b/ext/sidecar.c index a6ca821e623..9557e8586cd 100644 --- a/ext/sidecar.c +++ b/ext/sidecar.c @@ -178,6 +178,19 @@ static void dd_sidecar_post_connect(ddog_SidecarTransport **transport, bool is_f ddog_endpoint_drop(otlp_metrics_endpoint); } +#ifdef DDTRACE + // Both FFE writers belong to the PHP tracer. Configure their shared target + // during ordinary connection setup, including reconnect and fork recovery. + // Remote Config keeps the fixed Agent EVP v2 route. + const ddog_EvpProducerIdentity ffe_producer = { + .origin = DDOG_CHARSLICE_C("dd-trace-php"), + .version = DDOG_CHARSLICE_C(PHP_DDTRACE_VERSION), + }; + datadog_ffi_try("Failed configuring PHP FFE EVP transport", + ddog_sidecar_session_set_evp_transport(transport, DDOG_EVP_TRANSPORT_MODE_AGENT_ONLY, + datadog_endpoint, NULL, DDOG_CHARSLICE_C("event-platform-intake"), &ffe_producer)); +#endif + if (get_global_DD_INSTRUMENTATION_TELEMETRY_ENABLED()) { datadog_telemetry_register_services(transport); } diff --git a/libdatadog b/libdatadog index 7e9697df195..14921122b6f 160000 --- a/libdatadog +++ b/libdatadog @@ -1 +1 @@ -Subproject commit 7e9697df19593e77e6d8a4df567eaa66003cb286 +Subproject commit 14921122b6fe2cdb84138f3e0a148c55eb432530 diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index 72de20ca32f..01015c0577a 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -411,6 +411,13 @@ "default": "true" } ], + "DD_FLAGGING_EVALUATION_COUNTS_ENABLED": [ + { + "implementation": "A", + "type": "boolean", + "default": "true" + } + ], "DD_GIT_COMMIT_SHA": [ { "implementation": "B", diff --git a/src/api/FeatureFlags/Internal/NativeEvaluator.php b/src/api/FeatureFlags/Internal/NativeEvaluator.php index 929f058f733..419eeb0229e 100644 --- a/src/api/FeatureFlags/Internal/NativeEvaluator.php +++ b/src/api/FeatureFlags/Internal/NativeEvaluator.php @@ -39,11 +39,12 @@ public function evaluate( $targetingKey = null, array $attributes = array() ) { + $normalizedAttributes = $this->normalizeAttributes($attributes); $rawResult = \DDTrace\ffe_evaluate( $flagKey, $this->typeId($expectedType), $targetingKey, - $this->normalizeAttributes($attributes), + $normalizedAttributes, $this->recordMetrics ); @@ -53,6 +54,10 @@ public function evaluate( $details = $this->mapper->map($rawResult, $expectedType, $defaultValue); + // Both public APIs use this final mapped outcome. EVP counts are + // independent of OpenFeature's metric hook and the OTLP kill switch. + $this->recordFlagEvaluation($flagKey, $targetingKey, $normalizedAttributes, $rawResult, $details); + // APM feature-flag span enrichment. This is the single choke point both // the native Client and the OpenFeature DataDogProvider evaluate through, // and it is only reachable with the extension loaded (UnavailableEvaluator @@ -64,6 +69,31 @@ public function evaluate( return $details; } + private function recordFlagEvaluation($flagKey, $targetingKey, array $attributes, $rawResult, $details) + { + if (!function_exists('DDTrace\\Internal\\record_ffe_flag_evaluation')) { + return; + } + try { + $exposure = $details->getExposureData(); + $consent = is_object($rawResult) + && isset($rawResult->observeFullEvaluationData) + && $rawResult->observeFullEvaluationData === true; + \DDTrace\Internal\record_ffe_flag_evaluation( + $flagKey, + $details->getVariant(), + isset($exposure['allocationKey']) ? $exposure['allocationKey'] : null, + $targetingKey, + $attributes, + $details->getErrorCode(), + $details->getVariant() === null && !isset($exposure['serialId']), + $consent + ); + } catch (\Throwable $ignored) { + // Best-effort telemetry must not change a flag's evaluation result. + } + } + private function typeId($expectedType) { switch ($expectedType) { diff --git a/tests/ext/ffe/evaluation_metrics_native.phpt b/tests/ext/ffe/evaluation_metrics_native.phpt index c0d88766433..6d5943c254e 100644 --- a/tests/ext/ffe/evaluation_metrics_native.phpt +++ b/tests/ext/ffe/evaluation_metrics_native.phpt @@ -53,5 +53,5 @@ old_metrics_forwarder_exists=false old_exposure_forwarder_exists=false recorded=true load=true -evaluation_without_native_metric={"valueJson":"\"blue\"","variant":"blue","allocationKey":"alloc-string","reason":0,"errorCode":0,"doLog":true,"serialId":7,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} -missing_flag_without_native_metric={"valueJson":"null","variant":null,"allocationKey":null,"reason":5,"errorCode":3,"doLog":false,"serialId":null,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} +evaluation_without_native_metric={"valueJson":"\"blue\"","variant":"blue","allocationKey":"alloc-string","reason":0,"errorCode":0,"doLog":true,"observeFullEvaluationData":false,"serialId":7,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} +missing_flag_without_native_metric={"valueJson":"null","variant":null,"allocationKey":null,"reason":5,"errorCode":3,"doLog":false,"observeFullEvaluationData":false,"serialId":null,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} diff --git a/tests/ext/ffe/flag_evaluation_final_outcome.phpt b/tests/ext/ffe/flag_evaluation_final_outcome.phpt new file mode 100644 index 00000000000..55e4d6c2848 --- /dev/null +++ b/tests/ext/ffe/flag_evaluation_final_outcome.phpt @@ -0,0 +1,65 @@ +--TEST-- +FFE counts capture the final mapped outcome once, independently of OTLP metrics +--ENV-- +DD_METRICS_OTEL_ENABLED=false +DD_TRACE_GENERATE_ROOT_SPAN=0 +--FILE-- +args; +}); +$config = array( + 'createdAt' => '2026-05-22T00:00:00Z', + 'environment' => array('name' => 'test'), + 'observeFullEvaluationData' => true, + 'flags' => array('flag' => array( + 'key' => 'flag', 'enabled' => true, 'variationType' => 'STRING', + 'variations' => array('blue' => array('key' => 'blue', 'value' => 'blue')), + 'allocations' => array(array( + 'key' => 'allocation', 'rules' => array(), 'doLog' => false, + 'splits' => array(array('variationKey' => 'blue', 'serialId' => 0, 'shards' => array())), + )), + )), +); +var_dump(\DDTrace\Testing\ffe_load_config(json_encode($config))); +$client = new \DDTrace\FeatureFlags\Client(); +$context = array('targetingKey' => 'subject', 'attributes' => array('plan' => 'pro')); +var_dump($client->getStringValue('flag', 'fallback', $context)); +var_dump($client->getBooleanValue('flag', false, $context)); +var_dump($client->getStringValue('missing', 'fallback', $context)); + +// Simulate a config reload after assignment and a PHP mapping failure. +// The record must keep the assignment's consent and the mapper's final error. +$config['observeFullEvaluationData'] = false; +$reload = \DDTrace\install_hook('DDTrace\\ffe_evaluate', null, function ($hook) use ($config) { + $hook->returned->valueJson = '{invalid-json'; + \DDTrace\Testing\ffe_load_config(json_encode($config)); +}); +var_dump($client->getStringValue('flag', 'fallback', $context)); +\DDTrace\remove_hook($reload); + +// This is the evaluator mode used by OpenFeature: its own metric hook owns OTLP. +$evaluator = \DDTrace\FeatureFlags\Internal\NativeEvaluator::create(false); +var_dump($evaluator->evaluate('flag', \DDTrace\FeatureFlags\EvaluationType::STRING, + 'fallback', 'subject', array('plan' => 'pro'))->getValue()); +echo json_encode($calls, JSON_UNESCAPED_SLASHES), "\n"; +?> +--EXPECT-- +bool(true) +string(4) "blue" +bool(false) +string(8) "fallback" +string(8) "fallback" +string(4) "blue" +[["flag","blue","allocation","subject",{"plan":"pro"},null,false,true],["flag",null,null,"subject",{"plan":"pro"},"TYPE_MISMATCH",true,true],["missing",null,null,"subject",{"plan":"pro"},"FLAG_NOT_FOUND",true,true],["flag",null,null,"subject",{"plan":"pro"},"PARSE_ERROR",true,true],["flag","blue","allocation","subject",{"plan":"pro"},null,false,false]] diff --git a/tests/ext/ffe/flag_evaluation_kill_switch.phpt b/tests/ext/ffe/flag_evaluation_kill_switch.phpt new file mode 100644 index 00000000000..1c245226d3a --- /dev/null +++ b/tests/ext/ffe/flag_evaluation_kill_switch.phpt @@ -0,0 +1,17 @@ +--TEST-- +FFE count kill switch disables submission without disabling OTLP metrics +--ENV-- +DD_FLAGGING_EVALUATION_COUNTS_ENABLED=false +DD_METRICS_OTEL_ENABLED=true +--FILE-- + 'pro'), null, false, true +)); +var_dump(\DDTrace\Internal\record_ffe_evaluation_metric( + 'flag', 'blue', 'SPLIT', null, 'allocation' +)); +?> +--EXPECT-- +bool(false) +bool(true) diff --git a/tests/ext/ffe/flag_evaluation_openfeature_wire.phpt b/tests/ext/ffe/flag_evaluation_openfeature_wire.phpt new file mode 100644 index 00000000000..57a55a5cef3 --- /dev/null +++ b/tests/ext/ffe/flag_evaluation_openfeature_wire.phpt @@ -0,0 +1,38 @@ +--TEST-- +FFE OpenFeature evaluation counts and deduplicated exposures reach Agent EVP with privacy +--SKIPIF-- + +--ENV-- +DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 +DD_REMOTE_CONFIG_ENABLED=0 +DD_METRICS_OTEL_ENABLED=0 +DD_TRACE_GENERATE_ROOT_SPAN=0 +DD_TRACE_CLI_ENABLED=1 +DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED=true +DD_SERVICE=php-ffe-wire +DD_ENV=test +DD_VERSION=wire-version +DD_API_KEY=local-test-key-must-not-reach-agent +--FILE-- + +--EXPECT-- +service=php-ffe-wire +identity=bool(true) +exposures=int(2) +consented_count=int(2) +consented_subject=string(7) "subject" +consented_context=array(1) { + ["plan"]=> + string(3) "pro" +} +protected_count=int(1) +protected_subject=string(71) "sha256_a9491f4c1bf7b0cffbadcba2db8f028e4b3f2867cb59e1f3a0bc1968f3c51242" +protected_context_absent=bool(true) diff --git a/tests/ext/ffe/flag_evaluation_wire.phpt b/tests/ext/ffe/flag_evaluation_wire.phpt new file mode 100644 index 00000000000..0ad9bea2df1 --- /dev/null +++ b/tests/ext/ffe/flag_evaluation_wire.phpt @@ -0,0 +1,37 @@ +--TEST-- +FFE native evaluation counts and deduplicated exposures reach Agent EVP with privacy +--SKIPIF-- + +--ENV-- +DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 +DD_REMOTE_CONFIG_ENABLED=0 +DD_METRICS_OTEL_ENABLED=0 +DD_TRACE_GENERATE_ROOT_SPAN=0 +DD_TRACE_CLI_ENABLED=1 +DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED=true +DD_SERVICE=php-ffe-wire +DD_ENV=test +DD_VERSION=wire-version +DD_API_KEY=local-test-key-must-not-reach-agent +--FILE-- + +--EXPECT-- +service=php-ffe-wire +identity=bool(true) +exposures=int(2) +consented_count=int(2) +consented_subject=string(7) "subject" +consented_context=array(1) { + ["plan"]=> + string(3) "pro" +} +protected_count=int(1) +protected_subject=string(71) "sha256_a9491f4c1bf7b0cffbadcba2db8f028e4b3f2867cb59e1f3a0bc1968f3c51242" +protected_context_absent=bool(true) diff --git a/tests/ext/ffe/native_bridge_evaluate.phpt b/tests/ext/ffe/native_bridge_evaluate.phpt index f9fdb8761be..c5e80dc0e90 100644 --- a/tests/ext/ffe/native_bridge_evaluate.phpt +++ b/tests/ext/ffe/native_bridge_evaluate.phpt @@ -132,14 +132,14 @@ has_config_before=false native_exposure_flush_exists=true internal_exposure_flush_exists=false old_exposure_forwarder_exists=false -provider_not_ready={"valueJson":"null","variant":null,"allocationKey":null,"reason":5,"errorCode":6,"doLog":false,"serialId":null,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} +provider_not_ready={"valueJson":"null","variant":null,"allocationKey":null,"reason":5,"errorCode":6,"doLog":false,"observeFullEvaluationData":false,"serialId":null,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} load=true has_config_after=true -success={"valueJson":"\"blue\"","variant":"blue","allocationKey":"alloc-string","reason":0,"errorCode":0,"doLog":true,"serialId":7,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} +success={"valueJson":"\"blue\"","variant":"blue","allocationKey":"alloc-string","reason":0,"errorCode":0,"doLog":true,"observeFullEvaluationData":false,"serialId":7,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} object_success_value={"enabled":true,"threshold":2} object_success_metadata={"variant":"json-a","allocation_key":"alloc-json","reason":0,"error_code":0,"do_log":true} -numeric_attribute_key={"valueJson":"\"numeric-attribute-name\"","variant":"numeric-key","allocationKey":"alloc-numeric-attribute","reason":2,"errorCode":0,"doLog":true,"serialId":null,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} -empty_targeting_key={"valueJson":"\"empty-targeting-key\"","variant":"empty-target","allocationKey":"alloc-empty-targeting-key","reason":3,"errorCode":0,"doLog":true,"serialId":null,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} -missing={"valueJson":"null","variant":null,"allocationKey":null,"reason":5,"errorCode":3,"doLog":false,"serialId":null,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} -type_mismatch={"valueJson":"null","variant":null,"allocationKey":null,"reason":5,"errorCode":1,"doLog":false,"serialId":null,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} -parse_error={"valueJson":"null","variant":null,"allocationKey":null,"reason":5,"errorCode":2,"doLog":false,"serialId":null,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} +numeric_attribute_key={"valueJson":"\"numeric-attribute-name\"","variant":"numeric-key","allocationKey":"alloc-numeric-attribute","reason":2,"errorCode":0,"doLog":true,"observeFullEvaluationData":false,"serialId":null,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} +empty_targeting_key={"valueJson":"\"empty-targeting-key\"","variant":"empty-target","allocationKey":"alloc-empty-targeting-key","reason":3,"errorCode":0,"doLog":true,"observeFullEvaluationData":false,"serialId":null,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} +missing={"valueJson":"null","variant":null,"allocationKey":null,"reason":5,"errorCode":3,"doLog":false,"observeFullEvaluationData":false,"serialId":null,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} +type_mismatch={"valueJson":"null","variant":null,"allocationKey":null,"reason":5,"errorCode":1,"doLog":false,"observeFullEvaluationData":false,"serialId":null,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} +parse_error={"valueJson":"null","variant":null,"allocationKey":null,"reason":5,"errorCode":2,"doLog":false,"observeFullEvaluationData":false,"serialId":null,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} diff --git a/tests/ext/ffe/remote_config_lifecycle.phpt b/tests/ext/ffe/remote_config_lifecycle.phpt index 1a78212d634..801504bad74 100644 --- a/tests/ext/ffe/remote_config_lifecycle.phpt +++ b/tests/ext/ffe/remote_config_lifecycle.phpt @@ -83,7 +83,7 @@ reset_request_replayer(); before=false loaded=true has_config_after_add=true -success={"valueJson":"\"blue\"","variant":"blue","allocationKey":"alloc-string","reason":0,"errorCode":0,"doLog":true,"serialId":7,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} +success={"valueJson":"\"blue\"","variant":"blue","allocationKey":"alloc-string","reason":0,"errorCode":0,"doLog":true,"observeFullEvaluationData":false,"serialId":7,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} removed=true has_config_after_remove=false version_increased=true diff --git a/tests/ext/ffe/stubs/flag_evaluation_wire.inc b/tests/ext/ffe/stubs/flag_evaluation_wire.inc new file mode 100644 index 00000000000..20f8d40ec59 --- /dev/null +++ b/tests/ext/ffe/stubs/flag_evaluation_wire.inc @@ -0,0 +1,122 @@ + 0.5) break; + $connection = @stream_socket_accept($listener, 0.1); + if (!$connection) { + continue; + } + stream_set_timeout($connection, 2); + $requestLine = trim(fgets($connection)); + $requests[] = $requestLine; + $headers = array(); + while (($line = fgets($connection)) !== false && trim($line) !== '') { + $parts = explode(':', $line, 2); + if (count($parts) === 2) { + $headers[strtolower($parts[0])] = trim($parts[1]); + } + } + $length = isset($headers['content-length']) ? (int) $headers['content-length'] : 0; + $body = ''; + while (strlen($body) < $length && !feof($connection)) { + $chunk = fread($connection, $length - strlen($body)); + if ($chunk === false || $chunk === '') break; + $body .= $chunk; + } + if (strpos($requestLine, '/evp_proxy/v2/api/v2/') !== false) { + $captures[] = array($requestLine, $headers, json_decode($body, true)); + $lastCaptureAt = microtime(true); + } + $response = '{}'; + fwrite($connection, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n" . $response); + fclose($connection); + } while (microtime(true) < $deadline); + $stdout .= stream_get_contents($pipes[1]); + $stderr .= stream_get_contents($pipes[2]); + if (!$captures) { + throw new RuntimeException('no EVP capture: ' . json_encode($requests) . ' ' . $stdout . ' ' . $stderr); + } + if ($exitCode !== 0) { + throw new RuntimeException('evaluation process failed or timed out: ' . $stdout . ' ' . $stderr); + } + $rows = array(); + $exposureCount = 0; + $identity = true; + foreach ($captures as $capture) { + list($request, $headers, $payload) = $capture; + $identity = $identity && !isset($headers['dd-api-key']) + && isset($headers['dd-evp-origin']) && $headers['dd-evp-origin'] === 'dd-trace-php' + && isset($headers['dd-evp-origin-version']) && $headers['dd-evp-origin-version'] === phpversion('ddtrace'); + if (!$identity) { + echo 'identity_headers=', json_encode(array_intersect_key($headers, + array('dd-evp-origin' => true, 'dd-evp-origin-version' => true))), "\n"; + } + if (strpos($request, '/flagevaluation ') !== false) { + $context = $payload['context']; + ksort($context); + if ($context !== array('env' => 'test', 'service' => 'php-ffe-wire', 'version' => 'wire-version')) { + throw new RuntimeException('incorrect service context: ' . json_encode($payload['context'])); + } + foreach ($payload['flagEvaluations'] as $row) { + $key = $row['flag']['key']; + if (isset($rows[$key])) { + $rows[$key]['evaluation_count'] += $row['evaluation_count']; + } else { + $rows[$key] = $row; + } + } + } else { + $exposureCount += count($payload['exposures']); + } + } + echo 'service=php-ffe-wire', "\n"; + echo 'identity='; var_dump($identity); + echo 'exposures='; var_dump($exposureCount); + echo 'consented_count='; var_dump($rows['consented']['evaluation_count']); + echo 'consented_subject='; var_dump($rows['consented']['targeting_key']); + echo 'consented_context='; var_dump($rows['consented']['context']['evaluation']); + echo 'protected_count='; var_dump($rows['protected']['evaluation_count']); + echo 'protected_subject='; var_dump($rows['protected']['targeting_key']); + echo 'protected_context_absent='; var_dump(!isset($rows['protected']['context']['evaluation'])); +} finally { + if ($exitedAt === null) proc_terminate($process); + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($process); + fclose($listener); +} +?> diff --git a/tests/ext/ffe/stubs/flag_evaluation_wire_app.php b/tests/ext/ffe/stubs/flag_evaluation_wire_app.php new file mode 100644 index 00000000000..a5967b9bec1 --- /dev/null +++ b/tests/ext/ffe/stubs/flag_evaluation_wire_app.php @@ -0,0 +1,60 @@ +returned), "\n"; +}); +spl_autoload_register(function ($class) use ($root) { + if (strpos($class, 'DDTrace\\') === 0) { + $path = $root . '/src/api/' . str_replace('\\', '/', substr($class, 8)) . '.php'; + if (!is_file($path)) { + $path = $root . '/src/' . str_replace('\\', '/', $class) . '.php'; + } + if (is_file($path)) { + require_once $path; + } + } +}); + +$config = array( + 'createdAt' => '2026-05-22T00:00:00Z', + 'environment' => array('name' => 'test'), + 'observeFullEvaluationData' => true, + 'flags' => array(), +); +foreach (array('consented', 'protected') as $key) { + $config['flags'][$key] = array( + 'key' => $key, 'enabled' => true, 'variationType' => 'STRING', + 'variations' => array('blue' => array('key' => 'blue', 'value' => 'blue')), + 'allocations' => array(array( + 'key' => 'allocation', 'rules' => array(), 'doLog' => true, + 'splits' => array(array('variationKey' => 'blue', 'serialId' => 0, 'shards' => array())), + )), + ); +} +\DDTrace\Testing\ffe_load_config(json_encode($config)); +$client = new \DDTrace\FeatureFlags\Client(); +$context = array('targetingKey' => 'subject', 'attributes' => array('plan' => 'pro')); +if (getenv('PHP_FFE_WIRE_OPENFEATURE') === '1') { + require $root . '/tests/OpenFeature/vendor/autoload.php'; + $api = \OpenFeature\OpenFeatureAPI::getInstance(); + $api->setProvider(new \DDTrace\OpenFeature\DataDogProvider()); + $client = $api->getClient('php-ffe-wire'); + $context = new \OpenFeature\implementation\flags\EvaluationContext('subject', + new \OpenFeature\implementation\flags\Attributes(array('plan' => 'pro'))); +} +for ($i = 0; $i < 2; ++$i) { + if ($client->getStringValue('consented', 'fallback', $context) !== 'blue') { + throw new RuntimeException('assignment failed'); + } +} +$config['observeFullEvaluationData'] = false; +\DDTrace\Testing\ffe_load_config(json_encode($config)); +if ($client->getStringValue('protected', 'fallback', $context) !== 'blue') { + throw new RuntimeException('assignment failed'); +} +echo 'exposure_flush=', json_encode(\DDTrace\Testing\flush_ffe_exposures()), "\n"; +echo "evaluated\n"; +// Give the ordinary sidecar coalescer time to send while the parent captures. +// Shutdown delivery has its own lifecycle tests; this tests the live writer. +usleep(1000000); diff --git a/tracer/configuration.h b/tracer/configuration.h index d5120e3a2b7..1a5878daace 100644 --- a/tracer/configuration.h +++ b/tracer/configuration.h @@ -164,6 +164,7 @@ CONFIG(BOOL, DD_TRACE_RESOURCE_RENAMING_ALWAYS_SIMPLIFIED_ENDPOINT, "false") \ CONFIG(BOOL, DD_TRACE_STATS_COMPUTATION_ENABLED, "false") \ CONFIG(BOOL, DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED, "false") \ + CONFIG(BOOL, DD_FLAGGING_EVALUATION_COUNTS_ENABLED, "true") \ DD_INTEGRATIONS #ifndef DDTRACE_CONFIGURATION diff --git a/tracer/ddtrace.stub.php b/tracer/ddtrace.stub.php index adac5b1f10c..0ad50beed33 100644 --- a/tracer/ddtrace.stub.php +++ b/tracer/ddtrace.stub.php @@ -66,6 +66,7 @@ final class FfeResult { public int $reason = 0; public int $errorCode = 0; public bool $doLog = false; + public bool $observeFullEvaluationData = false; public ?int $serialId = null; public array $providerState = []; public ?string $errorMessage = null; @@ -1093,6 +1094,9 @@ function record_ffe_evaluation_metric(string $flagKey, ?string $variant, ?string */ function flush_ffe_evaluation_metrics(): bool {} + /** @internal Submit one final evaluation without waiting or reconnecting. */ + function record_ffe_flag_evaluation(string $flagKey, ?string $variant, ?string $allocationKey, ?string $targetingKey, array $attributes, ?string $errorType, bool $runtimeDefaultUsed, bool $observeFullEvaluationData): bool {} + } namespace datadog\appsec\v2 { diff --git a/tracer/ddtrace_arginfo.h b/tracer/ddtrace_arginfo.h index afa0f62d9f7..c34449762b9 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 */ +/* This is a generated file, edit the .stub.php file instead. + * Stub hash: fabc819a43be4dc1ce12f823113a9dd3d48af7cd */ 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) @@ -184,18 +184,11 @@ ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_DDTrace_ffe_evaluate, 0, 4, DDTra ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, recordMetric, _IS_BOOL, 0, "true") ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_ffe_has_config, 0, 0, _IS_BOOL, 0) -ZEND_END_ARG_INFO() +#define arginfo_DDTrace_ffe_has_config arginfo_DDTrace_are_endpoints_collected ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_ffe_config_version, 0, 0, IS_LONG, 0) ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_Testing_ffe_load_config, 0, 1, _IS_BOOL, 0) - ZEND_ARG_TYPE_INFO(0, json, IS_STRING, 0) -ZEND_END_ARG_INFO() - -#define arginfo_DDTrace_Testing_flush_ffe_exposures arginfo_DDTrace_are_endpoints_collected - ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_System_container_id, 0, 0, IS_STRING, 1) ZEND_END_ARG_INFO() @@ -229,6 +222,12 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_UserRequest_set_blocking ZEND_ARG_TYPE_INFO(0, blockingFunction, IS_CALLABLE, 0) ZEND_END_ARG_INFO() +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_Testing_ffe_load_config, 0, 1, _IS_BOOL, 0) + ZEND_ARG_TYPE_INFO(0, json, IS_STRING, 0) +ZEND_END_ARG_INFO() + +#define arginfo_DDTrace_Testing_flush_ffe_exposures arginfo_DDTrace_are_endpoints_collected + ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_Testing_trigger_error, 0, 2, IS_VOID, 0) ZEND_ARG_TYPE_INFO(0, message, IS_STRING, 0) ZEND_ARG_TYPE_INFO(0, errorType, IS_LONG, 0) @@ -255,7 +254,17 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_Internal_record_ffe_eval ZEND_ARG_TYPE_INFO(0, allocationKey, IS_STRING, 1) ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_Internal_flush_ffe_evaluation_metrics, 0, 0, _IS_BOOL, 0) +#define arginfo_DDTrace_Internal_flush_ffe_evaluation_metrics arginfo_DDTrace_are_endpoints_collected + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_Internal_record_ffe_flag_evaluation, 0, 8, _IS_BOOL, 0) + ZEND_ARG_TYPE_INFO(0, flagKey, IS_STRING, 0) + ZEND_ARG_TYPE_INFO(0, variant, IS_STRING, 1) + ZEND_ARG_TYPE_INFO(0, allocationKey, IS_STRING, 1) + ZEND_ARG_TYPE_INFO(0, targetingKey, IS_STRING, 1) + ZEND_ARG_TYPE_INFO(0, attributes, IS_ARRAY, 0) + ZEND_ARG_TYPE_INFO(0, errorType, IS_STRING, 1) + ZEND_ARG_TYPE_INFO(0, runtimeDefaultUsed, _IS_BOOL, 0) + ZEND_ARG_TYPE_INFO(0, observeFullEvaluationData, _IS_BOOL, 0) ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_datadog_appsec_v2_track_user_login_success, 0, 1, IS_VOID, 0) @@ -286,8 +295,7 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_dd_trace_noop, 0, 0, _IS_BOOL, 0 ZEND_ARG_VARIADIC_TYPE_INFO(0, args, IS_MIXED, 0) ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_dd_trace_dd_get_memory_limit, 0, 0, IS_LONG, 0) -ZEND_END_ARG_INFO() +#define arginfo_dd_trace_dd_get_memory_limit arginfo_DDTrace_ffe_config_version #define arginfo_dd_trace_check_memory_under_limit arginfo_DDTrace_are_endpoints_collected @@ -311,7 +319,7 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_dd_trace_buffer_span, 0, 1, _IS_ ZEND_ARG_TYPE_INFO(0, traceArray, IS_ARRAY, 0) ZEND_END_ARG_INFO() -#define arginfo_dd_trace_coms_trigger_writer_flush arginfo_dd_trace_dd_get_memory_limit +#define arginfo_dd_trace_coms_trigger_writer_flush arginfo_DDTrace_ffe_config_version ZEND_BEGIN_ARG_INFO_EX(arginfo_dd_trace_internal_fn, 0, 0, 1) ZEND_ARG_TYPE_INFO(0, functionName, IS_STRING, 0) @@ -322,11 +330,11 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_dd_trace_set_trace_id, 0, 0, _IS ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, traceId, IS_STRING, 1, "null") ZEND_END_ARG_INFO() -#define arginfo_dd_trace_closed_spans_count arginfo_dd_trace_dd_get_memory_limit +#define arginfo_dd_trace_closed_spans_count arginfo_DDTrace_ffe_config_version #define arginfo_dd_trace_tracer_is_limited arginfo_DDTrace_are_endpoints_collected -#define arginfo_dd_trace_compile_time_microseconds arginfo_dd_trace_dd_get_memory_limit +#define arginfo_dd_trace_compile_time_microseconds arginfo_DDTrace_ffe_config_version #define arginfo_dd_trace_serialize_closed_spans arginfo_DDTrace_current_context @@ -371,16 +379,16 @@ ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_DDTrace_SpanLink_fromHeader ZEND_ARG_TYPE_MASK(0, headersOrCallback, MAY_BE_ARRAY|MAY_BE_CALLABLE, NULL) ZEND_END_ARG_INFO() -#define arginfo_class_DDTrace_SpanData_getDuration arginfo_dd_trace_dd_get_memory_limit +#define arginfo_class_DDTrace_SpanData_getDuration arginfo_DDTrace_ffe_config_version -#define arginfo_class_DDTrace_SpanData_getStartTime arginfo_dd_trace_dd_get_memory_limit +#define arginfo_class_DDTrace_SpanData_getStartTime arginfo_DDTrace_ffe_config_version ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_DDTrace_SpanData_getLink, 0, 0, DDTrace\\SpanLink, 0) ZEND_END_ARG_INFO() #define arginfo_class_DDTrace_SpanData_hexId arginfo_DDTrace_startup_logs -#define arginfo_class_DDTrace_Integration_init arginfo_dd_trace_dd_get_memory_limit +#define arginfo_class_DDTrace_Integration_init arginfo_DDTrace_ffe_config_version ZEND_FUNCTION(DDTrace_trace_method); ZEND_FUNCTION(DDTrace_trace_function); @@ -428,8 +436,6 @@ ZEND_FUNCTION(DDTrace_flush_endpoints); ZEND_FUNCTION(DDTrace_ffe_evaluate); ZEND_FUNCTION(DDTrace_ffe_has_config); ZEND_FUNCTION(DDTrace_ffe_config_version); -ZEND_FUNCTION(DDTrace_Testing_ffe_load_config); -ZEND_FUNCTION(DDTrace_Testing_flush_ffe_exposures); ZEND_FUNCTION(DDTrace_System_container_id); ZEND_FUNCTION(DDTrace_System_process_tags_base_hash); ZEND_FUNCTION(DDTrace_Config_integration_analytics_enabled); @@ -438,6 +444,8 @@ ZEND_FUNCTION(DDTrace_UserRequest_has_listeners); ZEND_FUNCTION(DDTrace_UserRequest_notify_start); ZEND_FUNCTION(DDTrace_UserRequest_notify_commit); ZEND_FUNCTION(DDTrace_UserRequest_set_blocking_function); +ZEND_FUNCTION(DDTrace_Testing_ffe_load_config); +ZEND_FUNCTION(DDTrace_Testing_flush_ffe_exposures); ZEND_FUNCTION(DDTrace_Testing_trigger_error); ZEND_FUNCTION(DDTrace_Testing_emit_asm_event); ZEND_FUNCTION(DDTrace_Testing_normalize_tag_value); @@ -445,6 +453,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_record_ffe_flag_evaluation); 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 +556,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", "record_ffe_flag_evaluation"), zif_DDTrace_Internal_record_ffe_flag_evaluation, arginfo_DDTrace_Internal_record_ffe_flag_evaluation, 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) @@ -639,69 +649,75 @@ static zend_class_entry *register_class_DDTrace_FfeResult(void) zval property_valueJson_default_value; ZVAL_NULL(&property_valueJson_default_value); - zend_string *property_valueJson_name = zend_string_init("valueJson", sizeof("valueJson") - 1, true); + zend_string *property_valueJson_name = zend_string_init("valueJson", sizeof("valueJson") - 1, 1); zend_declare_typed_property(class_entry, property_valueJson_name, &property_valueJson_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING|MAY_BE_NULL)); - zend_string_release_ex(property_valueJson_name, true); + zend_string_release(property_valueJson_name); zval property_variant_default_value; ZVAL_NULL(&property_variant_default_value); - zend_string *property_variant_name = zend_string_init("variant", sizeof("variant") - 1, true); + zend_string *property_variant_name = zend_string_init("variant", sizeof("variant") - 1, 1); zend_declare_typed_property(class_entry, property_variant_name, &property_variant_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING|MAY_BE_NULL)); - zend_string_release_ex(property_variant_name, true); + zend_string_release(property_variant_name); zval property_allocationKey_default_value; ZVAL_NULL(&property_allocationKey_default_value); - zend_string *property_allocationKey_name = zend_string_init("allocationKey", sizeof("allocationKey") - 1, true); + zend_string *property_allocationKey_name = zend_string_init("allocationKey", sizeof("allocationKey") - 1, 1); zend_declare_typed_property(class_entry, property_allocationKey_name, &property_allocationKey_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING|MAY_BE_NULL)); - zend_string_release_ex(property_allocationKey_name, true); + zend_string_release(property_allocationKey_name); zval property_reason_default_value; ZVAL_LONG(&property_reason_default_value, 0); - zend_string *property_reason_name = zend_string_init("reason", sizeof("reason") - 1, true); + zend_string *property_reason_name = zend_string_init("reason", sizeof("reason") - 1, 1); zend_declare_typed_property(class_entry, property_reason_name, &property_reason_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_LONG)); - zend_string_release_ex(property_reason_name, true); + zend_string_release(property_reason_name); zval property_errorCode_default_value; ZVAL_LONG(&property_errorCode_default_value, 0); - zend_string *property_errorCode_name = zend_string_init("errorCode", sizeof("errorCode") - 1, true); + zend_string *property_errorCode_name = zend_string_init("errorCode", sizeof("errorCode") - 1, 1); zend_declare_typed_property(class_entry, property_errorCode_name, &property_errorCode_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_LONG)); - zend_string_release_ex(property_errorCode_name, true); + zend_string_release(property_errorCode_name); zval property_doLog_default_value; ZVAL_FALSE(&property_doLog_default_value); - zend_string *property_doLog_name = zend_string_init("doLog", sizeof("doLog") - 1, true); + zend_string *property_doLog_name = zend_string_init("doLog", sizeof("doLog") - 1, 1); zend_declare_typed_property(class_entry, property_doLog_name, &property_doLog_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_BOOL)); - zend_string_release_ex(property_doLog_name, true); + zend_string_release(property_doLog_name); + + zval property_observeFullEvaluationData_default_value; + ZVAL_FALSE(&property_observeFullEvaluationData_default_value); + zend_string *property_observeFullEvaluationData_name = zend_string_init("observeFullEvaluationData", sizeof("observeFullEvaluationData") - 1, 1); + zend_declare_typed_property(class_entry, property_observeFullEvaluationData_name, &property_observeFullEvaluationData_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_BOOL)); + zend_string_release(property_observeFullEvaluationData_name); zval property_serialId_default_value; ZVAL_NULL(&property_serialId_default_value); - zend_string *property_serialId_name = zend_string_init("serialId", sizeof("serialId") - 1, true); + zend_string *property_serialId_name = zend_string_init("serialId", sizeof("serialId") - 1, 1); zend_declare_typed_property(class_entry, property_serialId_name, &property_serialId_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_LONG|MAY_BE_NULL)); - zend_string_release_ex(property_serialId_name, true); + zend_string_release(property_serialId_name); zval property_providerState_default_value; ZVAL_EMPTY_ARRAY(&property_providerState_default_value); - zend_string *property_providerState_name = zend_string_init("providerState", sizeof("providerState") - 1, true); + zend_string *property_providerState_name = zend_string_init("providerState", sizeof("providerState") - 1, 1); zend_declare_typed_property(class_entry, property_providerState_name, &property_providerState_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_ARRAY)); - zend_string_release_ex(property_providerState_name, true); + zend_string_release(property_providerState_name); zval property_errorMessage_default_value; ZVAL_NULL(&property_errorMessage_default_value); - zend_string *property_errorMessage_name = zend_string_init("errorMessage", sizeof("errorMessage") - 1, true); + zend_string *property_errorMessage_name = zend_string_init("errorMessage", sizeof("errorMessage") - 1, 1); zend_declare_typed_property(class_entry, property_errorMessage_name, &property_errorMessage_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING|MAY_BE_NULL)); - zend_string_release_ex(property_errorMessage_name, true); + zend_string_release(property_errorMessage_name); zval property_hasConfig_default_value; ZVAL_NULL(&property_hasConfig_default_value); - zend_string *property_hasConfig_name = zend_string_init("hasConfig", sizeof("hasConfig") - 1, true); + zend_string *property_hasConfig_name = zend_string_init("hasConfig", sizeof("hasConfig") - 1, 1); zend_declare_typed_property(class_entry, property_hasConfig_name, &property_hasConfig_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_BOOL|MAY_BE_NULL)); - zend_string_release_ex(property_hasConfig_name, true); + zend_string_release(property_hasConfig_name); zval property_configVersion_default_value; ZVAL_NULL(&property_configVersion_default_value); - zend_string *property_configVersion_name = zend_string_init("configVersion", sizeof("configVersion") - 1, true); + zend_string *property_configVersion_name = zend_string_init("configVersion", sizeof("configVersion") - 1, 1); zend_declare_typed_property(class_entry, property_configVersion_name, &property_configVersion_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_LONG|MAY_BE_NULL)); - zend_string_release_ex(property_configVersion_name, true); + zend_string_release(property_configVersion_name); return class_entry; } @@ -720,15 +736,15 @@ static zend_class_entry *register_class_DDTrace_SpanEvent(zend_class_entry *clas zval property_attributes_default_value; ZVAL_UNDEF(&property_attributes_default_value); - zend_string *property_attributes_name = zend_string_init("attributes", sizeof("attributes") - 1, true); + zend_string *property_attributes_name = zend_string_init("attributes", sizeof("attributes") - 1, 1); zend_declare_typed_property(class_entry, property_attributes_name, &property_attributes_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_ARRAY)); - zend_string_release_ex(property_attributes_name, true); + zend_string_release(property_attributes_name); zval property_timestamp_default_value; ZVAL_UNDEF(&property_timestamp_default_value); - zend_string *property_timestamp_name = zend_string_init("timestamp", sizeof("timestamp") - 1, true); + zend_string *property_timestamp_name = zend_string_init("timestamp", sizeof("timestamp") - 1, 1); zend_declare_typed_property(class_entry, property_timestamp_name, &property_timestamp_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_LONG)); - zend_string_release_ex(property_timestamp_name, true); + zend_string_release(property_timestamp_name); return class_entry; } @@ -742,10 +758,10 @@ static zend_class_entry *register_class_DDTrace_ExceptionSpanEvent(zend_class_en zval property_exception_default_value; ZVAL_UNDEF(&property_exception_default_value); - zend_string *property_exception_name = zend_string_init("exception", sizeof("exception") - 1, true); + zend_string *property_exception_name = zend_string_init("exception", sizeof("exception") - 1, 1); zend_string *property_exception_class_Throwable = zend_string_init("Throwable", sizeof("Throwable")-1, 1); zend_declare_typed_property(class_entry, property_exception_name, &property_exception_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_CLASS(property_exception_class_Throwable, 0, 0)); - zend_string_release_ex(property_exception_name, true); + zend_string_release(property_exception_name); return class_entry; } @@ -760,33 +776,33 @@ static zend_class_entry *register_class_DDTrace_SpanLink(zend_class_entry *class zval property_traceId_default_value; ZVAL_UNDEF(&property_traceId_default_value); - zend_string *property_traceId_name = zend_string_init("traceId", sizeof("traceId") - 1, true); + zend_string *property_traceId_name = zend_string_init("traceId", sizeof("traceId") - 1, 1); zend_declare_typed_property(class_entry, property_traceId_name, &property_traceId_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING)); - zend_string_release_ex(property_traceId_name, true); + zend_string_release(property_traceId_name); zval property_spanId_default_value; ZVAL_UNDEF(&property_spanId_default_value); - zend_string *property_spanId_name = zend_string_init("spanId", sizeof("spanId") - 1, true); + zend_string *property_spanId_name = zend_string_init("spanId", sizeof("spanId") - 1, 1); zend_declare_typed_property(class_entry, property_spanId_name, &property_spanId_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING)); - zend_string_release_ex(property_spanId_name, true); + zend_string_release(property_spanId_name); zval property_traceState_default_value; ZVAL_UNDEF(&property_traceState_default_value); - zend_string *property_traceState_name = zend_string_init("traceState", sizeof("traceState") - 1, true); + zend_string *property_traceState_name = zend_string_init("traceState", sizeof("traceState") - 1, 1); zend_declare_typed_property(class_entry, property_traceState_name, &property_traceState_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING)); - zend_string_release_ex(property_traceState_name, true); + zend_string_release(property_traceState_name); zval property_attributes_default_value; ZVAL_UNDEF(&property_attributes_default_value); - zend_string *property_attributes_name = zend_string_init("attributes", sizeof("attributes") - 1, true); + zend_string *property_attributes_name = zend_string_init("attributes", sizeof("attributes") - 1, 1); zend_declare_typed_property(class_entry, property_attributes_name, &property_attributes_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_ARRAY)); - zend_string_release_ex(property_attributes_name, true); + zend_string_release(property_attributes_name); zval property_droppedAttributesCount_default_value; ZVAL_UNDEF(&property_droppedAttributesCount_default_value); - zend_string *property_droppedAttributesCount_name = zend_string_init("droppedAttributesCount", sizeof("droppedAttributesCount") - 1, true); + zend_string *property_droppedAttributesCount_name = zend_string_init("droppedAttributesCount", sizeof("droppedAttributesCount") - 1, 1); zend_declare_typed_property(class_entry, property_droppedAttributesCount_name, &property_droppedAttributesCount_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_LONG)); - zend_string_release_ex(property_droppedAttributesCount_name, true); + zend_string_release(property_droppedAttributesCount_name); return class_entry; } @@ -800,15 +816,15 @@ static zend_class_entry *register_class_DDTrace_GitMetadata(void) zval property_commitSha_default_value; ZVAL_EMPTY_STRING(&property_commitSha_default_value); - zend_string *property_commitSha_name = zend_string_init("commitSha", sizeof("commitSha") - 1, true); + zend_string *property_commitSha_name = zend_string_init("commitSha", sizeof("commitSha") - 1, 1); zend_declare_typed_property(class_entry, property_commitSha_name, &property_commitSha_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING)); - zend_string_release_ex(property_commitSha_name, true); + zend_string_release(property_commitSha_name); zval property_repositoryUrl_default_value; ZVAL_EMPTY_STRING(&property_repositoryUrl_default_value); - zend_string *property_repositoryUrl_name = zend_string_init("repositoryUrl", sizeof("repositoryUrl") - 1, true); + zend_string *property_repositoryUrl_name = zend_string_init("repositoryUrl", sizeof("repositoryUrl") - 1, 1); zend_declare_typed_property(class_entry, property_repositoryUrl_name, &property_repositoryUrl_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING)); - zend_string_release_ex(property_repositoryUrl_name, true); + zend_string_release(property_repositoryUrl_name); return class_entry; } @@ -830,27 +846,27 @@ static zend_class_entry *register_class_DDTrace_SpanData(void) zval property_service_default_value; ZVAL_EMPTY_STRING(&property_service_default_value); - zend_string *property_service_name = zend_string_init("service", sizeof("service") - 1, true); + zend_string *property_service_name = zend_string_init("service", sizeof("service") - 1, 1); zend_declare_typed_property(class_entry, property_service_name, &property_service_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING|MAY_BE_NULL)); - zend_string_release_ex(property_service_name, true); + zend_string_release(property_service_name); zval property_env_default_value; ZVAL_EMPTY_STRING(&property_env_default_value); - zend_string *property_env_name = zend_string_init("env", sizeof("env") - 1, true); + zend_string *property_env_name = zend_string_init("env", sizeof("env") - 1, 1); zend_declare_typed_property(class_entry, property_env_name, &property_env_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING)); - zend_string_release_ex(property_env_name, true); + zend_string_release(property_env_name); zval property_version_default_value; ZVAL_EMPTY_STRING(&property_version_default_value); - zend_string *property_version_name = zend_string_init("version", sizeof("version") - 1, true); + zend_string *property_version_name = zend_string_init("version", sizeof("version") - 1, 1); zend_declare_typed_property(class_entry, property_version_name, &property_version_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING)); - zend_string_release_ex(property_version_name, true); + zend_string_release(property_version_name); zval property_meta_struct_default_value; ZVAL_EMPTY_ARRAY(&property_meta_struct_default_value); - zend_string *property_meta_struct_name = zend_string_init("meta_struct", sizeof("meta_struct") - 1, true); + zend_string *property_meta_struct_name = zend_string_init("meta_struct", sizeof("meta_struct") - 1, 1); zend_declare_typed_property(class_entry, property_meta_struct_name, &property_meta_struct_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_ARRAY)); - zend_string_release_ex(property_meta_struct_name, true); + zend_string_release(property_meta_struct_name); zval property_type_default_value; ZVAL_EMPTY_STRING(&property_type_default_value); @@ -858,46 +874,46 @@ static zend_class_entry *register_class_DDTrace_SpanData(void) zval property_meta_default_value; ZVAL_EMPTY_ARRAY(&property_meta_default_value); - zend_string *property_meta_name = zend_string_init("meta", sizeof("meta") - 1, true); + zend_string *property_meta_name = zend_string_init("meta", sizeof("meta") - 1, 1); zend_declare_typed_property(class_entry, property_meta_name, &property_meta_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_ARRAY)); - zend_string_release_ex(property_meta_name, true); + zend_string_release(property_meta_name); zval property_metrics_default_value; ZVAL_EMPTY_ARRAY(&property_metrics_default_value); - zend_string *property_metrics_name = zend_string_init("metrics", sizeof("metrics") - 1, true); + zend_string *property_metrics_name = zend_string_init("metrics", sizeof("metrics") - 1, 1); zend_declare_typed_property(class_entry, property_metrics_name, &property_metrics_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_ARRAY)); - zend_string_release_ex(property_metrics_name, true); + zend_string_release(property_metrics_name); zval property_exception_default_value; ZVAL_NULL(&property_exception_default_value); - zend_string *property_exception_name = zend_string_init("exception", sizeof("exception") - 1, true); + zend_string *property_exception_name = zend_string_init("exception", sizeof("exception") - 1, 1); zend_string *property_exception_class_Throwable = zend_string_init("Throwable", sizeof("Throwable")-1, 1); zend_declare_typed_property(class_entry, property_exception_name, &property_exception_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_CLASS(property_exception_class_Throwable, 0, MAY_BE_NULL)); - zend_string_release_ex(property_exception_name, true); + zend_string_release(property_exception_name); zval property_id_default_value; ZVAL_UNDEF(&property_id_default_value); - zend_string *property_id_name = zend_string_init("id", sizeof("id") - 1, true); + zend_string *property_id_name = zend_string_init("id", sizeof("id") - 1, 1); zend_declare_typed_property(class_entry, property_id_name, &property_id_default_value, ZEND_ACC_PUBLIC|ZEND_ACC_READONLY, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING)); - zend_string_release_ex(property_id_name, true); + zend_string_release(property_id_name); zval property_links_default_value; ZVAL_EMPTY_ARRAY(&property_links_default_value); - zend_string *property_links_name = zend_string_init("links", sizeof("links") - 1, true); + zend_string *property_links_name = zend_string_init("links", sizeof("links") - 1, 1); zend_declare_typed_property(class_entry, property_links_name, &property_links_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_ARRAY)); - zend_string_release_ex(property_links_name, true); + zend_string_release(property_links_name); zval property_events_default_value; ZVAL_EMPTY_ARRAY(&property_events_default_value); - zend_string *property_events_name = zend_string_init("events", sizeof("events") - 1, true); + zend_string *property_events_name = zend_string_init("events", sizeof("events") - 1, 1); zend_declare_typed_property(class_entry, property_events_name, &property_events_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_ARRAY)); - zend_string_release_ex(property_events_name, true); + zend_string_release(property_events_name); zval property_peerServiceSources_default_value; ZVAL_EMPTY_ARRAY(&property_peerServiceSources_default_value); - zend_string *property_peerServiceSources_name = zend_string_init("peerServiceSources", sizeof("peerServiceSources") - 1, true); + zend_string *property_peerServiceSources_name = zend_string_init("peerServiceSources", sizeof("peerServiceSources") - 1, 1); zend_declare_typed_property(class_entry, property_peerServiceSources_name, &property_peerServiceSources_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_ARRAY)); - zend_string_release_ex(property_peerServiceSources_name, true); + zend_string_release(property_peerServiceSources_name); zval property_parent_default_value; ZVAL_UNDEF(&property_parent_default_value); @@ -906,22 +922,22 @@ static zend_class_entry *register_class_DDTrace_SpanData(void) zval property_stack_default_value; ZVAL_UNDEF(&property_stack_default_value); - zend_string *property_stack_name = zend_string_init("stack", sizeof("stack") - 1, true); + zend_string *property_stack_name = zend_string_init("stack", sizeof("stack") - 1, 1); zend_string *property_stack_class_DDTrace_SpanStack = zend_string_init("DDTrace\\SpanStack", sizeof("DDTrace\\SpanStack")-1, 1); zend_declare_typed_property(class_entry, property_stack_name, &property_stack_default_value, ZEND_ACC_PUBLIC|ZEND_ACC_READONLY, NULL, (zend_type) ZEND_TYPE_INIT_CLASS(property_stack_class_DDTrace_SpanStack, 0, 0)); - zend_string_release_ex(property_stack_name, true); + zend_string_release(property_stack_name); zval property_onClose_default_value; ZVAL_EMPTY_ARRAY(&property_onClose_default_value); - zend_string *property_onClose_name = zend_string_init("onClose", sizeof("onClose") - 1, true); + zend_string *property_onClose_name = zend_string_init("onClose", sizeof("onClose") - 1, 1); zend_declare_typed_property(class_entry, property_onClose_name, &property_onClose_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_ARRAY)); - zend_string_release_ex(property_onClose_name, true); + zend_string_release(property_onClose_name); zval property_baggage_default_value; ZVAL_EMPTY_ARRAY(&property_baggage_default_value); - zend_string *property_baggage_name = zend_string_init("baggage", sizeof("baggage") - 1, true); + zend_string *property_baggage_name = zend_string_init("baggage", sizeof("baggage") - 1, 1); zend_declare_typed_property(class_entry, property_baggage_name, &property_baggage_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_ARRAY)); - zend_string_release_ex(property_baggage_name, true); + zend_string_release(property_baggage_name); return class_entry; } @@ -945,65 +961,65 @@ static zend_class_entry *register_class_DDTrace_RootSpanData(zend_class_entry *c zval property_origin_default_value; ZVAL_UNDEF(&property_origin_default_value); - zend_string *property_origin_name = zend_string_init("origin", sizeof("origin") - 1, true); + zend_string *property_origin_name = zend_string_init("origin", sizeof("origin") - 1, 1); zend_declare_typed_property(class_entry, property_origin_name, &property_origin_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING)); - zend_string_release_ex(property_origin_name, true); + zend_string_release(property_origin_name); zval property_propagatedTags_default_value; ZVAL_EMPTY_ARRAY(&property_propagatedTags_default_value); - zend_string *property_propagatedTags_name = zend_string_init("propagatedTags", sizeof("propagatedTags") - 1, true); + zend_string *property_propagatedTags_name = zend_string_init("propagatedTags", sizeof("propagatedTags") - 1, 1); zend_declare_typed_property(class_entry, property_propagatedTags_name, &property_propagatedTags_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_ARRAY)); - zend_string_release_ex(property_propagatedTags_name, true); + zend_string_release(property_propagatedTags_name); zval property_samplingPriority_default_value; ZVAL_LONG(&property_samplingPriority_default_value, DDTRACE_PRIORITY_SAMPLING_UNKNOWN); - zend_string *property_samplingPriority_name = zend_string_init("samplingPriority", sizeof("samplingPriority") - 1, true); + zend_string *property_samplingPriority_name = zend_string_init("samplingPriority", sizeof("samplingPriority") - 1, 1); zend_declare_typed_property(class_entry, property_samplingPriority_name, &property_samplingPriority_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_LONG)); - zend_string_release_ex(property_samplingPriority_name, true); + zend_string_release(property_samplingPriority_name); zval property_propagatedSamplingPriority_default_value; ZVAL_UNDEF(&property_propagatedSamplingPriority_default_value); - zend_string *property_propagatedSamplingPriority_name = zend_string_init("propagatedSamplingPriority", sizeof("propagatedSamplingPriority") - 1, true); + zend_string *property_propagatedSamplingPriority_name = zend_string_init("propagatedSamplingPriority", sizeof("propagatedSamplingPriority") - 1, 1); zend_declare_typed_property(class_entry, property_propagatedSamplingPriority_name, &property_propagatedSamplingPriority_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_LONG)); - zend_string_release_ex(property_propagatedSamplingPriority_name, true); + zend_string_release(property_propagatedSamplingPriority_name); zval property_tracestate_default_value; ZVAL_UNDEF(&property_tracestate_default_value); - zend_string *property_tracestate_name = zend_string_init("tracestate", sizeof("tracestate") - 1, true); + zend_string *property_tracestate_name = zend_string_init("tracestate", sizeof("tracestate") - 1, 1); zend_declare_typed_property(class_entry, property_tracestate_name, &property_tracestate_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING)); - zend_string_release_ex(property_tracestate_name, true); + zend_string_release(property_tracestate_name); zval property_tracestateTags_default_value; ZVAL_EMPTY_ARRAY(&property_tracestateTags_default_value); - zend_string *property_tracestateTags_name = zend_string_init("tracestateTags", sizeof("tracestateTags") - 1, true); + zend_string *property_tracestateTags_name = zend_string_init("tracestateTags", sizeof("tracestateTags") - 1, 1); zend_declare_typed_property(class_entry, property_tracestateTags_name, &property_tracestateTags_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_ARRAY)); - zend_string_release_ex(property_tracestateTags_name, true); + zend_string_release(property_tracestateTags_name); zval property_parentId_default_value; ZVAL_UNDEF(&property_parentId_default_value); - zend_string *property_parentId_name = zend_string_init("parentId", sizeof("parentId") - 1, true); + zend_string *property_parentId_name = zend_string_init("parentId", sizeof("parentId") - 1, 1); zend_declare_typed_property(class_entry, property_parentId_name, &property_parentId_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING)); - zend_string_release_ex(property_parentId_name, true); + zend_string_release(property_parentId_name); zval property_traceId_default_value; ZVAL_EMPTY_STRING(&property_traceId_default_value); - zend_string *property_traceId_name = zend_string_init("traceId", sizeof("traceId") - 1, true); + zend_string *property_traceId_name = zend_string_init("traceId", sizeof("traceId") - 1, 1); zend_declare_typed_property(class_entry, property_traceId_name, &property_traceId_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING)); - zend_string_release_ex(property_traceId_name, true); + zend_string_release(property_traceId_name); zval property_gitMetadata_default_value; ZVAL_NULL(&property_gitMetadata_default_value); - zend_string *property_gitMetadata_name = zend_string_init("gitMetadata", sizeof("gitMetadata") - 1, true); + zend_string *property_gitMetadata_name = zend_string_init("gitMetadata", sizeof("gitMetadata") - 1, 1); zend_string *property_gitMetadata_class_DDTrace_GitMetadata = zend_string_init("DDTrace\\GitMetadata", sizeof("DDTrace\\GitMetadata")-1, 1); zend_declare_typed_property(class_entry, property_gitMetadata_name, &property_gitMetadata_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_CLASS(property_gitMetadata_class_DDTrace_GitMetadata, 0, MAY_BE_NULL)); - zend_string_release_ex(property_gitMetadata_name, true); + zend_string_release(property_gitMetadata_name); zval property_inferredSpan_default_value; ZVAL_NULL(&property_inferredSpan_default_value); - zend_string *property_inferredSpan_name = zend_string_init("inferredSpan", sizeof("inferredSpan") - 1, true); + zend_string *property_inferredSpan_name = zend_string_init("inferredSpan", sizeof("inferredSpan") - 1, 1); zend_string *property_inferredSpan_class_DDTrace_InferredSpanData = zend_string_init("DDTrace\\InferredSpanData", sizeof("DDTrace\\InferredSpanData")-1, 1); zend_declare_typed_property(class_entry, property_inferredSpan_name, &property_inferredSpan_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_CLASS(property_inferredSpan_class_DDTrace_InferredSpanData, 0, MAY_BE_NULL)); - zend_string_release_ex(property_inferredSpan_name, true); + zend_string_release(property_inferredSpan_name); return class_entry; } @@ -1022,16 +1038,16 @@ static zend_class_entry *register_class_DDTrace_SpanStack(void) zval property_active_default_value; ZVAL_NULL(&property_active_default_value); - zend_string *property_active_name = zend_string_init("active", sizeof("active") - 1, true); + zend_string *property_active_name = zend_string_init("active", sizeof("active") - 1, 1); zend_string *property_active_class_DDTrace_SpanData = zend_string_init("DDTrace\\SpanData", sizeof("DDTrace\\SpanData")-1, 1); zend_declare_typed_property(class_entry, property_active_name, &property_active_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_CLASS(property_active_class_DDTrace_SpanData, 0, MAY_BE_NULL)); - zend_string_release_ex(property_active_name, true); + zend_string_release(property_active_name); zval property_spanCreationObservers_default_value; ZVAL_EMPTY_ARRAY(&property_spanCreationObservers_default_value); - zend_string *property_spanCreationObservers_name = zend_string_init("spanCreationObservers", sizeof("spanCreationObservers") - 1, true); + zend_string *property_spanCreationObservers_name = zend_string_init("spanCreationObservers", sizeof("spanCreationObservers") - 1, 1); zend_declare_typed_property(class_entry, property_spanCreationObservers_name, &property_spanCreationObservers_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_ARRAY)); - zend_string_release_ex(property_spanCreationObservers_name, true); + zend_string_release(property_spanCreationObservers_name); return class_entry; } @@ -1045,21 +1061,21 @@ static zend_class_entry *register_class_DDTrace_Integration(void) zval const_NOT_LOADED_value; ZVAL_LONG(&const_NOT_LOADED_value, DD_TRACE_INTEGRATION_NOT_LOADED); - zend_string *const_NOT_LOADED_name = zend_string_init_interned("NOT_LOADED", sizeof("NOT_LOADED") - 1, true); + zend_string *const_NOT_LOADED_name = zend_string_init_interned("NOT_LOADED", sizeof("NOT_LOADED") - 1, 1); zend_declare_class_constant_ex(class_entry, const_NOT_LOADED_name, &const_NOT_LOADED_value, ZEND_ACC_PUBLIC, NULL); - zend_string_release_ex(const_NOT_LOADED_name, true); + zend_string_release(const_NOT_LOADED_name); zval const_LOADED_value; ZVAL_LONG(&const_LOADED_value, DD_TRACE_INTEGRATION_LOADED); - zend_string *const_LOADED_name = zend_string_init_interned("LOADED", sizeof("LOADED") - 1, true); + zend_string *const_LOADED_name = zend_string_init_interned("LOADED", sizeof("LOADED") - 1, 1); zend_declare_class_constant_ex(class_entry, const_LOADED_name, &const_LOADED_value, ZEND_ACC_PUBLIC, NULL); - zend_string_release_ex(const_LOADED_name, true); + zend_string_release(const_LOADED_name); zval const_NOT_AVAILABLE_value; ZVAL_LONG(&const_NOT_AVAILABLE_value, DD_TRACE_INTEGRATION_NOT_AVAILABLE); - zend_string *const_NOT_AVAILABLE_name = zend_string_init_interned("NOT_AVAILABLE", sizeof("NOT_AVAILABLE") - 1, true); + zend_string *const_NOT_AVAILABLE_name = zend_string_init_interned("NOT_AVAILABLE", sizeof("NOT_AVAILABLE") - 1, 1); zend_declare_class_constant_ex(class_entry, const_NOT_AVAILABLE_name, &const_NOT_AVAILABLE_value, ZEND_ACC_PUBLIC, NULL); - zend_string_release_ex(const_NOT_AVAILABLE_name, true); + zend_string_release(const_NOT_AVAILABLE_name); return class_entry; } diff --git a/tracer/ffe.c b/tracer/ffe.c index bf0a1cce629..a364c60f774 100644 --- a/tracer/ffe.c +++ b/tracer/ffe.c @@ -15,6 +15,98 @@ ZEND_EXTERN_MODULE_GLOBALS(datadog); #define DD_FFE_METRIC_BUFFER_LIMIT 1000 #define DD_FFE_EXPOSURE_BUFFER_LIMIT 1000 +#define DD_FFE_CONTEXT_FIELD_LIMIT 256 + +bool ddtrace_ffe_record_flag_evaluation(zend_string *flag_key, zend_string *variant, + zend_string *allocation_key, zend_string *targeting_key, HashTable *attributes, + zend_string *error_type, bool runtime_default_used, bool observe_full_evaluation_data) { + if (!get_DD_FLAGGING_EVALUATION_COUNTS_ENABLED() || !flag_key || !ZSTR_LEN(flag_key) + || !DATADOG_G(sidecar) || !datadog_sidecar_instance_id || !DATADOG_G(sidecar_queue_id)) { + return false; + } + + // Admission must precede telemetry snapshot work. Neither this check nor + // submission waits for capacity or reconnects the transport on evaluation. + if (ddog_sidecar_check_ffe_submission(DATADOG_G(sidecar)) != DDOG_FFE_SUBMISSION_STATUS_READY) { + return false; + } + + ddog_FfeScalarAttribute scalars[DD_FFE_CONTEXT_FIELD_LIMIT] = {0}; + zend_string *owned_keys[DD_FFE_CONTEXT_FIELD_LIMIT] = {0}; + size_t count = 0; + ddog_FfeSnapshotState snapshot = {0}; + // Protected observations never walk or serialize their context. + if (observe_full_evaluation_data) { + snapshot.context_truncated = zend_hash_num_elements(attributes) > DD_FFE_CONTEXT_FIELD_LIMIT; + zend_ulong index; + zend_string *key; + zval *value; + ZEND_HASH_FOREACH_KEY_VAL(attributes, index, key, value) { + if (count == DD_FFE_CONTEXT_FIELD_LIMIT) { + break; + } + if (!key) { + key = owned_keys[count] = zend_long_to_str((zend_long) index); + } + ddog_FfeScalarAttribute *attribute = &scalars[count++]; + attribute->key = dd_zend_string_to_CharSlice(key); + ZVAL_DEREF(value); + switch (Z_TYPE_P(value)) { + case IS_STRING: + attribute->kind = 0; + attribute->string_value = dd_zend_string_to_CharSlice(Z_STR_P(value)); + break; + case IS_TRUE: + case IS_FALSE: + attribute->kind = 1; + attribute->bool_value = Z_TYPE_P(value) == IS_TRUE; + break; + case IS_LONG: + attribute->kind = 2; + attribute->integer_value = Z_LVAL_P(value); + break; + case IS_DOUBLE: + attribute->kind = 3; + attribute->double_value = Z_DVAL_P(value); + break; + default: + // The evaluator accepts scalars only. Retain an omission + // marker if an internal caller supplies anything else. + attribute->kind = 4; + break; + } + } ZEND_HASH_FOREACH_END(); + } + + int64_t timestamp = (int64_t) (ddtrace_nanoseconds_realtime() / 1000000); + ddog_FfeFlagEvaluation event = { + .timestamp_ms = timestamp, + .first_evaluation_ms = timestamp, + .last_evaluation_ms = timestamp, + .evaluation_count = 1, + .flag_key = dd_zend_string_to_CharSlice(flag_key), + .variant = dd_zend_string_to_CharSlice(variant), + .allocation_key = dd_zend_string_to_CharSlice(allocation_key), + .targeting_key = dd_zend_string_to_CharSlice(targeting_key), + .error_message = dd_zend_string_to_CharSlice(error_type), + .runtime_default_used = runtime_default_used, + .observe_full_evaluation_data = observe_full_evaluation_data, + }; + ddog_FfeTelemetryContext context = { + .service = dd_zend_string_to_CharSlice(get_DD_SERVICE()), + .env = dd_zend_string_to_CharSlice(get_DD_ENV()), + .version = dd_zend_string_to_CharSlice(get_DD_VERSION()), + }; + ddog_FfeSubmissionStatus status = ddog_sidecar_try_submit_ffe_flag_evaluation( + DATADOG_G(sidecar), datadog_sidecar_instance_id, &DATADOG_G(sidecar_queue_id), + &context, &event, (ddog_Slice_FfeScalarAttribute) {.ptr = scalars, .len = count}, &snapshot); + for (size_t i = 0; i < count; ++i) { + if (owned_keys[i]) { + zend_string_release(owned_keys[i]); + } + } + return status == DDOG_FFE_SUBMISSION_STATUS_ACCEPTED; +} typedef struct { zend_string *flag_key; diff --git a/tracer/ffe.h b/tracer/ffe.h index 4da7f951f28..2353db57780 100644 --- a/tracer/ffe.h +++ b/tracer/ffe.h @@ -9,6 +9,10 @@ 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); +bool ddtrace_ffe_record_flag_evaluation(zend_string *flag_key, zend_string *variant, + zend_string *allocation_key, zend_string *targeting_key, HashTable *attributes, + zend_string *error_type, bool runtime_default_used, bool observe_full_evaluation_data); + void ddtrace_ffe_record_exposure(zend_string *flag_key, zend_string *targeting_key, zend_string *subject_attributes_json, zend_string *allocation_key, zend_string *variant, int32_t serial_id, bool has_serial_id); bool ddtrace_ffe_flush_exposures(void); diff --git a/tracer/functions.c b/tracer/functions.c index f0fa8a57d7e..fb1257592f5 100644 --- a/tracer/functions.c +++ b/tracer/functions.c @@ -1934,6 +1934,7 @@ PHP_FUNCTION(DDTrace_ffe_evaluate) { ddtrace_ffe_update_long_property(return_value, ZEND_STRL("reason"), ddtrace_ffe_effective_reason(result.reason, result.error_code)); ddtrace_ffe_update_long_property(return_value, ZEND_STRL("errorCode"), result.error_code); ddtrace_ffe_update_bool_property(return_value, ZEND_STRL("doLog"), result.do_log); + ddtrace_ffe_update_bool_property(return_value, ZEND_STRL("observeFullEvaluationData"), result.observe_full_evaluation_data); // serialId is only populated when the native result actually carried one // (has_serial_id). It stays null otherwise so the PHP accumulator can use // the Pattern B "missing variant => runtime default" branch, rather than @@ -2935,6 +2936,24 @@ PHP_FUNCTION(DDTrace_Internal_flush_ffe_evaluation_metrics) { RETURN_BOOL(ddtrace_ffe_flush_evaluation_metrics()); } +PHP_FUNCTION(DDTrace_Internal_record_ffe_flag_evaluation) { + zend_string *flag_key, *variant = NULL, *allocation_key = NULL, *targeting_key = NULL, *error_type = NULL; + HashTable *attributes; + zend_bool runtime_default_used, observe_full_evaluation_data; + ZEND_PARSE_PARAMETERS_START(8, 8) + Z_PARAM_STR(flag_key) + Z_PARAM_STR_OR_NULL(variant) + Z_PARAM_STR_OR_NULL(allocation_key) + Z_PARAM_STR_OR_NULL(targeting_key) + Z_PARAM_ARRAY_HT(attributes) + Z_PARAM_STR_OR_NULL(error_type) + Z_PARAM_BOOL(runtime_default_used) + Z_PARAM_BOOL(observe_full_evaluation_data) + ZEND_PARSE_PARAMETERS_END(); + RETURN_BOOL(ddtrace_ffe_record_flag_evaluation(flag_key, variant, allocation_key, + targeting_key, attributes, error_type, runtime_default_used, observe_full_evaluation_data)); +} + /* {{{ proto array generate_distributed_tracing_headers() */ PHP_FUNCTION(DDTrace_generate_distributed_tracing_headers) { zend_array *inject = NULL; From 8a2dea0b52902988f76d22fadaddeeea504655d4 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 8 Oct 2026 04:41:50 +0000 Subject: [PATCH 02/15] Fix FFE test portability and bound recorder stress arguments --- .../ffe/flag_evaluation_final_outcome.phpt | 15 +++++++++++++-- tests/ext/ffe/stubs/flag_evaluation_wire.inc | 2 +- tests/internal-api-stress-test.php | 19 +++++++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/tests/ext/ffe/flag_evaluation_final_outcome.phpt b/tests/ext/ffe/flag_evaluation_final_outcome.phpt index 55e4d6c2848..375fd14a676 100644 --- a/tests/ext/ffe/flag_evaluation_final_outcome.phpt +++ b/tests/ext/ffe/flag_evaluation_final_outcome.phpt @@ -6,9 +6,20 @@ DD_TRACE_GENERATE_ROOT_SPAN=0 --FILE-- name}\n"; + if ($function->name === 'DDTrace\\Internal\\record_ffe_flag_evaluation') { + // Eight parameters make the exhaustive garbage product too large on + // older PHP. Exercise a valid call and each argument's garbage values + // independently, keeping the other arguments valid so parsing reaches it. + $arguments = ['flag', 'variant', 'allocation', 'subject', [], null, false, false]; + $function->invokeArgs($arguments); + foreach ($arguments as $position => $unused) { + foreach (generate_garbage() as $garbage) { + $invocation = $arguments; + $invocation[$position] = $garbage; + try { + $function->invokeArgs($invocation); + } catch (TypeError $e) { + } + } + } + return; + } + $i = PHP_VERSION_ID >= 80100 ? $function->getNumberOfRequiredParameters() : ($minFunctionArgs[$function->name] ?? 0); $invocations = $i == 0 ? [[]] : []; $invocationTypeMap = []; From 489f7019bda508ea0359d83d193344036da4d9b6 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 8 Oct 2026 07:21:52 +0000 Subject: [PATCH 03/15] Resolve PHP API source layouts in native EVP child fixtures --- tests/ext/ffe/stubs/ffe_api_bootstrap.inc | 24 +++++++++++++++++++ .../ffe/stubs/flag_evaluation_wire_app.php | 13 +--------- 2 files changed, 25 insertions(+), 12 deletions(-) create mode 100644 tests/ext/ffe/stubs/ffe_api_bootstrap.inc diff --git a/tests/ext/ffe/stubs/ffe_api_bootstrap.inc b/tests/ext/ffe/stubs/ffe_api_bootstrap.inc new file mode 100644 index 00000000000..f348e84771b --- /dev/null +++ b/tests/ext/ffe/stubs/ffe_api_bootstrap.inc @@ -0,0 +1,24 @@ +returned), "\n"; }); -spl_autoload_register(function ($class) use ($root) { - if (strpos($class, 'DDTrace\\') === 0) { - $path = $root . '/src/api/' . str_replace('\\', '/', substr($class, 8)) . '.php'; - if (!is_file($path)) { - $path = $root . '/src/' . str_replace('\\', '/', $class) . '.php'; - } - if (is_file($path)) { - require_once $path; - } - } -}); $config = array( 'createdAt' => '2026-05-22T00:00:00Z', From 4dc52e673f4607819dfaf742888111480b4c44a4 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 8 Oct 2026 08:02:59 +0000 Subject: [PATCH 04/15] Avoid blocking child pipes and Windows shell quoting in FFE fixtures --- tests/ext/ffe/stubs/ffe_process.inc | 29 ++++++++++++++++++++ tests/ext/ffe/stubs/flag_evaluation_wire.inc | 16 ++++------- 2 files changed, 35 insertions(+), 10 deletions(-) create mode 100644 tests/ext/ffe/stubs/ffe_process.inc diff --git a/tests/ext/ffe/stubs/ffe_process.inc b/tests/ext/ffe/stubs/ffe_process.inc new file mode 100644 index 00000000000..9cdc7d3073e --- /dev/null +++ b/tests/ext/ffe/stubs/ffe_process.inc @@ -0,0 +1,29 @@ + 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/ffe/stubs/flag_evaluation_wire.inc b/tests/ext/ffe/stubs/flag_evaluation_wire.inc index b3a5a318a4c..723b6725b79 100644 --- a/tests/ext/ffe/stubs/flag_evaluation_wire.inc +++ b/tests/ext/ffe/stubs/flag_evaluation_wire.inc @@ -1,20 +1,16 @@ Date: Wed, 14 Oct 2026 21:05:22 +0000 Subject: [PATCH 05/15] fix(ffe): preserve evaluation timestamps and original identity validity --- src/api/FeatureFlags/Client.php | 4 +- src/api/FeatureFlags/Internal/Evaluator.php | 3 +- .../FeatureFlags/Internal/NativeEvaluator.php | 16 ++-- .../Internal/UnavailableEvaluator.php | 2 +- tests/OpenFeature/DataDogProviderTest.php | 2 +- .../OpenFeatureEvaluationMetricsTest.php | 2 +- tests/api/Unit/FeatureFlags/ClientTest.php | 2 +- .../ffe/flag_evaluation_final_outcome.phpt | 2 +- .../ext/ffe/flag_evaluation_kill_switch.phpt | 2 +- .../ffe/flag_evaluation_openfeature_wire.phpt | 5 +- .../ffe/flag_evaluation_targeting_key.phpt | 50 +++++++++++++ tests/ext/ffe/flag_evaluation_timestamp.phpt | 73 +++++++++++++++++++ tests/ext/ffe/flag_evaluation_wire.phpt | 5 +- tests/ext/ffe/stubs/flag_evaluation_wire.inc | 20 ++++- .../ffe/stubs/flag_evaluation_wire_app.php | 24 +++++- tests/internal-api-stress-test.php | 4 +- tracer/ddtrace.stub.php | 2 +- tracer/ddtrace_arginfo.h | 6 +- tracer/ffe.c | 12 +-- tracer/ffe.h | 3 +- tracer/functions.c | 10 ++- 21 files changed, 216 insertions(+), 33 deletions(-) create mode 100644 tests/ext/ffe/flag_evaluation_targeting_key.phpt create mode 100644 tests/ext/ffe/flag_evaluation_timestamp.phpt diff --git a/src/api/FeatureFlags/Client.php b/src/api/FeatureFlags/Client.php index 7a8df017709..8661f289608 100644 --- a/src/api/FeatureFlags/Client.php +++ b/src/api/FeatureFlags/Client.php @@ -107,6 +107,7 @@ public function getObjectDetails($flagKey, array $defaultValue, array $context = private function evaluate($flagKey, $expectedType, $defaultValue, array $context) { $flagKey = $this->expectFlagKey($flagKey); + $targetingKeyInvalid = isset($context['targetingKey']) && !is_string($context['targetingKey']); list($targetingKey, $attributes) = $this->normalizeContext($context); $details = $this->evaluator->evaluate( @@ -114,7 +115,8 @@ private function evaluate($flagKey, $expectedType, $defaultValue, array $context $expectedType, $defaultValue, $targetingKey, - $attributes + $attributes, + $targetingKeyInvalid ); $this->warnIfRuntimeNotReady($details); diff --git a/src/api/FeatureFlags/Internal/Evaluator.php b/src/api/FeatureFlags/Internal/Evaluator.php index 132e26cbcb2..636638c275d 100644 --- a/src/api/FeatureFlags/Internal/Evaluator.php +++ b/src/api/FeatureFlags/Internal/Evaluator.php @@ -12,7 +12,8 @@ interface Evaluator * @param mixed $defaultValue * @param string|null $targetingKey * @param array $attributes + * @param bool $targetingKeyInvalid Whether evaluation coerced a non-string identity. * @return EvaluationDetails */ - public function evaluate($flagKey, $expectedType, $defaultValue, $targetingKey = null, array $attributes = array()); + public function evaluate($flagKey, $expectedType, $defaultValue, $targetingKey = null, array $attributes = array(), $targetingKeyInvalid = false); } diff --git a/src/api/FeatureFlags/Internal/NativeEvaluator.php b/src/api/FeatureFlags/Internal/NativeEvaluator.php index 419eeb0229e..d196b1e61ff 100644 --- a/src/api/FeatureFlags/Internal/NativeEvaluator.php +++ b/src/api/FeatureFlags/Internal/NativeEvaluator.php @@ -37,8 +37,10 @@ public function evaluate( $expectedType, $defaultValue, $targetingKey = null, - array $attributes = array() + array $attributes = array(), + $targetingKeyInvalid = false ) { + $evaluationTimestampMs = (int) (microtime(true) * 1000); $normalizedAttributes = $this->normalizeAttributes($attributes); $rawResult = \DDTrace\ffe_evaluate( $flagKey, @@ -54,9 +56,9 @@ public function evaluate( $details = $this->mapper->map($rawResult, $expectedType, $defaultValue); - // Both public APIs use this final mapped outcome. EVP counts are + // Both public APIs use this final mapped outcome. EVP reporting is // independent of OpenFeature's metric hook and the OTLP kill switch. - $this->recordFlagEvaluation($flagKey, $targetingKey, $normalizedAttributes, $rawResult, $details); + $this->recordFlagEvaluation($flagKey, $targetingKey, $normalizedAttributes, $rawResult, $details, $evaluationTimestampMs, $targetingKeyInvalid); // APM feature-flag span enrichment. This is the single choke point both // the native Client and the OpenFeature DataDogProvider evaluate through, @@ -69,7 +71,7 @@ public function evaluate( return $details; } - private function recordFlagEvaluation($flagKey, $targetingKey, array $attributes, $rawResult, $details) + private function recordFlagEvaluation($flagKey, $targetingKey, array $attributes, $rawResult, $details, $evaluationTimestampMs, $targetingKeyInvalid) { if (!function_exists('DDTrace\\Internal\\record_ffe_flag_evaluation')) { return; @@ -83,11 +85,13 @@ private function recordFlagEvaluation($flagKey, $targetingKey, array $attributes $flagKey, $details->getVariant(), isset($exposure['allocationKey']) ? $exposure['allocationKey'] : null, - $targetingKey, + $targetingKeyInvalid ? null : $targetingKey, $attributes, $details->getErrorCode(), $details->getVariant() === null && !isset($exposure['serialId']), - $consent + $consent, + $evaluationTimestampMs, + $targetingKeyInvalid ); } catch (\Throwable $ignored) { // Best-effort telemetry must not change a flag's evaluation result. diff --git a/src/api/FeatureFlags/Internal/UnavailableEvaluator.php b/src/api/FeatureFlags/Internal/UnavailableEvaluator.php index fe1fde0c7d9..e77a0b53757 100644 --- a/src/api/FeatureFlags/Internal/UnavailableEvaluator.php +++ b/src/api/FeatureFlags/Internal/UnavailableEvaluator.php @@ -11,7 +11,7 @@ final class UnavailableEvaluator implements Evaluator const WARNING_MESSAGE = 'Datadog-backed PHP feature flag evaluation is unavailable for this client. ' . 'Returning default values because the ddtrace FFE runtime path was not available when the client was created.'; - public function evaluate($flagKey, $expectedType, $defaultValue, $targetingKey = null, array $attributes = array()) + public function evaluate($flagKey, $expectedType, $defaultValue, $targetingKey = null, array $attributes = array(), $targetingKeyInvalid = false) { return new EvaluationDetails( $defaultValue, diff --git a/tests/OpenFeature/DataDogProviderTest.php b/tests/OpenFeature/DataDogProviderTest.php index 1404395c075..bb1d06dcbba 100644 --- a/tests/OpenFeature/DataDogProviderTest.php +++ b/tests/OpenFeature/DataDogProviderTest.php @@ -251,7 +251,7 @@ public function setFlagNotFound(string $flagKey): self return $this; } - public function evaluate($flagKey, $expectedType, $defaultValue, $targetingKey = null, array $attributes = []) + public function evaluate($flagKey, $expectedType, $defaultValue, $targetingKey = null, array $attributes = [], $targetingKeyInvalid = false) { $this->calls[] = [ 'flagKey' => $flagKey, diff --git a/tests/OpenFeature/OpenFeatureEvaluationMetricsTest.php b/tests/OpenFeature/OpenFeatureEvaluationMetricsTest.php index 75ca6a619bc..313acacfda7 100644 --- a/tests/OpenFeature/OpenFeatureEvaluationMetricsTest.php +++ b/tests/OpenFeature/OpenFeatureEvaluationMetricsTest.php @@ -185,7 +185,7 @@ public function setSuccess( return $this; } - public function evaluate($flagKey, $expectedType, $defaultValue, $targetingKey = null, array $attributes = []) + public function evaluate($flagKey, $expectedType, $defaultValue, $targetingKey = null, array $attributes = [], $targetingKeyInvalid = false) { if (!array_key_exists($flagKey, $this->details)) { return new EvaluationDetails( diff --git a/tests/api/Unit/FeatureFlags/ClientTest.php b/tests/api/Unit/FeatureFlags/ClientTest.php index 7ea2458f419..929040e80ae 100644 --- a/tests/api/Unit/FeatureFlags/ClientTest.php +++ b/tests/api/Unit/FeatureFlags/ClientTest.php @@ -200,7 +200,7 @@ public function setSuccess( return $this; } - public function evaluate($flagKey, $expectedType, $defaultValue, $targetingKey = null, array $attributes = array()) + public function evaluate($flagKey, $expectedType, $defaultValue, $targetingKey = null, array $attributes = array(), $targetingKeyInvalid = false) { $this->calls[] = array( 'flagKey' => $flagKey, diff --git a/tests/ext/ffe/flag_evaluation_final_outcome.phpt b/tests/ext/ffe/flag_evaluation_final_outcome.phpt index 375fd14a676..eece0e573b9 100644 --- a/tests/ext/ffe/flag_evaluation_final_outcome.phpt +++ b/tests/ext/ffe/flag_evaluation_final_outcome.phpt @@ -28,7 +28,7 @@ spl_autoload_register(function ($class) use ($srcRoot) { $calls = array(); \DDTrace\install_hook('DDTrace\\Internal\\record_ffe_flag_evaluation', function ($hook) use (&$calls) { - $calls[] = $hook->args; + $calls[] = array_slice($hook->args, 0, 8); }); $config = array( 'createdAt' => '2026-05-22T00:00:00Z', diff --git a/tests/ext/ffe/flag_evaluation_kill_switch.phpt b/tests/ext/ffe/flag_evaluation_kill_switch.phpt index 1c245226d3a..0c5a354f89b 100644 --- a/tests/ext/ffe/flag_evaluation_kill_switch.phpt +++ b/tests/ext/ffe/flag_evaluation_kill_switch.phpt @@ -6,7 +6,7 @@ DD_METRICS_OTEL_ENABLED=true --FILE-- 'pro'), null, false, true + 'flag', 'blue', 'allocation', 'subject', array('plan' => 'pro'), null, false, true, 1700000000123 )); var_dump(\DDTrace\Internal\record_ffe_evaluation_metric( 'flag', 'blue', 'SPLIT', null, 'allocation' diff --git a/tests/ext/ffe/flag_evaluation_openfeature_wire.phpt b/tests/ext/ffe/flag_evaluation_openfeature_wire.phpt index 57a55a5cef3..aaf9ea1482f 100644 --- a/tests/ext/ffe/flag_evaluation_openfeature_wire.phpt +++ b/tests/ext/ffe/flag_evaluation_openfeature_wire.phpt @@ -34,5 +34,8 @@ consented_context=array(1) { string(3) "pro" } protected_count=int(1) -protected_subject=string(71) "sha256_a9491f4c1bf7b0cffbadcba2db8f028e4b3f2867cb59e1f3a0bc1968f3c51242" +protected_subject=string(71) "sha256_2234a4963a43ffe952df4e09b1fd0d36f8b49216e5cba4182ae4d01e67cf112a" protected_context_absent=bool(true) +protected_raw_bytes_absent=bool(true) +evaluation_times_preserved=bool(true) +identity_omissions_preserve_evaluations=bool(true) diff --git a/tests/ext/ffe/flag_evaluation_targeting_key.phpt b/tests/ext/ffe/flag_evaluation_targeting_key.phpt new file mode 100644 index 00000000000..62843e2c8e8 --- /dev/null +++ b/tests/ext/ffe/flag_evaluation_targeting_key.phpt @@ -0,0 +1,50 @@ +--TEST-- +FFE omits coerced targeting identity without changing evaluation or converting twice +--ENV-- +DD_METRICS_OTEL_ENABLED=false +DD_TRACE_GENERATE_ROOT_SPAN=0 +--FILE-- +conversions; + return 'subject'; + } +} +$native = $submitted = array(); +\DDTrace\install_hook('DDTrace\\ffe_evaluate', function ($hook) use (&$native) { + $native[] = $hook->args[2]; +}); +\DDTrace\install_hook('DDTrace\\Internal\\record_ffe_flag_evaluation', function ($hook) use (&$submitted) { + $submitted[] = $hook->args; +}); +$client = new \DDTrace\FeatureFlags\Client(new \DDTrace\Log\NullLogger('debug')); +$subject = new FfeTargetingSubject(); +$cases = array( + array('string', 'subject', 'subject', 'subject', false), + array('integer', 123, '123', null, true), + array('boolean', false, '', null, true), + array('object', $subject, 'subject', null, true), + array('empty', '', '', '', false), + array('null', null, null, null, false), +); +foreach ($cases as $i => $case) { + list($label, $input, $evaluatedKey, $reportedKey, $invalid) = $case; + $value = $client->getStringValue('missing', 'fallback', array('targetingKey' => $input)); + $args = $submitted[$i]; + echo $label, ': '; + var_dump($value === 'fallback' && $native[$i] === $evaluatedKey + && $args[3] === $reportedKey && isset($args[9]) && $args[9] === $invalid); +} +echo 'one conversion: '; var_dump($subject->conversions === 1); +?> +--EXPECT-- +string: bool(true) +integer: bool(true) +boolean: bool(true) +object: bool(true) +empty: bool(true) +null: bool(true) +one conversion: bool(true) diff --git a/tests/ext/ffe/flag_evaluation_timestamp.phpt b/tests/ext/ffe/flag_evaluation_timestamp.phpt new file mode 100644 index 00000000000..c7a41eb4dd1 --- /dev/null +++ b/tests/ext/ffe/flag_evaluation_timestamp.phpt @@ -0,0 +1,73 @@ +--TEST-- +FFE timestamps describe evaluation entry, including defaults and mapping errors +--ENV-- +DD_METRICS_OTEL_ENABLED=false +DD_TRACE_GENERATE_ROOT_SPAN=0 +--FILE-- + '2026-05-22T00:00:00Z', + 'environment' => array('name' => 'test'), + 'observeFullEvaluationData' => true, + 'flags' => array('flag' => array( + 'key' => 'flag', 'enabled' => true, 'variationType' => 'STRING', + 'variations' => array('blue' => array('key' => 'blue', 'value' => 'blue')), + 'allocations' => array(array( + 'key' => 'allocation', 'rules' => array(), 'doLog' => false, + 'splits' => array(array('variationKey' => 'blue', 'shards' => array())), + )), + )), +); +\DDTrace\install_hook('DDTrace\\ffe_evaluate', function () use (&$enteredNativeAt) { + $enteredNativeAt = (int) (microtime(true) * 1000); + // A timestamp taken after native evaluation would be observably too late. + usleep(3000); +}, function ($hook) use (&$reloadDuringMapping, &$config) { + if ($reloadDuringMapping) { + $config['observeFullEvaluationData'] = false; + \DDTrace\Testing\ffe_load_config(json_encode($config)); + $hook->returned->valueJson = '{invalid-json'; + } +}); +\DDTrace\install_hook('DDTrace\\Internal\\record_ffe_flag_evaluation', function ($hook) use (&$submitted) { + $submitted[] = $hook->args; +}); + +$evaluator = \DDTrace\FeatureFlags\Internal\NativeEvaluator::create(false); +$cases = array( + array('no config', 'flag', 'string', 'PROVIDER_NOT_READY', false, 'fallback'), + array('success', 'flag', 'string', null, true, 'blue'), + array('type error', 'flag', 'boolean', 'TYPE_MISMATCH', true, false), + array('missing flag', 'missing', 'string', 'FLAG_NOT_FOUND', true, 'fallback'), + array('mapping error after reload', 'flag', 'string', 'PARSE_ERROR', true, 'fallback'), + array('protected after reload', 'flag', 'string', null, false, 'blue'), + array('null native result', "bad-\xff", 'string', 'PROVIDER_NOT_READY', false, 'fallback'), +); +foreach ($cases as $i => $case) { + list($label, $flag, $type, $error, $consent, $expectedValue) = $case; + if ($i === 1) \DDTrace\Testing\ffe_load_config(json_encode($config)); + $reloadDuringMapping = $i === 4; + $before = (int) (microtime(true) * 1000); + $details = $evaluator->evaluate($flag, $type, $type === 'boolean' ? false : 'fallback'); + $args = $submitted[$i]; + echo $label, ': '; + var_dump(count($submitted) === $i + 1 + && $details->getValue() === $expectedValue + && $args[5] === $error && $args[7] === $consent + && isset($args[8]) && is_int($args[8]) + && $args[8] >= $before && $args[8] <= $enteredNativeAt); +} +?> +--EXPECT-- +no config: bool(true) +success: bool(true) +type error: bool(true) +missing flag: bool(true) +mapping error after reload: bool(true) +protected after reload: bool(true) +null native result: bool(true) diff --git a/tests/ext/ffe/flag_evaluation_wire.phpt b/tests/ext/ffe/flag_evaluation_wire.phpt index 0ad9bea2df1..d9f5609d695 100644 --- a/tests/ext/ffe/flag_evaluation_wire.phpt +++ b/tests/ext/ffe/flag_evaluation_wire.phpt @@ -33,5 +33,8 @@ consented_context=array(1) { string(3) "pro" } protected_count=int(1) -protected_subject=string(71) "sha256_a9491f4c1bf7b0cffbadcba2db8f028e4b3f2867cb59e1f3a0bc1968f3c51242" +protected_subject=string(71) "sha256_2234a4963a43ffe952df4e09b1fd0d36f8b49216e5cba4182ae4d01e67cf112a" protected_context_absent=bool(true) +protected_raw_bytes_absent=bool(true) +evaluation_times_preserved=bool(true) +identity_omissions_preserve_evaluations=bool(true) diff --git a/tests/ext/ffe/stubs/flag_evaluation_wire.inc b/tests/ext/ffe/stubs/flag_evaluation_wire.inc index 723b6725b79..309905d9ec6 100644 --- a/tests/ext/ffe/stubs/flag_evaluation_wire.inc +++ b/tests/ext/ffe/stubs/flag_evaluation_wire.inc @@ -54,7 +54,7 @@ try { $body .= $chunk; } if (strpos($requestLine, '/evp_proxy/v2/api/v2/') !== false) { - $captures[] = array($requestLine, $headers, json_decode($body, true)); + $captures[] = array($requestLine, $headers, json_decode($body, true), $body); $lastCaptureAt = microtime(true); } $response = '{}'; @@ -72,6 +72,7 @@ try { $rows = array(); $exposureCount = 0; $identity = true; + $protectedBytesAbsent = true; foreach ($captures as $capture) { list($request, $headers, $payload) = $capture; $identity = $identity && !isset($headers['dd-api-key']) @@ -82,6 +83,9 @@ try { array('dd-evp-origin' => true, 'dd-evp-origin-version' => true))), "\n"; } if (strpos($request, '/flagevaluation ') !== false) { + $protectedBytesAbsent = $protectedBytesAbsent + && strpos($capture[3], 'ffe-protected-pii-canary@example.test') === false + && strpos($capture[3], 'ffe-context-pii-canary@example.test') === false; $context = $payload['context']; ksort($context); if ($context !== array('env' => 'test', 'service' => 'php-ffe-wire', 'version' => 'wire-version')) { @@ -91,6 +95,9 @@ try { $key = $row['flag']['key']; if (isset($rows[$key])) { $rows[$key]['evaluation_count'] += $row['evaluation_count']; + $rows[$key]['timestamp'] = max($rows[$key]['timestamp'], $row['timestamp']); + $rows[$key]['first_evaluation'] = min($rows[$key]['first_evaluation'], $row['first_evaluation']); + $rows[$key]['last_evaluation'] = max($rows[$key]['last_evaluation'], $row['last_evaluation']); } else { $rows[$key] = $row; } @@ -108,6 +115,17 @@ try { echo 'protected_count='; var_dump($rows['protected']['evaluation_count']); echo 'protected_subject='; var_dump($rows['protected']['targeting_key']); echo 'protected_context_absent='; var_dump(!isset($rows['protected']['context']['evaluation'])); + echo 'protected_raw_bytes_absent='; var_dump($protectedBytesAbsent); + echo 'evaluation_times_preserved='; + var_dump($rows['timestamped']['evaluation_count'] === 2 + && $rows['timestamped']['timestamp'] === 1700000000456 + && $rows['timestamped']['first_evaluation'] === 1700000000123 + && $rows['timestamped']['last_evaluation'] === 1700000000456); + echo 'identity_omissions_preserve_evaluations='; + var_dump(!isset($rows['coerced']['targeting_key']) && $rows['coerced']['evaluation_count'] === 1 + && !isset($rows['missing']['targeting_key']) && $rows['missing']['evaluation_count'] === 1 + && !isset($rows['malformed']['targeting_key']) && $rows['malformed']['evaluation_count'] === 1 + && $rows['empty']['targeting_key'] === '' && $rows['empty']['evaluation_count'] === 1); } finally { if ($exitedAt === null) proc_terminate($process); fclose($pipes[1]); diff --git a/tests/ext/ffe/stubs/flag_evaluation_wire_app.php b/tests/ext/ffe/stubs/flag_evaluation_wire_app.php index 3627ceb4c13..653b10f87df 100644 --- a/tests/ext/ffe/stubs/flag_evaluation_wire_app.php +++ b/tests/ext/ffe/stubs/flag_evaluation_wire_app.php @@ -11,12 +11,12 @@ 'observeFullEvaluationData' => true, 'flags' => array(), ); -foreach (array('consented', 'protected') as $key) { +foreach (array('consented', 'protected', 'coerced', 'empty', 'missing', 'malformed') as $key) { $config['flags'][$key] = array( 'key' => $key, 'enabled' => true, 'variationType' => 'STRING', 'variations' => array('blue' => array('key' => 'blue', 'value' => 'blue')), 'allocations' => array(array( - 'key' => 'allocation', 'rules' => array(), 'doLog' => true, + 'key' => 'allocation', 'rules' => array(), 'doLog' => in_array($key, array('consented', 'protected'), true), 'splits' => array(array('variationKey' => 'blue', 'serialId' => 0, 'shards' => array())), )), ); @@ -39,11 +39,31 @@ } $config['observeFullEvaluationData'] = false; \DDTrace\Testing\ffe_load_config(json_encode($config)); +$protectedKey = 'ffe-protected-pii-canary@example.test'; +$protectedAttributes = array('email' => 'ffe-context-pii-canary@example.test'); +$context = array('targetingKey' => $protectedKey, 'attributes' => $protectedAttributes); +if (getenv('PHP_FFE_WIRE_OPENFEATURE') === '1') { + $context = new \OpenFeature\implementation\flags\EvaluationContext($protectedKey, + new \OpenFeature\implementation\flags\Attributes($protectedAttributes)); +} if ($client->getStringValue('protected', 'fallback', $context) !== 'blue') { throw new RuntimeException('assignment failed'); } echo 'exposure_flush=', json_encode(\DDTrace\Testing\flush_ffe_exposures()), "\n"; echo "evaluated\n"; +$nativeClient = new \DDTrace\FeatureFlags\Client(); +foreach (array('coerced' => 123, 'empty' => '', 'missing' => null, 'malformed' => "bad-\xff") as $flag => $key) { + if ($nativeClient->getStringValue($flag, 'fallback', array('targetingKey' => $key)) !== 'blue') { + throw new RuntimeException('identity handling changed assignment'); + } +} +// Fixed timestamps prove that submission preserves the evaluation time rather +// than reading the clock again when the C bridge or sidecar handles the event. +foreach (array(1700000000123, 1700000000456) as $timestamp) { + \DDTrace\Internal\record_ffe_flag_evaluation( + 'timestamped', 'blue', 'allocation', null, array(), null, false, false, $timestamp + ); +} // Give the ordinary sidecar coalescer time to send while the parent captures. // Shutdown delivery has its own lifecycle tests; this tests the live writer. usleep(1000000); diff --git a/tests/internal-api-stress-test.php b/tests/internal-api-stress-test.php index 2cec4f2c2ed..6cabfc588b9 100644 --- a/tests/internal-api-stress-test.php +++ b/tests/internal-api-stress-test.php @@ -142,10 +142,10 @@ function call_function(ReflectionFunction $function) print date('Y-m-d H:i:s') . " Executing: {$function->name}\n"; if ($function->name === 'DDTrace\\Internal\\record_ffe_flag_evaluation') { - // Eight parameters make the exhaustive garbage product too large on + // Ten parameters make the exhaustive garbage product too large on // older PHP. Exercise a valid call and each argument's garbage values // independently, keeping the other arguments valid so parsing reaches it. - $arguments = ['flag', 'variant', 'allocation', 'subject', [], null, false, false]; + $arguments = ['flag', 'variant', 'allocation', 'subject', [], null, false, false, 1700000000123, false]; $function->invokeArgs($arguments); foreach ($arguments as $position => $unused) { foreach (generate_garbage() as $garbage) { diff --git a/tracer/ddtrace.stub.php b/tracer/ddtrace.stub.php index 0ad50beed33..0f994d6f579 100644 --- a/tracer/ddtrace.stub.php +++ b/tracer/ddtrace.stub.php @@ -1095,7 +1095,7 @@ function record_ffe_evaluation_metric(string $flagKey, ?string $variant, ?string function flush_ffe_evaluation_metrics(): bool {} /** @internal Submit one final evaluation without waiting or reconnecting. */ - function record_ffe_flag_evaluation(string $flagKey, ?string $variant, ?string $allocationKey, ?string $targetingKey, array $attributes, ?string $errorType, bool $runtimeDefaultUsed, bool $observeFullEvaluationData): bool {} + function record_ffe_flag_evaluation(string $flagKey, ?string $variant, ?string $allocationKey, ?string $targetingKey, array $attributes, ?string $errorType, bool $runtimeDefaultUsed, bool $observeFullEvaluationData, int $evaluationTimestampMs, bool $targetingKeyInvalid = false): bool {} } diff --git a/tracer/ddtrace_arginfo.h b/tracer/ddtrace_arginfo.h index c34449762b9..dc107f67b09 100644 --- a/tracer/ddtrace_arginfo.h +++ b/tracer/ddtrace_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit the .stub.php file instead. - * Stub hash: fabc819a43be4dc1ce12f823113a9dd3d48af7cd */ + * Stub hash: 4725b7bd60526a5eeb72ca5e58dbd828c002876a */ 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) @@ -256,7 +256,7 @@ ZEND_END_ARG_INFO() #define arginfo_DDTrace_Internal_flush_ffe_evaluation_metrics arginfo_DDTrace_are_endpoints_collected -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_Internal_record_ffe_flag_evaluation, 0, 8, _IS_BOOL, 0) +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_Internal_record_ffe_flag_evaluation, 0, 9, _IS_BOOL, 0) ZEND_ARG_TYPE_INFO(0, flagKey, IS_STRING, 0) ZEND_ARG_TYPE_INFO(0, variant, IS_STRING, 1) ZEND_ARG_TYPE_INFO(0, allocationKey, IS_STRING, 1) @@ -265,6 +265,8 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_Internal_record_ffe_flag ZEND_ARG_TYPE_INFO(0, errorType, IS_STRING, 1) ZEND_ARG_TYPE_INFO(0, runtimeDefaultUsed, _IS_BOOL, 0) ZEND_ARG_TYPE_INFO(0, observeFullEvaluationData, _IS_BOOL, 0) + ZEND_ARG_TYPE_INFO(0, evaluationTimestampMs, IS_LONG, 0) + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, targetingKeyInvalid, _IS_BOOL, 0, "false") ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_datadog_appsec_v2_track_user_login_success, 0, 1, IS_VOID, 0) diff --git a/tracer/ffe.c b/tracer/ffe.c index 42d95bce7c9..0ed1b70bdcb 100644 --- a/tracer/ffe.c +++ b/tracer/ffe.c @@ -19,7 +19,8 @@ ZEND_EXTERN_MODULE_GLOBALS(datadog); bool ddtrace_ffe_record_flag_evaluation(zend_string *flag_key, zend_string *variant, zend_string *allocation_key, zend_string *targeting_key, HashTable *attributes, - zend_string *error_type, bool runtime_default_used, bool observe_full_evaluation_data) { + zend_string *error_type, bool runtime_default_used, bool observe_full_evaluation_data, + int64_t evaluation_timestamp_ms, bool targeting_key_invalid) { if (!get_DD_FLAGGING_EVALUATION_COUNTS_ENABLED() || !flag_key || !ZSTR_LEN(flag_key) || !DATADOG_G(sidecar) || !DATADOG_G(request_initialized)) { return false; @@ -28,7 +29,7 @@ bool ddtrace_ffe_record_flag_evaluation(zend_string *flag_key, zend_string *vari ddog_FfeScalarAttribute scalars[DD_FFE_CONTEXT_FIELD_LIMIT] = {0}; zend_string *owned_keys[DD_FFE_CONTEXT_FIELD_LIMIT] = {0}; size_t count = 0; - ddog_FfeSnapshotState snapshot = {0}; + ddog_FfeSnapshotState snapshot = {.targeting_key_invalid = targeting_key_invalid}; // Protected observations never walk or serialize their context. if (observe_full_evaluation_data) { snapshot.context_truncated = zend_hash_num_elements(attributes) > DD_FFE_CONTEXT_FIELD_LIMIT; @@ -72,11 +73,10 @@ bool ddtrace_ffe_record_flag_evaluation(zend_string *flag_key, zend_string *vari } ZEND_HASH_FOREACH_END(); } - int64_t timestamp = (int64_t) (ddtrace_nanoseconds_realtime() / 1000000); ddog_FfeFlagEvaluation event = { - .timestamp_ms = timestamp, - .first_evaluation_ms = timestamp, - .last_evaluation_ms = timestamp, + .timestamp_ms = evaluation_timestamp_ms, + .first_evaluation_ms = evaluation_timestamp_ms, + .last_evaluation_ms = evaluation_timestamp_ms, .evaluation_count = 1, .flag_key = dd_zend_string_to_CharSlice(flag_key), .variant = dd_zend_string_to_CharSlice(variant), diff --git a/tracer/ffe.h b/tracer/ffe.h index 2353db57780..ab5c8ed3c6f 100644 --- a/tracer/ffe.h +++ b/tracer/ffe.h @@ -11,7 +11,8 @@ bool ddtrace_ffe_flush_evaluation_metrics(void); bool ddtrace_ffe_record_flag_evaluation(zend_string *flag_key, zend_string *variant, zend_string *allocation_key, zend_string *targeting_key, HashTable *attributes, - zend_string *error_type, bool runtime_default_used, bool observe_full_evaluation_data); + zend_string *error_type, bool runtime_default_used, bool observe_full_evaluation_data, + int64_t evaluation_timestamp_ms, bool targeting_key_invalid); void ddtrace_ffe_record_exposure(zend_string *flag_key, zend_string *targeting_key, zend_string *subject_attributes_json, zend_string *allocation_key, zend_string *variant, int32_t serial_id, bool has_serial_id); bool ddtrace_ffe_flush_exposures(void); diff --git a/tracer/functions.c b/tracer/functions.c index aa4cd128024..6bee4575114 100644 --- a/tracer/functions.c +++ b/tracer/functions.c @@ -2938,7 +2938,9 @@ PHP_FUNCTION(DDTrace_Internal_record_ffe_flag_evaluation) { zend_string *flag_key, *variant = NULL, *allocation_key = NULL, *targeting_key = NULL, *error_type = NULL; HashTable *attributes; zend_bool runtime_default_used, observe_full_evaluation_data; - ZEND_PARSE_PARAMETERS_START(8, 8) + zend_long evaluation_timestamp_ms; + zend_bool targeting_key_invalid = false; + ZEND_PARSE_PARAMETERS_START(9, 10) Z_PARAM_STR(flag_key) Z_PARAM_STR_OR_NULL(variant) Z_PARAM_STR_OR_NULL(allocation_key) @@ -2947,9 +2949,13 @@ PHP_FUNCTION(DDTrace_Internal_record_ffe_flag_evaluation) { Z_PARAM_STR_OR_NULL(error_type) Z_PARAM_BOOL(runtime_default_used) Z_PARAM_BOOL(observe_full_evaluation_data) + Z_PARAM_LONG(evaluation_timestamp_ms) + Z_PARAM_OPTIONAL + Z_PARAM_BOOL(targeting_key_invalid) ZEND_PARSE_PARAMETERS_END(); RETURN_BOOL(ddtrace_ffe_record_flag_evaluation(flag_key, variant, allocation_key, - targeting_key, attributes, error_type, runtime_default_used, observe_full_evaluation_data)); + targeting_key, attributes, error_type, runtime_default_used, observe_full_evaluation_data, + evaluation_timestamp_ms, targeting_key_invalid)); } /* {{{ proto array generate_distributed_tracing_headers() */ From ea71f9cea871826ab78958232bf02cbd0fbbb4d5 Mon Sep 17 00:00:00 2001 From: Vickie Boettcher Date: Wed, 14 Oct 2026 22:00:13 +0000 Subject: [PATCH 06/15] feat(ffe): flush pending evaluations and report submission drops --- tests/ext/ffe/flag_evaluation_flush.phpt | 29 ++++ tests/ext/ffe/flag_evaluation_fork.phpt | 27 ++++ .../ffe/flag_evaluation_openfeature_wire.phpt | 8 ++ .../flag_evaluation_rejection_metrics.phpt | 124 ++++++++++++++++++ tests/ext/ffe/flag_evaluation_wire.phpt | 8 ++ tests/ext/ffe/stubs/flag_evaluation_flush.inc | 85 ++++++++++++ .../ffe/stubs/flag_evaluation_flush_app.php | 37 ++++++ tests/ext/ffe/stubs/flag_evaluation_wire.inc | 40 ++++++ .../ffe/stubs/flag_evaluation_wire_app.php | 40 ++++++ tracer/ddtrace.c | 5 + tracer/ddtrace_globals.h | 7 + tracer/ffe.c | 31 ++++- tracer/ffe.h | 1 + tracer/functions.c | 2 + tracer/tracer_telemetry.c | 61 +++++++++ tracer/tracer_telemetry.h | 2 + 16 files changed, 504 insertions(+), 3 deletions(-) create mode 100644 tests/ext/ffe/flag_evaluation_flush.phpt create mode 100644 tests/ext/ffe/flag_evaluation_fork.phpt create mode 100644 tests/ext/ffe/flag_evaluation_rejection_metrics.phpt create mode 100644 tests/ext/ffe/stubs/flag_evaluation_flush.inc create mode 100644 tests/ext/ffe/stubs/flag_evaluation_flush_app.php diff --git a/tests/ext/ffe/flag_evaluation_flush.phpt b/tests/ext/ffe/flag_evaluation_flush.phpt new file mode 100644 index 00000000000..8865a436b7a --- /dev/null +++ b/tests/ext/ffe/flag_evaluation_flush.phpt @@ -0,0 +1,29 @@ +--TEST-- +FFE explicit and configured shutdown flushes wait for pending HTTP sends with either trace sender +--SKIPIF-- + +--ENV-- +DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 +DD_REMOTE_CONFIG_ENABLED=0 +DD_METRICS_OTEL_ENABLED=0 +DD_TRACE_GENERATE_ROOT_SPAN=0 +DD_TRACE_CLI_ENABLED=1 +DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED=true +DD_TRACE_AGENT_TIMEOUT=5000 +DD_SERVICE=php-ffe-flush +--FILE-- + +--EXPECT-- +false/explicit: bool(true) +false/internal: bool(true) +false/shutdown: bool(true) +true/explicit: bool(true) +true/internal: bool(true) +true/shutdown: bool(true) diff --git a/tests/ext/ffe/flag_evaluation_fork.phpt b/tests/ext/ffe/flag_evaluation_fork.phpt new file mode 100644 index 00000000000..35d53417cd5 --- /dev/null +++ b/tests/ext/ffe/flag_evaluation_fork.phpt @@ -0,0 +1,27 @@ +--TEST-- +FFE observations before and after fork use the existing subprocess-sidecar lifecycle without duplication +--SKIPIF-- + +--ENV-- +DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 +DD_REMOTE_CONFIG_ENABLED=0 +DD_METRICS_OTEL_ENABLED=0 +DD_TRACE_GENERATE_ROOT_SPAN=0 +DD_TRACE_CLI_ENABLED=1 +DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED=true +DD_TRACE_AGENT_TIMEOUT=5000 +DD_TRACE_SIDECAR_CONNECTION_MODE=subprocess +DD_SERVICE=php-ffe-fork +--FILE-- + +--EXPECT-- +false/fork: bool(true) +true/fork: bool(true) diff --git a/tests/ext/ffe/flag_evaluation_openfeature_wire.phpt b/tests/ext/ffe/flag_evaluation_openfeature_wire.phpt index aaf9ea1482f..40df03cb66c 100644 --- a/tests/ext/ffe/flag_evaluation_openfeature_wire.phpt +++ b/tests/ext/ffe/flag_evaluation_openfeature_wire.phpt @@ -39,3 +39,11 @@ protected_context_absent=bool(true) protected_raw_bytes_absent=bool(true) evaluation_times_preserved=bool(true) identity_omissions_preserve_evaluations=bool(true) +first_256_inspected=bool(true) +unicode_and_scalar_boundaries=bool(true) +owned_snapshot=bool(true) +invalid_context_keeps_observation=bool(true) +recovered_connection_preserves_privacy=bool(true) +consented_identity_and_safe_error=bool(true) +diagnostics_exercised=bool(true) +diagnostics_pii_absent=bool(true) diff --git a/tests/ext/ffe/flag_evaluation_rejection_metrics.phpt b/tests/ext/ffe/flag_evaluation_rejection_metrics.phpt new file mode 100644 index 00000000000..79d1b440d82 --- /dev/null +++ b/tests/ext/ffe/flag_evaluation_rejection_metrics.phpt @@ -0,0 +1,124 @@ +--TEST-- +FFE producer rejections are counted once without counting accepted or disabled observations +--SKIPIF-- + +--ENV-- +DD_TRACE_GENERATE_ROOT_SPAN=0 +DD_TRACE_CLI_ENABLED=1 +DD_INSTRUMENTATION_TELEMETRY_ENABLED=1 +DD_REMOTE_CONFIG_ENABLED=0 +DD_METRICS_OTEL_ENABLED=0 +DD_TRACE_LOG_LEVEL=off +--INI-- +datadog.trace.agent_url="file://{PWD}/ffe-rejection-metrics.out" +--FILE-- +service = $service; +$span->env = 'test'; +\DDTrace\close_span(); +dd_trace_serialize_closed_spans(); + +// Invalid required metadata cannot be encoded. Optional identity loss is not +// a dropped evaluation; native telemetry accounts for its omission instead. +var_dump(recordEvaluation("invalid-\xFF")); +var_dump(recordEvaluation("invalid-\xFF")); +var_dump(recordEvaluation('')); +var_dump(recordEvaluation('valid', "invalid-\xFF")); +var_dump(recordEvaluation(str_repeat('x', 10 * 1024 * 1024))); +var_dump(recordEvaluation('degraded', str_repeat('x', 10 * 1024 * 1024))); +if (ini_set('datadog.flagging_evaluation_counts_enabled', '0') === false) { + throw new RuntimeException('could not disable flagevaluation submission'); +} +var_dump(recordEvaluation("disabled-\xFF")); +ini_set('datadog.flagging_evaluation_counts_enabled', '1'); + +// Evaluation must not reconnect. Wait for the existing broken-connection +// helper to take effect, then use ordinary lifecycle traffic to recover. +dd_trace_internal_fn('break_sidecar_connection'); +$deadline = microtime(true) + 5; +do { + usleep(10000); + $accepted = recordEvaluation('after-disconnect'); +} while ($accepted && microtime(true) < $deadline); +var_dump($accepted); +var_dump(recordEvaluation('still-disconnected')); +var_dump(recordEvaluation('still-disconnected')); +dd_trace_internal_fn('stats_sidecar'); +var_dump(recordEvaluation('recovered')); +// Finish accepted EVP writes before CLEAN removes the shared capture file. +dd_trace_synchronous_flush(); + +// Finalize again for a fresh telemetry application. Reusing the already-stopped +// worker could hide duplicate points by discarding them, rather than proving +// that the PHP counters were drained. +dd_trace_internal_fn('finalize_telemetry'); +$secondService = $service . '-second'; +$span = \DDTrace\start_span(); +$span->service = $secondService; +$span->env = 'test'; +\DDTrace\close_span(); +dd_trace_serialize_closed_spans(); +dd_trace_internal_fn('finalize_telemetry'); +$path = __DIR__ . '/ffe-rejection-metrics.out'; +$deadline = microtime(true) + 30; +do { + usleep(10000); + $counts = array(); + $closed = array(); + $raw = is_file($path) ? file_get_contents($path) : ''; + foreach (explode("\n", $raw) as $line) { + $envelope = json_decode($line, true); + $application = $envelope['application']['service_name'] ?? ''; + if ($application !== $service && $application !== $secondService) continue; + $batch = $envelope['request_type'] === 'message-batch' ? $envelope['payload'] : array($envelope); + foreach ($batch as $event) { + if ($event['request_type'] === 'app-closing') $closed[$application] = true; + if ($event['request_type'] !== 'generate-metrics') continue; + foreach ($event['payload']['series'] as $series) { + if ($series['metric'] !== 'flagevaluation.rows.dropped') continue; + $key = $series['namespace'] . ':' . implode(',', $series['tags']); + foreach ($series['points'] as $point) { + $counts[$key] = ($counts[$key] ?? 0) + $point[1]; + } + } + } + } +} while (count($closed) !== 2 && microtime(true) < $deadline); +ksort($counts); +echo 'completed='; var_dump(count($closed) === 2); +echo 'drop_counts=', json_encode($counts), "\n"; +echo 'diagnostics_pii_absent='; +var_dump(strpos($raw, 'rejection-pii-canary@example.test') === false); +?> +--EXPECT-- +bool(false) +bool(false) +bool(false) +bool(true) +bool(false) +bool(true) +bool(false) +bool(false) +bool(false) +bool(false) +bool(true) +completed=bool(true) +drop_counts={"tracers:reason:payload_limit":1,"tracers:reason:serialization_error":3,"tracers:reason:unavailable":3} +diagnostics_pii_absent=bool(true) +--CLEAN-- + 1, 'flush-flag' => 1) : array('flush-flag' => 1); + ksort($counts); + echo $sidecarTraceSender, '/', $mode, ': '; + var_dump($counts === $expected && $blocked && ($mode === 'shutdown' || strpos($stdout, 'returned') !== false)); + } finally { + if ($exitCode === null) proc_terminate($process); + foreach ($pipes as $pipe) fclose($pipe); + proc_close($process); + fclose($listener); + } + } +} +?> diff --git a/tests/ext/ffe/stubs/flag_evaluation_flush_app.php b/tests/ext/ffe/stubs/flag_evaluation_flush_app.php new file mode 100644 index 00000000000..9f1cb8181bf --- /dev/null +++ b/tests/ext/ffe/stubs/flag_evaluation_flush_app.php @@ -0,0 +1,37 @@ + 'test', 'service' => 'php-ffe-wire', 'version' => 'wire-version')) { @@ -126,11 +134,43 @@ try { && !isset($rows['missing']['targeting_key']) && $rows['missing']['evaluation_count'] === 1 && !isset($rows['malformed']['targeting_key']) && $rows['malformed']['evaluation_count'] === 1 && $rows['empty']['targeting_key'] === '' && $rows['empty']['evaluation_count'] === 1); + $bounded = $rows['bounded']['context']['evaluation']; + echo 'first_256_inspected='; + var_dump(count($bounded) === 255 && !isset($bounded['rejected']) + && !isset($bounded['beyond-limit']) && $bounded['field-1'] === 1 && $bounded['field-255'] === 255); + $unicode = $rows['unicode']['context']['evaluation']; + $expected = array(str_repeat('é', 256) => str_repeat('😀', 256), + 'bool' => false, 'integer' => -42, 'float' => 1.5, + 0 => 'zero-key', -1 => 'negative-key', 'empty' => ''); + ksort($unicode); ksort($expected); + echo 'unicode_and_scalar_boundaries='; var_dump($unicode === $expected); + echo 'owned_snapshot='; + var_dump($rows['snapshot']['context']['evaluation'] === array('value' => 'before-submission')); + echo 'invalid_context_keeps_observation='; + var_dump($rows['all-invalid']['evaluation_count'] === 1 && $rows['all-invalid']['variant']['key'] === 'blue' + && $rows['all-invalid']['targeting_key'] === 'subject' && !isset($rows['all-invalid']['context']['evaluation'])); + echo 'recovered_connection_preserves_privacy='; + var_dump($rows['recovered']['evaluation_count'] === 1 + && $rows['recovered']['targeting_key'] === 'sha256_2234a4963a43ffe952df4e09b1fd0d36f8b49216e5cba4182ae4d01e67cf112a' + && !isset($rows['recovered']['context']['evaluation'])); + echo 'consented_identity_and_safe_error='; + var_dump($rows['consented-canary']['targeting_key'] === 'ffe-consented-pii-canary@example.test' + && $rows['consented-canary']['context']['evaluation']['email'] === 'ffe-consented-context-canary@example.test' + && $rows['consented-canary']['error']['message'] === 'GENERAL'); + $diagnostics = file_get_contents($diagnosticLog) . $stderr; + echo 'diagnostics_exercised='; var_dump(strpos($diagnostics, 'FfeFlagEvaluationEvent') !== false); + echo 'diagnostics_pii_absent='; + var_dump(strpos($diagnostics, 'ffe-protected-pii-canary@example.test') === false + && strpos($diagnostics, 'ffe-context-pii-canary@example.test') === false + && strpos($diagnostics, 'ffe-consented-pii-canary@example.test') === false + && strpos($diagnostics, 'ffe-consented-context-canary@example.test') === false + && strpos($diagnostics, 'ffe-error-pii-canary@example.test') === false); } finally { if ($exitedAt === null) proc_terminate($process); fclose($pipes[1]); fclose($pipes[2]); proc_close($process); fclose($listener); + @unlink($diagnosticLog); } ?> diff --git a/tests/ext/ffe/stubs/flag_evaluation_wire_app.php b/tests/ext/ffe/stubs/flag_evaluation_wire_app.php index 653b10f87df..c3fa4cd0785 100644 --- a/tests/ext/ffe/stubs/flag_evaluation_wire_app.php +++ b/tests/ext/ffe/stubs/flag_evaluation_wire_app.php @@ -64,6 +64,46 @@ 'timestamped', 'blue', 'allocation', null, array(), null, false, false, $timestamp ); } +// Exercise the PHP/native snapshot boundary directly: the public evaluator +// already filters non-scalars before this boundary, but internal calls must +// still omit unsafe fields without losing the observation or invoking objects. +class FfeUnserializableContext { + public function __toString() { throw new RuntimeException('must not convert context'); } +} +$bounded = array('rejected' => str_repeat('x', 257)); +for ($i = 1; $i < 256; ++$i) $bounded['field-' . $i] = $i; +$bounded['beyond-limit'] = 'must-not-be-inspected'; +$unicode = array( + str_repeat('é', 256) => str_repeat('😀', 256), + str_repeat('é', 257) => 'overlong-key', + 'overlong-value' => str_repeat('😀', 257), + 'invalid-value' => "bad-\xff", "bad-\xff" => 'invalid-key', + 'nan' => NAN, 'infinity' => INF, + 'object' => new FfeUnserializableContext(), 'nested' => array('ignored'), + 'bool' => false, 'integer' => -42, 'float' => 1.5, + 0 => 'zero-key', -1 => 'negative-key', 'empty' => '', +); +$original = 'before-submission'; +$snapshot = array('value' => &$original); +foreach (array('bounded' => $bounded, 'unicode' => $unicode, 'snapshot' => $snapshot, + 'all-invalid' => array('bad' => NAN, 'object' => new FfeUnserializableContext())) as $flag => $attributes) { + if (!\DDTrace\Internal\record_ffe_flag_evaluation( + $flag, 'blue', 'allocation', 'subject', $attributes, null, false, true, 1700000000123 + )) throw new RuntimeException('context observation rejected'); +} +$original = 'after-submission'; +\DDTrace\Internal\record_ffe_flag_evaluation( + 'consented-canary', 'blue', null, 'ffe-consented-pii-canary@example.test', + array('email' => 'ffe-consented-context-canary@example.test'), + 'ffe-error-pii-canary@example.test', false, true, 1700000000123 +); +// Follow the existing broken-pipe telemetry test: a normal shared-sender +// operation notices the broken connection and replays its configuration. +dd_trace_internal_fn('break_sidecar_connection'); +dd_trace_internal_fn('stats_sidecar'); +if (!\DDTrace\Internal\record_ffe_flag_evaluation( + 'recovered', 'blue', null, $protectedKey, $protectedAttributes, null, false, false, 1700000000123 +)) throw new RuntimeException('submission after shared-sender recovery rejected'); // Give the ordinary sidecar coalescer time to send while the parent captures. // Shutdown delivery has its own lifecycle tests; this tests the live writer. usleep(1000000); diff --git a/tracer/ddtrace.c b/tracer/ddtrace.c index bcd418d0d4d..566b891386c 100644 --- a/tracer/ddtrace.c +++ b/tracer/ddtrace.c @@ -405,6 +405,9 @@ void ddtrace_mshutdown() { if (get_global_DD_TRACE_FORCE_FLUSH_ON_SHUTDOWN() && DATADOG_G(sidecar)) { ddog_sidecar_flush(&DATADOG_G(sidecar), (ddog_SidecarFlushOptions){.traces_and_stats = true, .telemetry = true}); } + if (get_global_DD_TRACE_FORCE_FLUSH_ON_SHUTDOWN()) { + ddtrace_ffe_flush_flag_evaluations(); + } ddtrace_telemetry_flush_bgs_metrics_final(); @@ -724,6 +727,8 @@ void ddtrace_internal_handle_postfork(void) { } void ddtrace_internal_handle_fork() { + // The parent owns its already-recorded losses; the child starts fresh. + memset(&DDTRACE_G(ffe_submission_drops), 0, sizeof(DDTRACE_G(ffe_submission_drops))); #ifdef __linux__ ddtrace_otel_detach(); ddtrace_otel_tid_fork_handler(); diff --git a/tracer/ddtrace_globals.h b/tracer/ddtrace_globals.h index a0bc90fb8c5..6b9e5280216 100644 --- a/tracer/ddtrace_globals.h +++ b/tracer/ddtrace_globals.h @@ -121,6 +121,13 @@ typedef struct { void *ffe_metric_buffer; size_t ffe_metric_buffer_len; size_t ffe_metric_buffer_cap; + bool ffe_evaluations_pending_flush; + struct { + uint64_t unavailable; + uint64_t queue_overflow; + uint64_t serialization_error; + uint64_t payload_limit; + } ffe_submission_drops; } ddtrace_globals; diff --git a/tracer/ffe.c b/tracer/ffe.c index 0ed1b70bdcb..0064f872990 100644 --- a/tracer/ffe.c +++ b/tracer/ffe.c @@ -2,6 +2,7 @@ #include "configuration.h" #include "span.h" +#include "tracer_telemetry.h" #include #include #include @@ -21,8 +22,15 @@ bool ddtrace_ffe_record_flag_evaluation(zend_string *flag_key, zend_string *vari zend_string *allocation_key, zend_string *targeting_key, HashTable *attributes, zend_string *error_type, bool runtime_default_used, bool observe_full_evaluation_data, int64_t evaluation_timestamp_ms, bool targeting_key_invalid) { - if (!get_DD_FLAGGING_EVALUATION_COUNTS_ENABLED() || !flag_key || !ZSTR_LEN(flag_key) - || !DATADOG_G(sidecar) || !DATADOG_G(request_initialized)) { + if (!get_DD_FLAGGING_EVALUATION_COUNTS_ENABLED()) { + return false; + } + if (!flag_key || !ZSTR_LEN(flag_key)) { + ddtrace_telemetry_count_ffe_rejection(DDOG_FFE_SUBMISSION_STATUS_INVALID_INPUT); + return false; + } + if (!DATADOG_G(sidecar) || !DATADOG_G(request_initialized)) { + ddtrace_telemetry_count_ffe_rejection(DDOG_FFE_SUBMISSION_STATUS_UNAVAILABLE); return false; } @@ -99,7 +107,24 @@ bool ddtrace_ffe_record_flag_evaluation(zend_string *flag_key, zend_string *vari zend_string_release(owned_keys[i]); } } - return status == DDOG_FFE_SUBMISSION_STATUS_ACCEPTED; + if (status == DDOG_FFE_SUBMISSION_STATUS_ACCEPTED) { + DDTRACE_G(ffe_evaluations_pending_flush) = true; + return true; + } + ddtrace_telemetry_count_ffe_rejection(status); + return false; +} + +void ddtrace_ffe_flush_flag_evaluations(void) { + if (!DDTRACE_G(ffe_evaluations_pending_flush) || !DATADOG_G(sidecar)) { + return; + } + // No extra flush IPC for applications that have not submitted evaluations. + // The sidecar may already have sent them on its timer; this also waits for + // any in-flight send. Delivery remains best-effort under its normal timeout. + DDTRACE_G(ffe_evaluations_pending_flush) = false; + datadog_ffi_try("Failed flushing flag evaluations", + ddog_sidecar_flush(&DATADOG_G(sidecar), (ddog_SidecarFlushOptions){.flag_evaluations = true})); } typedef struct { diff --git a/tracer/ffe.h b/tracer/ffe.h index ab5c8ed3c6f..2b52e938096 100644 --- a/tracer/ffe.h +++ b/tracer/ffe.h @@ -13,6 +13,7 @@ bool ddtrace_ffe_record_flag_evaluation(zend_string *flag_key, zend_string *vari zend_string *allocation_key, zend_string *targeting_key, HashTable *attributes, zend_string *error_type, bool runtime_default_used, bool observe_full_evaluation_data, int64_t evaluation_timestamp_ms, bool targeting_key_invalid); +void ddtrace_ffe_flush_flag_evaluations(void); void ddtrace_ffe_record_exposure(zend_string *flag_key, zend_string *targeting_key, zend_string *subject_attributes_json, zend_string *allocation_key, zend_string *variant, int32_t serial_id, bool has_serial_id); bool ddtrace_ffe_flush_exposures(void); diff --git a/tracer/functions.c b/tracer/functions.c index 6bee4575114..2234e9a2dac 100644 --- a/tracer/functions.c +++ b/tracer/functions.c @@ -2141,6 +2141,7 @@ PHP_FUNCTION(dd_trace_internal_fn) { if (DATADOG_G(sidecar)) { datadog_ffi_try("Failed synchronously flushing traces", ddog_sidecar_flush(&DATADOG_G(sidecar), (ddog_SidecarFlushOptions){.traces_and_stats = true})); } + ddtrace_ffe_flush_flag_evaluations(); RETVAL_TRUE; #ifndef _WIN32 } else if (FUNCTION_NAME_MATCHES("init_and_start_writer")) { @@ -2353,6 +2354,7 @@ PHP_FUNCTION(dd_trace_synchronous_flush) { if (DATADOG_G(sidecar)) { datadog_ffi_try("Failed synchronously flushing traces", ddog_sidecar_flush(&DATADOG_G(sidecar), (ddog_SidecarFlushOptions){.traces_and_stats = true})); } + ddtrace_ffe_flush_flag_evaluations(); RETURN_NULL(); } diff --git a/tracer/tracer_telemetry.c b/tracer/tracer_telemetry.c index d4f9f659e17..b8c6e036e41 100644 --- a/tracer/tracer_telemetry.c +++ b/tracer/tracer_telemetry.c @@ -73,6 +73,64 @@ void ddtrace_telemetry_first_init(void) { dd_composer_hook_id = zai_hook_install((zai_str)ZAI_STR_EMPTY, (zai_str)ZAI_STR_EMPTY, dd_check_for_composer_autoloader, NULL, ZAI_HOOK_AUX_UNUSED, 0); } +void ddtrace_telemetry_count_ffe_rejection(ddog_FfeSubmissionStatus status) { + if (!get_global_DD_INSTRUMENTATION_TELEMETRY_ENABLED()) { + return; + } + uint64_t *count; + switch (status) { + case DDOG_FFE_SUBMISSION_STATUS_UNAVAILABLE: + count = &DDTRACE_G(ffe_submission_drops.unavailable); + break; + case DDOG_FFE_SUBMISSION_STATUS_WOULD_BLOCK: + // Shared load shedding, pending configuration, or socket pressure. + // Conversion already happened, so this is not pre_queue_overflow. + count = &DDTRACE_G(ffe_submission_drops.queue_overflow); + break; + case DDOG_FFE_SUBMISSION_STATUS_INVALID_INPUT: + // Required metadata cannot be encoded; optional field loss is + // reported independently by the native sidecar, not as a row drop. + count = &DDTRACE_G(ffe_submission_drops.serialization_error); + break; + case DDOG_FFE_SUBMISSION_STATUS_PAYLOAD_TOO_LARGE: + count = &DDTRACE_G(ffe_submission_drops.payload_limit); + break; + default: + return; + } + if (*count < UINT64_MAX) { + ++*count; + } +} + +static void ddtrace_telemetry_flush_ffe_rejections(ddog_SidecarActionsBuffer *buffer) { + struct { + uint64_t *count; + ddog_CharSlice tag; + } reasons[] = { + {&DDTRACE_G(ffe_submission_drops.unavailable), DDOG_CHARSLICE_C("reason:unavailable")}, + {&DDTRACE_G(ffe_submission_drops.queue_overflow), DDOG_CHARSLICE_C("reason:queue_overflow")}, + {&DDTRACE_G(ffe_submission_drops.serialization_error), DDOG_CHARSLICE_C("reason:serialization_error")}, + {&DDTRACE_G(ffe_submission_drops.payload_limit), DDOG_CHARSLICE_C("reason:payload_limit")}, + }; + ddog_CharSlice metric = DDOG_CHARSLICE_C("flagevaluation.rows.dropped"); + bool registered = false; + for (size_t i = 0; i < sizeof(reasons) / sizeof(reasons[0]); ++i) { + uint64_t count = *reasons[i].count; + if (!count) { + continue; + } + if (!registered) { + ddog_sidecar_telemetry_register_metric(&DATADOG_G(sidecar), metric, DDOG_METRIC_TYPE_COUNT, DDOG_METRIC_NAMESPACE_TRACERS); + registered = true; + } + // Ordinary telemetry is best-effort. Do not replay these losses on a + // second finalize, or duplicate the sidecar's later-stage drop counters. + *reasons[i].count = 0; + ddog_sidecar_telemetry_add_span_metric_point_buffer(buffer, metric, (double)count, reasons[i].tag); + } +} + void ddtrace_telemetry_finalize(void) { ddog_SidecarActionsBuffer *buffer = datadog_telemetry_buffer(); // Send information about explicitly disabled integrations @@ -107,11 +165,14 @@ void ddtrace_telemetry_finalize(void) { ddog_sidecar_telemetry_register_metric(&DATADOG_G(sidecar), DDOG_CHARSLICE_C("context_header_style.malformed"), DDOG_METRIC_TYPE_COUNT, DDOG_METRIC_NAMESPACE_TRACERS); ddog_sidecar_telemetry_add_span_metric_point_buffer(buffer, DDOG_CHARSLICE_C("context_header_style.malformed"), DDTRACE_G(baggage_malformed_count), DDOG_CHARSLICE_C("header_style:baggage")); + ddtrace_telemetry_flush_ffe_rejections(buffer); + // Flush any accumulated BGS (background sender) metrics if enough time has passed. ddtrace_telemetry_flush_bgs_metrics_if_due(DATADOG_GLOBALS_PTR()); } void ddtrace_telemetry_rinit(void) { + memset(&DDTRACE_G(ffe_submission_drops), 0, sizeof(DDTRACE_G(ffe_submission_drops))); zend_hash_init(&DDTRACE_G(telemetry_spans_created_per_integration), 8, unused, NULL, 0); DDTRACE_G(baggage_extract_count) = 0; DDTRACE_G(baggage_inject_count) = 0; diff --git a/tracer/tracer_telemetry.h b/tracer/tracer_telemetry.h index 05de395fe32..1e1234ae447 100644 --- a/tracer/tracer_telemetry.h +++ b/tracer/tracer_telemetry.h @@ -12,6 +12,8 @@ void ddtrace_telemetry_notify_integration(const char *name, size_t name_len); void ddtrace_telemetry_notify_integration_version(const char *name, size_t name_len, const char *version, size_t version_len); void ddtrace_telemetry_inc_spans_created(ddtrace_span_data *span); +// Fixed request-local counters only; no logging, allocation, or sidecar calls. +void ddtrace_telemetry_count_ffe_rejection(ddog_FfeSubmissionStatus status); // Called by the background sender thread (coms.c) to accumulate metrics atomically. // Never touches the sidecar; the request thread flushes via ddtrace_telemetry_flush_bgs_metrics_if_due(). void ddtrace_telemetry_send_trace_api_metrics(trace_api_metrics metrics); From 6521c444e740175df4d17ed6031efe617fe19a80 Mon Sep 17 00:00:00 2001 From: Vickie Boettcher Date: Wed, 14 Oct 2026 22:09:58 +0000 Subject: [PATCH 07/15] test(ffe): cover forked drop counters and late event delivery --- .../ffe/flag_evaluation_rejection_fork.phpt | 88 +++++++++++++++++++ tests/ext/ffe/stubs/flag_evaluation_flush.inc | 22 +++-- 2 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 tests/ext/ffe/flag_evaluation_rejection_fork.phpt diff --git a/tests/ext/ffe/flag_evaluation_rejection_fork.phpt b/tests/ext/ffe/flag_evaluation_rejection_fork.phpt new file mode 100644 index 00000000000..70bd2ae260a --- /dev/null +++ b/tests/ext/ffe/flag_evaluation_rejection_fork.phpt @@ -0,0 +1,88 @@ +--TEST-- +FFE fork children report only their own rejected observations +--SKIPIF-- + +--ENV-- +DD_TRACE_GENERATE_ROOT_SPAN=0 +DD_TRACE_CLI_ENABLED=1 +DD_INSTRUMENTATION_TELEMETRY_ENABLED=1 +DD_REMOTE_CONFIG_ENABLED=0 +DD_METRICS_OTEL_ENABLED=0 +DD_TRACE_SIDECAR_CONNECTION_MODE=subprocess +DD_TRACE_LOG_LEVEL=off +--INI-- +datadog.trace.agent_url="file://{PWD}/ffe-rejection-fork.out" +--FILE-- +service = $service; + $span->env = 'test'; + \DDTrace\close_span(); + dd_trace_serialize_closed_spans(); +} + +function rejectEvaluation() { + if (\DDTrace\Internal\record_ffe_flag_evaluation( + '', 'blue', null, null, array(), null, false, false, 1700000000123 + )) throw new RuntimeException('invalid observation was accepted'); +} + +$parentService = 'ffe-rejection-parent-' . getmypid(); +setApplication($parentService); +rejectEvaluation(); +rejectEvaluation(); +$pid = pcntl_fork(); +if ($pid === -1) throw new RuntimeException('fork failed'); +if ($pid === 0) { + setApplication('ffe-rejection-child-' . getmypid()); + rejectEvaluation(); + dd_trace_internal_fn('finalize_telemetry'); + exit(0); +} +pcntl_waitpid($pid, $status); +if (!pcntl_wifexited($status) || pcntl_wexitstatus($status) !== 0) { + throw new RuntimeException('child failed'); +} +dd_trace_internal_fn('finalize_telemetry'); + +// Separate applications expose inherited counters instead of letting a stopped +// telemetry worker silently discard the child's data. Wait for both to finish. +$services = array($parentService => 'parent', 'ffe-rejection-child-' . $pid => 'child'); +$path = __DIR__ . '/ffe-rejection-fork.out'; +$deadline = microtime(true) + 30; +do { + usleep(10000); + $counts = array('parent' => 0, 'child' => 0); + $closed = array(); + foreach (is_file($path) ? file($path) : array() as $line) { + $envelope = json_decode($line, true); + $service = $envelope['application']['service_name'] ?? ''; + if (!isset($services[$service])) continue; + $batch = $envelope['request_type'] === 'message-batch' ? $envelope['payload'] : array($envelope); + foreach ($batch as $event) { + if ($event['request_type'] === 'app-closing') $closed[$service] = true; + if ($event['request_type'] !== 'generate-metrics') continue; + foreach ($event['payload']['series'] as $series) { + if ($series['metric'] !== 'flagevaluation.rows.dropped') continue; + if ($series['namespace'] !== 'tracers' || $series['tags'] !== array('reason:serialization_error')) { + throw new RuntimeException('unexpected drop metric dimensions'); + } + foreach ($series['points'] as $point) $counts[$services[$service]] += $point[1]; + } + } + } +} while (count($closed) !== 2 && microtime(true) < $deadline); +echo 'completed='; var_dump(count($closed) === 2); +echo 'drop_counts=', json_encode($counts), "\n"; +?> +--EXPECT-- +completed=bool(true) +drop_counts={"parent":2,"child":1} +--CLEAN-- + 0.5) break; $connection = @stream_socket_accept($listener, 0.1); if (!$connection) continue; stream_set_timeout($connection, 2); @@ -38,6 +45,7 @@ foreach (array('false', 'true') as $sidecarTraceSender) { $body .= $chunk; } if (strpos($request, '/flagevaluation ') !== false) { + $lastCaptureAt = microtime(true); $payload = json_decode($body, true); foreach ($payload['flagEvaluations'] as $row) { $key = $row['flag']['key']; @@ -57,7 +65,10 @@ foreach (array('false', 'true') as $sidecarTraceSender) { $status = proc_get_status($process); if (strpos($stdout, 'returned') !== false || !$status['running']) { $blocked = false; - if (!$status['running']) $exitCode = $status['exitcode']; + if (!$status['running']) { + $exitCode = $status['exitcode']; + $exitedAt = microtime(true); + } break; } usleep(1000); @@ -65,7 +76,6 @@ foreach (array('false', 'true') as $sidecarTraceSender) { } fwrite($connection, "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}"); fclose($connection); - if ($exitCode !== null) break; } while (microtime(true) < $deadline); $stdout .= ffeTestProcessRead($pipes[1]); $stderr .= ffeTestProcessRead($pipes[2]); From bac4b232821b496e22953fb4e5802d1048cda890 Mon Sep 17 00:00:00 2001 From: Vickie Boettcher Date: Thu, 15 Oct 2026 13:47:12 +0000 Subject: [PATCH 08/15] fix(ffe): flush worker evaluations through shared sidecar --- tests/ext/ffe/flag_evaluation_threads.phpt | 32 +++++++++++++++++++ tests/ext/ffe/stubs/flag_evaluation_flush.inc | 2 +- .../ffe/stubs/flag_evaluation_threads_app.php | 31 ++++++++++++++++++ tracer/ddtrace.c | 12 ++++--- tracer/ddtrace_globals.h | 1 - tracer/ffe.c | 13 -------- tracer/ffe.h | 1 - tracer/functions.c | 16 ++++++---- 8 files changed, 81 insertions(+), 27 deletions(-) create mode 100644 tests/ext/ffe/flag_evaluation_threads.phpt create mode 100644 tests/ext/ffe/stubs/flag_evaluation_threads_app.php diff --git a/tests/ext/ffe/flag_evaluation_threads.phpt b/tests/ext/ffe/flag_evaluation_threads.phpt new file mode 100644 index 00000000000..d33bf70f8fb --- /dev/null +++ b/tests/ext/ffe/flag_evaluation_threads.phpt @@ -0,0 +1,32 @@ +--TEST-- +FFE flush from the main thread includes worker-only evaluations +--SKIPIF-- + +--ENV-- +DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 +DD_REMOTE_CONFIG_ENABLED=0 +DD_METRICS_OTEL_ENABLED=0 +DD_TRACE_GENERATE_ROOT_SPAN=0 +DD_TRACE_CLI_ENABLED=1 +DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED=true +DD_TRACE_AGENT_TIMEOUT=5000 +DD_SERVICE=php-ffe-threads +--FILE-- + +--EXPECT-- +false/worker: bool(true) +false/explicit: bool(true) +false/internal: bool(true) +false/shutdown: bool(true) +true/worker: bool(true) +true/explicit: bool(true) +true/internal: bool(true) +true/shutdown: bool(true) diff --git a/tests/ext/ffe/stubs/flag_evaluation_flush.inc b/tests/ext/ffe/stubs/flag_evaluation_flush.inc index 3d8331b6c5b..87c67576bbe 100644 --- a/tests/ext/ffe/stubs/flag_evaluation_flush.inc +++ b/tests/ext/ffe/stubs/flag_evaluation_flush.inc @@ -8,7 +8,7 @@ foreach (array('false', 'true') as $sidecarTraceSender) { $listener = stream_socket_server('tcp://127.0.0.1:0', $errno, $error); if (!$listener) throw new RuntimeException($error); putenv('DD_TRACE_AGENT_URL=http://' . stream_socket_get_name($listener, false)); - $process = ffeTestProcess(__DIR__ . '/flag_evaluation_flush_app.php', $pipes); + $process = ffeTestProcess($flushApp ?? __DIR__ . '/flag_evaluation_flush_app.php', $pipes); $stdout = $stderr = ''; $exitCode = null; $exitedAt = null; diff --git a/tests/ext/ffe/stubs/flag_evaluation_threads_app.php b/tests/ext/ffe/stubs/flag_evaluation_threads_app.php new file mode 100644 index 00000000000..59fcc28a7cc --- /dev/null +++ b/tests/ext/ffe/stubs/flag_evaluation_threads_app.php @@ -0,0 +1,31 @@ +run(function ($channel) { + if (!\DDTrace\Internal\record_ffe_flag_evaluation( + 'flush-flag', 'blue', null, 'worker-subject', array(), null, false, false, 1700000000123 + )) throw new RuntimeException('worker submission rejected'); + if ($channel->recv() === 'flush') dd_trace_synchronous_flush(); + return true; +}, array($channel)); + +// The capture server releases this only after it is holding an HTTP send. +if (trim(fgets(STDIN)) !== 'flush') throw new RuntimeException('missing flush barrier'); +$mode = getenv('PHP_FFE_FLUSH_MODE'); +$channel->send($mode === 'worker' ? 'flush' : 'finish'); +if (!$future->value()) throw new RuntimeException('worker did not finish'); +$runtime->close(); +switch ($mode) { + case 'explicit': + dd_trace_synchronous_flush(); + break; + case 'internal': + dd_trace_internal_fn('synchronous_flush'); + break; + case 'shutdown': + exit(0); +} +echo "returned\n"; diff --git a/tracer/ddtrace.c b/tracer/ddtrace.c index 566b891386c..db5a017a9f6 100644 --- a/tracer/ddtrace.c +++ b/tracer/ddtrace.c @@ -391,8 +391,13 @@ void ddtrace_mshutdown() { ddtrace_integrations_mshutdown(); + ddog_SidecarFlushOptions flush_options = { + .traces_and_stats = true, .telemetry = true, .flag_evaluations = true + }; #ifndef _WIN32 if (!get_global_DD_TRACE_SIDECAR_TRACE_SENDER()) { + flush_options.traces_and_stats = false; + flush_options.telemetry = false; ddtrace_coms_mshutdown(); if (ddtrace_coms_flush_shutdown_writer_synchronous()) { ddtrace_coms_curl_shutdown(); @@ -400,13 +405,10 @@ void ddtrace_mshutdown() { * it is safe to free the cached proxy env strings for ASan. */ ddtrace_coms_mshutdown_proxy_env(); } - } else /* ! part of the if outside the ifdef */ + } #endif if (get_global_DD_TRACE_FORCE_FLUSH_ON_SHUTDOWN() && DATADOG_G(sidecar)) { - ddog_sidecar_flush(&DATADOG_G(sidecar), (ddog_SidecarFlushOptions){.traces_and_stats = true, .telemetry = true}); - } - if (get_global_DD_TRACE_FORCE_FLUSH_ON_SHUTDOWN()) { - ddtrace_ffe_flush_flag_evaluations(); + ddog_sidecar_flush(&DATADOG_G(sidecar), flush_options); } ddtrace_telemetry_flush_bgs_metrics_final(); diff --git a/tracer/ddtrace_globals.h b/tracer/ddtrace_globals.h index 6b9e5280216..43d3e098677 100644 --- a/tracer/ddtrace_globals.h +++ b/tracer/ddtrace_globals.h @@ -121,7 +121,6 @@ typedef struct { void *ffe_metric_buffer; size_t ffe_metric_buffer_len; size_t ffe_metric_buffer_cap; - bool ffe_evaluations_pending_flush; struct { uint64_t unavailable; uint64_t queue_overflow; diff --git a/tracer/ffe.c b/tracer/ffe.c index 0064f872990..10d984c9165 100644 --- a/tracer/ffe.c +++ b/tracer/ffe.c @@ -108,25 +108,12 @@ bool ddtrace_ffe_record_flag_evaluation(zend_string *flag_key, zend_string *vari } } if (status == DDOG_FFE_SUBMISSION_STATUS_ACCEPTED) { - DDTRACE_G(ffe_evaluations_pending_flush) = true; return true; } ddtrace_telemetry_count_ffe_rejection(status); return false; } -void ddtrace_ffe_flush_flag_evaluations(void) { - if (!DDTRACE_G(ffe_evaluations_pending_flush) || !DATADOG_G(sidecar)) { - return; - } - // No extra flush IPC for applications that have not submitted evaluations. - // The sidecar may already have sent them on its timer; this also waits for - // any in-flight send. Delivery remains best-effort under its normal timeout. - DDTRACE_G(ffe_evaluations_pending_flush) = false; - datadog_ffi_try("Failed flushing flag evaluations", - ddog_sidecar_flush(&DATADOG_G(sidecar), (ddog_SidecarFlushOptions){.flag_evaluations = true})); -} - typedef struct { zend_string *flag_key; zend_string *variant; diff --git a/tracer/ffe.h b/tracer/ffe.h index 2b52e938096..ab5c8ed3c6f 100644 --- a/tracer/ffe.h +++ b/tracer/ffe.h @@ -13,7 +13,6 @@ bool ddtrace_ffe_record_flag_evaluation(zend_string *flag_key, zend_string *vari zend_string *allocation_key, zend_string *targeting_key, HashTable *attributes, zend_string *error_type, bool runtime_default_used, bool observe_full_evaluation_data, int64_t evaluation_timestamp_ms, bool targeting_key_invalid); -void ddtrace_ffe_flush_flag_evaluations(void); void ddtrace_ffe_record_exposure(zend_string *flag_key, zend_string *targeting_key, zend_string *subject_attributes_json, zend_string *allocation_key, zend_string *variant, int32_t serial_id, bool has_serial_id); bool ddtrace_ffe_flush_exposures(void); diff --git a/tracer/functions.c b/tracer/functions.c index 2234e9a2dac..336d7c40dd1 100644 --- a/tracer/functions.c +++ b/tracer/functions.c @@ -2128,20 +2128,21 @@ PHP_FUNCTION(dd_trace_internal_fn) { } } else if (FUNCTION_NAME_MATCHES("synchronous_flush")) { uint32_t timeout = 1000; + ddog_SidecarFlushOptions flush_options = {.traces_and_stats = true, .flag_evaluations = true}; if (params_count == 1) { timeout = Z_LVAL_P(ZVAL_VARARG_PARAM(params, 0)); } #ifndef _WIN32 if (!get_global_DD_TRACE_SIDECAR_TRACE_SENDER()) { + flush_options.traces_and_stats = false; if (dd_rinit_once_done) { ddtrace_coms_synchronous_flush(timeout); } - } else + } #endif if (DATADOG_G(sidecar)) { - datadog_ffi_try("Failed synchronously flushing traces", ddog_sidecar_flush(&DATADOG_G(sidecar), (ddog_SidecarFlushOptions){.traces_and_stats = true})); + datadog_ffi_try("Failed synchronously flushing traces and flag evaluations", ddog_sidecar_flush(&DATADOG_G(sidecar), flush_options)); } - ddtrace_ffe_flush_flag_evaluations(); RETVAL_TRUE; #ifndef _WIN32 } else if (FUNCTION_NAME_MATCHES("init_and_start_writer")) { @@ -2344,17 +2345,20 @@ PHP_FUNCTION(dd_trace_synchronous_flush) { RETURN_NULL(); } + // FFE's coalescer is shared: evaluations may have come from another PHP + // thread even when this thread has never submitted one. + ddog_SidecarFlushOptions flush_options = {.traces_and_stats = true, .flag_evaluations = true}; #ifndef _WIN32 if (!get_global_DD_TRACE_SIDECAR_TRACE_SENDER()) { + flush_options.traces_and_stats = false; if (dd_rinit_once_done) { ddtrace_coms_synchronous_flush(timeout); } - } else + } #endif if (DATADOG_G(sidecar)) { - datadog_ffi_try("Failed synchronously flushing traces", ddog_sidecar_flush(&DATADOG_G(sidecar), (ddog_SidecarFlushOptions){.traces_and_stats = true})); + datadog_ffi_try("Failed synchronously flushing traces and flag evaluations", ddog_sidecar_flush(&DATADOG_G(sidecar), flush_options)); } - ddtrace_ffe_flush_flag_evaluations(); RETURN_NULL(); } From 3a809896c20f08df5017e0d01364e1eac0cbdc59 Mon Sep 17 00:00:00 2001 From: Vickie Boettcher Date: Thu, 15 Oct 2026 17:00:58 +0000 Subject: [PATCH 09/15] fix(ffe): preserve test-session routing and harden lifecycle fixtures --- ext/sidecar.c | 31 +++-- .../ffe/flag_evaluation_rejection_fork.phpt | 3 + .../ext/ffe/flag_evaluation_test_session.phpt | 118 ++++++++++++++++++ tests/ext/ffe/stubs/flag_evaluation_wire.inc | 3 +- 4 files changed, 145 insertions(+), 10 deletions(-) create mode 100644 tests/ext/ffe/flag_evaluation_test_session.phpt diff --git a/ext/sidecar.c b/ext/sidecar.c index 4bcef2f9101..0609203c927 100644 --- a/ext/sidecar.c +++ b/ext/sidecar.c @@ -186,6 +186,20 @@ static void dd_sidecar_on_connect(ddog_SidecarTransport **transport) { } } +#ifdef DDTRACE +static void dd_sidecar_configure_ffe_transport(ddog_SidecarTransport **transport) { + // Both FFE writers share this target. Replace it when the test token changes + // so new observations use the new session and queued ones keep the old one. + const ddog_EvpProducerIdentity ffe_producer = { + .origin = DDOG_CHARSLICE_C("dd-trace-php"), + .version = DDOG_CHARSLICE_C(PHP_DDTRACE_VERSION), + }; + datadog_ffi_try("Failed configuring PHP FFE EVP transport", + ddog_sidecar_session_set_evp_transport(transport, DDOG_EVP_TRANSPORT_MODE_AGENT_ONLY, + datadog_endpoint, NULL, DDOG_CHARSLICE_C("event-platform-intake"), &ffe_producer)); +} +#endif + // Sends the identity and configuration. Needed once per transport: the transport replays them after reconnects. static void dd_sidecar_configure(ddog_SidecarTransport **transport) { #ifdef _WIN32 @@ -252,16 +266,10 @@ static void dd_sidecar_configure(ddog_SidecarTransport **transport) { } #ifdef DDTRACE - // Both FFE writers belong to the PHP tracer. Configure their shared target - // during ordinary connection setup, including reconnect and fork recovery. + // Configure during ordinary connection setup, including fork recovery. + // The transport replays its configuration after reconnects. // Remote Config keeps the fixed Agent EVP v2 route. - const ddog_EvpProducerIdentity ffe_producer = { - .origin = DDOG_CHARSLICE_C("dd-trace-php"), - .version = DDOG_CHARSLICE_C(PHP_DDTRACE_VERSION), - }; - datadog_ffi_try("Failed configuring PHP FFE EVP transport", - ddog_sidecar_session_set_evp_transport(transport, DDOG_EVP_TRANSPORT_MODE_AGENT_ONLY, - datadog_endpoint, NULL, DDOG_CHARSLICE_C("event-platform-intake"), &ffe_producer)); + dd_sidecar_configure_ffe_transport(transport); #endif if (get_global_DD_INSTRUMENTATION_TELEMETRY_ENABLED()) { @@ -1038,6 +1046,11 @@ bool datadog_alter_test_session_token(zval *old_value, zval *new_value, zend_str if (DATADOG_G(sidecar)) { datadog_ffi_try("Failed updating test session token", ddog_sidecar_set_test_session_token(&DATADOG_G(sidecar), dd_zend_string_to_CharSlice(Z_STR_P(new_value)))); +#ifdef DDTRACE + if (token_changed) { + dd_sidecar_configure_ffe_transport(&DATADOG_G(sidecar)); + } +#endif } #if !defined(_WIN32) && defined(DDTRACE) ddtrace_coms_set_test_session_token(Z_STRVAL_P(new_value), Z_STRLEN_P(new_value)); diff --git a/tests/ext/ffe/flag_evaluation_rejection_fork.phpt b/tests/ext/ffe/flag_evaluation_rejection_fork.phpt index 70bd2ae260a..13d3fbdb3b2 100644 --- a/tests/ext/ffe/flag_evaluation_rejection_fork.phpt +++ b/tests/ext/ffe/flag_evaluation_rejection_fork.phpt @@ -48,6 +48,9 @@ pcntl_waitpid($pid, $status); if (!pcntl_wifexited($status) || pcntl_wexitstatus($status) !== 0) { throw new RuntimeException('child failed'); } +// The pcntl integration starts a new root span for the fork. Restore the +// parent's named telemetry application before draining its rejection counters. +setApplication($parentService); dd_trace_internal_fn('finalize_telemetry'); // Separate applications expose inherited counters instead of letting a stopped diff --git a/tests/ext/ffe/flag_evaluation_test_session.phpt b/tests/ext/ffe/flag_evaluation_test_session.phpt new file mode 100644 index 00000000000..efd5073e27b --- /dev/null +++ b/tests/ext/ffe/flag_evaluation_test_session.phpt @@ -0,0 +1,118 @@ +--TEST-- +FFE writers follow a changed test-session token without rerouting earlier evaluations +--SKIPIF-- + +--ENV-- +DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 +DD_REMOTE_CONFIG_ENABLED=0 +DD_METRICS_OTEL_ENABLED=0 +DD_TRACE_GENERATE_ROOT_SPAN=0 +DD_TRACE_CLI_ENABLED=1 +DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED=true +DD_SERVICE=php-ffe-test-session +--FILE-- + '2026-05-22T00:00:00Z', + 'environment' => array('name' => 'test'), + 'observeFullEvaluationData' => false, + 'flags' => array('flag' => array( + 'key' => 'flag', 'enabled' => true, 'variationType' => 'STRING', + 'variations' => array('blue' => array('key' => 'blue', 'value' => 'blue')), + 'allocations' => array(array( + 'key' => 'allocation', 'rules' => array(), 'doLog' => true, + 'splits' => array(array('variationKey' => 'blue', 'serialId' => 0, 'shards' => array())), + )), + )), + ); + if (!\DDTrace\Testing\ffe_load_config(json_encode($config))) throw new RuntimeException('configuration rejected'); + $client = new \DDTrace\FeatureFlags\Client(); + foreach (array('session-a', 'session-b') as $token) { + ini_set('datadog.trace.agent_test_session_token', $token); + if ($client->getStringValue('flag', 'fallback', array('targetingKey' => 'subject')) !== 'blue') { + throw new RuntimeException('assignment changed'); + } + // Hand off exposures before switching sessions. Flagevaluations may + // still be queued and must retain their original destination. + if (!\DDTrace\Testing\flush_ffe_exposures()) throw new RuntimeException('exposure flush rejected'); + } + dd_trace_synchronous_flush(5000); + exit(0); +} + +$listener = stream_socket_server('tcp://127.0.0.1:0', $errno, $error); +if (!$listener) throw new RuntimeException($error); +putenv('DD_TRACE_AGENT_URL=http://' . stream_socket_get_name($listener, false)); +putenv('DD_TRACE_AGENT_TEST_SESSION_TOKEN=session-a'); +putenv('PHP_FFE_TEST_SESSION_CHILD=1'); +$process = ffeTestProcess(__FILE__, $pipes); +fclose($pipes[0]); +$stdout = $stderr = ''; +$exitCode = $exitedAt = null; +$counts = array(); +try { + $deadline = microtime(true) + 15; + do { + $stdout .= ffeTestProcessRead($pipes[1]); + $stderr .= ffeTestProcessRead($pipes[2]); + if ($exitedAt === null) { + $status = proc_get_status($process); + if (!$status['running']) { + $exitCode = $status['exitcode']; + $exitedAt = microtime(true); + } + } + if ($exitedAt !== null && microtime(true) - $exitedAt > 0.5) break; + $connection = @stream_socket_accept($listener, 0.1); + if (!$connection) continue; + stream_set_timeout($connection, 2); + $request = fgets($connection); + $headers = array(); + while (($line = fgets($connection)) !== false && trim($line) !== '') { + $parts = explode(':', $line, 2); + if (count($parts) === 2) $headers[strtolower(trim($parts[0]))] = trim($parts[1]); + } + $length = (int) ($headers['content-length'] ?? 0); + $body = ''; + while (strlen($body) < $length && !feof($connection)) { + $chunk = fread($connection, $length - strlen($body)); + if ($chunk === false || $chunk === '') break; + $body .= $chunk; + } + if (strpos($request, '/evp_proxy/') !== false) { + $payload = json_decode($body, true); + $token = $headers['x-datadog-test-session-token'] ?? 'missing'; + if (!isset($counts[$token])) $counts[$token] = array('evaluations' => 0, 'exposures' => 0); + if (strpos($request, '/flagevaluation ') !== false) { + foreach ($payload['flagEvaluations'] as $row) { + $counts[$token]['evaluations'] += $row['evaluation_count']; + } + } else { + $counts[$token]['exposures'] += count($payload['exposures']); + } + } + fwrite($connection, "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}"); + fclose($connection); + } while (microtime(true) < $deadline); + $stdout .= ffeTestProcessRead($pipes[1]); + $stderr .= ffeTestProcessRead($pipes[2]); + if ($exitCode !== 0) throw new RuntimeException('child failed or timed out: ' . $stdout . $stderr); + ksort($counts); + echo json_encode($counts), "\n"; +} finally { + if ($exitCode === null) proc_terminate($process); + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($process); + fclose($listener); +} +?> +--EXPECT-- +{"session-a":{"evaluations":1,"exposures":1},"session-b":{"evaluations":1,"exposures":1}} diff --git a/tests/ext/ffe/stubs/flag_evaluation_wire.inc b/tests/ext/ffe/stubs/flag_evaluation_wire.inc index fdf06ee09f7..ada1b1d8dbe 100644 --- a/tests/ext/ffe/stubs/flag_evaluation_wire.inc +++ b/tests/ext/ffe/stubs/flag_evaluation_wire.inc @@ -142,7 +142,8 @@ try { $expected = array(str_repeat('é', 256) => str_repeat('😀', 256), 'bool' => false, 'integer' => -42, 'float' => 1.5, 0 => 'zero-key', -1 => 'negative-key', 'empty' => ''); - ksort($unicode); ksort($expected); + ksort($unicode, SORT_STRING); + ksort($expected, SORT_STRING); echo 'unicode_and_scalar_boundaries='; var_dump($unicode === $expected); echo 'owned_snapshot='; var_dump($rows['snapshot']['context']['evaluation'] === array('value' => 'before-submission')); From 1839b2fc06329df2bc61a5fdef62ff429a01d314 Mon Sep 17 00:00:00 2001 From: Vickie Boettcher Date: Fri, 16 Oct 2026 19:31:29 +0000 Subject: [PATCH 10/15] test(ffe): harden CI capture and instrumented lifecycle checks --- tests/ext/ffe/diagnostic_stream_scan.phpt | 26 ++++++++++++++++++ .../ffe/flag_evaluation_rejection_fork.phpt | 21 ++++++++++----- .../ext/ffe/flag_evaluation_test_session.phpt | 3 ++- tests/ext/ffe/stubs/ffe_process.inc | 16 +++++++++++ tests/ext/ffe/stubs/flag_evaluation_flush.inc | 5 +++- tests/ext/ffe/stubs/flag_evaluation_wire.inc | 27 +++++++++++++------ 6 files changed, 82 insertions(+), 16 deletions(-) create mode 100644 tests/ext/ffe/diagnostic_stream_scan.phpt diff --git a/tests/ext/ffe/diagnostic_stream_scan.phpt b/tests/ext/ffe/diagnostic_stream_scan.phpt new file mode 100644 index 00000000000..d95f99e291e --- /dev/null +++ b/tests/ext/ffe/diagnostic_stream_scan.phpt @@ -0,0 +1,26 @@ +--TEST-- +FFE diagnostic checks scan large logs without missing markers between chunks +--INI-- +memory_limit=16M +--FILE-- + +--EXPECT-- +bool(true) +bool(true) +bool(false) diff --git a/tests/ext/ffe/flag_evaluation_rejection_fork.phpt b/tests/ext/ffe/flag_evaluation_rejection_fork.phpt index 13d3fbdb3b2..03af38aa541 100644 --- a/tests/ext/ffe/flag_evaluation_rejection_fork.phpt +++ b/tests/ext/ffe/flag_evaluation_rejection_fork.phpt @@ -4,7 +4,11 @@ FFE fork children report only their own rejected observations --ENV-- DD_TRACE_GENERATE_ROOT_SPAN=0 @@ -15,7 +19,7 @@ DD_METRICS_OTEL_ENABLED=0 DD_TRACE_SIDECAR_CONNECTION_MODE=subprocess DD_TRACE_LOG_LEVEL=off --INI-- -datadog.trace.agent_url="file://{PWD}/ffe-rejection-fork.out" +datadog.trace.agent_url="file://{PWD}/tmp/ffe-rejection-fork/" --FILE-- 'parent', 'ffe-rejection-child-' . $pid => 'child'); -$path = __DIR__ . '/ffe-rejection-fork.out'; +$path = __DIR__ . '/tmp/ffe-rejection-fork/telemetry-*.json'; $deadline = microtime(true) + 30; do { usleep(10000); $counts = array('parent' => 0, 'child' => 0); $closed = array(); - foreach (is_file($path) ? file($path) : array() as $line) { - $envelope = json_decode($line, true); + // Directory capture publishes complete requests atomically and avoids + // inheriting the single-file recorder's locked mutex across a fork. + foreach (glob($path) as $file) { + $envelope = json_decode(file_get_contents($file), true); $service = $envelope['application']['service_name'] ?? ''; if (!isset($services[$service])) continue; $batch = $envelope['request_type'] === 'message-batch' ? $envelope['payload'] : array($envelope); @@ -88,4 +94,7 @@ completed=bool(true) drop_counts={"parent":2,"child":1} --CLEAN-- 0 ? substr($chunk, -$overlap) : ''; + } + return false; +} diff --git a/tests/ext/ffe/stubs/flag_evaluation_flush.inc b/tests/ext/ffe/stubs/flag_evaluation_flush.inc index 87c67576bbe..eb874d23b58 100644 --- a/tests/ext/ffe/stubs/flag_evaluation_flush.inc +++ b/tests/ext/ffe/stubs/flag_evaluation_flush.inc @@ -16,7 +16,10 @@ foreach (array('false', 'true') as $sidecarTraceSender) { $blocked = $released = false; $counts = array(); try { - $deadline = microtime(true) + 15; + // Valgrind also instruments the child, including startup and + // shutdown. Keep the HTTP barrier below unchanged; only allow + // more time for the instrumented process to finish. + $deadline = microtime(true) + (getenv('USE_ZEND_ALLOC') === '0' ? 60 : 15); do { $stdout .= ffeTestProcessRead($pipes[1]); $stderr .= ffeTestProcessRead($pipes[2]); diff --git a/tests/ext/ffe/stubs/flag_evaluation_wire.inc b/tests/ext/ffe/stubs/flag_evaluation_wire.inc index ada1b1d8dbe..63f79594fad 100644 --- a/tests/ext/ffe/stubs/flag_evaluation_wire.inc +++ b/tests/ext/ffe/stubs/flag_evaluation_wire.inc @@ -158,14 +158,25 @@ try { var_dump($rows['consented-canary']['targeting_key'] === 'ffe-consented-pii-canary@example.test' && $rows['consented-canary']['context']['evaluation']['email'] === 'ffe-consented-context-canary@example.test' && $rows['consented-canary']['error']['message'] === 'GENERAL'); - $diagnostics = file_get_contents($diagnosticLog) . $stderr; - echo 'diagnostics_exercised='; var_dump(strpos($diagnostics, 'FfeFlagEvaluationEvent') !== false); - echo 'diagnostics_pii_absent='; - var_dump(strpos($diagnostics, 'ffe-protected-pii-canary@example.test') === false - && strpos($diagnostics, 'ffe-context-pii-canary@example.test') === false - && strpos($diagnostics, 'ffe-consented-pii-canary@example.test') === false - && strpos($diagnostics, 'ffe-consented-context-canary@example.test') === false - && strpos($diagnostics, 'ffe-error-pii-canary@example.test') === false); + // TRACE logs can be large in CI. Inspect every byte without loading the + // entire log (and a second concatenated copy) into the PHP process. + $diagnostics = fopen($diagnosticLog, 'r'); + if ($diagnostics === false) throw new RuntimeException('cannot open diagnostic log'); + try { + echo 'diagnostics_exercised='; + var_dump(ffeTestStreamContains($diagnostics, 'FfeFlagEvaluationEvent') + || strpos($stderr, 'FfeFlagEvaluationEvent') !== false); + $piiAbsent = true; + foreach (array('ffe-protected-pii-canary@example.test', 'ffe-context-pii-canary@example.test', + 'ffe-consented-pii-canary@example.test', 'ffe-consented-context-canary@example.test', + 'ffe-error-pii-canary@example.test') as $canary) { + $piiAbsent = $piiAbsent && !ffeTestStreamContains($diagnostics, $canary) + && strpos($stderr, $canary) === false; + } + echo 'diagnostics_pii_absent='; var_dump($piiAbsent); + } finally { + fclose($diagnostics); + } } finally { if ($exitedAt === null) proc_terminate($process); fclose($pipes[1]); From ce0ad1f61c5237acf8bda9719fb9852c9c5cbb6e Mon Sep 17 00:00:00 2001 From: Vickie Boettcher Date: Sat, 17 Oct 2026 01:24:29 +0000 Subject: [PATCH 11/15] fix: release sidecar shutdown flush errors Drop the owned error returned by the shutdown flush while preserving best-effort error handling. Add a broken-connection regression verified with Valgrind leak checking. --- tests/ext/sidecar_shutdown_flush_error.phpt | 27 +++++++++++++++++++++ tracer/ddtrace.c | 2 +- 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 tests/ext/sidecar_shutdown_flush_error.phpt diff --git a/tests/ext/sidecar_shutdown_flush_error.phpt b/tests/ext/sidecar_shutdown_flush_error.phpt new file mode 100644 index 00000000000..44476302e23 --- /dev/null +++ b/tests/ext/sidecar_shutdown_flush_error.phpt @@ -0,0 +1,27 @@ +--TEST-- +Shutdown flush releases the error returned by a broken sidecar connection +--ENV-- +DD_TRACE_CLI_ENABLED=1 +DD_TRACE_SIDECAR_TRACE_SENDER=1 +DD_TRACE_FORCE_FLUSH_ON_SHUTDOWN=1 +DD_TRACE_GENERATE_ROOT_SPAN=0 +DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 +DD_REMOTE_CONFIG_ENABLED=0 +DD_METRICS_OTEL_ENABLED=0 +DD_TRACE_LOG_LEVEL=off +--FILE-- + +--EXPECT-- +bool(true) +bool(true) diff --git a/tracer/ddtrace.c b/tracer/ddtrace.c index 201b84ab7bc..3e59a22c0c9 100644 --- a/tracer/ddtrace.c +++ b/tracer/ddtrace.c @@ -408,7 +408,7 @@ void ddtrace_mshutdown() { } #endif if (get_global_DD_TRACE_FORCE_FLUSH_ON_SHUTDOWN() && DATADOG_G(sidecar)) { - ddog_sidecar_flush(&DATADOG_G(sidecar), flush_options); + ddog_MaybeError_drop(ddog_sidecar_flush(&DATADOG_G(sidecar), flush_options)); } ddtrace_telemetry_flush_bgs_metrics_final(); From 1b71b3d347e7754d1d0edca32a819384350c7f42 Mon Sep 17 00:00:00 2001 From: Vickie Boettcher Date: Mon, 19 Oct 2026 21:01:34 +0000 Subject: [PATCH 12/15] fix(ffe): preserve terminal outcomes and lifecycle metadata Include flag evaluations in signal flushes and use resolved request identity. Capture OpenFeature evaluation metadata per invocation and submit the final hook outcome without changing immediate native reporting. Cover signal delivery, default and updated identity, terminal hook failures, nested calls, fibers, and raw payload privacy. --- ext/sidecar.c | 2 +- src/DDTrace/OpenFeature/DataDogProvider.php | 50 +++++++++- .../OpenFeature/FlagEvaluationHook.php | 99 +++++++++++++++++++ .../FeatureFlags/Internal/NativeEvaluator.php | 38 +++++-- src/bridge/_files_openfeature.php | 1 + tests/ext/ffe/flag_evaluation_identity.phpt | 71 +++++++++++++ .../flag_evaluation_openfeature_nested.phpt | 75 ++++++++++++++ .../flag_evaluation_openfeature_outcome.phpt | 67 +++++++++++++ .../ffe/flag_evaluation_openfeature_wire.phpt | 1 + .../ext/ffe/flag_evaluation_signal_flush.phpt | 30 ++++++ tests/ext/ffe/stubs/flag_evaluation_flush.inc | 3 +- .../ffe/stubs/flag_evaluation_flush_app.php | 6 ++ .../stubs/flag_evaluation_identity_app.php | 11 +++ .../ffe/stubs/flag_evaluation_openfeature.inc | 52 ++++++++++ tests/ext/ffe/stubs/flag_evaluation_wire.inc | 12 +++ .../ffe/stubs/flag_evaluation_wire_app.php | 20 +++- tracer/ffe.c | 4 +- 17 files changed, 529 insertions(+), 13 deletions(-) create mode 100644 src/DDTrace/OpenFeature/FlagEvaluationHook.php create mode 100644 tests/ext/ffe/flag_evaluation_identity.phpt create mode 100644 tests/ext/ffe/flag_evaluation_openfeature_nested.phpt create mode 100644 tests/ext/ffe/flag_evaluation_openfeature_outcome.phpt create mode 100644 tests/ext/ffe/flag_evaluation_signal_flush.phpt create mode 100644 tests/ext/ffe/stubs/flag_evaluation_identity_app.php create mode 100644 tests/ext/ffe/stubs/flag_evaluation_openfeature.inc diff --git a/ext/sidecar.c b/ext/sidecar.c index 0609203c927..20d4a13de32 100644 --- a/ext/sidecar.c +++ b/ext/sidecar.c @@ -126,7 +126,7 @@ static void dd_sidecar_arm_signal_transport(ddog_SidecarTransport *owner, ddog_S ddog_SignalFlush *flush = NULL; if (!datadog_ffi_try("Failed preparing sidecar signal flush", ddog_sidecar_prepare_signal_flush( - connection, (ddog_SidecarFlushOptions){.traces_and_stats = true}, &flush))) { + connection, (ddog_SidecarFlushOptions){.traces_and_stats = true, .flag_evaluations = true}, &flush))) { // Clears the stale flush while still the owner, then lets a later RINIT retry. datadog_signals_set_sidecar_flush(NULL, true); atomic_store(&dd_sidecar_signal_owner, 0); diff --git a/src/DDTrace/OpenFeature/DataDogProvider.php b/src/DDTrace/OpenFeature/DataDogProvider.php index 122eb2ed2c3..931ba465e6a 100644 --- a/src/DDTrace/OpenFeature/DataDogProvider.php +++ b/src/DDTrace/OpenFeature/DataDogProvider.php @@ -34,16 +34,44 @@ final class DataDogProvider extends AbstractProvider private LoggerInterface $datadogLogger; private bool $warnedAboutRuntimeNotReady = false; private EvaluationMetricRecorder $metricRecorder; + private object $mainObservationScope; + private \WeakMap $fiberObservationScopes; public function __construct(?LoggerInterface $logger = null) { // Native evaluation metrics are disabled here because OpenFeature owns // the final provider outcome, including OF-level type mismatch mapping. - $this->evaluator = NativeEvaluator::create(false); + $this->mainObservationScope = (object) ['pending' => null, 'recorder' => null]; + $this->fiberObservationScopes = new \WeakMap(); + $this->evaluator = NativeEvaluator::create(false, function (array $observation): void { + $recorder = $this->observationScope()->recorder; + if ($recorder !== null) { + $recorder->capture($observation); + } else { + NativeEvaluator::submitFlagEvaluation($observation); + } + }); $this->datadogLogger = new NonThrowingLogger($logger ?: GlobalLogger::get()); $this->metricRecorder = EvaluationMetricRecorder::createDefault(); } + public function getHooks(): array + { + // The SDK asks once per invocation. A fresh hook keeps finalization + // state separate even when a before hook fails before ours runs. + return array_merge(parent::getHooks(), [new FlagEvaluationHook($this->observationScope())]); + } + + private function observationScope(): object + { + $fiber = class_exists(\Fiber::class, false) ? \Fiber::getCurrent() : null; + if ($fiber === null) { + return $this->mainObservationScope; + } + return $this->fiberObservationScopes[$fiber] + ?? ($this->fiberObservationScopes[$fiber] = (object) ['pending' => null, 'recorder' => null]); + } + /** * @internal Tests and Datadog-owned bridge adapters only. */ @@ -111,6 +139,26 @@ private function resolve( string $expectedType, mixed $defaultValue, ?EvaluationContext $context + ): ResolutionDetailsInterface { + $scope = $this->observationScope(); + $observation = $scope->pending; + if ($observation !== null) { + $observation->claim(); + } + $previous = $scope->recorder; + $scope->recorder = $observation; + try { + return $this->resolveWithObservation($flagKey, $expectedType, $defaultValue, $context); + } finally { + $scope->recorder = $previous; + } + } + + private function resolveWithObservation( + string $flagKey, + string $expectedType, + mixed $defaultValue, + ?EvaluationContext $context ): ResolutionDetailsInterface { $normalizedContext = $this->normalizeContext($context); $details = $this->evaluate($flagKey, $expectedType, $defaultValue, $normalizedContext); diff --git a/src/DDTrace/OpenFeature/FlagEvaluationHook.php b/src/DDTrace/OpenFeature/FlagEvaluationHook.php new file mode 100644 index 00000000000..ecb5aa7efb2 --- /dev/null +++ b/src/DDTrace/OpenFeature/FlagEvaluationHook.php @@ -0,0 +1,99 @@ +scope = $scope; + $this->startedAtMs = (int) (microtime(true) * 1000); + } + + public function supportsFlagValueType(string $flagValueType): bool + { + return true; + } + + public function before(HookContext $context, HookHints $hints): ?EvaluationContext + { + $this->previous = $this->scope->pending; + $this->scope->pending = $this; + return null; + } + + // The provider claims this invocation before resolving. Direct/nested + // provider calls must not overwrite its observation. + public function claim(): void + { + if ($this->scope->pending === $this) { + $this->scope->pending = $this->previous; + } + $this->previous = null; + } + + public function capture(array $observation): void + { + $this->observation = $observation; + } + + public function after(HookContext $context, ResolutionDetails $details, HookHints $hints): void + { + if ($this->observation === null) { + // No native snapshot (for example, an unavailable evaluator). + // Never infer consent from the current configuration. + $this->observation = $this->protectedObservation($context); + $this->observation[1] = $details->getVariant(); + $this->observation[5] = $details->getError() + ? $details->getError()->getResolutionErrorCode()->getValue() : null; + $this->observation[6] = $details->getVariant() === null; + } + } + + public function error(HookContext $context, \Throwable $error, HookHints $hints): void + { + $this->observation = $this->observation ?? $this->protectedObservation($context); + // Before/after failures replace the SDK result with the caller default. + // Keep evaluated consent/time, but not the successful variant/allocation. + $this->observation[1] = null; + $this->observation[2] = null; + $this->observation[5] = $error instanceof ThrowableWithResolutionError + ? $error->getResolutionError()->getResolutionErrorCode()->getValue() : 'GENERAL'; + $this->observation[6] = true; + } + + public function finally(HookContext $context, HookHints $hints): void + { + try { + if ($this->observation !== null) { + NativeEvaluator::submitFlagEvaluation($this->observation); + } + } finally { + $this->claim(); + $this->observation = null; + } + } + + private function protectedObservation(HookContext $context): array + { + return [$context->getFlagKey(), null, null, + $context->getEvaluationContext()->getTargetingKey(), [], 'GENERAL', true, + false, $this->startedAtMs, false]; + } +} diff --git a/src/api/FeatureFlags/Internal/NativeEvaluator.php b/src/api/FeatureFlags/Internal/NativeEvaluator.php index d196b1e61ff..18768b5c093 100644 --- a/src/api/FeatureFlags/Internal/NativeEvaluator.php +++ b/src/api/FeatureFlags/Internal/NativeEvaluator.php @@ -11,8 +11,9 @@ final class NativeEvaluator implements Evaluator private $mapper; private $recordMetrics; + private $flagEvaluationRecorder; - private function __construct($mapper = null, bool $recordMetrics = true) + private function __construct($mapper = null, bool $recordMetrics = true, $flagEvaluationRecorder = null) { if ($mapper !== null && !$mapper instanceof ResultMapper) { throw new \InvalidArgumentException('Expected a ResultMapper instance'); @@ -20,6 +21,7 @@ private function __construct($mapper = null, bool $recordMetrics = true) $this->mapper = $mapper ?: new ResultMapper(); $this->recordMetrics = $recordMetrics; + $this->flagEvaluationRecorder = $flagEvaluationRecorder; } public static function isAvailable() @@ -27,9 +29,10 @@ public static function isAvailable() return function_exists('DDTrace\\ffe_evaluate'); } - public static function create($recordMetrics = true) + /** @param callable|null $flagEvaluationRecorder Internal adapter for terminal OpenFeature reporting. */ + public static function create($recordMetrics = true, $flagEvaluationRecorder = null) { - return self::isAvailable() ? new self(null, $recordMetrics) : new UnavailableEvaluator(); + return self::isAvailable() ? new self(null, $recordMetrics, $flagEvaluationRecorder) : new UnavailableEvaluator(); } public function evaluate( @@ -56,8 +59,8 @@ public function evaluate( $details = $this->mapper->map($rawResult, $expectedType, $defaultValue); - // Both public APIs use this final mapped outcome. EVP reporting is - // independent of OpenFeature's metric hook and the OTLP kill switch. + // Native clients submit now. OpenFeature can retain this snapshot until + // its hooks finish, independently of native/OTLP metric recording. $this->recordFlagEvaluation($flagKey, $targetingKey, $normalizedAttributes, $rawResult, $details, $evaluationTimestampMs, $targetingKeyInvalid); // APM feature-flag span enrichment. This is the single choke point both @@ -81,7 +84,7 @@ private function recordFlagEvaluation($flagKey, $targetingKey, array $attributes $consent = is_object($rawResult) && isset($rawResult->observeFullEvaluationData) && $rawResult->observeFullEvaluationData === true; - \DDTrace\Internal\record_ffe_flag_evaluation( + $observation = array( $flagKey, $details->getVariant(), isset($exposure['allocationKey']) ? $exposure['allocationKey'] : null, @@ -93,11 +96,34 @@ private function recordFlagEvaluation($flagKey, $targetingKey, array $attributes $evaluationTimestampMs, $targetingKeyInvalid ); + if ($this->flagEvaluationRecorder !== null) { + // Keep only the native inspection budget plus one overflow + // marker while OpenFeature finishes its hooks. Scalars are + // already copied by normalizeAttributes; do not retain the + // customer's full context or any protected attributes. + $observation[4] = $consent ? array_slice($attributes, 0, 257, true) : array(); + ($this->flagEvaluationRecorder)($observation); + } else { + self::submitFlagEvaluation($observation); + } } catch (\Throwable $ignored) { // Best-effort telemetry must not change a flag's evaluation result. } } + /** @internal Shared submission for immediate native and finalized OpenFeature observations. */ + public static function submitFlagEvaluation(array $observation) + { + if (!function_exists('DDTrace\\Internal\\record_ffe_flag_evaluation')) { + return false; + } + try { + return \DDTrace\Internal\record_ffe_flag_evaluation(...$observation); + } catch (\Throwable $ignored) { + return false; + } + } + private function typeId($expectedType) { switch ($expectedType) { diff --git a/src/bridge/_files_openfeature.php b/src/bridge/_files_openfeature.php index d46c20f4164..a452c3eb382 100644 --- a/src/bridge/_files_openfeature.php +++ b/src/bridge/_files_openfeature.php @@ -1,5 +1,6 @@ +--ENV-- +DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 +DD_REMOTE_CONFIG_ENABLED=0 +DD_METRICS_OTEL_ENABLED=0 +DD_TRACE_GENERATE_ROOT_SPAN=0 +DD_TRACE_CLI_ENABLED=1 +DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED=true +DD_TRACE_AGENT_TIMEOUT=5000 +DD_SERVICE= +DD_ENV= +DD_VERSION= +--FILE-- + +--EXPECT-- +default={"env":"none","service":"flag_evaluation_identity_app.php"} +configured={"env":"changed-env","service":"changed-service"} diff --git a/tests/ext/ffe/flag_evaluation_openfeature_nested.phpt b/tests/ext/ffe/flag_evaluation_openfeature_nested.phpt new file mode 100644 index 00000000000..5dce34a8ff4 --- /dev/null +++ b/tests/ext/ffe/flag_evaluation_openfeature_nested.phpt @@ -0,0 +1,75 @@ +--TEST-- +FFE OpenFeature observations keep nested calls and interleaved fibers separate +--SKIPIF-- + +--ENV-- +DD_METRICS_OTEL_ENABLED=0 +DD_TRACE_GENERATE_ROOT_SPAN=0 +DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 +DD_REMOTE_CONFIG_ENABLED=0 +DD_TRACE_AGENT_URL=http://127.0.0.1:9 +--FILE-- +onAfter = function ($context) use ($client, $provider) { + if ($context->getFlagKey() !== 'flag') return; + $provider->resolveStringValue('inner', 'fallback'); + $client->getStringValue('inner', 'fallback'); + throw new RuntimeException('outer failed'); +}; +echo 'nested_result=', $client->getStringValue('flag', 'fallback'), "\n"; +foreach ($ffeRecords as $record) echo json_encode(array($record[0], $record[1], $record[5], $record[6])), "\n"; + +// Suspend inside provider resolution, while its recorder is active. A second +// fiber must not capture its observation in the suspended invocation's scope. +$ffeRecords = array(); +$hook->onAfter = null; +$suspend = \DDTrace\install_hook('DDTrace\\ffe_evaluate', null, function ($data) { + if ($data->args[0] === 'flag') Fiber::suspend(); +}); +$first = new Fiber(function () use ($client) { return $client->getStringValue('flag', 'fallback'); }); +$second = new Fiber(function () use ($client) { return $client->getStringValue('inner', 'fallback'); }); +$first->start(); +$second->start(); +echo 'during_resolution='; var_dump(count($ffeRecords)); +$first->resume(); +\DDTrace\remove_hook($suspend); +echo 'resolution_results=', $first->getReturn(), '/', $second->getReturn(), "\n"; +foreach ($ffeRecords as $record) echo json_encode(array($record[0], $record[1], $record[5], $record[6])), "\n"; + +$ffeRecords = array(); +$hook->onAfter = function ($context) { + if ($context->getFlagKey() === 'flag') { + Fiber::suspend(); + throw new RuntimeException('suspended evaluation failed'); + } +}; +$first = new Fiber(function () use ($client) { return $client->getStringValue('flag', 'fallback'); }); +$second = new Fiber(function () use ($client) { return $client->getStringValue('inner', 'fallback'); }); +$first->start(); +$second->start(); +echo 'while_suspended='; var_dump(count($ffeRecords)); +$first->resume(); +echo 'fiber_results=', $first->getReturn(), '/', $second->getReturn(), "\n"; +foreach ($ffeRecords as $record) echo json_encode(array($record[0], $record[1], $record[5], $record[6])), "\n"; +?> +--EXPECT-- +nested_result=fallback +["inner","blue",null,false] +["inner","blue",null,false] +["flag",null,"GENERAL",true] +during_resolution=int(1) +resolution_results=blue/blue +["inner","blue",null,false] +["flag","blue",null,false] +while_suspended=int(1) +fiber_results=fallback/blue +["inner","blue",null,false] +["flag",null,"GENERAL",true] diff --git a/tests/ext/ffe/flag_evaluation_openfeature_outcome.phpt b/tests/ext/ffe/flag_evaluation_openfeature_outcome.phpt new file mode 100644 index 00000000000..3db6d262fa1 --- /dev/null +++ b/tests/ext/ffe/flag_evaluation_openfeature_outcome.phpt @@ -0,0 +1,67 @@ +--TEST-- +FFE OpenFeature observations match terminal hook outcomes and preserve evaluation-time metadata +--SKIPIF-- + +--ENV-- +DD_METRICS_OTEL_ENABLED=0 +DD_TRACE_GENERATE_ROOT_SPAN=0 +DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 +DD_REMOTE_CONFIG_ENABLED=0 +DD_TRACE_AGENT_URL=http://127.0.0.1:9 +--FILE-- +onBefore = function () { throw new RuntimeException('before failed'); }; + } + if ($case === 'after-error') { + $hook->onAfter = function () use (&$afterStarted) { + $afterStarted = (int) (microtime(true) * 1000); + \DDTrace\Testing\ffe_load_config(ffeOutcomeConfig(false)); + usleep(20000); + throw new RuntimeException('after failed'); + }; + } + if ($case === 'error-hook-error') $hook->onError = function () { throw new RuntimeException('error hook failed'); }; + if ($case === 'finally-error') $hook->onFinally = function () { throw new RuntimeException('finally failed'); }; + $client = ffeOutcomeClient(new \DDTrace\OpenFeature\DataDogProvider(), $hook); + $details = $client->getStringDetails($case === 'provider-error' ? 'missing' : 'flag', 'fallback'); + $record = count($ffeRecords) === 1 ? $ffeRecords[0] : null; + $error = $details->getError() ? $details->getError()->getResolutionErrorCode()->getValue() : null; + echo $case, ': ', json_encode(array('value' => $details->getValue(), 'error' => $error, + 'records' => count($ffeRecords), 'variant' => $record ? $record[1] : null, + 'recordedError' => $record ? $record[5] : null, 'default' => $record ? $record[6] : null, + 'consent' => $record ? $record[7] : null)), "\n"; + if ($case === 'after-error') { + echo 'timestamp_preserved='; var_dump($record !== null && $record[8] <= $afterStarted); + echo 'allocation_cleared='; var_dump($record !== null && $record[2] === null); + } +} +\DDTrace\Testing\ffe_load_config(ffeOutcomeConfig()); +$ffeRecords = array(); +$provider = new \DDTrace\OpenFeature\DataDogProvider(); +$native = new \DDTrace\FeatureFlags\Client(); +echo 'direct_provider=', $provider->resolveStringValue('flag', 'fallback')->getValue(), "\n"; +echo 'native=', $native->getStringValue('flag', 'fallback'), "\n"; +echo 'direct_and_native_records='; var_dump(count($ffeRecords)); +?> +--EXPECT-- +success: {"value":"blue","error":null,"records":1,"variant":"blue","recordedError":null,"default":false,"consent":true} +before-error: {"value":"fallback","error":"GENERAL","records":1,"variant":null,"recordedError":"GENERAL","default":true,"consent":false} +after-error: {"value":"fallback","error":"GENERAL","records":1,"variant":null,"recordedError":"GENERAL","default":true,"consent":true} +timestamp_preserved=bool(true) +allocation_cleared=bool(true) +provider-error: {"value":"fallback","error":"FLAG_NOT_FOUND","records":1,"variant":null,"recordedError":"FLAG_NOT_FOUND","default":true,"consent":true} +error-hook-error: {"value":"fallback","error":"GENERAL","records":1,"variant":null,"recordedError":"GENERAL","default":true,"consent":false} +finally-error: {"value":"blue","error":null,"records":1,"variant":"blue","recordedError":null,"default":false,"consent":true} +direct_provider=blue +native=blue +direct_and_native_records=int(2) diff --git a/tests/ext/ffe/flag_evaluation_openfeature_wire.phpt b/tests/ext/ffe/flag_evaluation_openfeature_wire.phpt index 40df03cb66c..a2a58a3bddc 100644 --- a/tests/ext/ffe/flag_evaluation_openfeature_wire.phpt +++ b/tests/ext/ffe/flag_evaluation_openfeature_wire.phpt @@ -45,5 +45,6 @@ owned_snapshot=bool(true) invalid_context_keeps_observation=bool(true) recovered_connection_preserves_privacy=bool(true) consented_identity_and_safe_error=bool(true) +terminal_hook_errors=bool(true) diagnostics_exercised=bool(true) diagnostics_pii_absent=bool(true) diff --git a/tests/ext/ffe/flag_evaluation_signal_flush.phpt b/tests/ext/ffe/flag_evaluation_signal_flush.phpt new file mode 100644 index 00000000000..c9d14998a8d --- /dev/null +++ b/tests/ext/ffe/flag_evaluation_signal_flush.phpt @@ -0,0 +1,30 @@ +--TEST-- +FFE signal flush waits for pending HTTP sends before default-handler termination +--SKIPIF-- + +--ENV-- +DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 +DD_REMOTE_CONFIG_ENABLED=0 +DD_METRICS_OTEL_ENABLED=0 +DD_TRACE_GENERATE_ROOT_SPAN=0 +DD_TRACE_CLI_ENABLED=1 +DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED=true +DD_TRACE_AGENT_TIMEOUT=5000 +DD_SERVICE=php-ffe-signal-flush +DD_TRACE_FORCE_FLUSH_ON_SIGTERM=1 +DD_TRACE_FORCE_FLUSH_ON_SIGINT=1 +--FILE-- + +--EXPECT-- +false/sigterm: bool(true) +false/sigint: bool(true) +true/sigterm: bool(true) +true/sigint: bool(true) diff --git a/tests/ext/ffe/stubs/flag_evaluation_flush.inc b/tests/ext/ffe/stubs/flag_evaluation_flush.inc index eb874d23b58..35bc70ec219 100644 --- a/tests/ext/ffe/stubs/flag_evaluation_flush.inc +++ b/tests/ext/ffe/stubs/flag_evaluation_flush.inc @@ -86,7 +86,8 @@ foreach (array('false', 'true') as $sidecarTraceSender) { $expected = $mode === 'fork' ? array('flush-child' => 1, 'flush-flag' => 1) : array('flush-flag' => 1); ksort($counts); echo $sidecarTraceSender, '/', $mode, ': '; - var_dump($counts === $expected && $blocked && ($mode === 'shutdown' || strpos($stdout, 'returned') !== false)); + $terminates = in_array($mode, array('shutdown', 'sigterm', 'sigint'), true); + var_dump($counts === $expected && $blocked && ($terminates || strpos($stdout, 'returned') !== false)); } finally { if ($exitCode === null) proc_terminate($process); foreach ($pipes as $pipe) fclose($pipe); diff --git a/tests/ext/ffe/stubs/flag_evaluation_flush_app.php b/tests/ext/ffe/stubs/flag_evaluation_flush_app.php index 9f1cb8181bf..2fd00fb131c 100644 --- a/tests/ext/ffe/stubs/flag_evaluation_flush_app.php +++ b/tests/ext/ffe/stubs/flag_evaluation_flush_app.php @@ -29,6 +29,12 @@ break; case 'shutdown': exit(0); + case 'sigterm': + case 'sigint': + posix_kill(getmypid(), getenv('PHP_FFE_FLUSH_MODE') === 'sigterm' ? SIGTERM : SIGINT); + // The signal-flush worker owns termination. Do not fall through to + // normal PHP shutdown, which would hide a missing signal flush. + while (true) usleep(1000); } if (isset($pid)) { pcntl_waitpid($pid, $status); diff --git a/tests/ext/ffe/stubs/flag_evaluation_identity_app.php b/tests/ext/ffe/stubs/flag_evaluation_identity_app.php new file mode 100644 index 00000000000..c5a1cd759ae --- /dev/null +++ b/tests/ext/ffe/stubs/flag_evaluation_identity_app.php @@ -0,0 +1,11 @@ + '2026-05-22T00:00:00Z', + 'environment' => array('name' => 'test'), + 'observeFullEvaluationData' => $consent, 'flags' => array()); + foreach (array('flag', 'inner') as $key) { + $config['flags'][$key] = array('key' => $key, 'enabled' => true, 'variationType' => 'STRING', + 'variations' => array('blue' => array('key' => 'blue', 'value' => 'blue')), + 'allocations' => array(array('key' => 'allocation', 'rules' => array(), 'doLog' => false, + 'splits' => array(array('variationKey' => 'blue', 'serialId' => 1, 'shards' => array()))))); + } + return json_encode($config); +} + +class FfeOutcomeHook extends \OpenFeature\implementation\hooks\StringHook { + public $onBefore; + public $onAfter; + public $onError; + public $onFinally; + public function before(\OpenFeature\interfaces\hooks\HookContext $context, + \OpenFeature\interfaces\hooks\HookHints $hints): ?\OpenFeature\interfaces\flags\EvaluationContext { + return $this->onBefore ? ($this->onBefore)($context) : null; + } + public function after(\OpenFeature\interfaces\hooks\HookContext $context, + \OpenFeature\interfaces\provider\ResolutionDetails $details, + \OpenFeature\interfaces\hooks\HookHints $hints): void { + if ($this->onAfter) ($this->onAfter)($context, $details); + } + public function error(\OpenFeature\interfaces\hooks\HookContext $context, \Throwable $error, + \OpenFeature\interfaces\hooks\HookHints $hints): void { + if ($this->onError) ($this->onError)(); + } + public function finally(\OpenFeature\interfaces\hooks\HookContext $context, + \OpenFeature\interfaces\hooks\HookHints $hints): void { + if ($this->onFinally) ($this->onFinally)(); + } +} + +$ffeRecords = array(); +\DDTrace\install_hook('DDTrace\\Internal\\record_ffe_flag_evaluation', function ($hook) use (&$ffeRecords) { + $ffeRecords[] = $hook->args; +}); +function ffeOutcomeClient($provider, $hook = null) { + $api = \OpenFeature\OpenFeatureAPI::getInstance(); + $api->setProvider($provider); + $client = $api->getClient('ffe-terminal-outcome'); + if ($hook) $client->addHooks($hook); + return $client; +} diff --git a/tests/ext/ffe/stubs/flag_evaluation_wire.inc b/tests/ext/ffe/stubs/flag_evaluation_wire.inc index 63f79594fad..b6a472b82cb 100644 --- a/tests/ext/ffe/stubs/flag_evaluation_wire.inc +++ b/tests/ext/ffe/stubs/flag_evaluation_wire.inc @@ -158,6 +158,18 @@ try { var_dump($rows['consented-canary']['targeting_key'] === 'ffe-consented-pii-canary@example.test' && $rows['consented-canary']['context']['evaluation']['email'] === 'ffe-consented-context-canary@example.test' && $rows['consented-canary']['error']['message'] === 'GENERAL'); + if ($openFeature) { + $terminalErrors = true; + foreach (array('hook-before-error', 'hook-after-error') as $flag) { + $row = $rows[$flag]; + $terminalErrors = $terminalErrors && $row['evaluation_count'] === 1 + && !isset($row['variant']) && !isset($row['allocation']) + && $row['runtime_default_used'] === true && $row['error']['message'] === 'GENERAL' + && $row['targeting_key'] === 'sha256_2234a4963a43ffe952df4e09b1fd0d36f8b49216e5cba4182ae4d01e67cf112a' + && !isset($row['context']['evaluation']); + } + echo 'terminal_hook_errors='; var_dump($terminalErrors); + } // TRACE logs can be large in CI. Inspect every byte without loading the // entire log (and a second concatenated copy) into the PHP process. $diagnostics = fopen($diagnosticLog, 'r'); diff --git a/tests/ext/ffe/stubs/flag_evaluation_wire_app.php b/tests/ext/ffe/stubs/flag_evaluation_wire_app.php index c3fa4cd0785..c1d9b7d77f2 100644 --- a/tests/ext/ffe/stubs/flag_evaluation_wire_app.php +++ b/tests/ext/ffe/stubs/flag_evaluation_wire_app.php @@ -11,7 +11,7 @@ 'observeFullEvaluationData' => true, 'flags' => array(), ); -foreach (array('consented', 'protected', 'coerced', 'empty', 'missing', 'malformed') as $key) { +foreach (array('consented', 'protected', 'coerced', 'empty', 'missing', 'malformed', 'hook-after-error') as $key) { $config['flags'][$key] = array( 'key' => $key, 'enabled' => true, 'variationType' => 'STRING', 'variations' => array('blue' => array('key' => 'blue', 'value' => 'blue')), @@ -25,7 +25,7 @@ $client = new \DDTrace\FeatureFlags\Client(); $context = array('targetingKey' => 'subject', 'attributes' => array('plan' => 'pro')); if (getenv('PHP_FFE_WIRE_OPENFEATURE') === '1') { - require $root . '/tests/OpenFeature/vendor/autoload.php'; + require __DIR__ . '/flag_evaluation_openfeature.inc'; $api = \OpenFeature\OpenFeatureAPI::getInstance(); $api->setProvider(new \DDTrace\OpenFeature\DataDogProvider()); $client = $api->getClient('php-ffe-wire'); @@ -49,6 +49,22 @@ if ($client->getStringValue('protected', 'fallback', $context) !== 'blue') { throw new RuntimeException('assignment failed'); } +if (getenv('PHP_FFE_WIRE_OPENFEATURE') === '1') { + $hook = new FfeOutcomeHook(); + $hook->onBefore = function ($context) { + if ($context->getFlagKey() === 'hook-before-error') throw new RuntimeException('ffe-error-pii-canary@example.test'); + return null; + }; + $hook->onAfter = function ($context) { + if ($context->getFlagKey() === 'hook-after-error') throw new RuntimeException('ffe-error-pii-canary@example.test'); + }; + $client->addHooks($hook); + foreach (array('hook-before-error', 'hook-after-error') as $flag) { + if ($client->getStringValue($flag, 'fallback', $context) !== 'fallback') { + throw new RuntimeException('hook failure did not return default'); + } + } +} echo 'exposure_flush=', json_encode(\DDTrace\Testing\flush_ffe_exposures()), "\n"; echo "evaluated\n"; $nativeClient = new \DDTrace\FeatureFlags\Client(); diff --git a/tracer/ffe.c b/tracer/ffe.c index 10d984c9165..b578b434519 100644 --- a/tracer/ffe.c +++ b/tracer/ffe.c @@ -95,8 +95,8 @@ bool ddtrace_ffe_record_flag_evaluation(zend_string *flag_key, zend_string *vari .observe_full_evaluation_data = observe_full_evaluation_data, }; ddog_FfeTelemetryContext context = { - .service = dd_zend_string_to_CharSlice(get_DD_SERVICE()), - .env = dd_zend_string_to_CharSlice(get_DD_ENV()), + .service = dd_zend_string_to_CharSlice(DATADOG_G(last_service_name)), + .env = dd_zend_string_to_CharSlice(DATADOG_G(last_env_name)), .version = dd_zend_string_to_CharSlice(get_DD_VERSION()), }; ddog_FfeSubmissionStatus status = ddog_sidecar_try_submit_ffe_flag_evaluation( From a699c257c851bd8a3f437e135aaf500415b61d3d Mon Sep 17 00:00:00 2001 From: Vickie Boettcher Date: Mon, 19 Oct 2026 21:45:39 +0000 Subject: [PATCH 13/15] Address FFE test placement and simplify review follow-ups --- .claude/ci/tracer-unit-tests.md | 12 ++++ Makefile | 2 +- .../FeatureFlags/Internal/NativeEvaluator.php | 6 -- .../Common/FeatureFlags/ffe_api_bootstrap.inc | 13 ++++ .../flag_evaluation_openfeature.inc | 0 .../flag_evaluation_test_session_app.php | 28 +++++++++ .../FeatureFlags}/flag_evaluation_wire.inc | 2 +- .../flag_evaluation_wire_app.php | 0 .../flag_evaluation_openfeature_nested.phpt | 4 +- .../flag_evaluation_openfeature_outcome.phpt | 4 +- .../flag_evaluation_openfeature_wire.phpt | 5 +- .../flag_evaluation_final_outcome.phpt | 21 +------ .../flag_evaluation_targeting_key.phpt | 2 +- .../flag_evaluation_test_session.phpt | 36 +---------- .../flag_evaluation_timestamp.phpt | 2 +- .../FeatureFlags}/flag_evaluation_wire.phpt | 4 +- .../system_test_data_evaluate.phpt | 61 +------------------ tests/ext/ffe/stubs/ffe_api_bootstrap.inc | 24 -------- tests/ext/ffe/stubs/ffe_process.inc | 2 +- tracer/ddtrace.c | 2 - tracer/functions.c | 2 - tracer/tracer_telemetry.c | 12 ++-- 22 files changed, 73 insertions(+), 171 deletions(-) create mode 100644 tests/Common/FeatureFlags/ffe_api_bootstrap.inc rename tests/{ext/ffe/stubs => Common/FeatureFlags}/flag_evaluation_openfeature.inc (100%) create mode 100644 tests/Common/FeatureFlags/flag_evaluation_test_session_app.php rename tests/{ext/ffe/stubs => Common/FeatureFlags}/flag_evaluation_wire.inc (99%) rename tests/{ext/ffe/stubs => Common/FeatureFlags}/flag_evaluation_wire_app.php (100%) rename tests/{ext/ffe => OpenFeature}/flag_evaluation_openfeature_nested.phpt (93%) rename tests/{ext/ffe => OpenFeature}/flag_evaluation_openfeature_outcome.phpt (94%) rename tests/{ext/ffe => OpenFeature}/flag_evaluation_openfeature_wire.phpt (84%) rename tests/{ext/ffe => api/Unit/FeatureFlags}/flag_evaluation_final_outcome.phpt (76%) rename tests/{ext/ffe => api/Unit/FeatureFlags}/flag_evaluation_targeting_key.phpt (95%) rename tests/{ext/ffe => api/Unit/FeatureFlags}/flag_evaluation_test_session.phpt (65%) rename tests/{ext/ffe => api/Unit/FeatureFlags}/flag_evaluation_timestamp.phpt (97%) rename tests/{ext/ffe => api/Unit/FeatureFlags}/flag_evaluation_wire.phpt (86%) rename tests/{ext/ffe => api/Unit/FeatureFlags}/system_test_data_evaluate.phpt (78%) delete mode 100644 tests/ext/ffe/stubs/ffe_api_bootstrap.inc diff --git a/.claude/ci/tracer-unit-tests.md b/.claude/ci/tracer-unit-tests.md index 9b7569d1b4d..1077dc6a7e0 100644 --- a/.claude/ci/tracer-unit-tests.md +++ b/.claude/ci/tracer-unit-tests.md @@ -1,5 +1,17 @@ # Tracer Unit Tests and Native Extension Tests +## Choosing a test suite + +- `tests/ext/` tests the extension's exposed API directly. Do not load `src/` + classes from these tests: packaged extension tests may run without the + repository's PHP sources or Composer dependencies. +- Tests of `src/api/` belong in the API PHPUnit suite (`tests/api/Unit/`). + OpenFeature integration tests belong in `tests/OpenFeature/`. + Using native extension functions does not make a PHP-class test an + extension-only test. PHPUnit can run `.phpt` files as well as test classes. +- When moving tests, check suite discovery and the CI PHP-version matrix. + Keep native-client coverage on PHP 7; OpenFeature's suite requires PHP 8+. + ## CI Jobs **Source:** diff --git a/Makefile b/Makefile index 653639ccb55..e4532b21f0e 100644 --- a/Makefile +++ b/Makefile @@ -1701,7 +1701,7 @@ merge_coverage_reports: API_TESTS_ROOT := ./tests/api test_api_unit: composer.lock global_test_run_dependencies - $(ENV_OVERRIDE) php $(TRACER_SOURCES_INI) vendor/bin/phpunit --config=phpunit.xml $(API_TESTS_ROOT)/Unit $(TESTS) + $(ENV_OVERRIDE) php $(TRACER_SOURCES_INI) vendor/bin/phpunit --config=phpunit.xml --test-suffix=Test.php,.phpt $(API_TESTS_ROOT)/Unit $(TESTS) # Just test it does not crash, i.e. the exit code test_internal_api_randomized: $(SO_FILE) diff --git a/src/api/FeatureFlags/Internal/NativeEvaluator.php b/src/api/FeatureFlags/Internal/NativeEvaluator.php index 18768b5c093..b1984034af6 100644 --- a/src/api/FeatureFlags/Internal/NativeEvaluator.php +++ b/src/api/FeatureFlags/Internal/NativeEvaluator.php @@ -76,9 +76,6 @@ public function evaluate( private function recordFlagEvaluation($flagKey, $targetingKey, array $attributes, $rawResult, $details, $evaluationTimestampMs, $targetingKeyInvalid) { - if (!function_exists('DDTrace\\Internal\\record_ffe_flag_evaluation')) { - return; - } try { $exposure = $details->getExposureData(); $consent = is_object($rawResult) @@ -114,9 +111,6 @@ private function recordFlagEvaluation($flagKey, $targetingKey, array $attributes /** @internal Shared submission for immediate native and finalized OpenFeature observations. */ public static function submitFlagEvaluation(array $observation) { - if (!function_exists('DDTrace\\Internal\\record_ffe_flag_evaluation')) { - return false; - } try { return \DDTrace\Internal\record_ffe_flag_evaluation(...$observation); } catch (\Throwable $ignored) { diff --git a/tests/Common/FeatureFlags/ffe_api_bootstrap.inc b/tests/Common/FeatureFlags/ffe_api_bootstrap.inc new file mode 100644 index 00000000000..410abc21696 --- /dev/null +++ b/tests/Common/FeatureFlags/ffe_api_bootstrap.inc @@ -0,0 +1,13 @@ + '2026-05-22T00:00:00Z', + 'environment' => array('name' => 'test'), + 'observeFullEvaluationData' => false, + 'flags' => array('flag' => array( + 'key' => 'flag', 'enabled' => true, 'variationType' => 'STRING', + 'variations' => array('blue' => array('key' => 'blue', 'value' => 'blue')), + 'allocations' => array(array( + 'key' => 'allocation', 'rules' => array(), 'doLog' => true, + 'splits' => array(array('variationKey' => 'blue', 'serialId' => 0, 'shards' => array())), + )), + )), +); +if (!\DDTrace\Testing\ffe_load_config(json_encode($config))) throw new RuntimeException('configuration rejected'); +$client = new \DDTrace\FeatureFlags\Client(); +foreach (array('session-a', 'session-b') as $token) { + ini_set('datadog.trace.agent_test_session_token', $token); + if ($client->getStringValue('flag', 'fallback', array('targetingKey' => 'subject')) !== 'blue') { + throw new RuntimeException('assignment changed'); + } + // Hand off exposures before switching sessions. Flagevaluations may + // still be queued and must retain their original destination. + if (!\DDTrace\Testing\flush_ffe_exposures()) throw new RuntimeException('exposure flush rejected'); +} +dd_trace_synchronous_flush(5000); +exit(0); diff --git a/tests/ext/ffe/stubs/flag_evaluation_wire.inc b/tests/Common/FeatureFlags/flag_evaluation_wire.inc similarity index 99% rename from tests/ext/ffe/stubs/flag_evaluation_wire.inc rename to tests/Common/FeatureFlags/flag_evaluation_wire.inc index b6a472b82cb..8a1448f2b2c 100644 --- a/tests/ext/ffe/stubs/flag_evaluation_wire.inc +++ b/tests/Common/FeatureFlags/flag_evaluation_wire.inc @@ -1,5 +1,5 @@ --ENV-- DD_METRICS_OTEL_ENABLED=0 @@ -13,7 +13,7 @@ DD_REMOTE_CONFIG_ENABLED=0 DD_TRACE_AGENT_URL=http://127.0.0.1:9 --FILE-- --ENV-- DD_METRICS_OTEL_ENABLED=0 @@ -13,7 +13,7 @@ DD_REMOTE_CONFIG_ENABLED=0 DD_TRACE_AGENT_URL=http://127.0.0.1:9 --FILE-- --ENV-- DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 @@ -21,7 +20,7 @@ DD_API_KEY=local-test-key-must-not-reach-agent --FILE-- --EXPECT-- service=php-ffe-wire diff --git a/tests/ext/ffe/flag_evaluation_final_outcome.phpt b/tests/api/Unit/FeatureFlags/flag_evaluation_final_outcome.phpt similarity index 76% rename from tests/ext/ffe/flag_evaluation_final_outcome.phpt rename to tests/api/Unit/FeatureFlags/flag_evaluation_final_outcome.phpt index eece0e573b9..b27da2ce172 100644 --- a/tests/ext/ffe/flag_evaluation_final_outcome.phpt +++ b/tests/api/Unit/FeatureFlags/flag_evaluation_final_outcome.phpt @@ -5,26 +5,7 @@ DD_METRICS_OTEL_ENABLED=false DD_TRACE_GENERATE_ROOT_SPAN=0 --FILE-- --ENV-- DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 @@ -16,43 +14,13 @@ DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED=true DD_SERVICE=php-ffe-test-session --FILE-- '2026-05-22T00:00:00Z', - 'environment' => array('name' => 'test'), - 'observeFullEvaluationData' => false, - 'flags' => array('flag' => array( - 'key' => 'flag', 'enabled' => true, 'variationType' => 'STRING', - 'variations' => array('blue' => array('key' => 'blue', 'value' => 'blue')), - 'allocations' => array(array( - 'key' => 'allocation', 'rules' => array(), 'doLog' => true, - 'splits' => array(array('variationKey' => 'blue', 'serialId' => 0, 'shards' => array())), - )), - )), - ); - if (!\DDTrace\Testing\ffe_load_config(json_encode($config))) throw new RuntimeException('configuration rejected'); - $client = new \DDTrace\FeatureFlags\Client(); - foreach (array('session-a', 'session-b') as $token) { - ini_set('datadog.trace.agent_test_session_token', $token); - if ($client->getStringValue('flag', 'fallback', array('targetingKey' => 'subject')) !== 'blue') { - throw new RuntimeException('assignment changed'); - } - // Hand off exposures before switching sessions. Flagevaluations may - // still be queued and must retain their original destination. - if (!\DDTrace\Testing\flush_ffe_exposures()) throw new RuntimeException('exposure flush rejected'); - } - dd_trace_synchronous_flush(5000); - exit(0); -} +require __DIR__ . '/../../../ext/ffe/stubs/ffe_process.inc'; $listener = stream_socket_server('tcp://127.0.0.1:0', $errno, $error); if (!$listener) throw new RuntimeException($error); putenv('DD_TRACE_AGENT_URL=http://' . stream_socket_get_name($listener, false)); putenv('DD_TRACE_AGENT_TEST_SESSION_TOKEN=session-a'); -putenv('PHP_FFE_TEST_SESSION_CHILD=1'); -$process = ffeTestProcess(__FILE__, $pipes); +$process = ffeTestProcess(__DIR__ . '/../../../Common/FeatureFlags/flag_evaluation_test_session_app.php', $pipes); fclose($pipes[0]); $stdout = $stderr = ''; $exitCode = $exitedAt = null; diff --git a/tests/ext/ffe/flag_evaluation_timestamp.phpt b/tests/api/Unit/FeatureFlags/flag_evaluation_timestamp.phpt similarity index 97% rename from tests/ext/ffe/flag_evaluation_timestamp.phpt rename to tests/api/Unit/FeatureFlags/flag_evaluation_timestamp.phpt index c7a41eb4dd1..1517747e9fb 100644 --- a/tests/ext/ffe/flag_evaluation_timestamp.phpt +++ b/tests/api/Unit/FeatureFlags/flag_evaluation_timestamp.phpt @@ -5,7 +5,7 @@ DD_METRICS_OTEL_ENABLED=false DD_TRACE_GENERATE_ROOT_SPAN=0 --FILE-- --ENV-- DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 @@ -20,7 +18,7 @@ DD_API_KEY=local-test-key-must-not-reach-agent --FILE-- --EXPECT-- service=php-ffe-wire diff --git a/tests/ext/ffe/system_test_data_evaluate.phpt b/tests/api/Unit/FeatureFlags/system_test_data_evaluate.phpt similarity index 78% rename from tests/ext/ffe/system_test_data_evaluate.phpt rename to tests/api/Unit/FeatureFlags/system_test_data_evaluate.phpt index 8292184d8f1..364bb1b79a2 100644 --- a/tests/ext/ffe/system_test_data_evaluate.phpt +++ b/tests/api/Unit/FeatureFlags/system_test_data_evaluate.phpt @@ -10,14 +10,9 @@ if (getenv('USE_ZEND_ALLOC') === '0' && !getenv('SKIP_ASAN')) { DD_TRACE_GENERATE_ROOT_SPAN=0 --FILE-- Date: Mon, 19 Oct 2026 21:53:19 +0000 Subject: [PATCH 14/15] Defer flagevaluation application identity to the sidecar --- .../FeatureFlags/flag_evaluation_wire.inc | 17 +++++++++++------ .../flag_evaluation_openfeature_wire.phpt | 3 ++- .../Unit/FeatureFlags/flag_evaluation_wire.phpt | 3 ++- tests/ext/ffe/flag_evaluation_identity.phpt | 17 ++++++++++------- .../ffe/stubs/flag_evaluation_identity_app.php | 1 + tracer/ffe.c | 8 +++----- 6 files changed, 29 insertions(+), 20 deletions(-) diff --git a/tests/Common/FeatureFlags/flag_evaluation_wire.inc b/tests/Common/FeatureFlags/flag_evaluation_wire.inc index 8a1448f2b2c..08ef6b7f631 100644 --- a/tests/Common/FeatureFlags/flag_evaluation_wire.inc +++ b/tests/Common/FeatureFlags/flag_evaluation_wire.inc @@ -77,6 +77,7 @@ try { $rows = array(); $exposureCount = 0; $identity = true; + $evaluationIdentityAbsent = true; $protectedBytesAbsent = true; foreach ($captures as $capture) { list($request, $headers, $payload) = $capture; @@ -94,12 +95,10 @@ try { if (strpos($capture[3], 'ffe-error-pii-canary@example.test') !== false) { throw new RuntimeException('raw error text reached the payload'); } - $context = $payload['context']; - ksort($context); - if ($context !== array('env' => 'test', 'service' => 'php-ffe-wire', 'version' => 'wire-version')) { - throw new RuntimeException('incorrect service context: ' . json_encode($payload['context'])); - } + // Application attribution is deferred to FFL-3438/FFL-3439. + $evaluationIdentityAbsent = $evaluationIdentityAbsent && $payload['context'] === array(); foreach ($payload['flagEvaluations'] as $row) { + $evaluationIdentityAbsent = $evaluationIdentityAbsent && !isset($row['context']['dd']); $key = $row['flag']['key']; if (isset($rows[$key])) { $rows[$key]['evaluation_count'] += $row['evaluation_count']; @@ -111,11 +110,17 @@ try { } } } else { + $context = $payload['context']; + ksort($context); + if ($context !== array('env' => 'test', 'service' => 'php-ffe-wire', 'version' => 'wire-version')) { + throw new RuntimeException('incorrect exposure context: ' . json_encode($payload['context'])); + } $exposureCount += count($payload['exposures']); } } - echo 'service=php-ffe-wire', "\n"; + echo 'exposure_service=php-ffe-wire', "\n"; echo 'identity='; var_dump($identity); + echo 'evaluation_application_identity_absent='; var_dump($evaluationIdentityAbsent); echo 'exposures='; var_dump($exposureCount); echo 'consented_count='; var_dump($rows['consented']['evaluation_count']); echo 'consented_subject='; var_dump($rows['consented']['targeting_key']); diff --git a/tests/OpenFeature/flag_evaluation_openfeature_wire.phpt b/tests/OpenFeature/flag_evaluation_openfeature_wire.phpt index e3ec33196c3..042390095d3 100644 --- a/tests/OpenFeature/flag_evaluation_openfeature_wire.phpt +++ b/tests/OpenFeature/flag_evaluation_openfeature_wire.phpt @@ -23,8 +23,9 @@ $openFeature = true; require __DIR__ . '/../Common/FeatureFlags/flag_evaluation_wire.inc'; ?> --EXPECT-- -service=php-ffe-wire +exposure_service=php-ffe-wire identity=bool(true) +evaluation_application_identity_absent=bool(true) exposures=int(2) consented_count=int(2) consented_subject=string(7) "subject" diff --git a/tests/api/Unit/FeatureFlags/flag_evaluation_wire.phpt b/tests/api/Unit/FeatureFlags/flag_evaluation_wire.phpt index 78ea378889b..264b0b8682e 100644 --- a/tests/api/Unit/FeatureFlags/flag_evaluation_wire.phpt +++ b/tests/api/Unit/FeatureFlags/flag_evaluation_wire.phpt @@ -21,8 +21,9 @@ $openFeature = false; require __DIR__ . '/../../../Common/FeatureFlags/flag_evaluation_wire.inc'; ?> --EXPECT-- -service=php-ffe-wire +exposure_service=php-ffe-wire identity=bool(true) +evaluation_application_identity_absent=bool(true) exposures=int(2) consented_count=int(2) consented_subject=string(7) "subject" diff --git a/tests/ext/ffe/flag_evaluation_identity.phpt b/tests/ext/ffe/flag_evaluation_identity.phpt index 9b33397eb39..121281e6a50 100644 --- a/tests/ext/ffe/flag_evaluation_identity.phpt +++ b/tests/ext/ffe/flag_evaluation_identity.phpt @@ -1,5 +1,5 @@ --TEST-- -FFE payload uses resolved request identity and follows runtime service/environment changes +FFE evaluations remain deliverable without application identity pending sidecar attribution --SKIPIF-- --EXPECT-- -default={"env":"none","service":"flag_evaluation_identity_app.php"} -configured={"env":"changed-env","service":"changed-service"} +default=bool(true) +configured=bool(true) diff --git a/tests/ext/ffe/stubs/flag_evaluation_identity_app.php b/tests/ext/ffe/stubs/flag_evaluation_identity_app.php index c5a1cd759ae..320245f5a34 100644 --- a/tests/ext/ffe/stubs/flag_evaluation_identity_app.php +++ b/tests/ext/ffe/stubs/flag_evaluation_identity_app.php @@ -3,6 +3,7 @@ if ($flag === 'configured') { ini_set('datadog.service', 'changed-service'); ini_set('datadog.env', 'changed-env'); + ini_set('datadog.version', 'changed-version'); } if (!\DDTrace\Internal\record_ffe_flag_evaluation( $flag, 'blue', null, null, array(), null, false, false, 1700000000123 diff --git a/tracer/ffe.c b/tracer/ffe.c index b578b434519..a88bfb0d64e 100644 --- a/tracer/ffe.c +++ b/tracer/ffe.c @@ -94,11 +94,9 @@ bool ddtrace_ffe_record_flag_evaluation(zend_string *flag_key, zend_string *vari .runtime_default_used = runtime_default_used, .observe_full_evaluation_data = observe_full_evaluation_data, }; - ddog_FfeTelemetryContext context = { - .service = dd_zend_string_to_CharSlice(DATADOG_G(last_service_name)), - .env = dd_zend_string_to_CharSlice(DATADOG_G(last_env_name)), - .version = dd_zend_string_to_CharSlice(get_DD_VERSION()), - }; + // Sidecar application attribution is deferred to FFL-3438/FFL-3439. + // The current FFI still requires a context argument. + ddog_FfeTelemetryContext context = {0}; ddog_FfeSubmissionStatus status = ddog_sidecar_try_submit_ffe_flag_evaluation( DATADOG_G(sidecar), &context, &event, (ddog_Slice_FfeScalarAttribute) {.ptr = scalars, .len = count}, &snapshot); From 5ccd2efe021d84bb5f7c38e686370a5c289d62f1 Mon Sep 17 00:00:00 2001 From: Vickie Boettcher Date: Tue, 20 Oct 2026 17:18:15 +0000 Subject: [PATCH 15/15] test(ffe): skip exit leak scan for signal flush test Keep Valgrind runtime checks and all signal-flush assertions while avoiding the slow final scan of libdatadog shared-memory reservations left by intentional _exit. Other tests retain their existing leak-check settings. --- tests/ext/ffe/flag_evaluation_signal_flush.phpt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/ext/ffe/flag_evaluation_signal_flush.phpt b/tests/ext/ffe/flag_evaluation_signal_flush.phpt index c9d14998a8d..144c1813410 100644 --- a/tests/ext/ffe/flag_evaluation_signal_flush.phpt +++ b/tests/ext/ffe/flag_evaluation_signal_flush.phpt @@ -1,5 +1,10 @@ --TEST-- FFE signal flush waits for pending HTTP sends before default-handler termination +--DESCRIPTION-- +Default signal handling deliberately calls _exit without normal cleanup. +Keep Valgrind's runtime checks, but skip the final leak scan for this test: +libdatadog's unbacked shared-memory reservations make that scan very slow. +Normal-shutdown tests keep their existing Valgrind settings. --SKIPIF--