pigeon/observability/
mod.rs1pub mod metrics;
2pub(crate) mod panic;
3pub(crate) mod resources;
4pub(crate) mod transcript;
5
6use std::path::{Path, PathBuf};
7
8use tracing_error::ErrorLayer;
9use tracing_subscriber::layer::SubscriberExt;
10use tracing_subscriber::util::SubscriberInitExt;
11use tracing_subscriber::{EnvFilter, fmt};
12
13pub const LOG_DIR_ENV_VAR: &str = "PIGEON_LOG_DIR";
18
19const LOG_FILE_NAME: &str = "pigeon.jsonl";
20
21fn default_log_dir() -> Result<PathBuf, String> {
26 if let Ok(dir) = std::env::var(LOG_DIR_ENV_VAR) {
27 return Ok(PathBuf::from(dir));
28 }
29 let project_dirs = directories::ProjectDirs::from("", "", "pigeon")
30 .ok_or("could not determine the data directory for this platform")?;
31 Ok(project_dirs.data_local_dir().join("logs"))
32}
33
34fn resolve_log_path(log_file: Option<&Path>) -> Result<(PathBuf, String), String> {
39 match log_file {
40 Some(path) => {
41 let dir = path
42 .parent()
43 .filter(|parent| !parent.as_os_str().is_empty())
44 .map(Path::to_path_buf)
45 .unwrap_or_else(|| PathBuf::from("."));
46 let file_name = path
47 .file_name()
48 .map(|name| name.to_string_lossy().into_owned())
49 .unwrap_or_else(|| LOG_FILE_NAME.to_string());
50 Ok((dir, file_name))
51 }
52 None => Ok((default_log_dir()?, LOG_FILE_NAME.to_string())),
53 }
54}
55
56pub fn init(
65 log_level: Option<&str>,
66 log_file: Option<&Path>,
67) -> Result<tracing_appender::non_blocking::WorkerGuard, String> {
68 let (dir, file_name) = resolve_log_path(log_file)?;
69 std::fs::create_dir_all(&dir)
70 .map_err(|err| format!("failed to create {}: {err}", dir.display()))?;
71 let _ = LOG_FILE_PATH.set(dir.join(&file_name));
72
73 let appender = tracing_appender::rolling::never(&dir, &file_name);
74 let (writer, guard) = tracing_appender::non_blocking(appender);
75
76 let directive = log_level
77 .map(str::to_string)
78 .or_else(|| std::env::var("RUST_LOG").ok())
79 .unwrap_or_else(|| "warn,pigeon=info".to_string());
80 let filter = EnvFilter::try_new(&directive)
81 .map_err(|err| format!("invalid log filter '{directive}': {err}"))?;
82
83 let json_layer = fmt::layer()
84 .json()
85 .with_writer(writer)
86 .with_span_events(fmt::format::FmtSpan::CLOSE);
87
88 tracing_subscriber::registry()
89 .with(filter)
90 .with(json_layer)
91 .with(ErrorLayer::default())
92 .init();
93
94 Ok(guard)
95}
96
97pub fn install_panic_hook() {
110 panic::install_panic_hook();
111}
112
113pub(crate) fn instance() -> &'static str {
118 static INSTANCE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
119 INSTANCE.get_or_init(|| sysinfo::System::host_name().unwrap_or_else(|| "unknown".to_string()))
120}
121
122static LOG_FILE_PATH: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
126
127pub(crate) fn log_file_path() -> Result<PathBuf, String> {
128 LOG_FILE_PATH
129 .get()
130 .cloned()
131 .ok_or_else(|| "log file path not resolved -- observability::init must run first".into())
132}
133
134pub(crate) fn run_instrumented(command_name: &'static str, f: impl FnOnce() -> i32) -> i32 {
135 let span = tracing::info_span!("command", command = command_name, instance = instance());
141 let _guard = span.enter();
142 tracing::info!("command started");
145 let start = std::time::Instant::now();
146 let exit_code = f();
147 let elapsed = start.elapsed();
148 tracing::info!(
149 exit_code,
150 elapsed_ms = elapsed.as_millis() as u64,
151 "command finished"
152 );
153 let status = if exit_code == 0 { "success" } else { "failure" };
154 ::metrics::histogram!(
155 "pigeon_command_duration_seconds",
156 "command" => command_name,
157 "instance" => instance(),
158 )
159 .record(elapsed.as_secs_f64());
160 ::metrics::counter!(
161 "pigeon_command_runs_total",
162 "command" => command_name,
163 "status" => status,
164 "instance" => instance(),
165 )
166 .increment(1);
167 exit_code
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 #[test]
175 fn resolve_log_path_splits_a_given_file_into_dir_and_name() {
176 let (dir, file_name) = resolve_log_path(Some(Path::new("/tmp/foo/out.jsonl"))).unwrap();
177 assert_eq!(dir, PathBuf::from("/tmp/foo"));
178 assert_eq!(file_name, "out.jsonl");
179 }
180
181 #[test]
182 fn resolve_log_path_falls_back_to_the_default_dir_and_name_when_omitted() {
183 let dir = tempfile::tempdir().unwrap();
184 unsafe {
187 std::env::set_var(LOG_DIR_ENV_VAR, dir.path());
188 }
189 let (resolved_dir, file_name) = resolve_log_path(None).unwrap();
190 unsafe {
191 std::env::remove_var(LOG_DIR_ENV_VAR);
192 }
193 assert_eq!(resolved_dir, dir.path());
194 assert_eq!(file_name, LOG_FILE_NAME);
195 }
196
197 #[test]
198 fn run_instrumented_returns_the_wrapped_closure_s_exit_code() {
199 assert_eq!(run_instrumented("test.command", || 0), 0);
200 assert_eq!(run_instrumented("test.command", || 1), 1);
201 }
202}