diff --git a/Cargo.lock b/Cargo.lock index 6265fbfde55..44bce11ea2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1439,6 +1439,7 @@ dependencies = [ "nix 0.29.0", "prctl", "priority-queue", + "prost", "rand 0.8.5", "rmp-serde", "sendfd", diff --git a/components-rs/common.h b/components-rs/common.h index 44b4e3d7f10..bf0a5af1cf2 100644 --- a/components-rs/common.h +++ b/components-rs/common.h @@ -1227,6 +1227,28 @@ typedef struct ddog_FfeTelemetryContext { ddog_CharSlice version; } ddog_FfeTelemetryContext; +typedef struct ddog_FfeEvaluationMetric { + ddog_CharSlice flag_key; + ddog_CharSlice variant; + ddog_CharSlice reason; + ddog_CharSlice error_type; + ddog_CharSlice allocation_key; +} ddog_FfeEvaluationMetric; + +typedef struct ddog_Slice_FfeEvaluationMetric { + /** + * Should be non-null and suitably aligned for the underlying type. It is + * allowed but not recommended for the pointer to be null when the len is + * zero. + */ + const struct ddog_FfeEvaluationMetric *ptr; + /** + * The number of elements (not bytes) that `.ptr` points to. Must be less + * than or equal to [isize::MAX]. + */ + uintptr_t len; +} ddog_Slice_FfeEvaluationMetric; + typedef struct ddog_FfeExposure { uint64_t timestamp_ms; ddog_CharSlice flag_key; diff --git a/components-rs/sidecar.h b/components-rs/sidecar.h index 14887dfd3c3..09d5f0539d0 100644 --- a/components-rs/sidecar.h +++ b/components-rs/sidecar.h @@ -298,6 +298,23 @@ ddog_MaybeError ddog_sidecar_send_debugger_datum(struct ddog_SidecarTransport ** ddog_QueueId queue_id, struct ddog_DebuggerPayload *payload); +/** + * Send structured FFE evaluation metric events to the sidecar. The sidecar + * owns aggregation, OTLP/protobuf serialization, and OTLP HTTP delivery. This + * function is caller-driven so SDKs with existing host-language hooks can + * safely coexist until they explicitly migrate. + * + * # Safety + * `endpoint`, `context`, and every element in `metrics` must contain valid + * UTF-8 `CharSlice` values. Empty `endpoint` or `metrics` is a no-op. + */ +ddog_MaybeError ddog_sidecar_send_ffe_evaluation_metrics(struct ddog_SidecarTransport **transport, + const struct ddog_InstanceId *instance_id, + const ddog_QueueId *queue_id, + ddog_CharSlice endpoint, + const struct ddog_FfeTelemetryContext *context, + struct ddog_Slice_FfeEvaluationMetric metrics); + /** * Send structured FFE exposure events to the sidecar. The sidecar owns * deduplication, JSON serialization, and Agent EVP delivery. This function is diff --git a/ext/datadog.c b/ext/datadog.c index 4585aad81ef..f7f25ea0866 100644 --- a/ext/datadog.c +++ b/ext/datadog.c @@ -5,6 +5,7 @@ #include #include +#include #include #include "configuration.h" @@ -647,6 +648,7 @@ static PHP_RSHUTDOWN_FUNCTION(datadog) { #ifdef DDTRACE ddtrace_rshutdown(fast_shutdown); + datadog_ffe_flush_evaluation_metrics(); #endif datadog_sidecar_finalize(true); diff --git a/ext/datadog.h b/ext/datadog.h index c6f9fb8835e..6949a4feec5 100644 --- a/ext/datadog.h +++ b/ext/datadog.h @@ -69,6 +69,9 @@ ZEND_BEGIN_MODULE_GLOBALS(datadog) bool request_initialized; ddog_SidecarActionsBuffer *telemetry_buffer; ddog_SidecarActionsBuffer *metrics_buffer; + void *ffe_metric_buffer; + size_t ffe_metric_buffer_len; + size_t ffe_metric_buffer_cap; bool asm_event_emitted; diff --git a/src/DDTrace/OpenFeature/DataDogProvider.php b/src/DDTrace/OpenFeature/DataDogProvider.php index 257790a8b77..b42d928b999 100644 --- a/src/DDTrace/OpenFeature/DataDogProvider.php +++ b/src/DDTrace/OpenFeature/DataDogProvider.php @@ -4,10 +4,14 @@ namespace DDTrace\OpenFeature; -use DDTrace\FeatureFlags\Client as FeatureFlagsClient; use DDTrace\FeatureFlags\EvaluationDetails; use DDTrace\FeatureFlags\EvaluationErrorCode; use DDTrace\FeatureFlags\EvaluationReason; +use DDTrace\FeatureFlags\EvaluationType; +use DDTrace\FeatureFlags\Internal\Evaluator; +use DDTrace\FeatureFlags\Internal\Metric\EvaluationMetric; +use DDTrace\FeatureFlags\Internal\Metric\EvaluationMetricRecorder; +use DDTrace\FeatureFlags\Internal\NativeEvaluator; use DDTrace\Log\LoggerInterface; use DDTrace\Log\TriggerErrorLogger; use OpenFeature\implementation\provider\AbstractProvider; @@ -23,11 +27,37 @@ final class DataDogProvider extends AbstractProvider { protected static string $NAME = 'Datadog'; - private FeatureFlagsClient $client; + private Evaluator $evaluator; + private LoggerInterface $warningLogger; + private bool $warnedAboutNonProductionRuntime = false; + private EvaluationMetricRecorder $metricRecorder; public function __construct(?LoggerInterface $logger = null) { - $this->client = new FeatureFlagsClient($logger ?: new TriggerErrorLogger()); + // 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->warningLogger = $logger ?: new TriggerErrorLogger(); + $this->metricRecorder = EvaluationMetricRecorder::createDefault(); + } + + /** + * @internal Tests and Datadog-owned bridge adapters only. + */ + public static function createWithDependencies( + ?Evaluator $evaluator = null, + ?LoggerInterface $logger = null, + $metricRecorder = null + ): self { + $provider = new self($logger); + if ($evaluator !== null) { + $provider->evaluator = $evaluator; + } + if ($metricRecorder !== null) { + $provider->metricRecorder = new EvaluationMetricRecorder($metricRecorder); + } + + return $provider; } public function resolveBooleanValue( @@ -80,6 +110,8 @@ private function resolve( ?EvaluationContext $context ): ResolutionDetailsInterface { $details = $this->evaluate($flagKey, $expectedType, $defaultValue, $this->normalizeContext($context)); + $this->warnIfNonProductionRuntime($details); + $this->recordEvaluationMetric($flagKey, $details); $builder = (new ResolutionDetailsBuilder()) ->withValue($details->getValue()) @@ -100,6 +132,27 @@ private function resolve( return $builder->build(); } + private function recordEvaluationMetric(string $flagKey, EvaluationDetails $details): void + { + $this->metricRecorder->record(EvaluationMetric::create( + $flagKey, + $details->getVariant(), + $details->getReason(), + $details->getErrorCode(), + $this->allocationKey($details) + )); + } + + private function allocationKey(EvaluationDetails $details): ?string + { + $exposure = $details->getExposureData(); + if (!is_array($exposure) || !isset($exposure['allocationKey']) || !is_string($exposure['allocationKey'])) { + return null; + } + + return $exposure['allocationKey'] !== '' ? $exposure['allocationKey'] : null; + } + /** * @param bool|string|int|float|array $defaultValue * @param array $context @@ -110,14 +163,22 @@ private function evaluate( mixed $defaultValue, array $context ): EvaluationDetails { - return match ($expectedType) { - FlagValueType::BOOLEAN => $this->client->getBooleanDetails($flagKey, $defaultValue, $context), - FlagValueType::STRING => $this->client->getStringDetails($flagKey, $defaultValue, $context), - FlagValueType::INTEGER => $this->client->getIntegerDetails($flagKey, $defaultValue, $context), - FlagValueType::FLOAT => $this->client->getFloatDetails($flagKey, $defaultValue, $context), - FlagValueType::OBJECT => $this->client->getObjectDetails($flagKey, $defaultValue, $context), + $evaluationType = match ($expectedType) { + FlagValueType::BOOLEAN => EvaluationType::BOOLEAN, + FlagValueType::STRING => EvaluationType::STRING, + FlagValueType::INTEGER => EvaluationType::INTEGER, + FlagValueType::FLOAT => EvaluationType::FLOAT, + FlagValueType::OBJECT => EvaluationType::OBJECT, default => throw new \InvalidArgumentException('Unknown OpenFeature flag value type: ' . $expectedType), }; + + return $this->evaluator->evaluate( + $flagKey, + $evaluationType, + $defaultValue, + $context['targetingKey'] ?? null, + $context['attributes'] ?? [] + ); } /** @@ -142,6 +203,26 @@ private function normalizeContext(?EvaluationContext $context): array ]; } + private function warnIfNonProductionRuntime(EvaluationDetails $details): void + { + if ($this->warnedAboutNonProductionRuntime) { + return; + } + + $providerState = $details->getProviderState(); + if (!array_key_exists('productionRuntime', $providerState) || $providerState['productionRuntime'] !== false) { + return; + } + + $message = $details->getErrorMessage(); + if (!is_string($message) || $message === '') { + $message = 'Datadog-backed PHP OpenFeature evaluation is not fully enabled yet.'; + } + + $this->warningLogger->warning($message); + $this->warnedAboutNonProductionRuntime = true; + } + private function mapReason(string $reason): string { return match ($reason) { diff --git a/src/api/FeatureFlags/Internal/Metric/EvaluationMetric.php b/src/api/FeatureFlags/Internal/Metric/EvaluationMetric.php new file mode 100644 index 00000000000..8f3ecc91fdd --- /dev/null +++ b/src/api/FeatureFlags/Internal/Metric/EvaluationMetric.php @@ -0,0 +1,60 @@ +flagKey = $flagKey; + $this->variant = self::nullableString($variant); + $this->reason = self::nullableString($reason); + $this->errorCode = self::nullableString($errorCode); + $this->allocationKey = self::nullableString($allocationKey); + } + + public static function create($flagKey, $variant = null, $reason = null, $errorCode = null, $allocationKey = null) + { + return new self($flagKey, $variant, $reason, $errorCode, $allocationKey); + } + + public function getFlagKey() + { + return $this->flagKey; + } + + public function getVariant() + { + return $this->variant; + } + + public function getReason() + { + return $this->reason; + } + + public function getErrorCode() + { + return $this->errorCode; + } + + public function getAllocationKey() + { + return $this->allocationKey; + } + + private static function nullableString($value) + { + return is_string($value) && $value !== '' ? $value : null; + } +} diff --git a/src/api/FeatureFlags/Internal/Metric/EvaluationMetricRecorder.php b/src/api/FeatureFlags/Internal/Metric/EvaluationMetricRecorder.php new file mode 100644 index 00000000000..a1741c694d9 --- /dev/null +++ b/src/api/FeatureFlags/Internal/Metric/EvaluationMetricRecorder.php @@ -0,0 +1,75 @@ +recorder = $recorder; + } + + public static function createDefault() + { + return new self(self::nativeRecorder()); + } + + /** + * @internal Datadog-owned bridge adapters only. + * + * @return ?callable(EvaluationMetric): bool + */ + public static function nativeRecorder() + { + if (!self::isEnabled() || !function_exists('DDTrace\\Internal\\record_ffe_evaluation_metric')) { + return null; + } + + return static function (EvaluationMetric $metric) { + return \DDTrace\Internal\record_ffe_evaluation_metric( + $metric->getFlagKey(), + $metric->getVariant(), + $metric->getReason(), + $metric->getErrorCode(), + $metric->getAllocationKey() + ); + }; + } + + /** + * @internal Tests and Datadog-owned bridge adapters only. + */ + public function record(EvaluationMetric $metric) + { + if ($this->recorder === null) { + return false; + } + + try { + $recorder = $this->recorder; + return (bool) $recorder($metric); + } catch (\Throwable $throwable) { + return false; + } + } + + private static function isEnabled() + { + if (function_exists('dd_trace_env_config')) { + return \dd_trace_env_config('DD_METRICS_OTEL_ENABLED') === true; + } + + $value = getenv('DD_METRICS_OTEL_ENABLED'); + if ($value === false) { + return false; + } + + return in_array(strtolower((string) $value), array('1', 'true', 'yes', 'on'), true); + } +} diff --git a/src/api/FeatureFlags/Internal/NativeEvaluator.php b/src/api/FeatureFlags/Internal/NativeEvaluator.php index 483c86d0a1d..c77b76712f8 100644 --- a/src/api/FeatureFlags/Internal/NativeEvaluator.php +++ b/src/api/FeatureFlags/Internal/NativeEvaluator.php @@ -9,14 +9,16 @@ final class NativeEvaluator implements Evaluator const WARNING_MESSAGE = 'Datadog-backed PHP feature flag evaluation has no Remote Configuration data loaded for this request. Returning default values.'; private $mapper; + private $recordMetrics; - private function __construct($mapper = null) + private function __construct($mapper = null, $recordMetrics = true) { if ($mapper !== null && !$mapper instanceof ResultMapper) { throw new \InvalidArgumentException('Expected a ResultMapper instance'); } $this->mapper = $mapper ?: new ResultMapper(); + $this->recordMetrics = (bool) $recordMetrics; } public static function isAvailable() @@ -24,9 +26,9 @@ public static function isAvailable() return function_exists('DDTrace\\ffe_evaluate'); } - public static function create() + public static function create($recordMetrics = true) { - return self::isAvailable() ? new self() : new UnavailableEvaluator(); + return self::isAvailable() ? new self(null, $recordMetrics) : new UnavailableEvaluator(); } public function evaluate( @@ -40,7 +42,8 @@ public function evaluate( $flagKey, $this->typeId($expectedType), $targetingKey, - $this->normalizeAttributes($attributes) + $this->normalizeAttributes($attributes), + $this->recordMetrics ); if (is_array($rawResult) || is_object($rawResult)) { diff --git a/tests/OpenFeature/DataDogProviderTest.php b/tests/OpenFeature/DataDogProviderTest.php index c9844e6f3fe..45334ca32fc 100644 --- a/tests/OpenFeature/DataDogProviderTest.php +++ b/tests/OpenFeature/DataDogProviderTest.php @@ -4,7 +4,6 @@ namespace DDTrace\Tests\OpenFeature { -use DDTrace\FeatureFlags\Client as FeatureFlagsClient; use DDTrace\FeatureFlags\EvaluationDetails; use DDTrace\FeatureFlags\EvaluationErrorCode; use DDTrace\FeatureFlags\EvaluationReason; @@ -163,25 +162,7 @@ public function testTypeMismatchReturnsDefaultWithOpenFeatureError(): void private function providerForEvaluator(Evaluator $evaluator, ?LoggerInterface $logger = null): DataDogProvider { - $logger = $logger ?: new NullLogger(LogLevel::EMERGENCY); - $provider = new DataDogProvider($logger); - $client = $this->clientForEvaluator($evaluator, $logger); - - (function () use ($client): void { - $this->client = $client; - })->call($provider); - - return $provider; - } - - private function clientForEvaluator(Evaluator $evaluator, LoggerInterface $logger): FeatureFlagsClient - { - $client = new FeatureFlagsClient($logger); - (function () use ($evaluator): void { - $this->evaluator = $evaluator; - })->call($client); - - return $client; + return DataDogProvider::createWithDependencies($evaluator, $logger ?: new NullLogger(LogLevel::EMERGENCY)); } private function openFeatureClientFor(DataDogProvider $provider) diff --git a/tests/OpenFeature/OpenFeatureEvaluationMetricsTest.php b/tests/OpenFeature/OpenFeatureEvaluationMetricsTest.php new file mode 100644 index 00000000000..75ca6a619bc --- /dev/null +++ b/tests/OpenFeature/OpenFeatureEvaluationMetricsTest.php @@ -0,0 +1,245 @@ +setSuccess( + 'flag.allocation', + 'blue', + EvaluationReason::TARGETING_MATCH, + 'on', + ['allocationKey' => 'allocation-3baabb3c', 'doLog' => true] + ); + + $provider = DataDogProvider::createWithDependencies( + $evaluator, + null, + $recorder + ); + $client = $this->openFeatureClientFor($provider); + + $client->getStringValue('flag.allocation', 'red'); + + $calls = $recorder->calls(); + self::assertCount(1, $calls); + self::assertSame([ + 'flagKey' => 'flag.allocation', + 'variant' => 'on', + 'reason' => EvaluationReason::TARGETING_MATCH, + 'errorCode' => null, + 'allocationKey' => 'allocation-3baabb3c', + ], $calls[0]); + } + + public function testRecordsMetricThroughProviderOnTypeMismatch(): void + { + $recorder = new EvalMetricsRecordingRecorder(); + $evaluator = new OpenFeatureMetricEvaluator(); + // Provider resolves a type mismatch before returning to the OpenFeature + // SDK, so no separate PHP OpenFeature hook is needed for this case. + $evaluator->setSuccess('flag.mismatch', 'not-an-int', EvaluationReason::STATIC_REASON); + + $provider = DataDogProvider::createWithDependencies( + $evaluator, + null, + $recorder + ); + $client = $this->openFeatureClientFor($provider); + + $details = $client->getIntegerDetails('flag.mismatch', 7); + self::assertSame(EvaluationErrorCode::TYPE_MISMATCH, $details->getError()->getResolutionErrorCode()->getValue()); + + $calls = $recorder->calls(); + + self::assertCount(1, $calls); + self::assertSame('flag.mismatch', $calls[0]['flagKey']); + self::assertSame(EvaluationReason::ERROR, $calls[0]['reason']); + self::assertSame(EvaluationErrorCode::TYPE_MISMATCH, $calls[0]['errorCode']); + } + + public function testOpenFeaturePathRecordsOncePerEvaluation(): void + { + $recorder = new EvalMetricsRecordingRecorder(); + $evaluator = new OpenFeatureMetricEvaluator(); + $evaluator->setSuccess('flag.basic', 'value', EvaluationReason::STATIC_REASON, 'v1'); + + $provider = DataDogProvider::createWithDependencies( + $evaluator, + null, + $recorder + ); + $client = $this->openFeatureClientFor($provider); + + $client->getStringValue('flag.basic', 'default'); + $client->getStringValue('flag.basic', 'default'); + $client->getStringValue('flag.basic', 'default'); + + // Three evaluations, exactly three recorder calls. Aggregation happens + // natively in the sidecar, not in PHP. + self::assertCount(3, $recorder->calls()); + } + + public function testSupportsAllFlagValueTypes(): void + { + $recorder = new EvalMetricsRecordingRecorder(); + $evaluator = new OpenFeatureMetricEvaluator(); + $evaluator + ->setSuccess('b', true, EvaluationReason::STATIC_REASON) + ->setSuccess('s', 'x', EvaluationReason::STATIC_REASON) + ->setSuccess('i', 1, EvaluationReason::STATIC_REASON) + ->setSuccess('f', 1.5, EvaluationReason::STATIC_REASON) + ->setSuccess('o', ['k' => 'v'], EvaluationReason::STATIC_REASON); + + $provider = DataDogProvider::createWithDependencies( + $evaluator, + null, + $recorder + ); + $client = $this->openFeatureClientFor($provider); + + $client->getBooleanValue('b', false); + $client->getStringValue('s', ''); + $client->getIntegerValue('i', 0); + $client->getFloatValue('f', 0.0); + $client->getObjectValue('o', []); + + self::assertCount(5, $recorder->calls(), 'Recorder records for every supported flag value type'); + } + + private function openFeatureClientFor(DataDogProvider $provider) + { + $api = OpenFeatureAPI::getInstance(); + $api->setProvider($provider); + return $api->getClient('datadog-evalmetrics-test'); + } +} + +final class EvalMetricsRecordingRecorder +{ + private $calls = array(); + + public function __invoke(EvaluationMetric $metric) + { + $this->calls[] = array( + 'flagKey' => $metric->getFlagKey(), + 'variant' => $metric->getVariant(), + 'reason' => $metric->getReason(), + 'errorCode' => $metric->getErrorCode(), + 'allocationKey' => $metric->getAllocationKey(), + ); + return true; + } + + public function calls() + { + return $this->calls; + } +} + +final class OpenFeatureMetricEvaluator implements Evaluator +{ + /** @var array */ + private array $details = []; + + public function setSuccess( + string $flagKey, + mixed $value, + string $reason = EvaluationReason::STATIC_REASON, + ?string $variant = null, + array $exposureData = [] + ): self { + $this->details[$flagKey] = new EvaluationDetails( + $value, + $this->typeForValue($value), + $reason, + $variant, + null, + null, + [], + $exposureData + ); + return $this; + } + + public function evaluate($flagKey, $expectedType, $defaultValue, $targetingKey = null, array $attributes = []) + { + if (!array_key_exists($flagKey, $this->details)) { + return new EvaluationDetails( + $defaultValue, + $expectedType, + EvaluationReason::ERROR, + null, + EvaluationErrorCode::FLAG_NOT_FOUND, + 'Feature flag "' . $flagKey . '" was not found' + ); + } + + $details = $this->details[$flagKey]; + if (!$this->matchesExpectedType($details->getValue(), $expectedType)) { + return new EvaluationDetails( + $defaultValue, + $expectedType, + EvaluationReason::ERROR, + null, + EvaluationErrorCode::TYPE_MISMATCH, + 'Expected ' . $expectedType . ' flag value' + ); + } + return $details; + } + + private function typeForValue($value): string + { + if (is_bool($value)) { + return EvaluationType::BOOLEAN; + } + if (is_int($value)) { + return EvaluationType::INTEGER; + } + if (is_float($value)) { + return EvaluationType::FLOAT; + } + if (is_array($value)) { + return EvaluationType::OBJECT; + } + return EvaluationType::STRING; + } + + private function matchesExpectedType($value, string $expectedType): bool + { + switch ($expectedType) { + case EvaluationType::BOOLEAN: return is_bool($value); + case EvaluationType::STRING: return is_string($value); + case EvaluationType::INTEGER: return is_int($value); + case EvaluationType::FLOAT: return is_int($value) || is_float($value); + case EvaluationType::OBJECT: return is_array($value); + } + return false; + } +} + +} diff --git a/tests/api/Unit/FeatureFlags/EvaluationMetricRecorderTest.php b/tests/api/Unit/FeatureFlags/EvaluationMetricRecorderTest.php new file mode 100644 index 00000000000..0963129b46d --- /dev/null +++ b/tests/api/Unit/FeatureFlags/EvaluationMetricRecorderTest.php @@ -0,0 +1,82 @@ +assertTrue($metricRecorder->record(EvaluationMetric::create( + 'checkout.enabled', + 'treatment', + EvaluationReason::SPLIT, + null, + 'allocation-a' + ))); + + $this->assertSame(array(array( + 'flagKey' => 'checkout.enabled', + 'variant' => 'treatment', + 'reason' => EvaluationReason::SPLIT, + 'errorCode' => null, + 'allocationKey' => 'allocation-a', + )), $recorder->calls()); + } + + public function testRecorderNoopsWithoutCallable() + { + $metricRecorder = new EvaluationMetricRecorder(null); + + $this->assertFalse($metricRecorder->record(EvaluationMetric::create('flag.noop'))); + } + + public function testRecorderExceptionDoesNotEscape() + { + $metricRecorder = new EvaluationMetricRecorder(new ThrowingEvaluationMetricRecorder()); + + $this->assertFalse($metricRecorder->record(EvaluationMetric::create( + 'flag.throwing', + 'on', + EvaluationReason::SPLIT + ))); + } +} + +final class RecordingEvaluationMetricRecorder +{ + private $calls = array(); + + public function __invoke(EvaluationMetric $metric) + { + $this->calls[] = array( + 'flagKey' => $metric->getFlagKey(), + 'variant' => $metric->getVariant(), + 'reason' => $metric->getReason(), + 'errorCode' => $metric->getErrorCode(), + 'allocationKey' => $metric->getAllocationKey(), + ); + + return true; + } + + public function calls() + { + return $this->calls; + } +} + +final class ThrowingEvaluationMetricRecorder +{ + public function __invoke() + { + throw new \RuntimeException('metric recorder failed'); + } +} diff --git a/tests/ext/ffe/evaluation_metrics_native.phpt b/tests/ext/ffe/evaluation_metrics_native.phpt new file mode 100644 index 00000000000..572ea09fbbe --- /dev/null +++ b/tests/ext/ffe/evaluation_metrics_native.phpt @@ -0,0 +1,57 @@ +--TEST-- +FFE evaluation metrics use native recorder +--ENV-- +DD_METRICS_OTEL_ENABLED=true +--FILE-- + +--EXPECT-- +native_recorder_exists=true +native_flush_exists=true +old_metrics_forwarder_exists=false +old_exposure_forwarder_exists=false +recorded=true +load=true +evaluation_without_native_metric={"valueJson":"\"blue\"","variant":"blue","allocationKey":"alloc-string","reason":0,"errorCode":0,"doLog":true,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} +missing_flag_without_native_metric={"valueJson":"null","variant":null,"allocationKey":null,"reason":5,"errorCode":3,"doLog":false,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} diff --git a/tests/ext/ffe/native_bridge_evaluate.phpt b/tests/ext/ffe/native_bridge_evaluate.phpt index ef91b3d4bde..3b113f1300f 100644 --- a/tests/ext/ffe/native_bridge_evaluate.phpt +++ b/tests/ext/ffe/native_bridge_evaluate.phpt @@ -140,6 +140,6 @@ object_success_value={"enabled":true,"threshold":2} object_success_metadata={"variant":"json-a","allocation_key":"alloc-json","reason":0,"error_code":0,"do_log":true} numeric_attribute_key={"valueJson":"\"numeric-attribute-name\"","variant":"numeric-key","allocationKey":"alloc-numeric-attribute","reason":2,"errorCode":0,"doLog":true,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} empty_targeting_key={"valueJson":"\"empty-targeting-key\"","variant":"empty-target","allocationKey":"alloc-empty-targeting-key","reason":3,"errorCode":0,"doLog":true,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} -missing={"valueJson":"null","variant":null,"allocationKey":null,"reason":1,"errorCode":3,"doLog":false,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} +missing={"valueJson":"null","variant":null,"allocationKey":null,"reason":5,"errorCode":3,"doLog":false,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} type_mismatch={"valueJson":"null","variant":null,"allocationKey":null,"reason":5,"errorCode":1,"doLog":false,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} parse_error={"valueJson":"null","variant":null,"allocationKey":null,"reason":5,"errorCode":2,"doLog":false,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} diff --git a/tests/internal-api-stress-test.php b/tests/internal-api-stress-test.php index 8f881d821ec..d696d0226af 100644 --- a/tests/internal-api-stress-test.php +++ b/tests/internal-api-stress-test.php @@ -133,6 +133,7 @@ function ($hook = null) { $minFunctionArgs = [ 'DDTrace\ffe_evaluate' => 4, + 'DDTrace\Internal\record_ffe_evaluation_metric' => 5, ]; function call_function(ReflectionFunction $function) diff --git a/tracer/ddtrace.stub.php b/tracer/ddtrace.stub.php index d193dd03ab5..8cf38030e8f 100644 --- a/tracer/ddtrace.stub.php +++ b/tracer/ddtrace.stub.php @@ -900,7 +900,7 @@ function flush_endpoints(): void {} * * @internal Used by the Datadog feature flag client. */ - function ffe_evaluate(string $flagKey, int $expectedType, ?string $targetingKey, array $attributes): ?FfeResult {} + function ffe_evaluate(string $flagKey, int $expectedType, ?string $targetingKey, array $attributes, bool $recordMetric = true): ?FfeResult {} /** * Check if FFE (Feature Flag Evaluation) configuration is loaded. @@ -1068,6 +1068,24 @@ function add_span_flag(\DDTrace\SpanData $span, int $flag): void {} */ function handle_fork(): void {} + /** + * Record a Feature Flag Evaluation metric event in native request-local + * memory. The batch is flushed to the shared sidecar during request + * shutdown; PHP does not aggregate, encode OTLP, or perform transport. + * + * @internal + */ + function record_ffe_evaluation_metric(string $flagKey, ?string $variant, ?string $reason, ?string $errorType, ?string $allocationKey): bool {} + + /** + * Flush request-local Feature Flag Evaluation metric events to the shared + * sidecar. Intended for long-lived integration test servers; normal PHP + * requests flush during request shutdown. + * + * @internal + */ + function flush_ffe_evaluation_metrics(): bool {} + } namespace datadog\appsec\v2 { diff --git a/tracer/ddtrace_arginfo.h b/tracer/ddtrace_arginfo.h index cc8ef819343..f9ace83624e 100644 --- a/tracer/ddtrace_arginfo.h +++ b/tracer/ddtrace_arginfo.h @@ -181,6 +181,7 @@ ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_DDTrace_ffe_evaluate, 0, 4, DDTra ZEND_ARG_TYPE_INFO(0, expectedType, IS_LONG, 0) 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_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_ffe_has_config, 0, 0, _IS_BOOL, 0) @@ -246,6 +247,17 @@ ZEND_END_ARG_INFO() #define arginfo_DDTrace_Internal_handle_fork arginfo_DDTrace_flush +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_Internal_record_ffe_evaluation_metric, 0, 5, _IS_BOOL, 0) + ZEND_ARG_TYPE_INFO(0, flagKey, IS_STRING, 0) + ZEND_ARG_TYPE_INFO(0, variant, IS_STRING, 1) + ZEND_ARG_TYPE_INFO(0, reason, IS_STRING, 1) + ZEND_ARG_TYPE_INFO(0, errorType, IS_STRING, 1) + ZEND_ARG_TYPE_INFO(0, allocationKey, IS_STRING, 1) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_Internal_flush_ffe_evaluation_metrics, 0, 0, _IS_BOOL, 0) +ZEND_END_ARG_INFO() + ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_datadog_appsec_v2_track_user_login_success, 0, 1, IS_VOID, 0) ZEND_ARG_TYPE_INFO(0, login, IS_STRING, 0) ZEND_ARG_TYPE_MASK(0, user, MAY_BE_STRING|MAY_BE_ARRAY|MAY_BE_NULL, "null") @@ -431,6 +443,8 @@ ZEND_FUNCTION(DDTrace_Testing_emit_asm_event); ZEND_FUNCTION(DDTrace_Testing_normalize_tag_value); ZEND_FUNCTION(DDTrace_Internal_add_span_flag); ZEND_FUNCTION(DDTrace_Internal_handle_fork); +ZEND_FUNCTION(DDTrace_Internal_record_ffe_evaluation_metric); +ZEND_FUNCTION(DDTrace_Internal_flush_ffe_evaluation_metrics); ZEND_FUNCTION(datadog_appsec_v2_track_user_login_success); ZEND_FUNCTION(datadog_appsec_v2_track_user_login_failure); ZEND_FUNCTION(dd_trace_env_config); @@ -531,6 +545,8 @@ static const zend_function_entry ext_functions[] = { ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace\\Testing", "normalize_tag_value"), zif_DDTrace_Testing_normalize_tag_value, arginfo_DDTrace_Testing_normalize_tag_value, 0, NULL, NULL) ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace\\Internal", "add_span_flag"), zif_DDTrace_Internal_add_span_flag, arginfo_DDTrace_Internal_add_span_flag, 0, NULL, NULL) ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace\\Internal", "handle_fork"), zif_DDTrace_Internal_handle_fork, arginfo_DDTrace_Internal_handle_fork, 0, NULL, NULL) + ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace\\Internal", "record_ffe_evaluation_metric"), zif_DDTrace_Internal_record_ffe_evaluation_metric, arginfo_DDTrace_Internal_record_ffe_evaluation_metric, 0, NULL, NULL) + ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace\\Internal", "flush_ffe_evaluation_metrics"), zif_DDTrace_Internal_flush_ffe_evaluation_metrics, arginfo_DDTrace_Internal_flush_ffe_evaluation_metrics, 0, NULL, NULL) ZEND_RAW_FENTRY(ZEND_NS_NAME("datadog\\appsec\\v2", "track_user_login_success"), zif_datadog_appsec_v2_track_user_login_success, arginfo_datadog_appsec_v2_track_user_login_success, 0, NULL, NULL) ZEND_RAW_FENTRY(ZEND_NS_NAME("datadog\\appsec\\v2", "track_user_login_failure"), zif_datadog_appsec_v2_track_user_login_failure, arginfo_datadog_appsec_v2_track_user_login_failure, 0, NULL, NULL) ZEND_FE(dd_trace_env_config, arginfo_dd_trace_env_config) diff --git a/tracer/ffe.c b/tracer/ffe.c index 645b990abfd..a38e4b05f4b 100644 --- a/tracer/ffe.c +++ b/tracer/ffe.c @@ -4,15 +4,27 @@ #include "span.h" #include #include +#include #include #include #include +#include #include +#include ZEND_EXTERN_MODULE_GLOBALS(datadog); +#define DATADOG_FFE_METRIC_BUFFER_LIMIT 1000 #define DATADOG_FFE_EXPOSURE_BUFFER_LIMIT 1000 +typedef struct { + zend_string *flag_key; + zend_string *variant; + zend_string *reason; + zend_string *error_type; + zend_string *allocation_key; +} datadog_ffe_metric; + typedef struct { uint64_t timestamp_ms; zend_string *flag_key; @@ -22,6 +34,270 @@ typedef struct { zend_string *variant; } dd_ffe_exposure; +static void datadog_ffe_release_metric(datadog_ffe_metric *metric) { + if (!metric) { + return; + } + if (metric->flag_key) { + zend_string_release(metric->flag_key); + } + if (metric->variant) { + zend_string_release(metric->variant); + } + if (metric->reason) { + zend_string_release(metric->reason); + } + if (metric->error_type) { + zend_string_release(metric->error_type); + } + if (metric->allocation_key) { + zend_string_release(metric->allocation_key); + } +} + +static void datadog_ffe_clear_evaluation_metrics(void) { + datadog_ffe_metric *buffer = (datadog_ffe_metric *) DATADOG_G(ffe_metric_buffer); + for (size_t i = 0; i < DATADOG_G(ffe_metric_buffer_len); i++) { + datadog_ffe_release_metric(&buffer[i]); + } + if (buffer) { + efree(buffer); + } + DATADOG_G(ffe_metric_buffer) = NULL; + DATADOG_G(ffe_metric_buffer_len) = 0; + DATADOG_G(ffe_metric_buffer_cap) = 0; +} + +static zend_string *datadog_ffe_append_otlp_metrics_path(const char *base_endpoint, size_t base_len) { + while (base_len > 0 && base_endpoint[base_len - 1] == '/') { + base_len--; + } + + const char suffix[] = "/v1/metrics"; + size_t suffix_len = sizeof(suffix) - 1; + zend_string *endpoint = zend_string_alloc(base_len + suffix_len, 0); + memcpy(ZSTR_VAL(endpoint), base_endpoint, base_len); + memcpy(ZSTR_VAL(endpoint) + base_len, suffix, suffix_len); + ZSTR_VAL(endpoint)[base_len + suffix_len] = '\0'; + return endpoint; +} + +static zend_string *datadog_ffe_build_otlp_metrics_endpoint(const char *scheme, size_t scheme_len, const char *host, size_t host_len) { + if (!scheme || scheme_len == 0) { + scheme = "http"; + scheme_len = sizeof("http") - 1; + } + if (!host || host_len == 0) { + host = "localhost"; + host_len = sizeof("localhost") - 1; + } + + bool bracket_ipv6 = memchr(host, ':', host_len) && host[0] != '['; + const char separator[] = "://"; + const char suffix[] = ":4318/v1/metrics"; + size_t separator_len = sizeof(separator) - 1; + size_t suffix_len = sizeof(suffix) - 1; + size_t bracket_len = bracket_ipv6 ? 2 : 0; + + zend_string *endpoint = zend_string_alloc(scheme_len + separator_len + bracket_len + host_len + suffix_len, 0); + char *cursor = ZSTR_VAL(endpoint); + memcpy(cursor, scheme, scheme_len); + cursor += scheme_len; + memcpy(cursor, separator, separator_len); + cursor += separator_len; + if (bracket_ipv6) { + *cursor++ = '['; + } + memcpy(cursor, host, host_len); + cursor += host_len; + if (bracket_ipv6) { + *cursor++ = ']'; + } + memcpy(cursor, suffix, suffix_len); + cursor += suffix_len; + *cursor = '\0'; + return endpoint; +} + +static zend_string *datadog_ffe_otlp_metrics_endpoint_from_agent_config(void) { + zend_string *agent_scheme = NULL; + zend_string *agent_url = get_global_DD_TRACE_AGENT_URL(); + + if (ZSTR_LEN(agent_url) > 0) { + php_url *parsed = php_url_parse(ZSTR_VAL(agent_url)); + if (parsed) { +#if PHP_VERSION_ID >= 70300 + if (parsed->scheme) { + if (zend_string_equals_literal(parsed->scheme, "unix")) { + php_url_free(parsed); + return zend_string_copy(agent_url); + } + agent_scheme = zend_string_copy(parsed->scheme); + } + + if (parsed->host) { + zend_string *endpoint = datadog_ffe_build_otlp_metrics_endpoint( + agent_scheme ? ZSTR_VAL(agent_scheme) : NULL, + agent_scheme ? ZSTR_LEN(agent_scheme) : 0, + ZSTR_VAL(parsed->host), + ZSTR_LEN(parsed->host) + ); + if (agent_scheme) { + zend_string_release(agent_scheme); + } + php_url_free(parsed); + return endpoint; + } +#else + if (parsed->scheme) { + if (strcmp(parsed->scheme, "unix") == 0) { + php_url_free(parsed); + return zend_string_copy(agent_url); + } + agent_scheme = zend_string_init(parsed->scheme, strlen(parsed->scheme), 0); + } + + if (parsed->host) { + zend_string *endpoint = datadog_ffe_build_otlp_metrics_endpoint( + agent_scheme ? ZSTR_VAL(agent_scheme) : NULL, + agent_scheme ? ZSTR_LEN(agent_scheme) : 0, + parsed->host, + strlen(parsed->host) + ); + if (agent_scheme) { + zend_string_release(agent_scheme); + } + php_url_free(parsed); + return endpoint; + } +#endif + php_url_free(parsed); + } + } + + zend_string *agent_host = get_global_DD_AGENT_HOST(); + zend_string *endpoint = datadog_ffe_build_otlp_metrics_endpoint( + agent_scheme ? ZSTR_VAL(agent_scheme) : NULL, + agent_scheme ? ZSTR_LEN(agent_scheme) : 0, + ZSTR_VAL(agent_host), + ZSTR_LEN(agent_host) + ); + if (agent_scheme) { + zend_string_release(agent_scheme); + } + return endpoint; +} + +static zend_string *datadog_ffe_otlp_metrics_endpoint(void) { + const char *metrics_endpoint = getenv("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT"); + if (metrics_endpoint && metrics_endpoint[0] != '\0') { + return zend_string_init(metrics_endpoint, strlen(metrics_endpoint), 0); + } + + const char *base_endpoint = getenv("OTEL_EXPORTER_OTLP_ENDPOINT"); + if (base_endpoint && base_endpoint[0] != '\0') { + return datadog_ffe_append_otlp_metrics_path(base_endpoint, strlen(base_endpoint)); + } + + return datadog_ffe_otlp_metrics_endpoint_from_agent_config(); +} + +bool datadog_ffe_record_evaluation_metric( + const char *flag_key, + size_t flag_key_len, + const char *variant, + size_t variant_len, + const char *reason, + size_t reason_len, + const char *error_type, + size_t error_type_len, + const char *allocation_key, + size_t allocation_key_len +) { + if (!get_DD_METRICS_OTEL_ENABLED() || !flag_key || flag_key_len == 0) { + return false; + } + + if (DATADOG_G(ffe_metric_buffer_len) >= DATADOG_FFE_METRIC_BUFFER_LIMIT) { + return false; + } + + if (DATADOG_G(ffe_metric_buffer_len) == DATADOG_G(ffe_metric_buffer_cap)) { + size_t new_cap = DATADOG_G(ffe_metric_buffer_cap) == 0 ? 8 : DATADOG_G(ffe_metric_buffer_cap) * 2; + if (new_cap > DATADOG_FFE_METRIC_BUFFER_LIMIT) { + new_cap = DATADOG_FFE_METRIC_BUFFER_LIMIT; + } + DATADOG_G(ffe_metric_buffer) = safe_erealloc( + DATADOG_G(ffe_metric_buffer), + new_cap, + sizeof(datadog_ffe_metric), + 0 + ); + DATADOG_G(ffe_metric_buffer_cap) = new_cap; + } + + datadog_ffe_metric *buffer = (datadog_ffe_metric *) DATADOG_G(ffe_metric_buffer); + datadog_ffe_metric *metric = &buffer[DATADOG_G(ffe_metric_buffer_len)++]; + metric->flag_key = zend_string_init(flag_key, flag_key_len, 0); + metric->variant = zend_string_init(variant ? variant : "", variant ? variant_len : 0, 0); + metric->reason = zend_string_init(reason ? reason : "", reason ? reason_len : 0, 0); + metric->error_type = zend_string_init(error_type ? error_type : "", error_type ? error_type_len : 0, 0); + metric->allocation_key = zend_string_init(allocation_key ? allocation_key : "", allocation_key ? allocation_key_len : 0, 0); + + return true; +} + +bool datadog_ffe_flush_evaluation_metrics(void) { + size_t metric_count = DATADOG_G(ffe_metric_buffer_len); + datadog_ffe_metric *buffer = (datadog_ffe_metric *) DATADOG_G(ffe_metric_buffer); + + if (metric_count == 0 || !buffer) { + return false; + } + + if (!DATADOG_G(sidecar) || !datadog_sidecar_instance_id || !DATADOG_G(sidecar_queue_id)) { + datadog_ffe_clear_evaluation_metrics(); + return false; + } + + zend_string *endpoint = datadog_ffe_otlp_metrics_endpoint(); + ddog_FfeEvaluationMetric *ffi_metrics = safe_emalloc(metric_count, sizeof(ddog_FfeEvaluationMetric), 0); + for (size_t i = 0; i < metric_count; i++) { + ffi_metrics[i] = (ddog_FfeEvaluationMetric) { + .flag_key = dd_zend_string_to_CharSlice(buffer[i].flag_key), + .variant = dd_zend_string_to_CharSlice(buffer[i].variant), + .reason = dd_zend_string_to_CharSlice(buffer[i].reason), + .error_type = dd_zend_string_to_CharSlice(buffer[i].error_type), + .allocation_key = dd_zend_string_to_CharSlice(buffer[i].allocation_key), + }; + } + + ddog_FfeTelemetryContext context = { + .service = dd_zend_string_to_CharSlice(get_DD_SERVICE()), + .env = dd_zend_string_to_CharSlice(get_DD_ENV()), + .version = dd_zend_string_to_CharSlice(get_DD_VERSION()), + }; + ddog_Slice_FfeEvaluationMetric metric_slice = { + .ptr = ffi_metrics, + .len = metric_count, + }; + + bool flushed = datadog_ffi_try( + "Failed sending FFE metrics batch to sidecar", + ddog_sidecar_send_ffe_evaluation_metrics( + &DATADOG_G(sidecar), + datadog_sidecar_instance_id, + &DATADOG_G(sidecar_queue_id), + dd_zend_string_to_CharSlice(endpoint), + &context, + metric_slice)); + + efree(ffi_metrics); + zend_string_release(endpoint); + datadog_ffe_clear_evaluation_metrics(); + return flushed; +} + static void dd_ffe_release_exposure(dd_ffe_exposure *exposure) { zend_string_release(exposure->flag_key); zend_string_release(exposure->subject_id); diff --git a/tracer/ffe.h b/tracer/ffe.h index 4b9f6b5d08e..38a757f92f5 100644 --- a/tracer/ffe.h +++ b/tracer/ffe.h @@ -2,8 +2,12 @@ #define DDTRACE_FFE_H #include +#include #include +bool datadog_ffe_record_evaluation_metric(const char *flag_key, size_t flag_key_len, const char *variant, size_t variant_len, const char *reason, size_t reason_len, const char *error_type, size_t error_type_len, const char *allocation_key, size_t allocation_key_len); +bool datadog_ffe_flush_evaluation_metrics(void); + void ddtrace_ffe_record_exposure(zend_string *flag_key, zend_string *targeting_key, zend_string *subject_attributes_json, zend_string *allocation_key, zend_string *variant); bool ddtrace_ffe_flush_exposures(void); diff --git a/tracer/functions.c b/tracer/functions.c index 4b643d2af49..0584f37185a 100644 --- a/tracer/functions.c +++ b/tracer/functions.c @@ -1591,6 +1591,68 @@ PHP_FUNCTION(DDTrace_Testing_ffe_load_config) { RETURN_BOOL(ddog_ffe_load_config(dd_zend_string_to_CharSlice(json))); } +static const char *ddtrace_ffe_reason_name(int32_t reason) { + switch (reason) { + case 0: + return "STATIC"; + case 2: + return "TARGETING_MATCH"; + case 3: + return "SPLIT"; + case 4: + return "DISABLED"; + case 5: + return "ERROR"; + case 1: + default: + return "DEFAULT"; + } +} + +static const char *ddtrace_ffe_error_name(int32_t error_code) { + switch (error_code) { + case 0: + return NULL; + case 1: + return "TYPE_MISMATCH"; + case 2: + return "PARSE_ERROR"; + case 3: + return "FLAG_NOT_FOUND"; + case 6: + return "PROVIDER_NOT_READY"; + case 7: + default: + return "GENERAL"; + } +} + +static int32_t ddtrace_ffe_effective_reason(int32_t reason, int32_t error_code) { + return error_code == 0 ? reason : 5; +} + +static void ddtrace_ffe_record_evaluation_metric_result( + zend_string *flag_key, + zend_string *variant, + zend_string *allocation_key, + int32_t reason, + int32_t error_code +) { + const char *reason_name = ddtrace_ffe_reason_name(ddtrace_ffe_effective_reason(reason, error_code)); + const char *error_name = ddtrace_ffe_error_name(error_code); + datadog_ffe_record_evaluation_metric( + ZSTR_VAL(flag_key), + ZSTR_LEN(flag_key), + variant ? ZSTR_VAL(variant) : NULL, + variant ? ZSTR_LEN(variant) : 0, + reason_name, + strlen(reason_name), + error_name, + error_name ? strlen(error_name) : 0, + allocation_key ? ZSTR_VAL(allocation_key) : NULL, + allocation_key ? ZSTR_LEN(allocation_key) : 0); +} + static zend_string *ddtrace_ffe_attributes_json(zval *attrs_zv) { smart_str buf = {0}; zai_json_encode(&buf, attrs_zv, 0); @@ -1658,15 +1720,18 @@ PHP_FUNCTION(DDTrace_ffe_evaluate) { zend_string *key; zval *value; struct ddog_FfeResult result; + zend_bool record_metric = true; zend_string *value_json; zend_string *variant; zend_string *allocation_key; - ZEND_PARSE_PARAMETERS_START(4, 4) + ZEND_PARSE_PARAMETERS_START(4, 5) 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) ZEND_PARSE_PARAMETERS_END(); type_id = (int32_t) type_id_zl; @@ -1743,6 +1808,17 @@ PHP_FUNCTION(DDTrace_ffe_evaluate) { } if (!result.valid) { + if (record_metric) { + datadog_ffe_record_evaluation_metric( + ZSTR_VAL(flag_key), + ZSTR_LEN(flag_key), + NULL, + 0, + ZEND_STRL("ERROR"), + ZEND_STRL("PROVIDER_NOT_READY"), + NULL, + 0); + } RETURN_NULL(); } @@ -1750,6 +1826,15 @@ PHP_FUNCTION(DDTrace_ffe_evaluate) { variant = result.variant; allocation_key = result.allocation_key; + if (record_metric) { + ddtrace_ffe_record_evaluation_metric_result( + flag_key, + variant, + allocation_key, + result.reason, + result.error_code); + } + if (result.do_log && allocation_key && variant) { zend_string *subject_attributes_json = ddtrace_ffe_attributes_json(attrs_zv); ddtrace_ffe_record_exposure( @@ -1766,7 +1851,7 @@ PHP_FUNCTION(DDTrace_ffe_evaluate) { ddtrace_ffe_update_nullable_string_property(return_value, ZEND_STRL("valueJson"), value_json); ddtrace_ffe_update_nullable_string_property(return_value, ZEND_STRL("variant"), variant); ddtrace_ffe_update_nullable_string_property(return_value, ZEND_STRL("allocationKey"), allocation_key); - ddtrace_ffe_update_long_property(return_value, ZEND_STRL("reason"), result.reason); + ddtrace_ffe_update_long_property(return_value, ZEND_STRL("reason"), ddtrace_ffe_effective_reason(result.reason, result.error_code)); ddtrace_ffe_update_long_property(return_value, ZEND_STRL("errorCode"), result.error_code); ddtrace_ffe_update_bool_property(return_value, ZEND_STRL("doLog"), result.do_log); ddtrace_ffe_update_empty_array_property(return_value, ZEND_STRL("providerState")); @@ -2668,6 +2753,45 @@ PHP_FUNCTION(DDTrace_consume_distributed_tracing_headers) { RETURN_NULL(); } +PHP_FUNCTION(DDTrace_Internal_record_ffe_evaluation_metric) { + char *flag_key; + size_t flag_key_len; + char *variant = NULL; + size_t variant_len = 0; + char *reason = NULL; + size_t reason_len = 0; + char *error_type = NULL; + size_t error_type_len = 0; + char *allocation_key = NULL; + size_t allocation_key_len = 0; + + ZEND_PARSE_PARAMETERS_START(5, 5) + Z_PARAM_STRING(flag_key, flag_key_len) + Z_PARAM_STRING_OR_NULL(variant, variant_len) + Z_PARAM_STRING_OR_NULL(reason, reason_len) + Z_PARAM_STRING_OR_NULL(error_type, error_type_len) + Z_PARAM_STRING_OR_NULL(allocation_key, allocation_key_len) + ZEND_PARSE_PARAMETERS_END(); + + RETURN_BOOL(datadog_ffe_record_evaluation_metric( + flag_key, + flag_key_len, + variant, + variant_len, + reason, + reason_len, + error_type, + error_type_len, + allocation_key, + allocation_key_len)); +} + +PHP_FUNCTION(DDTrace_Internal_flush_ffe_evaluation_metrics) { + ZEND_PARSE_PARAMETERS_NONE(); + + RETURN_BOOL(datadog_ffe_flush_evaluation_metrics()); +} + /* {{{ proto array generate_distributed_tracing_headers() */ PHP_FUNCTION(DDTrace_generate_distributed_tracing_headers) { zend_array *inject = NULL;