Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
98fc6e5
feat(datadog-ffe): add flagevaluation EVP payload module + Cargo feat…
leoromanovsky Jun 12, 2026
89a2ba7
feat(datadog-sidecar): add FfeFlagEvaluationBatch sidecar action + EV…
leoromanovsky Jun 12, 2026
de8e987
fix(ffe): make flagevaluation batch bincode-safe over the sidecar IPC
leoromanovsky Jun 14, 2026
d02b30a
style(ffe): rustfmt + fix clippy expect-on-Option in enqueue_actions_…
leoromanovsky Jun 15, 2026
22f5642
fix(ffe): align flagevaluation sidecar delivery with worker schema
leoromanovsky Jun 16, 2026
c73a381
chore(ffe): apply flagevaluation rustfmt
leoromanovsky Jun 16, 2026
5e0ebf9
Merge remote-tracking branch 'origin/main' into leo.romanovsky/ffl-24…
leoromanovsky Jun 16, 2026
b576ea7
coalesce flagevaluation batches in sidecar
leoromanovsky Jun 17, 2026
53f81e5
fix(ffe): bound flagevaluation sidecar delivery
leoromanovsky Jun 17, 2026
fdfacd2
Merge remote-tracking branch 'origin/main' into leo.romanovsky/ffl-24…
leoromanovsky Jun 19, 2026
11ca09c
Update flagevaluation EVP endpoint contract
leoromanovsky Jun 19, 2026
3350d4b
Fix flagevaluation flusher clippy handling
leoromanovsky Jun 19, 2026
e16802b
Share FFE EVP proxy transport
leoromanovsky Jun 23, 2026
60fe825
fix(datadog-sidecar): satisfy clippy for EVP constants
leoromanovsky Jun 23, 2026
46734bc
fix(ffe): bound flagevaluation evp payloads
leoromanovsky Jun 23, 2026
2fa796b
fix(sidecar): emit flagevaluation telemetry counters
leoromanovsky Jun 23, 2026
4d38cc5
refactor(sidecar): share EVP proxy constants
leoromanovsky Jun 23, 2026
8dd0418
Use standard queue for FFE flag evaluations
leoromanovsky Jun 25, 2026
47474b0
Move FFE EVP writer logic into datadog-ffe
leoromanovsky Jun 25, 2026
7f642c3
Move FFE flag evaluation sender into datadog-ffe
leoromanovsky Jun 25, 2026
5f14270
Add flagevaluation EVP benchmarks
leoromanovsky Jun 28, 2026
22e356e
Merge remote-tracking branch 'origin/main' into leo.romanovsky/ffl-24…
leoromanovsky Jul 1, 2026
a57a94a
fix(datadog-ffe): align flagevaluation EVP payload limit
leoromanovsky Jul 1, 2026
2b6779a
fix(sidecar): gate FFE flag evaluation flushes
leoromanovsky Jul 3, 2026
3f59d64
fix(sidecar): name flag evaluation count metrics
leoromanovsky Jul 3, 2026
7898485
fix(sidecar): prune flag evaluation FFI context
leoromanovsky Jul 3, 2026
309ea95
fix(ffe): scope flag evaluation placeholder stripping
leoromanovsky Jul 3, 2026
bdbe29c
chore(ffe): simplify flag evaluation payload bookkeeping
leoromanovsky Jul 3, 2026
f1eb1ef
fix(ffe): lower flag evaluation sender failures to debug
leoromanovsky Jul 3, 2026
3ea3104
fix(ffe): preserve generic coalescer defaults
leoromanovsky Jul 3, 2026
2972e30
docs(ffe): explain manual default impls
leoromanovsky Jul 3, 2026
2f74955
chore(sidecar): apply nightly rustfmt
leoromanovsky Jul 3, 2026
040c5bb
fix(ffe): restore warning logs for delivery loss
leoromanovsky Jul 7, 2026
478422b
Merge remote-tracking branch 'origin/main' into leo.romanovsky/ffl-24…
leoromanovsky Jul 7, 2026
fd0f685
chore: merge latest main
leoromanovsky Jul 14, 2026
987d6da
Merge branch 'main' into leo.romanovsky/ffl-2446-ffe-flagevaluation-evp
pr-shepherd-6ad11f[bot] Jul 24, 2026
7fb7880
Merge branch 'main' into leo.romanovsky/ffl-2446-ffe-flagevaluation-evp
vjfridge Aug 5, 2026
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
3 changes: 1 addition & 2 deletions .github/workflows/release-proposal-dispatch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ jobs:
run: |
# Get commits since release for each crate and save to file
"${WORKFLOW_SCRIPTS_ROOT}/commits-since-release.sh" "$(cat /tmp/crates.json)" > /tmp/commits-by-crate.json

