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}