Skip to content

Commit 87f1683

Browse files
leoromanovskymorrisonlevibwoebi
authored
Add FFE evaluation metrics (#3911)
* Emit feature_flag.evaluations through libdatadog sidecar OTLP forwarder Adds the M3 evaluation-metrics layer on top of the hook PR (#3909) as a sibling of the EVP exposures PR (#3910). Records `feature_flag.evaluations` for both PHP 7 (DD Client hook) and PHP 8 (OpenFeature SDK hook); both paths share `EvaluationMetricHook::sharedWriter()` for unified aggregation. OTLP/protobuf payloads are encoded in PHP via the existing `OtlpMetricEncoder` and delivered to the user-configured OTLP HTTP metrics intake through the libdatadog sidecar (`ddog_sidecar_send_ffe_metrics` FFI added in DataDog/libdatadog#2026). This branch is force-pushed (user-authorized one-time exception to the no-force-push rule, 2026-05-23) to restructure history away from being linearly stacked on the M2 exposures PR (#3910). The PR now stacks directly on the hook PR (#3909) as a sibling of the EVP PR. PHP side: - Add `Internal/Metric/EvaluationMetricWriter` with bounded series aggregation, drop accounting, and shutdown flush. - Add `Internal/Metric/EvaluationMetricHook` (DD Client hook) and `OtlpMetricEncoder` (PHP 7-safe protobuf encoding). - Add `Internal/Metric/SidecarOtlpMetricsTransport` that calls `\DDTrace\send_ffe_metrics()` (FFI declared in #3910). Endpoint resolution: `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT`, falling back to `OTEL_EXPORTER_OTLP_ENDPOINT + /v1/metrics`, default `http://localhost:4318/v1/metrics`. - Add `DDTrace\OpenFeature\EvalMetricsHook` implementing `OpenFeature\interfaces\hooks\Hook` (after + error stages), registered on `DataDogProvider` via `setHooks()`. - `DataDogProvider` constructs its internal DD `Client` with `DefaultEvaluationCompletedHook::createWithoutMetric()` so the OpenFeature path records the metric via the OpenFeature hook (PR 3911 scope) and NOT via the DD Client hook — preventing double-counting. PHP 7 path keeps recording via the DD Client hook. - Add `Internal/CompositeEvaluationCompletedHook` and `Internal/DefaultEvaluationCompletedHook` (metric-only composite). This is the merge-conflict point with PR #3910's `[ExposureHook]` composite — second merge resolves by combining both hooks. - Update `Client::create()` to call `DefaultEvaluationCompletedHook::create()`. - Drop the obsolete `testOtlpTransportBuildsHttpProtobufRequest` PHPUnit test (HTTP construction now lives in libdatadog, covered by `cargo test -p datadog-sidecar ffe_metrics_flusher`). - Add `_files_openfeature.php` entry for `EvalMetricsHook.php`. C/Rust bridge: the `\DDTrace\send_ffe_metrics()` native function, its C wrapper `ddtrace_sidecar_send_ffe_metrics()`, and the `ddog_sidecar_send_ffe_metrics` FFI declaration in `components-rs/sidecar.h` were already added in #3910. This PR's branch picks up those changes once #3910 merges (or via the same libdatadog submodule pin during review). For development locally the libdatadog submodule is pinned to the FFE branch tip (`29762335c`). Docs: - Add `docs/php-ffe-stack/{stack,system}-pr3911.{mmd,png}` per the 4-PR documentation convention. Validation: - `php vendor/bin/phpunit --config phpunit.xml tests/api/Unit/FeatureFlags` → 40 tests, 160 assertions, OK. - Mermaid PNGs regenerate via `npx @mermaid-js/mermaid-cli`. `make test_featureflags`, OpenFeature PHPUnit, and ffe-dogfooding end-to-end validation will run in CI / are validated separately by FOLLOW-05 Steps 4–5. * Fix OTLP endpoint resolution for the test-agent / fallback path The SidecarOtlpMetricsTransport::resolveEndpoint() default ("http://localhost:4318/v1/metrics") doesn't match the system-tests parametric setup, where the PHP test client receives DD_AGENT_HOST=<test_agent_container> but no OTEL_EXPORTER_OTLP_METRICS_ENDPOINT. The previous OtlpHttpMetricTransport (replaced by this transport) derived the OTLP endpoint from DD_AGENT_HOST + port 4318. Restore that fallback so system-tests test_php_ffe_evaluation_metric finds the test-agent OTLP intake. Resolution order (now matches the old transport): 1. OTEL_EXPORTER_OTLP_METRICS_ENDPOINT (explicit) 2. OTEL_EXPORTER_OTLP_ENDPOINT + /v1/metrics 3. DD_AGENT_HOST + :4318/v1/metrics 4. localhost:4318/v1/metrics * Add native send_ffe_exposures + send_ffe_metrics C+Rust bridge The C/Rust bridge for the new native PHP functions \DDTrace\send_ffe_exposures() and \DDTrace\send_ffe_metrics() lives on the M2 EVP exposures PR (#3910) because that PR introduced the bridge when refactoring the exposure transport. PR #3911 (this PR) needs the same bridge for its OTLP metrics transport — without it the SidecarOtlpMetricsTransport silently drops batches because function_exists('\\DDTrace\\send_ffe_metrics') is false. Adds the same bridge files here so the M3 branch is independently compilable. At merge time the two PRs will conflict at the file level on these bridge files; resolution is deduplication (the bridge is identical in both PRs by design). Files added/modified: - components-rs/sidecar.h: declares ddog_sidecar_send_ffe_exposures and ddog_sidecar_send_ffe_metrics FFIs. - components-rs/common.h: declares ddog_ByteSlice typedef for the metrics payload. - ext/sidecar.h, ext/sidecar.c: C wrappers ddtrace_sidecar_send_ffe_exposures() and ddtrace_sidecar_send_ffe_metrics(). - ext/ddtrace.stub.php, ext/ddtrace_arginfo.h, ext/ddtrace.c: declares the native PHP functions and the PHP_FUNCTION implementations. * Update libdatadog submodule to pick up FFE dispatch fix Pulls in libdatadog commit `875ec8f0e` ("fix(sidecar): dispatch FFE actions before application-entry check"). Without this fix, the `SidecarOtlpMetricsTransport::send()` call from PHP would silently no-op for short-lived processes: the sidecar received the `FfeMetrics` action but dropped it because the `Entry::Occupied` gate on the application metadata had not yet fired. This unblocks the parametric system-test `Test_Feature_Flag_Parametric_Evaluation_Metrics::test_php_ffe_evaluation_metric` which exercises the full PHP -> sidecar -> OTLP-HTTP-intake path end-to-end. Local result: 26/27 FFE-scoped parametric tests pass (remaining failure is the EVP exposure test, which lives on the M2 PR #3910 branch). * docs(ffe): quote diagram titles, switch system to TD, re-render at high rez Three fixes to the per-PR FFE diagrams: 1. **Titles were truncated to "PHP FFE 4-PR stack — current =" with the PR number missing.** Mermaid uses the YAML frontmatter for the title block, and YAML treats unquoted `#` as the start of a comment, so `title: PHP FFE 4-PR stack — current = #3911 (OTLP metrics)` got parsed as just `PHP FFE 4-PR stack — current =`. Quoting the title keeps the `#PR-number` portion intact. 2. **System diagrams switched from `flowchart LR` to `flowchart TD`.** LR forced the PHP-process / host / backend lanes into a single very-wide row that rendered as an unreadable horizontal strip on PR pages. TD stacks them vertically and keeps the per-lane subgraphs readable. 3. **Re-rendered at 2400×2400 with `--scale 3`** (~1800×2000 stack, ~3000×4500 system) instead of the default ~600px width. PR-page thumbnails render legibly and zoomed-in detail stays sharp. README's regeneration recipe updated with all three knobs and a note on why the `title:` quoting matters. * docs(ffe): drop 'Hook seam' wording, use 'Hook layer' * DataDogProvider: never lose the metric hook when a user calls setHooks() `AbstractProvider::setHooks(array $hooks)` REPLACES the hook list, so registering our `EvalMetricsHook` via `setHooks([$metricsHook])` in the constructor would silently drop our metric emission as soon as a user configures their own provider-level hooks. Override `getHooks()` to always prepend the Datadog metric hook to the caller-supplied list. The user can register their own provider hooks freely (`$provider->setHooks($theirHooks)`) and we still record `feature_flag.evaluations` on every OpenFeature evaluation. Adds a unit test that constructs a DataDogProvider, calls `$provider->setHooks([$userHook])`, then asserts `$provider->getHooks()` returns `[EvalMetricsHook, $userHook]`. * SidecarOtlpMetricsTransport: drop custom DD_AGENT_HOST OTLP fallback `resolveEndpoint()` previously fell back to `http://$DD_AGENT_HOST:4318/v1/metrics` when neither OTel env var was set, and also peeled off `unix://` prefixes and wrapped IPv6 hosts in brackets. That extra path was Datadog-specific and undocumented — it existed to make the parametric system-tests pass without configuring the standard OTel env, and to be "helpful" for callers who set DD_AGENT_HOST but not OTEL_EXPORTER_OTLP_METRICS_ENDPOINT. Strip it. Follow the OpenTelemetry environment-variable spec only: 1. OTEL_EXPORTER_OTLP_METRICS_ENDPOINT — used as-is 2. OTEL_EXPORTER_OTLP_ENDPOINT — append /v1/metrics 3. http://localhost:4318/v1/metrics — OTel spec default If a Datadog Agent's OTLP intake is the target, the caller sets OTEL_EXPORTER_OTLP_METRICS_ENDPOINT explicitly. System-tests parametric metric test still passes: the parametric test client now injects OTEL_EXPORTER_OTLP_METRICS_ENDPOINT pointing at the test-agent OTLP listener (companion change in DataDog/system-tests:leo.romanovsky/pr-g-php-ffe-scaffold). FFE-scoped parametric: 26/27 (only the expected exposure_event failure remains — M3 branch has no exposure code path). * EvaluationMetricWriter: flush-on-full so long-running runtimes don't silently drop When the series map reaches `seriesLimit` (default 1000 unique attribute-sets), the writer was returning false and incrementing `dropped` for every new key. In PHP-FPM/Apache that was harmless because `register_shutdown_function` fires per request and the map empties at the end of each request. In long-running PHP runtimes — Swoole, RoadRunner, FrankenPHP/Octane, CLI worker loops — the shutdown function only fires when the worker process exits, so the series map filled once and then every new unique attribute-set was silently dropped for the rest of the worker's lifetime (hours or days). Flush inline when the cap is hit so the new key fits. Same fix as the parallel commit on the M2 (PR #3910) ExposureWriter. Test `testSeriesOverflowDropsNewSeriesButKeepsExistingSeries` renamed and rewritten to assert the new auto-flush behavior: `droppedCount() === 0` after three records that would have dropped one under the old code; transport receives three batches instead of one. Caught by Codex review on the parallel M2 commit. * chore(ffe): remove generated stack diagrams * fix(ffe): tidy evaluation metrics branch base * chore(ffe): update libdatadog sidecar dependency * Avoid FFE metric stress argument explosion * chore(ffe): update libdatadog sidecar dependency * chore(ffe): update libdatadog sidecar dependency * chore(ffe): update libdatadog sidecar dependency * chore(ffe): update libdatadog sidecar dependency * Fix FFE metric sidecar activation * Update FFE metrics dependency and result ABI * Bump libdatadog metric runtime * Honor Datadog agent config for FFE metrics * Move FFE metric buffering into tracer * Remove FFE evaluation RC polling * test: add unix domain sockets test * Fix FFE metrics OTLP endpoint handling * Fix FFE metrics UDS test sidecar setup * Fix FFE evaluation metrics transport * Fix FFE metrics UDS header assertion * Update supported config metadata for OTLP metrics * Align OTLP endpoint metadata with registry * fix: start sidecar for FFE evaluation metrics * Update libdatadog submodule now that PR has merged * fix: keep FFE config thread-local * Move ffe_metric_buffer to tracer/ * Simplify configuration for otel metrics, make it first class Also build the endpoint fully in rust code for simplicity * Avoid zend_string flip-flopping * fix: support OTEL metrics endpoint config * docs: explain PHP FFE metric recording * Revert "fix: support OTEL metrics endpoint config" This reverts commit c5934af. * Fix missing OTEL_ * Fix compilation Signed-off-by: Bob Weinand <[email protected]> * Fix otel zai config Signed-off-by: Bob Weinand <[email protected]> * Fix tests --------- Signed-off-by: Bob Weinand <[email protected]> Co-authored-by: Levi Morrison <[email protected]> Co-authored-by: Bob Weinand <[email protected]>
1 parent 0699758 commit 87f1683

39 files changed

Lines changed: 1183 additions & 78 deletions

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

appsec/tests/integration/src/test/groovy/com/datadog/appsec/php/integration/Laminas33Tests.groovy

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -82,19 +82,19 @@ class Laminas33Tests {
8282
})
8383

8484
assert endpoints.size() == 26
85-
assert endpoints.find { it.path == '/' && it.method == 'OTHER' && it.operationName == 'http.request' && it.resourceName == '* /' } != null
85+
assert endpoints.find { it.path == '/' && it.method == '*' && it.operationName == 'http.request' && it.resourceName == '* /' } != null
8686
assert endpoints.find {
87-
it.path == '/application[/:action]' && it.method == 'OTHER' && it.operationName == 'http.request' && it.resourceName == '* /application[/:action]'
87+
it.path == '/application[/:action]' && it.method == '*' && it.operationName == 'http.request' && it.resourceName == '* /application[/:action]'
8888
} != null
89-
assert endpoints.find { it.path == '/authenticate' && it.method == 'OTHER' && it.operationName == 'http.request' && it.resourceName == '* /authenticate' } != null
90-
assert endpoints.find { it.path == '/behind-auth' && it.method == 'OTHER' && it.operationName == 'http.request' && it.resourceName == '* /behind-auth' } != null
89+
assert endpoints.find { it.path == '/authenticate' && it.method == '*' && it.operationName == 'http.request' && it.resourceName == '* /authenticate' } != null
90+
assert endpoints.find { it.path == '/behind-auth' && it.method == '*' && it.operationName == 'http.request' && it.resourceName == '* /behind-auth' } != null
9191
assert endpoints.find {
92-
it.path == '/dynamic-path[/:param01]' && it.method == 'OTHER' && it.operationName == 'http.request' && it.resourceName == '* /dynamic-path[/:param01]'
92+
it.path == '/dynamic-path[/:param01]' && it.method == '*' && it.operationName == 'http.request' && it.resourceName == '* /dynamic-path[/:param01]'
9393
} != null
94-
assert endpoints.find { it.path == '/resource' && it.method == 'OTHER' && it.operationName == 'http.request' && it.resourceName == '* /resource' } != null
95-
assert endpoints.find { it.path == '/resource/:resourceId' && it.method == 'OTHER' && it.operationName == 'http.request' && it.resourceName == '* /resource/:resourceId' } != null
96-
assert endpoints.find { it.path == '/resource/:resourceId/:subId' && it.method == 'OTHER' && it.operationName == 'http.request' && it.resourceName == '* /resource/:resourceId/:subId' } != null
97-
assert endpoints.find { it.path == '/chain/:chainId' && it.method == 'OTHER' && it.operationName == 'http.request' && it.resourceName == '* /chain/:chainId' } != null
94+
assert endpoints.find { it.path == '/resource' && it.method == '*' && it.operationName == 'http.request' && it.resourceName == '* /resource' } != null
95+
assert endpoints.find { it.path == '/resource/:resourceId' && it.method == '*' && it.operationName == 'http.request' && it.resourceName == '* /resource/:resourceId' } != null
96+
assert endpoints.find { it.path == '/resource/:resourceId/:subId' && it.method == '*' && it.operationName == 'http.request' && it.resourceName == '* /resource/:resourceId/:subId' } != null
97+
assert endpoints.find { it.path == '/chain/:chainId' && it.method == '*' && it.operationName == 'http.request' && it.resourceName == '* /chain/:chainId' } != null
9898
assert endpoints.find { it.path == '/verb-test' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /verb-test' } != null
9999
assert endpoints.find { it.path == '/verb-test' && it.method == 'POST' && it.operationName == 'http.request' && it.resourceName == 'POST /verb-test' } != null
100100
assert endpoints.find { it.path == '/verb-test' && it.method == 'PUT' && it.operationName == 'http.request' && it.resourceName == 'PUT /verb-test' } != null
@@ -105,24 +105,24 @@ class Laminas33Tests {
105105
assert endpoints.find { it.path == '/multi-verb' && it.method == 'OPTIONS' && it.operationName == 'http.request' && it.resourceName == 'OPTIONS /multi-verb' } != null
106106
assert endpoints.find { it.path == '/multi-verb' && it.method == 'POST' && it.operationName == 'http.request' && it.resourceName == 'POST /multi-verb' } != null
107107
assert endpoints.find { it.path == '/multi-verb' && it.method == 'PUT' && it.operationName == 'http.request' && it.resourceName == 'PUT /multi-verb' } != null
108-
assert endpoints.find { it.path == '/profile' && it.method == 'OTHER' && it.operationName == 'http.request' && it.resourceName == '* /profile' } != null
108+
assert endpoints.find { it.path == '/profile' && it.method == '*' && it.operationName == 'http.request' && it.resourceName == '* /profile' } != null
109109
assert endpoints.find {
110-
it.path == '/regex-year/%year%' && it.method == 'OTHER' && it.operationName == 'http.request' && it.resourceName == '* /regex-year/%year%'
110+
it.path == '/regex-year/%year%' && it.method == '*' && it.operationName == 'http.request' && it.resourceName == '* /regex-year/%year%'
111111
} != null
112112
assert endpoints.find {
113-
it.path == '/scheme-only-page' && it.method == 'OTHER' && it.operationName == 'http.request' && it.resourceName == '* /scheme-only-page'
113+
it.path == '/scheme-only-page' && it.method == '*' && it.operationName == 'http.request' && it.resourceName == '* /scheme-only-page'
114114
} != null
115115
assert endpoints.find {
116-
it.path == '/placeholder-literal' && it.method == 'OTHER' && it.operationName == 'http.request' && it.resourceName == '* /placeholder-literal'
116+
it.path == '/placeholder-literal' && it.method == '*' && it.operationName == 'http.request' && it.resourceName == '* /placeholder-literal'
117117
} != null
118118
assert endpoints.find {
119-
it.path == '/wildcard-keys' && it.method == 'OTHER' && it.operationName == 'http.request' && it.resourceName == '* /wildcard-keys'
119+
it.path == '/wildcard-keys' && it.method == '*' && it.operationName == 'http.request' && it.resourceName == '* /wildcard-keys'
120120
} != null
121121
assert endpoints.find {
122-
it.path == '/wildcard-keys/*' && it.method == 'OTHER' && it.operationName == 'http.request' && it.resourceName == '* /wildcard-keys/*'
122+
it.path == '/wildcard-keys/*' && it.method == '*' && it.operationName == 'http.request' && it.resourceName == '* /wildcard-keys/*'
123123
} != null
124124
assert endpoints.find {
125-
it.path == '/any-verb' && it.method == 'OTHER' && it.operationName == 'http.request' && it.resourceName == '* /any-verb'
125+
it.path == '/any-verb' && it.method == '*' && it.operationName == 'http.request' && it.resourceName == '* /any-verb'
126126
} != null
127127
}
128128

components-rs/common.h

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1231,6 +1231,10 @@ typedef struct ddog_FfeExposure {
12311231
uint64_t timestamp_ms;
12321232
ddog_CharSlice flag_key;
12331233
ddog_CharSlice subject_id;
1234+
/**
1235+
* UTF-8 JSON object. Empty, invalid, or non-object JSON is serialized as
1236+
* an empty subject attribute object.
1237+
*/
12341238
ddog_CharSlice subject_attributes_json;
12351239
ddog_CharSlice allocation_key;
12361240
ddog_CharSlice variant;
@@ -1239,16 +1243,39 @@ typedef struct ddog_FfeExposure {
12391243
typedef struct ddog_Slice_FfeExposure {
12401244
/**
12411245
* Should be non-null and suitably aligned for the underlying type. It is
1242-
* allowed to point to read-only memory if `len` is zero.
1246+
* allowed but not recommended for the pointer to be null when the len is
1247+
* zero.
12431248
*/
12441249
const struct ddog_FfeExposure *ptr;
12451250
/**
12461251
* The number of elements (not bytes) that `.ptr` points to. Must be less
1247-
* than or equal to `isize::MAX`.
1252+
* than or equal to [isize::MAX].
12481253
*/
12491254
uintptr_t len;
12501255
} ddog_Slice_FfeExposure;
12511256

1257+
typedef struct ddog_FfeEvaluationMetric {
1258+
ddog_CharSlice flag_key;
1259+
ddog_CharSlice variant;
1260+
ddog_CharSlice reason;
1261+
ddog_CharSlice error_type;
1262+
ddog_CharSlice allocation_key;
1263+
} ddog_FfeEvaluationMetric;
1264+
1265+
typedef struct ddog_Slice_FfeEvaluationMetric {
1266+
/**
1267+
* Should be non-null and suitably aligned for the underlying type. It is
1268+
* allowed but not recommended for the pointer to be null when the len is
1269+
* zero.
1270+
*/
1271+
const struct ddog_FfeEvaluationMetric *ptr;
1272+
/**
1273+
* The number of elements (not bytes) that `.ptr` points to. Must be less
1274+
* than or equal to [isize::MAX].
1275+
*/
1276+
uintptr_t len;
1277+
} ddog_Slice_FfeEvaluationMetric;
1278+
12521279
/**
12531280
* Holds the raw parts of a Rust Vec; it should only be created from Rust,
12541281
* never from C.

components-rs/datadog.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ void ddtrace_drop_rust_string(char *input, uintptr_t len);
5151

5252
struct ddog_Endpoint *datadog_parse_agent_url(ddog_CharSlice url);
5353

54+
struct ddog_Endpoint *datadog_otel_metrics_endpoint_from_url(ddog_CharSlice url);
55+
56+
struct ddog_Endpoint *datadog_otel_metrics_endpoint_from_agent_url(ddog_CharSlice url);
57+
5458
void datadog_endpoint_as_crashtracker_config(const struct ddog_Endpoint *endpoint,
5559
void (*callback)(ddog_crasht_EndpointConfig, void*),
5660
void *userdata);

components-rs/ffe.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ fn assignment_value_to_json(value: &AssignmentValue) -> String {
276276
#[cfg(test)]
277277
mod tests {
278278
use super::*;
279-
use crate::bytes::ZendString;
279+
use crate::bytes::{OwnedZendString, ZendString};
280280
use std::alloc::{alloc_zeroed, dealloc, Layout};
281281
use std::ffi::CString;
282282
use std::mem;

components-rs/lib.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,17 @@ use http::uri::{PathAndQuery, Scheme};
1818
use http::Uri;
1919
use std::borrow::Cow;
2020
use std::ffi::{c_char, OsStr};
21+
#[cfg(unix)]
22+
use std::path::Path;
2123
use std::ptr::null_mut;
2224
use uuid::Uuid;
2325

2426
pub use libdd_crashtracker_ffi::*;
2527
pub use libdd_library_config_ffi::*;
2628
pub use datadog_sidecar_ffi::*;
2729
use libdd_common::{parse_uri, Endpoint};
30+
#[cfg(unix)]
31+
use libdd_common::connector::uds::socket_path_to_uri;
2832
use libdd_common_ffi::slice::AsBytes;
2933
pub use libdd_common_ffi::*;
3034
pub use libdd_telemetry_ffi::*;
@@ -146,6 +150,52 @@ pub unsafe extern "C" fn datadog_parse_agent_url(
146150
})
147151
}
148152

153+
#[cfg(unix)]
154+
fn otel_metrics_endpoint_from_unix_socket(_socket_path: &str) -> std::option::Option<Box<Endpoint>> {
155+
socket_path_to_uri(Path::new(_socket_path)).ok().and_then(|uri| {
156+
let mut parts = uri.into_parts();
157+
parts.path_and_query = Some(PathAndQuery::from_static("/v1/metrics"));
158+
Uri::from_parts(parts)
159+
.ok()
160+
.map(|url| Box::new(Endpoint::from_url(url)))
161+
})
162+
}
163+
164+
#[no_mangle]
165+
pub unsafe extern "C" fn datadog_otel_metrics_endpoint_from_url(url: CharSlice) -> std::option::Option<Box<Endpoint>> {
166+
let url_str = url.to_utf8_lossy();
167+
#[cfg(unix)]
168+
if let Some(socket_path) = url_str.strip_prefix("unix://") {
169+
let socket_path = socket_path.strip_suffix("/v1/metrics").unwrap_or(socket_path);
170+
return otel_metrics_endpoint_from_unix_socket(socket_path);
171+
}
172+
parse_uri(url_str.as_ref())
173+
.ok()
174+
.map(|url| Box::new(Endpoint::from_url(url)))
175+
}
176+
177+
#[no_mangle]
178+
pub unsafe extern "C" fn datadog_otel_metrics_endpoint_from_agent_url(url: CharSlice) -> std::option::Option<Box<Endpoint>> {
179+
let url_str = url.to_utf8_lossy();
180+
#[cfg(unix)]
181+
if let Some(socket_path) = url_str.strip_prefix("unix://") {
182+
return otel_metrics_endpoint_from_unix_socket(socket_path);
183+
}
184+
if url_str.starts_with("http") {
185+
let parsed = parse_uri(url_str.as_ref()).ok();
186+
let scheme = parsed.as_ref().and_then(|u| u.scheme_str()).unwrap_or("http");
187+
let host = parsed
188+
.as_ref()
189+
.and_then(|u| u.host())
190+
.unwrap_or("localhost");
191+
parse_uri(&format!("{}://{}:4318/v1/metrics", scheme, host))
192+
.ok()
193+
.map(|url| Box::new(Endpoint::from_url(url)))
194+
} else {
195+
datadog_parse_agent_url(url)
196+
}
197+
}
198+
149199
#[no_mangle]
150200
#[cfg(unix)]
151201
pub unsafe extern "C" fn datadog_endpoint_as_crashtracker_config(

components-rs/sidecar.h

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ ddog_MaybeError ddog_sidecar_session_set_config(struct ddog_SidecarTransport **t
195195
ddog_CharSlice session_id,
196196
const struct ddog_Endpoint *agent_endpoint,
197197
const struct ddog_Endpoint *dogstatsd_endpoint,
198+
const struct ddog_Endpoint *otlp_metrics_endpoint,
198199
ddog_CharSlice language,
199200
ddog_CharSlice language_version,
200201
ddog_CharSlice tracer_version,
@@ -314,6 +315,22 @@ ddog_MaybeError ddog_sidecar_send_ffe_exposure_batch(struct ddog_SidecarTranspor
314315
const struct ddog_FfeTelemetryContext *context,
315316
struct ddog_Slice_FfeExposure exposures);
316317

318+
/**
319+
* Send structured FFE evaluation metric events to the sidecar. The sidecar
320+
* owns aggregation, OTLP/protobuf serialization, and OTLP HTTP delivery. This
321+
* function is caller-driven so SDKs with existing host-language hooks can
322+
* safely coexist until they explicitly migrate.
323+
*
324+
* # Safety
325+
* `context` and every element in `metrics` must contain valid UTF-8
326+
* `CharSlice` values. Empty `metrics` is a no-op.
327+
*/
328+
ddog_MaybeError ddog_sidecar_send_ffe_evaluation_metrics(struct ddog_SidecarTransport **transport,
329+
const struct ddog_InstanceId *instance_id,
330+
const ddog_QueueId *queue_id,
331+
const struct ddog_FfeTelemetryContext *context,
332+
struct ddog_Slice_FfeEvaluationMetric metrics);
333+
317334
ddog_MaybeError ddog_sidecar_send_debugger_diagnostics(struct ddog_SidecarTransport **transport,
318335
const struct ddog_InstanceId *instance_id,
319336
ddog_QueueId queue_id,

ext/configuration.c

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
#include "logging.h"
77
#include <json/json.h>
88
#include "sidecar.h"
9+
#include "otel_config.h"
910
#include <components/log/log.h>
1011
#include <zai_string/string.h>
1112

@@ -127,6 +128,9 @@ static void dd_ini_env_to_ini_name(const zai_str env_name, zai_config_name *ini_
127128
} else if (env_name.ptr == strstr(env_name.ptr, "DD_DYNAMIC_INSTRUMENTATION_")) {
128129
ini_name->ptr[sizeof("datadog.dynamic_instrumentation") - 1] = '.';
129130
}
131+
} else if (env_name.ptr == strstr(env_name.ptr, "OTEL_")) {
132+
ini_name->len = env_name.len;
133+
memcpy(ini_name->ptr, env_name.ptr, env_name.len);
130134
} else {
131135
ini_name->len = 0;
132136
assert(false && "Unexpected env var name: missing 'DD_' prefix");

ext/configuration.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ enum datadog_sidecar_connection_mode {
8787
CONFIG(BOOL, DD_TRACE_AGENTLESS, "false", .ini_change = zai_config_system_ini_change) \
8888
CONFIG(STRING, DD_VERSION, "", .ini_change = datadog_alter_dd_version, \
8989
.env_config_fallback = ddtrace_conf_otel_resource_attributes_version) \
90+
CONFIG(STRING, OTEL_EXPORTER_OTLP_METRICS_ENDPOINT, "", \
91+
.ini_change = zai_config_system_ini_change, \
92+
.env_config_fallback = ddtrace_conf_otel_otlp_endpoint) \
9093
CONFIG(INT, DD_TRACE_BUFFER_SIZE, "2097152", .ini_change = zai_config_system_ini_change) \
9194
CONFIG(INT, DD_TRACE_AGENT_MAX_PAYLOAD_SIZE, "52428800", .ini_change = zai_config_system_ini_change) \
9295
CONFIG(INT, DD_TRACE_AGENT_STACK_BACKLOG, "12", .ini_change = zai_config_system_ini_change) \

ext/datadog.c

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ static void dd_activate_once(void) {
158158

159159
// must run before the first zai_hook_activate as tracer telemetry setup installs a global hook
160160
if (!datadog_disable) {
161-
// Only set up the sidecar when it's actually needed (appsec, telemetry, or trace sender).
161+
// Only set up the sidecar when it's actually needed (appsec, telemetry, trace sender, or OTLP metrics).
162162
ddog_RemoteConfigFlags flags = {0};
163163
bool enable_sidecar = datadog_sidecar_should_enable(&flags);
164164
if (enable_sidecar) {

0 commit comments

Comments
 (0)