Skip to content
Open
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
4 changes: 4 additions & 0 deletions examples/ffi/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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})
35 changes: 35 additions & 0 deletions examples/ffi/ffe_dependencies.c
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;
}
169 changes: 167 additions & 2 deletions libdd-ffe-ffi/src/assignment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -44,6 +45,7 @@ impl ResolutionDetails {
inner,
error_message,
flag_metadata,
dependencies: Vec::new(),
}
}
}
Expand Down Expand Up @@ -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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve evaluation trace logging

When any ddog_ffe_get_assignment* entry point is used with trace logging enabled, this direct call to eval_flag_with_trace bypasses Configuration::eval_flag_with_options, whose success and failure branches emit the evaluator's log::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 👍 / 👎.

flag_key,
context,
expected_type.into(),
Expand All @@ -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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 1cf7834, not on the current head of #2748 (983b4b0).

  • A merge of this head with 983b4b0 is clean, and 983b4b0 does not change any type that this PR uses.
  • 983b4b0 does change behavior that these accessors expose: prerequisites rejected by the depth guard now appear in ddog_ffe_assignment_dependency_count, an invalid maxDependencyDepth no longer fails ddog_ffe_configuration_new, and empty value lists are accepted.
  • This PR also conflicts with main in this file: main adds features and feature_destinations next to the new dependencies field. Keep all three fields.
  • The description reports 991 Ruby OpenFeature examples against a local build of this stack. Please rerun them after the rebase.

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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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.

  • This function returns an owned handle, but it has no #[must_use]. A caller that ignores the result leaks it. With #[must_use], cbindgen emits DDOG_CHECK_RETURN.
  • The new names (ddog_ffe_assignment_dependency_{count,key,exposure_eligible,result}) do not follow the existing ddog_ffe_assignment_get_* pattern. Consider ddog_ffe_assignment_get_dependency_*, before callers depend on them.
  • The allow(ffi-panic-boundary) comments say these calls only read an owned vector. Handle::as_ref can panic on a null handle, as the existing accessors can; these accessors require a live handle. Please reword the comments to say so.
  • The open Codex thread on line 298 is valid: calling eval_flag_with_trace directly bypasses the evaluator's log::trace! records for every C FFI evaluation. The impact is low (trace level only). The fix belongs in eval_flag_with_trace in feat(ffe): evaluate dependent flags with prerequisite telemetry #2748.

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)]
Expand Down Expand Up @@ -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"));
Expand Down
Loading