Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions examples/ffi/ffe.c
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,67 @@ void evaluate_and_print_flag(
ddog_ffe_assignment_drop(&assignment);
}

static int verify_opt_out_modes(void) {
const char* json =
"{\"createdAt\":\"2026-10-19T00:00:00Z\",\"environment\":{\"name\":\"test\"},"
"\"flags\":{\"feature\":{\"key\":\"feature\",\"enabled\":true,\"variationType\":\"BOOLEAN\","
"\"variations\":{\"on\":{\"key\":\"on\",\"value\":true},\"off\":{\"key\":\"off\",\"value\":false}},"
"\"allocations\":[{\"key\":\"experiment\",\"doLog\":true,\"randomizationActivatedAt\":\"2026-10-19T12:00:00Z\","
"\"splits\":[{\"variationKey\":\"on\",\"shards\":[]}]},{\"key\":\"ordinary\",\"doLog\":false,"
"\"splits\":[{\"variationKey\":\"off\",\"shards\":[]}]}]}}}";
struct ddog_ffe_Result_HandleConfiguration parsed = ddog_ffe_configuration_new(
(struct ddog_ffe_BorrowedStr){.ptr = (const uint8_t*)json, .len = strlen(json)}
);
if (parsed.tag != DDOG_FFE_RESULT_HANDLE_CONFIGURATION_OK_HANDLE_CONFIGURATION) {
ddog_Error_drop(&parsed.err);
return 1;
}
ddog_ffe_Handle_Configuration config = parsed.ok;
const char* cutoffs[] = {
"2026-10-19T11:59:59.999999999Z", "2026-10-19T12:00:00Z",
"2026-10-19T12:00:00.000000001Z"
};
const enum ddog_ffe_ExperimentationOptOutMode modes[] = {
DDOG_FFE_EXPERIMENTATION_OPT_OUT_MODE_DISABLED,
DDOG_FFE_EXPERIMENTATION_OPT_OUT_MODE_IMMEDIATE,
DDOG_FFE_EXPERIMENTATION_OPT_OUT_MODE_FUTURE_EXPERIMENTS_ONLY
};
int failed = 0;
for (size_t i = 0; i < 3; i++) {
struct ddog_ffe_AttributePair attribute = {
.name = "dd_experimentation_opt_out_at",
.value = {.tag = DDOG_FFE_ATTRIBUTE_VALUE_STRING, .string = cutoffs[i]}
};
ddog_ffe_Handle_EvaluationContext context = ddog_ffe_evaluation_context_new("subject", &attribute, 1);
for (size_t j = 0; j < 3; j++) {
ddog_ffe_Handle_ResolutionDetails result = ddog_ffe_get_assignment_with_opt_out_mode(
config, "feature", DDOG_FFE_EXPECTED_FLAG_TYPE_BOOLEAN, context, modes[j]
);
struct ddog_ffe_VariantValue value = ddog_ffe_assignment_get_value(result);
bool expected = j == 0 || (j == 2 && i == 2);
failed |= value.tag != DDOG_FFE_VARIANT_VALUE_BOOLEAN || value.boolean != expected;
failed |= ddog_ffe_assignment_get_error_code(result) != DDOG_FFE_ERROR_CODE_OK;
failed |= ddog_ffe_assignment_get_do_log(result) != expected;
ddog_ffe_assignment_drop(&result);
}
struct ddog_ffe_EvaluationOptions options = {.experimentation_opt_out_immediate = true};
ddog_ffe_Handle_ResolutionDetails old_result = ddog_ffe_get_assignment_with_options(
config, "feature", DDOG_FFE_EXPECTED_FLAG_TYPE_BOOLEAN, context, options
);
struct ddog_ffe_VariantValue old_value = ddog_ffe_assignment_get_value(old_result);
failed |= old_value.tag != DDOG_FFE_VARIANT_VALUE_BOOLEAN || old_value.boolean;
ddog_ffe_assignment_drop(&old_result);
ddog_ffe_evaluation_context_drop(&context);
}
ddog_ffe_configuration_drop(&config);
return failed;
}

