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
155 changes: 151 additions & 4 deletions libdd-ffe-ffi/src/assignment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,29 @@ 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
Expand All @@ -191,10 +214,38 @@ pub unsafe extern "C" fn ddog_ffe_get_assignment_with_options(
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> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
// SAFETY: the caller provides valid handles and a valid string.
unsafe { get_assignment_with_options(config, flag_key, expected_type, context, options) }
unsafe { get_assignment_with_mode(config, flag_key, expected_type, context, mode) }
}))
.unwrap_or_else(|_| {
Handle::new(
Expand All @@ -204,12 +255,12 @@ pub unsafe extern "C" fn ddog_ffe_get_assignment_with_options(
})
}

unsafe fn get_assignment_with_options(
unsafe fn get_assignment_with_mode(
config: Handle<Configuration>,
flag_key: *const c_char,
expected_type: ExpectedFlagType,
context: Handle<EvaluationContext>,
options: EvaluationOptions,
mode: ExperimentationOptOutMode,
) -> Handle<ResolutionDetails> {
if flag_key.is_null() {
return Handle::new(
Expand Down Expand Up @@ -241,7 +292,7 @@ unsafe fn get_assignment_with_options(
expected_type.into(),
now(),
ffe::EvaluationOptions {
experimentation_opt_out_immediate: options.experimentation_opt_out_immediate,
experimentation_opt_out_mode: mode.into(),
},
);

Expand Down Expand Up @@ -552,8 +603,104 @@ 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 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);
Expand Down
38 changes: 30 additions & 8 deletions libdd-ffe-test-suite/tests/canonical_fixtures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ use std::{collections::HashMap, fs, path::PathBuf, sync::Arc};
use chrono::Utc;
use libdd_ffe::rules_based::{
AssignmentReason, Attribute, Configuration, EvaluationContext, EvaluationError,
EvaluationOptions, FlagType, Str, UniversalFlagConfig, get_assignment,
EvaluationOptions, ExperimentationOptOutMode, FlagType, Str, UniversalFlagConfig,
get_assignment,
};
use serde::Deserialize;

Expand Down Expand Up @@ -51,6 +52,7 @@ fn evaluates_canonical_json_fixtures() {
let mut fixture_count = 0;
let mut baseline_count = 0;
let mut opt_out_count = 0;
let mut future_opt_out_count = 0;
for entry in fs::read_dir(&cases_dir).unwrap() {
let path = entry.unwrap().path();
if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
Expand Down Expand Up @@ -80,15 +82,29 @@ fn evaluates_canonical_json_fixtures() {
})
.collect();
let subject = EvaluationContext::new(test_case.targeting_key, Arc::new(attributes));
if let Some(expected) = test_case.capabilities.get("experimentationOptOutImmediate") {
opt_out_count += 1;
for (capability, mode, count) in [
(
"experimentationOptOutImmediate",
ExperimentationOptOutMode::Immediate,
&mut opt_out_count,
),
(
"experimentationOptOutFutureExperimentsOnly",
ExperimentationOptOutMode::FutureExperimentsOnly,
&mut future_opt_out_count,
),
] {
let Some(expected) = test_case.capabilities.get(capability) else {
continue;
};
*count += 1;
let result = config.eval_flag_with_options(
&test_case.flag,
&subject,
test_case.variation_type.into(),
now,
EvaluationOptions {
experimentation_opt_out_immediate: true,
experimentation_opt_out_mode: mode,
},
);
let (value, reason, error) = match result {
Expand All @@ -106,23 +122,23 @@ fn evaluates_canonical_json_fixtures() {
assert_eq!(
value,
expected.value,
"immediate value for {} in {}",
"{capability} value for {} in {}",
test_case.flag,
path.display()
);
if let Some(expected_reason) = expected.reason.as_deref() {
assert_eq!(
reason,
expected_reason,
"immediate reason for {} in {}",
"{capability} reason for {} in {}",
test_case.flag,
path.display()
);
}
assert_eq!(
error,
expected.error_code.as_deref(),
"immediate error for {} in {}",
"{capability} error for {} in {}",
test_case.flag,
path.display()
);
Expand Down Expand Up @@ -187,7 +203,13 @@ fn evaluates_canonical_json_fixtures() {
opt_out_count > 0,
"immediate opt-out capability was not exercised"
);
eprintln!("canonical fixtures: {baseline_count} baseline, {opt_out_count} immediate");
assert!(
future_opt_out_count > 0,
"future opt-out capability was not exercised"
);
eprintln!(
"canonical fixtures: {baseline_count} baseline, {opt_out_count} immediate, {future_opt_out_count} future"
);
}

fn reason_from_assignment(reason: AssignmentReason) -> &'static str {
Expand Down
20 changes: 20 additions & 0 deletions libdd-ffe/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,26 @@

This is an internal DataDog library that supports Feature Flag evaluation. It is intended for use in tracers and DataDog services. Expect frequent breaking changes and major bumps before it's stabilized.

## Subject experimentation opt-out

The caller selects `EvaluationOptions.experimentation_opt_out_mode`; the evaluator reads no environment variables.
Both enabled modes require a valid `dd_experimentation_opt_out_at` timestamp when the attribute is present.
Missing input preserves normal evaluation. Invalid input returns `InvalidContext` before flag lookup.

| Mode | Exposure-logging allocations |
| --- | --- |
| `Disabled` | Evaluate normally |
| `Immediate` | Skip when the subject supplies an opt-out timestamp |
| `FutureExperimentsOnly` | Skip when activation is equal to or later than opt-out, or activation is unknown |

Future mode uses `randomizationActivatedAt`, with nanosecond precision and timezone-aware comparisons.
Older allocations remain eligible; this does not prove previous enrollment.
Malformed activation rejects the affected flag in every mode. Missing or null activation is unknown.
Skipped allocations produce no exposure. Ordinary allocations remain available, and evaluation telemetry is unchanged.

The C API adds `ddog_ffe_get_assignment_with_opt_out_mode`.
The existing `ddog_ffe_get_assignment_with_options` interface and boolean layout remain unchanged for immediate-mode callers.

## `flagevaluation` EVP privacy

- Each observation carries the consent captured by its SDK at evaluation time.
Expand Down
Loading
Loading