agent_tui_common/common/
telemetry.rs1#![expect(clippy::print_stderr, reason = "Tracing not initialized yet")]
2
3use std::io::IsTerminal;
6use std::path::PathBuf;
7
8use tracing_appender::non_blocking::WorkerGuard;
9use tracing_subscriber::EnvFilter;
10use tracing_subscriber::Layer;
11use tracing_subscriber::fmt::writer::BoxMakeWriter;
12use tracing_subscriber::layer::SubscriberExt;
13
14#[derive(Debug)]
15pub struct TelemetryGuard {
16 _guard: Option<WorkerGuard>,
17}
18
19impl TelemetryGuard {
20 fn disabled() -> Self {
21 Self { _guard: None }
22 }
23}
24
25pub fn init_tracing(default_level: &str) -> TelemetryGuard {
26 let log_format = log_format_from_env();
27 let log_stream = log_stream_from_env();
28 let subscriber: Box<dyn tracing::Subscriber + Send + Sync> = match log_file_path_from_env() {
29 Some(path) => match std::fs::OpenOptions::new()
30 .create(true)
31 .append(true)
32 .open(&path)
33 {
34 Ok(file) => {
35 let (operator_writer, operator_ansi) = stream_writer(log_stream);
36 let (diagnostic_writer, guard) = tracing_appender::non_blocking(file);
37 let operator_filter = stream_filter_from_env(default_level);
38 let diagnostic_filter = diagnostic_filter_from_env(default_level);
39
40 let subscriber: Box<dyn tracing::Subscriber + Send + Sync> = match log_format {
41 LogFormat::Json => Box::new(
42 tracing_subscriber::registry()
43 .with(
44 tracing_subscriber::fmt::layer()
45 .with_target(false)
46 .with_thread_ids(true)
47 .with_thread_names(true)
48 .with_ansi(operator_ansi)
49 .with_writer(operator_writer)
50 .with_filter(operator_filter),
51 )
52 .with(
53 tracing_subscriber::fmt::layer()
54 .with_target(true)
55 .with_thread_ids(false)
56 .with_thread_names(false)
57 .with_ansi(false)
58 .json()
59 .with_writer(diagnostic_writer)
60 .with_filter(diagnostic_filter),
61 ),
62 ),
63 LogFormat::Text => Box::new(
64 tracing_subscriber::registry()
65 .with(
66 tracing_subscriber::fmt::layer()
67 .with_target(false)
68 .with_thread_ids(true)
69 .with_thread_names(true)
70 .with_ansi(operator_ansi)
71 .with_writer(operator_writer)
72 .with_filter(operator_filter),
73 )
74 .with(
75 tracing_subscriber::fmt::layer()
76 .with_target(true)
77 .with_thread_ids(true)
78 .with_thread_names(true)
79 .with_ansi(false)
80 .with_writer(diagnostic_writer)
81 .with_filter(diagnostic_filter),
82 ),
83 ),
84 };
85
86 if tracing::subscriber::set_global_default(subscriber).is_err() {
87 return TelemetryGuard::disabled();
88 }
89
90 return TelemetryGuard {
91 _guard: Some(guard),
92 };
93 }
94 Err(err) => {
95 eprintln!(
96 "Warning: failed to open log file {}: {}",
97 path.display(),
98 err
99 );
100 single_sink_subscriber(
101 log_stream,
102 log_format,
103 diagnostic_filter_from_env(default_level),
104 )
105 }
106 },
107 None => single_sink_subscriber(
108 log_stream,
109 log_format,
110 diagnostic_filter_from_env(default_level),
111 ),
112 };
113
114 if tracing::subscriber::set_global_default(subscriber).is_err() {
115 return TelemetryGuard::disabled();
116 }
117
118 TelemetryGuard::disabled()
119}
120
121fn single_sink_subscriber(
122 log_stream: LogStream,
123 log_format: LogFormat,
124 env_filter: EnvFilter,
125) -> Box<dyn tracing::Subscriber + Send + Sync> {
126 let (writer, ansi) = stream_writer(log_stream);
127
128 match log_format {
129 LogFormat::Json => Box::new(
130 tracing_subscriber::fmt()
131 .with_env_filter(env_filter)
132 .with_target(false)
133 .with_thread_ids(false)
134 .with_thread_names(false)
135 .with_ansi(false)
136 .json()
137 .with_writer(writer)
138 .finish(),
139 ),
140 LogFormat::Text => Box::new(
141 tracing_subscriber::fmt()
142 .with_env_filter(env_filter)
143 .with_target(false)
144 .with_thread_ids(true)
145 .with_thread_names(true)
146 .with_ansi(ansi)
147 .with_writer(writer)
148 .finish(),
149 ),
150 }
151}
152
153fn stream_writer(log_stream: LogStream) -> (BoxMakeWriter, bool) {
154 match log_stream {
155 LogStream::Stdout => (
156 BoxMakeWriter::new(std::io::stdout),
157 std::io::stdout().is_terminal(),
158 ),
159 LogStream::Stderr => (
160 BoxMakeWriter::new(std::io::stderr),
161 std::io::stderr().is_terminal(),
162 ),
163 }
164}
165
166fn log_file_path_from_env() -> Option<PathBuf> {
167 std::env::var("AGENT_TUI_LOG").ok().map(PathBuf::from)
168}
169
170fn diagnostic_filter_from_env(default_level: &str) -> EnvFilter {
171 env_filter_from_env(&["AGENT_TUI_LOG_FILTER", "RUST_LOG"], default_level)
172}
173
174fn stream_filter_from_env(default_level: &str) -> EnvFilter {
175 env_filter_from_env(
176 &[
177 "AGENT_TUI_LOG_STREAM_FILTER",
178 "AGENT_TUI_LOG_FILTER",
179 "RUST_LOG",
180 ],
181 default_level,
182 )
183}
184
185fn env_filter_from_env(keys: &[&str], default_level: &str) -> EnvFilter {
186 for key in keys {
187 let Ok(value) = std::env::var(key) else {
188 continue;
189 };
190 let trimmed = value.trim();
191 if trimmed.is_empty() {
192 continue;
193 }
194
195 match EnvFilter::try_new(trimmed) {
196 Ok(filter) => return filter,
197 Err(err) => eprintln!("Warning: invalid {key} filter '{trimmed}': {err}"),
198 }
199 }
200
201 EnvFilter::new(default_level)
202}
203
204#[derive(Clone, Copy, Debug, PartialEq, Eq)]
205enum LogFormat {
206 Text,
207 Json,
208}
209
210#[derive(Clone, Copy, Debug, PartialEq, Eq)]
211enum LogStream {
212 Stderr,
213 Stdout,
214}
215
216fn log_format_from_env() -> LogFormat {
217 match std::env::var("AGENT_TUI_LOG_FORMAT")
218 .ok()
219 .as_deref()
220 .map(str::trim)
221 .map(str::to_lowercase)
222 .as_deref()
223 {
224 Some("json") => LogFormat::Json,
225 _ => LogFormat::Text,
226 }
227}
228
229fn log_stream_from_env() -> LogStream {
230 match std::env::var("AGENT_TUI_LOG_STREAM")
231 .ok()
232 .as_deref()
233 .map(str::trim)
234 .map(str::to_lowercase)
235 .as_deref()
236 {
237 Some("stdout") => LogStream::Stdout,
238 _ => LogStream::Stderr,
239 }
240}
241
242#[cfg(test)]
243#[path = "telemetry_tests.rs"]
244mod tests;