Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
4ac8205
Add bounded feature flag evaluation EVP reporting
leoromanovsky Oct 8, 2026
8a2dea0
Fix FFE test portability and bound recorder stress arguments
leoromanovsky Oct 8, 2026
489f701
Resolve PHP API source layouts in native EVP child fixtures
leoromanovsky Oct 8, 2026
4dc52e6
Avoid blocking child pipes and Windows shell quoting in FFE fixtures
leoromanovsky Oct 8, 2026
f3dc709
Merge remote-tracking branch 'origin/master' into leo.romanovsky/php-…
vjfridge Oct 14, 2026
a722d56
fix(ffe): preserve evaluation timestamps and original identity validity
vjfridge Oct 14, 2026
ea71f9c
feat(ffe): flush pending evaluations and report submission drops
vjfridge Oct 14, 2026
6521c44
test(ffe): cover forked drop counters and late event delivery
vjfridge Oct 14, 2026
bac4b23
fix(ffe): flush worker evaluations through shared sidecar
vjfridge Oct 15, 2026
3a80989
fix(ffe): preserve test-session routing and harden lifecycle fixtures
vjfridge Oct 15, 2026
1839b2f
test(ffe): harden CI capture and instrumented lifecycle checks
vjfridge Oct 16, 2026
8d74888
Merge remote-tracking branch 'origin/master' into leo.romanovsky/php-…
vjfridge Oct 16, 2026
ce0ad1f
fix: release sidecar shutdown flush errors
vjfridge Oct 17, 2026
1a607d8
Merge remote-tracking branch 'origin/master' into leo.romanovsky/php-…
vjfridge Oct 19, 2026
1b71b3d
fix(ffe): preserve terminal outcomes and lifecycle metadata
vjfridge Oct 19, 2026
a699c25
Address FFE test placement and simplify review follow-ups
vjfridge Oct 19, 2026
986e074
Defer flagevaluation application identity to the sidecar
vjfridge Oct 19, 2026
5ccd2ef
test(ffe): skip exit leak scan for signal flush test
vjfridge Oct 20, 2026
37e3d84
Merge remote-tracking branch 'origin/master' into leo.romanovsky/php-…
vjfridge Oct 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .claude/ci/tracer-unit-tests.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Tracer Unit Tests and Native Extension Tests

## Choosing a test suite

- `tests/ext/` tests the extension's exposed API directly. Do not load `src/`
classes from these tests: packaged extension tests may run without the
repository's PHP sources or Composer dependencies.
- Tests of `src/api/` belong in the API PHPUnit suite (`tests/api/Unit/`).
OpenFeature integration tests belong in `tests/OpenFeature/`.
Using native extension functions does not make a PHP-class test an
extension-only test. PHPUnit can run `.phpt` files as well as test classes.
- When moving tests, check suite discovery and the CI PHP-version matrix.
Keep native-client coverage on PHP 7; OpenFeature's suite requires PHP 8+.

## CI Jobs

**Source:**
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -1701,7 +1701,7 @@ merge_coverage_reports:
API_TESTS_ROOT := ./tests/api

test_api_unit: composer.lock global_test_run_dependencies
$(ENV_OVERRIDE) php $(TRACER_SOURCES_INI) vendor/bin/phpunit --config=phpunit.xml $(API_TESTS_ROOT)/Unit $(TESTS)
$(ENV_OVERRIDE) php $(TRACER_SOURCES_INI) vendor/bin/phpunit --config=phpunit.xml --test-suffix=Test.php,.phpt $(API_TESTS_ROOT)/Unit $(TESTS)

# Just test it does not crash, i.e. the exit code
test_internal_api_randomized: $(SO_FILE)
Expand Down
1 change: 1 addition & 0 deletions components-rs/common.h
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,7 @@ typedef struct ddog_FfeResult {
int32_t serial_id;
bool has_serial_id;
bool do_log;
bool observe_full_evaluation_data;
bool valid;
} ddog_FfeResult;

Expand Down
51 changes: 50 additions & 1 deletion components-rs/ffe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ pub struct FfeResult {
pub serial_id: i32,
pub has_serial_id: bool,
pub do_log: bool,
// Consent belongs to the configuration snapshot used for this evaluation.
pub observe_full_evaluation_data: bool,
pub valid: bool,
}

Expand Down Expand Up @@ -166,7 +168,12 @@ pub extern "C" fn ddog_ffe_evaluate(
ffe::now(),
);

