Skip to main content

pigeon/observability/
mod.rs

1pub 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
13/// Overrides the default `<data-dir>/logs` location for the durable JSONL log
14/// file this module writes -- mirrors `commands::keyring::store::Store`'s
15/// `PIGEON_CONFIG_DIR` precedent, for the same reason: isolating tests (and
16/// manual use) from the real per-OS data directory (ADR-0073).
17pub const LOG_DIR_ENV_VAR: &str = "PIGEON_LOG_DIR";
18
19const LOG_FILE_NAME: &str = "pigeon.jsonl";
20
21/// `<log-dir>`, resolved when `--log-file` isn't given: `$PIGEON_LOG_DIR` if
22/// set, otherwise the OS-conventional local-data directory for `pigeon`.
23/// Deliberately not `config_dir()` (that's `keyring.toml`'s concern) or
24/// `cache_dir()` (this is a review artifact, not disposable).
25fn 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
34/// Splits `log_file` (when given) into its parent directory and file name,
35/// falling back to `default_log_dir()`/`pigeon.jsonl` otherwise --
36/// `tracing_appender::rolling::never` takes a directory and a file name as
37/// two separate arguments, not one path.
38fn 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
56/// Initializes the global tracing subscriber: a JSON-formatted, non-blocking
57/// file layer (the durable "review this run later" artifact, ADR-0073) plus
58/// an `ErrorLayer` (so `tracing_error::SpanTrace::capture()` works from
59/// anywhere spans are active). Deliberately registers no console layer --
60/// ADR-0013/0014/0015's `indicatif`/`MultiProgress` progress bars must never
61/// share stdout/stderr with a second, independent writer. Returns a
62/// `WorkerGuard` that must be kept alive for the whole process; dropping it
63/// early silently truncates buffered log lines.
64pub 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
97/// Runs `f` (one full command dispatch) inside a `command`-named tracing
98/// span carrying `command_name`, logging its elapsed time and exit code on
99/// completion (ADR-0073). Sound with a plain `span.enter()` guard here
100/// specifically because every command dispatch in this crate is fully
101/// synchronous end to end -- each async job builds and `block_on`s its own
102/// tokio runtime internally, so from this function's frame the whole call is
103/// one opaque synchronous closure with zero `.await` in it. Anywhere
104/// `.await` is actually present, use `#[tracing::instrument]`/
105/// `.instrument(span)` instead -- never a manual `span.enter()` guard held
106/// across an await point.
107/// Re-exports `panic::install_panic_hook` for `main.rs`, which lives in a
108/// separate (binary) crate and so can't reach a `pub(crate)` item directly.
109pub fn install_panic_hook() {
110    panic::install_panic_hook();
111}
112
113/// This process's hostname, resolved once and cached for the rest of the
114/// process's life (ADR-0097) -- reused for both the `command` span's
115/// `instance` field and every custom metric's `instance` label, so a run
116/// touching tens of thousands of items never re-queries `sysinfo` per item.
117pub(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
122/// The durable JSONL log file's resolved path, cached by `init()` (ADR-0100)
123/// -- lets a job upload the shared `pigeon.jsonl` to a report bucket without
124/// recomputing `resolve_log_path`'s `--log-file`-vs-default logic itself.
125static 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    // Every event in this run inherits both fields via `spans[]` (ADR-0093)
136    // -- "what job" (`command`) and "what instance" (`instance`), closing
137    // the gap a shared, multi-instance Cockpit store otherwise has no way
138    // to disambiguate on the logs side (Prometheus's scrape-level `job`/
139    // `instance` labels have no log-side equivalent).
140    let span = tracing::info_span!("command", command = command_name, instance = instance());
141    let _guard = span.enter();
142    // Brackets "command finished" below (ADR-0097) -- previously a job that
143    // crashed or hung left no trace that it had even started.
144    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        // SAFETY: tests run single-threaded within this process for this env var
185        // (no other test reads/writes PIGEON_LOG_DIR concurrently).
186        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}