# Capture ephemeral release branch tip now. Use this in Release version bumps
# so tag/merge-base resolution uses the same ref the script used.
git rev-parse HEAD > /tmp/release_head_sha
Expand Down Expand Up @@ -1011,7 +1011,6 @@ jobs:
if [ "${#PR_TITLE}" -gt 100 ]; then
PR_TITLE="${PR_TITLE:0:97}..."
fi

if gh pr create \
--head "$BRANCH_NAME" \
--title "$PR_TITLE" \
Expand Down
6 changes: 6 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

301 changes: 297 additions & 4 deletions datadog-sidecar-ffi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,15 @@ use datadog_sidecar::service::agent_info::AgentInfoReader;
use datadog_sidecar::service::telemetry::InternalTelemetryAction;
use datadog_sidecar::service::{
blocking::{self, SidecarTransport},
DynamicInstrumentationConfigState, FfeEvaluationMetric as SidecarFfeEvaluationMetric,
FfeExposure as SidecarFfeExposure, FfeExposureBatch as SidecarFfeExposureBatch,
FfeTelemetryContext as SidecarFfeTelemetryContext, InstanceId, QueueId, RuntimeMetadata,
SerializedTracerHeaderTags, SessionConfig, SidecarAction, SidecarFlushOptions,
AllocationKey, ContextDD, DynamicInstrumentationConfigState, EvalError,
FfeEvaluationMetric as SidecarFfeEvaluationMetric, FfeExposure as SidecarFfeExposure,
FfeExposureBatch as SidecarFfeExposureBatch,
FfeFlagEvaluationBatch as SidecarFfeFlagEvaluationBatch,
FfeFlagEvaluationEvent as SidecarFfeFlagEvaluationEvent,
FfeTelemetryContext as SidecarFfeTelemetryContext, FlagEvalEventContext, FlagKey, InstanceId,
QueueId, RuntimeMetadata, SerializedTracerHeaderTags, SessionConfig, SidecarAction,
SidecarFlushOptions, TargetingRuleKey, VariantKey, MAX_CONTEXT_DEPTH, MAX_CONTEXT_FIELDS,
MAX_FIELD_LENGTH,
};
use datadog_sidecar::service::{get_telemetry_action_sender, InternalTelemetryActions};
use datadog_sidecar::shm_remote_config::{path_for_remote_config, RemoteConfigReader};
Expand Down Expand Up @@ -1210,6 +1215,25 @@ pub struct FfeEvaluationMetric<'a> {
pub allocation_key: CharSlice<'a>,
}

#[repr(C)]
pub struct FfeFlagEvaluation<'a> {
pub timestamp_ms: i64,
pub flag_key: CharSlice<'a>,
pub first_evaluation_ms: i64,
pub last_evaluation_ms: i64,
pub evaluation_count: u64,
pub variant: CharSlice<'a>,
pub allocation_key: CharSlice<'a>,
pub targeting_rule_key: CharSlice<'a>,
pub targeting_key: CharSlice<'a>,
/// UTF-8 JSON object. Empty, invalid, or non-object JSON is omitted. Object
/// values are pruned to 256 leaf fields, 256-byte string values, and four
/// levels of nested context depth.
pub evaluation_context_json: CharSlice<'a>,
pub error_message: CharSlice<'a>,
pub runtime_default_used: bool,
}