result_from_assignment(assignment)
let mut result = result_from_assignment(assignment);
result.observe_full_evaluation_data = state
.config
.as_ref()
.is_some_and(Configuration::observe_full_evaluation_data);
result
})
}

Expand Down Expand Up @@ -231,6 +238,7 @@ fn result_from_assignment(assignment: Result<ffe::Assignment, EvaluationError>)
serial_id: assignment.serial_id.unwrap_or(0),
has_serial_id: assignment.serial_id.is_some(),
do_log: assignment.do_log,
observe_full_evaluation_data: false,
valid: true,
}
}
Expand All @@ -257,6 +265,7 @@ fn result_from_assignment(assignment: Result<ffe::Assignment, EvaluationError>)
serial_id: 0,
has_serial_id: false,
do_log: false,
observe_full_evaluation_data: false,
valid: true,
}
}
Expand All @@ -273,6 +282,7 @@ fn invalid_result() -> FfeResult {
serial_id: 0,
has_serial_id: false,
do_log: false,
observe_full_evaluation_data: false,
valid: false,
}
}
Expand Down Expand Up @@ -433,6 +443,45 @@ mod tests {
clear_config();
}

#[test]
fn evaluation_captures_consent_from_its_configuration() {
setup_zend_string_functions();
clear_config();
let flag_key = CString::new("empty.targeting.shard.flag").unwrap();
let evaluate = |value_type| {
ddog_ffe_evaluate(
char_slice(&flag_key),
value_type,
CharSlice::from(""),
std::ptr::null(),
0,
)
};
assert!(!evaluate(TYPE_STRING).observe_full_evaluation_data);

let mut config: serde_json::Value =
serde_json::from_str(EMPTY_TARGETING_KEY_CONFIG).unwrap();
config["observeFullEvaluationData"] = true.into();
let json = CString::new(config.to_string()).unwrap();
assert!(ddog_ffe_load_config(char_slice(&json)));
let allowed = evaluate(TYPE_STRING);
assert!(allowed.valid);
assert!(allowed.observe_full_evaluation_data);
// Error observations use the same consent as successful assignments.
let mismatch = evaluate(TYPE_BOOLEAN);
assert_ne!(mismatch.error_code, ERROR_NONE);
assert!(mismatch.observe_full_evaluation_data);

config["observeFullEvaluationData"] = false.into();
let json = CString::new(config.to_string()).unwrap();
assert!(ddog_ffe_load_config(char_slice(&json)));
assert!(!evaluate(TYPE_STRING).observe_full_evaluation_data);
// Reloading config must not retroactively change the prior outcome.
assert!(allowed.observe_full_evaluation_data);
clear_config();
assert!(!evaluate(TYPE_STRING).observe_full_evaluation_data);
}

#[test]
fn configuration_state_is_thread_local() {
clear_config();
Expand Down
28 changes: 27 additions & 1 deletion ext/sidecar.c
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ static void dd_sidecar_arm_signal_transport(ddog_SidecarTransport *owner, ddog_S
ddog_SignalFlush *flush = NULL;
if (!datadog_ffi_try("Failed preparing sidecar signal flush",
ddog_sidecar_prepare_signal_flush(
connection, (ddog_SidecarFlushOptions){.traces_and_stats = true}, &flush))) {
connection, (ddog_SidecarFlushOptions){.traces_and_stats = true, .flag_evaluations = true}, &flush))) {
// Clears the stale flush while still the owner, then lets a later RINIT retry.
datadog_signals_set_sidecar_flush(NULL, true);
atomic_store(&dd_sidecar_signal_owner, 0);
Expand Down Expand Up @@ -186,6 +186,20 @@ static void dd_sidecar_on_connect(ddog_SidecarTransport **transport) {
}
}

#ifdef DDTRACE
static void dd_sidecar_configure_ffe_transport(ddog_SidecarTransport **transport) {
// Both FFE writers share this target. Replace it when the test token changes
// so new observations use the new session and queued ones keep the old one.
const ddog_EvpProducerIdentity ffe_producer = {
.origin = DDOG_CHARSLICE_C("dd-trace-php"),
.version = DDOG_CHARSLICE_C(PHP_DDTRACE_VERSION),
};
datadog_ffi_try("Failed configuring PHP FFE EVP transport",
ddog_sidecar_session_set_evp_transport(transport, DDOG_EVP_TRANSPORT_MODE_AGENT_ONLY,
datadog_endpoint, NULL, DDOG_CHARSLICE_C("event-platform-intake"), &ffe_producer));
}
#endif

