Skip to content

Commit c79d783

Browse files
fix(ffe): honor shared fixture result metadata (#2109)
## Motivation The shared FFE fixtures now assert result metadata beyond returned values, including `reason` and `errorCode`. libdatadog was only checking fixture values, so it could accept shared fixture bumps while still drifting on public assignment metadata. The latest `ffe-system-test-data` main also corrects temporal allocation fixtures to expect `DEFAULT` for active date-window-only allocations. Those allocations return a configured platform/default value, but no targeting rule matched and no non-vacuous split selected the subject. ## Changes This updates the `ffe-system-test-data` submodule through `d9d8020`, including the merged temporal reason correction from DataDog/ffe-system-test-data#15. The canonical fixture harness now asserts fixture-provided `reason` and `errorCode` fields in addition to values. Invalid or unsupported per-flag config is represented as a distinct evaluation error that maps to caller-default behavior while preserving isolation for valid flags in the same payload. The evaluator now has an explicit `AssignmentReason::Default` for successful default/platform assignments. Active temporal allocations with no rules and no effective split now report `DEFAULT`, and the FFI mapping exposes that as `ddog_ffe_Reason::Default`. ## Decisions Top-level configuration parse behavior is unchanged: a malformed full payload still fails configuration creation, while malformed individual flags are isolated and evaluate to caller defaults. Missing flags continue to report `FLAG_NOT_FOUND`. Optional `errorCode` fixture assertions remain optional because some existing shared cases omit that field even when the reason is `ERROR`. Validation: - `cargo +nightly-2026-02-08 fmt --all -- --check` - `cargo check -p datadog-ffe -p datadog-ffe-ffi -p datadog-ffe-test-suite` - `cargo nextest run -p datadog-ffe -p datadog-ffe-ffi -p datadog-ffe-test-suite --no-fail-fast` - `cargo test --doc -p datadog-ffe -p datadog-ffe-ffi -p datadog-ffe-test-suite` - `cargo +stable clippy -p datadog-ffe -p datadog-ffe-ffi -p datadog-ffe-test-suite --all-targets --all-features -- -D warnings` - Earlier on this branch: `cargo +stable clippy --workspace --all-targets --all-features -- -D warnings` - Earlier on this branch: `cargo ffi-test` built the FFI artifacts and the `ffe` example passed; the overall command failed in the unrelated `crashtracking` example with signal 5. Co-authored-by: leo.romanovsky <leo.romanovsky@datadoghq.com>
1 parent bdc0a62 commit c79d783

7 files changed

Lines changed: 101 additions & 10 deletions

File tree

datadog-ffe-ffi/src/assignment.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,7 @@ impl From<&ResolutionDetails> for Reason {
370370
Ok(assignment) => assignment.reason.into(),
371371
Err(EvaluationError::FlagDisabled) => Reason::Disabled,
372372
Err(EvaluationError::DefaultAllocationNull) => Reason::Default,
373+
Err(EvaluationError::FlagConfigurationInvalid) => Reason::Default,
373374
Err(_) => Reason::Error,
374375
}
375376
}
@@ -379,6 +380,7 @@ impl From<AssignmentReason> for Reason {
379380
match value {
380381
AssignmentReason::TargetingMatch => Reason::TargetingMatch,
381382
AssignmentReason::Split => Reason::Split,
383+
AssignmentReason::Default => Reason::Default,
382384
AssignmentReason::Static => Reason::Static,
383385
}
384386
}
@@ -407,6 +409,7 @@ impl From<&EvaluationError> for ErrorCode {
407409
EvaluationError::TypeMismatch { .. } => ErrorCode::TypeMismatch,
408410
EvaluationError::TargetingKeyMissing => ErrorCode::TargetingKeyMissing,
409411
EvaluationError::ConfigurationParseError => ErrorCode::ParseError,
412+
EvaluationError::FlagConfigurationInvalid => ErrorCode::Ok,
410413
EvaluationError::ConfigurationMissing => ErrorCode::ProviderNotReady,
411414
EvaluationError::FlagUnrecognizedOrDisabled => ErrorCode::FlagNotFound,
412415
EvaluationError::FlagDisabled => ErrorCode::Ok,

datadog-ffe-test-suite/tests/canonical_fixtures.rs

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ use std::{collections::HashMap, fs, path::PathBuf, sync::Arc};
55

66
use chrono::Utc;
77
use datadog_ffe::rules_based::{
8-
get_assignment, Attribute, Configuration, EvaluationContext, FlagType, Str, UniversalFlagConfig,
8+
get_assignment, AssignmentReason, Attribute, Configuration, EvaluationContext, EvaluationError,
9+
FlagType, Str, UniversalFlagConfig,
910
};
1011
use serde::Deserialize;
1112

@@ -21,8 +22,11 @@ struct TestCase {
2122
}
2223

2324
#[derive(Debug, Deserialize)]
25+
#[serde(rename_all = "camelCase")]
2426
struct TestResult {
2527
value: serde_json::Value,
28+
reason: Option<String>,
29+
error_code: Option<String>,
2630
}
2731

2832
fn fixture_root() -> PathBuf {
@@ -63,9 +67,18 @@ fn evaluates_canonical_json_fixtures() {
6367
now,
6468
);
6569

66-
let actual = result
67-
.map(|assignment| assignment.value.variation_value())
68-
.unwrap_or(test_case.default_value);
70+
let (actual, actual_reason, actual_error_code) = match result {
71+
Ok(assignment) => (
72+
assignment.value.variation_value(),
73+
reason_from_assignment(assignment.reason),
74+
None,
75+
),
76+
Err(err) => (
77+
test_case.default_value.clone(),
78+
reason_from_error(&err),
79+
error_code_from_error(&err),
80+
),
81+
};
6982

7083
assert_eq!(
7184
actual,
@@ -74,8 +87,62 @@ fn evaluates_canonical_json_fixtures() {
7487
test_case.flag,
7588
path.display()
7689
);
90+
91+
if let Some(expected_reason) = test_case.result.reason.as_deref() {
92+
assert_eq!(
93+
actual_reason,
94+
expected_reason,
95+
"unexpected reason for flag {} in {}",
96+
test_case.flag,
97+
path.display()
98+
);
99+
}
100+
101+
if let Some(expected_error_code) = test_case.result.error_code.as_deref() {
102+
assert_eq!(
103+
actual_error_code,
104+
Some(expected_error_code),
105+
"unexpected error code for flag {} in {}",
106+
test_case.flag,
107+
path.display()
108+
);
109+
}
77110
}
78111
}
79112

80113
assert!(fixture_count > 0, "no canonical FFE fixtures loaded");
81114
}
115+
116+
fn reason_from_assignment(reason: AssignmentReason) -> &'static str {
117+
match reason {
118+
AssignmentReason::TargetingMatch => "TARGETING_MATCH",
119+
AssignmentReason::Split => "SPLIT",
120+
AssignmentReason::Default => "DEFAULT",
121+
AssignmentReason::Static => "STATIC",
122+
}
123+
}
124+
125+
fn reason_from_error(err: &EvaluationError) -> &'static str {
126+
match err {
127+
EvaluationError::FlagDisabled => "DISABLED",
128+
EvaluationError::DefaultAllocationNull | EvaluationError::FlagConfigurationInvalid => {
129+
"DEFAULT"
130+
}
131+
_ => "ERROR",
132+
}
133+
}
134+
135+
fn error_code_from_error(err: &EvaluationError) -> Option<&'static str> {
136+
match err {
137+
EvaluationError::FlagDisabled
138+
| EvaluationError::DefaultAllocationNull
139+
| EvaluationError::FlagConfigurationInvalid => None,
140+
EvaluationError::TypeMismatch { .. } => Some("TYPE_MISMATCH"),
141+
EvaluationError::TargetingKeyMissing => Some("TARGETING_KEY_MISSING"),
142+
EvaluationError::ConfigurationParseError => Some("PARSE_ERROR"),
143+
EvaluationError::ConfigurationMissing => Some("PROVIDER_NOT_READY"),
144+
EvaluationError::FlagUnrecognizedOrDisabled => Some("FLAG_NOT_FOUND"),
145+
EvaluationError::Internal(_) => Some("GENERAL"),
146+
_ => Some("GENERAL"),
147+
}
148+
}