/// Send structured FFE exposure events to the sidecar. The sidecar owns
/// deduplication, JSON serialization, and Agent EVP delivery. This function is
/// caller-driven; shared libdatadog evaluator calls do not log unless an SDK
Expand Down Expand Up @@ -1281,6 +1305,78 @@ fn ddog_sidecar_send_ffe_exposure_batch_impl(
MaybeError::None
}

/// Send structured FFE flag evaluation events to the sidecar. The sidecar owns
/// JSON serialization and Agent EVP delivery. This function is caller-driven;
/// callers must aggregate and bound event cardinality before passing a batch.
///
/// # Safety
/// `context` and every element in `flag_evaluations` must contain valid UTF-8
/// `CharSlice` values. Empty `flag_evaluations` is a no-op.
#[no_mangle]
#[allow(clippy::missing_safety_doc)]
pub unsafe extern "C" fn ddog_sidecar_send_ffe_flag_evaluation_batch(
transport: &mut Box<SidecarTransport>,
instance_id: &InstanceId,
queue_id: &QueueId,
context: &FfeTelemetryContext<'_>,
flag_evaluations: Slice<FfeFlagEvaluation<'_>>,
) -> MaybeError {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
ddog_sidecar_send_ffe_flag_evaluation_batch_impl(
transport,
instance_id,
queue_id,
context,
flag_evaluations,
)
}))
.unwrap_or_else(|panic| {
MaybeError::Some(libdd_common_ffi::utils::handle_panic_error(
panic,
"ddog_sidecar_send_ffe_flag_evaluation_batch",
))
})
}

fn ddog_sidecar_send_ffe_flag_evaluation_batch_impl(
transport: &mut Box<SidecarTransport>,
instance_id: &InstanceId,
queue_id: &QueueId,
context: &FfeTelemetryContext<'_>,
flag_evaluations: Slice<FfeFlagEvaluation<'_>>,
) -> MaybeError {
let flag_evaluations = try_c!(flag_evaluations
.try_as_slice()
.map_err(|e| format!("Invalid flag evaluation slice: {e}")));

if flag_evaluations.is_empty() {
return MaybeError::None;
}

let context = try_c!(ffe_context_from_ffi(context));
let flag_evaluations = try_c!(flag_evaluations
.iter()
.map(|event| ffe_flag_evaluation_from_ffi(event, &context.service))
.collect::<Result<Vec<_>, _>>());

if flag_evaluations.is_empty() {
return MaybeError::None;
}

try_c!(blocking::enqueue_actions(
transport,
instance_id,
queue_id,
vec![SidecarAction::FfeFlagEvaluationBatch(
SidecarFfeFlagEvaluationBatch {
context,
flag_evaluations,
}
)],
));
MaybeError::None
}

/// Send structured FFE evaluation metric events to the sidecar. The sidecar
/// owns aggregation, OTLP/protobuf serialization, and OTLP HTTP delivery. This
/// function is caller-driven so SDKs with existing host-language hooks can
Expand Down Expand Up @@ -1345,6 +1441,107 @@ fn ffe_exposure_from_ffi(exposure: &FfeExposure<'_>) -> Result<SidecarFfeExposur
})
}

fn ffe_flag_evaluation_from_ffi(
event: &FfeFlagEvaluation<'_>,
service: &str,
) -> Result<SidecarFfeFlagEvaluationEvent, String> {
let evaluation = optional_json_object_string(event.evaluation_context_json)?;
Comment thread
leoromanovsky marked this conversation as resolved.
let dd = (!service.is_empty()).then(|| ContextDD {
service: service.to_owned(),
});
let context = if evaluation.is_some() || dd.is_some() {
Some(FlagEvalEventContext { evaluation, dd })
} else {
None
};

Ok(SidecarFfeFlagEvaluationEvent {
timestamp: event.timestamp_ms,
flag: FlagKey {
key: char_slice_to_string(event.flag_key)?,
},
first_evaluation: event.first_evaluation_ms,
last_evaluation: event.last_evaluation_ms,
evaluation_count: event.evaluation_count,
variant: optional_string(event.variant)?.map(|key| VariantKey { key }),
allocation: optional_string(event.allocation_key)?.map(|key| AllocationKey { key }),
targeting_rule: optional_string(event.targeting_rule_key)?
.map(|key| TargetingRuleKey { key }),
targeting_key: optional_string(event.targeting_key)?,
context,
error: optional_string(event.error_message)?.map(|message| EvalError { message }),
runtime_default_used: event.runtime_default_used,
})
}

