polyc_runtime/observability.rs
1//! Logging + traces + panic discipline (PRD §11).
2//!
3//! All logs go to **stderr** (stdout is reserved for program data). Format
4//! is selected by `RUST_LOG_FORMAT` (`json` in prod, pretty otherwise);
5//! verbosity by `RUST_LOG` via `EnvFilter`.
6//!
7//! When `OTEL_EXPORTER_OTLP_ENDPOINT` is set, the tracing subscriber also
8//! gains an OpenTelemetry layer that batches spans and ships them via OTLP
9//! gRPC to the configured collector. `OTEL_SERVICE_NAME` overrides the
10//! service name (defaults to the caller's argument). Dev runs without
11//! that env var pay zero overhead — the layer is not constructed.
12//!
13//! Spans propagate through the in-process `tokio` runtime via
14//! `tracing-opentelemetry`; cross-process propagation across the
15//! `connectrpc` boundary uses the W3C `traceparent` header — wire it through
16//! the dialer when running in production.
17
18use std::io;
19use std::sync::OnceLock;
20
21use opentelemetry::KeyValue;
22use opentelemetry::global;
23use opentelemetry::trace::TracerProvider as _;
24use opentelemetry_otlp::SpanExporter;
25use opentelemetry_sdk::Resource;
26use opentelemetry_sdk::propagation::TraceContextPropagator;
27use opentelemetry_sdk::trace::SdkTracerProvider;
28use tokio::sync::broadcast;
29use tracing_subscriber::fmt::MakeWriter;
30use tracing_subscriber::{EnvFilter, fmt, prelude::*};
31
32/// How many recent log lines the in-process broadcast ring buffers for a slow
33/// subscriber before it starts dropping the oldest. The local dashboard's log
34/// stream is a live tail, not an audit trail, so lagging is fine.
35const LOG_BROADCAST_CAPACITY: usize = 512;
36
37/// Process-wide sender for the live log stream, set once by [`init`]. A
38/// `OnceLock` (not passed through every call site) so any in-process surface —
39/// the local dashboard's log route in particular — can `subscribe` without the
40/// binary threading a handle down to it.
41static LOG_BROADCAST: OnceLock<broadcast::Sender<String>> = OnceLock::new();
42
43/// Subscribe to the live process-log stream.
44///
45/// Returns a receiver that yields one formatted log line per emitted `tracing`
46/// event (the same text written to stderr), or `None` when [`init`] has not run
47/// yet. A receiver that falls behind the bounded ring drops the oldest lines
48/// rather than stalling the writer.
49#[must_use]
50pub fn subscribe_logs() -> Option<broadcast::Receiver<String>> {
51 LOG_BROADCAST.get().map(broadcast::Sender::subscribe)
52}
53
54/// A `MakeWriter` that tees each formatted log line to stderr and to the live
55/// broadcast. Cloning shares the same sender.
56#[derive(Clone)]
57struct BroadcastTee {
58 tx: broadcast::Sender<String>,
59}
60
61impl<'a> MakeWriter<'a> for BroadcastTee {
62 type Writer = TeeLine;
63
64 fn make_writer(&'a self) -> Self::Writer {
65 TeeLine {
66 buf: Vec::new(),
67 tx: self.tx.clone(),
68 }
69 }
70}
71
72/// One event's worth of formatted bytes: written straight through to stderr and
73/// accumulated so the whole line can be broadcast when the writer drops (the
74/// `fmt` layer makes a fresh writer per event and drops it once written).
75struct TeeLine {
76 buf: Vec<u8>,
77 tx: broadcast::Sender<String>,
78}
79
80impl io::Write for TeeLine {
81 fn write(&mut self, data: &[u8]) -> io::Result<usize> {
82 // stdout is reserved for program data; all logs go to stderr.
83 io::stderr().write_all(data)?;
84 self.buf.extend_from_slice(data);
85 Ok(data.len())
86 }
87
88 fn flush(&mut self) -> io::Result<()> {
89 io::stderr().flush()
90 }
91}
92
93impl Drop for TeeLine {
94 fn drop(&mut self) {
95 if self.buf.is_empty() {
96 return;
97 }
98 // A lossy decode keeps a stray non-UTF-8 byte from dropping the line.
99 let line = String::from_utf8_lossy(&self.buf).trim_end().to_owned();
100 if !line.is_empty() {
101 // `send` errs only when there are no receivers; the buffered ring
102 // means that is the sole failure mode, and it is not one to log.
103 let _ = self.tx.send(line);
104 }
105 }
106}
107
108/// Handle returned from [`init`] — keep it alive for the process lifetime;
109/// `drop` flushes traces before exit. `SdkTracerProvider::shutdown` blocks
110/// until exporters drain.
111pub struct ShutdownGuard {
112 provider: Option<SdkTracerProvider>,
113}
114
115impl Drop for ShutdownGuard {
116 fn drop(&mut self) {
117 if let Some(provider) = self.provider.take() {
118 // Best-effort: log to stderr and continue if flush fails.
119 if let Err(err) = provider.shutdown() {
120 eprintln!("opentelemetry shutdown failed: {err}");
121 }
122 }
123 }
124}
125
126/// Initialise the global tracing subscriber + (optional) `OTel` layer.
127///
128/// Also installs the panic hook. Call once, early in `main`. The returned
129/// guard flushes traces on drop — keep it alive for the process lifetime.
130#[must_use]
131pub fn init(service_name: &str) -> ShutdownGuard {
132 let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
133 let json = std::env::var("RUST_LOG_FORMAT").is_ok_and(|v| v.eq_ignore_ascii_case("json"));
134
135 let (tracer, provider) = build_otel_tracer(service_name);
136 let otel_layer = tracer.map(|t| tracing_opentelemetry::layer().with_tracer(t));
137
138 // Install the W3C `traceparent` propagator unconditionally. The format
139 // half is cheap (a unit struct), and installing it always means the
140 // dialer's `inject_*` calls and the server's `extract_*` calls behave
141 // consistently whether or not OTLP export is enabled — without it, the
142 // global getter returns a no-op propagator and `traceparent` headers
143 // silently disappear, which is the worst-of-both-worlds debug state.
144 // See `crate::propagation` for the matching inject/extract helpers.
145 global::set_text_map_propagator(TraceContextPropagator::new());
146
147 // Tee every formatted log line to stderr and to a process-wide broadcast the
148 // local dashboard tails. The ring is bounded, so a stalled subscriber never
149 // slows logging. Setting the sender always (not only when subscribed) means
150 // the very first lines are already flowing before anyone connects.
151 let (log_tx, _seed) = broadcast::channel(LOG_BROADCAST_CAPACITY);
152 let _ = LOG_BROADCAST.set(log_tx.clone());
153 let tee = BroadcastTee { tx: log_tx };
154
155 let registry = tracing_subscriber::registry().with(filter).with(otel_layer);
156 if json {
157 let fmt_layer = fmt::layer().json().flatten_event(true).with_writer(tee);
158 registry.with(fmt_layer).init();
159 } else {
160 let fmt_layer = fmt::layer().with_writer(tee);
161 registry.with(fmt_layer).init();
162 }
163
164 install_panic_hook();
165 ShutdownGuard { provider }
166}
167
168/// Build the OpenTelemetry tracer (the thing the tracing layer wraps), or
169/// `(None, None)` if no OTLP endpoint is configured. Also installs the
170/// global tracer provider so libraries that talk to
171/// `opentelemetry::global` see it.
172///
173/// Caveat: "the global" means *this* otel version's static. The lock
174/// currently carries a second otel stack (0.31, pinned by
175/// commonware-runtime — see the lockstep comment in the workspace
176/// Cargo.toml) whose own `global` stays the no-op default; only libraries
177/// emitting through the shared `tracing` facade are version-proof.
178fn build_otel_tracer(
179 service_name: &str,
180) -> (
181 Option<opentelemetry_sdk::trace::Tracer>,
182 Option<SdkTracerProvider>,
183) {
184 if std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").is_err() {
185 return (None, None);
186 }
187 let resolved_name = std::env::var("OTEL_SERVICE_NAME")
188 .ok()
189 .unwrap_or_else(|| service_name.to_owned());
190 let exporter = match SpanExporter::builder().with_tonic().build() {
191 Ok(exp) => exp,
192 Err(err) => {
193 eprintln!("opentelemetry OTLP exporter build failed: {err}; continuing without traces");
194 return (None, None);
195 }
196 };
197 let resource = Resource::builder()
198 .with_attribute(KeyValue::new("service.name", resolved_name.clone()))
199 .build();
200 let provider = SdkTracerProvider::builder()
201 .with_batch_exporter(exporter)
202 .with_resource(resource)
203 .build();
204 let tracer = provider.tracer(resolved_name);
205 global::set_tracer_provider(provider.clone());
206 (Some(tracer), Some(provider))
207}
208
209/// Emit a single structured `error` event on panic, then defer to the previous
210/// hook (which, under `panic = "abort"`, terminates the process).
211fn install_panic_hook() {
212 let previous = std::panic::take_hook();
213 std::panic::set_hook(Box::new(move |info| {
214 let location = info
215 .location()
216 .map_or_else(|| "unknown".to_owned(), ToString::to_string);
217 tracing::error!(panic = %info, location = %location, "process panicked");
218 previous(info);
219 }));
220}