datadog-ffe/src/rules_based/error.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ pub enum EvaluationError {
3232
#[error("failed to parse configuration")]
3333
ConfigurationParseError,
3434

35+
/// A requested flag exists in the configuration payload, but its per-flag configuration is
36+
/// invalid or unsupported by this SDK. The SDK should return the caller default for that flag.
37+
#[error("flag configuration is invalid or unsupported")]
38+
FlagConfigurationInvalid,
39+
3540
/// Configuration has not been fetched yet.
3641
#[error("flags configuration is missing")]
3742
ConfigurationMissing,

datadog-ffe/src/rules_based/eval/eval_assignment.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,12 @@ impl Allocation {
149149
};
150150

151151
// Determine the reason for assignment
152-
let reason = if !self.rules.is_empty() || self.start_at.is_some() || self.end_at.is_some() {
152+
let has_time_bounds = self.start_at.is_some() || self.end_at.is_some();
153+
let reason = if !self.rules.is_empty() {
153154
AssignmentReason::TargetingMatch
154-
} else if self.splits.len() == 1 && self.splits[0].shards.is_empty() {
155+
} else if has_time_bounds && self.splits.len() == 1 && split.shards.is_empty() {
156+
AssignmentReason::Default
157+
} else if self.splits.len() == 1 && !self.splits[0].has_shards {
155158
AssignmentReason::Static
156159
} else {
157160
AssignmentReason::Split

datadog-ffe/src/rules_based/ufc/assignment.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@ use crate::rules_based::{ufc::VariationType, FlagType, Str};
1111
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1212
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
1313
pub enum AssignmentReason {
14-
/// Assignment was made based on targeting rules or time bounds.
14+
/// Assignment was made based on targeting rules.
1515
TargetingMatch,
1616
/// Assignment was made based on traffic split allocation.
1717
Split,
18+
/// Assignment was made from a platform/default allocation value.
19+
Default,
1820
/// Assignment was made as a static/default value.
1921
Static,
2022
}

datadog-ffe/src/rules_based/ufc/compiled_flag_config.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ pub(crate) struct Allocation {
5151
#[derive(Debug)]
5252
pub(crate) struct Split {
5353
pub shards: Vec<Shard>,
54+
pub has_shards: bool,
5455
pub variation_key: Str,
5556
pub value: AssignmentValue,
5657
pub serial_id: Option<i32>,
@@ -85,8 +86,9 @@ impl From<UniversalFlagConfigWire> for CompiledFlagsConfig {
8586
(
8687
key,
8788
Option::from(flag)
88-
.ok_or(EvaluationError::ConfigurationParseError)
89-
.and_then(compile_flag),
89+
.ok_or(EvaluationError::FlagConfigurationInvalid)
90+
.and_then(compile_flag)
91+
.map_err(per_flag_config_error),
9092
)
9193
})
9294
.collect();
@@ -99,6 +101,13 @@ impl From<UniversalFlagConfigWire> for CompiledFlagsConfig {
99101
}
100102
}
101103

104+
fn per_flag_config_error(err: EvaluationError) -> EvaluationError {
105+
match err {
106+
EvaluationError::ConfigurationParseError => EvaluationError::FlagConfigurationInvalid,
107+
err => err,
108+
}
109+
}
110+
102111
fn compile_flag(flag: FlagWire) -> Result<Flag, EvaluationError> {
103112
if !flag.enabled {
104113
return Err(EvaluationError::FlagDisabled);
@@ -150,6 +159,7 @@ fn compile_split(
150159
split: SplitWire,
151160
variation_values: &HashMap<Str, AssignmentValue>,
152161
) -> Result<Split, EvaluationError> {
162+
let has_shards = !split.shards.is_empty();
153163
let shards = split
154164
.shards
155165
.into_iter()
@@ -167,6 +177,7 @@ fn compile_split(
167177

168178
Ok(Split {
169179
shards,
180+
has_shards,
170181
variation_key: split.variation_key,
171182
value: result,
172183
serial_id: split.serial_id,

0 commit comments

Comments
 (0)