From a6cb21f76258960fdb1a9d2159103f6fcdfab491 Mon Sep 17 00:00:00 2001 From: Helen Han Date: Mon, 2 Nov 2026 20:33:42 -0500 Subject: [PATCH] feat(ffe): expose prerequisite evaluations through C FFI Expose owned prerequisite results and exposure eligibility for server SDK integrations. Environment: Datadog workspace Co-Authored-By: Codex GPT-6 --- examples/ffi/CMakeLists.txt | 4 + examples/ffi/ffe_dependencies.c | 35 +++++++ libdd-ffe-ffi/src/assignment.rs | 169 +++++++++++++++++++++++++++++++- 3 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 examples/ffi/ffe_dependencies.c diff --git a/examples/ffi/CMakeLists.txt b/examples/ffi/CMakeLists.txt index 60a061278f..04c7433b2d 100644 --- a/examples/ffi/CMakeLists.txt +++ b/examples/ffi/CMakeLists.txt @@ -110,3 +110,7 @@ set_vcruntime_link_type(ffe ${VCRUNTIME_LINK_TYPE}) if(MSVC) target_compile_definitions(ffe PRIVATE _CRT_SECURE_NO_WARNINGS) endif() + +add_executable(ffe_dependencies ffe_dependencies.c) +target_link_libraries(ffe_dependencies PRIVATE Datadog::Profiling) +set_vcruntime_link_type(ffe_dependencies ${VCRUNTIME_LINK_TYPE}) diff --git a/examples/ffi/ffe_dependencies.c b/examples/ffi/ffe_dependencies.c new file mode 100644 index 0000000000..956ceea60d --- /dev/null +++ b/examples/ffi/ffe_dependencies.c @@ -0,0 +1,35 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 +#include +#include +#include "datadog/ffe.h" + +#define CHECK(condition) do { if (!(condition)) { fprintf(stderr, "failed: %s\n", #condition); return 1; } } while (0) + +int main(void) { + const char *json = "{\"createdAt\":\"2026-01-01T00:00:00Z\",\"environment\":{\"name\":\"test\"},\"observeFullEvaluationData\":false,\"flags\":{\"root\":{\"key\":\"root\",\"enabled\":true,\"variationType\":\"BOOLEAN\",\"variations\":{\"on\":{\"key\":\"on\",\"value\":true},\"off\":{\"key\":\"off\",\"value\":false}},\"allocations\":[{\"key\":\"root-allocation\",\"doLog\":true,\"splits\":[{\"variationKey\":\"on\",\"shards\":[]}],\"rules\":[{\"conditions\":[{\"flagEvaluation\":{\"key\":\"parent\"},\"operator\":\"ONE_OF\",\"value\":[\"on\"]},{\"flagEvaluation\":{\"key\":\"parent\"},\"operator\":\"ONE_OF\",\"value\":[\"on\"]}]}]}]},\"parent\":{\"key\":\"parent\",\"enabled\":true,\"variationType\":\"BOOLEAN\",\"variations\":{\"on\":{\"key\":\"on\",\"value\":true},\"off\":{\"key\":\"off\",\"value\":false}},\"allocations\":[{\"key\":\"parent-allocation\",\"doLog\":true,\"splits\":[{\"variationKey\":\"on\",\"shards\":[]}]}]},\"missing\":{\"key\":\"missing\",\"enabled\":true,\"variationType\":\"BOOLEAN\",\"variations\":{\"on\":{\"key\":\"on\",\"value\":true},\"off\":{\"key\":\"off\",\"value\":false}},\"allocations\":[{\"key\":\"missing-allocation\",\"doLog\":true,\"splits\":[{\"variationKey\":\"on\",\"shards\":[]}],\"rules\":[{\"conditions\":[{\"flagEvaluation\":{\"key\":\"absent\"},\"operator\":\"ONE_OF\",\"value\":[\"on\"]}]}]}]}}}"; + struct ddog_ffe_Result_HandleConfiguration parsed = ddog_ffe_configuration_new( + (struct ddog_ffe_BorrowedStr){.ptr = (const uint8_t *)json, .len = strlen(json)}); + CHECK(parsed.tag == DDOG_FFE_RESULT_HANDLE_CONFIGURATION_OK_HANDLE_CONFIGURATION); + ddog_ffe_Handle_Configuration config = parsed.ok; + ddog_ffe_Handle_EvaluationContext context = ddog_ffe_evaluation_context_new("subject", NULL, 0); + ddog_ffe_Handle_ResolutionDetails root = ddog_ffe_get_assignment( + config, "root", DDOG_FFE_EXPECTED_FLAG_TYPE_BOOLEAN, context); + CHECK(ddog_ffe_assignment_dependency_count(root) == 1); + struct ddog_ffe_BorrowedStr key = ddog_ffe_assignment_dependency_key(root, 0); + CHECK(key.len == 6 && memcmp(key.ptr, "parent", 6) == 0); + CHECK(ddog_ffe_assignment_dependency_exposure_eligible(root, 0)); + CHECK(ddog_ffe_assignment_dependency_key(root, 1).len == 0); + CHECK(!ddog_ffe_assignment_dependency_exposure_eligible(root, 1)); + ddog_ffe_Handle_ResolutionDetails child = ddog_ffe_assignment_dependency_result(root, 0); + ddog_ffe_assignment_drop(&root); + // Each child owns its data independently of the root result and configuration. + ddog_ffe_configuration_drop(&config); + CHECK(ddog_ffe_assignment_get_error_code(child) == DDOG_FFE_ERROR_CODE_OK); + struct ddog_ffe_VariantValue value = ddog_ffe_assignment_get_value(child); + CHECK(value.tag == DDOG_FFE_VARIANT_VALUE_BOOLEAN && value.boolean); + CHECK(ddog_ffe_assignment_dependency_count(child) == 0); + ddog_ffe_assignment_drop(&child); + ddog_ffe_evaluation_context_drop(&context); + return 0; +} diff --git a/libdd-ffe-ffi/src/assignment.rs b/libdd-ffe-ffi/src/assignment.rs index ba5d2059fc..47beeebba2 100644 --- a/libdd-ffe-ffi/src/assignment.rs +++ b/libdd-ffe-ffi/src/assignment.rs @@ -20,6 +20,7 @@ pub struct ResolutionDetails { // memoizing some fields, so we can hand off references to them: error_message: Option, flag_metadata: Vec>, + dependencies: Vec, } impl ResolutionDetails { fn new(value: Result) -> ResolutionDetails { @@ -44,6 +45,7 @@ impl ResolutionDetails { inner, error_message, flag_metadata, + dependencies: Vec::new(), } } } @@ -293,7 +295,7 @@ unsafe fn get_assignment_with_mode( ); }; - let assignment_result = config.eval_flag_with_options( + let evaluation = config.eval_flag_with_trace( flag_key, context, expected_type.into(), @@ -303,7 +305,79 @@ unsafe fn get_assignment_with_mode( }, ); - Handle::new(assignment_result.into()) + let mut details = ResolutionDetails::new(evaluation.result); + details.dependencies = evaluation.evaluations; + Handle::new(details) +} + +/// Number of unique prerequisites reached by this evaluation. +/// # Safety +/// `assignment` must be a live handle. +#[unsafe(no_mangle)] +// allow(ffi-panic-boundary): reads the length of an owned vector. +pub unsafe extern "C" fn ddog_ffe_assignment_dependency_count( + assignment: Handle, +) -> usize { + // SAFETY: caller guarantees a live handle. + unsafe { assignment.as_ref() }.dependencies.len() +} + +/// Prerequisite key, borrowed until the root assignment is freed; empty for an invalid index. +/// # Safety +/// `assignment` must be a live handle. +#[unsafe(no_mangle)] +// allow(ffi-panic-boundary): bounds-checked lookup and borrowing do not allocate. +pub unsafe extern "C" fn ddog_ffe_assignment_dependency_key( + assignment: Handle, + index: usize, +) -> BorrowedStr { + // SAFETY: caller guarantees a live handle. + match unsafe { assignment.as_ref() }.dependencies.get(index) { + // SAFETY: caller retains the root assignment throughout the borrow. + Some(record) => unsafe { BorrowedStr::borrow_from_str(&record.flag_key) }, + None => BorrowedStr::empty(), + } +} + +/// Whether this prerequisite contributed to the root's successful assignment. +/// This does not override the prerequisite's `do_log` or SDK consent and deduplication. +/// # Safety +/// `assignment` must be a live handle. +#[unsafe(no_mangle)] +// allow(ffi-panic-boundary): bounds-checked lookup of a boolean does not allocate. +pub unsafe extern "C" fn ddog_ffe_assignment_dependency_exposure_eligible( + assignment: Handle, + index: usize, +) -> bool { + // SAFETY: caller guarantees a live handle. + unsafe { assignment.as_ref() } + .dependencies + .get(index) + .is_some_and(|record| record.exposure_eligible) +} + +/// Copies a prerequisite result into an independently owned handle, including errors. +/// Free it with `ddog_ffe_assignment_drop`. Invalid indices return a General error. +/// # Safety +/// `assignment` must be a live handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_ffe_assignment_dependency_result( + assignment: Handle, + index: usize, +) -> Handle { + catch_assignment_panic(|| { + // SAFETY: caller guarantees a live handle. + let result = unsafe { assignment.as_ref() } + .dependencies + .get(index) + .map(|record| record.result.clone()) + .unwrap_or_else(|| { + Err(EvaluationError::Internal(Str::from_static_str( + "invalid prerequisite index", + ))) + }); + Handle::new(result.into()) + }) } #[repr(C)] @@ -614,6 +688,97 @@ mod tests { use super::*; + #[test] + fn prerequisite_handles_preserve_errors_eligibility_and_ownership() { + let json = br#"{ + "createdAt":"2026-01-01T00:00:00Z","environment":{"name":"test"}, + "flags":{ + "parent":{"key":"parent","enabled":true,"variationType":"BOOLEAN", + "variations":{"on":{"key":"on","value":true}}, + "allocations":[{"key":"parent-allocation","doLog":true,"splits":[{"variationKey":"on","shards":[]}]}]}, + "root":{"key":"root","enabled":true,"variationType":"BOOLEAN", + "variations":{"on":{"key":"on","value":true}}, + "allocations":[{"key":"root-allocation","rules":[{"conditions":[ + {"flagEvaluation":{"key":"parent"},"operator":"ONE_OF","value":["on"]}, + {"flagEvaluation":{"key":"parent"},"operator":"ONE_OF","value":["on"]} + ]}],"splits":[{"variationKey":"on","shards":[]}]}]} + }}"#; + for prerequisite in ["parent", "missing"] { + let wire = String::from_utf8(json.to_vec()).unwrap().replace( + "\"flagEvaluation\":{\"key\":\"parent\"}", + &format!("\"flagEvaluation\":{{\"key\":\"{prerequisite}\"}}"), + ); + let config = Configuration::from_server_response( + ffe::UniversalFlagConfig::from_json(wire.into_bytes()).unwrap(), + ); + let context = EvaluationContext::new(Some("subject".into()), Default::default()); + let trace = config.eval_flag_with_trace( + "root", + &context, + ffe::ExpectedFlagType::Boolean, + now(), + Default::default(), + ); + let mut details = ResolutionDetails::new(trace.result); + details.dependencies = trace.evaluations; + let mut root = Handle::new(details); + // SAFETY: the handle copies are borrowed only until root is freed below. + let count_handle = unsafe { std::ptr::read(&root) }; + assert_eq!( + // SAFETY: count_handle borrows the live root. + unsafe { ddog_ffe_assignment_dependency_count(count_handle) }, + 1 + ); + // SAFETY: this copy borrows the live root. + let key_handle = unsafe { std::ptr::read(&root) }; + // SAFETY: key_handle remains valid throughout this call. + let key = unsafe { ddog_ffe_assignment_dependency_key(key_handle, 0) }; + assert_eq!( + // SAFETY: the key is borrowed from the live root. + unsafe { std::slice::from_raw_parts(key.ptr, key.len) }, + prerequisite.as_bytes() + ); + // SAFETY: this copy borrows the live root. + let eligibility_handle = unsafe { std::ptr::read(&root) }; + assert_eq!( + // SAFETY: eligibility_handle remains valid throughout this call. + unsafe { ddog_ffe_assignment_dependency_exposure_eligible(eligibility_handle, 0) }, + prerequisite == "parent" + ); + // SAFETY: this copy borrows the live root. + let result_handle = unsafe { std::ptr::read(&root) }; + // SAFETY: result_handle is live; the returned copy owns its result independently. + let mut dependency = unsafe { ddog_ffe_assignment_dependency_result(result_handle, 0) }; + // SAFETY: all root borrows ended. + unsafe { ddog_ffe_assignment_drop(&mut root) }; + // SAFETY: the child owns its result and survives the root's destruction. + let error_handle = unsafe { std::ptr::read(&dependency) }; + assert_eq!( + // SAFETY: error_handle borrows the live child. + unsafe { ddog_ffe_assignment_get_error_code(error_handle) }, + if prerequisite == "parent" { + ErrorCode::Ok + } else { + ErrorCode::FlagNotFound + } + ); + if prerequisite == "parent" { + // SAFETY: this copy borrows the live child. + let allocation_handle = unsafe { std::ptr::read(&dependency) }; + // SAFETY: allocation_handle remains valid throughout the call. + let allocation = + unsafe { ddog_ffe_assignment_get_allocation_key(allocation_handle) }; + assert_eq!( + // SAFETY: allocation borrows the still-live child. + unsafe { std::slice::from_raw_parts(allocation.ptr, allocation.len) }, + b"parent-allocation" + ); + } + // SAFETY: all child borrows ended; free its owned copy once. + unsafe { ddog_ffe_assignment_drop(&mut dependency) }; + } + } + #[test] fn evaluation_panic_returns_owned_redacted_error() { let mut result = catch_assignment_panic(|| panic!("private context value"));