From 6b711107d2ff9267d7f1701d887287cd54510107 Mon Sep 17 00:00:00 2001 From: Eugen Nekhai Date: Thu, 3 Sep 2026 16:43:38 +0300 Subject: [PATCH] fix(llm-client): redact upstream body from client-call span Signed-off-by: Eugen Nekhai --- crates/libsy-llm-client/src/observability.rs | 14 ++++++- .../libsy-llm-client/tests/observability.rs | 38 ++++++++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/crates/libsy-llm-client/src/observability.rs b/crates/libsy-llm-client/src/observability.rs index 3c62d3a6d..c55d46ad8 100644 --- a/crates/libsy-llm-client/src/observability.rs +++ b/crates/libsy-llm-client/src/observability.rs @@ -66,7 +66,19 @@ pub(crate) fn observe_client_call(result: Result) -> Result } Err(error) => { let error_type = client_call_error_type(&error); - record_client_error(&span, &error_type, &error); + match &error { + LibsyError::ClientCall { + target, + source: LlmClientError::UpstreamHttp { status, .. }, + } => record_client_error( + &span, + &error_type, + &format_args!( + "client call to target {target:?} failed: upstream HTTP {status}" + ), + ), + _ => record_client_error(&span, &error_type, &error), + } Err(error) } } diff --git a/crates/libsy-llm-client/tests/observability.rs b/crates/libsy-llm-client/tests/observability.rs index 2538c3d91..4d3921afa 100644 --- a/crates/libsy-llm-client/tests/observability.rs +++ b/crates/libsy-llm-client/tests/observability.rs @@ -413,6 +413,9 @@ impl RoutedLlmClient for ClassifierClient { } } +// Conversation text that a provider may quote in an upstream error body. +const LEAKED_CONTENT: &str = "patient name is Jane Doe"; + enum JudgeOutcome { CallFailure, Reply(&'static str), @@ -440,7 +443,7 @@ impl RoutedLlmClient for JudgeClient { match &self.outcome { JudgeOutcome::CallFailure => Err(LlmClientError::UpstreamHttp { status: http::StatusCode::INTERNAL_SERVER_ERROR, - body: "server error".to_string(), + body: format!(r#"{{"error":{{"message":"server error: {LEAKED_CONTENT}"}}}}"#), }), JudgeOutcome::Reply(text) => Ok(Response { llm_response: LlmResponse::Agg(text_response(None, *text)), @@ -1213,6 +1216,39 @@ async fn typed_client_failure_records_semantic_error_type() { ); } +/// Verifies that the client span retains the HTTP status without the upstream body. +#[tokio::test] +async fn upstream_body_is_redacted_from_the_client_call_span() -> switchyard_libsy::Result<()> { + let _guard = serialize_test().lock().await; + let (store, _, _, _, _) = telemetry(); + const JUDGE: &str = "redaction-judge"; + let client = Arc::new(JudgeClient { + judge_model: JUDGE.into(), + outcome: JudgeOutcome::CallFailure, + }) as Arc; + run( + classifier_router(JUDGE, "redaction-weak", "redaction-strong")?, + client, + classifier_request(), + ) + .await?; + + let spans = store.spans(); + let client_span = find_span(&spans, "libsy.client_call", "selected_model", JUDGE); + assert_eq!( + client_span.fields.get("error.type").map(String::as_str), + Some("500") + ); + let error = client_span + .fields + .get("error") + .map(String::as_str) + .unwrap_or(""); + assert!(error.contains("upstream HTTP 500"), "{client_span:?}"); + assert!(!error.contains(LEAKED_CONTENT), "{client_span:?}"); + Ok(()) +} + #[tokio::test] async fn failed_call_records_error_outcome_and_warn_logs() -> switchyard_libsy::Result<()> { let _guard = serialize_test().lock().await;