Skip to content

Commit 6c034de

Browse files
committed
Add FFE evaluation correctness tests and LRU cache unit tests
Add ddog_ffe_load_config() FFI function to load UFC JSON config directly into the Rust FFE engine without Remote Config, enabling test-time config injection. Add 220 parametric evaluation tests driven by shared cross-tracer JSON fixtures (merged from dd-trace-py and dd-trace-java configs) and 8 LRU cache unit tests covering eviction, promotion, and edge cases.
1 parent 867337e commit 6c034de

32 files changed

Lines changed: 6433 additions & 1 deletion

components-rs/ddtrace.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ const char *ddog_remote_config_get_path(const struct ddog_RemoteConfigState *rem
6666

6767
bool ddog_process_remote_configs(struct ddog_RemoteConfigState *remote_config);
6868

69+
bool ddog_ffe_load_config(const char *json);
70+
6971
bool ddog_ffe_has_config(void);
7072

7173
bool ddog_ffe_config_changed(void);

components-rs/ffe.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use datadog_ffe::rules_based::{
22
self as ffe, AssignmentReason, AssignmentValue, Attribute, Configuration, EvaluationContext,
3-
EvaluationError, ExpectedFlagType, Str,
3+
EvaluationError, ExpectedFlagType, Str, UniversalFlagConfig,
44
};
55
use std::collections::HashMap;
66
use std::ffi::{c_char, CStr, CString};
@@ -31,6 +31,26 @@ pub fn clear_config() {
3131
}
3232
}
3333

