feat(core/debug): capture headers and order usage logs

This commit is contained in:
James Peter
2025-10-27 11:21:34 +10:00
parent 33c9c65b76
commit 80497dbc4f
3 changed files with 101 additions and 26 deletions
+36 -13
View File
@@ -1,3 +1,4 @@
use std::collections::BTreeMap;
use std::time::Duration;
use bytes::Bytes;
@@ -7,6 +8,7 @@ use futures::Stream;
use futures::StreamExt;
use futures::TryStreamExt;
use reqwest::StatusCode;
use reqwest::header::HeaderMap;
use serde_json::json;
use std::pin::Pin;
use std::task::Context;
@@ -344,17 +346,9 @@ pub(crate) async fn stream_chat_completions(
serde_json::to_string_pretty(&payload).unwrap_or_default()
);
// Start logging the request and get a request_id to track the response
let request_id = if let Ok(logger) = debug_logger.lock() {
logger
.start_request_log(&endpoint, &payload, log_tag)
.unwrap_or_default()
} else {
String::new()
};
let mut attempt = 0;
let max_retries = provider.request_max_retries();
let mut request_id = String::new();
loop {
attempt += 1;
@@ -369,11 +363,30 @@ pub(crate) async fn stream_chat_completions(
}
}
let res = req_builder
req_builder = req_builder
.header(reqwest::header::ACCEPT, "text/event-stream")
.json(&payload)
.send()
.await;
.json(&payload);
if request_id.is_empty() {
let endpoint_for_log = provider.get_full_url(&auth);
let header_snapshot = req_builder
.try_clone()
.and_then(|builder| builder.build().ok())
.map(|req| header_map_to_json(req.headers()));
if let Ok(logger) = debug_logger.lock() {
request_id = logger
.start_request_log(
&endpoint_for_log,
&payload,
header_snapshot.as_ref(),
log_tag,
)
.unwrap_or_default();
}
}
let res = req_builder.send().await;
match res {
Ok(resp) if resp.status().is_success() => {
@@ -1097,3 +1110,13 @@ impl<S> AggregatedChatStream<S> {
Self::new(inner, AggregateMode::Streaming)
}
}
fn header_map_to_json(headers: &HeaderMap) -> serde_json::Value {
let mut ordered: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (name, value) in headers.iter() {
let entry = ordered.entry(name.as_str().to_string()).or_default();
entry.push(value.to_str().unwrap_or_default().to_string());
}
serde_json::to_value(ordered).unwrap_or(serde_json::Value::Null)
}
+31 -9
View File
@@ -1,3 +1,4 @@
use std::collections::BTreeMap;
use std::io::BufRead;
use std::path::Path;
// use std::sync::OnceLock;
@@ -464,6 +465,7 @@ impl ModelClient {
let mut attempt = 0;
let max_retries = self.provider.request_max_retries();
let mut request_id = String::new();
// Compute endpoint with the latest available auth (may be None at this point).
let endpoint = self
@@ -475,15 +477,6 @@ impl ModelClient {
serde_json::to_string(&payload_json)?
);
// Start logging the request and get a request_id to track the response
let request_id = if let Ok(logger) = self.debug_logger.lock() {
logger
.start_request_log(&endpoint, &payload_json, log_tag)
.unwrap_or_default()
} else {
String::new()
};
loop {
attempt += 1;
@@ -521,6 +514,25 @@ impl ModelClient {
req_builder = req_builder.header("chatgpt-account-id", account_id);
}
if request_id.is_empty() {
let endpoint_for_log = self.provider.get_full_url(&auth);
let header_snapshot = req_builder
.try_clone()
.and_then(|builder| builder.build().ok())
.map(|req| header_map_to_json(req.headers()));
if let Ok(logger) = self.debug_logger.lock() {
request_id = logger
.start_request_log(
&endpoint_for_log,
&payload_json,
header_snapshot.as_ref(),
log_tag,
)
.unwrap_or_default();
}
}
let res = if let Some(otel) = self.otel_event_manager.as_ref() {
otel.log_request(attempt, || req_builder.send()).await
} else {
@@ -910,6 +922,16 @@ fn parse_header_str<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
headers.get(name)?.to_str().ok()
}
fn header_map_to_json(headers: &HeaderMap) -> Value {
let mut ordered: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (name, value) in headers.iter() {
let entry = ordered.entry(name.as_str().to_string()).or_default();
entry.push(value.to_str().unwrap_or_default().to_string());
}
serde_json::to_value(ordered).unwrap_or(Value::Null)
}
async fn process_sse<S>(
stream: S,
tx_event: mpsc::Sender<Result<ResponseEvent>>,
+34 -4
View File
@@ -89,6 +89,7 @@ impl DebugLogger {
&self,
endpoint: &str,
payload: &Value,
headers: Option<&Value>,
tag: Option<&str>,
) -> Result<String, std::io::Error> {
if !self.enabled {
@@ -109,13 +110,17 @@ impl DebugLogger {
let request_file_path = log_dir.join(request_filename);
// Create request object with metadata
let request_entry = serde_json::json!({
let mut request_entry = serde_json::json!({
"timestamp": timestamp.to_rfc3339(),
"request_id": request_id,
"endpoint": endpoint,
"payload": payload
});
if let (Some(headers), Some(obj)) = (headers, request_entry.as_object_mut()) {
obj.insert("headers".to_string(), headers.clone());
}
// Write pretty-printed JSON to request file
let formatted_request = serde_json::to_string_pretty(&request_entry)?;
fs::write(&request_file_path, formatted_request)?;
@@ -234,9 +239,15 @@ impl DebugLogger {
return Ok(());
}
let path = self
.usage_dir
.join(format!("{}_usage.json", session_id));
let session_id_str = session_id.to_string();
let path = if let Some(existing) = self.find_existing_usage_log(&session_id_str) {
existing
} else {
let timestamp = Local::now().format("%Y%m%d_%H%M%S%.3f");
self
.usage_dir
.join(format!("{}_{}_usage.json", timestamp, session_id_str))
};
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
@@ -255,6 +266,25 @@ impl DebugLogger {
Ok(())
}
fn find_existing_usage_log(&self, session_id: &str) -> Option<PathBuf> {
if let Ok(entries) = fs::read_dir(&self.usage_dir) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name == format!("{}_usage.json", session_id)
|| name.ends_with(&format!("_{}_usage.json", session_id))
{
return Some(path);
}
}
}
}
None
}
// Legacy methods for backward compatibility - they now create standalone files
pub fn log_request(
&self,