Skip to content
Merged
33 changes: 26 additions & 7 deletions crates/core/src/api/runtime/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1785,19 +1785,38 @@ fn validate_event_metadata_attributes(attributes: &BTreeMap<String, Json>) -> Re
Ok(())
}

fn is_otel_compatible_attribute_number(value: &serde_json::Number) -> bool {
#[derive(Clone, Copy, PartialEq, Eq)]
enum OtelAttributePrimitiveKind {
Boolean,
Integer,
Double,
String,
}

fn otel_compatible_attribute_number_kind(
Comment thread
ericevans-nv marked this conversation as resolved.
Resolved
value: &serde_json::Number,
) -> Option<OtelAttributePrimitiveKind> {
if let Some(value) = value.as_u64() {
return i64::try_from(value).is_ok();
return i64::try_from(value)
.is_ok()
.then_some(OtelAttributePrimitiveKind::Integer);
}
if value.as_i64().is_some() {
return Some(OtelAttributePrimitiveKind::Integer);
}
value.as_i64().is_some() || value.as_f64().is_some()
value.as_f64().map(|_| OtelAttributePrimitiveKind::Double)
}
Comment thread
ericevans-nv marked this conversation as resolved.
Resolved

fn is_otel_compatible_attribute_number(value: &serde_json::Number) -> bool {
otel_compatible_attribute_number_kind(value).is_some()
}

fn is_otel_compatible_attribute_value(value: &Json) -> bool {
fn primitive_kind(value: &Json) -> Option<u8> {
fn primitive_kind(value: &Json) -> Option<OtelAttributePrimitiveKind> {
match value {
Json::Bool(_) => Some(0),
Json::Number(value) if is_otel_compatible_attribute_number(value) => Some(1),
Json::String(_) => Some(2),
Json::Bool(_) => Some(OtelAttributePrimitiveKind::Boolean),
Json::Number(value) => otel_compatible_attribute_number_kind(value),
Json::String(_) => Some(OtelAttributePrimitiveKind::String),
_ => None,
}
}
Expand Down
7 changes: 5 additions & 2 deletions crates/core/tests/unit/runtime_state_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ async fn event_metadata_injection_accepts_flat_otel_values_and_empty_output() {
),
("nv.test.strings".into(), json!(["a", "b"])),
("nv.test.booleans".into(), json!([true, false])),
("nv.test.numbers".into(), json!([1, 2])),
("nv.test.integers".into(), json!([1, 2])),
("nv.test.doubles".into(), json!([1.25, 2.5])),
("nv.test.empty".into(), json!([])),
]))
})
Expand Down Expand Up @@ -68,7 +69,8 @@ async fn event_metadata_injection_accepts_flat_otel_values_and_empty_output() {
);
assert_eq!(metadata["nv.test.strings"], json!(["a", "b"]));
assert_eq!(metadata["nv.test.booleans"], json!([true, false]));
assert_eq!(metadata["nv.test.numbers"], json!([1, 2]));
assert_eq!(metadata["nv.test.integers"], json!([1, 2]));
assert_eq!(metadata["nv.test.doubles"], json!([1.25, 2.5]));
assert_eq!(metadata["nv.test.empty"], json!([]));
}

Expand All @@ -92,6 +94,7 @@ async fn event_metadata_injection_rejects_invalid_output_atomically() {
BTreeMap::from([("nv.test.object".into(), json!({"nested": true}))]),
BTreeMap::from([("nv.test.nested_list".into(), json!([[1]]))]),
BTreeMap::from([("nv.test.mixed_list".into(), json!([1, "two"]))]),
BTreeMap::from([("nv.test.mixed_numbers".into(), json!([1, 2.5]))]),
BTreeMap::from([("nv.test.oversized_number".into(), json!(u64::MAX))]),
BTreeMap::from([("nv.test.oversized_list".into(), json!([u64::MAX]))]),
];
Expand Down
32 changes: 32 additions & 0 deletions crates/ffi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ binding consumes it through CGo.
- **Exported `nemo_relay_*` symbols**: APIs for scopes, tool calls, LLM calls,
middleware, subscribers, plugins, observability exporters, and scope stack
isolation.
- **Event metadata injection**: Register global, scope-local, or plugin-owned
callbacks that inspect an immutable event and propose flat metadata additions.
- **Typed OpenTelemetry export**:
`nemo_relay_otel_subscriber_create` constructs one `full`, `gen_ai`, or
`openinference` trace subscriber. Independently managed log and metric
Expand All @@ -68,6 +70,36 @@ long-running callback work therefore occupy that thread and can reduce
middleware throughput. The FFI does not expose completion-based middleware
registration.

