feat(logging): persist diagnostics across restarts and redact secrets

Backend half of the logging overhaul.

Retention:
- Keep the last 4 rotated files at 20MB each and stop deleting logs on
  startup, so a crash's prior-run logs survive a restart.
- Apply the persisted log level right after plugin registration instead
  of at the end of setup.
- Bound crash.log with size-based rotation (5MB x 2).

Secret redaction on every log path:
- Proxy: strip userinfo/query from upstream URLs (keep path for
  diagnostics), exact-match redact known auth.api_key/access_token,
  classify request/response bodies instead of logging them, header
  allowlist.
- Redact the Gemini `?key=` in cache-trace endpoints.
- Omit MCP custom-field values (headers may carry tokens).
- Redact deeplink and model-fetch URLs before logging.

Docs:
- FAQ points users to the persistent crash.log for support workflows.
This commit is contained in:
Jason
2026-07-16 10:20:44 +08:00
parent 7f028632b5
commit 22d2872c33
14 changed files with 655 additions and 224 deletions
+70 -14
View File
@@ -225,9 +225,9 @@ pub async fn handle_non_streaming(
strip_hop_by_hop_response_headers(&mut response_headers);
log::debug!(
"[{}] 上游响应体内容: {}",
"[{}] 上游响应体已接收: bytes={} (content omitted)",
ctx.tag,
String::from_utf8_lossy(&body_bytes)
body_bytes.len()
);
// 解析并记录使用量。关闭 usage logging 时直接跳过,避免非流式响应整包 JSON parse。
@@ -761,11 +761,10 @@ pub fn create_logged_passthrough_stream(
}
_ => false,
};
if collected {
log::debug!("[{tag}] <<< SSE 事件: {data}");
} else {
log::debug!("[{tag}] <<< SSE 数据: {data}");
}
log::trace!(
"[{tag}] <<< SSE data: bytes={}, usage_collected={collected} (content omitted)",
data.len()
);
} else {
log::debug!("[{tag}] <<< SSE: [DONE]");
}
@@ -798,15 +797,53 @@ pub fn create_logged_passthrough_stream(
}
}
fn is_safe_diagnostic_header(name: &str) -> bool {
matches!(
name,
"content-type"
| "content-encoding"
| "content-length"
| "retry-after"
| "cf-ray"
| "x-request-id"
| "request-id"
| "x-correlation-id"
) || name.starts_with("x-ratelimit-")
|| name.starts_with("ratelimit-")
}
fn bounded_header_value(value: &axum::http::HeaderValue) -> Option<String> {
let value = value.to_str().ok()?;
let mut bounded = value.chars().take(160).collect::<String>();
if value.chars().count() > 160 {
bounded.push('…');
}
Some(bounded)
}
fn format_headers(headers: &HeaderMap) -> String {
headers
.iter()
.map(|(key, value)| {
let value_str = value.to_str().unwrap_or("<non-utf8>");
format!("{key}={value_str}")
let mut entries = headers
.keys()
.map(|key| {
let name = key.as_str();
if !is_safe_diagnostic_header(name) {
return name.to_string();
}
let values = headers
.get_all(key)
.iter()
.filter_map(bounded_header_value)
.collect::<Vec<_>>();
if values.is_empty() {
name.to_string()
} else {
format!("{name}={}", values.join("|"))
}
})
.collect::<Vec<_>>()
.join(", ")
.collect::<Vec<_>>();
entries.sort();
format!("[{}]", entries.join(", "))
}
#[cfg(test)]
@@ -827,6 +864,25 @@ mod tests {
use std::sync::Arc;
use tokio::sync::RwLock;
#[test]
fn format_headers_keeps_only_allowlisted_diagnostic_values() {
let mut headers = HeaderMap::new();
headers.insert("authorization", "Bearer super-secret".parse().unwrap());
headers.insert("set-cookie", "session=cookie-secret".parse().unwrap());
headers.insert("retry-after", "30".parse().unwrap());
headers.insert("x-ratelimit-remaining", "2".parse().unwrap());
headers.insert("cf-ray", "abc123-SJC".parse().unwrap());
let formatted = format_headers(&headers);
assert!(formatted.contains("authorization"), "{formatted}");
assert!(formatted.contains("set-cookie"), "{formatted}");
assert!(formatted.contains("retry-after=30"), "{formatted}");
assert!(formatted.contains("x-ratelimit-remaining=2"), "{formatted}");
assert!(formatted.contains("cf-ray=abc123-SJC"), "{formatted}");
assert!(!formatted.contains("super-secret"), "{formatted}");
assert!(!formatted.contains("cookie-secret"), "{formatted}");
}
#[test]
fn test_strip_sse_field_accepts_optional_space() {
assert_eq!(