diff --git a/components-rs/common.h b/components-rs/common.h index 9a148cb5d8b..75832653b9e 100644 --- a/components-rs/common.h +++ b/components-rs/common.h @@ -484,6 +484,8 @@ typedef struct ddog_FfeResult { _zend_string * allocation_key; int32_t reason; int32_t error_code; + int32_t serial_id; + bool has_serial_id; bool do_log; bool valid; } ddog_FfeResult; diff --git a/components-rs/ffe.rs b/components-rs/ffe.rs index f7684d069fa..4964bc7b0b9 100644 --- a/components-rs/ffe.rs +++ b/components-rs/ffe.rs @@ -97,6 +97,14 @@ pub struct FfeResult { pub allocation_key: MaybeOwnedZendString, pub reason: i32, pub error_code: i32, + // serial_id is the selected split's serial id, carried for APM span + // enrichment (ffe_flags_enc). The source field is Option; since the + // C ABI cannot represent Option as a plain field, we surface the + // presence separately via has_serial_id. Consumers MUST gate on + // has_serial_id (the Pattern B "missing variant => default" semantic) and + // never treat serial_id == 0 as "absent". + pub serial_id: i32, + pub has_serial_id: bool, pub do_log: bool, pub valid: bool, } @@ -220,6 +228,8 @@ fn result_from_assignment(assignment: Result) AssignmentReason::Default => REASON_DEFAULT, }, error_code: ERROR_NONE, + serial_id: assignment.serial_id.unwrap_or(0), + has_serial_id: assignment.serial_id.is_some(), do_log: assignment.do_log, valid: true, } @@ -244,6 +254,8 @@ fn result_from_assignment(assignment: Result) allocation_key: None, reason, error_code, + serial_id: 0, + has_serial_id: false, do_log: false, valid: true, } @@ -258,6 +270,8 @@ fn invalid_result() -> FfeResult { allocation_key: None, reason: REASON_ERROR, error_code: ERROR_GENERAL, + serial_id: 0, + has_serial_id: false, do_log: false, valid: false, } diff --git a/ext/configuration.h b/ext/configuration.h index e68326c8912..b17ab6dd04d 100644 --- a/ext/configuration.h +++ b/ext/configuration.h @@ -109,7 +109,8 @@ enum datadog_sidecar_connection_mode { CONFIG(DOUBLE, DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS, "5.0", .ini_change = zai_config_system_ini_change) \ CONFIG(BOOL, DD_REMOTE_CONFIG_ENABLED, "true", .ini_change = zai_config_system_ini_change) \ CONFIG(INT, DD_TRACE_RETRY_INTERVAL, "100", .ini_change = zai_config_system_ini_change) \ - CONFIG(BOOL, DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED, "true") + CONFIG(BOOL, DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED, "true") \ + CONFIG(BOOL, DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED, "false") #define DD_CONFIGURATIONS_ONLY #ifdef DDTRACE diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index 361057fad8f..a618195a97d 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -403,6 +403,13 @@ "default": "false" } ], + "DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED": [ + { + "implementation": "A", + "type": "boolean", + "default": "false" + } + ], "DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED": [ { "implementation": "B", diff --git a/src/DDTrace/OpenFeature/DataDogProvider.php b/src/DDTrace/OpenFeature/DataDogProvider.php index 29d182221e7..05c7e771f6d 100644 --- a/src/DDTrace/OpenFeature/DataDogProvider.php +++ b/src/DDTrace/OpenFeature/DataDogProvider.php @@ -111,12 +111,15 @@ private function resolve( mixed $defaultValue, ?EvaluationContext $context ): ResolutionDetailsInterface { - $details = $this->evaluate($flagKey, $expectedType, $defaultValue, $this->normalizeContext($context)); + $normalizedContext = $this->normalizeContext($context); + $details = $this->evaluate($flagKey, $expectedType, $defaultValue, $normalizedContext); $this->warnIfNonProductionRuntime($details); // The PHP OpenFeature SDK does not pass ResolutionDetails to finally // hooks, so PHP records metrics here after native evaluation has the // final provider result. $this->recordEvaluationMetric($flagKey, $details); + // APM span enrichment is recorded inside NativeEvaluator::evaluate() + // (the shared choke point), so there is nothing to do here. $builder = (new ResolutionDetailsBuilder()) ->withValue($details->getValue()) diff --git a/src/api/FeatureFlags/Internal/NativeEvaluator.php b/src/api/FeatureFlags/Internal/NativeEvaluator.php index 2d9c3674101..8588f694e8e 100644 --- a/src/api/FeatureFlags/Internal/NativeEvaluator.php +++ b/src/api/FeatureFlags/Internal/NativeEvaluator.php @@ -3,6 +3,7 @@ namespace DDTrace\FeatureFlags\Internal; use DDTrace\FeatureFlags\EvaluationType; +use DDTrace\FeatureFlags\SpanEnrichmentRegistry; final class NativeEvaluator implements Evaluator { @@ -50,7 +51,17 @@ public function evaluate( $rawResult = $this->withProviderState($rawResult); } - return $this->mapper->map($rawResult, $expectedType, $defaultValue); + $details = $this->mapper->map($rawResult, $expectedType, $defaultValue); + + // APM feature-flag span enrichment. This is the single choke point both + // the native Client and the OpenFeature DataDogProvider evaluate through, + // and it is only reachable with the extension loaded (UnavailableEvaluator + // is returned otherwise), so enrichment is recorded here once rather than + // duplicated in each caller. No-op when the gate is off or there is no + // active root span; never throws into evaluation. + SpanEnrichmentRegistry::record($flagKey, $details, $targetingKey); + + return $details; } private function typeId($expectedType) diff --git a/src/api/FeatureFlags/Internal/ResultMapper.php b/src/api/FeatureFlags/Internal/ResultMapper.php index 4dd30853dc5..d263d64e085 100644 --- a/src/api/FeatureFlags/Internal/ResultMapper.php +++ b/src/api/FeatureFlags/Internal/ResultMapper.php @@ -274,6 +274,19 @@ private function exposureData($rawResult) $exposureData['doLog'] = (bool) $this->read($rawResult, array('do_log', 'doLog'), false); } + // serialId is the selected split's serial id, surfaced from the native + // bridge for APM span enrichment. It is only present when the native + // result actually carried one; a null/absent value must be left out + // entirely rather than defaulted to 0/false. Absence alone does NOT + // mean a runtime default was used -- a waterfall-assigned variant can + // also have no serial id. SpanEnrichmentRegistry::record() only + // treats an evaluation as a runtime default when BOTH serialId AND the + // variant are absent. + $serialId = $this->read($rawResult, array('serial_id', 'serialId'), null); + if ($serialId !== null) { + $exposureData['serialId'] = (int) $serialId; + } + return $exposureData; } diff --git a/src/api/FeatureFlags/SpanEnrichmentAccumulator.php b/src/api/FeatureFlags/SpanEnrichmentAccumulator.php new file mode 100644 index 00000000000..7bfa9c5c41b --- /dev/null +++ b/src/api/FeatureFlags/SpanEnrichmentAccumulator.php @@ -0,0 +1,361 @@ + Set of unique serial ids (dedupe-before-encode). */ + private $serialIds = array(); + + /** @var array> sha256hex => set of serial ids. */ + private $subjects = array(); + + /** @var array flagKey => stringified default value. */ + private $defaults = array(); + + /** @var bool Whether the state changed since the last toSpanTags() encode. */ + private $dirty = false; + + /** + * Record a serial id seen during evaluation. Deduped via a set; dropped + * (with no error) once the frozen cap is reached. + */ + public function addSerialId(int $id) + { + if (isset($this->serialIds[$id])) { + return; + } + if (count($this->serialIds) >= self::MAX_SERIAL_IDS) { + return; + } + $this->serialIds[$id] = true; + $this->dirty = true; + } + + /** + * Associate a serial id with a (hashed) subject. The targeting key is + * SHA256-hashed before storage (privacy: raw targeting keys are never + * emitted) and is only recorded when `do_log` authorizes it. + */ + public function addSubject(string $targetingKey, int $id) + { + $hashed = $this->hashTargetingKey($targetingKey); + + if (isset($this->subjects[$hashed])) { + if (isset($this->subjects[$hashed][$id])) { + return; + } + if (count($this->subjects[$hashed]) >= self::MAX_EXPERIMENTS_PER_SUBJECT) { + return; + } + $this->subjects[$hashed][$id] = true; + $this->dirty = true; + return; + } + + if (count($this->subjects) >= self::MAX_SUBJECTS) { + return; + } + $this->subjects[$hashed] = array($id => true); + $this->dirty = true; + } + + /** + * Record a runtime-default value for a flag (first-wins). Object/array + * values are JSON-encoded (never the implicit "Array"/"[object Object]" + * cast); the stringified value is truncated to the frozen length budget in + * a UTF-8-safe manner. + * + * @param mixed $value + */ + public function addDefault(string $flagKey, $value) + { + if (array_key_exists($flagKey, $this->defaults)) { + return; + } + if (count($this->defaults) >= self::MAX_DEFAULTS) { + return; + } + + $this->defaults[$flagKey] = $this->stringifyDefault($value); + $this->dirty = true; + } + + /** + * Whether the accumulated state changed since the last call, consuming the + * flag. Lets the caller skip re-encoding and re-writing the span tags when + * an evaluation added nothing new. + */ + public function consumeDirty(): bool + { + $wasDirty = $this->dirty; + $this->dirty = false; + return $wasDirty; + } + + /** + * Whether the accumulator carries anything worth writing. Mirrors the Node + * reference: subjects are intentionally NOT checked, because addSubject is + * never reached without a preceding addSerialId. + */ + public function hasData() + { + return count($this->serialIds) > 0 || count($this->defaults) > 0; + } + + /** + * Encode the accumulated state into the frozen `ffe_*` span tag set. + * + * Output-shape contract (frozen): + * - ffe_flags_enc => BARE base64 string + * - ffe_subjects_enc => JSON-stringified object {sha256hex: base64} + * - ffe_runtime_defaults => JSON-stringified object {flagKey: valueStr} + * + * Empty components are omitted entirely. + * + * @return array + */ + public function toSpanTags() + { + $tags = array(); + + if (count($this->serialIds) > 0) { + $tags[self::TAG_FLAGS] = $this->encodeDeltaVarint(array_keys($this->serialIds)); + } + + if (count($this->subjects) > 0) { + $encodedSubjects = array(); + foreach ($this->subjects as $hashed => $ids) { + $encodedSubjects[$hashed] = $this->encodeDeltaVarint(array_keys($ids)); + } + // JSON_UNESCAPED_UNICODE + JSON_UNESCAPED_SLASHES match Node JSON.stringify + // byte-for-byte: raw UTF-8 (no \uXXXX) and bare '/' (base64 ids contain '/'). + $tags[self::TAG_SUBJECTS] = json_encode($encodedSubjects, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + if (count($this->defaults) > 0) { + $tags[self::TAG_RUNTIME_DEFAULTS] = json_encode($this->defaults, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + return $tags; + } + + /** + * ULEB128 delta-varint + base64 encoder (frozen). + * + * Rules (must match Node + the L2 decoder exactly): dedupe via set, sort + * ascending, delta-from-previous (first delta = first value), UNSIGNED + * LEB128 (7 bits/byte, MSB = continuation). Empty input encodes to "" so + * the caller omits the tag. + * + * Golden vector: [100,108,128,130] => "ZAgUAg==". + * + * @param array $ids + * @return string + */ + private function encodeDeltaVarint(array $ids) + { + if (count($ids) === 0) { + return ''; + } + + $unique = array(); + foreach ($ids as $id) { + $unique[(int) $id] = true; + } + $sorted = array_keys($unique); + sort($sorted, SORT_NUMERIC); + + $buffer = ''; + $prev = 0; + foreach ($sorted as $id) { + $delta = $id - $prev; + $prev = $id; + + // Unsigned LEB128 of the non-negative delta. + while ($delta > 0x7F) { + $buffer .= chr(($delta & 0x7F) | 0x80); + $delta >>= 7; + } + $buffer .= chr($delta & 0x7F); + } + + return base64_encode($buffer); + } + + /** + * Decode a delta-varint base64 string back into the serial-id set. Provided + * for round-trip self-tests (the L2 `utils.py` decoder is the authority); + * not used on the write path. + * + * @return array + */ + public function decodeDeltaVarint($encoded) + { + $ids = array(); + if (!is_string($encoded) || $encoded === '') { + return $ids; + } + + $bytes = base64_decode($encoded, true); + if ($bytes === false) { + return $ids; + } + + $prev = 0; + $shift = 0; + $delta = 0; + $length = strlen($bytes); + for ($i = 0; $i < $length; $i++) { + $byte = ord($bytes[$i]); + $delta |= ($byte & 0x7F) << $shift; + if (($byte & 0x80) === 0) { + $prev += $delta; + $ids[] = $prev; + $delta = 0; + $shift = 0; + } else { + $shift += 7; + } + } + + return $ids; + } + + /** + * Lowercase hex SHA256 of the targeting key (frozen; stdlib ext-hash). + */ + private function hashTargetingKey($key) + { + return hash('sha256', $key); + } + + /** + * @param mixed $value + * @return string + */ + private function stringifyDefault($value) + { + if (is_array($value) || is_object($value)) { + // Match Node JSON.stringify: raw UTF-8 (no \uXXXX) and bare '/'. Default + // json_encode escapes both, which both breaks byte-parity AND inflates the + // length so the 64-char truncation can cut mid-escape-sequence. + $encoded = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $valueStr = $encoded === false ? '' : $encoded; + } elseif (is_bool($value)) { + // Match the Node String(boolean) form: "true"/"false". + $valueStr = $value ? 'true' : 'false'; + } elseif ($value === null) { + $valueStr = 'null'; + } elseif (is_float($value)) { + $valueStr = $this->stringifyFloat($value); + } else { + $valueStr = (string) $value; + } + + return $this->truncateUtf8($valueStr, self::MAX_DEFAULT_VALUE_LENGTH); + } + + /** + * Match Node's String(number) form for floats. PHP's plain (string) cast + * diverges from it in three ways: uppercase "E" exponents (Node: lowercase + * "e"), a signed zero that renders "-0" (Node: "0"), and (via json_encode, + * used below to get a round-trip-shortest mantissa like Node's) a + * trailing ".0" before the exponent that Node's formatter omits. + */ + private function stringifyFloat($value) + { + if (is_nan($value)) { + return 'NaN'; + } + if (is_infinite($value)) { + return $value > 0 ? 'Infinity' : '-Infinity'; + } + if ($value === 0.0) { + return '0'; + } + + $encoded = json_encode($value); + return preg_replace('/\.0(?=e)/', '', $encoded); + } + + /** + * Truncate to at most $maxLength characters without splitting a multi-byte + * UTF-8 sequence. Matches up to $maxLength code points from the start: /u + * treats the subject as UTF-8 and /s lets '.' span newlines. If the subject + * is not valid UTF-8 (preg_match returns false), falls back to a + * codepoint-safe byte walk. + */ + private function truncateUtf8($value, $maxLength) + { + if (preg_match('/\A.{0,' . (int) $maxLength . '}/su', $value, $m)) { + return $m[0]; + } + + return $this->truncateUtf8ByteFallback($value, $maxLength); + } + + /** + * mbstring-free fallback for truncateUtf8(). Walks codepoint-by-codepoint + * (via UTF-8 leading-byte length) so the $maxLength cutoff counts + * characters, not bytes -- a byte cutoff would truncate multi-byte text + * (e.g. CJK, emoji) far below $maxLength chars. Kept as its own method so + * it is directly testable regardless of whether ext-mbstring is loaded in + * the environment running the tests. + */ + private function truncateUtf8ByteFallback($value, $maxLength) + { + if (strlen($value) <= $maxLength) { + return $value; + } + + $len = strlen($value); + $offset = 0; + $count = 0; + while ($offset < $len && $count < $maxLength) { + $byte = ord($value[$offset]); + if ($byte < 0x80) { + $seqLen = 1; + } elseif (($byte & 0xE0) === 0xC0) { + $seqLen = 2; + } elseif (($byte & 0xF0) === 0xE0) { + $seqLen = 3; + } elseif (($byte & 0xF8) === 0xF0) { + $seqLen = 4; + } else { + $seqLen = 1; + } + if ($offset + $seqLen > $len) { + break; + } + $offset += $seqLen; + $count++; + } + + return substr($value, 0, $offset); + } +} diff --git a/src/api/FeatureFlags/SpanEnrichmentRegistry.php b/src/api/FeatureFlags/SpanEnrichmentRegistry.php new file mode 100644 index 00000000000..4194d9243e7 --- /dev/null +++ b/src/api/FeatureFlags/SpanEnrichmentRegistry.php @@ -0,0 +1,113 @@ +getExposureData(); + $serialId = array_key_exists(self::SERIAL_ID_METADATA_KEY, $exposure) + ? $exposure[self::SERIAL_ID_METADATA_KEY] + : null; + $doLog = !empty($exposure[self::DO_LOG_METADATA_KEY]); + + if ($serialId !== null) { + $accumulator->addSerialId((int) $serialId); + if ($doLog && $targetingKey !== null && $targetingKey !== '') { + $accumulator->addSubject($targetingKey, (int) $serialId); + } + } else { + $variant = $details->getVariant(); + if ($variant === null || $variant === '') { + $accumulator->addDefault((string) $flagKey, $details->getValue()); + } + } + + // Only re-encode + rewrite the tags when this evaluation actually + // changed the accumulated state; a repeated/duplicate evaluation adds + // nothing new and the meta already holds the current union. + if ($accumulator->consumeDirty()) { + foreach ($accumulator->toSpanTags() as $key => $value) { + $root->meta[$key] = $value; + } + } + } catch (\Throwable $e) { + // Enrichment must never break flag evaluation. + } + } +} diff --git a/tests/OpenFeature/SpanEnrichmentAccumulatorTest.php b/tests/OpenFeature/SpanEnrichmentAccumulatorTest.php new file mode 100644 index 00000000000..56615a3bb9f --- /dev/null +++ b/tests/OpenFeature/SpanEnrichmentAccumulatorTest.php @@ -0,0 +1,311 @@ +addSerialId(100); + $acc->addSerialId(108); + $acc->addSerialId(128); + $acc->addSerialId(130); + + $tags = $acc->toSpanTags(); + + // [100,108,128,130] -> deltas [100,8,20,2] -> ULEB128 [0x64,0x08,0x14,0x02] + // -> base64 "ZAgUAg==". This is the frozen oracle shared with the L2 + // decode side; any divergence breaks backend/Trino parity. + self::assertSame('ZAgUAg==', $tags[SpanEnrichmentAccumulator::TAG_FLAGS]); + } + + public function testCodecRoundTrips(): void + { + $acc = new SpanEnrichmentAccumulator(); + $input = [130, 100, 108, 128, 100]; // out of order + a duplicate + foreach ($input as $id) { + $acc->addSerialId($id); + } + + $encoded = $acc->toSpanTags()[SpanEnrichmentAccumulator::TAG_FLAGS]; + $decoded = $acc->decodeDeltaVarint($encoded); + + self::assertSame([100, 108, 128, 130], $decoded); + } + + public function testCodecMultiByteVarint(): void + { + // 300 needs two ULEB128 bytes: delta 300 = 0b100101100 -> + // [0xAC, 0x02]. Round-tripping proves the continuation-bit handling. + $acc = new SpanEnrichmentAccumulator(); + $acc->addSerialId(300); + $acc->addSerialId(301); + + $decoded = $acc->decodeDeltaVarint( + $acc->toSpanTags()[SpanEnrichmentAccumulator::TAG_FLAGS] + ); + + self::assertSame([300, 301], $decoded); + } + + public function testEmptySerialIdsOmitsFlagsTag(): void + { + $acc = new SpanEnrichmentAccumulator(); + + self::assertFalse($acc->hasData()); + self::assertArrayNotHasKey(SpanEnrichmentAccumulator::TAG_FLAGS, $acc->toSpanTags()); + } + + // ---- dedupe + sort ---------------------------------------------------- + + public function testSerialIdsAreDedupedAndSorted(): void + { + $acc = new SpanEnrichmentAccumulator(); + foreach ([5, 1, 5, 3, 1] as $id) { + $acc->addSerialId($id); + } + + $decoded = $acc->decodeDeltaVarint( + $acc->toSpanTags()[SpanEnrichmentAccumulator::TAG_FLAGS] + ); + + self::assertSame([1, 3, 5], $decoded); + } + + // ---- Case 4: limits --------------------------------------------------- + + public function testMax200SerialIdsEnforced(): void + { + $acc = new SpanEnrichmentAccumulator(); + for ($i = 1; $i <= 250; $i++) { + $acc->addSerialId($i); + } + + $decoded = $acc->decodeDeltaVarint( + $acc->toSpanTags()[SpanEnrichmentAccumulator::TAG_FLAGS] + ); + + self::assertCount(200, $decoded); + self::assertSame(1, $decoded[0]); + self::assertSame(200, $decoded[199]); + } + + public function testMax10SubjectsEnforced(): void + { + $acc = new SpanEnrichmentAccumulator(); + for ($i = 1; $i <= 15; $i++) { + $acc->addSubject('subject-' . $i, $i); + } + + $subjects = json_decode($acc->toSpanTags()[SpanEnrichmentAccumulator::TAG_SUBJECTS], true); + + self::assertCount(10, $subjects); + } + + public function testMax20ExperimentsPerSubjectEnforced(): void + { + $acc = new SpanEnrichmentAccumulator(); + $acc->addSerialId(1); // ensure hasData() + for ($i = 1; $i <= 25; $i++) { + $acc->addSubject('user-123', $i); + } + + $subjects = json_decode($acc->toSpanTags()[SpanEnrichmentAccumulator::TAG_SUBJECTS], true); + $hashed = hash('sha256', 'user-123'); + $decoded = $acc->decodeDeltaVarint($subjects[$hashed]); + + self::assertCount(20, $decoded); + } + + public function testMax5DefaultsEnforced(): void + { + $acc = new SpanEnrichmentAccumulator(); + for ($i = 1; $i <= 8; $i++) { + $acc->addDefault('flag-' . $i, 'value-' . $i); + } + + $defaults = json_decode($acc->toSpanTags()[SpanEnrichmentAccumulator::TAG_RUNTIME_DEFAULTS], true); + + self::assertCount(5, $defaults); + } + + public function testDefaultIsFirstWins(): void + { + $acc = new SpanEnrichmentAccumulator(); + $acc->addDefault('flag', 'first'); + $acc->addDefault('flag', 'second'); + + $defaults = json_decode($acc->toSpanTags()[SpanEnrichmentAccumulator::TAG_RUNTIME_DEFAULTS], true); + + self::assertSame('first', $defaults['flag']); + } + + public function testDefaultValueTruncatedTo64Chars(): void + { + $acc = new SpanEnrichmentAccumulator(); + $acc->addDefault('flag', str_repeat('a', 100)); + + $defaults = json_decode($acc->toSpanTags()[SpanEnrichmentAccumulator::TAG_RUNTIME_DEFAULTS], true); + + self::assertSame(64, strlen($defaults['flag'])); + } + + public function testDefaultValueTruncationIsUtf8Safe(): void + { + // 80 two-byte characters (160 bytes) — exceeds the 64-CHARACTER budget. + // A naive 64-byte cut would split a multi-byte char and corrupt the + // string; the UTF-8-safe truncation keeps exactly 64 whole characters. + $acc = new SpanEnrichmentAccumulator(); + $acc->addDefault('flag', str_repeat("\u{00e9}", 80)); + + $defaults = json_decode($acc->toSpanTags()[SpanEnrichmentAccumulator::TAG_RUNTIME_DEFAULTS], true); + + // Valid UTF-8 (json_decode would have returned null on a broken string). + self::assertNotNull($defaults); + self::assertSame(64, mb_strlen($defaults['flag'], 'UTF-8')); + } + + /** + * Regression for the mbstring-free fallback path (PR review: byte vs. + * character truncation): without ext-mbstring, a naive substr($value, 0, + * 64) cuts at 64 BYTES, which truncates multi-byte text far below 64 + * characters (e.g. ~21 chars for 3-byte CJK). Invoked directly via + * Reflection so this test exercises the fallback regardless of whether + * ext-mbstring happens to be loaded in the environment running the suite. + * + * @dataProvider utf8FallbackTruncationCases + */ + public function testTruncateUtf8ByteFallbackCountsCharactersNotBytes(string $input, int $expectedChars): void + { + $acc = new SpanEnrichmentAccumulator(); + $method = new \ReflectionMethod(SpanEnrichmentAccumulator::class, 'truncateUtf8ByteFallback'); + $method->setAccessible(true); + + $truncated = $method->invoke($acc, $input, 64); + + self::assertTrue(mb_check_encoding($truncated, 'UTF-8'), 'fallback must not split a multi-byte sequence'); + self::assertSame($expectedChars, mb_strlen($truncated, 'UTF-8')); + } + + /** + * @return array + */ + public static function utf8FallbackTruncationCases(): array + { + return [ + 'ascii (1 byte/char)' => [str_repeat('a', 100), 64], + 'CJK (3 bytes/char)' => [str_repeat("\u{3042}", 100), 64], + 'emoji (4 bytes/char)' => [str_repeat("\u{1F600}", 100), 64], + 'shorter than limit unchanged' => [str_repeat("\u{3042}", 10), 10], + ]; + } + + // ---- Case 5: JSON / object default ------------------------------------ + + public function testObjectDefaultIsJsonStringified(): void + { + $acc = new SpanEnrichmentAccumulator(); + $acc->addDefault('flag', ['enabled' => true, 'n' => 3]); + + $defaults = json_decode($acc->toSpanTags()[SpanEnrichmentAccumulator::TAG_RUNTIME_DEFAULTS], true); + + // Must be JSON, never "Array"/"[object Object]". + self::assertSame('{"enabled":true,"n":3}', $defaults['flag']); + } + + /** + * Runtime-default rendering must match the frozen Node `String(value)` + * (RESEARCH.md:102): null => "null", booleans => "true"/"false", scalars + * via string cast, objects via JSON. This locks the scalar/null/bool parity + * the L2 decode side expects. + * + * @dataProvider nodeStringValueRenderings + * @param mixed $value + */ + public function testDefaultRenderingMatchesNodeStringValue($value, string $expected): void + { + $acc = new SpanEnrichmentAccumulator(); + $acc->addDefault('flag', $value); + + $defaults = json_decode($acc->toSpanTags()[SpanEnrichmentAccumulator::TAG_RUNTIME_DEFAULTS], true); + + self::assertSame($expected, $defaults['flag']); + } + + /** + * @return array + */ + public static function nodeStringValueRenderings(): array + { + return [ + 'null => "null"' => [null, 'null'], + 'true => "true"' => [true, 'true'], + 'false => "false"' => [false, 'false'], + 'int => decimal string' => [42, '42'], + 'float => string' => [4.5, '4.5'], + 'string passthrough' => ['plain', 'plain'], + 'stdClass => JSON' => [(object) ['a' => 1], '{"a":1}'], + // PHP's plain (string) cast diverges from Node's String(number) for + // these: uppercase "E" exponents, a "-0" for negative zero, and (via + // (string)'s default precision) a differently-rounded mantissa. + 'float exponent lowercase e' => [1.0e-7, '1e-7'], + 'float exponent with sign' => [1.5e21, '1.5e+21'], + 'negative zero => "0"' => [-0.0, '0'], + 'whole float => no decimal' => [100.0, '100'], + ]; + } + + public function testSubjectsTagIsJsonObjectOfBase64(): void + { + $acc = new SpanEnrichmentAccumulator(); + $acc->addSerialId(100); + $acc->addSubject('user-123', 100); + + $raw = $acc->toSpanTags()[SpanEnrichmentAccumulator::TAG_SUBJECTS]; + $subjects = json_decode($raw, true); + + self::assertIsArray($subjects); + $hashed = hash('sha256', 'user-123'); + // SHA256("user-123") is the frozen fixture digest. + self::assertSame( + 'fcdec6df4d44dbc637c7c5b58efface52a7f8a88535423430255be0bb89bedd8', + $hashed + ); + self::assertArrayHasKey($hashed, $subjects); + self::assertSame([100], $acc->decodeDeltaVarint($subjects[$hashed])); + } + + public function testFlagsTagIsBareBase64NotJson(): void + { + $acc = new SpanEnrichmentAccumulator(); + $acc->addSerialId(100); + + $flags = $acc->toSpanTags()[SpanEnrichmentAccumulator::TAG_FLAGS]; + + // Bare base64 string, NOT a JSON-wrapped value. + self::assertSame('"', substr(json_encode($flags), 0, 1)); + self::assertNull(json_decode($flags, true)); + self::assertSame($flags, base64_encode(base64_decode($flags, true))); + } + +} diff --git a/tests/OpenFeature/span_enrichment_concurrent_roots.phpt b/tests/OpenFeature/span_enrichment_concurrent_roots.phpt new file mode 100644 index 00000000000..f80927c2996 --- /dev/null +++ b/tests/OpenFeature/span_enrichment_concurrent_roots.phpt @@ -0,0 +1,58 @@ +--TEST-- +FFE span enrichment: concurrently-open root spans each keep their own tags +--INI-- +datadog.trace.generate_root_span=0 +datadog.experimental_flagging_provider_span_enrichment_enabled=1 +--FILE-- + 100, 'doLog' => false)); +SpanEnrichmentRegistry::record('flag.a', $a, null); + +// Trace B: a second, concurrently-open root on its own stack (A stays open +// underneath). Models the fibers / multiple-span-stacks case. +$rootB = \DDTrace\start_trace_span(); +$b = new EvaluationDetails('off', EvaluationType::STRING, EvaluationReason::SPLIT, 'b', null, null, array(), array('serialId' => 200, 'doLog' => false)); +SpanEnrichmentRegistry::record('flag.b', $b, null); + +// Each root carries only its own evaluation; keying the accumulator on the span +// object makes this correct without any manual root tracking. +show('rootA_flags', $codec->decodeDeltaVarint($rootA->meta['ffe_flags_enc'])); +show('rootB_flags', $codec->decodeDeltaVarint($rootB->meta['ffe_flags_enc'])); +show('rootA_has_b', in_array(200, $codec->decodeDeltaVarint($rootA->meta['ffe_flags_enc']), true)); +show('rootB_has_a', in_array(100, $codec->decodeDeltaVarint($rootB->meta['ffe_flags_enc']), true)); +?> +--EXPECT-- +rootA_flags=[100] +rootB_flags=[200] +rootA_has_b=false +rootB_has_a=false diff --git a/tests/OpenFeature/span_enrichment_gate_off.phpt b/tests/OpenFeature/span_enrichment_gate_off.phpt new file mode 100644 index 00000000000..517d0880f56 --- /dev/null +++ b/tests/OpenFeature/span_enrichment_gate_off.phpt @@ -0,0 +1,44 @@ +--TEST-- +FFE span enrichment: gate off writes no ffe_* tags onto the root span +--INI-- +datadog.experimental_flagging_provider_span_enrichment_enabled=0 +--FILE-- + 100, 'doLog' => true)); +SpanEnrichmentRegistry::record('flag.a', $a, 'user-1'); + +$meta = \DDTrace\root_span()->meta; +show('has_flags', isset($meta['ffe_flags_enc'])); +show('has_subjects', isset($meta['ffe_subjects_enc'])); +show('has_defaults', isset($meta['ffe_runtime_defaults'])); +?> +--EXPECT-- +has_flags=false +has_subjects=false +has_defaults=false diff --git a/tests/OpenFeature/span_enrichment_root_meta.phpt b/tests/OpenFeature/span_enrichment_root_meta.phpt new file mode 100644 index 00000000000..6f23b6cd03c --- /dev/null +++ b/tests/OpenFeature/span_enrichment_root_meta.phpt @@ -0,0 +1,66 @@ +--TEST-- +FFE span enrichment: evaluations aggregate onto the active root span's meta +--INI-- +datadog.experimental_flagging_provider_span_enrichment_enabled=1 +--FILE-- + 100, 'doLog' => true)); +SpanEnrichmentRegistry::record('flag.a', $a, 'user-1'); + +$b = new EvaluationDetails('blue', EvaluationType::STRING, EvaluationReason::SPLIT, 'blue', null, null, array(), array('serialId' => 108, 'doLog' => false)); +SpanEnrichmentRegistry::record('flag.b', $b, 'user-1'); + +$c = new EvaluationDetails('fallback', EvaluationType::STRING, EvaluationReason::DEFAULT_REASON, null, null, null, array(), array()); +SpanEnrichmentRegistry::record('flag.c', $c, null); + +$meta = \DDTrace\root_span()->meta; +$codec = new SpanEnrichmentAccumulator(); + +show('flags', $codec->decodeDeltaVarint($meta['ffe_flags_enc'])); + +$subjects = json_decode($meta['ffe_subjects_enc'], true); +$subjectKeys = array_keys($subjects); +$subjectVals = array_values($subjects); +show('subject_count', count($subjects)); +show('subject_key_is_sha256', (bool) preg_match('/^[0-9a-f]{64}$/', (string) $subjectKeys[0])); +show('subject_ids', $codec->decodeDeltaVarint($subjectVals[0])); + +show('defaults', json_decode($meta['ffe_runtime_defaults'], true)); +?> +--EXPECT-- +flags=[100,108] +subject_count=1 +subject_key_is_sha256=true +subject_ids=[100] +defaults={"flag.c":"fallback"} diff --git a/tests/api/Unit/FeatureFlags/ResultMapperTest.php b/tests/api/Unit/FeatureFlags/ResultMapperTest.php index 1f9d4f81e93..bc2881f81a4 100644 --- a/tests/api/Unit/FeatureFlags/ResultMapperTest.php +++ b/tests/api/Unit/FeatureFlags/ResultMapperTest.php @@ -227,4 +227,59 @@ public function testMapsObjectBridgeResultToEvaluationDetails() $details->getProviderState() ); } + + public function testThreadsSerialIdIntoExposureDataWhenPresent() + { + $details = (new ResultMapper())->map(array( + 'value_json' => '"blue"', + 'variant' => 'variant-a', + 'allocation_key' => 'alloc-1', + 'reason' => ResultMapper::BRIDGE_REASON_SPLIT, + 'error_code' => ResultMapper::BRIDGE_ERROR_NONE, + 'do_log' => true, + 'serial_id' => 4242, + ), EvaluationType::STRING, 'red'); + + $this->assertSame( + array('allocationKey' => 'alloc-1', 'doLog' => true, 'serialId' => 4242), + $details->getExposureData() + ); + // assertSame above already enforces the strict int type (4242 !== "4242"); + // avoid assertIsInt()/assertInternalType() which are unavailable on the + // PHP 7.0 PHPUnit and removed in PHPUnit 9 respectively. + $this->assertTrue(is_int($details->getExposureData()['serialId'])); + } + + public function testThreadsCamelCaseSerialIdFromObjectResult() + { + $rawResult = new \stdClass(); + $rawResult->valueJson = '"green"'; + $rawResult->variant = 'variant-b'; + $rawResult->allocationKey = 'alloc-2'; + $rawResult->reason = ResultMapper::BRIDGE_REASON_SPLIT; + $rawResult->errorCode = ResultMapper::BRIDGE_ERROR_NONE; + $rawResult->doLog = true; + $rawResult->serialId = 7; + + $details = (new ResultMapper())->map($rawResult, EvaluationType::STRING, 'fallback'); + + $this->assertSame( + array('allocationKey' => 'alloc-2', 'doLog' => true, 'serialId' => 7), + $details->getExposureData() + ); + } + + public function testOmitsSerialIdFromExposureDataWhenAbsent() + { + $details = (new ResultMapper())->map(array( + 'value_json' => '"blue"', + 'variant' => 'variant-a', + 'allocation_key' => 'alloc-1', + 'reason' => ResultMapper::BRIDGE_REASON_SPLIT, + 'error_code' => ResultMapper::BRIDGE_ERROR_NONE, + 'do_log' => true, + ), EvaluationType::STRING, 'red'); + + $this->assertArrayNotHasKey('serialId', $details->getExposureData()); + } } diff --git a/tests/ext/ffe/evaluation_metrics_native.phpt b/tests/ext/ffe/evaluation_metrics_native.phpt index 572ea09fbbe..c0d88766433 100644 --- a/tests/ext/ffe/evaluation_metrics_native.phpt +++ b/tests/ext/ffe/evaluation_metrics_native.phpt @@ -53,5 +53,5 @@ 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} +evaluation_without_native_metric={"valueJson":"\"blue\"","variant":"blue","allocationKey":"alloc-string","reason":0,"errorCode":0,"doLog":true,"serialId":7,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} +missing_flag_without_native_metric={"valueJson":"null","variant":null,"allocationKey":null,"reason":5,"errorCode":3,"doLog":false,"serialId":null,"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 3b113f1300f..f9fdb8761be 100644 --- a/tests/ext/ffe/native_bridge_evaluate.phpt +++ b/tests/ext/ffe/native_bridge_evaluate.phpt @@ -132,14 +132,14 @@ has_config_before=false native_exposure_flush_exists=true internal_exposure_flush_exists=false old_exposure_forwarder_exists=false -provider_not_ready={"valueJson":"null","variant":null,"allocationKey":null,"reason":5,"errorCode":6,"doLog":false,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} +provider_not_ready={"valueJson":"null","variant":null,"allocationKey":null,"reason":5,"errorCode":6,"doLog":false,"serialId":null,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} load=true has_config_after=true -success={"valueJson":"\"blue\"","variant":"blue","allocationKey":"alloc-string","reason":0,"errorCode":0,"doLog":true,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} +success={"valueJson":"\"blue\"","variant":"blue","allocationKey":"alloc-string","reason":0,"errorCode":0,"doLog":true,"serialId":7,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} 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":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} +numeric_attribute_key={"valueJson":"\"numeric-attribute-name\"","variant":"numeric-key","allocationKey":"alloc-numeric-attribute","reason":2,"errorCode":0,"doLog":true,"serialId":null,"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,"serialId":null,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} +missing={"valueJson":"null","variant":null,"allocationKey":null,"reason":5,"errorCode":3,"doLog":false,"serialId":null,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} +type_mismatch={"valueJson":"null","variant":null,"allocationKey":null,"reason":5,"errorCode":1,"doLog":false,"serialId":null,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} +parse_error={"valueJson":"null","variant":null,"allocationKey":null,"reason":5,"errorCode":2,"doLog":false,"serialId":null,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} diff --git a/tests/ext/ffe/remote_config_lifecycle.phpt b/tests/ext/ffe/remote_config_lifecycle.phpt index 1af6f2d41f3..ea7f571a1ed 100644 --- a/tests/ext/ffe/remote_config_lifecycle.phpt +++ b/tests/ext/ffe/remote_config_lifecycle.phpt @@ -84,7 +84,7 @@ reset_request_replayer(); before=false loaded=true has_config_after_add=true -success={"valueJson":"\"blue\"","variant":"blue","allocationKey":"alloc-string","reason":0,"errorCode":0,"doLog":true,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} +success={"valueJson":"\"blue\"","variant":"blue","allocationKey":"alloc-string","reason":0,"errorCode":0,"doLog":true,"serialId":7,"providerState":[],"errorMessage":null,"hasConfig":null,"configVersion":null} removed=true has_config_after_remove=false version_increased=true diff --git a/tests/ext/ffe/serial_id_passthrough.phpt b/tests/ext/ffe/serial_id_passthrough.phpt new file mode 100644 index 00000000000..5c8ffc6b30a --- /dev/null +++ b/tests/ext/ffe/serial_id_passthrough.phpt @@ -0,0 +1,77 @@ +--TEST-- +FFE native bridge surfaces the split serial_id (Rust FfeResult -> C reader -> PHP) for APM span enrichment +--FILE-- +serialId); + +$config = <<<'JSON' +{ + "createdAt": "2024-01-01T00:00:00Z", + "environment": {"name": "test"}, + "flags": { + "string.flag": { + "key": "string.flag", + "enabled": true, + "variationType": "STRING", + "variations": { + "blue": {"key": "blue", "value": "blue"} + }, + "allocations": [{ + "key": "alloc-string", + "rules": [], + "splits": [{"variationKey": "blue", "serialId": 4242, "shards": []}], + "doLog": true + }] + }, + "no.serial.flag": { + "key": "no.serial.flag", + "enabled": true, + "variationType": "STRING", + "variations": { + "green": {"key": "green", "value": "green"} + }, + "allocations": [{ + "key": "alloc-no-serial", + "rules": [], + "splits": [{"variationKey": "green", "shards": []}], + "doLog": false + }] + } + } +} +JSON; + +show('load', \DDTrace\Testing\ffe_load_config($config)); + +// A split that declares serialId surfaces it as an int on the result object. +$withSerial = \DDTrace\ffe_evaluate('string.flag', 0, 'user-1', array()); +show('with_serial_variant', $withSerial->variant); +show('with_serial_id', $withSerial->serialId); +show('with_serial_id_is_int', is_int($withSerial->serialId)); + +// A split that omits serialId leaves it null (Pattern B: absence != 0). This is +// what lets the PHP accumulator treat the evaluation as a runtime default rather +// than mistaking a 0 sentinel for a real serial id. +$noSerial = \DDTrace\ffe_evaluate('no.serial.flag', 0, 'user-1', array()); +show('no_serial_variant', $noSerial->variant); +show('no_serial_id', $noSerial->serialId); + +// Errors / unknown flags also carry a null serial id. +$missing = \DDTrace\ffe_evaluate('missing.flag', 0, 'user-1', array()); +show('missing_serial', $missing->serialId); +?> +--EXPECT-- +not_ready_serial=null +load=true +with_serial_variant="blue" +with_serial_id=4242 +with_serial_id_is_int=true +no_serial_variant="green" +no_serial_id=null +missing_serial=null diff --git a/tests/ext/ffe/system_test_data_evaluate.phpt b/tests/ext/ffe/system_test_data_evaluate.phpt index a673af26ce1..43398f7e5c6 100644 --- a/tests/ext/ffe/system_test_data_evaluate.phpt +++ b/tests/ext/ffe/system_test_data_evaluate.phpt @@ -112,6 +112,8 @@ function require_feature_flag_api($root) 'EvaluationReason', 'EvaluationErrorCode', 'EvaluationDetails', + 'SpanEnrichmentAccumulator', + 'SpanEnrichmentRegistry', ) as $classFile) { require_once $apiRoot . '/' . $classFile . '.php'; } diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 2a064f1b294..13e70fe2a27 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -143,6 +143,7 @@ ./OpenFeature/ + ./OpenFeature/ diff --git a/tracer/ddtrace.stub.php b/tracer/ddtrace.stub.php index 1499091ad71..adac5b1f10c 100644 --- a/tracer/ddtrace.stub.php +++ b/tracer/ddtrace.stub.php @@ -66,6 +66,7 @@ final class FfeResult { public int $reason = 0; public int $errorCode = 0; public bool $doLog = false; + public ?int $serialId = null; public array $providerState = []; public ?string $errorMessage = null; public ?bool $hasConfig = null; diff --git a/tracer/ddtrace_arginfo.h b/tracer/ddtrace_arginfo.h index 9a9b8a16d69..afa0f62d9f7 100644 --- a/tracer/ddtrace_arginfo.h +++ b/tracer/ddtrace_arginfo.h @@ -673,6 +673,12 @@ static zend_class_entry *register_class_DDTrace_FfeResult(void) zend_declare_typed_property(class_entry, property_doLog_name, &property_doLog_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_BOOL)); zend_string_release_ex(property_doLog_name, true); + zval property_serialId_default_value; + ZVAL_NULL(&property_serialId_default_value); + zend_string *property_serialId_name = zend_string_init("serialId", sizeof("serialId") - 1, true); + zend_declare_typed_property(class_entry, property_serialId_name, &property_serialId_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_LONG|MAY_BE_NULL)); + zend_string_release_ex(property_serialId_name, true); + zval property_providerState_default_value; ZVAL_EMPTY_ARRAY(&property_providerState_default_value); zend_string *property_providerState_name = zend_string_init("providerState", sizeof("providerState") - 1, true); diff --git a/tracer/functions.c b/tracer/functions.c index 4605db53444..c5a69362202 100644 --- a/tracer/functions.c +++ b/tracer/functions.c @@ -1882,6 +1882,13 @@ PHP_FUNCTION(DDTrace_ffe_evaluate) { 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); + // serialId is only populated when the native result actually carried one + // (has_serial_id). It stays null otherwise so the PHP accumulator can use + // the Pattern B "missing variant => runtime default" branch, rather than + // mistaking a 0 sentinel for a real serial id. + if (result.has_serial_id) { + ddtrace_ffe_update_long_property(return_value, ZEND_STRL("serialId"), (zend_long)result.serial_id); + } ddtrace_ffe_update_empty_array_property(return_value, ZEND_STRL("providerState")); } @@ -2120,6 +2127,31 @@ PHP_FUNCTION(dd_trace_internal_fn) { waited += 10; } RETVAL_BOOL(ddog_is_agent_info_ready()); + // await_ffe_config is a test/debug helper (like the other + // dd_trace_internal_fn helpers): it actively pumps Remote Config and can + // block for up to 5s, so long-running CLI test servers (the system-tests + // parametric app / ffe-dogfooding harness) can deterministically wait for + // the pushed UFC before evaluating. + } else if (FUNCTION_NAME_MATCHES("await_ffe_config")) { + // Block until the sidecar has delivered an FFE (FFE_FLAGS) Remote Config update and the + // worker has applied it. In long-running CLI servers (e.g. the parametric apps) the + // SIGVTALRM-driven remote-config refresh is starved because the process spends most of + // its time blocked in IO rather than burning CPU time, so an evaluation issued right + // after the agent ACKnowledges the config would otherwise still see no config and fall + // back to defaults. Actively pump remote configs here (same shape as await_agent_info) + // so feature-flag evaluation observes the pushed UFC. Times out after 5 seconds. + uint32_t timeout_ms = 5000; + if (params_count == 1) { + timeout_ms = (uint32_t)Z_LVAL_P(ZVAL_VARARG_PARAM(params, 0)); + } + uint32_t waited = 0; + while (!ddog_ffe_has_config() && waited < timeout_ms) { + // Actively read the SHM so we pick up the update the sidecar wrote. + datadog_check_for_new_config_now(); + usleep(10000); // 10ms + waited += 10; + } + RETVAL_BOOL(ddog_ffe_has_config()); } else if (FUNCTION_NAME_MATCHES("get_loaded_remote_configs")) { // Returns a PHP array mapping loaded RC config IDs to their content summary. // e.g. ["datadog/2/LIVE_DEBUGGING/logProbe_log.../config" => ["type"=>"probe","id"=>"log..."]] diff --git a/zend_abstract_interface/config/config.h b/zend_abstract_interface/config/config.h index d7598cf35fd..3a9c8d34f50 100644 --- a/zend_abstract_interface/config/config.h +++ b/zend_abstract_interface/config/config.h @@ -18,7 +18,7 @@ typedef uint16_t zai_config_id; #include "config_ini.h" #include "config_stable_file.h" -#define ZAI_CONFIG_ENTRIES_COUNT_MAX 300 +#define ZAI_CONFIG_ENTRIES_COUNT_MAX 320 #define ZAI_CONFIG_NAMES_COUNT_MAX 4 #define ZAI_CONFIG_NAME_BUFSIZ 72