Skip to main content

agent_tui_common/common/
telemetry.rs

1#![expect(clippy::print_stderr, reason = "Tracing not initialized yet")]
2
3//! Telemetry and tracing setup.
4
5use 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;