agent_orchestrator/observability/
init.rs1use 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)]
12pub struct CliLoggingOverrides {
14 pub verbose: bool,
16 pub level: Option<LogLevel>,
18 pub format: Option<LoggingFormat>,
20}
21
22#[derive(Debug, Clone)]
23pub struct ResolvedLoggingConfig {
25 pub level: LogLevel,
27 pub console_enabled: bool,
29 pub console_format: LoggingFormat,
31 pub console_ansi: bool,
33 pub file_enabled: bool,
35 pub file_format: LoggingFormat,
37 pub file_dir: PathBuf,
39}
40
41#[derive(Debug, Default)]
42pub struct ObservabilityGuard {
44 _file_guard: Option<tracing_appender::non_blocking::WorkerGuard>,
45}
46
47pub 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
186pub 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}