git_remote_object_store/protocol/tracing_init.rs
1//! Stderr-only `tracing` subscriber for the helper-protocol binaries.
2//!
3//! The remote-helper binaries speak the git transport protocol on stdout —
4//! see `.claude/rules/protocol-stdout.md` — so every log line MUST go to
5//! stderr. The subscriber returned here is wired with [`std::io::stderr`]
6//! as its writer; the `EnvFilter` is wrapped in a [`reload::Layer`] so
7//! the protocol REPL can raise the level to `info` at runtime in
8//! response to `option verbosity 2+`. The reload is one-way — there is
9//! no path that lowers the level once raised.
10//!
11//! Startup level is `error`, with one env-var override honoured:
12//!
13//! - `GIT_REMOTE_OBJECT_STORE_VERBOSE` — canonical name for this crate.
14//!
15//! A numeric value `>= 2` bumps the start level to `info`, matching the
16//! `option verbosity 2` threshold from the helper protocol.
17
18use std::env;
19
20use tracing::Level;
21use tracing_subscriber::EnvFilter;
22use tracing_subscriber::filter::LevelFilter;
23use tracing_subscriber::layer::SubscriberExt;
24use tracing_subscriber::reload;
25use tracing_subscriber::util::SubscriberInitExt;
26
27/// Canonical env var read at startup to bump the verbosity floor.
28pub const ENV_VERBOSE: &str = "GIT_REMOTE_OBJECT_STORE_VERBOSE";
29
30/// Handle returned by [`init`] so callers can flip the subscriber's filter
31/// at runtime. The underlying layer type is intentionally hidden behind a
32/// type alias to keep the public surface minimal.
33pub type ReloadHandle = reload::Handle<EnvFilter, tracing_subscriber::Registry>;
34
35/// Initialise the global subscriber and return a handle to its filter.
36///
37/// # Errors
38///
39/// Returns [`InitError`] if a global subscriber is already set (the
40/// subscriber can only be initialised once per process). The helper
41/// bins call this exactly once from `run_main`; tests that set up their
42/// own subscriber will receive `Err` here.
43pub fn init() -> Result<ReloadHandle, InitError> {
44 let initial = build_initial_filter();
45 let (filter, handle) = reload::Layer::new(initial);
46
47 tracing_subscriber::registry()
48 .with(filter)
49 .with(
50 tracing_subscriber::fmt::layer()
51 .with_writer(std::io::stderr)
52 .with_target(false),
53 )
54 .try_init()
55 .map_err(|source| InitError {
56 source: source.into(),
57 })?;
58
59 Ok(handle)
60}
61
62/// Raise the subscriber to `info` level. One-way: there is no inverse
63/// that lowers the level. Called by `option verbosity 2+`.
64///
65/// # Errors
66///
67/// Returns [`InitError`] if the reload handle has been invalidated (the
68/// subscriber was dropped).
69pub fn raise_to_info(handle: &ReloadHandle) -> Result<(), InitError> {
70 handle
71 .modify(|filter| *filter = info_filter())
72 .map_err(|source| InitError {
73 source: Box::new(source),
74 })
75}
76
77/// Errors surfaced by [`init`] / [`raise_to_info`]. Boxed so the public
78/// surface does not leak `tracing-subscriber`'s error types.
79#[derive(Debug, thiserror::Error)]
80#[error("failed to initialise tracing subscriber: {source}")]
81pub struct InitError {
82 #[source]
83 source: Box<dyn std::error::Error + Send + Sync + 'static>,
84}
85
86fn build_initial_filter() -> EnvFilter {
87 if env_verbose_at_least(2) {
88 info_filter()
89 } else {
90 EnvFilter::default().add_directive(LevelFilter::ERROR.into())
91 }
92}
93
94fn info_filter() -> EnvFilter {
95 EnvFilter::default().add_directive(Level::INFO.into())
96}
97
98fn env_verbose_at_least(threshold: u32) -> bool {
99 read_verbose_env() >= threshold
100}
101
102fn read_verbose_env() -> u32 {
103 env::var(ENV_VERBOSE)
104 .ok()
105 .and_then(|v| parse_verbose(&v))
106 .unwrap_or(0)
107}
108
109fn parse_verbose(value: &str) -> Option<u32> {
110 value.trim().parse::<u32>().ok()
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 #[test]
118 fn parse_verbose_accepts_decimal() {
119 assert_eq!(parse_verbose("2"), Some(2));
120 assert_eq!(parse_verbose(" 3 "), Some(3));
121 assert_eq!(parse_verbose("0"), Some(0));
122 }
123
124 #[test]
125 fn parse_verbose_rejects_non_decimal() {
126 assert_eq!(parse_verbose("foo"), None);
127 assert_eq!(parse_verbose(""), None);
128 assert_eq!(parse_verbose("-1"), None);
129 }
130
131 /// Pins the single-knob policy for every binary in the crate
132 /// (helper bins, LFS agent, management CLI): `RUST_LOG` MUST NOT
133 /// influence startup verbosity. Only [`ENV_VERBOSE`] is consulted.
134 ///
135 /// Regression guard for #179 (management CLI was reading `RUST_LOG`
136 /// via `EnvFilter::try_from_default_env`) and #180 (LFS agent's
137 /// non-debug REPL path pinned `error` and ignored `ENV_VERBOSE`).
138 /// Both fixes routed those entry points through this module so
139 /// all three binaries share one verbosity policy.
140 #[test]
141 fn read_verbose_env_ignores_rust_log() {
142 // `EnvGuard` holds a per-key serialization lock for each of
143 // `RUST_LOG` and `ENV_VERBOSE` and restores their prior values
144 // on drop — so a panic between the mutation and the assertion
145 // cannot leak either var into subsequent tests.
146 let _rust_log = crate::test_util::EnvGuard::set("RUST_LOG", "trace");
147 let verbose = crate::test_util::EnvGuard::unset(ENV_VERBOSE);
148 assert_eq!(
149 read_verbose_env(),
150 0,
151 "RUST_LOG must not influence verbosity; only {ENV_VERBOSE} does",
152 );
153 assert!(
154 !env_verbose_at_least(2),
155 "default floor is below info even when RUST_LOG=trace",
156 );
157
158 // Confirm the inverse: ENV_VERBOSE is the *only* knob that
159 // raises the floor. Setting it alongside RUST_LOG yields the
160 // info filter, proving the policy is single-source.
161 verbose.set_to("2");
162 assert!(env_verbose_at_least(2));
163 }
164}