返回 CodeWhale
session_diagnostics.rs
根目录 / crates / tui / src / session_diagnostics.rs
1 //! Privacy-first session failure diagnostics (#2022).
2 //!
3 //! This module intentionally consumes loose JSONL event shapes instead of one
4 //! exact persisted-session schema. Runtime logs, tool audits, and future bug
5 //! exports can all emit slightly different records; the classifier only needs
6 //! redacted handles, aggregate counts, and broad failure classes.
7
8 use std::collections::{BTreeMap, BTreeSet};
9 use std::fmt;
10
11 use serde::{Deserialize, Serialize};
12 use serde_json::Value;
13
14 use crate::error_taxonomy::{ErrorCategory, classify_error_message};
15
16 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
17 #[serde(rename_all = "snake_case")]
18 pub(crate) enum SessionFailureClass {
19 CommandExit,
20 Network,
21 SandboxApproval,
22 MissingDependency,
23 Timeout,
24 BackgroundJob,
25 ToolSchema,
26 Model,
27 Unknown,
28 UnclosedTurn,
29 }
30
31 impl fmt::Display for SessionFailureClass {
32 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33 let label = match self {
34 Self::CommandExit => "command_exit",
35 Self::Network => "network",
36 Self::SandboxApproval => "sandbox_approval",
37 Self::MissingDependency => "missing_dependency",
38 Self::Timeout => "timeout",
39 Self::BackgroundJob => "background_job",
40 Self::ToolSchema => "tool_schema",
41 Self::Model => "model",
42 Self::Unknown => "unknown",
43 Self::UnclosedTurn => "unclosed_turn",
44 };
45 f.write_str(label)
46 }
47 }
48
49 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50 pub(crate) struct SessionFailureSource {
51 pub line: usize,
52 #[serde(skip_serializing_if = "Option::is_none")]
53 pub event: Option<String>,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 pub turn_ref: Option<String>,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub tool_name: Option<String>,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub timestamp: Option<String>,
60 }
61
62 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63 pub(crate) struct SessionFailureSummary {
64 pub total_lines: usize,
65 pub malformed_lines: usize,
66 pub counts: BTreeMap<SessionFailureClass, usize>,
67 pub sources: BTreeMap<SessionFailureClass, Vec<SessionFailureSource>>,
68 }
69
70 impl SessionFailureSummary {
71 #[must_use]
72 pub(crate) fn count(&self, class: SessionFailureClass) -> usize {
73 self.counts.get(&class).copied().unwrap_or(0)
74 }
75
76 fn record(&mut self, class: SessionFailureClass, source: SessionFailureSource) {
77 *self.counts.entry(class).or_insert(0) += 1;
78 self.sources.entry(class).or_default().push(source);
79 }
80 }
81
82 #[must_use]
83 pub(crate) fn analyze_session_failure_jsonl(jsonl: &str) -> SessionFailureSummary {
84 let mut summary = SessionFailureSummary {
85 total_lines: 0,
86 malformed_lines: 0,
87 counts: BTreeMap::new(),
88 sources: BTreeMap::new(),
89 };
90 let mut open_turns: BTreeMap<String, SessionFailureSource> = BTreeMap::new();
91
92 for (idx, raw_line) in jsonl.lines().enumerate() {
93 let line_no = idx + 1;
94 let trimmed = raw_line.trim();
95 if trimmed.is_empty() {
96 continue;
97 }
98 summary.total_lines += 1;
99 let Ok(value) = serde_json::from_str::<Value>(trimmed) else {
100 summary.malformed_lines += 1;
101 continue;
102 };
103
104 let event = event_name(&value);
105 let turn_id = string_field_any(&value, &["turn_id", "turnId", "run_id"]);
106 let source = source_handle(line_no, &value, event.clone(), turn_id.as_deref());
107 let failure_signal = has_failure_signal(&value);
108
109 if event_matches(
110 event.as_deref(),
111 &["turn_started", "turnstarted", "turn_start"],
112 ) {
113 if let Some(turn_id) = turn_id {
114 open_turns.insert(turn_id, source);
115 }
116 continue;
117 }
118 if event_matches(
119 event.as_deref(),
120 &[
121 "turn_complete",
122 "turncompleted",
123 "turn_finished",
124 "turnfinished",
125 ],
126 ) {
127 if let Some(turn_id) = turn_id.as_ref() {
128 open_turns.remove(turn_id);
129 }
130 if failure_signal {
131 let class = classify_failure_signal(&value);
132 summary.record(class, source);
133 }
134 continue;
135 }
136
137 if failure_signal {
138 let class = classify_failure_signal(&value);
139 summary.record(class, source);
140 }
141 }
142
143 for (_, source) in open_turns {
144 summary.record(SessionFailureClass::UnclosedTurn, source);
145 }
146
147 summary
148 }
149
150 #[must_use]
151 pub(crate) fn format_redacted_failure_summary(summary: &SessionFailureSummary) -> String {
152 if summary.counts.is_empty() {
153 return "No session failure signals detected.".to_string();
154 }
155 let mut lines = vec![format!(
156 "Session failure diagnostics: {} JSONL lines inspected, {} malformed skipped.",
157 summary.total_lines, summary.malformed_lines
158 )];
159 for (class, count) in &summary.counts {
160 let sample = summary
161 .sources
162 .get(class)
163 .and_then(|sources| sources.first())
164 .map(format_source)
165 .unwrap_or_else(|| "no source".to_string());
166 lines.push(format!("- {class}: {count} (sample: {sample})"));
167 }
168 lines.join("\n")
169 }
170
171 fn source_handle(
172 line: usize,
173 value: &Value,
174 event: Option<String>,
175 turn_id: Option<&str>,
176 ) -> SessionFailureSource {
177 let tool_name = string_field_any(value, &["tool_name", "toolName", "tool"])
178 .filter(|name| event.as_deref().is_none_or(|event| event != name));
179 SessionFailureSource {
180 line,
181 event,
182 turn_ref: turn_id.map(crate::utils::redacted_identifier_for_log),
183 tool_name,
184 timestamp: string_field_any(value, &["timestamp", "ts", "created_at", "createdAt"]),
185 }
186 }
187
188 fn format_source(source: &SessionFailureSource) -> String {
189 let mut parts = vec![format!("line {}", source.line)];
190 if let Some(event) = source.event.as_deref() {
191 parts.push(format!("event={event}"));
192 }
193 if let Some(turn_ref) = source.turn_ref.as_deref() {
194 parts.push(format!("turn={turn_ref}"));
195 }
196 if let Some(tool_name) = source.tool_name.as_deref() {
197 parts.push(format!("tool={tool_name}"));
198 }
199 if let Some(timestamp) = source.timestamp.as_deref() {
200 parts.push(format!("ts={timestamp}"));
201 }
202 parts.join(" ")
203 }
204
205 fn has_failure_signal(value: &Value) -> bool {
206 numeric_field_any(value, &["exit_code", "exitCode"]).is_some_and(|code| code != 0)
207 || bool_field_any(value, &["success"]).is_some_and(|success| !success)
208 || bool_field_any(value, &["is_error", "isError"]).unwrap_or(false)
209 || failure_status(value).is_some()
210 || string_field_any(value, &["error", "stderr"]).is_some_and(|text| !text.is_empty())
211 }
212
213 fn classify_failure_signal(value: &Value) -> SessionFailureClass {
214 if let Some(message) = diagnostic_message(value) {
215 return classify_session_failure(value, &message);
216 }
217 if let Some(status) = failure_status(value) {
218 let lower = status.to_ascii_lowercase();
219 if lower.contains("timeout") || lower.contains("timed_out") {
220 return SessionFailureClass::Timeout;
221 }
222 if lower.contains("cancel") || lower.contains("background") || lower.contains("stale") {
223 return SessionFailureClass::BackgroundJob;
224 }
225 }
226 if numeric_field_any(value, &["exit_code", "exitCode"]).is_some_and(|code| code != 0) {
227 return SessionFailureClass::CommandExit;
228 }
229 SessionFailureClass::Unknown
230 }
231
232 fn classify_session_failure(value: &Value, message: &str) -> SessionFailureClass {
233 let lower = message.to_ascii_lowercase();
234 if lower.contains("background")
235 || lower.contains("task_shell")
236 || lower.contains("job timed out")
237 || lower.contains("job cancelled")
238 || lower.contains("stale job")
239 {
240 return SessionFailureClass::BackgroundJob;
241 }
242 if lower.contains("sandbox")
243 || lower.contains("approval")
244 || lower.contains("permission denied")
245 || lower.contains("operation not permitted")
246 || lower.contains("read-only")
247 || lower.contains("access is denied")
248 {
249 return SessionFailureClass::SandboxApproval;
250 }
251 if lower.contains("command not found")
252 || lower.contains("no such file or directory")
253 || lower.contains("missing binary")
254 || lower.contains("enoent")
255 || lower.contains("not installed")
256 {
257 return SessionFailureClass::MissingDependency;
258 }
259 if lower.contains("missing field")
260 || lower.contains("invalid tool")
261 || lower.contains("invalid input")
262 || lower.contains("schema")
263 || lower.contains("tool arguments")
264 {
265 return SessionFailureClass::ToolSchema;
266 }
267 if numeric_field_any(value, &["exit_code", "exitCode"]).is_some_and(|code| code != 0)
268 || lower.contains("non-zero")
269 || lower.contains("exit status")
270 || lower.contains("exit code")
271 {
272 return SessionFailureClass::CommandExit;
273 }
274 match classify_error_message(message) {
275 ErrorCategory::Network | ErrorCategory::RateLimit => SessionFailureClass::Network,
276 ErrorCategory::Timeout => SessionFailureClass::Timeout,
277 ErrorCategory::Authorization => SessionFailureClass::SandboxApproval,
278 ErrorCategory::Authentication => SessionFailureClass::Model,
279 ErrorCategory::State => SessionFailureClass::MissingDependency,
280 ErrorCategory::InvalidInput | ErrorCategory::Parse => SessionFailureClass::ToolSchema,
281 ErrorCategory::Tool => SessionFailureClass::CommandExit,
282 ErrorCategory::Internal if lower.contains("model") => SessionFailureClass::Model,
283 ErrorCategory::Internal => SessionFailureClass::Unknown,
284 }
285 }
286
287 fn diagnostic_message(value: &Value) -> Option<String> {
288 let mut parts = Vec::new();
289 collect_string_fields(
290 value,
291 &mut parts,
292 &[
293 "error", "message", "stderr", "reason", "result", "output", "content",
294 ],
295 0,
296 );
297 let mut seen = BTreeSet::new();
298 let deduped = parts
299 .into_iter()
300 .filter(|part| !part.trim().is_empty())
301 .filter(|part| seen.insert(part.clone()))
302 .collect::<Vec<_>>();
303 (!deduped.is_empty()).then(|| deduped.join(" "))
304 }
305
306 fn collect_string_fields(value: &Value, out: &mut Vec<String>, keys: &[&str], depth: usize) {
307 if depth > 4 {
308 return;
309 }
310 match value {
311 Value::Object(map) => {
312 for (key, value) in map {
313 if keys
314 .iter()
315 .any(|candidate| key.eq_ignore_ascii_case(candidate))
316 && let Some(text) = value.as_str()
317 {
318 out.push(text.to_string());
319 }
320 collect_string_fields(value, out, keys, depth + 1);
321 }
322 }
323 Value::Array(items) => {
324 for item in items {
325 collect_string_fields(item, out, keys, depth + 1);
326 }
327 }
328 _ => {}
329 }
330 }
331
332 fn event_name(value: &Value) -> Option<String> {
333 string_field_any(value, &["event", "type", "kind"]).map(|event| normalize_event(&event))
334 }
335
336 fn normalize_event(event: &str) -> String {
337 event
338 .trim()
339 .trim_matches('"')
340 .replace(['-', ' ', '.'], "_")
341 .to_ascii_lowercase()
342 }
343
344 fn event_matches(event: Option<&str>, aliases: &[&str]) -> bool {
345 event.is_some_and(|event| aliases.contains(&event))
346 }
347
348 fn failure_status(value: &Value) -> Option<String> {
349 string_field_any(value, &["status", "state", "outcome"]).filter(|status| {
350 let normalized = normalize_event(status);
351 matches!(
352 normalized.as_str(),
353 "failed"
354 | "failure"
355 | "error"
356 | "errored"
357 | "cancelled"
358 | "canceled"
359 | "timeout"
360 | "timed_out"
361 | "stale"
362 )
363 })
364 }
365
366 fn string_field_any(value: &Value, keys: &[&str]) -> Option<String> {
367 string_field_any_at(value, keys, 0)
368 }
369
370 fn string_field_any_at(value: &Value, keys: &[&str], depth: usize) -> Option<String> {
371 if depth > 4 {
372 return None;
373 }
374 match value {
375 Value::Object(map) => {
376 for key in keys {
377 if let Some(value) = map.iter().find_map(|(candidate, value)| {
378 candidate.eq_ignore_ascii_case(key).then_some(value)
379 }) && let Some(text) = value.as_str()
380 {
381 return Some(text.to_string());
382 }
383 }
384 for child in map.values() {
385 if let Some(found) = string_field_any_at(child, keys, depth + 1) {
386 return Some(found);
387 }
388 }
389 None
390 }
391 Value::Array(items) => items
392 .iter()
393 .find_map(|item| string_field_any_at(item, keys, depth + 1)),
394 _ => None,
395 }
396 }
397
398 fn numeric_field_any(value: &Value, keys: &[&str]) -> Option<i64> {
399 numeric_field_any_at(value, keys, 0)
400 }
401
402 fn numeric_field_any_at(value: &Value, keys: &[&str], depth: usize) -> Option<i64> {
403 if depth > 4 {
404 return None;
405 }
406 match value {
407 Value::Object(map) => {
408 for key in keys {
409 if let Some(value) = map.iter().find_map(|(candidate, value)| {
410 candidate.eq_ignore_ascii_case(key).then_some(value)
411 }) && let Some(number) = value.as_i64()
412 {
413 return Some(number);
414 }
415 }
416 for child in map.values() {
417 if let Some(found) = numeric_field_any_at(child, keys, depth + 1) {
418 return Some(found);
419 }
420 }
421 None
422 }
423 Value::Array(items) => items
424 .iter()
425 .find_map(|item| numeric_field_any_at(item, keys, depth + 1)),
426 _ => None,
427 }
428 }
429
430 fn bool_field_any(value: &Value, keys: &[&str]) -> Option<bool> {
431 bool_field_any_at(value, keys, 0)
432 }
433
434 fn bool_field_any_at(value: &Value, keys: &[&str], depth: usize) -> Option<bool> {
435 if depth > 4 {
436 return None;
437 }
438 match value {
439 Value::Object(map) => {
440 for key in keys {
441 if let Some(value) = map.iter().find_map(|(candidate, value)| {
442 candidate.eq_ignore_ascii_case(key).then_some(value)
443 }) && let Some(flag) = value.as_bool()
444 {
445 return Some(flag);
446 }
447 }
448 for child in map.values() {
449 if let Some(found) = bool_field_any_at(child, keys, depth + 1) {
450 return Some(found);
451 }
452 }
453 None
454 }
455 Value::Array(items) => items
456 .iter()
457 .find_map(|item| bool_field_any_at(item, keys, depth + 1)),
458 _ => None,
459 }
460 }
461
462 #[cfg(test)]
463 mod tests {
464 use super::*;
465
466 #[test]
467 fn synthetic_jsonl_classifies_environment_and_tool_failures() {
468 let jsonl = r#"
469 {"event":"turn_started","turn_id":"turn-secret-1","timestamp":"2026-06-25T12:00:00Z"}
470 {"event":"tool_call_complete","turn_id":"turn-secret-1","tool_name":"exec_shell","exit_code":127,"stderr":"bash: rg: command not found"}
471 {"event":"tool_call_complete","turn_id":"turn-secret-1","tool_name":"exec_shell","exit_code":2,"stderr":"command failed with exit code 2"}
472 {"event":"tool_call_complete","turn_id":"turn-secret-1","tool_name":"web_search","error":"DNS resolution failed for api.example.test"}
473 {"event":"tool_call_complete","turn_id":"turn-secret-1","tool_name":"apply_patch","error":"Permission denied by sandbox: read-only filesystem"}
474 {"event":"tool_call_complete","turn_id":"turn-secret-1","tool_name":"exec_shell","error":"request timed out after 30s"}
475 {"event":"tool_call_complete","turn_id":"turn-secret-1","tool_name":"task_shell_wait","error":"background job timed out"}
476 {"event":"tool_call_complete","turn_id":"turn-secret-1","tool_name":"mcp_tool","error":"missing field: tool arguments"}
477 {"event":"turn_complete","turn_id":"turn-secret-1","status":"completed"}
478 {"event":"turn_started","turn_id":"turn-secret-2"}
479 not json at all
480 "#;
481
482 let summary = analyze_session_failure_jsonl(jsonl);
483
484 assert_eq!(summary.malformed_lines, 1);
485 assert_eq!(summary.count(SessionFailureClass::MissingDependency), 1);
486 assert_eq!(summary.count(SessionFailureClass::CommandExit), 1);
487 assert_eq!(summary.count(SessionFailureClass::Network), 1);
488 assert_eq!(summary.count(SessionFailureClass::SandboxApproval), 1);
489 assert_eq!(summary.count(SessionFailureClass::Timeout), 1);
490 assert_eq!(summary.count(SessionFailureClass::BackgroundJob), 1);
491 assert_eq!(summary.count(SessionFailureClass::ToolSchema), 1);
492 assert_eq!(summary.count(SessionFailureClass::UnclosedTurn), 1);
493
494 let sources = summary
495 .sources
496 .get(&SessionFailureClass::MissingDependency)
497 .expect("missing-dependency source");
498 assert_eq!(sources[0].tool_name.as_deref(), Some("exec_shell"));
499 assert!(
500 sources[0]
501 .turn_ref
502 .as_deref()
503 .is_some_and(|turn| turn.starts_with("<redacted:")),
504 "turn ids must be redacted: {sources:?}"
505 );
506 }
507
508 #[test]
509 fn redacted_summary_omits_raw_messages_and_paths() {
510 let jsonl = r#"
511 {"event":"turn_started","turn_id":"turn-secret-1"}
512 {"event":"tool_call_complete","turn_id":"turn-secret-1","tool_name":"read_file","timestamp":"2026-06-25T12:34:56Z","error":"No such file or directory: /Users/alice/secret/project/.env"}
513 "#;
514
515 let summary = analyze_session_failure_jsonl(jsonl);
516 let rendered = format_redacted_failure_summary(&summary);
517
518 assert!(rendered.contains("missing_dependency"));
519 assert!(rendered.contains("line 3"));
520 assert!(rendered.contains("tool=read_file"));
521 assert!(rendered.contains("ts=2026-06-25T12:34:56Z"));
522 assert!(!rendered.contains("alice"));
523 assert!(!rendered.contains(".env"));
524 assert!(!rendered.contains("turn-secret-1"));
525 }
526
527 #[test]
528 fn successful_content_does_not_create_unknown_failure() {
529 let jsonl = r#"
530 {"event":"turn_started","turn_id":"turn-secret-1"}
531 {"event":"tool_call_complete","turn_id":"turn-secret-1","tool_name":"exec_shell","success":true,"content":"command output mentioning error budgets is still normal content"}
532 {"event":"turn_complete","turn_id":"turn-secret-1","status":"completed","message":"done"}
533 "#;
534
535 let summary = analyze_session_failure_jsonl(jsonl);
536
537 assert_eq!(summary.count(SessionFailureClass::Unknown), 0);
538 assert_eq!(summary.count(SessionFailureClass::CommandExit), 0);
539 assert_eq!(summary.count(SessionFailureClass::UnclosedTurn), 0);
540 assert!(
541 summary.counts.is_empty(),
542 "summary should be empty: {summary:?}"
543 );
544 }
545
546 #[test]
547 fn empty_error_field_is_not_a_failure_signal() {
548 let jsonl = r#"
549 {"event":"tool_call_complete","success":true,"error":"","stderr":""}
550 "#;
551
552 let summary = analyze_session_failure_jsonl(jsonl);
553
554 assert!(
555 summary.counts.is_empty(),
556 "empty error/stderr sentinels should not signal failure: {summary:?}"
557 );
558 }
559
560 #[test]
561 fn nested_generic_fields_do_not_shadow_source_handles() {
562 let jsonl = r#"
563 {"event":"tool_call_complete","turn_id":"turn-real","tool_name":"exec_shell","success":false,"payload":{"id":"nested-id","name":"nested-name","error":"nested error"}}
564 "#;
565
566 let summary = analyze_session_failure_jsonl(jsonl);
567 let source = summary
568 .sources
569 .values()
570 .flat_map(|sources| sources.iter())
571 .next()
572 .expect("failure source");
573
574 assert_eq!(source.tool_name.as_deref(), Some("exec_shell"));
575 assert!(
576 source
577 .turn_ref
578 .as_deref()
579 .is_some_and(|turn| turn.starts_with("<redacted:")),
580 "top-level turn id should be redacted and used: {source:?}"
581 );
582 }
583
584 #[test]
585 fn deeply_nested_error_field_is_ignored() {
586 let jsonl = r#"
587 {"event":"tool_call_complete","payload":{"a":{"b":{"c":{"d":{"e":{"error":"too deep"}}}}}}}
588 "#;
589
590 let summary = analyze_session_failure_jsonl(jsonl);
591
592 assert!(
593 summary.counts.is_empty(),
594 "deeply nested error fields should not signal failure: {summary:?}"
595 );
596 }
597
598 #[test]
599 fn authentication_failures_are_model_failures_not_sandbox_approval() {
600 let jsonl = r#"
601 {"event":"model_response","success":false,"error":"Authentication failed: invalid API key"}
602 "#;
603
604 let summary = analyze_session_failure_jsonl(jsonl);
605
606 assert_eq!(summary.count(SessionFailureClass::Model), 1);
607 assert_eq!(summary.count(SessionFailureClass::SandboxApproval), 0);
608 }
609 }
610
610 lines RUST