chore: record profile scope and restore diagnostics

This commit is contained in:
2026-06-14 14:30:18 +09:00
parent 1abce888ae
commit db95492a4f
7 changed files with 310 additions and 44 deletions
+89 -2
View File
@@ -12,7 +12,8 @@ use async_trait::async_trait;
use eventsource_stream::Eventsource;
use futures::{Stream, StreamExt, TryStreamExt};
use reqwest::header::{
ACCEPT, CONTENT_ENCODING, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue, RETRY_AFTER,
ACCEPT, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue,
RETRY_AFTER, TRANSFER_ENCODING,
};
use serde_json::{Map, Value, json};
@@ -62,6 +63,40 @@ impl ResolvedAuth {
}
}
fn header_value_for_diagnostics(headers: &HeaderMap, name: &'static HeaderName) -> Option<String> {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn response_header_diagnostics(headers: &HeaderMap) -> serde_json::Value {
serde_json::json!({
"content_type": header_value_for_diagnostics(headers, &CONTENT_TYPE),
"content_encoding": header_value_for_diagnostics(headers, &CONTENT_ENCODING),
"transfer_encoding": header_value_for_diagnostics(headers, &TRANSFER_ENCODING),
"content_length": header_value_for_diagnostics(headers, &CONTENT_LENGTH),
})
}
fn sse_error_context(status: u16, headers: &serde_json::Value, source: &str) -> String {
let field = |name: &str| {
headers
.get(name)
.and_then(serde_json::Value::as_str)
.unwrap_or("<none>")
};
format!(
"SSE stream parse failed after HTTP {status}: {source}; content-type={}, content-encoding={}, transfer-encoding={}, content-length={}",
field("content_type"),
field("content_encoding"),
field("transfer_encoding"),
field("content_length")
)
}
/// scheme 共通の HTTP 通信層。
pub struct HttpTransport<S: Scheme> {
http_client: reqwest::Client,
@@ -510,6 +545,7 @@ impl<S: Scheme + Clone + 'static> LlmClient for HttpTransport<S> {
.await
{
Ok(response) => {
let response_headers = response_header_diagnostics(response.headers());
emit_transport_trace(
&request,
"transport_http_headers_received",
@@ -517,6 +553,7 @@ impl<S: Scheme + Clone + 'static> LlmClient for HttpTransport<S> {
"elapsed_ms": send_started.elapsed().as_millis() as u64,
"status": response.status().as_u16(),
"success": response.status().is_success(),
"headers": response_headers,
}),
);
response
@@ -563,6 +600,9 @@ impl<S: Scheme + Clone + 'static> LlmClient for HttpTransport<S> {
);
let scheme = self.scheme.clone();
let status = response.status().as_u16();
let response_headers = response_header_diagnostics(response.headers());
let transport_trace = request.transport_trace.clone();
let byte_stream = response.bytes_stream().map_err(std::io::Error::other);
let event_stream = byte_stream.eventsource();
@@ -575,7 +615,21 @@ impl<S: Scheme + Clone + 'static> LlmClient for HttpTransport<S> {
Ok(events) => Ok(events),
Err(e) => Err(e),
},
Err(e) => Err(ClientError::Sse(e.to_string())),
Err(e) => {
let source = e.to_string();
let message = sse_error_context(status, &response_headers, &source);
if let Some(trace) = &transport_trace {
trace.emit(
"transport_sse_parse_error",
json!({
"status": status,
"headers": response_headers.clone(),
"error": source,
}),
);
}
Err(ClientError::Sse(message))
}
})
.map(|res| {
let s: Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>> = match res {
@@ -675,6 +729,39 @@ mod tests {
)
}
#[test]
fn sse_error_context_includes_response_headers() {
let headers = json!({
"content_type": "application/octet-stream",
"content_encoding": "gzip",
"transfer_encoding": "chunked",
"content_length": "123",
});
let message = sse_error_context(200, &headers, "stream did not contain valid UTF-8");
assert!(message.contains("HTTP 200"));
assert!(message.contains("stream did not contain valid UTF-8"));
assert!(message.contains("content-type=application/octet-stream"));
assert!(message.contains("content-encoding=gzip"));
assert!(message.contains("transfer-encoding=chunked"));
assert!(message.contains("content-length=123"));
}
#[test]
fn response_header_diagnostics_redacts_to_safe_header_subset() {
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("text/event-stream"));
headers.insert(CONTENT_ENCODING, HeaderValue::from_static("identity"));
headers.insert("authorization", HeaderValue::from_static("Bearer secret"));
let diagnostics = response_header_diagnostics(&headers);
assert_eq!(diagnostics["content_type"], "text/event-stream");
assert_eq!(diagnostics["content_encoding"], "identity");
assert!(diagnostics.get("authorization").is_none());
}
#[test]
fn request_body_shape_counts_reasoning_encrypted_content() {
let payload = request_body_shape_payload(&json!({