sqlite_graphrag/tracing_init.rs
1//! Centralized tracing subscriber initialization.
2//!
3//! Configures the global subscriber with JSON or pretty format,
4//! installs the panic hook and the log-to-tracing bridge.
5//!
6//! GAP-SG-99 / GAP-SG-108: when XDG `log.to_file` is truthy, also write a
7//! rolling file under the cache dir via `tracing-appender`. The returned
8//! [`WorkerGuard`](tracing_appender::non_blocking::WorkerGuard) MUST be held
9//! until process exit so buffers flush.
10
11use std::sync::Mutex;
12
13use tracing_appender::non_blocking::WorkerGuard;
14use tracing_subscriber::EnvFilter;
15
16/// Process-wide guard so the non-blocking appender flushes on drop.
17///
18/// Held in a static so the signal handler (and early `process::exit` paths)
19/// can still flush without access to `main` stack frames. Prefer
20/// [`flush_tracing`] over bare `process::exit` without a prior flush
21/// (tracing-appender WorkerGuard docs: abrupt exit may lose buffered lines).
22static FILE_GUARD: Mutex<Option<WorkerGuard>> = Mutex::new(None);
23
24/// Initializes the global tracing subscriber, panic hook, and log bridge.
25///
26/// Must be called exactly once, before any tracing events are emitted.
27/// After this call, panics on any thread produce `tracing::error!` events,
28/// and `log` crate events from dependencies (refinery, ureq, ort) are
29/// forwarded to the tracing subscriber.
30///
31/// Stderr is always the agent-facing diagnostic stream. File logging is
32/// optional via XDG `log.to_file` (GAP-SG-108).
33pub fn init_tracing(log_level: &str, log_format: &str) {
34 // TR02: the log→tracing bridge is activated automatically by
35 // tracing-subscriber's built-in `tracing-log` feature (default).
36 // Calling LogTracer::init() separately would conflict with the
37 // global logger that tracing-subscriber installs via .init().
38 let use_ansi = crate::terminal::should_use_ansi();
39 let to_file = crate::config::get_setting("log.to_file")
40 .ok()
41 .flatten()
42 .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
43 .unwrap_or(false);
44
45 let file_writer = if to_file {
46 let dir = crate::paths::cache_dir()
47 .unwrap_or_else(|_| std::env::temp_dir().join("sqlite-graphrag"));
48 let log_dir = dir.join("logs");
49 let _ = std::fs::create_dir_all(&log_dir);
50 let rotation = crate::config::get_setting("log.rotation")
51 .ok()
52 .flatten()
53 .unwrap_or_else(|| "daily".into());
54 let appender = match rotation.as_str() {
55 "hourly" => tracing_appender::rolling::hourly(&log_dir, "sqlite-graphrag.log"),
56 "never" => tracing_appender::rolling::never(&log_dir, "sqlite-graphrag.log"),
57 _ => tracing_appender::rolling::daily(&log_dir, "sqlite-graphrag.log"),
58 };
59 let (non_blocking, guard) = tracing_appender::non_blocking(appender);
60 if let Ok(mut slot) = FILE_GUARD.lock() {
61 *slot = Some(guard);
62 }
63 Some(non_blocking)
64 } else {
65 None
66 };
67
68 // Always write diagnostics to stderr (agent-native). Optionally tee to file.
69 if let Some(nb) = file_writer {
70 use tracing_subscriber::fmt::writer::MakeWriterExt;
71 let writer = std::io::stderr.and(nb);
72 if log_format == "json" {
73 tracing_subscriber::fmt()
74 .json()
75 .with_ansi(false)
76 .with_thread_ids(true)
77 .with_thread_names(true)
78 .with_env_filter(EnvFilter::new(log_level))
79 .with_writer(writer)
80 .init();
81 } else {
82 tracing_subscriber::fmt()
83 .with_ansi(use_ansi)
84 .with_env_filter(EnvFilter::new(log_level))
85 .with_writer(writer)
86 .init();
87 }
88 } else if log_format == "json" {
89 tracing_subscriber::fmt()
90 .json()
91 .with_ansi(false)
92 .with_thread_ids(true)
93 .with_thread_names(true)
94 .with_env_filter(EnvFilter::new(log_level))
95 .with_writer(std::io::stderr)
96 .init();
97 } else {
98 tracing_subscriber::fmt()
99 .with_ansi(use_ansi)
100 .with_env_filter(EnvFilter::new(log_level))
101 .with_writer(std::io::stderr)
102 .init();
103 }
104
105 // TR05: confirm effective filter after init
106 tracing::debug!(
107 target: "logging",
108 filter = %log_level,
109 format = %log_format,
110 ansi = use_ansi,
111 to_file,
112 "tracing subscriber initialized (local only; no remote telemetry)"
113 );
114
115 // TR01 (v1.0.80, A1/G2): panic hook emits a structured tracing::error!
116 // and DELIBERATELY DOES NOT call the previous hook. The default Rust
117 // panic hook prints the same payload + location to stderr; combined
118 // with the tracing event below, that produces a double-trace (one
119 // structured event in JSON or pretty, one unstructured dump). We
120 // prefer the structured single-trace: the tracing event carries the
121 // same payload and location fields and is captured by the global
122 // subscriber. Test runs still fail on panic because Rust aborts the
123 // process regardless of which hook is installed.
124 std::panic::set_hook(Box::new(|info| {
125 let payload = info
126 .payload()
127 .downcast_ref::<&str>()
128 .copied()
129 .or_else(|| info.payload().downcast_ref::<String>().map(|s| s.as_str()))
130 .unwrap_or("<non-string panic>");
131 let location = info
132 .location()
133 .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()));
134 tracing::error!(
135 target: "panic",
136 message = %payload,
137 location = location.as_deref().unwrap_or("unknown"),
138 "thread panicked"
139 );
140 }));
141}
142
143/// Drop the file-appender worker so buffered events flush (GAP-SG-99).
144///
145/// Safe to call multiple times. Invoked from the second-signal path before
146/// `process::exit` and at the end of a normal `main` return when possible.
147pub fn flush_tracing() {
148 if let Ok(mut slot) = FILE_GUARD.lock() {
149 *slot = None;
150 }
151}