diff --git a/examples/ffi/ffe.c b/examples/ffi/ffe.c index 0c3696ae2e..070184a587 100644 --- a/examples/ffi/ffe.c +++ b/examples/ffi/ffe.c @@ -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"); diff --git a/libdd-ffe-ffi/src/assignment.rs b/libdd-ffe-ffi/src/assignment.rs index 2cb4a88aee..fdf0c980cd 100644 --- a/libdd-ffe-ffi/src/assignment.rs +++ b/libdd-ffe-ffi/src/assignment.rs @@ -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 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 @@ -191,10 +214,38 @@ pub unsafe extern "C" fn ddog_ffe_get_assignment_with_options( expected_type: ExpectedFlagType, context: Handle, options: EvaluationOptions, +) -> Handle { + 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, + flag_key: *const c_char, + expected_type: ExpectedFlagType, + context: Handle, + mode: ExperimentationOptOutMode, ) -> Handle { 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( @@ -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, flag_key: *const c_char, expected_type: ExpectedFlagType, context: Handle, - options: EvaluationOptions, + mode: ExperimentationOptOutMode, ) -> Handle { if flag_key.is_null() { return Handle::new( @@ -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(), }, ); @@ -552,8 +603,104 @@ pub unsafe extern "C" fn ddog_ffe_assignment_drop(assignment: *mut Handle 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 { diff --git a/libdd-ffe/README.md b/libdd-ffe/README.md index 9ed2016102..625fb5f92c 100644 --- a/libdd-ffe/README.md +++ b/libdd-ffe/README.md @@ -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. diff --git a/libdd-ffe/src/rules_based/eval/eval_assignment.rs b/libdd-ffe/src/rules_based/eval/eval_assignment.rs index 34577086f3..ec8bb2d5bc 100644 --- a/libdd-ffe/src/rules_based/eval/eval_assignment.rs +++ b/libdd-ffe/src/rules_based/eval/eval_assignment.rs @@ -10,28 +10,73 @@ use crate::rules_based::{ ufc::{Allocation, Assignment, AssignmentReason, CompiledFlagsConfig, Flag, Shard, Split}, }; +/// Selects how a subject's opt-out timestamp affects exposure-logging allocations. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ExperimentationOptOutMode { + /// Ignore the opt-out attribute. + #[default] + Disabled, + /// Exclude all exposure-logging allocations immediately. + Immediate, + /// Exclude allocations activated at or after opt-out, or with unknown activation. + FutureExperimentsOnly, +} + /// Controls optional behavior for one evaluation. The SDK resolves configuration. #[derive(Clone, Copy, Debug, Default)] pub struct EvaluationOptions { /// Honor the subject's `dd_experimentation_opt_out_at` timestamp. - pub experimentation_opt_out_immediate: bool, + pub experimentation_opt_out_mode: ExperimentationOptOutMode, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum ExperimentationExclusion { + #[default] + None, + Immediate, + ActivatedAtOrAfter(Timestamp), +} + +impl ExperimentationExclusion { + fn excludes(self, allocation: &Allocation) -> bool { + if !allocation.do_log { + return false; + } + match self { + Self::None => false, + Self::Immediate => true, + Self::ActivatedAtOrAfter(cutoff) => allocation + .randomization_activated_at + .is_none_or(|activation| activation >= cutoff), + } + } } impl EvaluationOptions { - fn exclude_experiments(self, context: &EvaluationContext) -> Result { - if !self.experimentation_opt_out_immediate { - return Ok(false); + fn experimentation_exclusion( + self, + context: &EvaluationContext, + ) -> Result { + if self.experimentation_opt_out_mode == ExperimentationOptOutMode::Disabled { + return Ok(ExperimentationExclusion::None); } let Some(attribute) = context.get_attribute("dd_experimentation_opt_out_at") else { - return Ok(false); + return Ok(ExperimentationExclusion::None); }; let Some(timestamp) = attribute.as_str() else { return Err(EvaluationError::InvalidContext); }; // Use the same timestamp deserializer as allocation startAt and endAt. - Timestamp::deserialize(StrDeserializer::::new(timestamp)) - .map_err(|_| EvaluationError::InvalidContext)?; - Ok(true) + let cutoff = + Timestamp::deserialize(StrDeserializer::::new(timestamp)) + .map_err(|_| EvaluationError::InvalidContext)?; + Ok(match self.experimentation_opt_out_mode { + ExperimentationOptOutMode::Disabled => ExperimentationExclusion::None, + ExperimentationOptOutMode::Immediate => ExperimentationExclusion::Immediate, + ExperimentationOptOutMode::FutureExperimentsOnly => { + ExperimentationExclusion::ActivatedAtOrAfter(cutoff) + } + }) } } @@ -81,14 +126,11 @@ impl Configuration { now: DateTime, options: EvaluationOptions, ) -> Result { - let exclude_experiments = options.exclude_experiments(context)?; - let result = self.flags.compiled.eval_flag( - flag_key, - context, - expected_type, - now, - exclude_experiments, - ); + let exclusion = options.experimentation_exclusion(context)?; + let result = + self.flags + .compiled + .eval_flag(flag_key, context, expected_type, now, exclusion); match &result { Ok(assignment) => { @@ -119,10 +161,10 @@ impl CompiledFlagsConfig { subject: &EvaluationContext, expected_type: ExpectedFlagType, now: DateTime, - exclude_experiments: bool, + exclusion: ExperimentationExclusion, ) -> Result { self.get_flag(flag_key)? - .eval(subject, expected_type, now, exclude_experiments) + .eval(subject, expected_type, now, exclusion) } fn get_flag(&self, flag_key: &str) -> Result<&Flag, EvaluationError> { @@ -140,7 +182,7 @@ impl Flag { context: &EvaluationContext, expected_type: ExpectedFlagType, now: DateTime, - exclude_experiments: bool, + exclusion: ExperimentationExclusion, ) -> Result { if !expected_type.is_compatible(self.variation_type.into()) { return Err(EvaluationError::TypeMismatch { @@ -149,8 +191,7 @@ impl Flag { }); } - let (allocation, split, reason) = - self.find_allocation(context, now, exclude_experiments)?; + let (allocation, split, reason) = self.find_allocation(context, now, exclusion)?; Ok(Assignment { value: split.value.clone(), @@ -166,10 +207,10 @@ impl Flag { &self, context: &EvaluationContext, now: DateTime, - exclude_experiments: bool, + exclusion: ExperimentationExclusion, ) -> Result<(&Allocation, &Split, AssignmentReason), EvaluationError> { for allocation in &self.allocations { - if exclude_experiments && allocation.do_log { + if exclusion.excludes(allocation) { continue; } if let Some((split, reason)) = allocation.get_matching_split(context, now)? { @@ -279,7 +320,7 @@ mod opt_out_tests { #[test] fn opt_out_timestamp_validation_and_disabled_mode() { let immediate = EvaluationOptions { - experimentation_opt_out_immediate: true, + experimentation_opt_out_mode: ExperimentationOptOutMode::Immediate, }; for (value, expected) in [ (None, Ok(false)), @@ -300,13 +341,15 @@ mod opt_out_tests { ] { let subject = context(value.clone()); assert_eq!( - immediate.exclude_experiments(&subject), + immediate + .experimentation_exclusion(&subject) + .map(|value| value != ExperimentationExclusion::None), expected, "{value:?}" ); assert_eq!( - EvaluationOptions::default().exclude_experiments(&subject), - Ok(false) + EvaluationOptions::default().experimentation_exclusion(&subject), + Ok(ExperimentationExclusion::None) ); } } @@ -314,7 +357,7 @@ mod opt_out_tests { #[test] fn opt_out_uses_allocation_timestamp_contract() { let immediate = EvaluationOptions { - experimentation_opt_out_immediate: true, + experimentation_opt_out_mode: ExperimentationOptOutMode::Immediate, }; for timestamp in [ "2026-10-19T12:00:00Z", @@ -338,9 +381,9 @@ mod opt_out_tests { "splits": [], "doLog": true, })); - let result = immediate.exclude_experiments(&context(Some(json!(timestamp)))); + let result = immediate.experimentation_exclusion(&context(Some(json!(timestamp)))); let expected = if allocation.is_ok() { - Ok(true) + Ok(ExperimentationExclusion::Immediate) } else { Err(EvaluationError::InvalidContext) }; @@ -392,7 +435,7 @@ mod opt_out_tests { ExpectedFlagType::Boolean, Utc::now(), EvaluationOptions { - experimentation_opt_out_immediate: true, + experimentation_opt_out_mode: ExperimentationOptOutMode::Immediate, }, ) .unwrap(); @@ -400,6 +443,156 @@ mod opt_out_tests { assert_eq!(assignment.value.variation_value(), json!(false)); assert!(!assignment.do_log); } + + fn future_configuration(activation: serde_json::Value) -> Configuration { + let mut flag = json!({ + "key": "feature", "enabled": true, "variationType": "BOOLEAN", + "variations": { + "on": {"key": "on", "value": true}, + "off": {"key": "off", "value": false} + }, + "allocations": [{ + "key": "experiment", "doLog": true, + "randomizationActivatedAt": activation, + "splits": [{"variationKey": "on", "shards": [], "serialId": 7}] + }, { + "key": "ordinary", "doLog": false, + "splits": [{"variationKey": "off", "shards": [], "serialId": 8}] + }] + }); + let original = flag.clone(); + flag["key"] = json!("neighbor"); + flag["allocations"][0]["randomizationActivatedAt"] = serde_json::Value::Null; + Configuration::from_server_response( + UniversalFlagConfig::from_json( + serde_json::to_vec(&json!({ + "createdAt": "2026-10-19T00:00:00Z", + "environment": {"name": "test"}, + "flags": {"feature": original, "neighbor": flag} + })) + .unwrap(), + ) + .unwrap(), + ) + } + + #[test] + fn future_cutoff_preserves_precision_and_exposure_selection() { + let subject = context(Some(json!("2026-10-19T12:00:00Z"))); + for (activation, eligible) in [ + (json!("2026-10-19T11:59:59.999999999Z"), true), + (json!("2026-10-19T12:00:00Z"), false), + (json!("2026-10-19T12:00:00.000000001Z"), false), + (json!("2026-10-19T17:30:00+05:30"), false), + (serde_json::Value::Null, false), + ] { + let config = future_configuration(activation.clone()); + for mode in [ + ExperimentationOptOutMode::Disabled, + ExperimentationOptOutMode::Immediate, + ExperimentationOptOutMode::FutureExperimentsOnly, + ] { + let options = EvaluationOptions { + experimentation_opt_out_mode: mode, + }; + let expected = mode == ExperimentationOptOutMode::Disabled + || (mode == ExperimentationOptOutMode::FutureExperimentsOnly && eligible); + let result = config + .eval_flag_with_options( + "feature", + &subject, + ExpectedFlagType::Boolean, + Utc::now(), + options, + ) + .unwrap(); + assert_eq!( + result.value.variation_value(), + json!(expected), + "{mode:?}: {activation}" + ); + assert_eq!(result.do_log, expected); + assert_eq!( + result.allocation_key.as_str(), + if expected { "experiment" } else { "ordinary" } + ); + assert_eq!(result.serial_id, Some(if expected { 7 } else { 8 })); + let eligible_subject = config + .eval_flag_with_options( + "feature", + &context(None), + ExpectedFlagType::Boolean, + Utc::now(), + options, + ) + .unwrap(); + assert!(eligible_subject.do_log); + } + } + } + + #[test] + fn malformed_activation_is_isolated_in_every_mode() { + for activation in [json!("bad"), json!(42), json!(true), json!([]), json!({})] { + let config = future_configuration(activation); + for mode in [ + ExperimentationOptOutMode::Disabled, + ExperimentationOptOutMode::Immediate, + ExperimentationOptOutMode::FutureExperimentsOnly, + ] { + let options = EvaluationOptions { + experimentation_opt_out_mode: mode, + }; + let subject = context(Some(json!("2026-10-19T12:00:00Z"))); + assert_eq!( + config + .eval_flag_with_options( + "feature", + &subject, + ExpectedFlagType::Boolean, + Utc::now(), + options, + ) + .unwrap_err(), + EvaluationError::FlagConfigurationInvalid + ); + assert!( + config + .eval_flag_with_options( + "neighbor", + &subject, + ExpectedFlagType::Boolean, + Utc::now(), + options, + ) + .is_ok() + ); + } + } + } + + #[test] + fn future_invalid_context_precedes_flag_lookup() { + let config = future_configuration(json!("2026-10-19T00:00:00Z")); + for value in [ + json!(null), + json!(true), + json!(42), + json!(""), + json!("private-invalid"), + ] { + let result = config.eval_flag_with_options( + "missing", + &context(Some(value)), + ExpectedFlagType::Boolean, + Utc::now(), + EvaluationOptions { + experimentation_opt_out_mode: ExperimentationOptOutMode::FutureExperimentsOnly, + }, + ); + assert_eq!(result.unwrap_err(), EvaluationError::InvalidContext); + } + } } #[cfg(test)] diff --git a/libdd-ffe/src/rules_based/eval/mod.rs b/libdd-ffe/src/rules_based/eval/mod.rs index 39359bc731..2d465d36fc 100644 --- a/libdd-ffe/src/rules_based/eval/mod.rs +++ b/libdd-ffe/src/rules_based/eval/mod.rs @@ -5,5 +5,5 @@ mod eval_assignment; mod eval_rules; mod evaluation_context; -pub use eval_assignment::{EvaluationOptions, get_assignment}; +pub use eval_assignment::{EvaluationOptions, ExperimentationOptOutMode, get_assignment}; pub use evaluation_context::EvaluationContext; diff --git a/libdd-ffe/src/rules_based/mod.rs b/libdd-ffe/src/rules_based/mod.rs index 53587708f9..8b85eadbdc 100644 --- a/libdd-ffe/src/rules_based/mod.rs +++ b/libdd-ffe/src/rules_based/mod.rs @@ -13,7 +13,7 @@ mod ufc; pub use attributes::Attribute; pub use configuration::Configuration; pub use error::EvaluationError; -pub use eval::{EvaluationContext, EvaluationOptions, get_assignment}; +pub use eval::{EvaluationContext, EvaluationOptions, ExperimentationOptOutMode, get_assignment}; pub use str::Str; pub use timestamp::{Timestamp, now}; pub use ufc::{Assignment, AssignmentReason, AssignmentValue, UniversalFlagConfig}; diff --git a/libdd-ffe/src/rules_based/ufc/compiled_flag_config.rs b/libdd-ffe/src/rules_based/ufc/compiled_flag_config.rs index c58def67f5..dda6357e39 100644 --- a/libdd-ffe/src/rules_based/ufc/compiled_flag_config.rs +++ b/libdd-ffe/src/rules_based/ufc/compiled_flag_config.rs @@ -46,6 +46,7 @@ pub(crate) struct Allocation { pub key: Str, pub start_at: Option, pub end_at: Option, + pub randomization_activated_at: Option, pub rules: Box<[RuleWire]>, pub splits: Box<[Split]>, pub do_log: bool, @@ -153,6 +154,7 @@ fn compile_allocation( key: allocation.key, start_at: allocation.start_at, end_at: allocation.end_at, + randomization_activated_at: allocation.randomization_activated_at, rules: allocation.rules.unwrap_or_default(), splits, do_log: allocation.do_log, diff --git a/libdd-ffe/src/rules_based/ufc/models.rs b/libdd-ffe/src/rules_based/ufc/models.rs index c4434c9da0..34d97f48e7 100644 --- a/libdd-ffe/src/rules_based/ufc/models.rs +++ b/libdd-ffe/src/rules_based/ufc/models.rs @@ -155,6 +155,8 @@ pub(crate) struct AllocationWire { pub start_at: Option, #[serde(default)] pub end_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub randomization_activated_at: Option, pub splits: Vec, #[serde(default = "default_do_log")] pub do_log: bool,