Skip to main content

degenbot_core/
telemetry.rs

1//! The observability facade (ADR-043).
2//!
3//! The standard puts every diagnostic on a **closed** `degenbot::<domain>`
4//! target, keeps engine diagnostics inside an engine span, and provides a
5//! single entry point so call sites cannot hand-write a target, derive a
6//! `[tag]` prefix, or emit a bare `tracing::info!` that bypasses the level
7//! rubric.
8//!
9//! This module owns:
10//!
11//! - [`domain`] — the closed target set (single source of truth for the
12//!   Phase-4 `telemetry.diag` control surface and the enforcement gate).
13//! - the domain-target and event macros: `diag!`, `op_info!`, `op_warn!`,
14//!   `op_error!`, `op_span!`.
15//! - the engine-span guard ([`guard_diag`]) that fires when a diagnostic is
16//!   emitted with no active engine span (ADR-043 §3).
17//!
18//! The core crates emit through these macros; the pure-Rust binary and the
19//! `PyO3` binding install sinks (the facade never installs a subscriber).
20
21/// The closed target domain set (ADR-043 §3).
22///
23/// A diagnostic is a `debug` event under its own domain; the `OTel` spans layer
24/// raises `degenbot=debug` by default so every domain survives in traces.
25pub mod domain {
26    /// Pool/token state mutations and applies.
27    pub const STATE: &str = "degenbot::state";
28    /// Path registration and lifecycle.
29    pub const PATH: &str = "degenbot::path";
30    /// Solve dispatch and path walking.
31    pub const SOLVER: &str = "degenbot::solver";
32    /// Simulation and revert diagnostics.
33    pub const SIM: &str = "degenbot::sim";
34    /// Block pump, drain, dispatch, and quiesce.
35    pub const PUMP: &str = "degenbot::pump";
36    /// Execution seam and submit decisions.
37    pub const EXEC: &str = "degenbot::exec";
38    /// Liquidity verification and divergence probes.
39    pub const VERIFY: &str = "degenbot::verify";
40    /// WS/HTTP log and header ingestion.
41    pub const INGEST: &str = "degenbot::ingest";
42    /// RPC provider and transport.
43    pub const RPC: &str = "degenbot::rpc";
44    /// Aave updater and event processing.
45    pub const AAVE: &str = "degenbot::aave";
46
47    /// Every domain, for the config validator and the enforcement gate.
48    pub const ALL: [&str; 10] = [
49        STATE, PATH, SOLVER, SIM, PUMP, EXEC, VERIFY, INGEST, RPC, AAVE,
50    ];
51
52    /// `true` iff `target` is one of the closed domain targets.
53    #[must_use]
54    pub fn is_valid(target: &str) -> bool {
55        ALL.contains(&target)
56    }
57}
58
59/// Every engine span name starts with this prefix (ADR-043 §3).
60pub const ENGINE_SPAN_PREFIX: &str = "degenbot.";
61
62/// `true` when the current (thread-local) span is an engine span.
63///
64/// Engine diagnostics must be emitted inside an engine span so they export to
65/// Jaeger as span events; the `OTel` layer is spans-only, so an event outside a
66/// span is silently dropped by the backend.
67#[must_use]
68pub fn in_engine_span() -> bool {
69    tracing::Span::current()
70        .metadata()
71        .is_some_and(|meta| meta.name().starts_with(ENGINE_SPAN_PREFIX))
72}
73
74/// Once-only latch for the release-mode engine-span guard warning.
75static DIAG_GUARD_WARNED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
76
77/// Guard a diagnostic event: assert (debug) / warn once (release) if no engine
78/// span is active. Called by the `diag!` facade macro.
79pub fn guard_diag(context: &str) {
80    if in_engine_span() {
81        return;
82    }
83    // The abort-on-regression form is opt-in (`strict-engine-spans`): a
84    // diagnostic regression must not take down a live worker thread. The
85    // one-shot WARN below is the always-on signal (ADR-043 §3).
86    #[cfg(feature = "strict-engine-spans")]
87    debug_assert!(
88        false,
89        "diagnostic emitted outside an engine span from {context} (ADR-043 §3)"
90    );
91    if !DIAG_GUARD_WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
92        tracing::warn!(
93            target: DIAGNOSTIC_TARGET,
94            context,
95            "diagnostic emitted outside an engine span (this warning fires once; ADR-043 §3)"
96        );
97    }
98}
99
100/// The diagnostic target used by failure events (matches `degenbot-bot`'s
101/// `DIAGNOSTIC_TARGET`); panic failures ride it.
102pub const DIAGNOSTIC_TARGET: &str = "degenbot::diag";
103
104/// Install the process panic hook (ADR-043 §2).
105///
106/// Fires exactly one ERROR carrying the panic payload and the thread/task name,
107/// with the active span marked ERROR so the `OTel` layer exports it as a span
108/// exception. Chains the previously installed hook, so the default backtrace
109/// still prints. Both consumers (the pure-Rust binary and the `PyO3` binding)
110/// call this once at startup; the last installer wins, which is fine because
111/// the behavior is identical.
112pub fn install_panic_hook() {
113    let previous = std::panic::take_hook();
114    std::panic::set_hook(Box::new(move |info| {
115        let thread = std::thread::current();
116        let thread = thread.name().unwrap_or("<unnamed>").to_owned();
117        let payload = info
118            .payload()
119            .downcast_ref::<&str>()
120            .map(|s| (*s).to_owned())
121            .or_else(|| info.payload().downcast_ref::<String>().cloned())
122            .unwrap_or_else(|| info.to_string());
123        tracing::Span::current().record("otel.status_code", "ERROR");
124        tracing::error!(
125            target: DIAGNOSTIC_TARGET,
126            exception_type = "panic",
127            thread,
128            payload,
129            "panic captured"
130        );
131        previous(info);
132    }));
133}
134
135/// Map a domain token to its closed target, or fail the build.
136///
137/// This is the compile-time half of the closed-set guarantee: a misspelled
138/// domain is a `compile_error!`, not a runtime no-op.
139///
140/// ```
141/// assert_eq!(degenbot_core::telemetry_target!(sim), "degenbot::sim");
142/// assert_eq!(degenbot_core::telemetry_target!(aave), "degenbot::aave");
143/// ```
144///
145/// ```compile_fail
146/// // A misspelled domain fails the build instead of silently no-op'ing.
147/// let _ = degenbot_core::telemetry_target!(sim2);
148/// ```
149#[macro_export]
150macro_rules! telemetry_target {
151    (state) => {
152        $crate::telemetry::domain::STATE
153    };
154    (path) => {
155        $crate::telemetry::domain::PATH
156    };
157    (solver) => {
158        $crate::telemetry::domain::SOLVER
159    };
160    (sim) => {
161        $crate::telemetry::domain::SIM
162    };
163    (pump) => {
164        $crate::telemetry::domain::PUMP
165    };
166    (exec) => {
167        $crate::telemetry::domain::EXEC
168    };
169    (verify) => {
170        $crate::telemetry::domain::VERIFY
171    };
172    (ingest) => {
173        $crate::telemetry::domain::INGEST
174    };
175    (rpc) => {
176        $crate::telemetry::domain::RPC
177    };
178    (aave) => {
179        $crate::telemetry::domain::AAVE
180    };
181    ($other:ident) => {
182        ::core::compile_error!(::core::concat!(
183            "unknown telemetry domain `",
184            ::core::stringify!($other),
185            "`; valid domains: state,path,solver,sim,pump,exec,verify,ingest,rpc,aave"
186        ))
187    };
188}
189
190/// A DEBUG diagnostic under a closed domain target, with the engine-span guard.
191#[macro_export]
192macro_rules! diag {
193    (domain = $domain:ident, $($rest:tt)*) => {{
194        $crate::telemetry::guard_diag(::core::module_path!());
195        ::tracing::debug!(target: $crate::telemetry_target!($domain), $($rest)*)
196    }};
197}
198
199/// A TRACE forensic diagnostic under a closed domain target (full-field
200/// dumps: call traces, tick maps). Never on the console by default — the sink
201/// must enable `degenbot=trace`; the phase-4 forensic file sink captures it.
202#[macro_export]
203macro_rules! diag_trace {
204    (domain = $domain:ident, $($rest:tt)*) => {{
205        $crate::telemetry::guard_diag(::core::module_path!());
206        ::tracing::trace!(target: $crate::telemetry_target!($domain), $($rest)*)
207    }};
208}
209
210/// A DEBUG lifecycle event under a closed domain target (the boot/census
211/// verdicts: process-wide sizing, runtime construction). Deliberately
212/// unguarded — unlike [`diag!`] these fire BEFORE any engine span exists, so
213/// the engine-span guard would only raise false alarms.
214#[macro_export]
215macro_rules! op_debug {
216    (domain = $domain:ident, $($rest:tt)*) => {
217        ::tracing::debug!(target: $crate::telemetry_target!($domain), $($rest)*)
218    };
219}
220
221/// An INFO lifecycle event under a closed domain target.
222#[macro_export]
223macro_rules! op_info {
224    (domain = $domain:ident, $($rest:tt)*) => {
225        ::tracing::info!(target: $crate::telemetry_target!($domain), $($rest)*)
226    };
227}
228
229/// A WARN event under a closed domain target.
230#[macro_export]
231macro_rules! op_warn {
232    (domain = $domain:ident, $($rest:tt)*) => {
233        ::tracing::warn!(target: $crate::telemetry_target!($domain), $($rest)*)
234    };
235}
236
237/// An ERROR event under a closed domain target.
238#[macro_export]
239macro_rules! op_error {
240    (domain = $domain:ident, $($rest:tt)*) => {
241        ::tracing::error!(target: $crate::telemetry_target!($domain), $($rest)*)
242    };
243}
244
245/// An INFO span named on the domain target (the engine-span namespace).
246#[macro_export]
247macro_rules! op_span {
248    (domain = $domain:ident, $name:literal $(, $($field:tt)*)?) => {
249        ::tracing::info_span!(
250            target: $crate::telemetry_target!($domain),
251            $name $(, $($field)*)?
252        )
253    };
254}
255
256/// Closed list of retired verbosity env names (ADR-043 §5) mapped to the
257/// equivalent domain directive. Detection only — no alias is honored. The
258/// shim is deleted at 0.7 alongside the other gated cleanups.
259pub const RETIRED_ENV_NAMES: &[(&str, &str)] = &[
260    ("DEGENBOT_VERIFY_DBG", "degenbot::verify=debug"),
261    ("DEGENBOT_V2_CALC_TRACE", "degenbot::sim=debug"),
262    ("DEGENBOT_SIM_LOG_REVERTED_SWAPS", "degenbot::sim=debug"),
263    ("DEGENBOT_SIM_DIVERGENCE_LOG", "degenbot::sim=debug"),
264    ("DEGENBOT_DUMP_CALL_TRACE", "degenbot::sim=trace"),
265    ("DEGENBOT_DUMP_TICK_MAPS", "degenbot::state=trace"),
266    ("DEGENBOT_WS_TRACE", "degenbot::ingest=debug"),
267    ("DEGENBOT_DRAIN_DBG", "degenbot::pump=debug"),
268    ("DEGENBOT_TRACE_DISPATCH", "degenbot::pump=debug"),
269    ("DEGENBOT_TRACE_REGISTER_SEED", "degenbot::path=debug"),
270    ("DEGENBOT_TRACE_LIQUIDITY", "degenbot::state=debug"),
271    ("DEGENBOT_TRACE_TICK", "degenbot::state=debug"),
272    ("DEGENBOT_GATE_TRACE", "degenbot::solver=debug"),
273    ("DEGENBOT_AAVE_EVTRACE", "degenbot::aave=debug"),
274    ("DEGENBOT_AAVE_TX_TRACE", "degenbot::aave=debug"),
275    // The retired two-tunnel console hack (ADR-043 §6): exactly one
276    // console-emitting writer per process is derived from binding-present.
277    (
278        "DEGENBOT_LOG_FMT",
279        "the console writer is derived (one writer per process)",
280    ),
281];
282
283/// Boot-time detection (ADR-043 §5): WARN once per retired env name present in
284/// the process environment, naming the equivalent domain directive. The
285/// retired names no longer control anything, so this is a loud migration
286/// signal rather than a compatibility shim. Idempotent and cheap; call once at
287/// process boot.
288pub fn warn_retired_env_names() {
289    for (name, directive) in RETIRED_ENV_NAMES {
290        if let Ok(value) = std::env::var(name) {
291            crate::op_warn!(
292                domain = pump,
293                retired_env = %name,
294                value = %value,
295                equivalent = %directive,
296                "retired telemetry flag set; use the domain directive instead (ADR-043 section 5)"
297            );
298        }
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::domain;
305    use std::fmt::Write as _;
306    use std::sync::{Arc, Mutex};
307    use tracing_subscriber::layer::SubscriberExt;
308
309    static PANIC_HOOK_LOCK: Mutex<()> = Mutex::new(());
310
311    #[derive(Clone, Default)]
312    struct Capture {
313        events: Arc<Mutex<Vec<String>>>,
314        records: Arc<Mutex<Vec<String>>>,
315    }
316
317    impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for Capture {
318        fn on_event(
319            &self,
320            event: &tracing::Event<'_>,
321            _ctx: tracing_subscriber::layer::Context<'_, S>,
322        ) {
323            if *event.metadata().level() != tracing::Level::ERROR {
324                return;
325            }
326            let mut text = String::new();
327            event.record(&mut Fmt(&mut text));
328            self.events
329                .lock()
330                .unwrap_or_else(std::sync::PoisonError::into_inner)
331                .push(text);
332        }
333
334        fn on_record(
335            &self,
336            _span: &tracing::span::Id,
337            values: &tracing::span::Record<'_>,
338            _ctx: tracing_subscriber::layer::Context<'_, S>,
339        ) {
340            let mut text = String::new();
341            values.record(&mut Fmt(&mut text));
342            self.records
343                .lock()
344                .unwrap_or_else(std::sync::PoisonError::into_inner)
345                .push(text);
346        }
347    }
348
349    struct Fmt<'a>(&'a mut String);
350
351    impl tracing::field::Visit for Fmt<'_> {
352        fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
353            let _ = write!(self.0, "{}={value:?} ", field.name());
354        }
355
356        fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
357            let _ = write!(self.0, "{}={value} ", field.name());
358        }
359    }
360
361    #[test]
362    fn domain_set_is_closed_and_complete() {
363        assert!(domain::is_valid(domain::SIM));
364        assert!(domain::is_valid(domain::AAVE));
365        assert!(!domain::is_valid("degenbot::diag"));
366        assert!(!domain::is_valid("sim"));
367        assert_eq!(domain::ALL.len(), 10);
368    }
369
370    #[test]
371    fn engine_span_detection_uses_the_degenbot_prefix() {
372        let subscriber = tracing_subscriber::registry();
373        tracing::subscriber::with_default(subscriber, || {
374            assert!(!super::in_engine_span());
375            let span = tracing::info_span!("degenbot.test.engine");
376            let _guard = span.enter();
377            assert!(super::in_engine_span());
378        });
379    }
380
381    #[test]
382    #[expect(clippy::panic)] // the test panics on purpose
383    fn panic_hook_emits_one_error_and_marks_the_active_span_error() {
384        let _guard = PANIC_HOOK_LOCK
385            .lock()
386            .unwrap_or_else(std::sync::PoisonError::into_inner);
387
388        let capture = Capture::default();
389        let subscriber = tracing_subscriber::registry().with(capture.clone());
390        tracing::subscriber::with_default(subscriber, || {
391            // The hook records otel.status_code on the ACTIVE span; declare the
392            // field so the subscriber observes the write (ADR-043 §2).
393            let span = tracing::info_span!(
394                "degenbot.test.engine",
395                otel.status_code = tracing::field::Empty,
396            );
397            let _entered = span.enter();
398            super::install_panic_hook();
399            let _ = std::panic::catch_unwind(|| panic!("boom-facade-123"));
400            // Drop the facade hook; take_hook restores the default hook.
401            let _ = std::panic::take_hook();
402        });
403
404        let events = capture
405            .events
406            .lock()
407            .unwrap_or_else(std::sync::PoisonError::into_inner);
408        assert_eq!(events.len(), 1, "exactly one ERROR per panic: {events:?}");
409        assert!(
410            events[0].contains("boom-facade-123"),
411            "payload carried: {events:?}"
412        );
413        assert!(
414            events[0].contains("thread="),
415            "thread name carried: {events:?}"
416        );
417        drop(events);
418
419        let records = capture
420            .records
421            .lock()
422            .unwrap_or_else(std::sync::PoisonError::into_inner);
423        assert!(
424            records
425                .iter()
426                .any(|r| r.contains("otel.status_code") && r.contains("ERROR")),
427            "active span marked Status::Error: {records:?}"
428        );
429    }
430
431    #[test]
432    fn facade_macros_expand_on_the_domain_target() {
433        let subscriber = tracing_subscriber::registry();
434        tracing::subscriber::with_default(subscriber, || {
435            crate::op_info!(domain = sim, block = 1u64, "sim probe");
436            let span = crate::op_span!(domain = sim, "degenbot.sim.probe", block = 1u64);
437            let _guard = span.enter();
438        });
439    }
440}