ctoolbox/utilities/
logging.rs

1//! Tracing-based logging setup.
2//! Useful documentation: <https://docs.rs/tracing-subscriber/latest/tracing_subscriber/layer/index.html#per-layer-filtering>
3
4use std::{env, fmt, io, sync::OnceLock, time::SystemTime};
5
6use anyhow::{Context, Result, anyhow};
7use clap::crate_name;
8use tracing::{
9    Event, Level, Subscriber,
10    field::{Field, Visit},
11};
12use tracing_appender::rolling::{self, Builder};
13use tracing_subscriber::{
14    EnvFilter, Layer,
15    fmt::{
16        FmtContext, FormatEvent, FormatFields, FormattedFields, format::Writer,
17    },
18    prelude::*,
19    registry::LookupSpan,
20};
21
22use crate::storage::{get_storage_dir, pc_settings::get_bool_setting};
23use crate::{cli::Invocation, utilities::COLUMN_UUID_DELIM};
24
25pub mod test_internal;
26pub mod test_subscriber;
27
28/// Custom event formatter. Creates a fresh timestamp per event.
29/// Avoids panics if span extensions are missing.
30#[derive(Clone)]
31struct LogEventFormatter {
32    pid: u64,
33    sub_index: Option<String>,
34    service_name: Option<String>,
35}
36
37impl LogEventFormatter {
38    fn format_timestamp() -> String {
39        humantime::format_rfc3339_micros(SystemTime::now()).to_string()
40    }
41}
42
43struct MyVisitor {
44    file: Option<String>,
45    line: Option<u32>,
46}
47
48impl Visit for MyVisitor {
49    fn record_str(&mut self, field: &Field, value: &str) {
50        if field.name() == "log.file" {
51            self.file = Some(value.to_string());
52        }
53    }
54    fn record_u64(&mut self, field: &Field, value: u64) {
55        if field.name() == "log.line" {
56            self.line = u32::try_from(value).ok();
57        }
58    }
59    fn record_debug(&mut self, _field: &Field, _value: &dyn fmt::Debug) {
60        // Ignore other fields
61    }
62}
63
64fn extract_file_line(event: &Event) -> (Option<String>, Option<u32>) {
65    let mut visitor = MyVisitor {
66        file: None,
67        line: None,
68    };
69    event.record(&mut visitor);
70    (visitor.file, visitor.line)
71}
72
73impl<S, N> FormatEvent<S, N> for LogEventFormatter
74where
75    S: Subscriber + for<'a> LookupSpan<'a>,
76    N: for<'a> FormatFields<'a> + 'static,
77{
78    fn format_event(
79        &self,
80        ctx: &FmtContext<'_, S, N>,
81        mut writer: Writer<'_>,
82        event: &Event<'_>,
83    ) -> fmt::Result {
84        let meta = event.metadata();
85
86        // Module path (preferred) falls back to target, then "unknown"
87        let target = meta
88            .module_path()
89            .or_else(|| Some(meta.target()))
90            .unwrap_or("unknown");
91
92        let (file, line) = extract_file_line(event);
93
94        // File/line (fallback "unknown:0")
95        let mut file = file
96            .or_else(|| meta.file().map(std::string::ToString::to_string))
97            .unwrap_or_else(|| "unknown".to_string());
98        if file.starts_with("src/") {
99            file = file.rsplit('/').next().unwrap_or(&file).to_string();
100        } else if meta.level() == &Level::DEBUG {
101            return Ok(());
102        }
103        let line = line.or_else(|| meta.line()).unwrap_or(0);
104
105        let mut column = "";
106        // Try to serialize the event into a string
107        let mut buf = String::new();
108        let column_writer = Writer::new(&mut buf);
109        if ctx
110            .field_format()
111            .format_fields(column_writer, event)
112            .is_ok()
113        {
114            if let Some(idx) = buf.find(COLUMN_UUID_DELIM) {
115                let after = &buf[idx + COLUMN_UUID_DELIM.len()..];
116                let col_text = after.split_whitespace().next().unwrap_or("");
117                column = col_text;
118            }
119        }
120        let column_formatted;
121        if !column.is_empty() {
122            column_formatted = format!(":{column}");
123            column = &column_formatted;
124        }
125
126        let pid = self.pid;
127        let sub_idx = self.sub_index.as_deref().unwrap_or("");
128        let svc_name = self.service_name.as_deref().unwrap_or("user");
129        let ts = Self::format_timestamp();
130
131        write!(
132            &mut writer,
133            "[{ts} {} {file}:{line}{column} {target} {sub_idx}:{pid}/{svc_name}] ",
134            meta.level()
135        )?;
136
137        // Span chain (root -> current)
138        if let Some(scope) = ctx.event_scope() {
139            for span in scope.from_root() {
140                write!(writer, "{}", span.name())?;
141                if let Some(fields) = span
142                    .extensions()
143                    .get::<FormattedFields<N>>()
144                    .filter(|f| !f.is_empty())
145                {
146                    write!(writer, "{{{fields}}}")?;
147                }
148                write!(writer, ": ")?;
149            }
150        }
151
152        // Event fields
153        let mut event_buf = String::new();
154        {
155            let mut tmp_writer = Writer::new(&mut event_buf);
156            ctx.field_format()
157                .format_fields(tmp_writer.by_ref(), event)?;
158        }
159        // Remove column UUID and column number
160        if let Some(idx) = event_buf.find(COLUMN_UUID_DELIM) {
161            // Remove from delimiter up to space (assumed column number)
162            let before = &event_buf[..idx];
163            let after = &event_buf[idx + COLUMN_UUID_DELIM.len()..];
164            // Remove column number: split at first whitespace after delimiter
165            let after_trimmed = after
166                .split_whitespace()
167                .skip(1)
168                .collect::<Vec<_>>()
169                .join(" ");
170            write!(writer, "{before}{after_trimmed}")?;
171        } else {
172            write!(writer, "{event_buf}")?;
173        }
174        writeln!(writer)
175    }
176}
177
178/// Builds the filter directive string based on build configuration.
179fn build_default_filter_directives() -> String {
180    let crate_name = crate_name!();
181
182    if cfg!(debug_assertions) {
183        // Use an explicit "warn" root to keep noise down, override interesting crates to debug
184        format!(
185            "warn,{crate_name}=debug,tower_http=debug,hyper=warn,axum::rejection=trace"
186        )
187    } else {
188        // For some reason using "error" root hides web log warnings (404 etc)
189        format!(
190            "warn,{crate_name}=warn,tower_http=debug,hyper=error,axum::rejection=warn"
191        )
192    }
193}
194
195/// Creates an `EnvFilter` from either `RUST_LOG` or the default directives.
196/// Returns the filter and the directive string used.
197/// FIXME: It looks like only the directives variable here is actually used.
198fn create_env_filter() -> (EnvFilter, String) {
199    let default_directives = build_default_filter_directives();
200
201    // Check if RUST_LOG is set and non-empty
202    let directives = match env::var("RUST_LOG") {
203        Ok(val) if !val.trim().is_empty() => val,
204        _ => default_directives.clone(),
205    };
206
207    // Use the builder pattern to properly parse directives
208    let filter = EnvFilter::builder()
209        .with_default_directive(Level::INFO.into())
210        .parse(&directives)
211        .unwrap_or_else(|_| {
212            EnvFilter::builder()
213                .with_default_directive(Level::INFO.into())
214                .parse_lossy(&default_directives)
215        });
216
217    (filter, directives)
218}
219
220#[tracing::instrument]
221pub fn setup_logger(invocation: &Invocation) -> Result<()> {
222    let pid = u64::from(std::process::id());
223
224    let (sub_index, service_name) = match invocation {
225        Invocation::User(_) => {
226            (Some("workspace".to_string()), Some("workspace".to_string()))
227        }
228        Invocation::Subprocess(sub) => (
229            Some(sub.subprocess_index.to_string()),
230            Some(sub.service_name.clone()),
231        ),
232        _ => (None, None),
233    };
234
235    /*
236    - When running tests or in a debug build (when running tests, output should only be shown in the terminal for failed tests, as with normal test output.):
237        - Warnings from dependencies should be logged to the appropriate files and printed to the terminal.
238        - Debug logging from my own application should be logged to the appropriate files and printed to the terminal. For this I'm using my utility module's wrappers around log::debug, etc. which seems to be working when running tests, but not when running it as a build.
239        - HTTP(s) requests should be logged to the appropriate file and printed to the terminal.
240    - When running a release build:
241        - Errors from dependencies should be printed to the terminal.
242        - Warnings from my own application should be printed to the terminal.
243        - HTTP(s) requests should be printed to the terminal.
244    */
245
246    // Get the directive string once, then create separate filters for each layer
247    let (_, directives) = create_env_filter();
248
249    // File logging only in debug (including tests). Release: only stderr (per specification).
250    let stderr_formatter = LogEventFormatter {
251        pid,
252        sub_index: sub_index.clone(),
253        service_name: service_name.clone(),
254    };
255
256    // Optional JSON structured logging
257    // let json_layer = tracing_subscriber::fmt::layer()
258    //     .json()
259    //     .with_current_span(true)
260    //     .with_span_list(true)
261    //     .with_filter(env_filter.clone());
262
263    let mut layers = Vec::new();
264
265    // Build subscriber
266    if cfg!(debug_assertions) || get_bool_setting("log_stack_file") {
267        layers.push(get_file_writer_layer(
268            invocation,
269            stderr_formatter.clone(),
270            &directives,
271        )?);
272    }
273    if cfg!(test) {
274        layers.push(get_mock_writer_layer(stderr_formatter.clone()));
275    }
276    let stderr_layer = get_stderr_layer(stderr_formatter, &directives)?;
277    layers.push(stderr_layer);
278
279    let result = tracing_subscriber::registry().with(layers).try_init();
280    match result {
281        Ok(()) => Ok(()),
282        Err(err) => Err(anyhow!(
283            "Failed to initialize logging in release mode: {err}"
284        )),
285    }
286}
287
288fn get_stderr_layer<S>(
289    formatter: LogEventFormatter,
290    directives: &str,
291) -> Result<Box<dyn Layer<S> + Send + Sync + 'static>>
292where
293    S: Subscriber + for<'a> LookupSpan<'a>,
294{
295    let env_filter = EnvFilter::builder()
296        .with_default_directive(Level::INFO.into())
297        .parse(directives)
298        .with_context(|| format!("Failed to parse filter directives: {directives}"))?;
299
300    Ok(tracing_subscriber::fmt::layer()
301        .with_writer(io::stderr)
302        // work around https://github.com/rust-lang/rust/issues/90785
303        .with_test_writer()
304        .with_ansi(false)
305        .event_format(formatter)
306        .with_filter(env_filter)
307        .boxed())
308}
309
310fn get_mock_writer_layer<S>(
311    formatter: LogEventFormatter,
312) -> Box<dyn Layer<S> + Send + Sync + 'static>
313where
314    S: Subscriber + for<'a> LookupSpan<'a>,
315{
316    let filter_directives = if cfg!(feature = "tracing-test-no-env-filter") {
317        "trace".to_string()
318    } else {
319        let crate_name = module_path!()
320            .split(':')
321            .next()
322            .unwrap_or("unknown")
323            .to_string();
324        format!("{crate_name}=trace")
325    };
326    let env_filter = EnvFilter::builder()
327        .with_default_directive(Level::TRACE.into())
328        .parse_lossy(&filter_directives);
329
330    let mock_writer = crate::utilities::logging::test_internal::MockWriter::new(
331        crate::utilities::logging::test_internal::global_buf(),
332    );
333
334    tracing_subscriber::fmt::layer()
335        .with_writer(mock_writer)
336        .with_level(true)
337        .with_ansi(false)
338        .event_format(formatter)
339        .with_filter(env_filter)
340        .boxed()
341}
342
343fn get_file_writer_layer<S>(
344    invocation: &Invocation,
345    formatter: LogEventFormatter,
346    directives: &str,
347) -> Result<Box<dyn Layer<S> + Send + Sync + 'static>>
348where
349    S: Subscriber + for<'a> LookupSpan<'a>,
350{
351    let log_dir = get_storage_dir()
352        .context("failed to resolve storage dir for logs")?
353        .join("logs");
354    std::fs::create_dir_all(&log_dir)
355        .with_context(|| format!("creating log dir: {}", log_dir.display()))?;
356
357    // File named by service / workspace
358    let filename_base = match invocation {
359        Invocation::User(_) => "workspace".to_string(),
360        Invocation::Subprocess(sub) => sub.service_name.clone(),
361        _ => "app".to_string(),
362    };
363    // if running tests, add "test" to filename
364    let filename_base = if cfg!(test) {
365        format!("test-{filename_base}")
366    } else {
367        filename_base
368    };
369
370    let file_appender = Builder::new()
371        .rotation(rolling::Rotation::DAILY)
372        .max_log_files(7)
373        .filename_prefix(filename_base.to_string())
374        .filename_suffix("log")
375        .build(&log_dir)?;
376    let (nb_writer, guard) = tracing_appender::non_blocking(file_appender);
377
378    // Keep guards alive for the process lifetime.
379    static APPENDER_GUARDS: OnceLock<
380        std::sync::Mutex<Vec<tracing_appender::non_blocking::WorkerGuard>>,
381    > = OnceLock::new();
382    let guards =
383        APPENDER_GUARDS.get_or_init(|| std::sync::Mutex::new(Vec::new()));
384    if let Ok(mut guards) = guards.lock() {
385        guards.push(guard);
386    }
387
388    let env_filter = EnvFilter::builder()
389        .with_default_directive(Level::INFO.into())
390        .parse(directives)
391        .with_context(|| format!("Failed to parse filter directives: {directives}"))?;
392
393    let file_layer = tracing_subscriber::fmt::layer()
394        .with_writer(nb_writer)
395        .event_format(formatter)
396        .with_ansi(false)
397        .with_filter(env_filter)
398        .boxed();
399
400    Ok(file_layer)
401}
402
403/* -------------- OPTIONAL: AXUM / TOWER_HTTP TRACE CONFIG EXAMPLE -----------------
404
405In your server builder (where you currently do:
406    .layer(TraceLayer::new_for_http())
407You can do something more explicit so you get clearer request lifecycle events:
408
409use tower_http::trace::{TraceLayer, DefaultMakeSpan, DefaultOnRequest, DefaultOnResponse};
410use tracing::Level;
411
412pub fn make_app(state: AppState) -> Router {
413    build_routes(state)
414        .layer(
415            TraceLayer::new_for_http()
416                // Span created per request (INFO so it shows under tower_http=info)
417                .make_span_with(
418                    DefaultMakeSpan::new()
419                        .level(Level::INFO)
420                        .include_headers(false)
421                )
422                // Log a line when the request starts
423                .on_request(
424                    DefaultOnRequest::new()
425                        .level(Level::INFO)
426                )
427                // Log a line when the response finishes (includes latency)
428                .on_response(
429                    DefaultOnResponse::new()
430                        .level(Level::INFO)
431                        .include_headers(false)
432                )
433        )
434        .layer(CompressionLayer::new())
435        .layer(CorsLayer::permissive())
436}
437
438---------------------------------------------------------------------------------- */