From 7aecaffffac931d65b0f5ec45d2cb291f425e5dd Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 12 Nov 2026 23:05:11 +0000 Subject: [PATCH 1/5] fix(ffe): deliver agentless events directly when no Agent is available --- components-rs/datadog.h | 6 + components-rs/tracer_runtime/ffe.rs | 82 ++++++++++++++ docs/feature_flags_agentless.md | 16 ++- ext/sidecar.c | 18 ++- tests/ext/ffe/agentless_evp_transport.phpt | 107 ++++++++++++++++++ tests/ext/ffe/stubs/agentless_evp_app.php | 9 ++ tests/ext/ffe/stubs/agentless_evp_config.json | 17 +++ 7 files changed, 250 insertions(+), 5 deletions(-) create mode 100644 tests/ext/ffe/agentless_evp_transport.phpt create mode 100644 tests/ext/ffe/stubs/agentless_evp_app.php create mode 100644 tests/ext/ffe/stubs/agentless_evp_config.json diff --git a/components-rs/datadog.h b/components-rs/datadog.h index 7bbec348d9..b17d126420 100644 --- a/components-rs/datadog.h +++ b/components-rs/datadog.h @@ -314,6 +314,12 @@ struct ddog_FfeCdnConfig *ddog_ffe_cdn_config_new(ddog_CharSlice base_url, void ddog_ffe_cdn_config_drop(struct ddog_FfeCdnConfig*); +/** + * Build the authenticated EVP intake independently of any custom CDN URL. + * Missing credentials or an invalid destination leave local EVP discovery available. + */ +struct ddog_Endpoint *ddog_ffe_evp_endpoint_new(ddog_CharSlice site, ddog_CharSlice api_key); + struct ddog_FfeResult ddog_ffe_evaluate(ddog_CharSlice flag_key, int32_t expected_type, ddog_CharSlice targeting_key, diff --git a/components-rs/tracer_runtime/ffe.rs b/components-rs/tracer_runtime/ffe.rs index 700d2f4e8c..8fc98f1d38 100644 --- a/components-rs/tracer_runtime/ffe.rs +++ b/components-rs/tracer_runtime/ffe.rs @@ -237,6 +237,46 @@ pub extern "C" fn ddog_ffe_cdn_config_new( #[no_mangle] pub extern "C" fn ddog_ffe_cdn_config_drop(_: Box) {} +/// Build the authenticated EVP intake independently of any custom CDN URL. +/// Missing credentials or an invalid destination leave local EVP discovery available. +#[no_mangle] +pub extern "C" fn ddog_ffe_evp_endpoint_new( + site: CharSlice, + api_key: CharSlice, +) -> Option> { + let site = site.try_to_utf8().ok()?.trim(); + let site = if site.is_empty() { + "datadoghq.com" + } else { + site + }; + // Validate the input before URI parsing, which can discard a fragment. + if !site + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-')) + { + return None; + } + let endpoint = libdd_common::Endpoint { + url: format!( + "https://event-platform-intake.{}/", + site.to_ascii_lowercase() + ) + .parse() + .ok()?, + api_key: Some(api_key.try_to_utf8().ok()?.to_owned().into()), + ..Default::default() + }; + datadog_sidecar::service::EvpTransportConfig::prefer_local_then_direct( + libdd_common::Endpoint::default(), + Some(endpoint.clone()), + "event-platform-intake", + ) + .validate() + .ok()?; + Some(Box::new(endpoint)) +} + const REASON_STATIC: i32 = 0; const REASON_DEFAULT: i32 = 1; const REASON_TARGETING_MATCH: i32 = 2; @@ -512,6 +552,48 @@ mod tests { static INIT_ZEND_STRING_FUNCTIONS: Once = Once::new(); + #[test] + fn evp_endpoint_uses_canonical_https_intake() { + for (site, host) in [ + ("datadoghq.eu", "event-platform-intake.datadoghq.eu"), + ( + " US3.Datadoghq.com ", + "event-platform-intake.us3.datadoghq.com", + ), + ("", "event-platform-intake.datadoghq.com"), + ] { + let endpoint = ddog_ffe_evp_endpoint_new(site.into(), "test-key".into()).unwrap(); + assert_eq!(endpoint.url.scheme_str(), Some("https")); + assert_eq!(endpoint.url.host(), Some(host)); + assert_eq!(endpoint.url.path(), "/"); + assert_eq!(endpoint.api_key.as_deref(), Some("test-key")); + } + } + + #[test] + fn evp_endpoint_rejects_invalid_destinations_and_credentials() { + for site in [ + "https://datadoghq.com", + "datadoghq.com:443", + "datadoghq.com/path", + "datadoghq.com?query", + "datadoghq.com#fragment", + "datadoghq.com@other.invalid", + "bad site", + "a..invalid", + "-bad.invalid", + "\u{212a}.com", + ] { + assert!( + ddog_ffe_evp_endpoint_new(site.into(), "test-key".into()).is_none(), + "{site}" + ); + } + for key in ["", "key\r\nInjected: value"] { + assert!(ddog_ffe_evp_endpoint_new("datadoghq.com".into(), key.into()).is_none()); + } + } + fn setup_zend_string_functions() { INIT_ZEND_STRING_FUNCTIONS.call_once(|| unsafe { crate::bytes::ddog_init_span_func( diff --git a/docs/feature_flags_agentless.md b/docs/feature_flags_agentless.md index e55b134011..24bf9d7009 100644 --- a/docs/feature_flags_agentless.md +++ b/docs/feature_flags_agentless.md @@ -86,9 +86,19 @@ A forked child that did not inherit a configuration gets its own wait. ## Telemetry -Configuration delivery and telemetry delivery are independent. Exposure and -evaluation telemetry still go through the sidecar to the Agent, or through -OTLP. +Configuration delivery and event delivery are independent. In agentless mode, +the PHP sidecar sends exposures and evaluation-count events through a compatible +local Datadog Agent EVP proxy when available. Otherwise, with a valid `DD_SITE` +and `DD_API_KEY`, it sends them directly to +`https://event-platform-intake.`. A custom CDN base URL does not change +the event destination and never receives the API key. + +Both event streams share route discovery and fallback state. A definitively +rejected proxy request (404/405 or a connection failure before sending) may be +replayed directly. Ambiguous failures switch future traffic to direct intake +without replaying the current batch. Without direct credentials, the sidecar +can rediscover a local proxy after a cooldown. Remote Config mode keeps the +fixed Agent EVP route. OTLP metric delivery is unchanged. ## Tests diff --git a/ext/sidecar.c b/ext/sidecar.c index 6c97bff68b..ae05b2d604 100644 --- a/ext/sidecar.c +++ b/ext/sidecar.c @@ -18,6 +18,9 @@ #include "target_metadata.h" #include "ffi_utils.h" #include +#ifdef TRACER +#include +#endif #ifdef APPSEC #include #endif @@ -183,9 +186,20 @@ static void dd_sidecar_configure_ffe_transport(ddog_SidecarTransport **transport .origin = DDOG_CHARSLICE_C("dd-trace-php"), .version = DDOG_CHARSLICE_C(PHP_DDTRACE_VERSION), }; + bool agentless = ddtrace_ffe_agentless_enabled(); + ddog_Endpoint *direct_endpoint = agentless ? ddog_ffe_evp_endpoint_new( + dd_zend_string_to_CharSlice(get_global_DD_SITE()), + dd_zend_string_to_CharSlice(get_global_DD_API_KEY())) : NULL; + if (direct_endpoint) { + dd_set_endpoint_test_token(direct_endpoint); + } 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)); + ddog_sidecar_session_set_evp_transport(transport, + agentless ? DDOG_EVP_TRANSPORT_MODE_PREFER_LOCAL_THEN_DIRECT : DDOG_EVP_TRANSPORT_MODE_AGENT_ONLY, + datadog_endpoint, direct_endpoint, DDOG_CHARSLICE_C("event-platform-intake"), &ffe_producer)); + if (direct_endpoint) { + ddog_endpoint_drop(direct_endpoint); + } } #endif diff --git a/tests/ext/ffe/agentless_evp_transport.phpt b/tests/ext/ffe/agentless_evp_transport.phpt new file mode 100644 index 0000000000..132117bed9 --- /dev/null +++ b/tests/ext/ffe/agentless_evp_transport.phpt @@ -0,0 +1,107 @@ +--TEST-- +FFE agentless discovers a shared local EVP route while Remote Config keeps EVP v2 +--SKIPIF-- + +--ENV-- +DD_TRACE_CLI_ENABLED=1 +DD_TRACE_GENERATE_ROOT_SPAN=0 +DD_REMOTE_CONFIG_ENABLED=0 +DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 +DD_METRICS_OTEL_ENABLED=0 +DD_TRACE_AGENT_TIMEOUT=5000 +DD_SERVICE=php-ffe-transport +DD_FLAGGING_EVALUATION_COUNTS_ENABLED=1 +--FILE-- + array('/evp_proxy/v2', '/evp_proxy/v4'), + 'evp_proxy_allowed_headers' => array('DD-EVP-ORIGIN', 'DD-EVP-ORIGIN-VERSION'))); + } elseif ($path === '/configuration') { + $response = json_encode(array('data' => array('type' => 'universal-flag-configuration', + 'attributes' => json_decode(file_get_contents(__DIR__ . '/stubs/agentless_evp_config.json'), true)))); + } elseif (strpos($path, '/evp_proxy/') === 0) { + if (strpos($path, $prefix) !== 0) throw new RuntimeException('wrong EVP route: ' . $path); + if ($headers['dd-evp-origin'] !== 'dd-trace-php' || $headers['dd-evp-origin-version'] !== phpversion('ddtrace')) { + throw new RuntimeException('wrong producer identity'); + } + $kind = substr($path, strlen($prefix)); + $payload = json_decode($body, true); + $field = $kind === 'exposures' ? 'exposures' : 'flagEvaluations'; + if (!isset($payload[$field]) || count($payload[$field]) !== 1 + || $payload[$field][0]['flag']['key'] !== 'transport-flag') { + throw new RuntimeException('unexpected event payload: ' . $body); + } + if (isset($events[$kind])) throw new RuntimeException('duplicate event batch'); + $events[$kind] = true; + } + fwrite($connection, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: " + . strlen($response) . "\r\nConnection: close\r\n\r\n" . $response); + } finally { + fclose($connection); + } + } + $status = proc_get_status($process); + if (!$status['running']) { $exitCode = $status['exitcode']; break; } + } while (microtime(true) < $deadline); + if ($exitCode !== 0) throw new RuntimeException('child failed: ' . ffeTestProcessRead($pipes[2])); + if (!isset($events['exposures'], $events['flagevaluation'])) throw new RuntimeException('missing event stream'); + echo $mode, ': both streams delivered with PHP identity and no API key', "\n"; + } finally { + if ($exitCode === null) proc_terminate($process); + foreach ($pipes as $pipe) fclose($pipe); + proc_close($process); + fclose($server); + } +} +?> +--EXPECT-- +agentless: both streams delivered with PHP identity and no API key +no-key: both streams delivered with PHP identity and no API key +invalid-site: both streams delivered with PHP identity and no API key +remote-config: both streams delivered with PHP identity and no API key diff --git a/tests/ext/ffe/stubs/agentless_evp_app.php b/tests/ext/ffe/stubs/agentless_evp_app.php new file mode 100644 index 0000000000..b7dac8832d --- /dev/null +++ b/tests/ext/ffe/stubs/agentless_evp_app.php @@ -0,0 +1,9 @@ +getStringDetails('transport-flag', 'fallback', array('targetingKey' => 'transport-user')); +if ($details->getValue() !== 'blue') throw new RuntimeException('evaluation failed'); +dd_trace_synchronous_flush(); +echo "delivered\n"; diff --git a/tests/ext/ffe/stubs/agentless_evp_config.json b/tests/ext/ffe/stubs/agentless_evp_config.json new file mode 100644 index 0000000000..e60460c5da --- /dev/null +++ b/tests/ext/ffe/stubs/agentless_evp_config.json @@ -0,0 +1,17 @@ +{ + "createdAt": "2026-05-22T00:00:00Z", + "format": "SERVER", + "environment": {"name": "test"}, + "flags": { + "transport-flag": { + "key": "transport-flag", + "enabled": true, + "variationType": "STRING", + "variations": {"on": {"key": "on", "value": "blue"}}, + "allocations": [{ + "key": "allocation", "rules": [], "doLog": true, + "splits": [{"variationKey": "on", "serialId": 7, "shards": []}] + }] + } + } +} From ec428b1121fb91cad27f026e940d23a8ef667322 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 13 Nov 2026 01:09:26 +0000 Subject: [PATCH 2/5] fix(ffe): preserve updated EVP tokens and wait for sidecar delivery --- ext/sidecar.c | 10 ++++++---- tests/ext/ffe/agentless_evp_transport.phpt | 17 +++++++++++++++-- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/ext/sidecar.c b/ext/sidecar.c index ae05b2d604..0b32177adb 100644 --- a/ext/sidecar.c +++ b/ext/sidecar.c @@ -179,7 +179,7 @@ static void dd_sidecar_on_connect(ddog_SidecarTransport **transport) { } #ifdef TRACER -static void dd_sidecar_configure_ffe_transport(ddog_SidecarTransport **transport) { +static void dd_sidecar_configure_ffe_transport(ddog_SidecarTransport **transport, ddog_CharSlice test_token) { // 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 = { @@ -191,7 +191,7 @@ static void dd_sidecar_configure_ffe_transport(ddog_SidecarTransport **transport dd_zend_string_to_CharSlice(get_global_DD_SITE()), dd_zend_string_to_CharSlice(get_global_DD_API_KEY())) : NULL; if (direct_endpoint) { - dd_set_endpoint_test_token(direct_endpoint); + ddog_endpoint_set_test_token_if_changed(direct_endpoint, test_token); } datadog_ffi_try("Failed configuring PHP FFE EVP transport", ddog_sidecar_session_set_evp_transport(transport, @@ -272,7 +272,8 @@ static void dd_sidecar_configure(ddog_SidecarTransport **transport) { // Configure during ordinary connection setup, including fork recovery. // The transport replays its configuration after reconnects. // Remote Config keeps the fixed Agent EVP v2 route. - dd_sidecar_configure_ffe_transport(transport); + dd_sidecar_configure_ffe_transport(transport, dd_zend_string_to_CharSlice(zai_config_is_initialized() + ? get_DD_TRACE_AGENT_TEST_SESSION_TOKEN() : get_global_DD_TRACE_AGENT_TEST_SESSION_TOKEN())); #endif if (get_global_DD_INSTRUMENTATION_TELEMETRY_ENABLED()) { @@ -1049,7 +1050,8 @@ bool datadog_alter_test_session_token(zval *old_value, zval *new_value, zend_str ddog_sidecar_set_test_session_token(&DATADOG_G(sidecar), dd_zend_string_to_CharSlice(Z_STR_P(new_value)))); #ifdef TRACER if (token_changed) { - dd_sidecar_configure_ffe_transport(&DATADOG_G(sidecar)); + // The INI callback runs before the new value is visible through the getter. + dd_sidecar_configure_ffe_transport(&DATADOG_G(sidecar), dd_zend_string_to_CharSlice(Z_STR_P(new_value))); } #endif } diff --git a/tests/ext/ffe/agentless_evp_transport.phpt b/tests/ext/ffe/agentless_evp_transport.phpt index 132117bed9..600219bf04 100644 --- a/tests/ext/ffe/agentless_evp_transport.phpt +++ b/tests/ext/ffe/agentless_evp_transport.phpt @@ -36,6 +36,8 @@ foreach (array('agentless', 'no-key', 'invalid-site', 'remote-config') as $mode) $process = ffeTestProcess(__DIR__ . '/stubs/agentless_evp_app.php', $pipes, $environment); $events = array(); $exitCode = null; + $exitedAt = null; + $lastCaptureAt = 0; $prefix = $mode === 'remote-config' ? '/evp_proxy/v2/api/v2/' : '/evp_proxy/v4/api/v2/'; try { $deadline = microtime(true) + (getenv('USE_ZEND_ALLOC') === '0' ? 60 : 15); @@ -79,6 +81,7 @@ foreach (array('agentless', 'no-key', 'invalid-site', 'remote-config') as $mode) } if (isset($events[$kind])) throw new RuntimeException('duplicate event batch'); $events[$kind] = true; + $lastCaptureAt = microtime(true); } fwrite($connection, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: " . strlen($response) . "\r\nConnection: close\r\n\r\n" . $response); @@ -86,8 +89,18 @@ foreach (array('agentless', 'no-key', 'invalid-site', 'remote-config') as $mode) fclose($connection); } } - $status = proc_get_status($process); - if (!$status['running']) { $exitCode = $status['exitcode']; break; } + if ($exitedAt === null) { + $status = proc_get_status($process); + if (!$status['running']) { + $exitCode = $status['exitcode']; + $exitedAt = microtime(true); + } + } + if ($exitCode !== null && $exitCode !== 0) break; + // The sidecar can finish delivery after PHP exits. Keep receiving + // both streams, then observe a short quiet period for duplicates. + if ($exitedAt !== null && isset($events['exposures'], $events['flagevaluation']) + && microtime(true) - max($exitedAt, $lastCaptureAt) > 0.5) break; } while (microtime(true) < $deadline); if ($exitCode !== 0) throw new RuntimeException('child failed: ' . ffeTestProcessRead($pipes[2])); if (!isset($events['exposures'], $events['flagevaluation'])) throw new RuntimeException('missing event stream'); From a122cbc2ee47551e911de695ea2d45d0e1f4dc99 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 13 Nov 2026 10:30:02 -0500 Subject: [PATCH 3/5] test(ffe): serve concurrent EVP requests without blocking --- tests/ext/ffe/agentless_evp_transport.phpt | 26 ++++------- tests/ext/ffe/stubs/ffe_http_server.inc | 53 ++++++++++++++++++++++ 2 files changed, 62 insertions(+), 17 deletions(-) create mode 100644 tests/ext/ffe/stubs/ffe_http_server.inc diff --git a/tests/ext/ffe/agentless_evp_transport.phpt b/tests/ext/ffe/agentless_evp_transport.phpt index 600219bf04..36599bd57d 100644 --- a/tests/ext/ffe/agentless_evp_transport.phpt +++ b/tests/ext/ffe/agentless_evp_transport.phpt @@ -18,6 +18,7 @@ DD_FLAGGING_EVALUATION_COUNTS_ENABLED=1 --FILE-- array('/evp_proxy/v2', '/evp_proxy/v4'), @@ -103,12 +91,16 @@ foreach (array('agentless', 'no-key', 'invalid-site', 'remote-config') as $mode) && microtime(true) - max($exitedAt, $lastCaptureAt) > 0.5) break; } while (microtime(true) < $deadline); if ($exitCode !== 0) throw new RuntimeException('child failed: ' . ffeTestProcessRead($pipes[2])); - if (!isset($events['exposures'], $events['flagevaluation'])) throw new RuntimeException('missing event stream'); + if (!isset($events['exposures'], $events['flagevaluation'])) { + throw new RuntimeException($mode . ': missing event stream; received ' . implode(', ', array_keys($events)) + . '; child output: ' . ffeTestProcessRead($pipes[1]) . ffeTestProcessRead($pipes[2])); + } echo $mode, ': both streams delivered with PHP identity and no API key', "\n"; } finally { if ($exitCode === null) proc_terminate($process); foreach ($pipes as $pipe) fclose($pipe); proc_close($process); + foreach ($clients as $client) fclose($client['stream']); fclose($server); } } diff --git a/tests/ext/ffe/stubs/ffe_http_server.inc b/tests/ext/ffe/stubs/ffe_http_server.inc new file mode 100644 index 0000000000..84637b2036 --- /dev/null +++ b/tests/ext/ffe/stubs/ffe_http_server.inc @@ -0,0 +1,53 @@ + $connection, 'buffer' => ''); + } + continue; + } + $id = (int)$connection; + $chunk = fread($connection, 8192); + if ($chunk === false) throw new RuntimeException('cannot read HTTP request'); + if ($chunk === '') { + if (feof($connection)) { + fclose($connection); + unset($clients[$id]); + } + continue; + } + $clients[$id]['buffer'] .= $chunk; + $buffer = $clients[$id]['buffer']; + $headerEnd = strpos($buffer, "\r\n\r\n"); + if ($headerEnd === false) continue; + $lines = explode("\r\n", substr($buffer, 0, $headerEnd)); + $request = explode(' ', array_shift($lines)); + if (count($request) !== 3) throw new RuntimeException('invalid HTTP request line'); + $headers = array(); + foreach ($lines as $line) { + $pair = explode(':', $line, 2); + if (count($pair) !== 2) throw new RuntimeException('invalid HTTP header'); + $headers[strtolower($pair[0])] = trim($pair[1]); + } + $length = isset($headers['content-length']) ? (int)$headers['content-length'] : 0; + $body = substr($buffer, $headerEnd + 4); + if (strlen($body) < $length) continue; + unset($clients[$id]); + stream_set_blocking($connection, true); + stream_set_timeout($connection, 2); + return array($connection, $request[1], $headers, $body); + } + return null; +} From 4984b2fe20fd4a128efcb1b810fbdb128dab7e67 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 13 Nov 2026 18:28:29 +0000 Subject: [PATCH 4/5] Fix FFE local discovery across agentless setup and tracing modes --- ext/sidecar.c | 34 ++++- .../ffe/agentless_evp_managed_failure.phpt | 27 ++++ .../ffe/agentless_evp_trace_agentless.phpt | 31 +++++ tests/ext/ffe/agentless_evp_transport.phpt | 101 ++------------- tests/ext/ffe/agentless_evp_unix.phpt | 26 ++++ tests/ext/ffe/stubs/agentless_evp_app.php | 27 +++- .../ext/ffe/stubs/agentless_evp_transport.inc | 117 ++++++++++++++++++ tracer/ffe.c | 2 +- 8 files changed, 262 insertions(+), 103 deletions(-) create mode 100644 tests/ext/ffe/agentless_evp_managed_failure.phpt create mode 100644 tests/ext/ffe/agentless_evp_trace_agentless.phpt create mode 100644 tests/ext/ffe/agentless_evp_unix.phpt create mode 100644 tests/ext/ffe/stubs/agentless_evp_transport.inc diff --git a/ext/sidecar.c b/ext/sidecar.c index 0b32177adb..52399567d3 100644 --- a/ext/sidecar.c +++ b/ext/sidecar.c @@ -181,7 +181,9 @@ static void dd_sidecar_on_connect(ddog_SidecarTransport **transport) { #ifdef TRACER static void dd_sidecar_configure_ffe_transport(ddog_SidecarTransport **transport, ddog_CharSlice test_token) { // 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. + // so batches already handed to the sidecar keep their previous session. + // PHP-buffered exposures and deferred userland observations use the session + // active at handoff, not necessarily the one active during evaluation. const ddog_EvpProducerIdentity ffe_producer = { .origin = DDOG_CHARSLICE_C("dd-trace-php"), .version = DDOG_CHARSLICE_C(PHP_DDTRACE_VERSION), @@ -193,10 +195,32 @@ static void dd_sidecar_configure_ffe_transport(ddog_SidecarTransport **transport if (direct_endpoint) { ddog_endpoint_set_test_token_if_changed(direct_endpoint, test_token); } - datadog_ffi_try("Failed configuring PHP FFE EVP transport", - ddog_sidecar_session_set_evp_transport(transport, - agentless ? DDOG_EVP_TRANSPORT_MODE_PREFER_LOCAL_THEN_DIRECT : DDOG_EVP_TRANSPORT_MODE_AGENT_ONLY, - datadog_endpoint, direct_endpoint, DDOG_CHARSLICE_C("event-platform-intake"), &ffe_producer)); + // Trace-agentless mode uses an intake descriptor for datadog_endpoint. + // FFE agentless discovery still needs the configured local Agent URL. + ddog_Endpoint *local_endpoint = datadog_endpoint; + if (agentless) { + char *agent_url = datadog_agent_url(); + local_endpoint = datadog_parse_agent_url((ddog_CharSlice) {.ptr = agent_url, .len = strlen(agent_url)}); + free(agent_url); + if (!local_endpoint) { + // The FFI requires a non-null local endpoint. A relative URI has + // no receiver: discovery fails locally, allowing direct fallback. + local_endpoint = ddog_endpoint_from_url(DDOG_CHARSLICE_C("/")); + } + if (local_endpoint) { + ddog_endpoint_set_test_token_if_changed(local_endpoint, test_token); + ddog_endpoint_set_timeout(local_endpoint, get_global_DD_TRACE_AGENT_TIMEOUT()); + } + } + if (local_endpoint) { + datadog_ffi_try("Failed configuring PHP FFE EVP transport", + ddog_sidecar_session_set_evp_transport(transport, + agentless ? DDOG_EVP_TRANSPORT_MODE_PREFER_LOCAL_THEN_DIRECT : DDOG_EVP_TRANSPORT_MODE_AGENT_ONLY, + local_endpoint, direct_endpoint, DDOG_CHARSLICE_C("event-platform-intake"), &ffe_producer)); + } + if (agentless && local_endpoint) { + ddog_endpoint_drop(local_endpoint); + } if (direct_endpoint) { ddog_endpoint_drop(direct_endpoint); } diff --git a/tests/ext/ffe/agentless_evp_managed_failure.phpt b/tests/ext/ffe/agentless_evp_managed_failure.phpt new file mode 100644 index 0000000000..e256783efc --- /dev/null +++ b/tests/ext/ffe/agentless_evp_managed_failure.phpt @@ -0,0 +1,27 @@ +--TEST-- +FFE keeps local EVP discovery when managed CDN setup fails +--SKIPIF-- + +--ENV-- +DD_TRACE_CLI_ENABLED=1 +DD_TRACE_GENERATE_ROOT_SPAN=0 +DD_REMOTE_CONFIG_ENABLED=0 +DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 +DD_METRICS_OTEL_ENABLED=0 +DD_TRACE_AGENT_TIMEOUT=5000 +DD_SERVICE=php-ffe-transport +DD_FLAGGING_EVALUATION_COUNTS_ENABLED=1 +--FILE-- + +--EXPECT-- +managed-no-key/false: expected streams delivered with PHP identity, token and no API key +managed-no-key/true: expected streams delivered with PHP identity, token and no API key +managed-invalid-site/false: expected streams delivered with PHP identity, token and no API key +managed-invalid-site/true: expected streams delivered with PHP identity, token and no API key diff --git a/tests/ext/ffe/agentless_evp_trace_agentless.phpt b/tests/ext/ffe/agentless_evp_trace_agentless.phpt new file mode 100644 index 0000000000..41a190a39c --- /dev/null +++ b/tests/ext/ffe/agentless_evp_trace_agentless.phpt @@ -0,0 +1,31 @@ +--TEST-- +FFE local EVP discovery is independent of trace-agentless mode +--SKIPIF-- + +--ENV-- +DD_TRACE_CLI_ENABLED=1 +DD_TRACE_GENERATE_ROOT_SPAN=0 +DD_REMOTE_CONFIG_ENABLED=0 +DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 +DD_METRICS_OTEL_ENABLED=0 +DD_TRACE_AGENT_TIMEOUT=5000 +DD_SERVICE=php-ffe-transport +DD_FLAGGING_EVALUATION_COUNTS_ENABLED=1 +--FILE-- + +--EXPECT-- +trace-agentless/false: expected streams delivered with PHP identity, token and no API key +trace-agentless/true: expected streams delivered with PHP identity, token and no API key +trace-agentless-prefix/false: expected streams delivered with PHP identity, token and no API key +trace-agentless-prefix/true: expected streams delivered with PHP identity, token and no API key +trace-agentless-token/false: expected streams delivered with PHP identity, token and no API key +trace-agentless-token/true: expected streams delivered with PHP identity, token and no API key +trace-agentless-invalid/false: invalid local endpoint handled +trace-agentless-invalid/true: invalid local endpoint handled diff --git a/tests/ext/ffe/agentless_evp_transport.phpt b/tests/ext/ffe/agentless_evp_transport.phpt index 36599bd57d..e042253003 100644 --- a/tests/ext/ffe/agentless_evp_transport.phpt +++ b/tests/ext/ffe/agentless_evp_transport.phpt @@ -17,96 +17,15 @@ DD_SERVICE=php-ffe-transport DD_FLAGGING_EVALUATION_COUNTS_ENABLED=1 --FILE-- array('/evp_proxy/v2', '/evp_proxy/v4'), - 'evp_proxy_allowed_headers' => array('DD-EVP-ORIGIN', 'DD-EVP-ORIGIN-VERSION'))); - } elseif ($path === '/configuration') { - $response = json_encode(array('data' => array('type' => 'universal-flag-configuration', - 'attributes' => json_decode(file_get_contents(__DIR__ . '/stubs/agentless_evp_config.json'), true)))); - } elseif (strpos($path, '/evp_proxy/') === 0) { - if (strpos($path, $prefix) !== 0) throw new RuntimeException('wrong EVP route: ' . $path); - if ($headers['dd-evp-origin'] !== 'dd-trace-php' || $headers['dd-evp-origin-version'] !== phpversion('ddtrace')) { - throw new RuntimeException('wrong producer identity'); - } - $kind = substr($path, strlen($prefix)); - $payload = json_decode($body, true); - $field = $kind === 'exposures' ? 'exposures' : 'flagEvaluations'; - if (!isset($payload[$field]) || count($payload[$field]) !== 1 - || $payload[$field][0]['flag']['key'] !== 'transport-flag') { - throw new RuntimeException('unexpected event payload: ' . $body); - } - if (isset($events[$kind])) throw new RuntimeException('duplicate event batch'); - $events[$kind] = true; - $lastCaptureAt = microtime(true); - } - fwrite($connection, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: " - . strlen($response) . "\r\nConnection: close\r\n\r\n" . $response); - } finally { - fclose($connection); - } - } - if ($exitedAt === null) { - $status = proc_get_status($process); - if (!$status['running']) { - $exitCode = $status['exitcode']; - $exitedAt = microtime(true); - } - } - if ($exitCode !== null && $exitCode !== 0) break; - // The sidecar can finish delivery after PHP exits. Keep receiving - // both streams, then observe a short quiet period for duplicates. - if ($exitedAt !== null && isset($events['exposures'], $events['flagevaluation']) - && microtime(true) - max($exitedAt, $lastCaptureAt) > 0.5) break; - } while (microtime(true) < $deadline); - if ($exitCode !== 0) throw new RuntimeException('child failed: ' . ffeTestProcessRead($pipes[2])); - if (!isset($events['exposures'], $events['flagevaluation'])) { - throw new RuntimeException($mode . ': missing event stream; received ' . implode(', ', array_keys($events)) - . '; child output: ' . ffeTestProcessRead($pipes[1]) . ffeTestProcessRead($pipes[2])); - } - echo $mode, ': both streams delivered with PHP identity and no API key', "\n"; - } finally { - if ($exitCode === null) proc_terminate($process); - foreach ($pipes as $pipe) fclose($pipe); - proc_close($process); - foreach ($clients as $client) fclose($client['stream']); - fclose($server); - } -} +$modes = array('agentless', 'no-key', 'invalid-site', 'remote-config'); +require __DIR__ . '/stubs/agentless_evp_transport.inc'; ?> --EXPECT-- -agentless: both streams delivered with PHP identity and no API key -no-key: both streams delivered with PHP identity and no API key -invalid-site: both streams delivered with PHP identity and no API key -remote-config: both streams delivered with PHP identity and no API key +agentless/false: expected streams delivered with PHP identity, token and no API key +agentless/true: expected streams delivered with PHP identity, token and no API key +no-key/false: expected streams delivered with PHP identity, token and no API key +no-key/true: expected streams delivered with PHP identity, token and no API key +invalid-site/false: expected streams delivered with PHP identity, token and no API key +invalid-site/true: expected streams delivered with PHP identity, token and no API key +remote-config/false: expected streams delivered with PHP identity, token and no API key +remote-config/true: expected streams delivered with PHP identity, token and no API key diff --git a/tests/ext/ffe/agentless_evp_unix.phpt b/tests/ext/ffe/agentless_evp_unix.phpt new file mode 100644 index 0000000000..d090d8fe63 --- /dev/null +++ b/tests/ext/ffe/agentless_evp_unix.phpt @@ -0,0 +1,26 @@ +--TEST-- +FFE agentless events use the configured Unix socket +--SKIPIF-- + +--ENV-- +DD_TRACE_CLI_ENABLED=1 +DD_TRACE_GENERATE_ROOT_SPAN=0 +DD_REMOTE_CONFIG_ENABLED=0 +DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 +DD_METRICS_OTEL_ENABLED=0 +DD_TRACE_AGENT_TIMEOUT=5000 +DD_SERVICE=php-ffe-transport +DD_FLAGGING_EVALUATION_COUNTS_ENABLED=1 +--FILE-- + +--EXPECT-- +trace-agentless-unix/false: expected streams delivered with PHP identity, token and no API key +trace-agentless-unix/true: expected streams delivered with PHP identity, token and no API key diff --git a/tests/ext/ffe/stubs/agentless_evp_app.php b/tests/ext/ffe/stubs/agentless_evp_app.php index b7dac8832d..d7446d8d0f 100644 --- a/tests/ext/ffe/stubs/agentless_evp_app.php +++ b/tests/ext/ffe/stubs/agentless_evp_app.php @@ -1,9 +1,24 @@ getStringDetails('transport-flag', 'fallback', array('targetingKey' => 'transport-user')); -if ($details->getValue() !== 'blue') throw new RuntimeException('evaluation failed'); +// Drain startup configuration before submitting a best-effort observation. +// This is a routing test, not a shutdown or load-shedding test. +dd_trace_synchronous_flush(); +$failed = getenv('PHP_FFE_TRANSPORT_FAILURE') === 'true'; +if (!$failed && !\DDTrace\Testing\ffe_load_config(file_get_contents(__DIR__ . '/agentless_evp_config.json'))) { + throw new RuntimeException('configuration rejected'); +} +foreach (explode(',', getenv('PHP_FFE_TRANSPORT_TOKENS')) as $token) { + ini_set('datadog.trace.agent_test_session_token', $token); + $result = \DDTrace\ffe_evaluate('transport-flag', \DDTrace\FFE_STRING, 'transport-user-' . $token, array()); + if (!$failed && (!$result || $result->valueJson !== '"blue"')) throw new RuntimeException('evaluation failed'); + // Native error code 6 is PROVIDER_NOT_READY. + if ($failed && (\DDTrace\ffe_has_config() || !$result || $result->errorCode !== 6)) { + throw new RuntimeException('expected provider-not-ready evaluation'); + } + if (!\DDTrace\Internal\record_ffe_flag_evaluation( + 'transport-flag', $failed ? null : 'blue', null, 'transport-user-' . $token, array(), + $failed ? 'PROVIDER_NOT_READY' : null, $failed, false, 1700000000123 + )) throw new RuntimeException('evaluation count submission rejected'); + if (!$failed && !\DDTrace\Testing\flush_ffe_exposures()) throw new RuntimeException('exposure handoff failed'); +} dd_trace_synchronous_flush(); echo "delivered\n"; diff --git a/tests/ext/ffe/stubs/agentless_evp_transport.inc b/tests/ext/ffe/stubs/agentless_evp_transport.inc new file mode 100644 index 0000000000..30da4b60ca --- /dev/null +++ b/tests/ext/ffe/stubs/agentless_evp_transport.inc @@ -0,0 +1,117 @@ + $mode === 'remote-config' ? array('/evp_proxy/v2') : array('/evp_proxy/v4'), + 'evp_proxy_allowed_headers' => array('DD-EVP-ORIGIN', 'DD-EVP-ORIGIN-VERSION'))); + } elseif ($path === '/configuration') { + $response = json_encode(array('data' => array('type' => 'universal-flag-configuration', + 'attributes' => json_decode(file_get_contents(__DIR__ . '/agentless_evp_config.json'), true)))); + } elseif (strpos($path, $pathPrefix . '/evp_proxy/') === 0) { + $path = substr($path, strlen($pathPrefix)); + if (strpos($path, $prefix) !== 0) throw new RuntimeException('wrong EVP route: ' . $path); + if ($headers['dd-evp-origin'] !== 'dd-trace-php' || $headers['dd-evp-origin-version'] !== phpversion('ddtrace')) { + throw new RuntimeException('wrong producer identity'); + } + if (!isset($headers['x-datadog-test-session-token']) || !in_array($headers['x-datadog-test-session-token'], $tokens, true)) throw new RuntimeException('wrong test token'); + $kind = substr($path, strlen($prefix)); + if (!in_array($kind, $expected, true)) throw new RuntimeException('unexpected event stream: ' . $kind); + $payload = json_decode($body, true); + $field = $kind === 'exposures' ? 'exposures' : 'flagEvaluations'; + if (!isset($payload[$field]) || count($payload[$field]) !== 1 + || $payload[$field][0]['flag']['key'] !== 'transport-flag') { + throw new RuntimeException('unexpected event payload: ' . $body); + } + if (strpos($mode, 'managed-') === 0 && ($kind !== 'flagevaluation' || empty($payload[$field][0]['runtime_default_used']))) throw new RuntimeException('expected failed-evaluation count'); + $subject = 'transport-user-' . $headers['x-datadog-test-session-token']; + $row = $payload[$field][0]; + if ($kind === 'exposures' ? $row['subject']['id'] !== $subject + : $row['targeting_key'] !== 'sha256_' . hash('sha256', $subject)) { + throw new RuntimeException('event attributed to the wrong test session'); + } + $eventKey = $kind . '/' . $headers['x-datadog-test-session-token']; + if (isset($events[$eventKey])) throw new RuntimeException('duplicate event batch'); + $events[$eventKey] = true; + $lastCaptureAt = microtime(true); + } + fwrite($connection, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: " + . strlen($response) . "\r\nConnection: close\r\n\r\n" . $response); + } finally { + fclose($connection); + } + } + if ($exitedAt === null) { + $status = proc_get_status($process); + if (!$status['running']) { + $exitCode = $status['exitcode']; + $exitedAt = microtime(true); + } + } + if ($exitCode !== null && $exitCode !== 0) break; + // The sidecar can finish delivery after PHP exits. Keep receiving + // both streams, then observe a short quiet period for duplicates. + if ($exitedAt !== null && count($events) === count($expected) * count($tokens) + && microtime(true) - max($exitedAt, $lastCaptureAt) > 0.5) break; + } while (microtime(true) < $deadline); + if ($exitCode !== 0) throw new RuntimeException('child failed: ' . ffeTestProcessRead($pipes[1]) . ffeTestProcessRead($pipes[2])); + if (count($events) !== count($expected) * count($tokens)) { + throw new RuntimeException($mode . ': missing event stream; received ' . implode(', ', array_keys($events)) + . '; child output: ' . ffeTestProcessRead($pipes[1]) . ffeTestProcessRead($pipes[2])); + } + echo $mode, '/', $sender, $mode === 'trace-agentless-invalid' ? ': invalid local endpoint handled' + : ': expected streams delivered with PHP identity, token and no API key', "\n"; + } finally { + if ($exitCode === null) proc_terminate($process); + foreach ($pipes as $pipe) fclose($pipe); + proc_close($process); + foreach ($clients as $client) fclose($client['stream']); + fclose($server); + if ($unix) unlink($socket); + } + } +} +?> diff --git a/tracer/ffe.c b/tracer/ffe.c index 888f651c1a..bc5092b8ca 100644 --- a/tracer/ffe.c +++ b/tracer/ffe.c @@ -267,7 +267,7 @@ bool ddtrace_ffe_remote_config_enabled(void) { } bool ddtrace_ffe_agentless_enabled(void) { - return dd_ffe.cdn_config != NULL; + return dd_ffe.enabled && dd_ffe.source == DDTRACE_FFE_SOURCE_AGENTLESS; } // The in-process thread listener does not fetch from the CDN. A reader of the agentless directory would then not receive any product. From a98560dcdae6ec192d87f20a9be400e14ced0ea0 Mon Sep 17 00:00:00 2001 From: Bob Weinand Date: Sat, 14 Nov 2026 23:19:12 +0100 Subject: [PATCH 5/5] test(ffe): fix agentless EVP transport tests under valgrind and on Windows Valgrind: the sidecar builds its rustls root store lazily inside the first timed /info probe. Under valgrind that alone takes several seconds, so the 5s DD_TRACE_AGENT_TIMEOUT expired, no EVP route was found and the flagevaluation batch was dropped. Raise the child's agent timeout when running under valgrind. Windows: every subprocess sidecar is spawned from the fixed trampoline %TEMP%\datadog-ipc-helper-, with a single ".old" slot for a still-running predecessor. The test's own shared sidecar plus the previous child's lingering per-process sidecar occupy both slots, so the second child failed with ERROR_FILE_EXISTS (os error 80). Give each child a private TMP/TEMP directory. Co-Authored-By: Claude Opus 5.5 --- .../ext/ffe/stubs/agentless_evp_transport.inc | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/ext/ffe/stubs/agentless_evp_transport.inc b/tests/ext/ffe/stubs/agentless_evp_transport.inc index 30da4b60ca..4539d56800 100644 --- a/tests/ext/ffe/stubs/agentless_evp_transport.inc +++ b/tests/ext/ffe/stubs/agentless_evp_transport.inc @@ -28,6 +28,26 @@ foreach ($modes as $mode) { $environment['DD_API_KEY'] = in_array($mode, array('no-key', 'managed-no-key'), true) ? '' : 'must-not-reach-local-proxy'; $environment['DD_SITE'] = ($mode !== 'agentless' && $mode !== 'remote-config' && $mode !== 'no-key' && $mode !== 'managed-no-key') ? 'bad/site' : 'datadoghq.com'; $environment['NO_PROXY'] = '127.0.0.1,localhost'; + // Under valgrind the sidecar builds its TLS root store inside the first + // timed request, which alone can exceed the default 5s agent timeout. + if (getenv('USE_ZEND_ALLOC') === '0') $environment['DD_TRACE_AGENT_TIMEOUT'] = '30000'; + if (PHP_OS === 'WINNT') { + // Windows spawns every sidecar from %TEMP%\datadog-ipc-helper-, + // keeping one ".old" slot for a still-running predecessor. This test's + // own shared sidecar and the previous child's lingering one fill both + // slots, so give each child a private temp directory. + $temp = sys_get_temp_dir() . '\\ffe-evp-' . getmypid() . '-' . $mode . '-' . $sender; + if (!is_dir($temp) && !mkdir($temp)) throw new RuntimeException('cannot create child temp directory'); + // Best effort: a sidecar that is still lingering keeps its executable. + register_shutdown_function(function () use ($temp) { + foreach (glob($temp . '\\*') ?: array() as $file) @unlink($file); + @rmdir($temp); + }); + foreach (array_keys($environment) as $key) { + if (in_array(strtoupper($key), array('TMP', 'TEMP'), true)) unset($environment[$key]); + } + $environment['TMP'] = $environment['TEMP'] = $temp; + } $process = ffeTestProcess(__DIR__ . '/agentless_evp_app.php', $pipes, $environment); $events = array(); $expected = strpos($mode, 'managed-') === 0 ? array('flagevaluation') : array('exposures', 'flagevaluation');