Skip to main content

agent_orchestrator/observability/
init.rs

1use crate::config::{LogLevel, LoggingConfig, LoggingFormat, OrchestratorConfig};
2use crate::config_ext::OrchestratorConfigExt as _;
3use anyhow::{Context, Result};
4use std::io::IsTerminal;
5use std::path::{Path, PathBuf};
6use tracing_subscriber::Layer;
7use tracing_subscriber::filter::LevelFilter;
8use tracing_subscriber::layer::SubscriberExt;
9use tracing_subscriber::util::SubscriberInitExt;
10
11#[derive(Debug, Clone, Copy, Default)]
12/// CLI flags that override logging configuration resolved from runtime policy.
13pub struct CliLoggingOverrides {
14    /// Forces at least debug-level logging when set.
15    pub verbose: bool,
16    /// Explicit log level override.
17    pub level: Option<LogLevel>,
18    /// Explicit console log format override.
19    pub format: Option<LoggingFormat>,
20}
21
22#[derive(Debug, Clone)]
23/// Fully resolved logging configuration used to initialize tracing subscribers.
24pub struct ResolvedLoggingConfig {
25    /// Effective minimum log level.
26    pub level: LogLevel,
27    /// Whether console logging is enabled.
28    pub console_enabled: bool,
29    /// Console log output format.
30    pub console_format: LoggingFormat,
31    /// Whether ANSI styling is enabled for console output.
32    pub console_ansi: bool,
33    /// Whether file logging is enabled.
34    pub file_enabled: bool,
35    /// File log output format.
36    pub file_format: LoggingFormat,
37    /// Directory where rolling log files are written.
38    pub file_dir: PathBuf,
39}
40
41#[derive(Debug, Default)]
42/// Holds background logging guards that must live for the lifetime of observability.
43pub struct ObservabilityGuard {
44    _file_guard: Option<tracing_appender::non_blocking::WorkerGuard>,
45}
46
47/// Initializes tracing subscribers using config and CLI/environment overrides.
48pub fn init_observability(
49    data_dir: &Path,
50    config: Option<&OrchestratorConfig>,
51    overrides: CliLoggingOverrides,
52) -> Result<ObservabilityGuard> {
53    let resolved = resolve_logging_config(data_dir, config, overrides);
54    let level_filter = LevelFilter::from_level(resolved.level.as_tracing_level());
55    let mut file_guard = None;
56
57    if resolved.file_enabled {
58        std::fs::create_dir_all(&resolved.file_dir).with_context(|| {
59            format!(
60                "failed to create system log directory {}",
61                resolved.file_dir.display()
62            )
63        })?;
64        let appender = tracing_appender::rolling::daily(&resolved.file_dir, "orchestrator.log");
65        let (non_blocking, guard) = tracing_appender::non_blocking(appender);
66        file_guard = Some(guard);
67
68        match (
69            resolved.console_enabled,
70            resolved.console_format,
71            resolved.file_format,
72        ) {
73            (true, LoggingFormat::Pretty, LoggingFormat::Pretty) => tracing_subscriber::registry()
74                .with(
75                    tracing_subscriber::fmt::layer()
76                        .compact()
77                        .with_writer(std::io::stderr)
78                        .with_ansi(resolved.console_ansi)
79                        .with_filter(level_filter),
80                )
81                .with(
82                    tracing_subscriber::fmt::layer()
83                        .compact()
84                        .with_ansi(false)
85                        .with_writer(non_blocking)
86                        .with_filter(level_filter),
87                )
88                .try_init()
89                .context("failed to initialize structured logging")?,
90            (true, LoggingFormat::Pretty, LoggingFormat::Json) => tracing_subscriber::registry()
91                .with(
92                    tracing_subscriber::fmt::layer()
93                        .compact()
94                        .with_writer(std::io::stderr)
95                        .with_ansi(resolved.console_ansi)
96                        .with_filter(level_filter),
97                )
98                .with(
99                    tracing_subscriber::fmt::layer()
100                        .json()
101                        .with_writer(non_blocking)
102                        .with_filter(level_filter),
103                )
104                .try_init()
105                .context("failed to initialize structured logging")?,
106            (true, LoggingFormat::Json, LoggingFormat::Pretty) => tracing_subscriber::registry()
107                .with(
108                    tracing_subscriber::fmt::layer()
109                        .json()
110                        .with_writer(std::io::stderr)
111                        .with_filter(level_filter),
112                )
113                .with(
114                    tracing_subscriber::fmt::layer()
115                        .compact()
116                        .with_ansi(false)
117                        .with_writer(non_blocking)
118                        .with_filter(level_filter),
119                )
120                .try_init()
121                .context("failed to initialize structured logging")?,
122            (true, LoggingFormat::Json, LoggingFormat::Json) => tracing_subscriber::registry()
123                .with(
124                    tracing_subscriber::fmt::layer()
125                        .json()
126                        .with_writer(std::io::stderr)
127                        .with_filter(level_filter),
128                )
129                .with(
130                    tracing_subscriber::fmt::layer()
131                        .json()
132                        .with_writer(non_blocking)
133                        .with_filter(level_filter),
134                )
135                .try_init()
136                .context("failed to initialize structured logging")?,
137            (false, _, LoggingFormat::Pretty) => tracing_subscriber::registry()
138                .with(
139                    tracing_subscriber::fmt::layer()
140                        .compact()
141                        .with_ansi(false)
142                        .with_writer(non_blocking)
143                        .with_filter(level_filter),
144                )
145                .try_init()
146                .context("failed to initialize structured logging")?,
147            (false, _, LoggingFormat::Json) => tracing_subscriber::registry()
148                .with(
149                    tracing_subscriber::fmt::layer()
150                        .json()
151                        .with_writer(non_blocking)
152                        .with_filter(level_filter),
153                )
154                .try_init()
155                .context("failed to initialize structured logging")?,
156        }
157    } else if resolved.console_enabled {
158        match resolved.console_format {
159            LoggingFormat::Pretty => tracing_subscriber::registry()
160                .with(
161                    tracing_subscriber::fmt::layer()
162                        .compact()
163                        .with_writer(std::io::stderr)
164                        .with_ansi(resolved.console_ansi)
165                        .with_filter(level_filter),
166                )
167                .try_init()
168                .context("failed to initialize structured logging")?,
169            LoggingFormat::Json => tracing_subscriber::registry()
170                .with(
171                    tracing_subscriber::fmt::layer()
172                        .json()
173                        .with_writer(std::io::stderr)
174                        .with_filter(level_filter),
175                )
176                .try_init()
177                .context("failed to initialize structured logging")?,
178        }
179    }
180
181    Ok(ObservabilityGuard {
182        _file_guard: file_guard,
183    })
184}
185
186/// Resolves the effective logging configuration before subscriber initialization.
187pub fn resolve_logging_config(
188    data_dir: &Path,
189    config: Option<&OrchestratorConfig>,
190    overrides: CliLoggingOverrides,
191) -> ResolvedLoggingConfig {
192    let logging = config
193        .map(|cfg| cfg.runtime_policy().observability.logging.clone())
194        .unwrap_or_default();
195
196    let mut level = logging.level;
197    if overrides.verbose {
198        level = level.max(LogLevel::Debug);
199    }
200    if let Some(cli_level) = overrides.level {
201        level = cli_level;
202    }
203    if let Some(env_level) = read_env_level() {
204        level = env_level;
205    }
206
207    let mut console_format = logging.console.format;
208    if let Some(cli_format) = overrides.format {
209        console_format = cli_format;
210    }
211    if let Some(env_format) = read_env_format() {
212        console_format = env_format;
213    }
214
215    let file_dir = resolve_log_dir(data_dir, &logging);
216
217    ResolvedLoggingConfig {
218        level,
219        console_enabled: logging.console.enabled,
220        console_format,
221        console_ansi: logging.console.ansi && std::io::stderr().is_terminal(),
222        file_enabled: logging.file.enabled,
223        file_format: logging.file.format,
224        file_dir,
225    }
226}
227
228fn read_env_level() -> Option<LogLevel> {
229    std::env::var("ORCHESTRATOR_LOG")
230        .ok()
231        .or_else(|| std::env::var("RUST_LOG").ok())
232        .and_then(|value| value.split(',').next().and_then(LogLevel::parse))
233}
234
235fn read_env_format() -> Option<LoggingFormat> {
236    std::env::var("ORCHESTRATOR_LOG_FORMAT")
237        .ok()
238        .as_deref()
239        .and_then(LoggingFormat::parse)
240}
241
242fn resolve_log_dir(data_dir: &Path, logging: &LoggingConfig) -> PathBuf {
243    let configured = Path::new(&logging.file.directory);
244    if configured.is_absolute() {
245        configured.to_path_buf()
246    } else {
247        data_dir.join(configured)
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use crate::config::LoggingFormat;
255
256    fn sample_config() -> OrchestratorConfig {
257        OrchestratorConfig::default()
258    }
259
260    #[test]
261    fn verbose_raises_default_level_to_debug() {
262        let cfg = sample_config();
263        let resolved = resolve_logging_config(
264            Path::new("/tmp/app"),
265            Some(&cfg),
266            CliLoggingOverrides {
267                verbose: true,
268                ..CliLoggingOverrides::default()
269            },
270        );
271        assert_eq!(resolved.level, LogLevel::Debug);
272    }
273
274    #[test]
275    fn cli_level_overrides_config() {
276        let cfg = sample_config();
277        let resolved = resolve_logging_config(
278            Path::new("/tmp/app"),
279            Some(&cfg),
280            CliLoggingOverrides {
281                level: Some(LogLevel::Trace),
282                ..CliLoggingOverrides::default()
283            },
284        );
285        assert_eq!(resolved.level, LogLevel::Trace);
286    }
287
288    #[test]
289    fn cli_format_overrides_console_format() {
290        let cfg = sample_config();
291        let resolved = resolve_logging_config(
292            Path::new("/tmp/app"),
293            Some(&cfg),
294            CliLoggingOverrides {
295                format: Some(LoggingFormat::Json),
296                ..CliLoggingOverrides::default()
297            },
298        );
299        assert_eq!(resolved.console_format, LoggingFormat::Json);
300    }
301
302    #[test]
303    fn relative_file_path_is_resolved_from_app_root() {
304        let cfg = sample_config();
305        let resolved = resolve_logging_config(
306            Path::new("/tmp/app"),
307            Some(&cfg),
308            CliLoggingOverrides::default(),
309        );
310        assert_eq!(resolved.file_dir, Path::new("/tmp/app/logs/system"));
311    }
312}