Repository navigation
feat(ffe): expose prerequisite evaluations through C FFI #2791
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: helen/ffe-dependent-flags-evaluator
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| // Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| #include <stdio.h> | ||
| #include <string.h> | ||
| #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; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,6 +20,7 @@ pub struct ResolutionDetails { | |
| // memoizing some fields, so we can hand off references to them: | ||
| error_message: Option<String>, | ||
| flag_metadata: Vec<KeyValue<BorrowedStr, BorrowedStr>>, | ||
| dependencies: Vec<ffe::EvaluationRecord>, | ||
| } | ||
| impl ResolutionDetails { | ||
| fn new(value: Result<Assignment, EvaluationError>) -> 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( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 Comment from Claude working with Aaron Silverman: Heads up: This PR is based on
|
||
| assignment: Handle<ResolutionDetails>, | ||
| ) -> 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<ResolutionDetails>, | ||
| 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<ResolutionDetails>, | ||
| 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( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 Comment from Claude working with Aaron Silverman: [P3] Small API points to settle before these accessors ship.
|
||
| assignment: Handle<ResolutionDetails>, | ||
| index: usize, | ||
| ) -> Handle<ResolutionDetails> { | ||
| 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")); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When any
ddog_ffe_get_assignment*entry point is used with trace logging enabled, this direct call toeval_flag_with_tracebypassesConfiguration::eval_flag_with_options, whose success and failure branches emit the evaluator'slog::trace!records. Consequently, all C FFI evaluations silently lose their existing diagnostic logs, including evaluations without prerequisites; retain equivalent logging while collecting the trace.Useful? React with 👍 / 👎.