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