Skip to content

Commit e74b050

Browse files
committed
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.
1 parent 49d53ef commit e74b050

19 files changed

Lines changed: 1373 additions & 6 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
title: PHP FFE 4-PR stack — current = #3911 (OTLP metrics)
3+
---
4+
flowchart TD
5+
pr3906["PR #3906 — M1 Evaluations<br/>libdatadog FFE + Remote Config + PHP 7/8 APIs"]:::done
6+
pr3909["PR #3909 — Hook<br/>EvaluationCompleted envelope + Hook interface +<br/>Client.evaluate callback wiring"]:::done
7+
pr3910["PR #3910 — EVP exposures<br/>ExposureHook + SidecarExposureTransport<br/>→ libdatadog sidecar → Agent EVP proxy"]:::future
8+
pr3911["PR #3911 — Metrics (this PR)<br/>EvaluationMetricHook + OpenFeature EvalMetricsHook<br/>+ SidecarOtlpMetricsTransport<br/>→ ddog_sidecar_send_ffe_metrics<br/>→ libdatadog sidecar → OTLP HTTP intake"]:::current
9+
10+
pr3906 --> pr3909
11+
pr3909 --> pr3910
12+
pr3909 --> pr3911
13+
14+
classDef done fill:#bbf7d0,stroke:#16a34a,color:#052e16
15+
classDef current fill:#ddd6fe,stroke:#6d28d9,color:#1e1b4b,stroke-width:3px
16+
classDef future fill:#e5e7eb,stroke:#6b7280,color:#374151,stroke-dasharray: 5 5
68.6 KB
Loading
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
title: PHP FFE system architecture — current = #3911 (OTLP metrics)
3+
---
4+
flowchart LR
5+
subgraph PHPProc["PHP request worker (FPM / CLI / Apache)"]
6+
UserCode["User code"]
7+
OFClient["OpenFeature Client<br/>(PHP 8 path)"]
8+
DDClient["DDTrace FeatureFlags Client<br/>(PHP 7 and PHP 8 share this)"]
9+
DDProvider["DDTrace OpenFeature DataDogProvider<br/>(PHP 8 only)"]
10+
Evaluator["NativeEvaluator (FFI → libdatadog)"]
11+
RC["Remote Config client (FFE_FLAGS product)"]
12+
Hook["EvaluationCompletedHook composite"]
13+
OFHook["OpenFeature SDK Hook layer<br/>(EvalMetricsHook on DataDogProvider) — THIS PR"]
14+
ExposureWriter["ExposureWriter (future)"]
15+
MetricWriter["EvaluationMetricWriter<br/>(aggregate, OTLP protobuf encode) — THIS PR"]
16+
SidecarFFI["ddog_sidecar_send_ffe_metrics FFI — THIS PR"]
17+
end
18+
19+
subgraph Host["Host"]
20+
Sidecar["libdatadog sidecar (Rust, async) — THIS PR"]
21+
end
22+
23+
subgraph Backend["Backend"]
24+
Agent["Datadog Agent (future)"]
25+
EVP["EVP proxy (future)"]
26+
OTLP["OTLP HTTP intake — THIS PR"]
27+
end
28+
29+
UserCode -- "PHP 7" --> DDClient
30+
UserCode -- "PHP 8" --> OFClient --> DDProvider --> DDClient
31+
DDClient --> Evaluator
32+
Evaluator <--> RC
33+
DDClient --> Hook
34+
Hook --> ExposureWriter
35+
Hook --> MetricWriter
36+
DDProvider -. registers .-> OFHook
37+
OFHook --> MetricWriter
38+
OFHook --> ExposureWriter
39+
ExposureWriter --> SidecarFFI
40+
MetricWriter -- "OTLP protobuf" --> SidecarFFI
41+
SidecarFFI --> Sidecar
42+
Sidecar --> Agent --> EVP
43+
Sidecar -- "OTLP HTTP" --> OTLP
44+
45+
classDef pr1 fill:#bbf7d0,stroke:#16a34a,color:#052e16
46+
classDef pr2 fill:#bfdbfe,stroke:#1d4ed8,color:#0c1f5b
47+
classDef pr4 fill:#ddd6fe,stroke:#6d28d9,color:#1e1b4b,stroke-width:3px
48+
classDef future fill:#e5e7eb,stroke:#9ca3af,color:#374151,stroke-dasharray: 5 5
49+
50+
class UserCode,OFClient,DDClient,DDProvider,Evaluator,RC pr1
51+
class Hook pr2
52+
class MetricWriter,OFHook,SidecarFFI,Sidecar,OTLP pr4
53+
class ExposureWriter,Agent,EVP future
20.3 KB
Loading

