From fe4ba843d7c7b67b108eb49e829a1d89ced5d6e9 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 19 Oct 2026 23:27:45 -0400 Subject: [PATCH 1/6] feat(ffe): support immediate subject experimentation opt-out --- Cargo.lock | 1 + README.md | 43 ++++++++++ components-rs/ffe.rs | 81 +++++++++++++++++-- libdatadog | 2 +- metadata/supported-configurations.json | 7 ++ src/DDTrace/OpenFeature/DataDogProvider.php | 9 ++- src/api/FeatureFlags/Client.php | 8 +- src/api/FeatureFlags/EvaluationErrorCode.php | 2 + .../ExperimentationOptOutMode.php | 42 ++++++++++ .../FeatureFlags/Internal/NativeEvaluator.php | 20 ++++- .../FeatureFlags/Internal/ResultMapper.php | 3 + tests/FeatureFlags/ffe-system-test-data | 2 +- tests/OpenFeature/DataDogProviderTest.php | 41 ++++++++++ tests/api/Unit/FeatureFlags/ClientTest.php | 13 +++ .../ExperimentationOptOutModeTest.php | 42 ++++++++++ ...experimentation_opt_out_configuration.phpt | 73 +++++++++++++++++ tests/ext/ffe/system_test_data_evaluate.phpt | 22 ++++- tracer/configuration.h | 1 + tracer/ddtrace.stub.php | 2 +- tracer/ddtrace_arginfo.h | 3 +- tracer/functions.c | 15 +++- 21 files changed, 408 insertions(+), 24 deletions(-) create mode 100644 src/api/FeatureFlags/ExperimentationOptOutMode.php create mode 100644 tests/api/Unit/FeatureFlags/ExperimentationOptOutModeTest.php create mode 100644 tests/ext/ffe/experimentation_opt_out_configuration.phpt diff --git a/Cargo.lock b/Cargo.lock index 0e3c0872a8c..ac76c002a14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3392,6 +3392,7 @@ dependencies = [ name = "libdd-trace-utils" version = "14.0.0" dependencies = [ + "allocator-api2", "anyhow", "base64 0.22.1", "bolero", diff --git a/README.md b/README.md index 1e2ffca1a5e..5c43de09f28 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,49 @@ 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). +### Per-subject experimentation opt-out + +Enable immediate exclusion through SDK configuration: + +```sh +export DD_FEATURE_FLAGS_EXPERIMENTATION_OPT_OUT_MODE=immediate +``` + +Pass the customer's stored opt-out time in each subject's 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()); +$attributes = []; +if ($athlete->experimentationOptOutAt !== null) { + $attributes['dd_experimentation_opt_out_at'] = + $athlete->experimentationOptOutAt->format(DATE_RFC3339); +} +$enabled = $api->getClient()->getBooleanValue( + 'new-route-planner', + false, + new EvaluationContext($athlete->id, new Attributes($attributes)) +); +``` + +The default mode is `disabled`. An explicit constructor setting overrides the environment: +`new DataDogProvider(null, ExperimentationOptOutMode::DISABLED)`, using +`DDTrace\FeatureFlags\ExperimentationOptOutMode`. +The native `DDTrace\FeatureFlags\Client` accepts the same second constructor argument. +Configuration is resolved once when the client or provider is constructed. + +In immediate mode, a valid RFC3339 timestamp skips allocations that record experiment exposures. +Evaluation continues through ordinary feature allocations. If none match, the SDK returns the application default. +A future timestamp also takes effect immediately. It does not schedule exclusion. +Omit the attribute to retain normal participation. Null, non-string, empty, and malformed values return +the application default with `INVALID_CONTEXT`. +Unsupported configuration values fail construction. Evaluation telemetry remains independent. + ## Contributing Before contributing to this open source project, read our [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/components-rs/ffe.rs b/components-rs/ffe.rs index 4964bc7b0b9..ad2157bd9f8 100644 --- a/components-rs/ffe.rs +++ b/components-rs/ffe.rs @@ -1,7 +1,7 @@ use crate::bytes::MaybeOwnedZendString; use datadog_ffe::rules_based::{ self as ffe, AssignmentReason, AssignmentValue, Attribute, Configuration, EvaluationContext, - EvaluationError, ExpectedFlagType, Str, UniversalFlagConfig, + EvaluationError, EvaluationOptions, ExpectedFlagType, Str, UniversalFlagConfig, }; use libdd_common_ffi::slice::{AsBytes, CharSlice}; use std::cell::RefCell; @@ -77,6 +77,7 @@ const ERROR_NONE: i32 = 0; const ERROR_TYPE_MISMATCH: i32 = 1; const ERROR_CONFIG_PARSE: i32 = 2; const ERROR_FLAG_UNRECOGNIZED: i32 = 3; +const ERROR_INVALID_CONTEXT: i32 = 5; const ERROR_CONFIG_MISSING: i32 = 6; const ERROR_GENERAL: i32 = 7; @@ -125,6 +126,7 @@ pub extern "C" fn ddog_ffe_evaluate( targeting_key: CharSlice<'_>, attributes: *const FfeAttribute<'_>, attributes_count: usize, + experimentation_opt_out_immediate: bool, ) -> FfeResult { if flag_key.as_raw_parts().0.is_null() { return invalid_result(); @@ -158,13 +160,21 @@ pub extern "C" fn ddog_ffe_evaluate( FFE_STATE.with(|state| { let state = state.borrow(); - let assignment = ffe::get_assignment( - state.config.as_ref(), - flag_key, - &context, - expected_type, - ffe::now(), - ); + let assignment = state + .config + .as_ref() + .ok_or(EvaluationError::ConfigurationMissing) + .and_then(|config| { + config.eval_flag_with_options( + flag_key, + &context, + expected_type, + ffe::now(), + EvaluationOptions { + experimentation_opt_out_immediate, + }, + ) + }); result_from_assignment(assignment) }) @@ -237,6 +247,7 @@ fn result_from_assignment(assignment: Result) Err(error) => { let (error_code, reason) = match &error { EvaluationError::TypeMismatch { .. } => (ERROR_TYPE_MISMATCH, REASON_ERROR), + EvaluationError::InvalidContext => (ERROR_INVALID_CONTEXT, REASON_ERROR), EvaluationError::ConfigurationParseError => (ERROR_CONFIG_PARSE, REASON_ERROR), EvaluationError::FlagConfigurationInvalid => (ERROR_CONFIG_PARSE, REASON_ERROR), EvaluationError::ConfigurationMissing => (ERROR_CONFIG_MISSING, REASON_ERROR), @@ -420,6 +431,7 @@ mod tests { CharSlice::from(""), std::ptr::null(), 0, + false, ); assert!(result.valid); @@ -433,6 +445,59 @@ mod tests { clear_config(); } + #[test] + fn immediate_opt_out_skips_logging_and_rejects_invalid_context() { + setup_zend_string_functions(); + clear_config(); + assert!(ddog_ffe_load_config(CharSlice::from( + EMPTY_TARGETING_KEY_CONFIG + ))); + let attr = FfeAttribute { + key: CharSlice::from("dd_experimentation_opt_out_at"), + value_type: ATTR_TYPE_STRING, + string_value: CharSlice::from("2099-01-01T00:00:00Z"), + number_value: 0.0, + bool_value: false, + }; + let result = ddog_ffe_evaluate( + CharSlice::from("empty.targeting.shard.flag"), + TYPE_STRING, + CharSlice::from(""), + &attr, + 1, + true, + ); + assert!(result.valid); + assert_eq!(result.reason, REASON_DEFAULT); + assert_eq!(result.error_code, ERROR_NONE); + assert!(!result.do_log); + let invalid = FfeAttribute { + value_type: ATTR_TYPE_BOOL, + ..attr + }; + let result = ddog_ffe_evaluate( + CharSlice::from("empty.targeting.shard.flag"), + TYPE_STRING, + CharSlice::from(""), + &invalid, + 1, + true, + ); + assert_eq!(result.error_code, ERROR_INVALID_CONTEXT); + assert!(!result.do_log); + let result = ddog_ffe_evaluate( + CharSlice::from("empty.targeting.shard.flag"), + TYPE_STRING, + CharSlice::from(""), + &invalid, + 1, + false, + ); + assert_eq!(result.error_code, ERROR_NONE); + assert!(result.do_log); + clear_config(); + } + #[test] fn configuration_state_is_thread_local() { clear_config(); diff --git a/libdatadog b/libdatadog index 634af2c847b..dabc13f99b8 160000 --- a/libdatadog +++ b/libdatadog @@ -1 +1 @@ -Subproject commit 634af2c847b1d0b7f6f89dff4f715a7828da93c4 +Subproject commit dabc13f99b8fa77d45520f7d0e04858a49e85ceb diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index 72de20ca32f..5077c62361f 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -411,6 +411,13 @@ "default": "true" } ], + "DD_FEATURE_FLAGS_EXPERIMENTATION_OPT_OUT_MODE": [ + { + "implementation": "A", + "type": "string", + "default": "disabled" + } + ], "DD_GIT_COMMIT_SHA": [ { "implementation": "B", diff --git a/src/DDTrace/OpenFeature/DataDogProvider.php b/src/DDTrace/OpenFeature/DataDogProvider.php index 122eb2ed2c3..1c8c88f0c1c 100644 --- a/src/DDTrace/OpenFeature/DataDogProvider.php +++ b/src/DDTrace/OpenFeature/DataDogProvider.php @@ -35,11 +35,11 @@ final class DataDogProvider extends AbstractProvider private bool $warnedAboutRuntimeNotReady = false; private EvaluationMetricRecorder $metricRecorder; - public function __construct(?LoggerInterface $logger = null) + public function __construct(?LoggerInterface $logger = null, ?string $experimentationOptOutMode = null) { // 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, $experimentationOptOutMode); $this->datadogLogger = new NonThrowingLogger($logger ?: GlobalLogger::get()); $this->metricRecorder = EvaluationMetricRecorder::createDefault(); } @@ -204,6 +204,10 @@ private function normalizeContext(?EvaluationContext $context): array $attributes = []; foreach ($context->getAttributes()->toArray() as $key => $value) { + if ($key === 'dd_experimentation_opt_out_at') { + $attributes[$key] = $value; + continue; + } if (is_bool($value) || is_int($value) || is_float($value) || is_string($value)) { $attributes[(string) $key] = $value; } @@ -254,6 +258,7 @@ private function mapErrorCode(string $errorCode): ErrorCode EvaluationErrorCode::FLAG_NOT_FOUND => ErrorCode::FLAG_NOT_FOUND(), EvaluationErrorCode::PARSE_ERROR => ErrorCode::PARSE_ERROR(), EvaluationErrorCode::TYPE_MISMATCH => ErrorCode::TYPE_MISMATCH(), + EvaluationErrorCode::INVALID_CONTEXT => ErrorCode::INVALID_CONTEXT(), default => ErrorCode::GENERAL(), }; } diff --git a/src/api/FeatureFlags/Client.php b/src/api/FeatureFlags/Client.php index 7a8df017709..3780ba3377a 100644 --- a/src/api/FeatureFlags/Client.php +++ b/src/api/FeatureFlags/Client.php @@ -14,13 +14,13 @@ final class Client private $logger; private $warnedAboutRuntimeNotReady = false; - public function __construct($logger = null) + public function __construct($logger = null, $experimentationOptOutMode = null) { if ($logger !== null && !$logger instanceof LoggerInterface) { throw new \InvalidArgumentException('Expected a LoggerInterface instance'); } - $this->evaluator = NativeEvaluator::create(); + $this->evaluator = NativeEvaluator::create(true, $experimentationOptOutMode); $this->logger = new NonThrowingLogger($logger ?: GlobalLogger::get()); } @@ -132,6 +132,10 @@ private function normalizeContext(array $context) $attributes = array(); if (isset($context['attributes']) && is_array($context['attributes'])) { foreach ($context['attributes'] as $key => $value) { + if ($key === 'dd_experimentation_opt_out_at') { + $attributes[$key] = $value; + continue; + } if (is_bool($value) || is_int($value) || is_float($value) || is_string($value)) { $attributes[(string) $key] = $value; } diff --git a/src/api/FeatureFlags/EvaluationErrorCode.php b/src/api/FeatureFlags/EvaluationErrorCode.php index a8f9a722a22..f03ae97cfc4 100644 --- a/src/api/FeatureFlags/EvaluationErrorCode.php +++ b/src/api/FeatureFlags/EvaluationErrorCode.php @@ -9,6 +9,7 @@ final class EvaluationErrorCode const TYPE_MISMATCH = 'TYPE_MISMATCH'; const GENERAL = 'GENERAL'; const PROVIDER_NOT_READY = 'PROVIDER_NOT_READY'; + const INVALID_CONTEXT = 'INVALID_CONTEXT'; private static $valid = array( self::FLAG_NOT_FOUND => true, @@ -16,6 +17,7 @@ final class EvaluationErrorCode self::TYPE_MISMATCH => true, self::GENERAL => true, self::PROVIDER_NOT_READY => true, + self::INVALID_CONTEXT => true, ); private function __construct() diff --git a/src/api/FeatureFlags/ExperimentationOptOutMode.php b/src/api/FeatureFlags/ExperimentationOptOutMode.php new file mode 100644 index 00000000000..456f3c369e4 --- /dev/null +++ b/src/api/FeatureFlags/ExperimentationOptOutMode.php @@ -0,0 +1,42 @@ +mapper = $mapper ?: new ResultMapper(); $this->recordMetrics = $recordMetrics; + $this->experimentationOptOutImmediate = $experimentationOptOutMode === ExperimentationOptOutMode::IMMEDIATE; } public static function isAvailable() @@ -27,9 +30,10 @@ public static function isAvailable() return function_exists('DDTrace\\ffe_evaluate'); } - public static function create($recordMetrics = true) + public static function create($recordMetrics = true, $experimentationOptOutMode = null) { - return self::isAvailable() ? new self(null, $recordMetrics) : new UnavailableEvaluator(); + $mode = ExperimentationOptOutMode::resolve($experimentationOptOutMode); + return self::isAvailable() ? new self(null, $recordMetrics, $mode) : new UnavailableEvaluator(); } public function evaluate( @@ -44,7 +48,8 @@ public function evaluate( $this->typeId($expectedType), $targetingKey, $this->normalizeAttributes($attributes), - $this->recordMetrics + $this->recordMetrics, + $this->experimentationOptOutImmediate ); if (is_array($rawResult) || is_object($rawResult)) { @@ -86,6 +91,13 @@ private function normalizeAttributes(array $attributes) { $normalized = array(); foreach ($attributes as $key => $value) { + // Preserve a present invalid opt-out value through the scalar bridge. + // Rust validates the timestamp only when immediate mode is enabled. + if ($this->experimentationOptOutImmediate && + $key === 'dd_experimentation_opt_out_at' && !is_string($value)) { + $normalized[$key] = ''; + continue; + } if (is_bool($value) || is_int($value) || is_float($value) || is_string($value)) { $normalized[(string) $key] = $value; } diff --git a/src/api/FeatureFlags/Internal/ResultMapper.php b/src/api/FeatureFlags/Internal/ResultMapper.php index d263d64e085..948d1881692 100644 --- a/src/api/FeatureFlags/Internal/ResultMapper.php +++ b/src/api/FeatureFlags/Internal/ResultMapper.php @@ -20,6 +20,7 @@ final class ResultMapper const BRIDGE_ERROR_TYPE_MISMATCH = 1; const BRIDGE_ERROR_CONFIG_PARSE = 2; const BRIDGE_ERROR_FLAG_UNRECOGNIZED = 3; + const BRIDGE_ERROR_INVALID_CONTEXT = 5; const BRIDGE_ERROR_CONFIG_MISSING = 6; const BRIDGE_ERROR_GENERAL = 7; @@ -233,6 +234,8 @@ private function mapErrorCode($errorCode) return EvaluationErrorCode::FLAG_NOT_FOUND; case self::BRIDGE_ERROR_CONFIG_MISSING: return EvaluationErrorCode::PROVIDER_NOT_READY; + case self::BRIDGE_ERROR_INVALID_CONTEXT: + return EvaluationErrorCode::INVALID_CONTEXT; case self::BRIDGE_ERROR_GENERAL: default: return EvaluationErrorCode::GENERAL; diff --git a/tests/FeatureFlags/ffe-system-test-data b/tests/FeatureFlags/ffe-system-test-data index 0c467473aa8..71bb9069b84 160000 --- a/tests/FeatureFlags/ffe-system-test-data +++ b/tests/FeatureFlags/ffe-system-test-data @@ -1 +1 @@ -Subproject commit 0c467473aa8dc1f1cbfc0ef9a60d5c1c7a8826e6 +Subproject commit 71bb9069b8472b98570efca32a0c2c0b14dfffe2 diff --git a/tests/OpenFeature/DataDogProviderTest.php b/tests/OpenFeature/DataDogProviderTest.php index 1404395c075..0c288462b45 100644 --- a/tests/OpenFeature/DataDogProviderTest.php +++ b/tests/OpenFeature/DataDogProviderTest.php @@ -113,6 +113,33 @@ public function testUnavailableRuntimeReturnsDefaultDetailsAndOneWarning(): void self::assertSame([$details->getError()->getResolutionErrorMessage()], $logger->warnings()); } + public function testInvalidOptOutValueIsNotDroppedFromContext(): void + { + $evaluator = new OpenFeatureTestEvaluator(); + $evaluator->setInvalidContext('feature'); + $provider = $this->providerForEvaluator($evaluator); + $details = $provider->resolveStringValue('feature', 'fallback', new EvaluationContext( + 'subject', + new Attributes(['dd_experimentation_opt_out_at' => null]) + )); + + self::assertSame( + ['dd_experimentation_opt_out_at' => null], + $evaluator->getCalls()[0]['attributes'] + ); + self::assertSame('fallback', $details->getValue()); + self::assertSame( + ErrorCode::INVALID_CONTEXT()->getValue(), + $details->getError()->getResolutionErrorCode()->getValue() + ); + } + + public function testUnsupportedOptOutModeFailsDuringConstruction(): void + { + $this->expectException(\InvalidArgumentException::class); + new DataDogProvider(null, 'future'); + } + public function testProviderWarningIsEmittedOncePerProvider(): void { $logger = new OpenFeatureRecordingLogger(); @@ -251,6 +278,20 @@ public function setFlagNotFound(string $flagKey): self return $this; } + public function setInvalidContext(string $flagKey): self + { + $this->details[$flagKey] = new EvaluationDetails( + 'fallback', + EvaluationType::STRING, + EvaluationReason::ERROR, + null, + EvaluationErrorCode::INVALID_CONTEXT, + 'dd_experimentation_opt_out_at must be an RFC3339 timestamp string' + ); + + return $this; + } + public function evaluate($flagKey, $expectedType, $defaultValue, $targetingKey = null, array $attributes = []) { $this->calls[] = [ diff --git a/tests/api/Unit/FeatureFlags/ClientTest.php b/tests/api/Unit/FeatureFlags/ClientTest.php index 7ea2458f419..8239cf3c4c2 100644 --- a/tests/api/Unit/FeatureFlags/ClientTest.php +++ b/tests/api/Unit/FeatureFlags/ClientTest.php @@ -110,6 +110,19 @@ public function testUnavailableRuntimeReturnsDefaultWithProviderNotReadyDetailsA $this->assertSame(array($details->getErrorMessage()), $logger->warnings()); } + public function testInvalidOptOutAttributeIsNotDroppedDuringNormalization() + { + $evaluator = new ClientTestEvaluator(); + $evaluator->setSuccess('flag.context', 'on'); + $client = $this->clientForEvaluator($evaluator); + $client->getStringValue('flag.context', 'off', array( + 'attributes' => array('dd_experimentation_opt_out_at' => null), + )); + $calls = $evaluator->getCalls(); + $this->assertArrayHasKey('dd_experimentation_opt_out_at', $calls[0]['attributes']); + $this->assertNull($calls[0]['attributes']['dd_experimentation_opt_out_at']); + } + public function testWarningIsEmittedOncePerClientNotOncePerEvaluation() { $logger = new RecordingLogger(); diff --git a/tests/api/Unit/FeatureFlags/ExperimentationOptOutModeTest.php b/tests/api/Unit/FeatureFlags/ExperimentationOptOutModeTest.php new file mode 100644 index 00000000000..bca76f99276 --- /dev/null +++ b/tests/api/Unit/FeatureFlags/ExperimentationOptOutModeTest.php @@ -0,0 +1,42 @@ +assertSame($expected, ExperimentationOptOutMode::resolve($value)); + } + + public static function modeProvider() + { + return array( + array('disabled', 'disabled'), + array('immediate', 'immediate'), + array(' IMMEDIATE ', 'immediate'), + array('', 'disabled'), + ); + } + + /** + * @dataProvider invalidModeProvider + */ + public function testUnsupportedModeThrows($value) + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Experimentation opt-out mode must be disabled or immediate'); + ExperimentationOptOutMode::resolve($value); + } + + public static function invalidModeProvider() + { + return array(array('unknown'), array(true), array(1), array(array())); + } +} diff --git a/tests/ext/ffe/experimentation_opt_out_configuration.phpt b/tests/ext/ffe/experimentation_opt_out_configuration.phpt new file mode 100644 index 00000000000..0436af1daf5 --- /dev/null +++ b/tests/ext/ffe/experimentation_opt_out_configuration.phpt @@ -0,0 +1,73 @@ +--TEST-- +FFE immediate opt-out uses SDK configuration and preserves explicit overrides +--ENV-- +DD_TRACE_GENERATE_ROOT_SPAN=0 +DD_FEATURE_FLAGS_EXPERIMENTATION_OPT_OUT_MODE= IMMEDIATE +--FILE-- + '19', 'attributes' => array('country' => 'UK')); +$cases = array( + 'absent' => array(false, null), + 'valid' => array(true, '2026-10-19T12:00:00Z'), + 'future' => array(true, '2099-01-01T00:00:00.123456789+05:30'), + 'null' => array(true, null), + 'boolean' => array(true, true), + 'array' => array(true, array()), + 'empty' => array(true, ''), + 'malformed' => array(true, 'invalid'), +); +foreach ($cases as $name => $case) { + $subject = $context; + if ($case[0]) { + $subject['attributes']['dd_experimentation_opt_out_at'] = $case[1]; + } + $details = $client->getStringDetails('new-user-onboarding', 'fallback', $subject); + echo $name . '=' . $details->getValue() . ',' . ($details->getErrorCode() ?: 'OK') . "\n"; + echo $name . '_disabled=' . $disabled->getStringValue('new-user-onboarding', 'fallback', $subject) . "\n"; +} +$context['attributes']['dd_experimentation_opt_out_at'] = '2026-10-19T00:00:00Z'; +ini_set('datadog.feature_flags.experimentation_opt_out_mode', 'disabled'); +echo 'snapshot=' . $client->getStringValue('new-user-onboarding', 'fallback', $context) . "\n"; +echo 'new_client=' . (new \DDTrace\FeatureFlags\Client())->getStringValue('new-user-onboarding', 'fallback', $context) . "\n"; +ini_set('datadog.feature_flags.experimentation_opt_out_mode', 'invalid'); +try { + new \DDTrace\FeatureFlags\Client(); + echo "invalid_mode=accepted\n"; +} catch (\InvalidArgumentException $error) { + echo "invalid_mode=rejected\n"; +} +echo 'override=' . (new \DDTrace\FeatureFlags\Client(null, 'disabled'))->getStringValue('new-user-onboarding', 'fallback', $context) . "\n"; +?> +--EXPECT-- +absent=red,OK +absent_disabled=red +valid=fallback,OK +valid_disabled=red +future=fallback,OK +future_disabled=red +null=fallback,INVALID_CONTEXT +null_disabled=red +boolean=fallback,INVALID_CONTEXT +boolean_disabled=red +array=fallback,INVALID_CONTEXT +array_disabled=red +empty=fallback,INVALID_CONTEXT +empty_disabled=red +malformed=fallback,INVALID_CONTEXT +malformed_disabled=red +snapshot=fallback +new_client=red +invalid_mode=rejected +override=red diff --git a/tests/ext/ffe/system_test_data_evaluate.phpt b/tests/ext/ffe/system_test_data_evaluate.phpt index 8292184d8f1..7d366f05480 100644 --- a/tests/ext/ffe/system_test_data_evaluate.phpt +++ b/tests/ext/ffe/system_test_data_evaluate.phpt @@ -60,9 +60,11 @@ if (count($caseFiles) === 0) { throw new \RuntimeException('no evaluation-case fixture files found under ' . $fixtureRoot); } -$client = new \DDTrace\FeatureFlags\Client(new FfeFixtureLogger()); +$client = new \DDTrace\FeatureFlags\Client(new FfeFixtureLogger(), 'disabled'); +$immediateClient = new \DDTrace\FeatureFlags\Client(new FfeFixtureLogger(), 'immediate'); $caseCount = 0; +$immediateCaseCount = 0; $failures = array(); foreach ($caseFiles as $caseFile) { $cases = decode_json_file($caseFile); @@ -75,6 +77,11 @@ foreach ($caseFiles as $caseFile) { $caseCount++; try { run_fixture_case($client, basename($caseFile), $index, $case, $failures); + if (isset($case['capabilities']['experimentationOptOutImmediate'])) { + $immediateCaseCount++; + $case['result'] = $case['capabilities']['experimentationOptOutImmediate']; + run_fixture_case($immediateClient, basename($caseFile), $index, $case, $failures, true); + } } catch (\Throwable $exception) { $failures[] = basename($caseFile) . '#' . $index . ': ' . $exception->getMessage(); } @@ -87,6 +94,7 @@ foreach ($failures as $failure) { show('fixture_files', count($caseFiles)); show('cases', $caseCount); +show('immediate_cases', $immediateCaseCount); show('failures', count($failures)); function require_feature_flag_api($root) @@ -123,6 +131,7 @@ function require_feature_flag_api($root) 'EvaluationType', 'EvaluationReason', 'EvaluationErrorCode', + 'ExperimentationOptOutMode', 'EvaluationDetails', 'SpanEnrichmentAccumulator', 'SpanEnrichmentRegistry', @@ -143,7 +152,7 @@ function require_feature_flag_api($root) require_once $apiRoot . '/Client.php'; } -function run_fixture_case($client, $fileName, $index, array $case, array &$failures) +function run_fixture_case($client, $fileName, $index, array $case, array &$failures, $checkDetails = false) { foreach (array('flag', 'variationType', 'defaultValue', 'targetingKey', 'attributes', 'result') as $requiredKey) { if (!array_key_exists($requiredKey, $case)) { @@ -185,6 +194,14 @@ function run_fixture_case($client, $fileName, $index, array $case, array &$failu . ': value got=' . encode_value($details->getValue()) . ' want=' . encode_value($case['result']['value']); } + if ($checkDetails) { + if ($details->getReason() !== $case['result']['reason']) { + $failures[] = $fileName . '#' . $index . ': unexpected immediate reason ' . $details->getReason(); + } + if ($details->getErrorCode() !== ($case['result']['errorCode'] ?? null)) { + $failures[] = $fileName . '#' . $index . ': unexpected immediate error ' . $details->getErrorCode(); + } + } } function evaluate_fixture_case($client, $variationType, $flag, $defaultValue, array $context) @@ -290,4 +307,5 @@ function show($label, $value) config_loaded=true fixture_files=%d cases=%d +immediate_cases=18 failures=0 diff --git a/tracer/configuration.h b/tracer/configuration.h index ca83da9d11e..8a501287f51 100644 --- a/tracer/configuration.h +++ b/tracer/configuration.h @@ -136,6 +136,7 @@ CONFIG(BOOL, DD_INTEGRATION_METRICS_ENABLED, "true", \ .env_config_fallback = ddtrace_conf_otel_metrics_exporter) \ CONFIG(BOOL, DD_METRICS_OTEL_ENABLED, "false") \ + CONFIG(STRING, DD_FEATURE_FLAGS_EXPERIMENTATION_OPT_OUT_MODE, "disabled") \ CONFIG(BOOL, DD_LOGS_OTEL_ENABLED, "false") \ CONFIG(BOOL, DD_TRACE_OTEL_ENABLED, "false") \ CONFIG(STRING, DD_OPENAI_SERVICE, "") \ diff --git a/tracer/ddtrace.stub.php b/tracer/ddtrace.stub.php index adac5b1f10c..3890471619d 100644 --- a/tracer/ddtrace.stub.php +++ b/tracer/ddtrace.stub.php @@ -907,7 +907,7 @@ function flush_endpoints(): void {} * * @internal Used by the Datadog feature flag client. */ - function ffe_evaluate(string $flagKey, int $expectedType, ?string $targetingKey, array $attributes, bool $recordMetric = true): ?FfeResult {} + function ffe_evaluate(string $flagKey, int $expectedType, ?string $targetingKey, array $attributes, bool $recordMetric = true, bool $experimentationOptOutImmediate = false): ?FfeResult {} /** * Check if FFE (Feature Flag Evaluation) configuration is loaded. diff --git a/tracer/ddtrace_arginfo.h b/tracer/ddtrace_arginfo.h index afa0f62d9f7..2a6fe227634 100644 --- a/tracer/ddtrace_arginfo.h +++ b/tracer/ddtrace_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit ddtrace.stub.php instead. - * Stub hash: b3087c1f239d5aa8ea38f875b7236f47e56a1ca7 */ + * Stub hash: aa44838ce45eb7e073b3fe7ba7efcd40f6af32cd */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_trace_method, 0, 3, _IS_BOOL, 0) ZEND_ARG_TYPE_INFO(0, className, IS_STRING, 0) @@ -182,6 +182,7 @@ ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_DDTrace_ffe_evaluate, 0, 4, DDTra ZEND_ARG_TYPE_INFO(0, targetingKey, IS_STRING, 1) ZEND_ARG_TYPE_INFO(0, attributes, IS_ARRAY, 0) ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, recordMetric, _IS_BOOL, 0, "true") + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, experimentationOptOutImmediate, _IS_BOOL, 0, "false") ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_ffe_has_config, 0, 0, _IS_BOOL, 0) diff --git a/tracer/functions.c b/tracer/functions.c index 5657740cb11..3bade59b14d 100644 --- a/tracer/functions.c +++ b/tracer/functions.c @@ -1715,6 +1715,8 @@ static const char *ddtrace_ffe_error_name(int32_t error_code) { return "PARSE_ERROR"; case 3: return "FLAG_NOT_FOUND"; + case 5: + return "INVALID_CONTEXT"; case 6: return "PROVIDER_NOT_READY"; case 7: @@ -1807,17 +1809,19 @@ PHP_FUNCTION(DDTrace_ffe_evaluate) { zval *value; struct ddog_FfeResult result; zend_bool record_metric = true; + zend_bool experimentation_opt_out_immediate = false; zend_string *value_json; zend_string *variant; zend_string *allocation_key; - ZEND_PARSE_PARAMETERS_START(4, 5) + ZEND_PARSE_PARAMETERS_START(4, 6) Z_PARAM_STR(flag_key) Z_PARAM_LONG(type_id_zl) Z_PARAM_STR_OR_NULL(targeting_key) Z_PARAM_ARRAY(attrs_zv) Z_PARAM_OPTIONAL Z_PARAM_BOOL(record_metric) + Z_PARAM_BOOL(experimentation_opt_out_immediate) ZEND_PARSE_PARAMETERS_END(); type_id = (int32_t) type_id_zl; @@ -1861,6 +1865,12 @@ PHP_FUNCTION(DDTrace_ffe_evaluate) { c_attrs[idx].bool_value = false; break; default: + if (experimentation_opt_out_immediate && + zend_string_equals_literal(key, "dd_experimentation_opt_out_at")) { + c_attrs[idx].value_type = 0; + c_attrs[idx].string_value = DDOG_CHARSLICE_C(""); + break; + } if (owned_key) { zend_string_release(owned_key); } @@ -1879,7 +1889,8 @@ PHP_FUNCTION(DDTrace_ffe_evaluate) { type_id, dd_zend_string_to_CharSlice(targeting_key), c_attrs, - attrs_count + attrs_count, + experimentation_opt_out_immediate ); if (c_attrs) { efree(c_attrs); From a6db75070743aa6e15b03a620e0f52f014595e98 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 19 Oct 2026 23:29:45 -0400 Subject: [PATCH 2/6] fix(ffe): synchronize the native evaluation declaration --- components-rs/datadog.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/components-rs/datadog.h b/components-rs/datadog.h index be764525f34..5a93357088b 100644 --- a/components-rs/datadog.h +++ b/components-rs/datadog.h @@ -142,7 +142,8 @@ struct ddog_FfeResult ddog_ffe_evaluate(ddog_CharSlice flag_key, int32_t expected_type, ddog_CharSlice targeting_key, const struct ddog_FfeAttribute *attributes, - uintptr_t attributes_count); + uintptr_t attributes_count, + bool experimentation_opt_out_immediate); bool ddog_shall_log(enum ddog_Log category); From c25ade2a9f0b8e22937eb60edd57f80c7a4aea79 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 20 Oct 2026 00:04:33 -0400 Subject: [PATCH 3/6] test: use canonical experimentation opt-out INI name --- tests/ext/ffe/experimentation_opt_out_configuration.phpt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/ext/ffe/experimentation_opt_out_configuration.phpt b/tests/ext/ffe/experimentation_opt_out_configuration.phpt index 0436af1daf5..6ae043aa6f1 100644 --- a/tests/ext/ffe/experimentation_opt_out_configuration.phpt +++ b/tests/ext/ffe/experimentation_opt_out_configuration.phpt @@ -38,10 +38,10 @@ foreach ($cases as $name => $case) { echo $name . '_disabled=' . $disabled->getStringValue('new-user-onboarding', 'fallback', $subject) . "\n"; } $context['attributes']['dd_experimentation_opt_out_at'] = '2026-10-19T00:00:00Z'; -ini_set('datadog.feature_flags.experimentation_opt_out_mode', 'disabled'); +ini_set('datadog.feature_flags_experimentation_opt_out_mode', 'disabled'); echo 'snapshot=' . $client->getStringValue('new-user-onboarding', 'fallback', $context) . "\n"; echo 'new_client=' . (new \DDTrace\FeatureFlags\Client())->getStringValue('new-user-onboarding', 'fallback', $context) . "\n"; -ini_set('datadog.feature_flags.experimentation_opt_out_mode', 'invalid'); +ini_set('datadog.feature_flags_experimentation_opt_out_mode', 'invalid'); try { new \DDTrace\FeatureFlags\Client(); echo "invalid_mode=accepted\n"; From aa25210af3804ea362c1dfd8ee49dfe928e2af6d Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 20 Oct 2026 11:06:05 -0400 Subject: [PATCH 4/6] chore: update native opt-out dependency after main merge --- libdatadog | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libdatadog b/libdatadog index dabc13f99b8..20fc742af6d 160000 --- a/libdatadog +++ b/libdatadog @@ -1 +1 @@ -Subproject commit dabc13f99b8fa77d45520f7d0e04858a49e85ceb +Subproject commit 20fc742af6d1449d518fdd487bb3b0acff028c12 From 50de75fa3a04431dd4bef3a4ff035249cc5ddb2e Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 20 Oct 2026 11:11:15 -0400 Subject: [PATCH 5/6] test: load opt-out client from PECL installation --- .../experimentation_opt_out_configuration.phpt | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/ext/ffe/experimentation_opt_out_configuration.phpt b/tests/ext/ffe/experimentation_opt_out_configuration.phpt index 6ae043aa6f1..708fa583e0d 100644 --- a/tests/ext/ffe/experimentation_opt_out_configuration.phpt +++ b/tests/ext/ffe/experimentation_opt_out_configuration.phpt @@ -6,9 +6,20 @@ DD_FEATURE_FLAGS_EXPERIMENTATION_OPT_OUT_MODE= IMMEDIATE --FILE-- Date: Tue, 20 Oct 2026 18:21:18 -0400 Subject: [PATCH 6/6] Refresh immediate opt-out fixtures and native validation --- Cargo.lock | 16 ++++++-- Cargo.toml | 2 + libdatadog | 2 +- tests/FeatureFlags/ffe-system-test-data | 2 +- tests/OpenFeature/DataDogProviderTest.php | 37 +++++++++++++++++-- ...experimentation_opt_out_configuration.phpt | 3 ++ tests/ext/ffe/system_test_data_evaluate.phpt | 6 +-- 7 files changed, 56 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ac76c002a14..50c7c00313f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2801,6 +2801,8 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tower-service", + "webpki-root-certs", + "webpki-roots", "windows-sys 0.52.0", ] @@ -3304,6 +3306,7 @@ name = "libdd-tinybytes" version = "1.1.5" dependencies = [ "libdd-tinybytes", + "libdd-trace-model", "once_cell", "pretty_assertions", "proptest", @@ -3313,6 +3316,10 @@ dependencies = [ "test-case", ] +[[package]] +name = "libdd-trace-model" +version = "0.1.0" + [[package]] name = "libdd-trace-normalization" version = "4.2.0" @@ -3417,6 +3424,7 @@ dependencies = [ "libdd-capabilities-impl", "libdd-common", "libdd-tinybytes", + "libdd-trace-model", "libdd-trace-normalization", "libdd-trace-protobuf", "libdd-trace-utils", @@ -6558,18 +6566,18 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.6" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb" dependencies = [ "rustls-pki-types", ] [[package]] name = "webpki-roots" -version = "1.0.6" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] diff --git a/Cargo.toml b/Cargo.toml index b789d59ef89..ebea161fc3d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -341,6 +341,8 @@ walkdir = { version = "2.4", default-features = false } wasm-bindgen-futures = { version = "0.4", default-features = false } wasm-bindgen-test = { version = "=0.3.50", default-features = false } web-time = { version = "1", default-features = false } +webpki-roots = { version = "1.0.10", default-features = false } +webpki-root-certs = { version = "1.0.10", default-features = false } windows = { version = "0.59.0", default-features = false } windows-sys = { version = "0.52", default-features = false } winver = { version = "1.0.0", default-features = false } diff --git a/libdatadog b/libdatadog index 20fc742af6d..02000478129 160000 --- a/libdatadog +++ b/libdatadog @@ -1 +1 @@ -Subproject commit 20fc742af6d1449d518fdd487bb3b0acff028c12 +Subproject commit 020004781291bd9178cd91868f53291bfed550f7 diff --git a/tests/FeatureFlags/ffe-system-test-data b/tests/FeatureFlags/ffe-system-test-data index 71bb9069b84..52e43dc2693 160000 --- a/tests/FeatureFlags/ffe-system-test-data +++ b/tests/FeatureFlags/ffe-system-test-data @@ -1 +1 @@ -Subproject commit 71bb9069b8472b98570efca32a0c2c0b14dfffe2 +Subproject commit 52e43dc2693e5af14fd60210f9471d0dc08ce73f diff --git a/tests/OpenFeature/DataDogProviderTest.php b/tests/OpenFeature/DataDogProviderTest.php index 0c288462b45..f7d264a7b02 100644 --- a/tests/OpenFeature/DataDogProviderTest.php +++ b/tests/OpenFeature/DataDogProviderTest.php @@ -113,18 +113,19 @@ public function testUnavailableRuntimeReturnsDefaultDetailsAndOneWarning(): void self::assertSame([$details->getError()->getResolutionErrorMessage()], $logger->warnings()); } - public function testInvalidOptOutValueIsNotDroppedFromContext(): void + /** @dataProvider invalidOptOutValues */ + public function testInvalidOptOutValueIsNotDroppedFromContext($value): void { $evaluator = new OpenFeatureTestEvaluator(); $evaluator->setInvalidContext('feature'); $provider = $this->providerForEvaluator($evaluator); $details = $provider->resolveStringValue('feature', 'fallback', new EvaluationContext( 'subject', - new Attributes(['dd_experimentation_opt_out_at' => null]) + new Attributes(['dd_experimentation_opt_out_at' => $value]) )); self::assertSame( - ['dd_experimentation_opt_out_at' => null], + ['dd_experimentation_opt_out_at' => $value], $evaluator->getCalls()[0]['attributes'] ); self::assertSame('fallback', $details->getValue()); @@ -134,6 +135,36 @@ public function testInvalidOptOutValueIsNotDroppedFromContext(): void ); } + public static function invalidOptOutValues(): array + { + return [ + 'null' => [null], + 'array' => [[]], + 'object' => [(object) ['timestamp' => '2026-10-19T12:00:00Z']], + ]; + } + + /** @dataProvider invalidOptOutValues */ + public function testNativeBridgePreservesInvalidOptOutValues($value): void + { + $class = new \ReflectionClass(\DDTrace\FeatureFlags\Internal\NativeEvaluator::class); + $evaluator = $class->newInstanceWithoutConstructor(); + $mode = $class->getProperty('experimentationOptOutImmediate'); + $mode->setAccessible(true); + $normalize = $class->getMethod('normalizeAttributes'); + $normalize->setAccessible(true); + $attributes = ['dd_experimentation_opt_out_at' => $value, 'plan' => 'pro']; + + $mode->setValue($evaluator, true); + self::assertSame( + ['dd_experimentation_opt_out_at' => '', 'plan' => 'pro'], + $normalize->invoke($evaluator, $attributes) + ); + $mode->setValue($evaluator, false); + self::assertSame(['plan' => 'pro'], $normalize->invoke($evaluator, $attributes)); + self::assertSame($value, $attributes['dd_experimentation_opt_out_at']); + } + public function testUnsupportedOptOutModeFailsDuringConstruction(): void { $this->expectException(\InvalidArgumentException::class); diff --git a/tests/ext/ffe/experimentation_opt_out_configuration.phpt b/tests/ext/ffe/experimentation_opt_out_configuration.phpt index 708fa583e0d..3f31b1b46d6 100644 --- a/tests/ext/ffe/experimentation_opt_out_configuration.phpt +++ b/tests/ext/ffe/experimentation_opt_out_configuration.phpt @@ -36,6 +36,7 @@ $cases = array( 'null' => array(true, null), 'boolean' => array(true, true), 'array' => array(true, array()), + 'object' => array(true, (object) array('timestamp' => '2026-10-19T12:00:00Z')), 'empty' => array(true, ''), 'malformed' => array(true, 'invalid'), ); @@ -74,6 +75,8 @@ boolean=fallback,INVALID_CONTEXT boolean_disabled=red array=fallback,INVALID_CONTEXT array_disabled=red +object=fallback,INVALID_CONTEXT +object_disabled=red empty=fallback,INVALID_CONTEXT empty_disabled=red malformed=fallback,INVALID_CONTEXT diff --git a/tests/ext/ffe/system_test_data_evaluate.phpt b/tests/ext/ffe/system_test_data_evaluate.phpt index 7d366f05480..7338d3d00c1 100644 --- a/tests/ext/ffe/system_test_data_evaluate.phpt +++ b/tests/ext/ffe/system_test_data_evaluate.phpt @@ -225,8 +225,8 @@ function evaluate_fixture_case($client, $variationType, $flag, $defaultValue, ar function values_match($actual, $expected, $variationType) { if ($variationType === 'NUMERIC') { - return is_numeric($actual) - && is_numeric($expected) + return (is_int($actual) || is_float($actual)) + && (is_int($expected) || is_float($expected)) && abs((float) $actual - (float) $expected) < 0.000001; } @@ -307,5 +307,5 @@ function show($label, $value) config_loaded=true fixture_files=%d cases=%d -immediate_cases=18 +immediate_cases=26 failures=0