34+
/// Load a UFC JSON config string directly into the FFE engine.
35+
/// Used by tests to load config without Remote Config.
36+
#[no_mangle]
37+
pub extern "C" fn ddog_ffe_load_config(json: *const c_char) -> bool {
38+
if json.is_null() {
39+
return false;
40+
}
41+
let json_str = match unsafe { CStr::from_ptr(json) }.to_str() {
42+
Ok(s) => s,
43+
Err(_) => return false,
44+
};
45+
match UniversalFlagConfig::from_json(json_str.as_bytes().to_vec()) {
46+
Ok(ufc) => {
47+
store_config(Configuration::from_server_response(ufc));
48+
true
49+
}
50+
Err(_) => false,
51+
}
52+
}
53+
3454
/// Check if FFE configuration is loaded.
3555
#[no_mangle]
3656
pub extern "C" fn ddog_ffe_has_config() -> bool {

ext/ddtrace.c

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2927,6 +2927,11 @@ PHP_FUNCTION(dd_trace_internal_fn) {
29272927
RETVAL_BOOL(ddog_ffe_has_config());
29282928
} else if (FUNCTION_NAME_MATCHES("ffe_config_changed")) {
29292929
RETVAL_BOOL(ddog_ffe_config_changed());
2930+
} else if (params_count == 1 && FUNCTION_NAME_MATCHES("ffe_load_config")) {
2931+
zval *json_zv = ZVAL_VARARG_PARAM(params, 0);
2932+
if (Z_TYPE_P(json_zv) == IS_STRING) {
2933+
RETVAL_BOOL(ddog_ffe_load_config(Z_STRVAL_P(json_zv)));
2934+
}
29302935
} else if (FUNCTION_NAME_MATCHES("ffe_evaluate") && params_count >= 4) {
29312936
/* ffe_evaluate(flag_key, type_id, targeting_key, attributes) */
29322937
zval *flag_key_zv = ZVAL_VARARG_PARAM(params, 0);
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
<?php
2+
3+
namespace DDTrace\Tests\FeatureFlags;
4+
5+
use DDTrace\Tests\Common\BaseTestCase;
6+
7+
final class EvaluationTest extends BaseTestCase
8+
{
9+
private static $configLoaded = false;
10+
private static $configFlagKeys = [];
11+
12+
public static function ddSetUpBeforeClass()
13+
{
14+
parent::ddSetUpBeforeClass();
15+
16+
if (!function_exists('dd_trace_internal_fn')) {
17+
self::markTestSkipped('dd_trace_internal_fn not available (extension not loaded)');
18+
}
19+
20+
$configPath = __DIR__ . '/fixtures/config/ufc-config.json';
21+
$json = file_get_contents($configPath);
22+
self::$configLoaded = \dd_trace_internal_fn('ffe_load_config', $json);
23+
if (!self::$configLoaded) {
24+
self::fail('Failed to load UFC config from ' . $configPath);
25+
}
26+
27+
// Track which flags exist in the config so we can distinguish
28+
// "flag not in config" from "flag exists but no matching allocation"
29+
$config = json_decode($json, true);
30+
if (isset($config['flags'])) {
31+
self::$configFlagKeys = array_keys($config['flags']);
32+
}
33+
}
34+
35+
/**
36+
* Map variation type string to the integer type ID expected by ffe_evaluate.
37+
* 0 = string, 1 = integer, 2 = float, 3 = boolean, 4 = object (JSON)
38+
*/
39+
private static function variationTypeToId($variationType)
40+
{
41+
$map = [
42+
'STRING' => 0,
43+
'INTEGER' => 1,
44+
'NUMERIC' => 2,
45+
'BOOLEAN' => 3,
46+
'JSON' => 4,
47+
];
48+
return isset($map[$variationType]) ? $map[$variationType] : -1;
49+
}
50+
51+
/**
52+
* Build the attributes array for ffe_evaluate from the test case attributes.
53+
* Only scalar types (string, number, bool) are supported by the FFI bridge.
54+
*/
55+
private static function buildAttributes(array $attrs)
56+
{
57+
$result = [];
58+
foreach ($attrs as $key => $value) {
59+
if (is_string($value) || is_numeric($value) || is_bool($value)) {
60+
$result[$key] = $value;
61+
}
62+
}
63+
return $result;
64+
}
65+
66+
/**
67+
* Parse the value_json string returned by ffe_evaluate based on the variation type.
68+
*/
69+
private static function parseValueJson($valueJson, $variationType)
70+
{
71+
switch ($variationType) {
72+
case 'STRING':
73+
return json_decode($valueJson, true);
74+
case 'INTEGER':
75+
return (int) $valueJson;
76+
case 'NUMERIC':
77+
return (float) $valueJson;
78+
case 'BOOLEAN':
79+
return $valueJson === 'true';
80+
case 'JSON':
81+
return json_decode($valueJson, true);
82+
default:
83+
return json_decode($valueJson, true);
84+
}
85+
}
86+
87+
/**
88+
* Data provider that scans all evaluation case fixture files and flattens
89+
* every scenario into a [fileName, caseIndex, caseData] tuple.
90+
*/
91+
public function provideEvaluationCases()
92+
{
93+
$casesDir = __DIR__ . '/fixtures/evaluation-cases';
94+
$files = glob($casesDir . '/*.json');
95+
$dataset = [];
96+
97+
foreach ($files as $filePath) {
98+
$fileName = basename($filePath, '.json');
99+
$cases = json_decode(file_get_contents($filePath), true);
100+
101+
foreach ($cases as $index => $case) {
102+
$label = sprintf('%s#%d (%s)', $fileName, $index, $case['flag']);
103+
$dataset[$label] = [$fileName, $index, $case];
104+
}
105+
}
106+
107+
return $dataset;
108+
}
109+
110+
/**
111+
* @dataProvider provideEvaluationCases
112+
*/
113+
public function testEvaluation($fileName, $caseIndex, $case)
114+
{
115+
if (!self::$configLoaded) {
116+
$this->markTestSkipped('UFC config was not loaded');
117+
}
118+
119+
$flagKey = $case['flag'];
120+
$variationType = $case['variationType'];
121+
$typeId = self::variationTypeToId($variationType);
122+
$targetingKey = isset($case['targetingKey']) ? $case['targetingKey'] : '';
123+
$attributes = isset($case['attributes']) ? self::buildAttributes($case['attributes']) : [];
124+
$defaultValue = isset($case['defaultValue']) ? $case['defaultValue'] : null;
125+
$expectedValue = $case['result']['value'];
126+
127+
// Skip test cases that reference flags not present in the UFC config
128+
// AND expect a non-default result (these require a different config).
129+
if (!in_array($flagKey, self::$configFlagKeys) && $expectedValue !== $defaultValue) {
130+
$this->markTestSkipped(
131+
sprintf('Flag "%s" not in UFC config and expected non-default value', $flagKey)
132+
);
133+
}
134+
135+
$result = \dd_trace_internal_fn('ffe_evaluate', $flagKey, $typeId, $targetingKey, $attributes);
136+
137+
$this->assertNotNull(
138+
$result,
139+
sprintf('ffe_evaluate returned null for %s#%d', $fileName, $caseIndex)
140+
);
141+
142+
$this->assertArrayHasKey('value_json', $result);
143+
144+
$errorCode = isset($result['error_code']) ? (int) $result['error_code'] : 0;
145+
146+
// When the evaluator returns an error, the Provider layer would return
147+
// the defaultValue. If the expected result equals the defaultValue,
148+
// verify the evaluator correctly returned an error (no match).
149+
if ($errorCode !== 0 && $expectedValue === $defaultValue) {
150+
// Evaluator correctly could not resolve — Provider returns default.
151+
$this->assertTrue(true);
152+
return;
153+
}
154+
155+
// error_code=0 with reason=1 means DefaultAllocationNull (no matching
156+
// allocation). Same Provider-level default behavior applies.
157+
$reason = isset($result['reason']) ? (int) $result['reason'] : -1;
158+
if ($errorCode === 0 && $reason === 1 && $expectedValue === $defaultValue) {
159+
$this->assertTrue(true);
160+
return;
161+
}
162+
163+
$actualValue = self::parseValueJson($result['value_json'], $variationType);
164+
165+
if ($variationType === 'NUMERIC') {
166+
$this->assertEquals(
167+
$expectedValue,
168+
$actualValue,
169+
sprintf('Value mismatch for %s#%d (flag=%s)', $fileName, $caseIndex, $flagKey),
170+
1e-10
171+
);
172+
} else {
173+
$this->assertSame(
174+
$expectedValue,
175+
$actualValue,
176+
sprintf('Value mismatch for %s#%d (flag=%s): expected %s, got %s',
177+
$fileName, $caseIndex, $flagKey,
178+
json_encode($expectedValue), json_encode($actualValue))
179+
);
180+
}
181+
}
182+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
<?php
2+
3+
namespace DDTrace\Tests\FeatureFlags;
4+
5+
require_once __DIR__ . '/../../src/DDTrace/FeatureFlags/LRUCache.php';
6+
7+
use DDTrace\FeatureFlags\LRUCache;
8+
use DDTrace\Tests\Common\BaseTestCase;
9+
10+
final class LRUCacheTest extends BaseTestCase
11+
{
12+
public function testGetMissReturnsNull()
13+
{
14+
$cache = new LRUCache(10);
15+
$this->assertNull($cache->get('nonexistent'));
16+
}
17+
18+
public function testSetAndGet()
19+
{
20+
$cache = new LRUCache(10);
21+
$cache->set('key1', 'value1');
22+
$this->assertSame('value1', $cache->get('key1'));
23+
}
24+
25+
public function testEviction()
26+
{
27+
$cache = new LRUCache(3);
28+
$cache->set('a', 1);
29+
$cache->set('b', 2);
30+
$cache->set('c', 3);
31+
32+
// Cache is full; inserting a 4th should evict 'a' (least recently used)
33+
$cache->set('d', 4);
34+
35+
$this->assertNull($cache->get('a'), 'Oldest entry should be evicted');
36+
$this->assertSame(2, $cache->get('b'));
37+
$this->assertSame(3, $cache->get('c'));
38+
$this->assertSame(4, $cache->get('d'));
39+
}
40+
41+
public function testAccessPromotesEntry()
42+
{
43+
$cache = new LRUCache(3);
44+
$cache->set('a', 1);
45+
$cache->set('b', 2);
46+
$cache->set('c', 3);
47+
48+
// Access 'a' to promote it — now 'b' is the least recently used
49+
$cache->get('a');
50+
51+
$cache->set('d', 4);
52+
53+
$this->assertNull($cache->get('b'), "'b' should be evicted as LRU");
54+
$this->assertSame(1, $cache->get('a'), "'a' should survive after promotion");
55+
$this->assertSame(3, $cache->get('c'));
56+
$this->assertSame(4, $cache->get('d'));
57+
}
58+
59+
public function testUpdateExistingKey()
60+
{
61+
$cache = new LRUCache(3);
62+
$cache->set('a', 1);
63+
$cache->set('b', 2);
64+
$cache->set('c', 3);
65+
66+
// Update 'a' — this should promote it to most recently used
67+
$cache->set('a', 100);
68+
69+
$this->assertSame(100, $cache->get('a'), 'Value should be updated');
70+
71+
// Now 'b' is LRU. Adding a new entry should evict 'b'.
72+
$cache->set('d', 4);
73+
$this->assertNull($cache->get('b'), "'b' should be evicted");
74+
$this->assertSame(100, $cache->get('a'));
75+
}
76+
77+
public function testClear()
78+
{
79+
$cache = new LRUCache(10);
80+
$cache->set('a', 1);
81+
$cache->set('b', 2);
82+
$cache->clear();
83+
84+
$this->assertNull($cache->get('a'));
85+
$this->assertNull($cache->get('b'));
86+
}
87+
88+
public function testEvictionOrder()
89+
{
90+
$cache = new LRUCache(4);
91+
92+
// Insert a, b, c, d in order — LRU order: a, b, c, d
93+
$cache->set('a', 1);
94+
$cache->set('b', 2);
95+
$cache->set('c', 3);
96+
$cache->set('d', 4);
97+
98+
// Access 'b' and 'a' — LRU order is now: c, d, b, a
99+
$cache->get('b');
100+
$cache->get('a');
101+
102+
// Insert 'e' — should evict 'c' (the LRU) — order: d, b, a, e
103+
$cache->set('e', 5);
104+
$this->assertNull($cache->get('c'), "'c' should be evicted first");
105+
106+
// Insert 'f' — should evict 'd' (now the LRU) — order: b, a, e, f
107+
$cache->set('f', 6);
108+
$this->assertNull($cache->get('d'), "'d' should be evicted");
109+
110+
// b, a, e, f should still be present
111+
$this->assertSame(2, $cache->get('b'));
112+
$this->assertSame(1, $cache->get('a'));
113+
$this->assertSame(5, $cache->get('e'));
114+
$this->assertSame(6, $cache->get('f'));
115+
}
116+
117+
public function testSizeOneCache()
118+
{
119+
$cache = new LRUCache(1);
120+
$cache->set('a', 1);
121+
$this->assertSame(1, $cache->get('a'));
122+
123+
$cache->set('b', 2);
124+
$this->assertNull($cache->get('a'), 'Old entry should be evicted in size-1 cache');
125+
$this->assertSame(2, $cache->get('b'));
126+
}
127+
}

tests/FeatureFlags/bootstrap.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
error_reporting(E_ALL);
4+
5+
require dirname(__DIR__, 2) . '/vendor/autoload.php';
6+
7+
$phpunitVersionParts = explode('.', \PHPUnit\Runner\Version::id());
8+
define('PHPUNIT_MAJOR', intval($phpunitVersionParts[0]));
9+
10+
if (PHPUNIT_MAJOR >= 8) {
11+
require dirname(__DIR__) . '/Common/MultiPHPUnitVersionAdapter_typed.php';
12+
} else {
13+
require dirname(__DIR__) . '/Common/MultiPHPUnitVersionAdapter_untyped.php';
14+
}
15+
16+
// Stub dd_trace_internal_fn if the extension is not loaded
17+
if (!function_exists('dd_trace_internal_fn')) {
18+
function dd_trace_internal_fn() { return false; }
19+
}

0 commit comments

Comments
 (0)