Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions components-rs/datadog.h
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,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,
Expand Down
82 changes: 82 additions & 0 deletions components-rs/tracer_runtime/ffe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<FfeCdnConfig>) {}

/// 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<Box<libdd_common::Endpoint>> {
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;
Expand Down Expand Up @@ -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(
Expand Down
16 changes: 13 additions & 3 deletions docs/feature_flags_agentless.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<DD_SITE>`. 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

Expand Down
54 changes: 47 additions & 7 deletions ext/sidecar.c
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
#include "target_metadata.h"
#include "ffi_utils.h"
#include <tracer/tracer_api.h>
#ifdef TRACER
#include <tracer/ffe.h>
#endif
#ifdef APPSEC
#include <appsec/src/extension/ddappsec_api.h>
#endif
Expand Down Expand Up @@ -176,16 +179,51 @@ 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.
// 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),
};
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));
bool agentless = ddtrace_ffe_agentless_enabled();
Comment thread
leoromanovsky marked this conversation as resolved.
Resolved
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) {
ddog_endpoint_set_test_token_if_changed(direct_endpoint, test_token);
}
// 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);
}
}
#endif

Expand Down Expand Up @@ -259,7 +297,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()) {
Expand Down Expand Up @@ -1036,7 +1075,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)));
Comment thread
leoromanovsky marked this conversation as resolved.
Resolved
}
#endif
}
Expand Down
27 changes: 27 additions & 0 deletions tests/ext/ffe/agentless_evp_managed_failure.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
--TEST--
FFE keeps local EVP discovery when managed CDN setup fails
--SKIPIF--
<?php
if (PHP_VERSION_ID < 70200) die('skip: TEST_PHP_EXTRA_ARGS requires PHP 7.2+');
if (!function_exists('proc_open') || !function_exists('stream_socket_server')) die('skip: subprocess sockets required');
if (!getenv('TEST_PHP_EXECUTABLE')) die('skip: TEST_PHP_EXECUTABLE required');
?>
--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--
<?php
$modes = array('managed-no-key', 'managed-invalid-site');
require __DIR__ . '/stubs/agentless_evp_transport.inc';
?>
--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
31 changes: 31 additions & 0 deletions tests/ext/ffe/agentless_evp_trace_agentless.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
--TEST--
FFE local EVP discovery is independent of trace-agentless mode
--SKIPIF--
<?php
if (PHP_VERSION_ID < 70200) die('skip: TEST_PHP_EXTRA_ARGS requires PHP 7.2+');
if (!function_exists('proc_open') || !function_exists('stream_socket_server')) die('skip: subprocess sockets required');
if (!getenv('TEST_PHP_EXECUTABLE')) die('skip: TEST_PHP_EXECUTABLE required');
?>
--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--
<?php
$modes = array('trace-agentless', 'trace-agentless-prefix', 'trace-agentless-token', 'trace-agentless-invalid');
require __DIR__ . '/stubs/agentless_evp_transport.inc';
?>
--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
31 changes: 31 additions & 0 deletions tests/ext/ffe/agentless_evp_transport.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
--TEST--
FFE agentless discovers a shared local EVP route while Remote Config keeps EVP v2
--SKIPIF--
<?php
if (PHP_VERSION_ID < 70200) die('skip: TEST_PHP_EXTRA_ARGS requires PHP 7.2+');
if (!function_exists('proc_open') || !function_exists('stream_socket_server')) die('skip: subprocess sockets required');
if (!getenv('TEST_PHP_EXECUTABLE')) die('skip: TEST_PHP_EXECUTABLE required');
?>
--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--
<?php
$modes = array('agentless', 'no-key', 'invalid-site', 'remote-config');
require __DIR__ . '/stubs/agentless_evp_transport.inc';
?>
--EXPECT--
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
26 changes: 26 additions & 0 deletions tests/ext/ffe/agentless_evp_unix.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
--TEST--
FFE agentless events use the configured Unix socket
--SKIPIF--
<?php
if (PHP_OS === 'WINNT') die('skip: Unix sockets required');
if (PHP_VERSION_ID < 70200) die('skip: TEST_PHP_EXTRA_ARGS requires PHP 7.2+');
if (!function_exists('proc_open') || !function_exists('stream_socket_server')) die('skip: subprocess sockets required');
if (!getenv('TEST_PHP_EXECUTABLE')) die('skip: TEST_PHP_EXECUTABLE required');
?>
--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--
<?php
$modes = array('trace-agentless-unix');
require __DIR__ . '/stubs/agentless_evp_transport.inc';
?>
--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
24 changes: 24 additions & 0 deletions tests/ext/ffe/stubs/agentless_evp_app.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php
// 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";
17 changes: 17 additions & 0 deletions tests/ext/ffe/stubs/agentless_evp_config.json
Original file line number Diff line number Diff line change
@@ -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": []}]
}]
}
}
}
Loading
Loading