Skip to main content

cljrs_runtime/
logging.rs

1//! Diagnostic logging configuration: `tracing` targets and filters.
2//!
3//! The runtime, the GC, and the compiler emit their internal diagnostics with
4//! plain `tracing::debug!` / `tracing::trace!` under a small set of **feature
5//! targets** — see [`FEATURE_TARGETS`]. Selecting them is a filter, not an API:
6//! anything that can build a [`Targets`] filter can turn them on.
7//!
8//! Two entry points build that filter for the two hosts that ship in this
9//! workspace:
10//!
11//! * The `cljrs` CLI starts from [`base_filter`] (its `--debug`/`--trace`
12//!   level, with the feature targets pinned off and the codegen crates pinned
13//!   to `warn`), layers each `-X debug:gc,jit` flag on with [`apply_x_flag`],
14//!   and installs the result with [`init`].
15//! * A generated AOT harness calls [`init_from_env`], which enables *nothing*
16//!   unless `CLJRS_X_FLAG` or `RUST_LOG` asks for it.
17//!
18//! An embedding host is free to ignore all of this and install its own
19//! subscriber; the emitting code has no opinion.
20//!
21//! This module is native-only: `tracing-subscriber` is a host-side concern and
22//! a `wasm32` runtime installs no subscriber. The `tracing::debug!` call sites
23//! themselves compile everywhere and are inert without one.
24
25use tracing::level_filters::LevelFilter;
26use tracing_subscriber::filter::Targets;
27use tracing_subscriber::layer::SubscriberExt as _;
28use tracing_subscriber::util::SubscriberInitExt as _;
29
30/// Targets carrying the runtime's own internal diagnostics.
31///
32/// These are firehoses — `gc` logs every collection decision, `env` every
33/// symbol lookup — so [`base_filter`] pins them off rather than letting a
34/// blanket `--debug` turn them all on at once. Name the ones you want:
35/// `-X debug:gc,jit`, `CLJRS_X_FLAG=trace:env`, or `RUST_LOG=gc=debug`.
36///
37/// | Target | Emitted by |
38/// |---|---|
39/// | `gc` | `cljrs-gc`: collection cycles, region allocation |
40/// | `env` | `cljrs-runtime::env`: symbol lookup |
41/// | `ir` | `cljrs-runtime::tiered`: lowering, IR interpretation, cache eviction |
42/// | `jit` | `cljrs-runtime::tiered` and `cljrs-compiler::jit`: promotion, compilation, code-cache reclamation |
43pub const FEATURE_TARGETS: &[&str] = &["gc", "env", "ir", "jit"];
44
45/// Crates whose logging is noisy enough to drown out everything else.
46///
47/// Cranelift (and its register allocator) log whole function bodies of IR at
48/// `info`/`debug` through the `log` crate, which `tracing-subscriber`'s
49/// `tracing-log` bridge forwards into our subscriber. A single JIT compile
50/// therefore buries any real message. [`base_filter`] pins these to `warn`
51/// regardless of the requested default level; set `RUST_LOG` to see them.
52pub const NOISY_TARGETS: &[&str] = &[
53    "cranelift_codegen",
54    "cranelift_frontend",
55    "cranelift_jit",
56    "cranelift_module",
57    "cranelift_native",
58    "cranelift_object",
59    "regalloc2",
60];
61
62/// The filter a host starts from: everything at `default`, the codegen crates
63/// pinned to `warn`, and the runtime's [`FEATURE_TARGETS`] pinned off.
64pub fn base_filter(default: impl Into<LevelFilter>) -> Targets {
65    let mut filter = Targets::new().with_default(default.into());
66    for target in NOISY_TARGETS {
67        filter = filter.with_target(*target, LevelFilter::WARN);
68    }
69    for target in FEATURE_TARGETS {
70        filter = filter.with_target(*target, LevelFilter::OFF);
71    }
72    filter
73}
74
75/// Fold one `-X` / `CLJRS_X_FLAG` spec into `filter`.
76///
77/// Format: `<level>:<target1>,<target2>,…` where `<level>` is `debug` or
78/// `trace`, e.g. `debug:gc,jit` or `trace:env`. Any target name is accepted,
79/// including one nothing ever logs to.
80///
81/// Returns `Err` with a message if the format is invalid.
82pub fn apply_x_flag(mut filter: Targets, spec: &str) -> Result<Targets, String> {
83    let (level_str, targets) = spec
84        .split_once(':')
85        .ok_or_else(|| format!("expected <level>:<targets>, got: {spec}"))?;
86
87    let level = match level_str {
88        "debug" => LevelFilter::DEBUG,
89        "trace" => LevelFilter::TRACE,
90        other => {
91            return Err(format!(
92                "unknown level '{other}', expected 'debug' or 'trace'"
93            ));
94        }
95    };
96
97    for target in targets.split(',') {
98        let target = target.trim();
99        if target.is_empty() {
100            continue;
101        }
102        filter = filter.with_target(target.to_string(), level);
103    }
104    Ok(filter)
105}
106
107/// Install `filter` as the process's global subscriber, formatting to stderr.
108///
109/// Idempotent in the sense that a second call (or a host that installed its
110/// own subscriber first) is ignored rather than panicking.
111pub fn init(filter: Targets) {
112    let _ = tracing_subscriber::registry()
113        .with(filter)
114        .with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
115        .try_init();
116}
117
118/// Replace `base` with `RUST_LOG`'s filter when that variable is set and
119/// non-empty.
120///
121/// A spec that does not parse is reported on stderr and `base` is kept.
122/// `RUST_LOG` belongs to the ecosystem, not to us — plenty of crates read it,
123/// and a value we reject may be aimed at one of them — so an unusable value
124/// degrades the host to its own default rather than killing the process or
125/// silencing it. Both hosts in this workspace use this, so
126/// `RUST_LOG=<garbage>` means the same thing to the CLI and to an AOT binary.
127pub fn apply_rust_log(base: Targets) -> Targets {
128    let Ok(spec) = std::env::var("RUST_LOG") else {
129        return base;
130    };
131    if spec.trim().is_empty() {
132        return base;
133    }
134    match spec.parse::<Targets>() {
135        Ok(filter) => filter,
136        Err(e) => {
137            // No subscriber is installed yet, so this cannot go through `tracing`.
138            eprintln!("cljrs: ignoring RUST_LOG ({e}); using the default log filter");
139            base
140        }
141    }
142}
143
144/// Install a subscriber configured entirely from the environment.
145///
146/// Nothing is enabled by default — a binary that sets neither variable logs
147/// exactly as much as one with no subscriber at all. `RUST_LOG` is a full
148/// [`Targets`] spec (`gc=debug,cranelift_codegen=info`); `CLJRS_X_FLAG` names
149/// feature targets ([`apply_x_flag`]) and is layered on top, so both can be
150/// used together.
151///
152/// This is what a generated AOT harness calls, so `CLJRS_X_FLAG=debug:gc
153/// ./my-binary` behaves the same as `cljrs -X debug:gc run my-app.cljrs` —
154/// including on a value that does not parse. `CLJRS_X_FLAG` is ours alone and
155/// has exactly one meaning, so a bad one is an `Err` here just as a bad `-X` is
156/// a hard error in the CLI: the caller reports it and exits, because leaving
157/// someone who asked for diagnostics with none is the one outcome nobody wants.
158/// (`RUST_LOG` is treated more leniently — see [`apply_rust_log`].)
159pub fn init_from_env() -> Result<(), String> {
160    let mut filter = apply_rust_log(Targets::new());
161    if let Ok(spec) = std::env::var("CLJRS_X_FLAG")
162        && !spec.trim().is_empty()
163    {
164        filter = apply_x_flag(filter, &spec).map_err(|e| format!("invalid CLJRS_X_FLAG: {e}"))?;
165    }
166    init(filter);
167    Ok(())
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use tracing::Level;
174
175    /// A target the flag does not name keeps whatever the base filter gave it;
176    /// the ones it names are raised to the requested level.
177    #[test]
178    fn x_flag_raises_only_the_named_targets() {
179        let filter = apply_x_flag(base_filter(Level::INFO), "debug:gc,jit").unwrap();
180        assert!(filter.would_enable("gc", &Level::DEBUG));
181        assert!(filter.would_enable("jit", &Level::DEBUG));
182        // Named at debug, so trace stays off.
183        assert!(!filter.would_enable("gc", &Level::TRACE));
184        // Not named: still pinned off by `base_filter`.
185        assert!(!filter.would_enable("env", &Level::DEBUG));
186    }
187
188    #[test]
189    fn trace_level_enables_debug_too() {
190        let filter = apply_x_flag(Targets::new(), "trace:env").unwrap();
191        assert!(filter.would_enable("env", &Level::TRACE));
192        assert!(filter.would_enable("env", &Level::DEBUG));
193    }
194
195    /// A blanket `--debug` must not turn the runtime firehoses on; only `-X`
196    /// does. Ordinary crate targets still follow the default level.
197    #[test]
198    fn base_filter_pins_feature_and_noisy_targets() {
199        let filter = base_filter(Level::DEBUG);
200        for target in FEATURE_TARGETS {
201            assert!(
202                !filter.would_enable(target, &Level::DEBUG),
203                "{target} must stay off under a blanket --debug"
204            );
205        }
206        assert!(!filter.would_enable("cranelift_codegen", &Level::INFO));
207        assert!(filter.would_enable("cranelift_codegen", &Level::WARN));
208        assert!(filter.would_enable("some_other_crate", &Level::DEBUG));
209    }
210
211    #[test]
212    fn malformed_x_flags_are_rejected() {
213        assert!(apply_x_flag(Targets::new(), "bogus").is_err());
214        assert!(apply_x_flag(Targets::new(), "warn:gc").is_err());
215    }
216
217    /// `RUST_LOG` is process-global state, so these cases share one test rather
218    /// than racing each other across the thread pool.
219    #[test]
220    fn rust_log_replaces_the_base_but_never_silences_it() {
221        // SAFETY: single-threaded within this test; no other test reads RUST_LOG.
222        let restore = std::env::var("RUST_LOG").ok();
223
224        unsafe { std::env::remove_var("RUST_LOG") };
225        assert!(
226            apply_rust_log(base_filter(Level::INFO)).would_enable("anything", &Level::INFO),
227            "unset RUST_LOG keeps the base filter"
228        );
229
230        unsafe { std::env::set_var("RUST_LOG", "   ") };
231        assert!(
232            apply_rust_log(base_filter(Level::INFO)).would_enable("anything", &Level::INFO),
233            "blank RUST_LOG keeps the base filter"
234        );
235
236        unsafe { std::env::set_var("RUST_LOG", "gc=debug") };
237        let parsed = apply_rust_log(base_filter(Level::INFO));
238        assert!(
239            parsed.would_enable("gc", &Level::DEBUG),
240            "a valid RUST_LOG replaces the base, unpinning the feature targets"
241        );
242
243        // The point of the fallback: a typo degrades to the host's default
244        // rather than to silence, and it degrades the same way for every host.
245        unsafe { std::env::set_var("RUST_LOG", "=[not a filter]=") };
246        let fallback = apply_rust_log(base_filter(Level::INFO));
247        assert!(
248            fallback.would_enable("anything", &Level::INFO),
249            "an unparseable RUST_LOG must not silence the process"
250        );
251        assert!(
252            !fallback.would_enable("gc", &Level::DEBUG),
253            "...and must leave the base filter's pins intact"
254        );
255
256        match restore {
257            Some(v) => unsafe { std::env::set_var("RUST_LOG", v) },
258            None => unsafe { std::env::remove_var("RUST_LOG") },
259        }
260    }
261}