int main(int argc, char* argv[]) {
if (verify_opt_out_modes() != 0) {
fprintf(stderr, "Experimentation opt-out mode checks failed\n");
return 1;
}
printf("Datadog FFE FFI Example\n");
printf("=======================\n\n");

Expand Down
242 changes: 241 additions & 1 deletion libdd-ffe-ffi/src/assignment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,119 @@ pub unsafe extern "C" fn ddog_ffe_get_assignment(
flag_key: *const c_char,
expected_type: ExpectedFlagType,
context: Handle<EvaluationContext>,
) -> Handle<ResolutionDetails> {
// SAFETY: this function has the same handle and string requirements.
unsafe {
ddog_ffe_get_assignment_with_options(
config,
flag_key,
expected_type,
context,
EvaluationOptions::default(),
)
}
}

/// Optional behavior selected by the SDK at provider construction.
#[derive(Clone, Copy, Default)]
#[repr(C)]
pub struct EvaluationOptions {
pub experimentation_opt_out_immediate: bool,
}

/// SDK-selected handling of a subject's experimentation opt-out timestamp.
#[derive(Clone, Copy, Default)]
#[repr(C)]
pub enum ExperimentationOptOutMode {
/// Ignore the subject's opt-out attribute.
#[default]
Disabled,
/// Exclude exposure-logging allocations immediately.
Immediate,
/// Exclude allocations activated at or after opt-out, or with unknown activation.
FutureExperimentsOnly,
}

impl From<ExperimentationOptOutMode> for ffe::ExperimentationOptOutMode {
fn from(mode: ExperimentationOptOutMode) -> Self {
match mode {
ExperimentationOptOutMode::Disabled => Self::Disabled,
ExperimentationOptOutMode::Immediate => Self::Immediate,
ExperimentationOptOutMode::FutureExperimentsOnly => Self::FutureExperimentsOnly,
}
}
}

/// Evaluate a flag with optional subject experimentation exclusion.
///
/// # Ownership
/// The caller must free the returned handle with `ddog_ffe_assignment_drop`.
///
/// # Safety
/// `config` and `context` must be valid handles. `flag_key` must be a valid
/// NUL-terminated string.
#[unsafe(no_mangle)]
#[must_use]
pub unsafe extern "C" fn ddog_ffe_get_assignment_with_options(
config: Handle<Configuration>,
flag_key: *const c_char,
expected_type: ExpectedFlagType,
context: Handle<EvaluationContext>,
options: EvaluationOptions,
) -> Handle<ResolutionDetails> {
let mode = if options.experimentation_opt_out_immediate {
ExperimentationOptOutMode::Immediate
} else {
ExperimentationOptOutMode::Disabled
};
// SAFETY: this function has the same handle and string requirements.
unsafe {
ddog_ffe_get_assignment_with_opt_out_mode(config, flag_key, expected_type, context, mode)
}
}

/// Evaluate a flag with SDK-selected experimentation opt-out behavior.
///
/// # Ownership
/// The caller must free the returned handle with `ddog_ffe_assignment_drop`.
///
/// # Safety
/// `config` and `context` must be valid handles. `flag_key` must be a valid
/// NUL-terminated string. `mode` must be a valid enum value.
#[unsafe(no_mangle)]
#[must_use]
pub unsafe extern "C" fn ddog_ffe_get_assignment_with_opt_out_mode(
config: Handle<Configuration>,
flag_key: *const c_char,
expected_type: ExpectedFlagType,
context: Handle<EvaluationContext>,
mode: ExperimentationOptOutMode,
) -> Handle<ResolutionDetails> {
catch_assignment_panic(|| {
// SAFETY: the caller provides valid handles and a valid string.
unsafe { get_assignment_with_mode(config, flag_key, expected_type, context, mode) }
})
}

// Other FFI crates have private catch_panic! macros tied to their own error types.
// Keep this boundary's handle ownership and redacted error conversion together.
fn catch_assignment_panic(
evaluate: impl FnOnce() -> Handle<ResolutionDetails>,
) -> Handle<ResolutionDetails> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(evaluate)).unwrap_or_else(|_| {
Handle::new(
EvaluationError::Internal(Str::from_static_str("feature flag evaluation failed"))
.into(),
)
})
}

