From 89acc732ca69dc9a0596a196ab1feadfbf9b41a1 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 15 Oct 2026 16:24:35 -0400 Subject: [PATCH 1/4] feat(openfeature): add per-subject evaluation telemetry opt-out --- README.md | 24 ++ src/DDTrace/OpenFeature/DataDogProvider.php | 28 +- .../Internal/EvaluationTelemetry.php | 16 + .../FeatureFlags/Internal/NativeEvaluator.php | 22 +- .../EvaluationTelemetryOptOutTest.php | 407 ++++++++++++++++++ ...valuation_telemetry_opt_out_not_ready.phpt | 41 ++ tracer/functions.c | 5 + 7 files changed, 531 insertions(+), 12 deletions(-) create mode 100644 src/api/FeatureFlags/Internal/EvaluationTelemetry.php create mode 100644 tests/OpenFeature/EvaluationTelemetryOptOutTest.php create mode 100644 tests/OpenFeature/evaluation_telemetry_opt_out_not_ready.phpt diff --git a/README.md b/README.md index 1e2ffca1a5e..14e102483a1 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,30 @@ For more information about configuring and instrumenting **ddtrace**, view the [ The **ddtrace** package provides an [OpenTracing-compatible tracer](https://docs.datadoghq.com/tracing/custom_instrumentation/php/?tab=tracingfunctioncalls#opentracing). +### OpenFeature evaluation telemetry opt-out + +The Datadog OpenFeature provider can suppress evaluation telemetry for individual subjects. This option is disabled by default. +Enable the provider option, then set `dd_evaluation_telemetry_opt_out` to boolean `true` in the evaluation context: + +```php +use DDTrace\OpenFeature\DataDogProvider; +use OpenFeature\implementation\flags\Attributes; +use OpenFeature\implementation\flags\EvaluationContext; +use OpenFeature\OpenFeatureAPI; + +$api = OpenFeatureAPI::getInstance(); +$api->setProvider(new DataDogProvider(enableEvaluationTelemetryOptOutParameter: true)); +$context = new EvaluationContext('subject-123', new Attributes([ + 'dd_evaluation_telemetry_opt_out' => true, +])); +$value = $api->getClient()->getBooleanValue('checkout-redesign', false, $context); +``` + +Only boolean `true` suppresses new evaluation metrics and automatic flag span annotations. Strings such as `"true"` do not suppress telemetry. +The provider reads the final merged context after OpenFeature `before` hooks. +Flag results, experiment exposures, custom hooks, unrelated telemetry, and previously queued data remain unchanged. +This option does not disable experiment participation or exposure logging. The native `DDTrace\FeatureFlags\Client` API remains unchanged. + ## Contributing Before contributing to this open source project, read our [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/src/DDTrace/OpenFeature/DataDogProvider.php b/src/DDTrace/OpenFeature/DataDogProvider.php index 122eb2ed2c3..e192724c4e2 100644 --- a/src/DDTrace/OpenFeature/DataDogProvider.php +++ b/src/DDTrace/OpenFeature/DataDogProvider.php @@ -9,6 +9,7 @@ use DDTrace\FeatureFlags\EvaluationReason; use DDTrace\FeatureFlags\EvaluationType; use DDTrace\FeatureFlags\Internal\Evaluator; +use DDTrace\FeatureFlags\Internal\EvaluationTelemetry; use DDTrace\FeatureFlags\Internal\Metric\EvaluationMetric; use DDTrace\FeatureFlags\Internal\Metric\EvaluationMetricRecorder; use DDTrace\FeatureFlags\Internal\NativeEvaluator; @@ -34,12 +35,20 @@ final class DataDogProvider extends AbstractProvider private LoggerInterface $datadogLogger; private bool $warnedAboutRuntimeNotReady = false; private EvaluationMetricRecorder $metricRecorder; + private bool $enableEvaluationTelemetryOptOutParameter; - public function __construct(?LoggerInterface $logger = null) - { + /** + * When enabled, boolean true in dd_evaluation_telemetry_opt_out suppresses + * evaluation metrics and automatic span annotations. Exposures are unchanged. + */ + public function __construct( + ?LoggerInterface $logger = null, + bool $enableEvaluationTelemetryOptOutParameter = false + ) { // Native evaluation metrics are disabled here because OpenFeature owns // the final provider outcome, including OF-level type mismatch mapping. - $this->evaluator = NativeEvaluator::create(false); + $this->evaluator = NativeEvaluator::create(false, $enableEvaluationTelemetryOptOutParameter); + $this->enableEvaluationTelemetryOptOutParameter = $enableEvaluationTelemetryOptOutParameter; $this->datadogLogger = new NonThrowingLogger($logger ?: GlobalLogger::get()); $this->metricRecorder = EvaluationMetricRecorder::createDefault(); } @@ -50,9 +59,10 @@ public function __construct(?LoggerInterface $logger = null) public static function createWithDependencies( ?Evaluator $evaluator = null, ?LoggerInterface $logger = null, - $metricRecorder = null + $metricRecorder = null, + bool $enableEvaluationTelemetryOptOutParameter = false ): self { - $provider = new self($logger); + $provider = new self($logger, $enableEvaluationTelemetryOptOutParameter); if ($evaluator !== null) { $provider->evaluator = $evaluator; } @@ -113,12 +123,18 @@ private function resolve( ?EvaluationContext $context ): ResolutionDetailsInterface { $normalizedContext = $this->normalizeContext($context); + $recordTelemetry = EvaluationTelemetry::shouldRecord( + $this->enableEvaluationTelemetryOptOutParameter, + $normalizedContext['attributes'] ?? [] + ); $details = $this->evaluate($flagKey, $expectedType, $defaultValue, $normalizedContext); $this->warnIfRuntimeNotReady($details); // The PHP OpenFeature SDK does not pass ResolutionDetails to finally // hooks, so PHP records metrics here after native evaluation has the // final provider result. - $this->recordEvaluationMetric($flagKey, $details); + if ($recordTelemetry) { + $this->recordEvaluationMetric($flagKey, $details); + } // APM span enrichment is recorded inside NativeEvaluator::evaluate() // (the shared choke point), so there is nothing to do here. diff --git a/src/api/FeatureFlags/Internal/EvaluationTelemetry.php b/src/api/FeatureFlags/Internal/EvaluationTelemetry.php new file mode 100644 index 00000000000..913dbcf8636 --- /dev/null +++ b/src/api/FeatureFlags/Internal/EvaluationTelemetry.php @@ -0,0 +1,16 @@ +mapper = $mapper ?: new ResultMapper(); $this->recordMetrics = $recordMetrics; + $this->enableEvaluationTelemetryOptOutParameter = $enableEvaluationTelemetryOptOutParameter; } public static function isAvailable() @@ -27,9 +32,11 @@ public static function isAvailable() return function_exists('DDTrace\\ffe_evaluate'); } - public static function create($recordMetrics = true) + public static function create($recordMetrics = true, $enableEvaluationTelemetryOptOutParameter = false) { - return self::isAvailable() ? new self(null, $recordMetrics) : new UnavailableEvaluator(); + return self::isAvailable() + ? new self(null, $recordMetrics, $enableEvaluationTelemetryOptOutParameter) + : new UnavailableEvaluator(); } public function evaluate( @@ -39,12 +46,13 @@ public function evaluate( $targetingKey = null, array $attributes = array() ) { + $recordTelemetry = EvaluationTelemetry::shouldRecord($this->enableEvaluationTelemetryOptOutParameter, $attributes); $rawResult = \DDTrace\ffe_evaluate( $flagKey, $this->typeId($expectedType), $targetingKey, $this->normalizeAttributes($attributes), - $this->recordMetrics + $this->recordMetrics && $recordTelemetry ); if (is_array($rawResult) || is_object($rawResult)) { @@ -59,7 +67,9 @@ public function evaluate( // is returned otherwise), so enrichment is recorded here once rather than // duplicated in each caller. No-op when the gate is off or there is no // active root span; never throws into evaluation. - SpanEnrichmentRegistry::record($flagKey, $details, $targetingKey); + if ($recordTelemetry) { + SpanEnrichmentRegistry::record($flagKey, $details, $targetingKey); + } return $details; } diff --git a/tests/OpenFeature/EvaluationTelemetryOptOutTest.php b/tests/OpenFeature/EvaluationTelemetryOptOutTest.php new file mode 100644 index 00000000000..8578c17391d --- /dev/null +++ b/tests/OpenFeature/EvaluationTelemetryOptOutTest.php @@ -0,0 +1,407 @@ + provider -> native evaluator -> native queues. + * Queue observations do not flush or replace any telemetry recorder. + */ +final class EvaluationTelemetryOptOutTest extends TestCase +{ + private const KEY = 'dd_evaluation_telemetry_opt_out'; + + private $api; + private array $initialCounts; + private array $previousIni = []; + + protected function setUp(): void + { + if (!function_exists('DDTrace\\Testing\\ffe_load_config')) { + self::markTestSkipped('The native ddtrace feature flag evaluator is required.'); + } + foreach ([ + 'datadog.metrics_otel_enabled', + 'datadog.experimental_flagging_provider_span_enrichment_enabled', + ] as $name) { + $this->previousIni[$name] = ini_set($name, '1'); + } + self::assertTrue(\dd_trace_env_config('DD_METRICS_OTEL_ENABLED')); + self::assertTrue(SpanEnrichmentRegistry::gateEnabled()); + $providerPath = getenv('DD_AUTOLOAD_NO_COMPILE') === 'true' + ? '/src/DDTrace/OpenFeature/DataDogProvider.php' + : '/src/bridge/_generated_openfeature.php'; + foreach ([ + DataDogProvider::class => $providerPath, + NativeEvaluator::class => '/src/api/FeatureFlags/Internal/NativeEvaluator.php', + \DDTrace\FeatureFlags\Internal\EvaluationTelemetry::class => '/src/api/FeatureFlags/Internal/EvaluationTelemetry.php', + ] as $class => $path) { + self::assertSame(realpath(dirname(__DIR__, 2) . $path), realpath((new \ReflectionClass($class))->getFileName())); + } + $this->api = OpenFeatureAPI::getInstance(); + $this->api->clearHooks(); + $this->api->setEvaluationContext(new EvaluationContext()); + $root = \DDTrace\root_span(); + self::assertNotNull($root); + ObjectKVStore::put($root, SpanEnrichmentRegistry::ACCUMULATOR_KEY, new SpanEnrichmentAccumulator()); + foreach (['ffe_flags_enc', 'ffe_subjects_enc', 'ffe_runtime_defaults'] as $key) { + unset($root->meta[$key]); + } + $root->meta['customer.tag'] = 'retained'; + self::assertTrue(\DDTrace\Testing\ffe_load_config($this->fixture())); + $this->initialCounts = $this->counts(); + } + + protected function tearDown(): void + { + if ($this->api !== null) { + $this->api->clearHooks(); + $this->api->setEvaluationContext(new EvaluationContext()); + } + foreach ($this->previousIni as $name => $value) { + ini_set($name, $value); + } + } + + /** @dataProvider decisions */ + public function testStrictDecisionPreservesNativeAssignment(bool $enabled, array $attributes, bool $suppressed): void + { + $client = $this->client($enabled); + $details = $client->getStringDetails('string.flag', 'fallback', new EvaluationContext('subject', new Attributes($attributes))); + + self::assertSame('blue', $details->getValue()); + self::assertSame('treatment', $details->getVariant()); + self::assertSame('STATIC', $details->getReason()); + self::assertNull($details->getError()); + $this->assertDelta($suppressed ? 0 : 1, 1); + $this->assertSpanIds($suppressed ? [] : [101]); + self::assertSame('retained', \DDTrace\root_span()->meta['customer.tag']); + } + + public static function decisions(): array + { + return [ + 'enabled true' => [true, [self::KEY => true], true], + 'disabled true' => [false, [self::KEY => true], false], + 'missing' => [true, [], false], + 'false' => [true, [self::KEY => false], false], + 'null' => [true, [self::KEY => null], false], + 'string true' => [true, [self::KEY => 'true'], false], + 'string false' => [true, [self::KEY => 'false'], false], + 'empty string' => [true, [self::KEY => ''], false], + 'integer one' => [true, [self::KEY => 1], false], + 'integer zero' => [true, [self::KEY => 0], false], + 'float one' => [true, [self::KEY => 1.0], false], + 'array' => [true, [self::KEY => [true]], false], + 'old key' => [true, ['dd_telemetry_opt_out' => true], false], + ]; + } + + public function testDefaultConstructorAndNativeClientKeepExistingBehavior(): void + { + $this->api->setProvider(new DataDogProvider()); + self::assertSame('blue', $this->api->getClient()->getStringValue('string.flag', 'fallback', $this->context(true))); + self::assertSame('blue', (new Client())->getStringValue('string.flag', 'fallback', [ + 'targetingKey' => 'native-subject', 'attributes' => [self::KEY => true], + ])); + $this->assertDelta(2, 2); + $this->assertSpanIds([101]); + } + + public function testAllValueTypesAndExistingQueuesArePreserved(): void + { + $client = $this->client(true); + self::assertSame('blue', $client->getStringValue('string.flag', '', $this->context(false))); + $meta = \DDTrace\root_span()->meta; + $version = \DDTrace\ffe_config_version(); + $optedOut = $this->context(true); + self::assertTrue($client->getBooleanValue('boolean.flag', false, $optedOut)); + self::assertSame(42, $client->getIntegerValue('integer.flag', 0, $optedOut)); + self::assertSame(3.5, $client->getFloatValue('float.flag', 0.0, $optedOut)); + self::assertSame(['enabled' => true], $client->getObjectValue('object.flag', [], $optedOut)); + self::assertSame('blue', $client->getStringValue('string.flag', '', $optedOut)); + self::assertSame($meta, \DDTrace\root_span()->meta); + self::assertSame($version, \DDTrace\ffe_config_version()); + $this->assertDelta(1, 6); + } + + public function testMergedContextAndBeforeHooksDetermineDecision(): void + { + $client = $this->client(true); + $this->api->setEvaluationContext($this->context(true)); + $client->setEvaluationContext($this->context(false)); + $client->getStringValue('string.flag', '', $this->context(true)); + $client->getStringValue('string.flag', ''); + + $hook = new EvaluationTelemetryTestHook(); + $hook->beforeCallback = fn () => $this->context(true); + $client->addHooks($hook); + $client->getStringValue('integer.flag', '', $this->context(false)); + self::assertSame(['before', 'after', 'finally'], $hook->stages); + $this->assertDelta(1, 2); + $this->assertSpanIds([101]); + } + + public function testBeforeHookCanRestoreTelemetryAndCustomHooksRun(): void + { + $client = $this->client(true); + $hook = new EvaluationTelemetryTestHook(); + $hook->beforeCallback = fn () => $this->context(false); + $client->addHooks($hook); + self::assertSame('blue', $client->getStringValue('string.flag', '', $this->context(true))); + self::assertSame(['before', 'after', 'finally'], $hook->stages); + $this->assertDelta(1, 1); + } + + public function testRandomizedAssignmentAndExposureStayUnchanged(): void + { + $client = $this->client(true); + $allowed = new EvaluationContext('same-subject', new Attributes([self::KEY => false])); + $suppressed = new EvaluationContext('same-subject', new Attributes([self::KEY => true])); + $first = $client->getStringDetails('random.flag', 'fallback', $allowed); + $second = $client->getStringDetails('random.flag', 'fallback', $suppressed); + self::assertNull($first->getError()); + self::assertSame('SPLIT', $first->getReason()); + self::assertSame($first->getValue(), $second->getValue()); + self::assertSame($first->getVariant(), $second->getVariant()); + self::assertSame($first->getReason(), $second->getReason()); + $this->assertDelta(1, 2); + } + + public function testContextMutationIsReadForEachEvaluation(): void + { + $client = $this->client(true); + $attributes = new MutableAttributes([self::KEY => true]); + $context = new EvaluationContext('same-subject', $attributes); + self::assertSame('blue', $client->getStringValue('string.flag', '', $context)); + $attributes->add(self::KEY, 'true'); + self::assertSame('blue', $client->getStringValue('string.flag', '', $context)); + $attributes->add(self::KEY, true); + self::assertSame('blue', $client->getStringValue('string.flag', '', $context)); + $this->assertDelta(1, 3); + } + + public function testProvidersKeepIndependentActivation(): void + { + $enabled = new DataDogProvider(null, true); + $disabled = new DataDogProvider(); + $this->api->setProvider($enabled); + $client = $this->api->getClient(); + $hook = new EvaluationTelemetryTestHook(); + $hook->beforeCallback = function () use ($client, $disabled, $enabled) { + $this->api->setProvider($disabled); + self::assertSame(42, $client->getIntegerValue('integer.flag', 0, $this->context(true))); + $this->api->setProvider($enabled); + return null; + }; + self::assertSame('blue', $client->getStringValue('string.flag', '', $this->context(true), new EvaluationOptions([$hook]))); + $this->assertDelta(1, 2); + $this->assertSpanIds([103]); + } + + /** @dataProvider errors */ + public function testErrorsPreserveDefaultsAndSuppressNewTelemetry(string $flag, string $error): void + { + $client = $this->client(true); + $details = $client->getStringDetails($flag, 'fallback', $this->context(true)); + self::assertSame('fallback', $details->getValue()); + self::assertSame('ERROR', $details->getReason()); + self::assertSame($error, $details->getError()->getResolutionErrorCode()->getValue()); + $this->assertDelta(0, 0); + self::assertArrayNotHasKey('ffe_runtime_defaults', \DDTrace\root_span()->meta); + $this->assertSpanIds([]); + } + + public static function errors(): array + { + return [['missing.flag', 'FLAG_NOT_FOUND'], ['integer.flag', 'TYPE_MISMATCH']]; + } + + /** @dataProvider hookFailures */ + public function testHookFailuresDoNotLeakSuppression(string $stage, int $exposures): void + { + $client = $this->client(true); + $hook = new EvaluationTelemetryTestHook(); + $hook->{$stage . 'Callback'} = static function () { + throw new \RuntimeException('customer hook failure'); + }; + $options = new EvaluationOptions([$hook]); + $details = $client->getStringDetails('string.flag', 'fallback', $this->context(true), $options); + if ($stage !== 'finally') { + self::assertSame('fallback', $details->getValue()); + } + self::assertContains('finally', $hook->stages); + self::assertSame('blue', $client->getStringValue('string.flag', '', $this->context(false))); + $this->assertDelta(1, $exposures + 1); + } + + public static function hookFailures(): array + { + return [['before', 0], ['after', 1], ['finally', 1]]; + } + + public function testNestedEvaluationsKeepSeparateDecisions(): void + { + $client = $this->client(true); + $hook = new EvaluationTelemetryTestHook(); + $hook->beforeCallback = function () use ($client) { + self::assertSame(42, $client->getIntegerValue('integer.flag', 0, $this->context(false))); + return null; + }; + self::assertSame('blue', $client->getStringValue('string.flag', '', $this->context(true), new EvaluationOptions([$hook]))); + $this->assertDelta(1, 2); + $this->assertSpanIds([103]); + } + + public function testConcurrentFibersKeepSeparateDecisions(): void + { + if (!class_exists('Fiber')) { + self::markTestSkipped('Fibers require PHP 8.1.'); + } + $client = $this->client(true); + $hook = new EvaluationTelemetryTestHook(); + $hook->beforeCallback = static function () { + \Fiber::suspend(); + return null; + }; + $options = new EvaluationOptions([$hook]); + $suppressed = new \Fiber(fn () => $client->getStringValue('string.flag', '', $this->context(true), $options)); + $allowed = new \Fiber(fn () => $client->getIntegerValue('integer.flag', 0, $this->context(false), $options)); + $suppressed->start(); + $allowed->start(); + $allowed->resume(); + $suppressed->resume(); + self::assertSame('blue', $suppressed->getReturn()); + self::assertSame(42, $allowed->getReturn()); + $this->assertDelta(1, 2); + $this->assertSpanIds([103]); + } + + private function client(bool $enabled) + { + $this->api->setProvider(new DataDogProvider(null, $enabled)); + return $this->api->getClient('telemetry-opt-out'); + } + + private function context(bool $optOut): EvaluationContext + { + return new EvaluationContext($optOut ? 'opted-out' : 'allowed', new Attributes([self::KEY => $optOut])); + } + + private function counts(): array + { + $counts = \dd_trace_internal_fn('ffe_buffer_counts'); + self::assertIsArray($counts, 'Build this checkout to observe the actual native queues.'); + return $counts; + } + + private function assertDelta(int $metrics, int $exposures): void + { + $counts = $this->counts(); + self::assertSame(['evaluation_metrics' => $metrics, 'exposures' => $exposures], [ + 'evaluation_metrics' => $counts['evaluation_metrics'] - $this->initialCounts['evaluation_metrics'], + 'exposures' => $counts['exposures'] - $this->initialCounts['exposures'], + ], 'native admission counts'); + } + + private function assertSpanIds(array $ids): void + { + $meta = \DDTrace\root_span()->meta; + $actual = isset($meta['ffe_flags_enc']) ? (new SpanEnrichmentAccumulator())->decodeDeltaVarint($meta['ffe_flags_enc']) : []; + self::assertSame($ids, $actual); + } + + private function fixture(): string + { + $flags = []; + $values = ['string' => 'blue', 'boolean' => true, 'integer' => 42, 'float' => 3.5, 'object' => ['enabled' => true]]; + $types = ['string' => 'STRING', 'boolean' => 'BOOLEAN', 'integer' => 'INTEGER', 'float' => 'NUMERIC', 'object' => 'JSON']; + $id = 101; + foreach ($values as $type => $value) { + $key = $type . '.flag'; + $flags[$key] = [ + 'key' => $key, 'enabled' => true, 'variationType' => $types[$type], + 'variations' => ['treatment' => ['key' => 'treatment', 'value' => $value]], + 'allocations' => [[ + 'key' => 'allocation-' . $type, 'rules' => [], 'doLog' => true, + 'splits' => [['variationKey' => 'treatment', 'serialId' => $id++, 'shards' => []]], + ]], + ]; + } + $flags['random.flag'] = [ + 'key' => 'random.flag', 'enabled' => true, 'variationType' => 'STRING', + 'variations' => ['a' => ['key' => 'a', 'value' => 'A'], 'b' => ['key' => 'b', 'value' => 'B']], + 'allocations' => [[ + 'key' => 'random-allocation', 'rules' => [], 'doLog' => true, + 'splits' => [ + ['variationKey' => 'a', 'serialId' => 201, 'shards' => [[ + 'salt' => 'opt-out-test', 'totalShards' => 10000, 'ranges' => [['start' => 0, 'end' => 5000]], + ]]], + ['variationKey' => 'b', 'serialId' => 202, 'shards' => [[ + 'salt' => 'opt-out-test', 'totalShards' => 10000, 'ranges' => [['start' => 5000, 'end' => 10000]], + ]]], + ], + ]], + ]; + return json_encode(['createdAt' => '2026-01-01T00:00:00Z', 'environment' => ['name' => 'test'], 'flags' => $flags]); + } +} + +final class EvaluationTelemetryTestHook implements Hook +{ + public array $stages = []; + public $beforeCallback; + public $afterCallback; + public $finallyCallback; + + public function supportsFlagValueType(string $flagValueType): bool + { + return true; + } + + public function before(HookContext $context, HookHints $hints): ?EvaluationContextInterface + { + $this->stages[] = 'before'; + return $this->beforeCallback ? ($this->beforeCallback)($context) : null; + } + + public function after(HookContext $context, ResolutionDetails $details, HookHints $hints): void + { + $this->stages[] = 'after'; + if ($this->afterCallback) { + ($this->afterCallback)($context); + } + } + + public function error(HookContext $context, \Throwable $error, HookHints $hints): void + { + $this->stages[] = 'error'; + } + + public function finally(HookContext $context, HookHints $hints): void + { + $this->stages[] = 'finally'; + if ($this->finallyCallback) { + ($this->finallyCallback)($context); + } + } +} diff --git a/tests/OpenFeature/evaluation_telemetry_opt_out_not_ready.phpt b/tests/OpenFeature/evaluation_telemetry_opt_out_not_ready.phpt new file mode 100644 index 00000000000..01b5a3b6893 --- /dev/null +++ b/tests/OpenFeature/evaluation_telemetry_opt_out_not_ready.phpt @@ -0,0 +1,41 @@ +--TEST-- +OpenFeature evaluation telemetry opt-out preserves provider-not-ready defaults +--SKIPIF-- + +--INI-- +datadog.metrics_otel_enabled=1 +datadog.experimental_flagging_provider_span_enrichment_enabled=1 +--FILE-- +setProvider(new DataDogProvider(new NullLogger('error'), true)); +$client = $api->getClient(); +var_dump(\DDTrace\ffe_has_config()); +foreach ([true, false] as $optOut) { + $details = $client->getStringDetails('flag', 'fallback', new EvaluationContext('subject', new Attributes([ + 'dd_evaluation_telemetry_opt_out' => $optOut, + ]))); + echo json_encode([ + 'value' => $details->getValue(), + 'reason' => $details->getReason(), + 'error' => $details->getError()->getResolutionErrorCode()->getValue(), + 'counts' => dd_trace_internal_fn('ffe_buffer_counts'), + 'defaults' => isset(\DDTrace\root_span()->meta['ffe_runtime_defaults']), + ]), "\n"; +} +?> +--EXPECT-- +bool(false) +{"value":"fallback","reason":"ERROR","error":"PROVIDER_NOT_READY","counts":{"evaluation_metrics":0,"exposures":0},"defaults":false} +{"value":"fallback","reason":"ERROR","error":"PROVIDER_NOT_READY","counts":{"evaluation_metrics":1,"exposures":0},"defaults":true} diff --git a/tracer/functions.c b/tracer/functions.c index 5657740cb11..9aa66faa0b1 100644 --- a/tracer/functions.c +++ b/tracer/functions.c @@ -2084,6 +2084,11 @@ PHP_FUNCTION(dd_trace_internal_fn) { datadog_metric_add_point(Z_STR_P(metric_name), zval_get_double(metric_value), Z_STR_P(tags)); RETVAL_TRUE; } + } else if (params_count == 0 && FUNCTION_NAME_MATCHES("ffe_buffer_counts")) { + // Read-only test observation: do not flush queues or expose subject data. + array_init(return_value); + add_assoc_long(return_value, "evaluation_metrics", (zend_long) DDTRACE_G(ffe_metric_buffer_len)); + add_assoc_long(return_value, "exposures", (zend_long) DDTRACE_G(ffe_exposure_buffer_len)); } else if (FUNCTION_NAME_MATCHES("dump_sidecar")) { if (!DATADOG_G(sidecar)) { RETURN_FALSE; From 141f7e60fb1b6425d34300ba381c4867c0c9b71c Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 15 Oct 2026 17:36:17 -0400 Subject: [PATCH 2/4] feat(openfeature): configure per-subject telemetry opt-out through environment --- README.md | 11 ++++ metadata/supported-configurations.json | 7 +++ src/DDTrace/OpenFeature/DataDogProvider.php | 7 ++- .../OpenFeature/evaluation_telemetry_env.inc | 59 +++++++++++++++++++ .../evaluation_telemetry_env_false.phpt | 17 ++++++ .../evaluation_telemetry_env_invalid.phpt | 17 ++++++ .../evaluation_telemetry_env_true.phpt | 17 ++++++ .../evaluation_telemetry_env_unset.phpt | 19 ++++++ tracer/configuration.h | 1 + 9 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 tests/OpenFeature/evaluation_telemetry_env.inc create mode 100644 tests/OpenFeature/evaluation_telemetry_env_false.phpt create mode 100644 tests/OpenFeature/evaluation_telemetry_env_invalid.phpt create mode 100644 tests/OpenFeature/evaluation_telemetry_env_true.phpt create mode 100644 tests/OpenFeature/evaluation_telemetry_env_unset.phpt diff --git a/README.md b/README.md index 14e102483a1..40a80eb7b67 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,17 @@ The provider reads the final merged context after OpenFeature `before` hooks. Flag results, experiment exposures, custom hooks, unrelated telemetry, and previously queued data remain unchanged. This option does not disable experiment participation or exposure logging. The native `DDTrace\FeatureFlags\Client` API remains unchanged. +To enable support through the environment, set this variable before PHP starts: + +```sh +export DD_FEATURE_FLAGS_EVALUATION_TELEMETRY_PER_SUBJECT_OPT_OUT_ENABLED=true +``` + +Then construct `new DataDogProvider()` without the option. The provider captures tracer configuration when it is constructed. +An explicit `true` or `false` constructor option overrides the environment. Omitted or `null` options use tracer configuration. +Unset or invalid environment values default to disabled. The equivalent PHP INI option is `datadog.feature_flags_evaluation_telemetry_per_subject_opt_out_enabled`. +Environment activation only enables support for the subject attribute. Subjects without typed boolean `true` continue to record evaluation telemetry. + ## Contributing Before contributing to this open source project, read our [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index 72de20ca32f..3590607e323 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -411,6 +411,13 @@ "default": "true" } ], + "DD_FEATURE_FLAGS_EVALUATION_TELEMETRY_PER_SUBJECT_OPT_OUT_ENABLED": [ + { + "implementation": "A", + "type": "boolean", + "default": "false" + } + ], "DD_GIT_COMMIT_SHA": [ { "implementation": "B", diff --git a/src/DDTrace/OpenFeature/DataDogProvider.php b/src/DDTrace/OpenFeature/DataDogProvider.php index e192724c4e2..7ecb4e7eedb 100644 --- a/src/DDTrace/OpenFeature/DataDogProvider.php +++ b/src/DDTrace/OpenFeature/DataDogProvider.php @@ -40,11 +40,14 @@ final class DataDogProvider extends AbstractProvider /** * When enabled, boolean true in dd_evaluation_telemetry_opt_out suppresses * evaluation metrics and automatic span annotations. Exposures are unchanged. + * Null uses tracer configuration at construction. Explicit true or false overrides it. */ public function __construct( ?LoggerInterface $logger = null, - bool $enableEvaluationTelemetryOptOutParameter = false + ?bool $enableEvaluationTelemetryOptOutParameter = null ) { + $enableEvaluationTelemetryOptOutParameter = $enableEvaluationTelemetryOptOutParameter + ?? (\dd_trace_env_config('DD_FEATURE_FLAGS_EVALUATION_TELEMETRY_PER_SUBJECT_OPT_OUT_ENABLED') === true); // Native evaluation metrics are disabled here because OpenFeature owns // the final provider outcome, including OF-level type mismatch mapping. $this->evaluator = NativeEvaluator::create(false, $enableEvaluationTelemetryOptOutParameter); @@ -60,7 +63,7 @@ public static function createWithDependencies( ?Evaluator $evaluator = null, ?LoggerInterface $logger = null, $metricRecorder = null, - bool $enableEvaluationTelemetryOptOutParameter = false + ?bool $enableEvaluationTelemetryOptOutParameter = null ): self { $provider = new self($logger, $enableEvaluationTelemetryOptOutParameter); if ($evaluator !== null) { diff --git a/tests/OpenFeature/evaluation_telemetry_env.inc b/tests/OpenFeature/evaluation_telemetry_env.inc new file mode 100644 index 00000000000..7abd31af40f --- /dev/null +++ b/tests/OpenFeature/evaluation_telemetry_env.inc @@ -0,0 +1,59 @@ + '2026-01-01T00:00:00Z', + 'environment' => ['name' => 'test'], + 'flags' => ['flag' => [ + 'key' => 'flag', 'enabled' => true, 'variationType' => 'STRING', + 'variations' => ['treatment' => ['key' => 'treatment', 'value' => 'blue']], + 'allocations' => [[ + 'key' => 'allocation', 'rules' => [], 'doLog' => true, + 'splits' => [['variationKey' => 'treatment', 'serialId' => 101, 'shards' => []]], + ]], + ]], +])); + +function showEvaluation($label, DataDogProvider $provider, array $attributes): void +{ + $api = OpenFeatureAPI::getInstance(); + $api->setProvider($provider); + $root = \DDTrace\root_span(); + ObjectKVStore::put($root, SpanEnrichmentRegistry::ACCUMULATOR_KEY, new SpanEnrichmentAccumulator()); + unset($root->meta['ffe_flags_enc'], $root->meta['ffe_subjects_enc']); + $before = dd_trace_internal_fn('ffe_buffer_counts'); + $details = $api->getClient()->getStringDetails('flag', 'fallback', new EvaluationContext('subject', new Attributes($attributes))); + $after = dd_trace_internal_fn('ffe_buffer_counts'); + echo $label, '=', json_encode([ + 'value' => $details->getValue(), + 'variant' => $details->getVariant(), + 'reason' => $details->getReason(), + 'error' => $details->getError(), + 'metrics' => $after['evaluation_metrics'] - $before['evaluation_metrics'], + 'exposures' => $after['exposures'] - $before['exposures'], + 'annotation' => isset($root->meta['ffe_flags_enc']), + ]), "\n"; +} + +$optedOut = ['dd_evaluation_telemetry_opt_out' => true]; +$fromEnvironment = new DataDogProvider(); +showEvaluation('omitted', $fromEnvironment, $optedOut); +showEvaluation('explicit-false', new DataDogProvider(enableEvaluationTelemetryOptOutParameter: false), $optedOut); +showEvaluation('explicit-true', new DataDogProvider(enableEvaluationTelemetryOptOutParameter: true), $optedOut); +showEvaluation('string-true', $fromEnvironment, ['dd_evaluation_telemetry_opt_out' => 'true']); +showEvaluation('missing', $fromEnvironment, []); + +// A provider keeps its construction-time decision when tracer config changes. +$config = 'DD_FEATURE_FLAGS_EVALUATION_TELEMETRY_PER_SUBJECT_OPT_OUT_ENABLED'; +ini_set('datadog.feature_flags_evaluation_telemetry_per_subject_opt_out_enabled', dd_trace_env_config($config) ? '0' : '1'); +showEvaluation('existing-provider', $fromEnvironment, $optedOut); +showEvaluation('new-provider', new DataDogProvider(), $optedOut); diff --git a/tests/OpenFeature/evaluation_telemetry_env_false.phpt b/tests/OpenFeature/evaluation_telemetry_env_false.phpt new file mode 100644 index 00000000000..28ec1004661 --- /dev/null +++ b/tests/OpenFeature/evaluation_telemetry_env_false.phpt @@ -0,0 +1,17 @@ +--TEST-- +OpenFeature disabled environment opt-out preserves explicit true and construction-time configuration +--ENV-- +DD_FEATURE_FLAGS_EVALUATION_TELEMETRY_PER_SUBJECT_OPT_OUT_ENABLED=false +--INI-- +datadog.metrics_otel_enabled=1 +datadog.experimental_flagging_provider_span_enrichment_enabled=1 +--FILE-- + +--EXPECT-- +omitted={"value":"blue","variant":"treatment","reason":"STATIC","error":null,"metrics":1,"exposures":1,"annotation":true} +explicit-false={"value":"blue","variant":"treatment","reason":"STATIC","error":null,"metrics":1,"exposures":1,"annotation":true} +explicit-true={"value":"blue","variant":"treatment","reason":"STATIC","error":null,"metrics":0,"exposures":1,"annotation":false} +string-true={"value":"blue","variant":"treatment","reason":"STATIC","error":null,"metrics":1,"exposures":1,"annotation":true} +missing={"value":"blue","variant":"treatment","reason":"STATIC","error":null,"metrics":1,"exposures":1,"annotation":true} +existing-provider={"value":"blue","variant":"treatment","reason":"STATIC","error":null,"metrics":1,"exposures":1,"annotation":true} +new-provider={"value":"blue","variant":"treatment","reason":"STATIC","error":null,"metrics":0,"exposures":1,"annotation":false} diff --git a/tests/OpenFeature/evaluation_telemetry_env_invalid.phpt b/tests/OpenFeature/evaluation_telemetry_env_invalid.phpt new file mode 100644 index 00000000000..cd50cfb9477 --- /dev/null +++ b/tests/OpenFeature/evaluation_telemetry_env_invalid.phpt @@ -0,0 +1,17 @@ +--TEST-- +OpenFeature invalid environment opt-out defaults to disabled +--ENV-- +DD_FEATURE_FLAGS_EVALUATION_TELEMETRY_PER_SUBJECT_OPT_OUT_ENABLED=invalid +--INI-- +datadog.metrics_otel_enabled=1 +datadog.experimental_flagging_provider_span_enrichment_enabled=1 +--FILE-- + +--EXPECT-- +omitted={"value":"blue","variant":"treatment","reason":"STATIC","error":null,"metrics":1,"exposures":1,"annotation":true} +explicit-false={"value":"blue","variant":"treatment","reason":"STATIC","error":null,"metrics":1,"exposures":1,"annotation":true} +explicit-true={"value":"blue","variant":"treatment","reason":"STATIC","error":null,"metrics":0,"exposures":1,"annotation":false} +string-true={"value":"blue","variant":"treatment","reason":"STATIC","error":null,"metrics":1,"exposures":1,"annotation":true} +missing={"value":"blue","variant":"treatment","reason":"STATIC","error":null,"metrics":1,"exposures":1,"annotation":true} +existing-provider={"value":"blue","variant":"treatment","reason":"STATIC","error":null,"metrics":1,"exposures":1,"annotation":true} +new-provider={"value":"blue","variant":"treatment","reason":"STATIC","error":null,"metrics":0,"exposures":1,"annotation":false} diff --git a/tests/OpenFeature/evaluation_telemetry_env_true.phpt b/tests/OpenFeature/evaluation_telemetry_env_true.phpt new file mode 100644 index 00000000000..1530e1cf8d5 --- /dev/null +++ b/tests/OpenFeature/evaluation_telemetry_env_true.phpt @@ -0,0 +1,17 @@ +--TEST-- +OpenFeature environment opt-out preserves emissions and explicit provider precedence +--ENV-- +DD_FEATURE_FLAGS_EVALUATION_TELEMETRY_PER_SUBJECT_OPT_OUT_ENABLED=true +--INI-- +datadog.metrics_otel_enabled=1 +datadog.experimental_flagging_provider_span_enrichment_enabled=1 +--FILE-- + +--EXPECT-- +omitted={"value":"blue","variant":"treatment","reason":"STATIC","error":null,"metrics":0,"exposures":1,"annotation":false} +explicit-false={"value":"blue","variant":"treatment","reason":"STATIC","error":null,"metrics":1,"exposures":1,"annotation":true} +explicit-true={"value":"blue","variant":"treatment","reason":"STATIC","error":null,"metrics":0,"exposures":1,"annotation":false} +string-true={"value":"blue","variant":"treatment","reason":"STATIC","error":null,"metrics":1,"exposures":1,"annotation":true} +missing={"value":"blue","variant":"treatment","reason":"STATIC","error":null,"metrics":1,"exposures":1,"annotation":true} +existing-provider={"value":"blue","variant":"treatment","reason":"STATIC","error":null,"metrics":0,"exposures":1,"annotation":false} +new-provider={"value":"blue","variant":"treatment","reason":"STATIC","error":null,"metrics":1,"exposures":1,"annotation":true} diff --git a/tests/OpenFeature/evaluation_telemetry_env_unset.phpt b/tests/OpenFeature/evaluation_telemetry_env_unset.phpt new file mode 100644 index 00000000000..cf6f2601316 --- /dev/null +++ b/tests/OpenFeature/evaluation_telemetry_env_unset.phpt @@ -0,0 +1,19 @@ +--TEST-- +OpenFeature unset environment opt-out defaults to disabled +--SKIPIF-- + +--INI-- +datadog.metrics_otel_enabled=1 +datadog.experimental_flagging_provider_span_enrichment_enabled=1 +--FILE-- + +--EXPECT-- +omitted={"value":"blue","variant":"treatment","reason":"STATIC","error":null,"metrics":1,"exposures":1,"annotation":true} +explicit-false={"value":"blue","variant":"treatment","reason":"STATIC","error":null,"metrics":1,"exposures":1,"annotation":true} +explicit-true={"value":"blue","variant":"treatment","reason":"STATIC","error":null,"metrics":0,"exposures":1,"annotation":false} +string-true={"value":"blue","variant":"treatment","reason":"STATIC","error":null,"metrics":1,"exposures":1,"annotation":true} +missing={"value":"blue","variant":"treatment","reason":"STATIC","error":null,"metrics":1,"exposures":1,"annotation":true} +existing-provider={"value":"blue","variant":"treatment","reason":"STATIC","error":null,"metrics":1,"exposures":1,"annotation":true} +new-provider={"value":"blue","variant":"treatment","reason":"STATIC","error":null,"metrics":0,"exposures":1,"annotation":false} diff --git a/tracer/configuration.h b/tracer/configuration.h index ca83da9d11e..8ecb22809a0 100644 --- a/tracer/configuration.h +++ b/tracer/configuration.h @@ -164,6 +164,7 @@ CONFIG(BOOL, DD_TRACE_RESOURCE_RENAMING_ALWAYS_SIMPLIFIED_ENDPOINT, "false") \ CONFIG(BOOL, DD_TRACE_STATS_COMPUTATION_ENABLED, "false") \ CONFIG(BOOL, DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED, "false") \ + CONFIG(BOOL, DD_FEATURE_FLAGS_EVALUATION_TELEMETRY_PER_SUBJECT_OPT_OUT_ENABLED, "false") \ DD_INTEGRATIONS #ifndef DDTRACE_CONFIGURATION From 4e66babf7fcdc97f4a3fb68a5dad7ec94a61e80d Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 15 Oct 2026 18:02:37 -0400 Subject: [PATCH 3/4] fix(openfeature): load telemetry dependencies in PHPT tests --- tests/OpenFeature/evaluation_telemetry_env.inc | 4 +++- tests/OpenFeature/evaluation_telemetry_opt_out_not_ready.phpt | 4 +++- tests/ext/ffe/system_test_data_evaluate.phpt | 1 + 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/OpenFeature/evaluation_telemetry_env.inc b/tests/OpenFeature/evaluation_telemetry_env.inc index 7abd31af40f..12a751e3020 100644 --- a/tests/OpenFeature/evaluation_telemetry_env.inc +++ b/tests/OpenFeature/evaluation_telemetry_env.inc @@ -1,6 +1,8 @@ addPsr4('DDTrace\\', [__DIR__ . '/../../src/DDTrace', __DIR__ . '/../../src/api']); use DDTrace\FeatureFlags\SpanEnrichmentAccumulator; use DDTrace\FeatureFlags\SpanEnrichmentRegistry; diff --git a/tests/OpenFeature/evaluation_telemetry_opt_out_not_ready.phpt b/tests/OpenFeature/evaluation_telemetry_opt_out_not_ready.phpt index 01b5a3b6893..582f3667c93 100644 --- a/tests/OpenFeature/evaluation_telemetry_opt_out_not_ready.phpt +++ b/tests/OpenFeature/evaluation_telemetry_opt_out_not_ready.phpt @@ -10,7 +10,9 @@ datadog.metrics_otel_enabled=1 datadog.experimental_flagging_provider_span_enrichment_enabled=1 --FILE-- addPsr4('DDTrace\\', [__DIR__ . '/../../src/DDTrace', __DIR__ . '/../../src/api']); use DDTrace\Log\NullLogger; use DDTrace\OpenFeature\DataDogProvider; diff --git a/tests/ext/ffe/system_test_data_evaluate.phpt b/tests/ext/ffe/system_test_data_evaluate.phpt index 8292184d8f1..db22d3c3975 100644 --- a/tests/ext/ffe/system_test_data_evaluate.phpt +++ b/tests/ext/ffe/system_test_data_evaluate.phpt @@ -133,6 +133,7 @@ function require_feature_flag_api($root) $internalRoot = $apiRoot . '/Internal'; foreach (array( 'Evaluator', + 'EvaluationTelemetry', 'ResultMapper', 'UnavailableEvaluator', 'NativeEvaluator', From 32ec3f4e73ade3a76c9eff96345bac24c4fc6af0 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Sun, 18 Oct 2026 10:01:22 -0400 Subject: [PATCH 4/4] test(openfeature): retain telemetry opt-out attributes --- README.md | 6 ++- .../EvaluationTelemetryOptOutTest.php | 40 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 40a80eb7b67..d0736db0106 100644 --- a/README.md +++ b/README.md @@ -53,9 +53,13 @@ $value = $api->getClient()->getBooleanValue('checkout-redesign', false, $context Only boolean `true` suppresses new evaluation metrics and automatic flag span annotations. Strings such as `"true"` do not suppress telemetry. The provider reads the final merged context after OpenFeature `before` hooks. -Flag results, experiment exposures, custom hooks, unrelated telemetry, and previously queued data remain unchanged. +Telemetry suppression preserves flag evaluation, experiment exposures, custom hooks, unrelated telemetry, and previously queued data. This option does not disable experiment participation or exposure logging. The native `DDTrace\FeatureFlags\Client` API remains unchanged. +The attribute remains available to targeting rules and normal exposure subject attributes, regardless of activation. +It follows the same scalar attribute normalization and exposure handling as other context attributes. +A targeting rule can change the assignment when this attribute changes. Telemetry suppression does not change that targeting behavior. + To enable support through the environment, set this variable before PHP starts: ```sh diff --git a/tests/OpenFeature/EvaluationTelemetryOptOutTest.php b/tests/OpenFeature/EvaluationTelemetryOptOutTest.php index 8578c17391d..4161445acb4 100644 --- a/tests/OpenFeature/EvaluationTelemetryOptOutTest.php +++ b/tests/OpenFeature/EvaluationTelemetryOptOutTest.php @@ -116,6 +116,26 @@ public static function decisions(): array ]; } + /** @dataProvider decisions */ + public function testOptOutAttributeUsesNormalTargetingAndExposureAdmission(bool $enabled, array $attributes, bool $suppressed): void + { + $client = $this->client($enabled); + $context = new EvaluationContext('same-subject', new Attributes($attributes)); + $details = $client->getStringDetails('attribute-targeting.flag', 'runtime-default', $context); + $value = $attributes[self::KEY] ?? null; + // ONE_OF uses the evaluator's normal string matching for boolean and string values. + $matched = $value === true || $value === 'true'; + + self::assertSame($matched ? 'attribute-matched' : 'attribute-not-matched', $details->getValue()); + self::assertSame($matched ? 'matched' : 'fallback', $details->getVariant()); + self::assertSame($matched ? 'TARGETING_MATCH' : 'STATIC', $details->getReason()); + self::assertNull($details->getError()); + self::assertSame($attributes, $context->getAttributes()->toArray()); + $this->assertDelta($suppressed ? 0 : 1, 1); + $this->assertSpanIds($suppressed ? [] : [$matched ? 301 : 302]); + self::assertSame('retained', \DDTrace\root_span()->meta['customer.tag']); + } + public function testDefaultConstructorAndNativeClientKeepExistingBehavior(): void { $this->api->setProvider(new DataDogProvider()); @@ -362,6 +382,26 @@ private function fixture(): string ], ]], ]; + $flags['attribute-targeting.flag'] = [ + 'key' => 'attribute-targeting.flag', 'enabled' => true, 'variationType' => 'STRING', + 'variations' => [ + 'matched' => ['key' => 'matched', 'value' => 'attribute-matched'], + 'fallback' => ['key' => 'fallback', 'value' => 'attribute-not-matched'], + ], + 'allocations' => [ + [ + 'key' => 'attribute-match', 'doLog' => true, + 'rules' => [['conditions' => [[ + 'attribute' => self::KEY, 'operator' => 'ONE_OF', 'value' => ['true'], + ]]]], + 'splits' => [['variationKey' => 'matched', 'serialId' => 301, 'shards' => []]], + ], + [ + 'key' => 'attribute-fallback', 'rules' => [], 'doLog' => true, + 'splits' => [['variationKey' => 'fallback', 'serialId' => 302, 'shards' => []]], + ], + ], + ]; return json_encode(['createdAt' => '2026-01-01T00:00:00Z', 'environment' => ['name' => 'test'], 'flags' => $flags]); } }