// Sends the identity and configuration. Needed once per transport: the transport replays them after reconnects.
static void dd_sidecar_configure(ddog_SidecarTransport **transport) {
#ifdef _WIN32
Expand Down Expand Up @@ -251,6 +265,13 @@ static void dd_sidecar_configure(ddog_SidecarTransport **transport) {
ddog_endpoint_drop(otlp_metrics_endpoint);
}

#ifdef DDTRACE
// Configure during ordinary connection setup, including fork recovery.
// The transport replays its configuration after reconnects.
// Remote Config keeps the fixed Agent EVP v2 route.
dd_sidecar_configure_ffe_transport(transport);
#endif

if (get_global_DD_INSTRUMENTATION_TELEMETRY_ENABLED()) {
datadog_telemetry_register_services(transport);
}
Expand Down Expand Up @@ -1025,6 +1046,11 @@ bool datadog_alter_test_session_token(zval *old_value, zval *new_value, zend_str
if (DATADOG_G(sidecar)) {
datadog_ffi_try("Failed updating test session token",
ddog_sidecar_set_test_session_token(&DATADOG_G(sidecar), dd_zend_string_to_CharSlice(Z_STR_P(new_value))));
#ifdef DDTRACE
if (token_changed) {
dd_sidecar_configure_ffe_transport(&DATADOG_G(sidecar));
}
#endif
}
#if !defined(_WIN32) && defined(DDTRACE)
ddtrace_coms_set_test_session_token(Z_STRVAL_P(new_value), Z_STRLEN_P(new_value));
Expand Down
7 changes: 7 additions & 0 deletions metadata/supported-configurations.json
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,13 @@
"default": "true"
}
],
"DD_FLAGGING_EVALUATION_COUNTS_ENABLED": [
{
"implementation": "A",
"type": "boolean",
"default": "true"
}
],
"DD_GIT_COMMIT_SHA": [
{
"implementation": "B",
Expand Down
50 changes: 49 additions & 1 deletion src/DDTrace/OpenFeature/DataDogProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,44 @@ final class DataDogProvider extends AbstractProvider
private LoggerInterface $datadogLogger;
private bool $warnedAboutRuntimeNotReady = false;
private EvaluationMetricRecorder $metricRecorder;
private object $mainObservationScope;
private \WeakMap $fiberObservationScopes;

public function __construct(?LoggerInterface $logger = 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->mainObservationScope = (object) ['pending' => null, 'recorder' => null];
$this->fiberObservationScopes = new \WeakMap();
$this->evaluator = NativeEvaluator::create(false, function (array $observation): void {
$recorder = $this->observationScope()->recorder;
if ($recorder !== null) {
$recorder->capture($observation);
} else {
NativeEvaluator::submitFlagEvaluation($observation);
}
});
$this->datadogLogger = new NonThrowingLogger($logger ?: GlobalLogger::get());
$this->metricRecorder = EvaluationMetricRecorder::createDefault();
}

public function getHooks(): array
{
// The SDK asks once per invocation. A fresh hook keeps finalization
// state separate even when a before hook fails before ours runs.
return array_merge(parent::getHooks(), [new FlagEvaluationHook($this->observationScope())]);
}

private function observationScope(): object
{
$fiber = class_exists(\Fiber::class, false) ? \Fiber::getCurrent() : null;
if ($fiber === null) {
return $this->mainObservationScope;
}
return $this->fiberObservationScopes[$fiber]
?? ($this->fiberObservationScopes[$fiber] = (object) ['pending' => null, 'recorder' => null]);
}

/**
* @internal Tests and Datadog-owned bridge adapters only.
*/
Expand Down Expand Up @@ -111,6 +139,26 @@ private function resolve(
string $expectedType,
mixed $defaultValue,
?EvaluationContext $context
): ResolutionDetailsInterface {
$scope = $this->observationScope();
$observation = $scope->pending;
if ($observation !== null) {
$observation->claim();
}
$previous = $scope->recorder;
$scope->recorder = $observation;
try {
return $this->resolveWithObservation($flagKey, $expectedType, $defaultValue, $context);
} finally {
$scope->recorder = $previous;
}
}

private function resolveWithObservation(
string $flagKey,
string $expectedType,
mixed $defaultValue,
?EvaluationContext $context
): ResolutionDetailsInterface {
$normalizedContext = $this->normalizeContext($context);
$details = $this->evaluate($flagKey, $expectedType, $defaultValue, $normalizedContext);
Expand Down
99 changes: 99 additions & 0 deletions src/DDTrace/OpenFeature/FlagEvaluationHook.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

declare(strict_types=1);

namespace DDTrace\OpenFeature;

use DDTrace\FeatureFlags\Internal\NativeEvaluator;
use OpenFeature\interfaces\flags\EvaluationContext;
use OpenFeature\interfaces\hooks\Hook;
use OpenFeature\interfaces\hooks\HookContext;
use OpenFeature\interfaces\hooks\HookHints;
use OpenFeature\interfaces\provider\ResolutionDetails;
use OpenFeature\interfaces\provider\ThrowableWithResolutionError;

/** @internal One instance per OpenFeature evaluation, not a provider-wide last result. */
final class FlagEvaluationHook implements Hook
{
private object $scope;
private ?self $previous = null;
private ?array $observation = null;
private int $startedAtMs;

public function __construct(object $scope)
{
$this->scope = $scope;
$this->startedAtMs = (int) (microtime(true) * 1000);
}

public function supportsFlagValueType(string $flagValueType): bool
{
return true;
}

public function before(HookContext $context, HookHints $hints): ?EvaluationContext
{
$this->previous = $this->scope->pending;
$this->scope->pending = $this;
return null;
}

// The provider claims this invocation before resolving. Direct/nested
// provider calls must not overwrite its observation.
public function claim(): void
{
if ($this->scope->pending === $this) {
$this->scope->pending = $this->previous;
}
$this->previous = null;
}

public function capture(array $observation): void
{
$this->observation = $observation;
}

public function after(HookContext $context, ResolutionDetails $details, HookHints $hints): void
{
if ($this->observation === null) {
// No native snapshot (for example, an unavailable evaluator).
// Never infer consent from the current configuration.
$this->observation = $this->protectedObservation($context);
$this->observation[1] = $details->getVariant();
$this->observation[5] = $details->getError()
? $details->getError()->getResolutionErrorCode()->getValue() : null;
$this->observation[6] = $details->getVariant() === null;
}
}

public function error(HookContext $context, \Throwable $error, HookHints $hints): void
{
$this->observation = $this->observation ?? $this->protectedObservation($context);
// Before/after failures replace the SDK result with the caller default.
// Keep evaluated consent/time, but not the successful variant/allocation.
$this->observation[1] = null;
$this->observation[2] = null;
$this->observation[5] = $error instanceof ThrowableWithResolutionError
? $error->getResolutionError()->getResolutionErrorCode()->getValue() : 'GENERAL';
$this->observation[6] = true;
}

public function finally(HookContext $context, HookHints $hints): void
{
try {
if ($this->observation !== null) {
NativeEvaluator::submitFlagEvaluation($this->observation);
}
} finally {
$this->claim();
$this->observation = null;
}
}

private function protectedObservation(HookContext $context): array
{
return [$context->getFlagKey(), null, null,
$context->getEvaluationContext()->getTargetingKey(), [], 'GENERAL', true,
false, $this->startedAtMs, false];
}
}
4 changes: 3 additions & 1 deletion src/api/FeatureFlags/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,16 @@ public function getObjectDetails($flagKey, array $defaultValue, array $context =
private function evaluate($flagKey, $expectedType, $defaultValue, array $context)
{
$flagKey = $this->expectFlagKey($flagKey);
$targetingKeyInvalid = isset($context['targetingKey']) && !is_string($context['targetingKey']);
list($targetingKey, $attributes) = $this->normalizeContext($context);

$details = $this->evaluator->evaluate(
$flagKey,
$expectedType,
$defaultValue,
$targetingKey,
$attributes
$attributes,
$targetingKeyInvalid
);

$this->warnIfRuntimeNotReady($details);
Expand Down
3 changes: 2 additions & 1 deletion src/api/FeatureFlags/Internal/Evaluator.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ interface Evaluator
* @param mixed $defaultValue
* @param string|null $targetingKey
* @param array<string, bool|int|float|string> $attributes
* @param bool $targetingKeyInvalid Whether evaluation coerced a non-string identity.
* @return EvaluationDetails
*/
public function evaluate($flagKey, $expectedType, $defaultValue, $targetingKey = null, array $attributes = array());
public function evaluate($flagKey, $expectedType, $defaultValue, $targetingKey = null, array $attributes = array(), $targetingKeyInvalid = false);
}
Loading
Loading