zeroclaw_log/subscriber.rs
1//! Global tracing-subscriber installation. The only public entry
2//! point a daemon binary needs. Owns the agent-alias-prefixed
3//! formatter and the `LogCaptureLayer` wiring so the rest of the
4//! workspace never names a `tracing` or `tracing_subscriber` type.
5
6use tracing::Subscriber;
7use tracing_subscriber::EnvFilter;
8use tracing_subscriber::Layer;
9use tracing_subscriber::fmt;
10use tracing_subscriber::fmt::FormatFields;
11use tracing_subscriber::fmt::format::Writer;
12use tracing_subscriber::layer::SubscriberExt;
13use tracing_subscriber::registry::LookupSpan;
14
15use crate::event::ZeroclawAttribution;
16use crate::layer::LogCaptureLayer;
17
18/// Install the global tracing subscriber. Two independent axes:
19///
20/// * **Recording floor** — what reaches the `LogCaptureLayer` (and thus
21/// the JSONL writer, broadcast hook, and Observer bridge). Resolved
22/// as: `recording_override` (the `--log-level` flag) if `Some`,
23/// else `RUST_LOG` from the environment, else `default_filter`.
24///
25/// * **Terminal display** — the stderr fmt layer. Gated entirely by
26/// `verbose`: when `false` the fmt layer is muted (no log lines ever
27/// reach the terminal; direct `println!`/stdout is untouched). When
28/// `true` it surfaces events down to the same recording floor.
29///
30/// All filter strings are `RUST_LOG`-compatible directives (e.g.
31/// `"info"` or `"debug,matrix_sdk=warn"`).
32///
33/// Both axes are fixed for the process lifetime — the global subscriber
34/// is installed once and cannot be reconfigured without a restart.
35///
36/// Panics on subscriber install failure — the daemon cannot operate
37/// without logging.
38pub fn install_global_subscriber(
39 recording_override: Option<&str>,
40 default_filter: &str,
41 verbose: bool,
42) {
43 // Recording floor: explicit flag wins, then RUST_LOG, then default.
44 let recording_filter = match recording_override {
45 Some(flag) => EnvFilter::new(flag),
46 None => {
47 EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_filter))
48 }
49 };
50
51 // The fmt (terminal) layer carries its own filter so display can be
52 // muted without touching what the capture layer records. When
53 // verbose is off, an OFF filter discards every event before it
54 // formats — stdout (println!) is unaffected because it never routes
55 // through tracing.
56 let fmt_filter = if verbose {
57 match recording_override {
58 Some(flag) => EnvFilter::new(flag),
59 None => {
60 EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_filter))
61 }
62 }
63 } else {
64 EnvFilter::new("off")
65 };
66
67 let fmt_layer = fmt::layer()
68 .with_writer(std::io::stderr)
69 .event_format(AgentAliasFormatter::new())
70 .with_filter(fmt_filter);
71
72 let subscriber = tracing_subscriber::registry()
73 .with(LogCaptureLayer.with_filter(recording_filter))
74 .with(fmt_layer);
75
76 tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed");
77}
78
79/// Test-only helper: install a minimal global subscriber that routes
80/// `record!` emissions through `LogCaptureLayer` (and thus the broadcast
81/// hook) without any terminal fmt output. Returns a guard that resets
82/// the broadcast hook on drop. Use in combination with
83/// [`crate::subscribe`] to capture events from a unit test without
84/// the test crate depending on `tracing` / `tracing-subscriber`.
85///
86/// Idempotent: subsequent calls are no-ops if a subscriber is already
87/// installed (the global default cannot be replaced once set). For
88/// isolated capture across multiple tests, use the broadcast hook
89/// directly without changing the global subscriber.
90#[doc(hidden)]
91pub fn try_install_capture_subscriber() {
92 use tracing_subscriber::Registry;
93 let subscriber = Registry::default().with(LogCaptureLayer);
94 let _ = tracing::subscriber::set_global_default(subscriber);
95}
96
97/// Tracing event formatter that prefixes each log line with the most
98/// specific alias-bound label available in the current span scope.
99/// `agent_alias` wins; falls back to the channel composite; finally
100/// to `[system]` for boot / migration / install-wide messages.
101struct AgentAliasFormatter {
102 inner: fmt::format::Format<fmt::format::Full, fmt::time::SystemTime>,
103}
104
105impl AgentAliasFormatter {
106 fn new() -> Self {
107 Self {
108 inner: fmt::format::Format::default(),
109 }
110 }
111}
112
113impl<S, N> fmt::FormatEvent<S, N> for AgentAliasFormatter
114where
115 S: Subscriber + for<'a> LookupSpan<'a>,
116 N: for<'writer> FormatFields<'writer> + 'static,
117{
118 fn format_event(
119 &self,
120 ctx: &fmt::FmtContext<'_, S, N>,
121 mut writer: Writer<'_>,
122 event: &tracing::Event<'_>,
123 ) -> std::fmt::Result {
124 let label = ctx
125 .event_scope()
126 .and_then(|scope| {
127 scope.into_iter().find_map(|span| {
128 span.extensions()
129 .get::<ZeroclawAttribution>()
130 .and_then(|attribution| {
131 attribution
132 .get("agent_alias")
133 .or_else(|| attribution.get("channel"))
134 .map(str::to_string)
135 })
136 })
137 })
138 .unwrap_or_else(|| "system".to_string());
139 write!(writer, "[{label}] ")?;
140 self.inner.format_event(ctx, writer, event)
141 }
142}