Skip to main content

leviath_telemetry/
lib.rs

1//! OpenTelemetry export for Leviath's telemetry event stream (issue #73).
2//!
3//! The runtime emits pure-data [`TelemetryEvent`](leviath_core::telemetry::TelemetryEvent)s
4//! into whatever [`TelemetrySink`] the host installs; this crate provides the
5//! sinks that leave the process: [`OtelSink`] (OTLP over HTTP/protobuf - port
6//! 4318, never gRPC) and [`LogSink`] (readable lines through `tracing`).
7//! [`build_sink`] picks one from the `[observability]` config. The SDK
8//! dependency stops here: `leviath-runtime` sees only the trait.
9
10mod log_sink;
11mod otel;
12
13use std::sync::Arc;
14
15use leviath_core::config::{ObservabilityConfig, TelemetryExporterKind};
16use leviath_core::telemetry::TelemetrySink;
17
18pub use log_sink::LogSink;
19pub use otel::OtelSink;
20
21/// A boxed `tracing-subscriber` layer, installable into the CLI's reloadable
22/// subscriber slot to forward the process's own log events over OTLP.
23pub type LogLayer = Box<dyn tracing_subscriber::Layer<tracing_subscriber::Registry> + Send + Sync>;
24
25/// What [`build_sink`] hands the host: the event sink for the engine's
26/// telemetry resource, plus - for the OTLP exporter - a `tracing` layer that
27/// exports the daemon's own log events through the same pipeline.
28pub struct BuiltTelemetry {
29    /// The sink the runtime's observer emits into.
30    pub sink: Arc<dyn TelemetrySink>,
31    /// Daemon-level log export (`None` for the stdout exporter, whose events
32    /// already flow through `tracing`).
33    pub log_layer: Option<LogLayer>,
34}
35
36/// The sink the config asks for, or `None` when telemetry is off (disabled,
37/// `exporter = "none"`, or an OTLP pipeline that failed to build - the last
38/// is logged and dropped rather than failing the daemon: observability must
39/// never stop the work it observes).
40pub fn build_sink(cfg: &ObservabilityConfig) -> Option<BuiltTelemetry> {
41    if !cfg.enabled {
42        return None;
43    }
44    match cfg.exporter {
45        TelemetryExporterKind::None => None,
46        TelemetryExporterKind::Stdout => Some(BuiltTelemetry {
47            sink: Arc::new(LogSink),
48            log_layer: None,
49        }),
50        TelemetryExporterKind::Otlp => {
51            // The OTLP exporters construct a blocking reqwest client, which
52            // panics when built on a tokio runtime thread (the daemon calls
53            // this from one); build on a plain thread instead.
54            let cfg = cfg.clone();
55            let built = std::thread::spawn(move || OtelSink::from_config(&cfg))
56                .join()
57                .expect("exporter construction reports errors rather than panicking");
58            match built {
59                Ok(sink) => {
60                    let log_layer = Some(sink.tracing_log_layer());
61                    Some(BuiltTelemetry {
62                        sink: Arc::new(sink),
63                        log_layer,
64                    })
65                }
66                Err(err) => {
67                    tracing::warn!("telemetry disabled: {err}");
68                    None
69                }
70            }
71        }
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    fn config(enabled: bool, exporter: TelemetryExporterKind) -> ObservabilityConfig {
80        ObservabilityConfig {
81            enabled,
82            exporter,
83            endpoint: None,
84            service_name: None,
85        }
86    }
87
88    #[test]
89    fn disabled_config_builds_no_sink() {
90        assert!(build_sink(&config(false, TelemetryExporterKind::Otlp)).is_none());
91    }
92
93    #[test]
94    fn none_exporter_builds_no_sink() {
95        assert!(build_sink(&config(true, TelemetryExporterKind::None)).is_none());
96    }
97
98    #[test]
99    fn stdout_exporter_builds_the_log_sink_without_a_log_layer() {
100        let built = build_sink(&config(true, TelemetryExporterKind::Stdout)).unwrap();
101        assert!(built.log_layer.is_none());
102    }
103
104    #[tokio::test(flavor = "multi_thread")]
105    async fn otlp_exporter_builds_from_a_runtime_thread_with_a_log_layer() {
106        // The regression this guards: blocking-client construction panics on a
107        // tokio thread unless it's hopped to a plain one.
108        let built = build_sink(&config(true, TelemetryExporterKind::Otlp)).unwrap();
109        assert!(built.log_layer.is_some());
110    }
111
112    #[test]
113    fn an_unparseable_endpoint_disables_telemetry_with_a_warning() {
114        let cfg = ObservabilityConfig {
115            enabled: true,
116            exporter: TelemetryExporterKind::Otlp,
117            endpoint: Some("not a url at all".to_string()),
118            service_name: None,
119        };
120        assert!(build_sink(&cfg).is_none());
121    }
122}