1use std::io::Write;
15use std::time::Duration;
16
17use serde_json::{json, Value};
18
19use crate::errors::{InnateError, Result};
20
21const PREVIEW_CAP: usize = 4000;
23const ROTATE_BYTES: u64 = 5 * 1024 * 1024;
25
26fn enabled() -> bool {
28 !matches!(
29 std::env::var("INNATE_LLM_TRACE").ok().as_deref(),
30 Some("0") | Some("false") | Some("off")
31 )
32}
33
34pub fn record(
43 label: &str,
44 url: &str,
45 request: &Value,
46 outcome: &Result<Value>,
47 attempts: u32,
48 elapsed: Duration,
49) {
50 if !enabled() {
51 return;
52 }
53 let entry = build_entry(label, url, request, outcome, attempts, elapsed);
54 if let Ok(line) = serde_json::to_string(&entry) {
56 let _ = append_line(&line);
57 }
58}
59
60fn build_entry(
61 label: &str,
62 url: &str,
63 request: &Value,
64 outcome: &Result<Value>,
65 attempts: u32,
66 elapsed: Duration,
67) -> Value {
68 let kind = match label {
69 "Embedding" => "embedding",
70 _ => "chat",
71 };
72 let model = request.get("model").and_then(Value::as_str).unwrap_or("");
73 let (status, error, response_preview, usage) = match outcome {
74 Ok(resp) => (
75 "ok".to_string(),
76 Value::Null,
77 json!(truncate(&resp.to_string())),
78 resp.get("usage").cloned().unwrap_or(Value::Null),
79 ),
80 Err(e) => {
81 let msg = e.to_string();
82 (classify_error(&msg), json!(msg), Value::Null, Value::Null)
83 }
84 };
85
86 json!({
87 "ts": crate::utils::utc_now_iso(),
88 "kind": kind,
89 "label": label,
90 "model": model,
91 "host": host_of(url),
92 "status": status,
93 "attempts": attempts,
94 "latency_ms": elapsed.as_millis() as u64,
95 "token_usage": usage,
96 "error": error,
97 "request_preview": truncate(&request.to_string()),
98 "response_preview": response_preview,
99 })
100}
101
102fn classify_error(msg: &str) -> String {
106 let lower = msg.to_ascii_lowercase();
107 if lower.contains("status: 429") || lower.contains("429 ") {
108 "rate_limited".into()
109 } else if let Some(code) = extract_status_code(&lower) {
110 if (500..=599).contains(&code) {
111 "http_5xx".into()
112 } else if (400..=499).contains(&code) {
113 "http_4xx".into()
114 } else {
115 "error".into()
116 }
117 } else if lower.contains("transport") || lower.contains("connection") || lower.contains("tls") {
118 "transport".into()
119 } else {
120 "error".into()
121 }
122}
123
124fn extract_status_code(lower: &str) -> Option<u16> {
126 let bytes = lower.as_bytes();
127 for i in 0..bytes.len().saturating_sub(2) {
128 if bytes[i].is_ascii_digit()
129 && bytes[i + 1].is_ascii_digit()
130 && bytes[i + 2].is_ascii_digit()
131 {
132 let three = &lower[i..i + 3];
133 if let Ok(code) = three.parse::<u16>() {
134 if (100..=599).contains(&code) {
135 return Some(code);
136 }
137 }
138 }
139 }
140 None
141}
142
143fn host_of(url: &str) -> String {
144 url.split("://")
145 .nth(1)
146 .unwrap_or(url)
147 .split('/')
148 .next()
149 .unwrap_or("")
150 .to_string()
151}
152
153fn truncate(s: &str) -> String {
154 if s.len() <= PREVIEW_CAP {
155 return s.to_string();
156 }
157 let mut end = PREVIEW_CAP;
159 while end > 0 && !s.is_char_boundary(end) {
160 end -= 1;
161 }
162 format!("{}…[truncated {} bytes]", &s[..end], s.len() - end)
163}
164
165fn append_line(line: &str) -> std::io::Result<()> {
166 let path = crate::paths::llm_trace_path();
167 if let Some(dir) = path.parent() {
168 std::fs::create_dir_all(dir)?;
169 }
170 rotate_if_large(&path);
171 let mut f = std::fs::OpenOptions::new()
172 .create(true)
173 .append(true)
174 .open(&path)?;
175 writeln!(f, "{line}")
176}
177
178fn rotate_if_large(path: &std::path::Path) {
181 if let Ok(meta) = std::fs::metadata(path) {
182 if meta.len() >= ROTATE_BYTES {
183 let rotated = path.with_extension("log.1");
184 let _ = std::fs::rename(path, rotated);
185 }
186 }
187}
188
189pub fn read_recent(limit: usize, kind: Option<&str>, status: Option<&str>) -> Result<Vec<Value>> {
192 let path = crate::paths::llm_trace_path();
193 let text = match std::fs::read_to_string(&path) {
194 Ok(t) => t,
195 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
196 Err(e) => return Err(InnateError::Other(format!("read llm_trace.log: {e}"))),
197 };
198 let mut out = Vec::new();
199 for line in text.lines().rev() {
200 if line.trim().is_empty() {
201 continue;
202 }
203 let Ok(v) = serde_json::from_str::<Value>(line) else {
204 continue; };
206 if let Some(k) = kind {
207 if v.get("kind").and_then(Value::as_str) != Some(k) {
208 continue;
209 }
210 }
211 if let Some(s) = status {
212 if v.get("status").and_then(Value::as_str) != Some(s) {
213 continue;
214 }
215 }
216 out.push(v);
217 if out.len() >= limit {
218 break;
219 }
220 }
221 Ok(out)
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 #[test]
229 fn host_strips_scheme_and_path() {
230 assert_eq!(
231 host_of("https://dashscope.aliyuncs.com/v1/embeddings"),
232 "dashscope.aliyuncs.com"
233 );
234 assert_eq!(host_of("http://127.0.0.1:8788/x"), "127.0.0.1:8788");
235 }
236
237 #[test]
238 fn truncate_respects_cap_and_utf8() {
239 let s = "a".repeat(PREVIEW_CAP + 50);
240 let t = truncate(&s);
241 assert!(t.contains("truncated"));
242 let multi = "好".repeat(PREVIEW_CAP); let _ = truncate(&multi); }
245
246 #[test]
247 fn classifies_statuses() {
248 assert_eq!(
249 classify_error("LLM HTTP error: ... status: 404 ..."),
250 "http_4xx"
251 );
252 assert_eq!(
253 classify_error("LLM HTTP error: ... status: 503 ..."),
254 "http_5xx"
255 );
256 assert_eq!(
257 classify_error("Embedding HTTP error: Transport(...)"),
258 "transport"
259 );
260 }
261
262 #[test]
263 fn build_entry_redacts_to_host_and_records_outcome() {
264 let req = json!({"model": "text-embedding-v4", "input": "secret prompt"});
265 let ok: Result<Value> = Ok(json!({"data":[1], "usage":{"prompt_tokens":3}}));
266 let e = build_entry(
267 "Embedding",
268 "https://h.example.com/v1/embeddings",
269 &req,
270 &ok,
271 1,
272 Duration::from_millis(42),
273 );
274 assert_eq!(e["kind"], "embedding");
275 assert_eq!(e["host"], "h.example.com");
276 assert_eq!(e["status"], "ok");
277 assert_eq!(e["model"], "text-embedding-v4");
278 assert_eq!(e["latency_ms"], 42);
279 assert_eq!(e["token_usage"]["prompt_tokens"], 3);
280 assert!(e["request_preview"]
282 .as_str()
283 .unwrap()
284 .contains("secret prompt"));
285 assert!(!e.to_string().contains("Authorization"));
286 }
287}