## Event Metadata Injection

An event metadata injector receives a borrowed `FfiEvent` and returns a
heap-allocated JSON object containing proposed metadata additions. Relay frees
the returned string, validates the object, and inserts only keys that are not
already present. Return null after calling
`nemo_relay_set_last_error_message` to reject that callback's additions while
allowing the event to continue through sanitization and delivery.

```c
#include <string.h>

static char *inject_metadata(void *user_data, const FfiEvent *event) {
(void)user_data;
(void)event;
return strdup("{\"application.region\":\"us-central\"}");
}

NemoRelayStatus status = nemo_relay_register_event_metadata_injector(
"application-metadata", 10, inject_metadata, NULL, NULL
);
/* Emit scopes and marks while the callback is registered. */
(void)nemo_relay_deregister_event_metadata_injector("application-metadata");
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

Use `nemo_relay_scope_register_event_metadata_injector` for an active scope or
`nemo_relay_plugin_context_register_event_metadata_injector` during plugin
registration. The corresponding deregistration functions remove future
invocations; scope and plugin cleanup also remove their owned registrations.

## OTLP Logs and Metrics

The raw C ABI remains experimental and source-first. Configure plugin-managed
Expand Down
77 changes: 72 additions & 5 deletions crates/ffi/nemo_relay.h
Original file line number Diff line number Diff line change
Expand Up @@ -252,12 +252,13 @@ typedef struct Option_NemoRelayFinalizerCb Option_NemoRelayFinalizerCb;
typedef struct Option_NemoRelayPluginValidateCb Option_NemoRelayPluginValidateCb;

/**
* Callback for mark and scope event sanitizers.
* The returned JSON string transfers to Relay and is freed exactly once.
* Callback for event metadata injection.
*
* The returned string must contain a JSON object whose properties are proposed
* metadata additions. It transfers to Relay and is freed exactly once. Return
* null after setting the last error message to report a callback failure.
*/
typedef char *(*NemoRelayEventSanitizeCb)(void *user_data,
const struct FfiEvent *event,
const char *fields_json);
typedef char *(*NemoRelayEventMetadataInjectorCb)(void *user_data, const struct FfiEvent *event);

/**
* Optional destructor for user data passed to callbacks.
Expand All @@ -269,6 +270,14 @@ typedef char *(*NemoRelayEventSanitizeCb)(void *user_data,
*/
typedef void (*NemoRelayFreeFn)(void *user_data);

/**
* Callback for mark and scope event sanitizers.
* The returned JSON string transfers to Relay and is freed exactly once.
*/
typedef char *(*NemoRelayEventSanitizeCb)(void *user_data,
const struct FfiEvent *event,
const char *fields_json);

/**
* Callback for LLM execution (default callable). Receives a native JSON C string,
* returns the response as a JSON C string.
Expand Down Expand Up @@ -871,6 +880,50 @@ NemoRelayStatus nemo_relay_adaptive_build_cache_telemetry_event(const char *opti
*/
NemoRelayStatus nemo_relay_adaptive_set_latency_sensitivity(uint32_t value);

/**
* Register a global event metadata injector.
*
* # Safety
* Pointers must remain valid for the documented call lifetime. The callback
* and user data remain owned by Relay until deregistration.
*/
NemoRelayStatus nemo_relay_register_event_metadata_injector(const char *name,
int32_t priority,
NemoRelayEventMetadataInjectorCb cb,
void *user_data,
NemoRelayFreeFn free_fn);

/**
* Deregister a global event metadata injector.
*
* # Safety
* `name` must be a valid C string.
*/
NemoRelayStatus nemo_relay_deregister_event_metadata_injector(const char *name);

/**
* Register an event metadata injector owned by an active scope.
*
* # Safety
* Pointers must remain valid for the documented call lifetime. The callback
* and user data remain owned by Relay until deregistration or scope cleanup.
*/
NemoRelayStatus nemo_relay_scope_register_event_metadata_injector(const char *scope_uuid,
const char *name,
int32_t priority,
NemoRelayEventMetadataInjectorCb cb,
void *user_data,
NemoRelayFreeFn free_fn);

/**
* Deregister an event metadata injector owned by an active scope.
*
* # Safety
* String pointers must be valid C strings.
*/
NemoRelayStatus nemo_relay_scope_deregister_event_metadata_injector(const char *scope_uuid,
const char *name);

/**
* Register a global mark event sanitizer.
* # Safety
Expand Down Expand Up @@ -2057,6 +2110,20 @@ NemoRelayStatus nemo_relay_plugin_context_register_subscriber(struct FfiPluginCo
void *user_data,
NemoRelayFreeFn free_fn);

/**
* Register an event metadata injector into a plugin context.
*
* # Safety
* Pointers must remain valid for the documented call lifetime. The callback
* and user data remain owned by the plugin registration until rollback.
*/
NemoRelayStatus nemo_relay_plugin_context_register_event_metadata_injector(struct FfiPluginContext *ctx,
const char *name,
int32_t priority,
NemoRelayEventMetadataInjectorCb cb,
void *user_data,
NemoRelayFreeFn free_fn);

/**
* Register a mark event sanitizer into a plugin context.
* # Safety
Expand Down
99 changes: 97 additions & 2 deletions crates/ffi/src/api/event_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
// SPDX-License-Identifier: Apache-2.0

use super::{
NemoRelayEventSanitizeCb, NemoRelayFreeFn, NemoRelayStatus, c_char, c_str_to_string,
clear_last_error, core_registry_api, set_last_error, status_from_error, wrap_event_sanitize_fn,
NemoRelayEventMetadataInjectorCb, NemoRelayEventSanitizeCb, NemoRelayFreeFn, NemoRelayStatus,
c_char, c_str_to_string, clear_last_error, core_registry_api, set_last_error,
status_from_error, wrap_event_metadata_injector_fn, wrap_event_sanitize_fn,
};

#[derive(Clone, Copy)]
Expand Down Expand Up @@ -67,6 +68,100 @@ fn parse_scope_uuid(value: *const c_char) -> Result<uuid::Uuid, NemoRelayStatus>
})
}

/// Register a global event metadata injector.
///
/// # Safety
/// Pointers must remain valid for the documented call lifetime. The callback
/// and user data remain owned by Relay until deregistration.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn nemo_relay_register_event_metadata_injector(
name: *const c_char,
priority: i32,
cb: NemoRelayEventMetadataInjectorCb,
user_data: *mut libc::c_void,
free_fn: NemoRelayFreeFn,
) -> NemoRelayStatus {
clear_last_error();
let callback = wrap_event_metadata_injector_fn(cb, user_data, free_fn);
let name = match c_str_to_string(name) {
Ok(value) => value,
Err(status) => return status,
};
core_registry_api::register_event_metadata_injector(&name, priority, callback)
.map(|()| NemoRelayStatus::Ok)
.unwrap_or_else(|error| status_from_error(&error))
}

/// Deregister a global event metadata injector.
///
/// # Safety
/// `name` must be a valid C string.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn nemo_relay_deregister_event_metadata_injector(
name: *const c_char,
) -> NemoRelayStatus {
clear_last_error();
let name = match c_str_to_string(name) {
Ok(value) => value,
Err(status) => return status,
};
core_registry_api::deregister_event_metadata_injector(&name)
.map(|_| NemoRelayStatus::Ok)
.unwrap_or_else(|error| status_from_error(&error))
}

/// Register an event metadata injector owned by an active scope.
///
/// # Safety
/// Pointers must remain valid for the documented call lifetime. The callback
/// and user data remain owned by Relay until deregistration or scope cleanup.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn nemo_relay_scope_register_event_metadata_injector(
scope_uuid: *const c_char,
name: *const c_char,
priority: i32,
cb: NemoRelayEventMetadataInjectorCb,
user_data: *mut libc::c_void,
free_fn: NemoRelayFreeFn,
) -> NemoRelayStatus {
clear_last_error();
let callback = wrap_event_metadata_injector_fn(cb, user_data, free_fn);
let uuid = match parse_scope_uuid(scope_uuid) {
Ok(value) => value,
Err(status) => return status,
};
let name = match c_str_to_string(name) {
Ok(value) => value,
Err(status) => return status,
};
core_registry_api::scope_register_event_metadata_injector(&uuid, &name, priority, callback)
.map(|()| NemoRelayStatus::Ok)
.unwrap_or_else(|error| status_from_error(&error))
}

/// Deregister an event metadata injector owned by an active scope.
///
/// # Safety
/// String pointers must be valid C strings.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn nemo_relay_scope_deregister_event_metadata_injector(
scope_uuid: *const c_char,
name: *const c_char,
) -> NemoRelayStatus {
clear_last_error();
let uuid = match parse_scope_uuid(scope_uuid) {
Ok(value) => value,
Err(status) => return status,
};
let name = match c_str_to_string(name) {
Ok(value) => value,
Err(status) => return status,
};
core_registry_api::scope_deregister_event_metadata_injector(&uuid, &name)
.map(|_| NemoRelayStatus::Ok)
.unwrap_or_else(|error| status_from_error(&error))
}

unsafe fn register_scope(
scope_uuid: *const c_char,
name: *const c_char,
Expand Down
13 changes: 7 additions & 6 deletions crates/ffi/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,13 @@ use std::sync::{Arc, OnceLock};
use std::time::Duration;

use crate::callable::{
NemoRelayCodecDecodeFn, NemoRelayCodecEncodeFn, NemoRelayCollectorCb, NemoRelayEventSanitizeCb,
NemoRelayEventSubscriberCb, NemoRelayFinalizerCb, NemoRelayFreeFn, NemoRelayLlmConditionalCb,
NemoRelayLlmExecCb, NemoRelayLlmExecInterceptCb, NemoRelayLlmRequestInterceptCb,
NemoRelayLlmSanitizeRequestCb, NemoRelayLlmSanitizeResponseCb, NemoRelayPluginRegisterCb,
NemoRelayPluginValidateCb, NemoRelayToolConditionalCb, NemoRelayToolExecCb,
NemoRelayToolExecInterceptCb, NemoRelayToolSanitizeCb, wrap_codec_fn, wrap_collector_fn,
NemoRelayCodecDecodeFn, NemoRelayCodecEncodeFn, NemoRelayCollectorCb,
NemoRelayEventMetadataInjectorCb, NemoRelayEventSanitizeCb, NemoRelayEventSubscriberCb,
NemoRelayFinalizerCb, NemoRelayFreeFn, NemoRelayLlmConditionalCb, NemoRelayLlmExecCb,
NemoRelayLlmExecInterceptCb, NemoRelayLlmRequestInterceptCb, NemoRelayLlmSanitizeRequestCb,
NemoRelayLlmSanitizeResponseCb, NemoRelayPluginRegisterCb, NemoRelayPluginValidateCb,
NemoRelayToolConditionalCb, NemoRelayToolExecCb, NemoRelayToolExecInterceptCb,
NemoRelayToolSanitizeCb, wrap_codec_fn, wrap_collector_fn, wrap_event_metadata_injector_fn,
wrap_event_sanitize_fn, wrap_event_subscriber, wrap_finalizer_fn, wrap_llm_conditional_fn,
wrap_llm_exec_fn, wrap_llm_exec_intercept_fn, wrap_llm_request_intercept_fn,
wrap_llm_sanitize_request_fn, wrap_llm_sanitize_response_fn, wrap_llm_stream_exec_fn,
Expand Down
Loading
Loading