Skip to content
Open
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
2 changes: 2 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,5 @@ tools/sidecar_mockgen/ @DataDog/libdatadog-php
libdd-data-pipeline/src/otlp/ @DataDog/apm-sdk-capabilities-rust
libdd-data-pipeline/tests/test_trace_exporter_otlp_export.rs @DataDog/apm-sdk-capabilities-rust
libdd-trace-utils/src/otlp_encoder/ @DataDog/apm-sdk-capabilities-rust
datadog-sidecar/src/service/ffe_exposures_flusher.rs @DataDog/libdatadog-php @DataDog/libdatadog-apm @DataDog/feature-flagging-and-experimentation-sdk
datadog-sidecar/src/service/ffe_metrics_flusher.rs @DataDog/libdatadog-php @DataDog/libdatadog-apm @DataDog/feature-flagging-and-experimentation-sdk
69 changes: 68 additions & 1 deletion datadog-sidecar-ffi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ use datadog_sidecar::shm_remote_config::{path_for_remote_config, RemoteConfigRea
use libc::c_char;
use libdd_common::tag::Tag;
use libdd_common::Endpoint;
use libdd_common_ffi::slice::{AsBytes, CharSlice};
use libdd_common_ffi::slice::{AsBytes, ByteSlice, CharSlice};
use libdd_common_ffi::{self as ffi, MaybeError};
#[cfg(windows)]
use libdd_crashtracker_ffi::Metadata;
Expand Down Expand Up @@ -1116,6 +1116,73 @@ pub unsafe extern "C" fn ddog_sidecar_send_debugger_datum(
ddog_sidecar_send_debugger_data(transport, instance_id, queue_id, vec![*payload])
}

/// Forward a single FFE (Feature Flag Evaluation) exposure batch payload to
/// the sidecar. The sidecar asynchronously POSTs it to the agent EVP proxy
/// at `/evp_proxy/v2/api/v2/exposures`.
///
/// The payload is produced by `ddog_ffe_flush_exposures()` in `components-rs`.
/// A null or zero-length slice is a no-op (the PHP side indicates "nothing to
/// flush" by returning such a slice).
///
/// # Safety
/// `payload` must be a valid UTF-8 `CharSlice` (as returned by
/// `ddog_ffe_flush_exposures`) or a default (null, 0) slice.
#[no_mangle]
#[allow(clippy::missing_safety_doc)]
pub unsafe extern "C" fn ddog_sidecar_send_ffe_exposures(
transport: &mut Box<SidecarTransport>,
instance_id: &InstanceId,
queue_id: &QueueId,
payload: CharSlice,
) -> MaybeError {
if payload.is_empty() {
return MaybeError::None;
}
let payload = payload.to_utf8_lossy().into_owned();
try_c!(blocking::enqueue_actions(
transport,
instance_id,
queue_id,
vec![SidecarAction::FfeExposures(payload)],
));
MaybeError::None
}

/// Forward a single FFE (Feature Flag Evaluation) metrics batch payload to
/// the sidecar. The sidecar asynchronously POSTs the OTLP/protobuf bytes to
/// the OTLP HTTP metrics intake at the given `endpoint` URL (typically the
/// value of `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT`, default
/// `http://localhost:4318/v1/metrics`).
///
/// The PHP-side `OtlpMetricEncoder` produces `payload`. A null/empty payload
/// or an empty endpoint is a no-op.
///
/// # Safety
/// `endpoint` must be a valid UTF-8 `CharSlice`. `payload` must be a valid
/// `ByteSlice` (as returned by the PHP encoder).
#[no_mangle]
#[allow(clippy::missing_safety_doc)]
pub unsafe extern "C" fn ddog_sidecar_send_ffe_metrics(
transport: &mut Box<SidecarTransport>,
instance_id: &InstanceId,
queue_id: &QueueId,
endpoint: CharSlice,
payload: ByteSlice,
) -> MaybeError {
if endpoint.is_empty() || payload.is_empty() {
return MaybeError::None;
}
let endpoint = endpoint.to_utf8_lossy().into_owned();
let payload = payload.as_slice().to_vec();
try_c!(blocking::enqueue_actions(
transport,
instance_id,
queue_id,
vec![SidecarAction::FfeMetrics { endpoint, payload }],
));
MaybeError::None
}

#[no_mangle]
#[allow(clippy::missing_safety_doc)]
#[allow(improper_ctypes_definitions)] // DebuggerPayload is just a pointer, we hide its internals
Expand Down
164 changes: 164 additions & 0 deletions datadog-sidecar/src/service/ffe_exposures_flusher.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/
// SPDX-License-Identifier: Apache-2.0

//! Forwards FFE (Feature Flag Evaluation) exposure payloads from the PHP tracer
//! to the Datadog Agent's EVP proxy.
//!
//! Protocol matches dd-trace-go / dd-trace-rb / dd-trace-py / dd-trace-js /
//! dd-trace-dotnet: `POST /evp_proxy/v2/api/v2/exposures` with the header
//! `X-Datadog-EVP-Subdomain: event-platform-intake`. No agent capability gate.

use http::uri::PathAndQuery;
use http::Method;
use libdd_capabilities::Bytes;
use libdd_capabilities_impl::HttpClientCapability;
use libdd_common::Endpoint;
use tracing::{debug, warn};

/// EVP proxy path for FFE exposure intake.
pub(crate) const EVP_EXPOSURES_PATH: &str = "/evp_proxy/v2/api/v2/exposures";

/// EVP subdomain that routes requests to event-platform intake.
pub(crate) const EVP_SUBDOMAIN_HEADER: &str = "X-Datadog-EVP-Subdomain";
pub(crate) const EVP_SUBDOMAIN_VALUE: &str = "event-platform-intake";

const USER_AGENT: &str = concat!("ddtrace-php-sidecar/", env!("CARGO_PKG_VERSION"));

/// Build the FFE exposure endpoint from a session's agent base endpoint.
/// Overrides only the path (`/evp_proxy/v2/api/v2/exposures`), preserving
/// scheme, authority, api_key (agentless), timeout, and test_token.
pub(crate) fn exposure_endpoint(base: &Endpoint) -> Option<Endpoint> {
let mut parts = base.url.clone().into_parts();
parts.path_and_query = Some(PathAndQuery::from_static(EVP_EXPOSURES_PATH));
let url = http::Uri::from_parts(parts).ok()?;
Some(Endpoint {
url,
..base.clone()
})
}

/// POST a single FFE exposure payload to the agent EVP proxy.
/// Fire-and-forget: non-2xx responses and network errors are logged at `debug`
/// and dropped (matches dd-trace-go behaviour).
pub(crate) async fn send_payload<C: HttpClientCapability>(
client: &C,
endpoint: &Endpoint,
payload: String,
) {
let builder = match endpoint.to_request_builder(USER_AGENT) {
Ok(b) => b,
Err(e) => {
debug!("ffe_exposures_flusher: failed to build request: {e:?}");
return;
}
};

let req = match builder
.method(Method::POST)
.header("Content-Type", "application/json")
.header(EVP_SUBDOMAIN_HEADER, EVP_SUBDOMAIN_VALUE)
.body(Bytes::from(payload))
{
Ok(r) => r,
Err(e) => {
debug!("ffe_exposures_flusher: failed to construct request body: {e:?}");
return;
}
};

match client.request(req).await {
Ok(resp) => {
let status = resp.status();
if !status.is_success() {
// dd-trace-go logs a readable error body on non-2xx.
let body_preview = truncate(resp.body().as_ref(), 256);
warn!("ffe_exposures_flusher: non-2xx response {status}: {body_preview}");
} else {
debug!("ffe_exposures_flusher: sent exposure batch, status={status}");
}
}
Err(e) => {
debug!("ffe_exposures_flusher: request failed: {e:?}");
}
}
}

fn truncate(bytes: &[u8], cap: usize) -> String {
let take = bytes.len().min(cap);
String::from_utf8_lossy(&bytes[..take]).into_owned()
}

#[cfg(test)]
mod tests {
use super::*;
use httpmock::MockServer;
use libdd_capabilities_impl::NativeCapabilities;

fn endpoint_for(server: &MockServer) -> Endpoint {
Endpoint {
url: server.url("/").parse().unwrap(),
..Endpoint::default()
}
}

/// V3: POST hits `/evp_proxy/v2/api/v2/exposures` with the correct
/// subdomain header and application/json content type. Body round-trips.
#[tokio::test]
#[cfg_attr(miri, ignore)]
async fn posts_to_evp_proxy() {
let server = MockServer::start_async().await;
let mock = server
.mock_async(|when, then| {
when.method(httpmock::Method::POST)
.path(EVP_EXPOSURES_PATH)
.header(EVP_SUBDOMAIN_HEADER, EVP_SUBDOMAIN_VALUE)
.header("content-type", "application/json");
then.status(202);
})
.await;

let base = endpoint_for(&server);
let ep = exposure_endpoint(&base).unwrap();
let client = NativeCapabilities::new_client();

let payload =
r#"{"context":{"service":"svc","env":"prod","version":"1"},"exposures":[]}"#.to_owned();
send_payload(&client, &ep, payload.clone()).await;

mock.assert_async().await;

// Verify the endpoint was hit exactly once.
let calls = mock.calls_async().await;
assert_eq!(calls, 1);
}

/// Non-2xx responses are logged and dropped; no panic, no retry.
#[tokio::test]
#[cfg_attr(miri, ignore)]
async fn non_2xx_does_not_panic() {
let server = MockServer::start_async().await;
let _mock = server
.mock_async(|when, then| {
when.method(httpmock::Method::POST).path(EVP_EXPOSURES_PATH);
then.status(500).body("intake overloaded");
})
.await;

let base = endpoint_for(&server);
let ep = exposure_endpoint(&base).unwrap();
let client = NativeCapabilities::new_client();
send_payload(&client, &ep, "{}".to_owned()).await;
}

#[test]
fn endpoint_preserves_authority_overrides_path() {
let base = Endpoint {
url: "http://agent.internal:8126/v0.4/traces".parse().unwrap(),
..Endpoint::default()
};
let ep = exposure_endpoint(&base).unwrap();
assert_eq!(ep.url.scheme_str(), Some("http"));
assert_eq!(ep.url.authority().unwrap().as_str(), "agent.internal:8126");
assert_eq!(ep.url.path(), EVP_EXPOSURES_PATH);
}
}
Loading
Loading