Skip to content

Commit a1af795

Browse files
committed
feat: queue managed observability sanitizers
Signed-off-by: Will Killian <wkillian@nvidia.com>
1 parent 1e50d67 commit a1af795

6 files changed

Lines changed: 564 additions & 164 deletions

File tree

crates/core/src/api/llm.rs

Lines changed: 203 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ use crate::api::runtime::NemoRelayContextState;
2222
use crate::api::runtime::global_context;
2323
use crate::api::runtime::state::contextualize_stream;
2424
use crate::api::runtime::subscriber_dispatcher::{
25-
PendingPublication, dispatch_reserved_sanitized_event, dispatch_sanitized_event,
26-
dispatch_transformed_event, register_pending_publication,
25+
EventTransformFn, PendingPublication, dispatch_reserved_sanitized_event,
26+
dispatch_sanitized_event, dispatch_transformed_event, register_pending_publication,
2727
};
2828
use crate::api::runtime::{
2929
EventSubscriberFn, LlmCollectorFn, LlmExecutionNextFn, LlmFinalizerFn, LlmJsonStream,
@@ -415,6 +415,7 @@ fn limit_annotated_request_history_to_current_user_turn(
415415
)
416416
}
417417

418+
#[cfg(test)]
418419
async fn emit_llm_start_with_subscribers(
419420
handle: &LlmHandle,
420421
request: &LlmRequest,
@@ -485,6 +486,87 @@ async fn emit_llm_start_with_subscribers(
485486
Ok(())
486487
}
487488

489+
fn queue_llm_start_with_subscribers(
490+
handle: &LlmHandle,
491+
request: &LlmRequest,
492+
annotated_request: Option<Arc<AnnotatedLlmRequest>>,
493+
request_codec: Option<Arc<dyn LlmCodec>>,
494+
subscribers: &[EventSubscriberFn],
495+
) -> Result<()> {
496+
ensure_runtime_owner()?;
497+
let scope_stack = handle.captured_scope_stack().clone();
498+
let (entries, agent_is_fresh, full_payloads_enabled) = {
499+
let mut scope_guard = scope_stack.write().expect("scope stack lock poisoned");
500+
let scope_locals = scope_guard.collect_scope_local_registries(|registries| {
501+
&registries.llm_sanitize_request_guardrails
502+
});
503+
let context = global_context();
504+
let state = context
505+
.read()
506+
.map_err(|error| FlowError::Internal(error.to_string()))?;
507+
let entries = state.llm_sanitize_request_entries(&scope_locals);
508+
let full_payloads_enabled = state.observability_full_payloads_enabled;
509+
drop(state);
510+
let agent_is_fresh = scope_guard.take_agent_freshness(handle.parent_uuid);
511+
(entries, agent_is_fresh, full_payloads_enabled)
512+
};
513+
let observable_request = remove_observability_credential_headers(request.clone());
514+
let queued_handle = handle.clone();
515+
let event = {
516+
let context = global_context();
517+
let state = context
518+
.read()
519+
.map_err(|error| FlowError::Internal(error.to_string()))?;
520+
state.build_llm_start_event(handle, None, None)
521+
};
522+
let event_sanitizers = snapshot_event_sanitizers(&event, &scope_stack).unwrap_or_default();
523+
dispatch_transformed_event(
524+
event,
525+
Box::new(move |event| {
526+
Box::pin(async move {
527+
let mut sanitized_request =
528+
NemoRelayContextState::llm_sanitize_request_snapshot_chain(
529+
observable_request.clone(),
530+
LlmSanitizeRequestContext::for_request_codec(request_codec.clone()),
531+
&entries,
532+
)
533+
.await;
534+
let request_changed = sanitized_request
535+
.as_ref()
536+
.is_some_and(|sanitized| sanitized != &observable_request);
537+
let mut annotation = match (sanitized_request.as_ref(), request_codec.as_deref()) {
538+
(Some(sanitized), Some(codec)) if request_changed => {
539+
codec.decode(sanitized).ok().map(Arc::new)
540+
}
541+
(Some(_), _) if !request_changed => annotated_request,
542+
_ => None,
543+
};
544+
if !full_payloads_enabled
545+
&& !agent_is_fresh
546+
&& let Some(sanitized_request) = sanitized_request.as_mut()
547+
{
548+
project_llm_request_to_current_user_turn(
549+
sanitized_request,
550+
&mut annotation,
551+
request_codec.as_deref(),
552+
);
553+
}
554+
let input = sanitized_request
555+
.as_ref()
556+
.and_then(|request| serde_json::to_value(request).ok());
557+
global_context()
558+
.read()
559+
.map(|state| state.build_llm_start_event(&queued_handle, input, annotation))
560+
.unwrap_or(event)
561+
})
562+
}),
563+
event_sanitizers,
564+
subscribers,
565+
scope_stack,
566+
);
567+
Ok(())
568+
}
569+
488570
fn remove_observability_credential_headers(mut request: LlmRequest) -> LlmRequest {
489571
request.headers.retain(|name, _| {
490572
!OBSERVABILITY_CREDENTIAL_HEADERS
@@ -841,7 +923,6 @@ pub fn llm_call(params: LlmCallParams<'_>) -> Result<LlmHandle> {
841923

842924
#[derive(Clone, Copy)]
843925
struct LlmCallEndBehavior {
844-
response_codec_errors_fatal: bool,
845926
attach_estimated_cost: bool,
846927
}
847928

@@ -851,6 +932,18 @@ struct LlmEndPayload {
851932
decode_error: Option<FlowError>,
852933
}
853934

935+
/// Queue a provisional LLM END event and replace its observability-only
936+
/// payload on the serial publication path before event sanitizers run.
937+
fn queue_llm_end_event(
938+
event: Event,
939+
transform: EventTransformFn,
940+
subscribers: &[EventSubscriberFn],
941+
scope_stack: ScopeStackHandle,
942+
) {
943+
let event_sanitizers = snapshot_event_sanitizers(&event, &scope_stack).unwrap_or_default();
944+
dispatch_transformed_event(event, transform, event_sanitizers, subscribers, scope_stack);
945+
}
946+
854947
async fn build_llm_end_payload(
855948
handle: &LlmHandle,
856949
response: Json,
@@ -1007,7 +1100,6 @@ pub fn llm_call_end(params: LlmCallEndParams<'_>) -> Result<()> {
10071100
response_codec,
10081101
&entries,
10091102
LlmCallEndBehavior {
1010-
response_codec_errors_fatal: false,
10111103
attach_estimated_cost: false,
10121104
},
10131105
)
@@ -1076,41 +1168,65 @@ async fn llm_call_end_with_behavior(
10761168
(entries, subscribers)
10771169
};
10781170
handle.optimization_recorder.close_for_finalization(None);
1079-
emit_optimization_marks(handle, &subscribers).await;
1080-
let payload = build_llm_end_payload(
1081-
handle,
1082-
response,
1083-
data,
1084-
annotated_response,
1085-
response_codec,
1086-
&entries,
1087-
behavior,
1088-
)
1089-
.await;
1171+
enqueue_optimization_marks(handle, &subscribers);
1172+
let queued_handle = handle.clone();
10901173
let event = {
10911174
let context = global_context();
10921175
let state = context
10931176
.read()
10941177
.map_err(|error| FlowError::Internal(error.to_string()))?;
1095-
let end_metadata = metadata_with_otel_status(metadata, "OK", None);
1178+
let end_metadata = metadata_with_otel_status(metadata.clone(), "OK", None);
10961179
state.build_llm_end_event(
10971180
EndLlmHandleParams::builder()
10981181
.handle(handle)
1099-
.data_opt(payload.data)
1182+
.data(Json::Null)
11001183
.metadata_opt(end_metadata)
1101-
.annotated_response_opt(payload.annotated_response)
11021184
.timestamp_opt(timestamp)
11031185
.build(),
11041186
)
11051187
};
1106-
queue_sanitized_event_with_scope_stack(event, &subscribers, handle.captured_scope_stack());
1107-
if let Some(error) = payload.decode_error
1108-
&& behavior.response_codec_errors_fatal
1109-
{
1110-
Err(error)
1111-
} else {
1112-
Ok(())
1113-
}
1188+
let scope_stack = handle.captured_scope_stack().clone();
1189+
queue_llm_end_event(
1190+
event,
1191+
Box::new(move |event| {
1192+
Box::pin(async move {
1193+
let payload = build_llm_end_payload(
1194+
&queued_handle,
1195+
response,
1196+
data,
1197+
annotated_response,
1198+
response_codec,
1199+
&entries,
1200+
behavior,
1201+
)
1202+
.await;
1203+
if let Some(error) = payload.decode_error {
1204+
log::error!(
1205+
target: "nemo_relay.runtime",
1206+
event = "managed_llm_response_codec_failed";
1207+
"Managed LLM response annotation failed during queued publication: {error}"
1208+
);
1209+
}
1210+
let context = global_context();
1211+
let Ok(state) = context.read() else {
1212+
return event;
1213+
};
1214+
let end_metadata = metadata_with_otel_status(metadata, "OK", None);
1215+
state.build_llm_end_event(
1216+
EndLlmHandleParams::builder()
1217+
.handle(&queued_handle)
1218+
.data_opt(payload.data)
1219+
.metadata_opt(end_metadata)
1220+
.annotated_response_opt(payload.annotated_response)
1221+
.timestamp_opt(timestamp)
1222+
.build(),
1223+
)
1224+
})
1225+
}),
1226+
&subscribers,
1227+
scope_stack,
1228+
);
1229+
Ok(())
11141230
}
11151231

11161232
#[cfg(test)]
@@ -1178,46 +1294,70 @@ async fn emit_llm_end_without_output(
11781294
let entries = state.llm_sanitize_response_entries(&scope_locals);
11791295
(entries, subscribers)
11801296
};
1181-
let had_fallback_data = handle.data.is_some();
1182-
let data = if let Some(data) = handle.data.clone() {
1183-
NemoRelayContextState::llm_sanitize_response_snapshot_chain(
1184-
data,
1185-
LlmSanitizeResponseContext::for_response_codec(response_codec),
1186-
&entries,
1187-
)
1188-
.await
1189-
} else {
1190-
None
1191-
};
1192-
let annotation_omitted =
1193-
(had_fallback_data && data.is_none()) || data.as_ref().is_some_and(Json::is_null);
11941297
handle.optimization_recorder.close_for_finalization(None);
1195-
emit_optimization_marks(handle, &subscribers).await;
1196-
let pricing = crate::codec::response::active_pricing_resolver();
1197-
let annotated_response = (!annotation_omitted)
1198-
.then(|| {
1199-
finalize_optimization_summary(
1200-
&handle.optimization_recorder,
1201-
None,
1202-
handle.model_name.as_deref(),
1203-
&pricing,
1204-
)
1205-
})
1206-
.flatten()
1207-
.map(|summary| {
1208-
Arc::new(AnnotatedLlmResponse {
1209-
optimization_summary: Some(summary),
1210-
..AnnotatedLlmResponse::default()
1211-
})
1212-
});
1298+
enqueue_optimization_marks(handle, &subscribers);
1299+
let queued_handle = handle.clone();
1300+
let fallback_data = handle.data.clone();
12131301
let event = {
12141302
let context = global_context();
12151303
let state = context
12161304
.read()
12171305
.map_err(|error| FlowError::Internal(error.to_string()))?;
1218-
state.end_llm_handle(handle, data, metadata, annotated_response)
1306+
state.build_llm_end_event(
1307+
EndLlmHandleParams::builder()
1308+
.handle(handle)
1309+
.data(Json::Null)
1310+
.metadata_opt(metadata.clone())
1311+
.build(),
1312+
)
12191313
};
1220-
queue_sanitized_event_with_scope_stack(event, &subscribers, handle.captured_scope_stack());
1314+
let scope_stack = handle.captured_scope_stack().clone();
1315+
queue_llm_end_event(
1316+
event,
1317+
Box::new(move |event| {
1318+
Box::pin(async move {
1319+
let had_fallback_data = fallback_data.is_some();
1320+
let data = match fallback_data {
1321+
Some(data) => {
1322+
NemoRelayContextState::llm_sanitize_response_snapshot_chain(
1323+
data,
1324+
LlmSanitizeResponseContext::for_response_codec(response_codec),
1325+
&entries,
1326+
)
1327+
.await
1328+
}
1329+
None => None,
1330+
};
1331+
let annotation_omitted = (had_fallback_data && data.is_none())
1332+
|| data.as_ref().is_some_and(Json::is_null);
1333+
let pricing = crate::codec::response::active_pricing_resolver();
1334+
let annotated_response = (!annotation_omitted)
1335+
.then(|| {
1336+
finalize_optimization_summary(
1337+
&queued_handle.optimization_recorder,
1338+
None,
1339+
queued_handle.model_name.as_deref(),
1340+
&pricing,
1341+
)
1342+
})
1343+
.flatten()
1344+
.map(|summary| {
1345+
Arc::new(AnnotatedLlmResponse {
1346+
optimization_summary: Some(summary),
1347+
..AnnotatedLlmResponse::default()
1348+
})
1349+
});
1350+
global_context()
1351+
.read()
1352+
.map(|state| {
1353+
state.end_llm_handle(&queued_handle, data, metadata, annotated_response)
1354+
})
1355+
.unwrap_or(event)
1356+
})
1357+
}),
1358+
&subscribers,
1359+
scope_stack,
1360+
);
12211361
Ok(())
12221362
}
12231363

@@ -1470,14 +1610,13 @@ pub async fn llm_call_execute(params: LlmCallExecuteParams) -> Result<Json> {
14701610
let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
14711611
snapshot_event_subscribers(scope_guard.collect_scope_local_subscribers())?
14721612
};
1473-
emit_llm_start_with_subscribers(
1613+
queue_llm_start_with_subscribers(
14741614
&handle,
14751615
&intercepted_request,
14761616
annotated_request.clone(),
14771617
request_codec.clone(),
14781618
&lifecycle_subscribers,
1479-
)
1480-
.await?;
1619+
)?;
14811620
emit_pending_request_marks(&handle, pending_marks, &lifecycle_subscribers).await?;
14821621
handle
14831622
.optimization_recorder
@@ -1523,7 +1662,6 @@ pub async fn llm_call_execute(params: LlmCallExecuteParams) -> Result<Json> {
15231662
.response_codec_opt(response_codec)
15241663
.build(),
15251664
LlmCallEndBehavior {
1526-
response_codec_errors_fatal: false,
15271665
attach_estimated_cost: true,
15281666
},
15291667
Some(&lifecycle_subscribers),
@@ -1679,14 +1817,13 @@ pub async fn llm_stream_call_execute(params: LlmStreamCallExecuteParams) -> Resu
16791817
let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
16801818
snapshot_event_subscribers(scope_guard.collect_scope_local_subscribers())?
16811819
};
1682-
emit_llm_start_with_subscribers(
1820+
queue_llm_start_with_subscribers(
16831821
&handle,
16841822
&intercepted_request,
16851823
annotated_request,
16861824
request_codec.clone(),
16871825
&lifecycle_subscribers,
1688-
)
1689-
.await?;
1826+
)?;
16901827
emit_pending_request_marks(&handle, pending_marks, &lifecycle_subscribers).await?;
16911828
handle
16921829
.optimization_recorder

0 commit comments

Comments
 (0)