Skip to main content

faucet_core/observability/
otel.rs

1//! OpenTelemetry (OTLP) export of traces + metrics (#201).
2//!
3//! `OtelConfig` and its enums are pure data (no opentelemetry types) so they
4//! compile unconditionally. Everything that touches the OTel SDK is gated on
5//! `#[cfg(feature = "otel")]` further down this file.
6
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10/// OTLP transport protocol.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
12#[serde(rename_all = "lowercase")]
13pub enum OtelProtocol {
14    /// gRPC over tonic (default OTLP port 4317). Must be initialised inside a
15    /// tokio runtime.
16    #[default]
17    Grpc,
18    /// HTTP/protobuf (default OTLP port 4318).
19    Http,
20}
21
22/// A telemetry signal that can be exported over OTLP.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
24#[serde(rename_all = "lowercase")]
25pub enum OtelSignal {
26    Traces,
27    Metrics,
28}
29
30fn default_protocol() -> OtelProtocol {
31    OtelProtocol::Grpc
32}
33fn default_sample_ratio() -> f64 {
34    1.0
35}
36fn default_export() -> Vec<OtelSignal> {
37    vec![OtelSignal::Traces, OtelSignal::Metrics]
38}
39fn default_service_name() -> String {
40    "faucet".to_string()
41}
42fn default_timeout_secs() -> u64 {
43    10
44}
45fn default_metric_interval_secs() -> u64 {
46    60
47}
48
49/// Pure-data OTLP export configuration. Contains no opentelemetry types.
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51pub struct OtelConfig {
52    /// Collector endpoint. When empty, resolved per protocol
53    /// (`http://localhost:4317` grpc / `:4318` http) by [`OtelConfig::resolve_endpoint`].
54    #[serde(default)]
55    pub endpoint: String,
56    #[serde(default = "default_protocol")]
57    pub protocol: OtelProtocol,
58    /// Extra headers sent on every export (e.g. backend auth tokens).
59    #[serde(default)]
60    pub headers: std::collections::HashMap<String, String>,
61    /// Head-based trace sampling ratio, 0.0..=1.0.
62    #[serde(default = "default_sample_ratio")]
63    pub sample_ratio: f64,
64    /// Which signals to export.
65    #[serde(default = "default_export")]
66    pub export: Vec<OtelSignal>,
67    /// OTel resource `service.name`.
68    #[serde(default = "default_service_name")]
69    pub service_name: String,
70    /// Per-export timeout (seconds).
71    #[serde(default = "default_timeout_secs")]
72    pub timeout_secs: u64,
73    /// Metric push interval (seconds).
74    #[serde(default = "default_metric_interval_secs")]
75    pub metric_interval_secs: u64,
76}
77
78impl Default for OtelConfig {
79    fn default() -> Self {
80        Self {
81            endpoint: String::new(),
82            protocol: default_protocol(),
83            headers: std::collections::HashMap::new(),
84            sample_ratio: default_sample_ratio(),
85            export: default_export(),
86            service_name: default_service_name(),
87            timeout_secs: default_timeout_secs(),
88            metric_interval_secs: default_metric_interval_secs(),
89        }
90    }
91}
92
93impl OtelConfig {
94    /// Whether the given signal is in the export list.
95    pub fn exports(&self, signal: OtelSignal) -> bool {
96        self.export.contains(&signal)
97    }
98
99    /// The collector endpoint, filling in the protocol-specific default when
100    /// `endpoint` is empty.
101    pub fn resolve_endpoint(&self) -> String {
102        if !self.endpoint.is_empty() {
103            return self.endpoint.clone();
104        }
105        match self.protocol {
106            OtelProtocol::Grpc => "http://localhost:4317".to_string(),
107            OtelProtocol::Http => "http://localhost:4318".to_string(),
108        }
109    }
110
111    /// Validate ranges + endpoint URL at config-load time. Returns a message on
112    /// the first problem.
113    pub fn validate(&self) -> Result<(), String> {
114        if !(0.0..=1.0).contains(&self.sample_ratio) {
115            return Err(format!(
116                "otel.sample_ratio must be in 0.0..=1.0, got {}",
117                self.sample_ratio
118            ));
119        }
120        if self.timeout_secs == 0 {
121            return Err("otel.timeout_secs must be > 0".to_string());
122        }
123        if self.metric_interval_secs == 0 {
124            return Err("otel.metric_interval_secs must be > 0".to_string());
125        }
126        // resolve_endpoint() always yields a value; validate the effective URL.
127        let ep = self.resolve_endpoint();
128        url::Url::parse(&ep)
129            .map_err(|e| format!("otel.endpoint is not a valid URL ({ep}): {e}"))?;
130        Ok(())
131    }
132}
133
134/// opentelemetry-otlp uses a programmatic HTTP endpoint verbatim (it only
135/// appends the per-signal path for the `OTEL_EXPORTER_OTLP_ENDPOINT` env-var
136/// form). Users naturally configure the base endpoint, so for HTTP we append the
137/// conventional per-signal path ourselves when it is not already present.
138// Always compiled (unit-testable without the feature); only *called* from the
139// otel-gated `mod sdk`, so it reads as dead code in a no-otel non-test build.
140#[cfg_attr(not(feature = "otel"), allow(dead_code))]
141pub(crate) fn http_signal_endpoint(base: &str, signal_path: &str) -> String {
142    let trimmed = base.trim_end_matches('/');
143    if trimmed.ends_with(signal_path) {
144        trimmed.to_string()
145    } else {
146        format!("{trimmed}{signal_path}")
147    }
148}
149
150/// Map an `opentelemetry*` tracing event target to a `signal` label for the
151/// `faucet_otel_export_failures_total` counter. Pure — unit-testable without a
152/// tracing `Context`.
153// Always compiled (unit-testable without the feature); only *called* from the
154// otel-gated `mod layer`, so it reads as dead code in a no-otel non-test build.
155#[cfg_attr(not(feature = "otel"), allow(dead_code))]
156pub(crate) fn otel_signal_label(target: &str) -> &'static str {
157    if target.contains("metric") {
158        "metrics"
159    } else if target.contains("trace") || target.contains("span") {
160        "traces"
161    } else {
162        "export"
163    }
164}
165
166/// Register HELP text for the OTLP export metric. Called from
167/// `install_observability` (a `describe!` into a not-yet-installed recorder is a
168/// no-op, so ordering is forgiving).
169pub fn describe() {
170    metrics::describe_counter!(
171        "faucet_otel_export_failures_total",
172        "OTLP export attempts that failed (collector unreachable, serialization error, etc.)."
173    );
174}
175
176#[cfg(feature = "otel")]
177mod layer {
178    use super::otel_signal_label;
179    use tracing::{Event, Level, Subscriber};
180    use tracing_subscriber::layer::{Context, Layer};
181
182    /// A `tracing` layer that counts ERROR/WARN events emitted by the
183    /// opentelemetry SDK (its only error channel in the 0.31 line, since
184    /// `global::set_error_handler` was removed) into
185    /// `faucet_otel_export_failures_total{signal}`.
186    pub struct OtelErrorCountLayer;
187
188    impl<S: Subscriber> Layer<S> for OtelErrorCountLayer {
189        fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
190            let meta = event.metadata();
191            let target = meta.target();
192            if target.starts_with("opentelemetry")
193                && matches!(*meta.level(), Level::ERROR | Level::WARN)
194            {
195                metrics::counter!(
196                    "faucet_otel_export_failures_total",
197                    "signal" => otel_signal_label(target),
198                )
199                .increment(1);
200            }
201        }
202    }
203}
204
205#[cfg(feature = "otel")]
206pub use layer::OtelErrorCountLayer;
207
208#[cfg(feature = "otel")]
209mod sdk {
210    use super::{OtelConfig, OtelProtocol};
211    use opentelemetry_otlp::{WithExportConfig, WithHttpConfig};
212    use opentelemetry_sdk::Resource;
213    use opentelemetry_sdk::metrics::{PeriodicReader, SdkMeterProvider};
214    use opentelemetry_sdk::trace::{Sampler, SdkTracerProvider};
215    use std::sync::OnceLock;
216    use std::time::Duration;
217
218    pub type OtelError = Box<dyn std::error::Error + Send + Sync>;
219
220    fn resource(cfg: &OtelConfig) -> Resource {
221        Resource::builder()
222            .with_service_name(cfg.service_name.clone())
223            .build()
224    }
225
226    fn header_map(cfg: &OtelConfig) -> std::collections::HashMap<String, String> {
227        cfg.headers.clone()
228    }
229
230    /// Build a batch-exporting tracer provider. NOTE: with grpc-tonic this MUST
231    /// be called inside a tokio runtime (the CLI is `#[tokio::main]`).
232    pub fn build_trace_provider(cfg: &OtelConfig) -> Result<SdkTracerProvider, OtelError> {
233        let endpoint = cfg.resolve_endpoint();
234        let timeout = Duration::from_secs(cfg.timeout_secs);
235        let exporter = match cfg.protocol {
236            OtelProtocol::Grpc => opentelemetry_otlp::SpanExporter::builder()
237                .with_tonic()
238                .with_endpoint(&endpoint)
239                .with_timeout(timeout)
240                .build()?,
241            OtelProtocol::Http => opentelemetry_otlp::SpanExporter::builder()
242                .with_http()
243                .with_endpoint(super::http_signal_endpoint(&endpoint, "/v1/traces"))
244                .with_headers(header_map(cfg))
245                .with_timeout(timeout)
246                .build()?,
247        };
248        let provider = SdkTracerProvider::builder()
249            .with_batch_exporter(exporter)
250            .with_sampler(Sampler::TraceIdRatioBased(cfg.sample_ratio))
251            .with_resource(resource(cfg))
252            .build();
253        Ok(provider)
254    }
255
256    /// Build a NON-global metrics recorder bridging the `metrics` facade to an
257    /// OTLP PeriodicReader, plus its meter provider (kept alive by the caller).
258    pub fn build_meter_provider(
259        cfg: &OtelConfig,
260    ) -> Result<(SdkMeterProvider, metrics_exporter_opentelemetry::Recorder), OtelError> {
261        let endpoint = cfg.resolve_endpoint();
262        let timeout = Duration::from_secs(cfg.timeout_secs);
263        let exporter = match cfg.protocol {
264            OtelProtocol::Grpc => opentelemetry_otlp::MetricExporter::builder()
265                .with_tonic()
266                .with_endpoint(&endpoint)
267                .with_timeout(timeout)
268                .build()?,
269            OtelProtocol::Http => opentelemetry_otlp::MetricExporter::builder()
270                .with_http()
271                .with_endpoint(super::http_signal_endpoint(&endpoint, "/v1/metrics"))
272                .with_headers(header_map(cfg))
273                .with_timeout(timeout)
274                .build()?,
275        };
276        let reader = PeriodicReader::builder(exporter)
277            .with_interval(Duration::from_secs(cfg.metric_interval_secs))
278            .build();
279        let res = resource(cfg);
280        let (provider, recorder) =
281            metrics_exporter_opentelemetry::Recorder::builder(cfg.service_name.clone())
282                .with_meter_provider(move |mb| mb.with_reader(reader).with_resource(res))
283                .build();
284        Ok((provider, recorder))
285    }
286
287    pub struct OtelGuard {
288        pub tracer: Option<SdkTracerProvider>,
289        pub meter: Option<SdkMeterProvider>,
290    }
291
292    static GUARD: OnceLock<OtelGuard> = OnceLock::new();
293
294    /// Store providers for the process lifetime. Returns false if already set.
295    pub fn set_guard(guard: OtelGuard) -> bool {
296        GUARD.set(guard).is_ok()
297    }
298
299    /// Flush + shut down installed providers. Idempotent.
300    pub fn shutdown_otel() {
301        if let Some(g) = GUARD.get() {
302            if let Some(t) = g.tracer.as_ref() {
303                let _ = t.force_flush();
304                let _ = t.shutdown();
305            }
306            if let Some(m) = g.meter.as_ref() {
307                let _ = m.force_flush();
308                let _ = m.shutdown();
309            }
310        }
311    }
312
313    /// Install the W3C trace-context propagator globally (#230 groundwork).
314    pub fn install_propagator() {
315        use opentelemetry_sdk::propagation::TraceContextPropagator;
316        opentelemetry::global::set_text_map_propagator(TraceContextPropagator::new());
317    }
318}
319
320#[cfg(feature = "otel")]
321pub use sdk::{
322    OtelError, OtelGuard, build_meter_provider, build_trace_provider, install_propagator,
323    set_guard, shutdown_otel,
324};
325
326/// No-op `shutdown_otel` when the `otel` feature is disabled, so CLI call sites
327/// compile in every build.
328#[cfg(not(feature = "otel"))]
329pub fn shutdown_otel() {}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    #[cfg(feature = "otel")]
336    #[test]
337    fn error_layer_increments_export_failures_counter() {
338        // Validates the failure-isolation acceptance criterion end-to-end: an
339        // ERROR event from an `opentelemetry*` target, routed through
340        // `OtelErrorCountLayer`, bumps `faucet_otel_export_failures_total` with
341        // the classified `signal` label. Reuses the crate-wide shared debugging
342        // recorder + LOCK (same pattern as the decorator metric tests).
343        use crate::observability::decorator::source_tests::{LOCK, snapshotter};
344        use metrics_util::debugging::DebugValue;
345        use tracing_subscriber::layer::SubscriberExt;
346
347        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
348        let snap = snapshotter();
349
350        let subscriber = tracing_subscriber::registry().with(OtelErrorCountLayer);
351        tracing::subscriber::with_default(subscriber, || {
352            tracing::error!(
353                target: "opentelemetry_sdk::metrics::periodic_reader",
354                "simulated export failure"
355            );
356        });
357
358        let found = snap.snapshot().into_vec().into_iter().any(
359            |(key, _u, _d, v): (metrics_util::CompositeKey, _, _, _)| {
360                key.key().name() == "faucet_otel_export_failures_total"
361                    && key
362                        .key()
363                        .labels()
364                        .any(|l: &metrics::Label| l.key() == "signal" && l.value() == "metrics")
365                    && matches!(v, DebugValue::Counter(c) if c >= 1)
366            },
367        );
368        assert!(
369            found,
370            "OtelErrorCountLayer must increment faucet_otel_export_failures_total{{signal=metrics}}"
371        );
372    }
373
374    #[cfg(feature = "otel")]
375    #[tokio::test]
376    async fn build_providers_construct_without_a_live_collector() {
377        let cfg = OtelConfig {
378            endpoint: "http://localhost:4317".into(),
379            protocol: OtelProtocol::Grpc,
380            ..Default::default()
381        };
382        let tp = build_trace_provider(&cfg).expect("trace provider builds");
383        let (mp, _recorder) = build_meter_provider(&cfg).expect("meter provider builds");
384        let _ = tp.force_flush();
385        let _ = mp.force_flush();
386        let _ = tp.shutdown();
387        let _ = mp.shutdown();
388    }
389
390    #[test]
391    fn http_signal_endpoint_appends_path_when_absent() {
392        assert_eq!(
393            http_signal_endpoint("http://c:4318", "/v1/traces"),
394            "http://c:4318/v1/traces"
395        );
396        assert_eq!(
397            http_signal_endpoint("http://c:4318/", "/v1/traces"),
398            "http://c:4318/v1/traces"
399        );
400        // idempotent: already has the path
401        assert_eq!(
402            http_signal_endpoint("http://c:4318/v1/traces", "/v1/traces"),
403            "http://c:4318/v1/traces"
404        );
405        assert_eq!(
406            http_signal_endpoint("http://c:4318", "/v1/metrics"),
407            "http://c:4318/v1/metrics"
408        );
409    }
410
411    #[test]
412    fn record_otel_error_classifies_signal_by_target() {
413        assert_eq!(
414            otel_signal_label("opentelemetry_sdk::metrics::periodic_reader"),
415            "metrics"
416        );
417        assert_eq!(
418            otel_signal_label("opentelemetry_sdk::trace::span_processor"),
419            "traces"
420        );
421        assert_eq!(otel_signal_label("opentelemetry_otlp::exporter"), "export");
422        assert_eq!(otel_signal_label("opentelemetry"), "export");
423    }
424
425    #[test]
426    fn config_defaults_are_applied_from_empty_yaml() {
427        let cfg: OtelConfig = serde_json::from_str("{}").unwrap();
428        assert_eq!(cfg.protocol, OtelProtocol::Grpc);
429        assert_eq!(cfg.sample_ratio, 1.0);
430        assert_eq!(cfg.export, vec![OtelSignal::Traces, OtelSignal::Metrics]);
431        assert_eq!(cfg.service_name, "faucet");
432        assert_eq!(cfg.timeout_secs, 10);
433        assert_eq!(cfg.metric_interval_secs, 60);
434        assert!(cfg.exports(OtelSignal::Traces));
435        assert!(cfg.exports(OtelSignal::Metrics));
436    }
437
438    #[test]
439    fn resolve_endpoint_defaults_per_protocol() {
440        let mut cfg = OtelConfig::default();
441        assert_eq!(cfg.resolve_endpoint(), "http://localhost:4317");
442        cfg.protocol = OtelProtocol::Http;
443        assert_eq!(cfg.resolve_endpoint(), "http://localhost:4318");
444        cfg.endpoint = "http://collector:4317".into();
445        assert_eq!(cfg.resolve_endpoint(), "http://collector:4317");
446    }
447
448    #[test]
449    fn validate_rejects_bad_values() {
450        let mut cfg = OtelConfig::default();
451        assert!(cfg.validate().is_ok());
452
453        cfg.sample_ratio = 1.5;
454        assert!(cfg.validate().is_err());
455        cfg.sample_ratio = -0.1;
456        assert!(cfg.validate().is_err());
457        cfg.sample_ratio = 0.5;
458        assert!(cfg.validate().is_ok());
459
460        cfg.timeout_secs = 0;
461        assert!(cfg.validate().is_err());
462        cfg.timeout_secs = 10;
463
464        cfg.metric_interval_secs = 0;
465        assert!(cfg.validate().is_err());
466        cfg.metric_interval_secs = 60;
467
468        cfg.endpoint = "not a url".into();
469        assert!(cfg.validate().is_err());
470    }
471}