Skip to main content

rmcp_server_kit/
observability.rs

1use std::{path::Path, sync::Arc};
2
3use tracing_subscriber::{
4    EnvFilter, Layer as _,
5    fmt::time::FormatTime,
6    layer::SubscriberExt,
7    util::{SubscriberInitExt, TryInitError},
8};
9
10use crate::config::ObservabilityConfig;
11
12/// Timestamp formatter that emits local time via `chrono::Local`.
13#[derive(Clone, Copy)]
14struct LocalTime;
15
16impl FormatTime for LocalTime {
17    fn format_time(&self, w: &mut tracing_subscriber::fmt::format::Writer<'_>) -> std::fmt::Result {
18        write!(
19            w,
20            "{}",
21            chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%.3f%:z")
22        )
23    }
24}
25
26/// Initialize structured logging from an [`ObservabilityConfig`].
27///
28/// Respects `RUST_LOG` env var if set; otherwise uses `config.log_level`.
29/// When `log_format` is `"json"`, emits machine-readable JSON lines.
30/// When `audit_log_path` is set, appends an additional JSON log file
31/// at INFO level for audit trail purposes.
32///
33/// # Errors
34///
35/// Returns [`TryInitError`] if a global tracing subscriber has already
36/// been installed (e.g. by a previous call to this function or
37/// [`init_tracing`]). Callers that want to tolerate double-initialization
38/// (such as test harnesses) can ignore the error.
39pub fn init_tracing_from_config(config: &ObservabilityConfig) -> Result<(), TryInitError> {
40    let filter =
41        EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&config.log_level));
42
43    let (audit_writer, audit_warnings) = config
44        .audit_log_path
45        .as_ref()
46        .map_or((None, Vec::new()), |p| open_audit_file(p));
47
48    // "pretty" and "text" are aliases for human-readable output.
49    let result = if config.log_format == "json" {
50        let subscriber = tracing_subscriber::registry().with(filter).with(
51            tracing_subscriber::fmt::layer()
52                .json()
53                .with_timer(LocalTime)
54                .with_writer(std::io::stderr),
55        );
56        init_with_optional_audit(subscriber, audit_writer)
57    } else {
58        let subscriber = tracing_subscriber::registry().with(filter).with(
59            tracing_subscriber::fmt::layer()
60                .with_timer(LocalTime)
61                .with_writer(std::io::stderr),
62        );
63        init_with_optional_audit(subscriber, audit_writer)
64    };
65
66    if result.is_ok() {
67        for warning in audit_warnings {
68            tracing::warn!(warning = %warning, "audit logging initialization warning");
69        }
70    }
71
72    result
73}
74
75/// Attach an optional audit JSON log layer and initialize the subscriber.
76///
77/// Extracted to avoid duplicating the audit layer construction in both
78/// the JSON and pretty format branches of [`init_tracing_from_config`].
79///
80/// Uses [`SubscriberInitExt::try_init`] so that a previously-installed
81/// global subscriber yields [`TryInitError`] rather than panicking.
82fn init_with_optional_audit<S>(
83    subscriber: S,
84    audit_writer: Option<AuditFile>,
85) -> Result<(), TryInitError>
86where
87    S: tracing::Subscriber
88        + for<'span> tracing_subscriber::registry::LookupSpan<'span>
89        + Send
90        + Sync
91        + 'static,
92{
93    if let Some(writer) = audit_writer {
94        subscriber
95            .with(
96                tracing_subscriber::fmt::layer()
97                    .json()
98                    .with_timer(LocalTime)
99                    .with_writer(writer)
100                    .with_filter(tracing_subscriber::filter::LevelFilter::INFO),
101            )
102            .try_init()
103    } else {
104        subscriber.try_init()
105    }
106}
107
108/// Initialize structured logging with a simple filter string.
109///
110/// Convenience function for callers that don't use [`ObservabilityConfig`].
111/// Respects `RUST_LOG` env var. Falls back to `default_filter` (e.g. `"info"`).
112///
113/// # Errors
114///
115/// Returns [`TryInitError`] if a global tracing subscriber has already
116/// been installed. This makes the function safe to call repeatedly from
117/// tests or embedders without panicking.
118pub fn init_tracing(default_filter: &str) -> Result<(), TryInitError> {
119    tracing_subscriber::registry()
120        .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_filter)))
121        .with(
122            tracing_subscriber::fmt::layer()
123                .with_timer(LocalTime)
124                .with_writer(std::io::stderr),
125        )
126        .try_init()
127}
128
129/// Newtype wrapper around a shared file handle for audit logging.
130///
131/// Implements `MakeWriter` so it can be used with `tracing_subscriber::fmt`.
132#[derive(Clone)]
133struct AuditFile(Arc<std::fs::File>);
134
135impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for AuditFile {
136    type Writer = AuditFileWriter;
137
138    fn make_writer(&'a self) -> Self::Writer {
139        AuditFileWriter(Arc::clone(&self.0))
140    }
141}
142
143/// A thin wrapper that implements `io::Write` by delegating to the inner `File`.
144struct AuditFileWriter(Arc<std::fs::File>);
145
146impl std::io::Write for AuditFileWriter {
147    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
148        std::io::Write::write(&mut &*self.0, buf)
149    }
150
151    fn flush(&mut self) -> std::io::Result<()> {
152        std::io::Write::flush(&mut &*self.0)
153    }
154}
155
156/// Open the audit log file for appending.
157///
158/// Returns an optional writer and any warnings encountered while preparing it.
159///
160/// # Log rotation
161///
162/// The writer opens the file in append mode and holds a long-lived handle
163/// for the lifetime of the process. There is **no** built-in rotation, no
164/// SIGHUP-style reopen, and no compression. Operators are expected to use
165/// an external rotator such as `logrotate` (Linux) or `newsyslog` (BSD /
166/// macOS) configured with `copytruncate` (or equivalent) so the inode this
167/// handle points at is preserved across rotations. If the rotator instead
168/// renames + recreates the file, this writer will keep writing to the
169/// renamed (rotated) inode until the process restarts.
170fn open_audit_file(path: &Path) -> (Option<AuditFile>, Vec<String>) {
171    let mut warnings = Vec::new();
172
173    // Ensure parent directory exists.
174    if let Some(parent) = path.parent()
175        && !parent.exists()
176        && let Err(e) = std::fs::create_dir_all(parent)
177    {
178        warnings.push(format!(
179            "failed to create audit log directory {}: {e}",
180            path.display()
181        ));
182        return (None, warnings);
183    }
184
185    match std::fs::OpenOptions::new()
186        .create(true)
187        .append(true)
188        .open(path)
189    {
190        Ok(f) => {
191            // Restrict audit log to owner-only on Unix (0o600).
192            #[cfg(unix)]
193            {
194                use std::os::unix::fs::PermissionsExt;
195                if let Err(e) = f.set_permissions(std::fs::Permissions::from_mode(0o600)) {
196                    warnings.push(format!("failed to set audit log permissions to 0o600: {e}"));
197                }
198            }
199            (Some(AuditFile(Arc::new(f))), warnings)
200        }
201        Err(e) => {
202            warnings.push(format!(
203                "failed to open audit log file {}: {e}",
204                path.display()
205            ));
206            (None, warnings)
207        }
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    #![allow(
214        clippy::unwrap_used,
215        clippy::expect_used,
216        clippy::panic,
217        clippy::indexing_slicing,
218        clippy::unwrap_in_result,
219        clippy::print_stdout,
220        clippy::print_stderr
221    )]
222    use super::{init_tracing, init_tracing_from_config};
223    use crate::config::ObservabilityConfig;
224
225    #[test]
226    fn config_format_valid() {
227        let config = ObservabilityConfig {
228            log_level: "debug".into(),
229            log_format: "json".into(),
230            audit_log_path: None,
231            log_request_headers: false,
232            metrics_enabled: false,
233            metrics_bind: "127.0.0.1:9090".into(),
234        };
235        assert!(config.log_format == "json" || config.log_format == "pretty");
236    }
237
238    /// Calling either `init_tracing` entry point twice in the same process
239    /// must NOT panic. The second (and any subsequent) call must return
240    /// `Err(TryInitError)` instead. This guards against regressions of the
241    /// pre-0.11 `.init()` behaviour, which aborted the process when a
242    /// global subscriber was already installed (e.g. by a sibling test).
243    ///
244    /// All four call orderings are exercised in a single test because the
245    /// global tracing subscriber is process-wide state - we cannot rely on
246    /// test isolation here.
247    #[test]
248    fn init_tracing_double_init_returns_err_not_panic() {
249        // First call: may succeed or fail depending on whether another
250        // test in this binary already installed a subscriber. Either is
251        // acceptable; we only require that it does not panic.
252        let _ = init_tracing("info");
253
254        // Second call: a global subscriber is now guaranteed to exist,
255        // so this MUST return Err and MUST NOT panic.
256        let second = init_tracing("debug");
257        assert!(
258            second.is_err(),
259            "second init_tracing must return Err once a global subscriber exists"
260        );
261
262        // The companion entry point must also report Err rather than panic.
263        let cfg = ObservabilityConfig {
264            log_level: "info".into(),
265            log_format: "pretty".into(),
266            audit_log_path: None,
267            log_request_headers: false,
268            metrics_enabled: false,
269            metrics_bind: "127.0.0.1:9090".into(),
270        };
271        let third = init_tracing_from_config(&cfg);
272        assert!(
273            third.is_err(),
274            "init_tracing_from_config must return Err once a global subscriber exists"
275        );
276    }
277}