Skip to main content

core_lib/log/
file_layer.rs

1//! A [`Layer`] that appends every event carrying a `log_path` field to the
2//! matching per-watch log file in `~/.fleet/logs/`.
3//!
4//! It reproduces the exact line format produced by the historical `Logger`
5//! (`[YYYY-MM-DD HH:MM:SS] LEVEL: message`) so `fleet logs`, the `fleet stats`
6//! dashboard and the file-based tests are unaffected.
7
8use std::{fs::OpenOptions, io::Write, path::Path};
9
10use chrono::Local;
11use tracing::{
12    Event, Level, Subscriber,
13    field::{Field, Visit},
14};
15use tracing_subscriber::{Layer, registry::LookupSpan};
16
17const RESET: &str = "\x1b[0m";
18const BG_BLUE: &str = "\x1b[44m"; // info
19const BG_ORANGE: &str = "\x1b[48;5;208m"; // warning
20const BG_RED: &str = "\x1b[41m";
21const BG_GREEN: &str = "\x1b[42m"; // job start
22const BG_MAGENTA: &str = "\x1b[45m"; // job end
23const FG_BOLD_WHITE: &str = "\x1b[97;1m";
24
25/// Fields a logging event carries for the per-watch line to be reconstructed.
26#[derive(Debug, Default)]
27struct LogFields {
28    log_path: Option<String>,
29    message: Option<String>,
30    kind: Option<String>,
31}
32
33impl Visit for LogFields {
34    fn record_str(&mut self, field: &Field, value: &str) {
35        match field.name() {
36            "log_path" => self.log_path = Some(value.to_string()),
37            "message" => self.message = Some(value.to_string()),
38            "kind" => self.kind = Some(value.to_string()),
39            _ => {}
40        }
41    }
42
43    fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
44        let rendered = format!("{value:?}");
45        match field.name() {
46            "log_path" => self.log_path = Some(rendered),
47            "message" => self.message = Some(rendered),
48            "kind" => self.kind = Some(rendered),
49            _ => {}
50        }
51    }
52}
53
54/// A [`Layer`] writing each `tracing` event to a per-watch log file.
55#[derive(Debug)]
56pub struct FileLayer {
57    color_enable: bool,
58}
59
60impl FileLayer {
61    pub fn new() -> Self {
62        let no_color = std::env::var("FLEET_NO_COLOR").ok().as_deref() == Some("1");
63        Self {
64            color_enable: !no_color,
65        }
66    }
67
68    /// Level badge for the line, honoring the custom `JOB START`/`JOB END`
69    /// labels and the `FLEET_NO_COLOR` escape hatch.
70    fn label(&self, event: &Event<'_>, fields: &LogFields) -> String {
71        if let Some(kind @ ("JOB START" | "JOB END")) = fields.kind.as_deref() {
72            return if self.color_enable {
73                let bg = if kind == "JOB START" {
74                    BG_GREEN
75                } else {
76                    BG_MAGENTA
77                };
78                format!("{bg}{FG_BOLD_WHITE} {kind} {RESET}")
79            } else {
80                kind.to_string()
81            };
82        }
83
84        let level = match *event.metadata().level() {
85            Level::ERROR => "ERROR",
86            Level::WARN => "WARNING",
87            Level::INFO => "INFO",
88            Level::DEBUG => "DEBUG",
89            Level::TRACE => "TRACE",
90        };
91
92        if self.color_enable {
93            let bg = match level {
94                "INFO" => BG_BLUE,
95                "WARNING" => BG_ORANGE,
96                "ERROR" => BG_RED,
97                _ => "",
98            };
99            if bg.is_empty() {
100                level.to_string()
101            } else {
102                format!("{bg}{FG_BOLD_WHITE} {level} {RESET}")
103            }
104        } else {
105            level.to_string()
106        }
107    }
108
109    /// Appends a single line to the log file, creating it when missing.
110    /// A fresh handle per write keeps the write loop immune to `logs rm`.
111    fn append(path: &Path, line: &str) {
112        let Ok(mut file) = OpenOptions::new().append(true).create(true).open(path) else {
113            return;
114        };
115        let _ = file.write_all(line.as_bytes());
116        let _ = file.flush();
117    }
118}
119
120impl Default for FileLayer {
121    fn default() -> Self {
122        Self::new()
123    }
124}
125
126impl<S> Layer<S> for FileLayer
127where
128    S: Subscriber + for<'a> LookupSpan<'a>,
129{
130    fn on_event(&self, event: &Event<'_>, _ctx: tracing_subscriber::layer::Context<'_, S>) {
131        let mut fields = LogFields::default();
132        event.record(&mut fields);
133
134        let Some(log_path) = fields.log_path.as_deref().filter(|p| !p.is_empty()) else {
135            return;
136        };
137
138        let now = Local::now();
139        let line = format!(
140            "[{}] {}: {}\n",
141            now.format("%Y-%m-%d %H:%M:%S"),
142            self.label(event, &fields),
143            fields.message.unwrap_or_default()
144        );
145        Self::append(Path::new(&log_path), &line);
146    }
147}