unsafe fn get_assignment_with_mode(
config: Handle<Configuration>,
flag_key: *const c_char,
expected_type: ExpectedFlagType,
context: Handle<EvaluationContext>,
mode: ExperimentationOptOutMode,
) -> Handle<ResolutionDetails> {
if flag_key.is_null() {
return Handle::new(
Expand All @@ -180,7 +293,15 @@ pub unsafe extern "C" fn ddog_ffe_get_assignment(
);
};

let assignment_result = config.eval_flag(flag_key, context, expected_type.into(), now());
let assignment_result = config.eval_flag_with_options(
flag_key,
context,
expected_type.into(),
now(),
ffe::EvaluationOptions {
experimentation_opt_out_mode: mode.into(),
},
);

Handle::new(assignment_result.into())
}
Expand Down Expand Up @@ -407,6 +528,7 @@ impl From<&EvaluationError> for ErrorCode {
match value {
EvaluationError::TypeMismatch { .. } => ErrorCode::TypeMismatch,
EvaluationError::TargetingKeyMissing => ErrorCode::TargetingKeyMissing,
EvaluationError::InvalidContext => ErrorCode::InvalidContext,
EvaluationError::ConfigurationParseError => ErrorCode::ParseError,
EvaluationError::FlagConfigurationInvalid => ErrorCode::ParseError,
EvaluationError::ConfigurationMissing => ErrorCode::ProviderNotReady,
Expand Down Expand Up @@ -488,13 +610,131 @@ pub unsafe extern "C" fn ddog_ffe_assignment_drop(assignment: *mut Handle<Resolu

#[cfg(test)]
mod tests {
use std::{collections::HashMap, sync::Arc};

use super::*;

#[test]
fn evaluation_panic_returns_owned_redacted_error() {
let mut result = catch_assignment_panic(|| panic!("private context value"));
// SAFETY: the returned handle is live until it is freed below.
let details = unsafe { result.as_ref() };
assert_eq!(ErrorCode::from(details), ErrorCode::General);
assert_eq!(
details.error_message.as_deref(),
Some("internal error: feature flag evaluation failed")
);
assert!(details.flag_metadata.is_empty());
// SAFETY: all borrows ended; free the owned result once.
unsafe { ddog_ffe_assignment_drop(&mut result) };
}

#[test]
fn opt_out_modes_cross_the_ffi_boundary() {
let wire = ffe::UniversalFlagConfig::from_json(
br#"{
"createdAt":"2026-10-19T00:00:00Z", "environment":{"name":"test"},
"flags":{"feature":{
"key":"feature", "enabled":true, "variationType":"BOOLEAN",
"variations":{"on":{"key":"on","value":true},"off":{"key":"off","value":false}},
"allocations":[
{"key":"experiment","doLog":true,"randomizationActivatedAt":"2026-10-19T12:00:00Z",
"splits":[{"variationKey":"on","shards":[]}]},
{"key":"ordinary","doLog":false,"splits":[{"variationKey":"off","shards":[]}]}
]
}}
}"#.to_vec(),
)
.unwrap();
let mut config = Handle::new(Configuration::from_server_response(wire));
for (cutoff, future_excluded) in [
("2026-10-19T11:59:59.999999999Z", true),
("2026-10-19T12:00:00Z", true),
("2026-10-19T12:00:00.000000001Z", false),
] {
let mut context = Handle::new(EvaluationContext::new(
Some("subject".into()),
Arc::new(HashMap::from([(
"dd_experimentation_opt_out_at".into(),
ffe::Attribute::from(cutoff),
)])),
));
for (mode, excluded) in [
(ExperimentationOptOutMode::Disabled, false),
(ExperimentationOptOutMode::Immediate, true),
(
ExperimentationOptOutMode::FutureExperimentsOnly,
future_excluded,
),
] {
// SAFETY: handles have no Drop. Copies borrow live resources, as C callers do.
// Each original handle is freed exactly once after its last use.
let config_copy = unsafe { std::ptr::read(&config) };
// SAFETY: the context stays live throughout this evaluation.
let context_copy = unsafe { std::ptr::read(&context) };
// SAFETY: both handles are live and the flag key is a static C string.
let mut result = unsafe {
ddog_ffe_get_assignment_with_opt_out_mode(
config_copy,
c"feature".as_ptr(),
ExpectedFlagType::Boolean,
context_copy,
mode,
)
};
// SAFETY: the returned handle is live until the drop below.
let details = unsafe { result.as_ref() };
assert_eq!(ErrorCode::from(details), ErrorCode::Ok);
let assignment = details.as_ref().as_ref().unwrap();
assert_eq!(assignment.do_log, !excluded);
assert_eq!(
assignment.allocation_key.as_str(),
if excluded { "ordinary" } else { "experiment" }
);
// SAFETY: the result is no longer borrowed and is freed once.
unsafe { ddog_ffe_assignment_drop(&mut result) };
}
// SAFETY: these copies borrow the live resources; only the originals are freed.
let config_copy = unsafe { std::ptr::read(&config) };
// SAFETY: the context stays live through the existing boolean interface call.
let context_copy = unsafe { std::ptr::read(&context) };
// SAFETY: both handles are live and the flag key is a static C string.
let mut result = unsafe {
ddog_ffe_get_assignment_with_options(
config_copy,
c"feature".as_ptr(),
ExpectedFlagType::Boolean,
context_copy,
EvaluationOptions {
experimentation_opt_out_immediate: true,
},
)
};
// SAFETY: the result stays live until the drop below.
let details = unsafe { result.as_ref() };
assert_eq!(ErrorCode::from(details), ErrorCode::Ok);
assert!(!details.as_ref().as_ref().unwrap().do_log);
// SAFETY: all result borrows ended; free each handle exactly once.
unsafe { ddog_ffe_assignment_drop(&mut result) };
// SAFETY: all calls using the context completed.
unsafe { Handle::free(&mut context) };
}
// SAFETY: all calls completed and the configuration handle is still live.
unsafe { Handle::free(&mut config) };
}

#[test]
fn rejected_flag_is_exposed_as_parse_error() {
let details = ResolutionDetails::from(EvaluationError::FlagConfigurationInvalid);

assert!(matches!(Reason::from(&details), Reason::Error));
assert_eq!(ErrorCode::from(&details), ErrorCode::ParseError);
}

#[test]
fn invalid_opt_out_is_exposed_as_invalid_context() {
let details = ResolutionDetails::from(EvaluationError::InvalidContext);
assert!(matches!(Reason::from(&details), Reason::Error));
assert_eq!(ErrorCode::from(&details), ErrorCode::InvalidContext);
}
}
2 changes: 1 addition & 1 deletion libdd-ffe-test-suite/ffe-system-test-data
Submodule ffe-system-test-data updated 29 files
+6 −6 .github/workflows/downstream-compatibility.yml
+5 −1 .github/workflows/validate-fixtures.yml
+34 −0 .github/workflows/validate-precomputed-fixtures.yml
+209 −0 AGENTS.md
+41 −6 CONTRIBUTING.md
+322 −30 README.md
+47 −0 ci/test_validate_fixtures.py
+34 −6 ci/validate-fixtures.py
+197 −0 ci/validate-precomputed-fixtures.py
+22 −0 evaluation-cases/test-case-experimentation-opt-out-do-log.json
+146 −0 evaluation-cases/test-case-experimentation-opt-out-future.json
+236 −0 evaluation-cases/test-case-experimentation-opt-out.json
+97 −0 evaluation-cases/test-case-serial-id-metadata.json
+67 −0 evaluation-cases/test-case-span-enrichment-inputs.json
+179 −0 precomputed-assignments/README.md
+87 −0 precomputed-assignments/cases/emissions/all-doLog-false.json
+76 −0 precomputed-assignments/cases/emissions/duplicate-evaluation.json
+142 −0 precomputed-assignments/cases/evaluator/all-types-success.json
+111 −0 precomputed-assignments/cases/evaluator/defaults-and-emission-gates.json
+64 −0 precomputed-assignments/cases/evaluator/empty-flags-response.json
+129 −0 precomputed-assignments/cases/evaluator/falsy-values.json
+57 −0 precomputed-assignments/cases/evaluator/integer-precision-native-only.json
+471 −0 precomputed-assignments/cases/evaluator/multi-flag-set.json
+91 −0 precomputed-assignments/cases/evaluator/multiple-reasons.json
+87 −0 precomputed-assignments/cases/evaluator/object-nested-deep.json
+108 −0 precomputed-assignments/cases/evaluator/special-characters-in-keys.json
+135 −0 precomputed-assignments/cases/evaluator/type-mismatch-all-types.json
+305 −0 schemas/precomputed-assignment.schema.json
+293 −8 ufc-config.json
Loading
Loading