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}