fn prune_evaluation_context_json(value: serde_json::Value) -> Option<String> {
let serde_json::Value::Object(attrs) = value else {
return None;
};

let mut remaining_fields = MAX_CONTEXT_FIELDS;
let pruned = prune_context_object(&attrs, 1, &mut remaining_fields);
Some(serde_json::Value::Object(pruned).to_string())
}

fn prune_context_object(
attrs: &serde_json::Map<String, serde_json::Value>,
depth: usize,
remaining_fields: &mut usize,
) -> serde_json::Map<String, serde_json::Value> {
let mut keys: Vec<_> = attrs.keys().collect();
keys.sort();

let mut pruned = serde_json::Map::new();
for key in keys {
if *remaining_fields == 0 {
break;
}
let Some(value) = attrs
.get(key)
.and_then(|value| prune_context_value(value, depth, remaining_fields))
else {
continue;
};
pruned.insert(key.clone(), value);
}
pruned
}

fn prune_context_value(
value: &serde_json::Value,
depth: usize,
remaining_fields: &mut usize,
) -> Option<serde_json::Value> {
match value {
serde_json::Value::String(s) if s.len() > MAX_FIELD_LENGTH => None,
serde_json::Value::Object(attrs) => {
if depth >= MAX_CONTEXT_DEPTH {
return None;
}
let pruned = prune_context_object(attrs, depth + 1, remaining_fields);
(!pruned.is_empty()).then_some(serde_json::Value::Object(pruned))
}
serde_json::Value::Array(values) => {
if depth >= MAX_CONTEXT_DEPTH {
return None;
}
let pruned: Vec<_> = values
.iter()
.filter_map(|value| prune_context_value(value, depth + 1, remaining_fields))
.collect();
(!pruned.is_empty()).then_some(serde_json::Value::Array(pruned))
}
_ => {
if *remaining_fields == 0 {
return None;
}
*remaining_fields -= 1;
Some(value.clone())
}
}
}

fn ffe_metric_from_ffi(
metric: &FfeEvaluationMetric<'_>,
) -> Result<SidecarFfeEvaluationMetric, String> {
Expand All @@ -1365,6 +1562,17 @@ fn optional_string(slice: CharSlice) -> Result<Option<String>, String> {
}
}

fn optional_json_object_string(slice: CharSlice) -> Result<Option<String>, String> {
let Some(raw) = optional_string(slice)? else {
return Ok(None);
};
let value = match serde_json::from_str::<serde_json::Value>(&raw) {
Ok(value) => value,
Err(_) => return Ok(None),
};
Ok(prune_evaluation_context_json(value))
}

#[no_mangle]
#[allow(clippy::missing_safety_doc)]
#[allow(improper_ctypes_definitions)] // DebuggerPayload is just a pointer, we hide its internals
Expand Down Expand Up @@ -1786,6 +1994,23 @@ mod tests {
use super::*;
use std::borrow::Cow;

fn ffi_flag_evaluation<'a>(evaluation_context_json: &'a str) -> FfeFlagEvaluation<'a> {
FfeFlagEvaluation {
timestamp_ms: 1_700_000_000_000,
flag_key: CharSlice::from("flag-a"),
first_evaluation_ms: 1_699_999_000_000,
last_evaluation_ms: 1_700_000_000_000,
evaluation_count: 7,
variant: CharSlice::empty(),
allocation_key: CharSlice::empty(),
targeting_rule_key: CharSlice::empty(),
targeting_key: CharSlice::empty(),
evaluation_context_json: CharSlice::from(evaluation_context_json),
error_message: CharSlice::empty(),
runtime_default_used: false,
}
}

