1use super::super::config::{ConsoleFormat, LoggingConfig, Section};
2use anyhow::Context;
3use std::io::Write;
4use std::path::Path;
5use std::sync::{Arc, OnceLock};
6
7use parking_lot::Mutex;
8use tracing_subscriber::{Layer, fmt};
9
10#[cfg(feature = "otel")]
12pub type OtelLayer = tracing_opentelemetry::OpenTelemetryLayer<
13 tracing_subscriber::Registry,
14 opentelemetry_sdk::trace::Tracer,
15>;
16#[cfg(not(feature = "otel"))]
17pub type OtelLayer = ();
18
19fn matches_crate_prefix(target: &str, crate_name: &str) -> bool {
23 target == crate_name
24 || (target.starts_with(crate_name) && target[crate_name.len()..].starts_with("::"))
25}
26
27use file_rotate::{
30 ContentLimit, FileRotate,
31 compression::Compression,
32 suffix::{AppendTimestamp, FileLimit},
33};
34
35#[derive(Clone)]
36struct RotWriter(Arc<Mutex<FileRotate<AppendTimestamp>>>);
37
38impl<'a> fmt::MakeWriter<'a> for RotWriter {
39 type Writer = RotWriterHandle;
40 fn make_writer(&'a self) -> Self::Writer {
41 RotWriterHandle(self.0.clone())
42 }
43}
44
45#[derive(Clone)]
46struct RotWriterHandle(Arc<Mutex<FileRotate<AppendTimestamp>>>);
47
48impl Write for RotWriterHandle {
49 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
52 self.0.lock().write(buf)
53 }
54 fn flush(&mut self) -> std::io::Result<()> {
55 self.0.lock().flush()
56 }
57 fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()> {
58 self.0.lock().write_all(buf)
59 }
60 fn write_fmt(&mut self, args: std::fmt::Arguments<'_>) -> std::io::Result<()> {
61 self.0.lock().write_fmt(args)
62 }
63}
64
65#[derive(Clone)]
67struct RoutedWriterHandle(Option<RotWriterHandle>);
68
69impl Write for RoutedWriterHandle {
70 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
71 if let Some(w) = &mut self.0 {
72 w.write(buf)
73 } else {
74 Ok(buf.len())
75 }
76 }
77 fn flush(&mut self) -> std::io::Result<()> {
78 if let Some(w) = &mut self.0 {
79 w.flush()
80 } else {
81 Ok(())
82 }
83 }
84 fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()> {
85 if let Some(w) = &mut self.0 {
86 w.write_all(buf)
87 } else {
88 Ok(())
89 }
90 }
91 fn write_fmt(&mut self, args: std::fmt::Arguments<'_>) -> std::io::Result<()> {
92 if let Some(w) = &mut self.0 {
93 w.write_fmt(args)
94 } else {
95 Ok(())
96 }
97 }
98}
99
100#[derive(Clone)]
103struct MultiFileRouter {
104 default: Option<RotWriter>, by_prefix: Vec<(String, RotWriter)>, }
107
108impl MultiFileRouter {
109 fn resolve_for(&self, target: &str) -> Option<RotWriterHandle> {
110 for (crate_name, wr) in &self.by_prefix {
111 if matches_crate_prefix(target, crate_name) {
112 return Some(RotWriterHandle(wr.0.clone()));
113 }
114 }
115 self.default.as_ref().map(|w| RotWriterHandle(w.0.clone()))
116 }
117
118 fn is_empty(&self) -> bool {
119 self.default.is_none() && self.by_prefix.is_empty()
120 }
121}
122
123impl<'a> fmt::MakeWriter<'a> for MultiFileRouter {
124 type Writer = RoutedWriterHandle;
125
126 fn make_writer(&'a self) -> Self::Writer {
127 RoutedWriterHandle(self.default.as_ref().map(|w| RotWriterHandle(w.0.clone())))
128 }
129
130 fn make_writer_for(&'a self, meta: &tracing::Metadata<'_>) -> Self::Writer {
131 let target = meta.target();
132 RoutedWriterHandle(self.resolve_for(target))
133 }
134}
135
136struct ConfigData<'a> {
139 default_section: Option<&'a Section>,
140 crate_sections: Vec<(String, &'a Section)>,
141}
142
143fn extract_config_data(cfg: &LoggingConfig) -> ConfigData<'_> {
144 let crate_sections = cfg
145 .iter()
146 .filter(|(k, _)| k.as_str() != "default")
147 .map(|(k, v)| (k.clone(), v))
148 .collect::<Vec<_>>();
149
150 ConfigData {
151 default_section: cfg.get("default"),
152 crate_sections,
153 }
154}
155
156fn create_rotating_writer_at_path(
159 log_path: &Path,
160 max_bytes: usize,
161 max_age_days: Option<u32>,
162 max_backups: Option<usize>,
163) -> Result<RotWriter, Box<dyn std::error::Error + Send + Sync>> {
164 if let Some(parent) = log_path.parent() {
165 std::fs::create_dir_all(parent)?;
166 }
167
168 let max_age_days = max_age_days.unwrap_or(1);
170 let age = chrono::Duration::try_days(i64::from(max_age_days))
171 .with_context(|| format!("Invalid max_age_days: {max_age_days}"))?;
172 let limit = if let Some(n) = max_backups {
173 FileLimit::MaxFiles(n)
174 } else {
175 FileLimit::Age(age)
176 };
177
178 let rot = FileRotate::new(
179 log_path,
180 AppendTimestamp::default(limit),
181 ContentLimit::BytesSurpassed(max_bytes),
182 Compression::None,
183 None,
184 );
185
186 Ok(RotWriter(Arc::new(Mutex::new(rot))))
187}
188
189static CONSOLE_GUARD: OnceLock<WorkerGuard> = OnceLock::new();
195
196#[allow(unknown_lints, de1301_no_print_macros)] pub fn init_logging_unified(cfg: &LoggingConfig, base_dir: &Path, otel_layer: Option<OtelLayer>) {
199 CONSOLE_GUARD.get_or_init(|| {
200 if let Err(e) = tracing_log::LogTracer::init() {
202 eprintln!("LogTracer init skipped: {e}");
203 }
204
205 let data = extract_config_data(cfg);
206
207 if data.crate_sections.is_empty() && data.default_section.is_none() {
208 return init_minimal(otel_layer);
210 }
211
212 let file_router = build_file_router(&data, base_dir);
214
215 let console_targets = build_target_console(&data);
216 let file_targets = build_target_file(&data, file_router.default.is_some());
217
218 let console_format = data
219 .default_section
220 .map(|s| s.console_format)
221 .unwrap_or_default();
222
223 install_subscriber(
224 &console_targets,
225 &file_targets,
226 file_router,
227 console_format,
228 otel_layer,
229 )
230 });
231}
232
233use tracing::level_filters::LevelFilter;
236use tracing_appender::non_blocking::WorkerGuard;
237use tracing_subscriber::filter::Targets;
238
239const NOISY_CRATES: &[&str] = &["h2"];
241
242fn build_target_console(config: &ConfigData) -> Targets {
243 let default_level = config
245 .default_section
246 .and_then(|s| s.console_level)
247 .map_or(LevelFilter::INFO, LevelFilter::from_level);
248
249 let mut targets = Targets::new().with_default(default_level);
251
252 for crate_name in NOISY_CRATES {
254 targets = targets.with_target(*crate_name, LevelFilter::WARN);
255 }
256
257 for (crate_name, section) in &config.crate_sections {
259 if let Some(level) = section.console_level.map(LevelFilter::from_level) {
260 targets = targets.with_target(crate_name.clone(), level);
261 }
262 }
263
264 targets
265}
266
267fn build_target_file(config: &ConfigData, has_default_file: bool) -> Targets {
268 let default_level = if has_default_file {
270 config
271 .default_section
272 .and_then(Section::file_level)
273 .map_or(LevelFilter::INFO, LevelFilter::from_level)
274 } else {
275 LevelFilter::OFF
276 };
277
278 let mut targets = Targets::new().with_default(default_level);
279
280 for (crate_name, section) in &config.crate_sections {
282 if let Some(level) = section.file_level().map(LevelFilter::from_level) {
283 targets = targets.with_target(crate_name.clone(), level);
284 }
285 }
286
287 targets
288}
289
290fn build_file_router(config: &ConfigData, base_dir: &Path) -> MultiFileRouter {
293 let mut router = MultiFileRouter {
294 default: None,
295 by_prefix: Vec::with_capacity(config.crate_sections.len()),
296 };
297
298 if let Some(section) = config.default_section {
299 router.default = create_file_writer(None, section, base_dir);
300 }
301
302 for (crate_name, section) in &config.crate_sections {
303 if let Some(writer) = create_file_writer(Some(crate_name), section, base_dir) {
304 router.by_prefix.push((crate_name.clone(), writer));
305 }
306 }
307
308 router
311 .by_prefix
312 .sort_by(|a, b| b.0.len().cmp(&a.0.len()).then_with(|| a.0.cmp(&b.0)));
313
314 router
315}
316
317trait HasMaxSizeBytes {
318 fn max_size_bytes(&self) -> usize;
319}
320
321const DEFAULT_SECTION_MAX_SIZE_MB: usize = 100;
322
323impl HasMaxSizeBytes for Section {
324 fn max_size_bytes(&self) -> usize {
325 self.max_size_mb
326 .map(|mb| mb * 1024 * 1024)
327 .and_then(|b| usize::try_from(b).ok())
328 .unwrap_or(DEFAULT_SECTION_MAX_SIZE_MB * 1024 * 1024)
329 }
330}
331
332#[allow(unknown_lints, de1301_no_print_macros)] fn create_file_writer(
334 crate_name: Option<&str>,
335 section: &Section,
336 base_dir: &Path,
337) -> Option<RotWriter> {
338 let file = section.file()?;
339
340 let max_bytes = section.max_size_bytes();
341
342 let p = Path::new(file);
343 let log_path = if p.is_absolute() {
344 p.to_path_buf()
345 } else {
346 base_dir.join(p)
347 };
348
349 match create_rotating_writer_at_path(
350 &log_path,
351 max_bytes,
352 section.max_age_days,
353 section.max_backups,
354 ) {
355 Ok(writer) => Some(writer),
356 Err(e) => {
357 match crate_name {
358 Some(crate_name) => eprintln!(
359 "Failed to init log file for subsystem '{}': {} ({})",
360 crate_name,
361 log_path.to_string_lossy(),
362 e,
363 ),
364 None => eprintln!(
365 "Failed to initialize default log file '{}'",
366 log_path.to_string_lossy()
367 ),
368 }
369 None
370 }
371 }
372}
373
374fn stderr_supports_ansi() -> bool {
379 _ = enable_ansi_support::enable_ansi_support();
380 supports_color::on(supports_color::Stream::Stderr).is_some_and(|level| level.has_basic)
381}
382
383#[allow(unknown_lints, de1301_no_print_macros)]
390fn install_subscriber(
391 console_targets: &tracing_subscriber::filter::Targets,
392 file_targets: &tracing_subscriber::filter::Targets,
393 file_router: MultiFileRouter,
394 console_format: ConsoleFormat,
395 #[cfg_attr(not(feature = "otel"), allow(unused_variables))] otel_layer: Option<OtelLayer>,
396) -> WorkerGuard {
397 use tracing_subscriber::{EnvFilter, Registry, fmt, layer::SubscriberExt};
398
399 let env: Option<EnvFilter> = EnvFilter::try_from_default_env().ok();
402
403 let (nb_stderr, guard) = tracing_appender::non_blocking(std::io::stderr());
405
406 let (console_text, console_json) = match console_format {
409 ConsoleFormat::Text => (
410 Some(
411 fmt::layer()
412 .with_writer(nb_stderr)
413 .with_ansi(stderr_supports_ansi())
414 .with_target(true)
415 .with_level(true)
416 .with_timer(fmt::time::UtcTime::rfc_3339())
417 .with_filter(console_targets.clone()),
418 ),
419 None,
420 ),
421 ConsoleFormat::Json => (
422 None,
423 Some(
424 fmt::layer()
425 .json()
426 .with_writer(nb_stderr)
427 .with_ansi(false)
428 .with_target(true)
429 .with_level(true)
430 .with_timer(fmt::time::UtcTime::rfc_3339())
431 .with_filter(console_targets.clone()),
432 ),
433 ),
434 };
435
436 let file_layer_opt = if file_router.is_empty() {
438 None
439 } else {
440 Some(
441 fmt::layer()
442 .json()
443 .with_ansi(false)
444 .with_target(true)
445 .with_level(true)
446 .with_timer(fmt::time::UtcTime::rfc_3339())
447 .with_writer(file_router)
448 .with_filter(file_targets.clone()),
449 )
450 };
451
452 let subscriber = {
458 let base = Registry::default();
459
460 #[cfg(feature = "otel")]
461 let base = {
462 let otel_opt = otel_layer.map(|otel| otel.with_filter(console_targets.clone()));
463 base.with(otel_opt)
464 };
465 #[cfg(not(feature = "otel"))]
466 let base = base;
467
468 let base = base.with(env);
469 base.with(console_text)
470 .with(console_json)
471 .with(file_layer_opt)
472 };
473
474 if let Err(e) = tracing::subscriber::set_global_default(subscriber) {
475 eprintln!("tracing subscriber init failed: {e}");
476 }
477
478 guard
479}
480#[allow(unknown_lints, de1301_no_print_macros)]
482fn init_minimal(
483 #[cfg_attr(not(feature = "otel"), allow(unused_variables))] otel: Option<OtelLayer>,
484) -> WorkerGuard {
485 use tracing_subscriber::{EnvFilter, Registry, fmt, layer::SubscriberExt};
486
487 let env = EnvFilter::try_from_default_env().ok();
489
490 let (nb_stderr, guard) = tracing_appender::non_blocking(std::io::stderr());
492
493 let fmt_layer = fmt::layer()
494 .with_writer(nb_stderr)
495 .with_ansi(stderr_supports_ansi())
496 .with_target(true)
497 .with_timer(fmt::time::UtcTime::rfc_3339());
498
499 let subscriber = {
501 let base = Registry::default();
502
503 #[cfg(feature = "otel")]
504 let base = base.with(otel);
505 #[cfg(not(feature = "otel"))]
506 let base = base;
507
508 base.with(env).with(fmt_layer)
509 };
510
511 if let Err(e) = tracing::subscriber::set_global_default(subscriber) {
514 eprintln!("tracing subscriber init failed (minimal): {e}");
515 }
516
517 guard
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523 use std::io::Write;
524
525 fn assert_concurrent_writes<'a, W>(writer: &'a W, log_path: &Path)
527 where
528 W: fmt::MakeWriter<'a> + Sync,
529 W::Writer: Write,
530 {
531 const NUM_THREADS: usize = 8;
532 const LINES_PER_THREAD: usize = 500;
533 const TOTAL_LINES: usize = NUM_THREADS * LINES_PER_THREAD;
534
535 std::thread::scope(|s| {
536 for thread_id in 0..NUM_THREADS {
537 s.spawn(move || {
538 for line_no in 0..LINES_PER_THREAD {
539 let mut handle = writer.make_writer();
540 writeln!(handle, "thread={thread_id} line={line_no}")
541 .expect("write must not fail");
542 }
543 });
544 }
545 });
546
547 let content = std::fs::read_to_string(log_path).expect("failed to read log file");
548 let lines: Vec<&str> = content.lines().collect();
549
550 assert_eq!(
551 lines.len(),
552 TOTAL_LINES,
553 "expected {TOTAL_LINES} lines but found {} ({} records {})",
554 lines.len(),
555 lines.len().abs_diff(TOTAL_LINES),
556 if lines.len() < TOTAL_LINES {
557 "lost"
558 } else {
559 "extra"
560 },
561 );
562
563 for (i, line) in lines.iter().enumerate() {
565 assert!(
566 line.starts_with("thread=") && line.contains(" line="),
567 "corrupted line {i}: {line:?}",
568 );
569 }
570 }
571
572 #[test]
573 fn concurrent_writes_are_not_dropped() {
574 let dir = tempfile::tempdir().expect("failed to create temp dir");
575 let log_path = dir.path().join("test.log");
576
577 let writer = create_rotating_writer_at_path(&log_path, 50 * 1024 * 1024, None, Some(1))
578 .expect("failed to create rotating writer");
579
580 assert_concurrent_writes(&writer, &log_path);
581 }
582
583 #[test]
584 fn concurrent_writes_through_routed_writer() {
585 let dir = tempfile::tempdir().expect("failed to create temp dir");
586 let log_path = dir.path().join("routed.log");
587
588 let rot = create_rotating_writer_at_path(&log_path, 50 * 1024 * 1024, None, Some(1))
589 .expect("failed to create rotating writer");
590
591 let router = MultiFileRouter {
592 default: Some(rot),
593 by_prefix: Vec::new(),
594 };
595
596 assert_concurrent_writes(&router, &log_path);
597 }
598
599 fn tmp_writer(dir: &Path, name: &str) -> (RotWriter, std::path::PathBuf) {
601 let p = dir.join(name);
602 let w = create_rotating_writer_at_path(&p, 50 * 1024 * 1024, None, Some(1))
603 .expect("failed to create rotating writer");
604 (w, p)
605 }
606
607 #[test]
608 fn resolve_for_picks_longest_matching_prefix() {
609 let dir = tempfile::tempdir().expect("failed to create temp dir");
610
611 let (broad, broad_path) = tmp_writer(dir.path(), "broad.log");
612 let (specific, specific_path) = tmp_writer(dir.path(), "specific.log");
613
614 broad.0.lock().write_all(b"BROAD\n").unwrap();
616 specific.0.lock().write_all(b"SPECIFIC\n").unwrap();
617
618 let router = MultiFileRouter {
619 default: None,
620 by_prefix: vec![
621 ("cf-gears::api_gateway".into(), specific),
622 ("cf-gears".into(), broad),
623 ],
624 };
625
626 let mut handle = router
628 .resolve_for("cf-gears::api_gateway::handler")
629 .expect("should resolve");
630 handle.write_all(b"routed\n").unwrap();
631 handle.flush().unwrap();
632
633 let specific_content = std::fs::read_to_string(&specific_path).unwrap();
634 assert!(
635 specific_content.contains("routed"),
636 "expected write to land in specific log, got: {specific_content:?}"
637 );
638
639 let broad_content = std::fs::read_to_string(&broad_path).unwrap();
640 assert!(
641 !broad_content.contains("routed"),
642 "write should NOT appear in broad log, got: {broad_content:?}"
643 );
644 }
645
646 #[test]
650 fn build_file_router_sorts_prefixes_longest_match_wins() {
651 use crate::bootstrap::config::SectionFile;
652
653 let dir = tempfile::tempdir().expect("failed to create temp dir");
654
655 let broad_section = Section {
656 console_format: ConsoleFormat::default(),
657 console_level: None,
658 section_file: Some(SectionFile {
659 file: "broad.log".to_owned(),
660 file_level: None,
661 }),
662 max_age_days: None,
663 max_backups: Some(1),
664 max_size_mb: None,
665 };
666 let specific_section = Section {
667 console_format: ConsoleFormat::default(),
668 console_level: None,
669 section_file: Some(SectionFile {
670 file: "specific.log".to_owned(),
671 file_level: None,
672 }),
673 max_age_days: None,
674 max_backups: Some(1),
675 max_size_mb: None,
676 };
677
678 let config = ConfigData {
681 default_section: None,
682 crate_sections: vec![
683 ("cf-gears".to_owned(), &broad_section),
684 ("cf-gears::api_gateway".to_owned(), &specific_section),
685 ],
686 };
687
688 let router = build_file_router(&config, dir.path());
689
690 let mut handle = router
691 .resolve_for("cf-gears::api_gateway::handler")
692 .expect("should resolve");
693 handle.write_all(b"routed\n").unwrap();
694 handle.flush().unwrap();
695
696 let specific_content = std::fs::read_to_string(dir.path().join("specific.log")).unwrap();
697 assert!(
698 specific_content.contains("routed"),
699 "expected write to land in specific log, got: {specific_content:?}"
700 );
701
702 let broad_content = std::fs::read_to_string(dir.path().join("broad.log")).unwrap();
703 assert!(
704 !broad_content.contains("routed"),
705 "write should NOT appear in broad log, got: {broad_content:?}"
706 );
707 }
708
709 #[test]
710 fn resolve_for_exact_match() {
711 let dir = tempfile::tempdir().expect("failed to create temp dir");
712 let (writer, _) = tmp_writer(dir.path(), "exact.log");
713
714 let router = MultiFileRouter {
715 default: None,
716 by_prefix: vec![("cf-gears".into(), writer)],
717 };
718
719 assert!(
721 router.resolve_for("cf-gears").is_some(),
722 "exact target should match"
723 );
724 assert!(
726 router.resolve_for("cf-gears::sub").is_some(),
727 "submodule target should match"
728 );
729 assert!(
731 router.resolve_for("cf_gears_extra").is_none(),
732 "non-prefix target should not match"
733 );
734 assert!(
735 router.resolve_for("other").is_none(),
736 "unrelated target should not match"
737 );
738 }
739
740 #[test]
741 fn resolve_for_falls_back_to_default() {
742 let dir = tempfile::tempdir().expect("failed to create temp dir");
743 let (default_writer, default_path) = tmp_writer(dir.path(), "default.log");
744
745 default_writer.0.lock().write_all(b"DEFAULT\n").unwrap();
746
747 let router = MultiFileRouter {
748 default: Some(default_writer),
749 by_prefix: vec![],
750 };
751
752 let mut handle = router
754 .resolve_for("unknown_crate::gear")
755 .expect("should fall back to default");
756 handle.write_all(b"fallback\n").unwrap();
757 handle.flush().unwrap();
758
759 let content = std::fs::read_to_string(&default_path).unwrap();
760 assert!(
761 content.contains("fallback"),
762 "expected write to land in default log, got: {content:?}"
763 );
764 }
765}