src/DDTrace/OpenFeature/DataDogProvider.php

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
use DDTrace\FeatureFlags\EvaluationDetails;
99
use DDTrace\FeatureFlags\EvaluationErrorCode;
1010
use DDTrace\FeatureFlags\EvaluationReason;
11+
use DDTrace\FeatureFlags\Internal\DefaultEvaluationCompletedHook;
12+
use DDTrace\FeatureFlags\Internal\Metric\EvaluationMetricHook;
1113
use DDTrace\FeatureFlags\Internal\NoopWarningEmitter;
1214
use DDTrace\FeatureFlags\Internal\TriggerErrorWarningEmitter;
1315
use DDTrace\FeatureFlags\Internal\WarningEmitter;
@@ -27,19 +29,55 @@ final class DataDogProvider extends AbstractProvider
2729
private FeatureFlagsClient $client;
2830
private WarningEmitter $warningEmitter;
2931
private bool $warnedAboutNonProductionRuntime = false;
32+
private ?EvaluationDetails $lastEvaluationDetails = null;
3033

3134
public function __construct()
3235
{
33-
$this->client = FeatureFlagsClient::createWithDependencies(null, new NoopWarningEmitter());
36+
// PHP 8 OpenFeature path records `feature_flag.evaluations` via an
37+
// OpenFeature `after`/`error` hook (mirroring dd-trace-go/js/java/dotnet
38+
// architecture). Construct the DD client with an exposure-only hook
39+
// composite so the metric is not also recorded inside Client::evaluate()
40+
// and double-counted.
41+
$this->client = FeatureFlagsClient::createWithDependencies(
42+
null,
43+
new NoopWarningEmitter(),
44+
DefaultEvaluationCompletedHook::createWithoutMetric()
45+
);
3446
$this->warningEmitter = new TriggerErrorWarningEmitter();
47+
48+
$provider = $this;
49+
$this->setHooks([
50+
new EvalMetricsHook(
51+
EvaluationMetricHook::sharedWriter(),
52+
function () use ($provider) {
53+
return $provider->consumeLastEvaluationDetails();
54+
}
55+
),
56+
]);
57+
}
58+
59+
/**
60+
* @internal Datadog-owned bridge adapters only.
61+
*
62+
* Returns the most recent DD-side `EvaluationDetails` produced by this
63+
* provider during the current OpenFeature evaluation, then clears the
64+
* stash. Returns `null` if the provider was not invoked for this
65+
* evaluation (e.g. a `before` hook threw before `resolve*Value`).
66+
*/
67+
public function consumeLastEvaluationDetails(): ?EvaluationDetails
68+
{
69+
$details = $this->lastEvaluationDetails;
70+
$this->lastEvaluationDetails = null;
71+
return $details;
3572
}
3673

