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