1use std::{
57 path::{Path, PathBuf},
58 sync::{Arc, Mutex},
59};
60
61use n0_future::{
62 task::{AbortOnDropHandle, JoinHandle},
63 time::Duration,
64};
65use tracing::{Subscriber, debug, warn};
66use tracing_subscriber::{
67 EnvFilter, Layer, Registry, layer::SubscriberExt as _, registry::LookupSpan, reload,
68 util::SubscriberInitExt as _,
69};
70
71#[derive(Debug, thiserror::Error)]
73pub enum InstallError {
74 #[error("global tracing dispatcher is already set")]
76 AlreadyInstalled,
77 #[error("file logger setup failed: {0}")]
80 FileLogger(#[from] FileLoggerError),
81}
82
83#[derive(Debug, thiserror::Error)]
85pub enum SetFilterError {
86 #[error("invalid filter directives: {0}")]
88 InvalidDirectives(String),
89 #[error("reload handle is no longer valid")]
91 ReloadFailed,
92}
93
94#[derive(Clone)]
97pub struct LogCollector {
98 inner: Arc<CollectorInner>,
99}
100
101struct CollectorInner {
102 reload_handle: reload::Handle<EnvFilter, Registry>,
103 revert_task: Mutex<Option<AbortOnDropHandle<()>>>,
104 log_dir: PathBuf,
107 file_name_prefix: String,
110}
111
112const OFF_DIRECTIVES: &str = "off";
115
116impl LogCollector {
117 pub fn set_filter(
121 &self,
122 directives: &str,
123 expires_in: Option<Duration>,
124 revert_to: Option<&str>,
125 ) -> Result<(), SetFilterError> {
126 let filter = EnvFilter::try_new(directives)
127 .map_err(|e| SetFilterError::InvalidDirectives(e.to_string()))?;
128 self.inner
129 .reload_handle
130 .reload(filter)
131 .map_err(|_| SetFilterError::ReloadFailed)?;
132
133 let mut guard = self.inner.revert_task.lock().expect("poisoned");
134 *guard = None;
135
136 if let Some(expires_in) = expires_in {
137 let collector = self.clone();
138 let revert_to = revert_to.map(str::to_string);
139 let handle: JoinHandle<()> = n0_future::task::spawn(async move {
140 n0_future::time::sleep(expires_in).await;
141 let target = revert_to.as_deref();
142 if let Err(err) = collector.revert(target) {
143 warn!(?err, "failed to revert log filter");
144 }
145 });
146 *guard = Some(AbortOnDropHandle::new(handle));
147 }
148 Ok(())
149 }
150
151 pub fn revert(&self, to: Option<&str>) -> Result<(), SetFilterError> {
154 let directives = to.unwrap_or(OFF_DIRECTIVES);
155 let filter = EnvFilter::try_new(directives)
156 .map_err(|e| SetFilterError::InvalidDirectives(e.to_string()))?;
157 self.inner
158 .reload_handle
159 .reload(filter)
160 .map_err(|_| SetFilterError::ReloadFailed)
161 }
162
163 pub(crate) fn current_log_file(&self) -> std::io::Result<Option<PathBuf>> {
170 let dir = &self.inner.log_dir;
171 let prefix = &self.inner.file_name_prefix;
172 let entries = match std::fs::read_dir(dir) {
173 Ok(e) => e,
174 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
175 Err(err) => return Err(err),
176 };
177 let mut best: Option<(PathBuf, std::time::SystemTime)> = None;
178 for entry in entries.flatten() {
179 let name = entry.file_name();
180 let name = name.to_string_lossy();
181 if !name.starts_with(prefix) {
182 continue;
183 }
184 let Ok(meta) = entry.metadata() else { continue };
185 if !meta.is_file() {
186 continue;
187 }
188 let mtime = meta.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH);
189 match &best {
190 Some((_, current)) if *current >= mtime => {}
191 _ => best = Some((entry.path(), mtime)),
192 }
193 }
194 Ok(best.map(|(p, _)| p))
195 }
196}
197
198impl std::fmt::Debug for LogCollector {
199 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200 f.debug_struct("LogCollector").finish_non_exhaustive()
201 }
202}
203
204pub fn install(config: FileLoggerConfig) -> Result<(LogCollector, WorkerGuard), InstallError> {
215 let (collector, file_layer, guard) = layer(config)?;
216 tracing_subscriber::registry()
217 .with(file_layer)
218 .try_init()
219 .map_err(|_| InstallError::AlreadyInstalled)?;
220 debug!("iroh-services file logger installed");
221 Ok((collector, guard))
222}
223
224pub fn layer(
229 config: FileLoggerConfig,
230) -> Result<
231 (
232 LogCollector,
233 impl Layer<Registry> + Send + Sync + 'static,
234 WorkerGuard,
235 ),
236 InstallError,
237> {
238 let filter = EnvFilter::try_new(OFF_DIRECTIVES).expect("'off' is always a valid directive");
240 let (filter, reload_handle) = reload::Layer::new(filter);
241
242 let log_dir = config.dir.clone();
243 let file_name_prefix = config.file_name_prefix.clone();
244 let (file_layer, guard) = file_layer::<Registry>(config)?;
245 let layer = file_layer.with_filter(filter);
246
247 let inner = Arc::new(CollectorInner {
248 reload_handle,
249 revert_task: Mutex::new(None),
250 log_dir,
251 file_name_prefix,
252 });
253 let collector = LogCollector { inner };
254 Ok((collector, layer, guard))
255}
256
257pub use tracing_appender::non_blocking::WorkerGuard;
262pub use tracing_appender::rolling::Rotation;
267
268#[derive(Debug, thiserror::Error)]
270pub enum FileLoggerError {
271 #[error("file logger setup failed: {0}")]
273 Io(#[from] std::io::Error),
274 #[error("file logger builder rejected configuration: {0}")]
277 Builder(String),
278}
279
280#[derive(Debug, Clone)]
287pub struct FileLoggerConfig {
288 dir: PathBuf,
289 rotation: Rotation,
290 file_name_prefix: String,
291 max_files: Option<usize>,
292}
293
294impl FileLoggerConfig {
295 pub fn new<P: Into<PathBuf>>(dir: P) -> Self {
298 Self {
299 dir: dir.into(),
300 rotation: Rotation::DAILY,
301 file_name_prefix: "iroh-services".into(),
302 max_files: Some(30),
303 }
304 }
305
306 pub fn with_rotation(mut self, rotation: Rotation) -> Self {
308 self.rotation = rotation;
309 self
310 }
311
312 pub fn with_file_name_prefix<S: Into<String>>(mut self, prefix: S) -> Self {
315 self.file_name_prefix = prefix.into();
316 self
317 }
318
319 pub fn with_max_files(mut self, max_files: Option<usize>) -> Self {
323 self.max_files = max_files;
324 self
325 }
326}
327
328pub fn file_layer<S>(
337 config: FileLoggerConfig,
338) -> Result<(impl Layer<S> + Send + Sync + 'static, WorkerGuard), FileLoggerError>
339where
340 S: Subscriber + for<'a> LookupSpan<'a>,
341{
342 let FileLoggerConfig {
343 dir,
344 rotation,
345 file_name_prefix,
346 max_files,
347 } = config;
348
349 create_dir_all(&dir)?;
350
351 let mut builder = tracing_appender::rolling::RollingFileAppender::builder()
352 .rotation(rotation)
353 .filename_prefix(file_name_prefix);
354 if let Some(max) = max_files {
355 builder = builder.max_log_files(max);
356 }
357 let appender = builder
358 .build(&dir)
359 .map_err(|e| FileLoggerError::Builder(e.to_string()))?;
360
361 let (writer, guard) = tracing_appender::non_blocking(appender);
362 let layer = tracing_subscriber::fmt::layer()
363 .with_writer(writer)
364 .with_ansi(false)
365 .json();
366 Ok((layer, guard))
367}
368
369fn create_dir_all(dir: &Path) -> Result<(), FileLoggerError> {
370 std::fs::create_dir_all(dir).map_err(FileLoggerError::Io)
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376
377 #[test]
380 fn file_layer_writes_to_disk() {
381 use tracing::Dispatch;
382 use tracing_subscriber::{Registry, layer::SubscriberExt};
383
384 let tmp = tempfile::tempdir().unwrap();
385 let (layer, guard) = file_layer::<Registry>(
386 FileLoggerConfig::new(tmp.path())
387 .with_file_name_prefix("test")
388 .with_max_files(Some(2)),
389 )
390 .expect("file_layer setup");
391
392 let subscriber = Registry::default().with(layer);
393 let dispatch = Dispatch::new(subscriber);
394 tracing::dispatcher::with_default(&dispatch, || {
395 tracing::info!(target: "file_layer_test", "hello from the file logger");
396 });
397 drop(guard);
398
399 let mut found = false;
400 for entry in std::fs::read_dir(tmp.path()).unwrap() {
401 let entry = entry.unwrap();
402 if !entry.file_name().to_string_lossy().starts_with("test") {
403 continue;
404 }
405 let contents = std::fs::read_to_string(entry.path()).unwrap();
406 if contents.contains("hello from the file logger") {
407 found = true;
408 break;
409 }
410 }
411 assert!(found, "expected log line to be written to a test.* file");
412 }
413
414 #[tokio::test(flavor = "current_thread")]
418 async fn cloud_filter_controls_file_writes() {
419 use tracing::Dispatch;
420
421 let tmp = tempfile::tempdir().unwrap();
422 let (collector, log_layer, guard) =
423 layer(FileLoggerConfig::new(tmp.path()).with_file_name_prefix("controlled")).unwrap();
424
425 let subscriber = Registry::default().with(log_layer);
426 let dispatch = Dispatch::new(subscriber);
427 tracing::dispatcher::with_default(&dispatch, || {
428 tracing::info!(target: "logtest", "before-set");
430
431 collector
432 .set_filter("info", None, None)
433 .expect("set_filter to info");
434 tracing::info!(target: "logtest", "after-set");
435 });
436 drop(guard);
437
438 let mut combined = String::new();
439 for entry in std::fs::read_dir(tmp.path()).unwrap() {
440 let entry = entry.unwrap();
441 if entry
442 .file_name()
443 .to_string_lossy()
444 .starts_with("controlled")
445 {
446 combined.push_str(&std::fs::read_to_string(entry.path()).unwrap());
447 }
448 }
449 assert!(
450 !combined.contains("before-set"),
451 "before-set should be filtered out, got: {combined}"
452 );
453 assert!(
454 combined.contains("after-set"),
455 "after-set should be written after set_filter, got: {combined}"
456 );
457 }
458}