3774
/**
3875
* @internal Tests and Datadog-owned bridge adapters only.
3976
*/
4077
public static function createWithDependencies(
4178
?FeatureFlagsClient $client = null,
42-
?WarningEmitter $warningEmitter = null
79+
?WarningEmitter $warningEmitter = null,
80+
$metricWriter = null
4381
): self {
4482
$provider = new self();
4583
if ($client !== null) {
@@ -48,6 +86,16 @@ public static function createWithDependencies(
4886
if ($warningEmitter !== null) {
4987
$provider->warningEmitter = $warningEmitter;
5088
}
89+
if ($metricWriter !== null) {
90+
$provider->setHooks([
91+
new EvalMetricsHook(
92+
$metricWriter,
93+
function () use ($provider) {
94+
return $provider->consumeLastEvaluationDetails();
95+
}
96+
),
97+
]);
98+
}
5199

52100
return $provider;
53101
}
@@ -101,7 +149,11 @@ private function resolve(
101149
mixed $defaultValue,
102150
?EvaluationContext $context
103151
): ResolutionDetailsInterface {
152+
// Clear any stale stash so a throw from inside evaluate() leaves it null
153+
// and the OpenFeature `error` hook sees no leftover details from a prior call.
154+
$this->lastEvaluationDetails = null;
104155
$details = $this->evaluate($flagKey, $expectedType, $defaultValue, $this->normalizeContext($context));
156+
$this->lastEvaluationDetails = $details;
105157
$this->warnIfNonProductionRuntime($details);
106158

107159
$builder = (new ResolutionDetailsBuilder())
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace DDTrace\OpenFeature;
6+
7+
use DDTrace\FeatureFlags\EvaluationDetails;
8+
use DDTrace\FeatureFlags\Internal\Metric\EvaluationMetricWriter;
9+
use OpenFeature\interfaces\flags\EvaluationContext;
10+
use OpenFeature\interfaces\hooks\Hook;
11+
use OpenFeature\interfaces\hooks\HookContext;
12+
use OpenFeature\interfaces\hooks\HookHints;
13+
use OpenFeature\interfaces\provider\ResolutionDetails;
14+
use OpenFeature\interfaces\provider\ThrowableWithResolutionError;
15+
use Throwable;
16+
17+
/**
18+
* OpenFeature `Hook` that records `feature_flag.evaluations` for PHP 8 OpenFeature
19+
* users. Matches the architectural pattern used by dd-trace-go, dd-trace-js,
20+
* dd-trace-java, and dd-trace-dotnet, where metrics are emitted via the
21+
* OpenFeature SDK's hook lifecycle.
22+
*
23+
* PHP 7 callers using `DDTrace\FeatureFlags\Client` directly continue to
24+
* record metrics via the internal `EvaluationCompletedHook` composite.
25+
* To avoid double-counting on the PHP 8 path, `DataDogProvider` constructs
26+
* its `Client` with `DefaultEvaluationCompletedHook::createWithoutMetric()`
27+
* (exposure-only), so the metric is recorded here instead.
28+
*
29+
* Allocation key is not carried in OpenFeature `ResolutionDetails` in the
30+
* PHP SDK (no `flagMetadata`). The provider stashes the most recent
31+
* `EvaluationDetails` and exposes it via a supplier callable so the hook
32+
* can read the allocation key when present. Single-threaded request flow
33+
* makes the stash safe.
34+
*/
35+
final class EvalMetricsHook implements Hook
36+
{
37+
/** @var ?EvaluationMetricWriter */
38+
private $writer;
39+
40+
/** @var callable():?EvaluationDetails */
41+
private $detailsSource;
42+
43+
/**
44+
* @param ?EvaluationMetricWriter $writer Writer used to record metrics. `null` disables recording.
45+
* @param callable():?EvaluationDetails $detailsSource Supplier returning the last DD-side evaluation
46+
* result for the current call, or null if no DD-side evaluation occurred (e.g. a `before`
47+
* hook threw before the provider was invoked).
48+
*/
49+
public function __construct($writer, callable $detailsSource)
50+
{
51+
if ($writer !== null && !$writer instanceof EvaluationMetricWriter) {
52+
throw new \InvalidArgumentException('Expected an EvaluationMetricWriter instance or null');
53+
}
54+
$this->writer = $writer;
55+
$this->detailsSource = $detailsSource;
56+
}
57+
58+
public function before(HookContext $context, HookHints $hints): ?EvaluationContext
59+
{
60+
return null;
61+
}
62+
63+
public function after(HookContext $context, ResolutionDetails $details, HookHints $hints): void
64+
{
65+
$reason = $details->getReason();
66+
$variant = $details->getVariant();
67+
$error = $details->getError();
68+
$errorCode = $error !== null ? (string) $error->getResolutionErrorCode() : null;
69+
70+
$this->record($context->getFlagKey(), $variant, $reason, $errorCode);
71+
}
72+
73+
public function error(HookContext $context, Throwable $error, HookHints $hints): void
74+
{
75+
$errorCode = $error instanceof ThrowableWithResolutionError
76+
? (string) $error->getResolutionError()->getResolutionErrorCode()
77+
: 'GENERAL';
78+
79+
$this->record($context->getFlagKey(), null, 'ERROR', $errorCode);
80+
}
81+
82+
public function finally(HookContext $context, HookHints $hints): void
83+
{
84+
// Metric recording happens in after/error so that the resolution outcome
85+
// is available. No work needed here.
86+
}
87+
88+
public function supportsFlagValueType(string $flagValueType): bool
89+
{
90+
return true;
91+
}
92+
93+
private function record(string $flagKey, $variant, $reason, $errorCode): void
94+
{
95+
if ($this->writer === null) {
96+
return;
97+
}
98+
99+
$allocationKey = null;
100+
$supplier = $this->detailsSource;
101+
$details = $supplier();
102+
if ($details instanceof EvaluationDetails) {
103+
$exposure = $details->getExposureData();
104+
if (is_array($exposure) && isset($exposure['allocationKey']) && is_string($exposure['allocationKey']) && $exposure['allocationKey'] !== '') {
105+
$allocationKey = $exposure['allocationKey'];
106+
}
107+
}
108+
109+
try {
110+
$this->writer->recordAttributes($flagKey, $variant, $reason, $errorCode, $allocationKey);
111+
} catch (Throwable $throwable) {
112+
// Metric recording must never affect flag evaluation.
113+
}
114+
}
115+
}

src/api/FeatureFlags/Client.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace DDTrace\FeatureFlags;
44

5+
use DDTrace\FeatureFlags\Internal\DefaultEvaluationCompletedHook;
56
use DDTrace\FeatureFlags\Internal\Evaluator;
67
use DDTrace\FeatureFlags\Internal\EvaluationCompleted;
78
use DDTrace\FeatureFlags\Internal\EvaluationCompletedHook;
@@ -29,7 +30,7 @@ private function __construct(
2930

3031
public static function create()
3132
{
32-
return self::createWithDependencies();
33+
return self::createWithDependencies(null, null, DefaultEvaluationCompletedHook::create());
3334
}
3435

3536
/**
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
namespace DDTrace\FeatureFlags\Internal;
4+
5+
final class CompositeEvaluationCompletedHook implements EvaluationCompletedHook
6+
{
7+
private $hooks;
8+
9+
public function __construct(array $hooks)
10+
{
11+
foreach ($hooks as $hook) {
12+
if (!$hook instanceof EvaluationCompletedHook) {
13+
throw new \InvalidArgumentException('Expected EvaluationCompletedHook instances');
14+
}
15+
}
16+
17+
$this->hooks = $hooks;
18+
}
19+
20+
public function evaluationCompleted(EvaluationCompleted $evaluation)
21+
{
22+
foreach ($this->hooks as $hook) {
23+
try {
24+
$hook->evaluationCompleted($evaluation);
25+
} catch (\Throwable $throwable) {
26+
// One internal hook must not prevent later hooks from observing the evaluation.
27+
}
28+
}
29+
}
30+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
namespace DDTrace\FeatureFlags\Internal;
4+
5+
use DDTrace\FeatureFlags\Internal\Metric\EvaluationMetricHook;
6+
7+
/**
8+
* @internal Datadog-owned bridge adapters only.
9+
*
10+
* Returns the composite of internal hooks installed for production callers of
11+
* `DDTrace\FeatureFlags\Client::create()`.
12+
*
13+
* This file is the merge-conflict point between the EVP exposures PR (#3910)
14+
* and this OTLP metrics PR (#3911). Each PR contributes one hook to the
15+
* composite. Today (PR #3911 scope): the composite contains only
16+
* `EvaluationMetricHook`. The second PR to merge resolves the conflict by
17+
* combining both `ExposureHook` and `EvaluationMetricHook` in the composite.
18+
*
19+
* The `createWithoutMetric()` factory is used by `DDTrace\OpenFeature\DataDogProvider`:
20+
* the OpenFeature path records `feature_flag.evaluations` via an
21+
* OpenFeature SDK `Hook` (`EvalMetricsHook`) rather than through the DD
22+
* Client hook, so the DD Client composite for the OpenFeature path
23+
* intentionally excludes the metric hook to avoid double-counting.
24+
*/
25+
final class DefaultEvaluationCompletedHook
26+
{
27+
private function __construct()
28+
{
29+
}
30+
31+
public static function create()
32+
{
33+
return new CompositeEvaluationCompletedHook(array(
34+
EvaluationMetricHook::createDefault(),
35+
));
36+
}
37+
38+
/**
39+
* @internal Datadog-owned bridge adapters only.
40+
*
41+
* Composite used by the PHP 8 OpenFeature `DataDogProvider`. Excludes
42+
* the metric hook because the OpenFeature SDK Hook layer
43+
* (`EvalMetricsHook`) records the metric for that path.
44+
*/
45+
public static function createWithoutMetric()
46+
{
47+
return new CompositeEvaluationCompletedHook(array());
48+
}
49+
}

0 commit comments

Comments
 (0)