rmcp_server_kit/
observability.rs1use 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#[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
26pub 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 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
75fn 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
108pub 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#[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
143struct 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
156fn open_audit_file(path: &Path) -> (Option<AuditFile>, Vec<String>) {
171 let mut warnings = Vec::new();
172
173 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 #[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 #[test]
248 fn init_tracing_double_init_returns_err_not_panic() {
249 let _ = init_tracing("info");
253
254 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 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}