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
+5 -1
View File
@@ -65,9 +65,13 @@ pub async fn fetch_models(
let candidates = build_models_url_candidates(base_url, is_full_url, models_url_override)?;
let client = crate::proxy::http_client::get();
let mut last_err: Option<String> = None;
let log_secrets = vec![api_key.to_string()];
for url in &candidates {
log::debug!("[ModelFetch] Trying endpoint: {url}");
log::debug!(
"[ModelFetch] Trying endpoint: {}",
crate::url_for_log_with_secrets(url, &log_secrets)
);
let mut request = client
.get(url)
.header("Authorization", format!("Bearer {api_key}"))
+4 -28
View File
@@ -401,33 +401,7 @@ pub fn webdav_status_error(op: &str, status: StatusCode, url: &str) -> AppError
}
fn redact_url(raw: &str) -> String {
match Url::parse(raw) {
Ok(mut parsed) => {
let _ = parsed.set_username("");
let _ = parsed.set_password(None);
let mut out = format!("{}://", parsed.scheme());
if let Some(host) = parsed.host_str() {
out.push_str(host);
}
if let Some(port) = parsed.port() {
out.push(':');
out.push_str(&port.to_string());
}
out.push_str(parsed.path());
let mut keys: Vec<String> = parsed.query_pairs().map(|(k, _)| k.into_owned()).collect();
keys.sort();
keys.dedup();
if !keys.is_empty() {
out.push_str("?[keys:");
out.push_str(&keys.join(","));
out.push(']');
}
out
}
Err(_) => raw.split('?').next().unwrap_or(raw).to_string(),
}
crate::redact_url_for_log(raw)
}
fn response_too_large_error(url: &str, max_bytes: usize) -> AppError {
@@ -520,9 +494,11 @@ mod tests {
#[test]
fn redact_url_hides_credentials_and_query_values() {
// userinfo 与整个 query 一并剥离,host+path 保留用于诊断。
let redacted = redact_url("https://alice:secret@example.com:8443/dav?token=abc&foo=1");
assert_eq!(redacted, "https://example.com:8443/dav?[keys:foo,token]");
assert_eq!(redacted, "https://example.com:8443/dav");
assert!(!redacted.contains("secret"));
assert!(!redacted.contains("abc"));
}
#[test]