#[test]
fn otlp_metrics_endpoint_inherits_agent_test_token_when_missing() {
let agent_endpoint = Endpoint {
Expand Down Expand Up @@ -1819,4 +2044,72 @@ mod tests {

assert_eq!(endpoint.test_token.as_deref(), Some("metrics-token"));
}

#[test]
fn ffe_flag_evaluation_preserves_service_without_evaluation_context() {
let event = ffi_flag_evaluation("");

let converted = ffe_flag_evaluation_from_ffi(&event, "checkout").unwrap();
let context = converted.context.expect("service attribution must remain");

assert!(context.evaluation.is_none());
assert_eq!(
context.dd.expect("dd context must be present").service,
"checkout"
);
}

#[test]
fn ffe_flag_evaluation_prunes_context_field_count_and_long_strings() {
let mut attrs = serde_json::Map::new();
attrs.insert(
"aaa_long".to_owned(),
serde_json::Value::String("x".repeat(MAX_FIELD_LENGTH + 1)),
);
for index in 0..=MAX_CONTEXT_FIELDS {
attrs.insert(format!("field_{index:03}"), serde_json::json!(index));
}
let raw = serde_json::Value::Object(attrs).to_string();
let event = ffi_flag_evaluation(&raw);

let converted = ffe_flag_evaluation_from_ffi(&event, "checkout").unwrap();
let context = converted.context.expect("context must be present");
let evaluation = context.evaluation.expect("evaluation context must remain");
let value: serde_json::Value = serde_json::from_str(&evaluation).unwrap();
let attrs = value.as_object().unwrap();

assert_eq!(attrs.len(), MAX_CONTEXT_FIELDS);
assert!(!attrs.contains_key("aaa_long"));
assert!(attrs.contains_key("field_000"));
assert!(attrs.contains_key(&format!("field_{:03}", MAX_CONTEXT_FIELDS - 1)));
assert!(!attrs.contains_key(&format!("field_{MAX_CONTEXT_FIELDS:03}")));
}

#[test]
fn ffe_flag_evaluation_prunes_context_beyond_depth_four() {
let raw = serde_json::json!({
"a": {
"b": {
"c": {
"d": "kept",
"too_deep": {
"e": "dropped"
}
}
}
},
"top": true
})
.to_string();
let event = ffi_flag_evaluation(&raw);

let converted = ffe_flag_evaluation_from_ffi(&event, "checkout").unwrap();
let context = converted.context.expect("context must be present");
let evaluation = context.evaluation.expect("evaluation context must remain");
let value: serde_json::Value = serde_json::from_str(&evaluation).unwrap();

assert_eq!(value["a"]["b"]["c"]["d"], "kept");
assert!(value["a"]["b"]["c"].get("too_deep").is_none());
assert_eq!(value["top"], true);
}
}
2 changes: 1 addition & 1 deletion datadog-sidecar/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ libdd-trace-utils = { path = "../libdd-trace-utils" }
libdd-trace-stats = { path = "../libdd-trace-stats", features = ["telemetry", "dogstatsd"] }
libdd-remote-config = { path = "../libdd-remote-config" }
datadog-live-debugger = { path = "../datadog-live-debugger" }
libdd-ffe = { path = "../libdd-ffe", features = ["exposure-events", "evaluation-metrics"] }
libdd-ffe = { path = "../libdd-ffe", features = ["exposure-events", "evaluation-metrics", "flagevaluation-evp"] }
libdd-crashtracker = { path = "../libdd-crashtracker" }
libdd-dogstatsd-client = { path = "../libdd-dogstatsd-client" }
libdd-tinybytes = { path = "../libdd-tinybytes" }
Expand Down
Loading
Loading