faucet_core/observability/
install.rs1use thiserror::Error;
6
7#[derive(Debug, Clone, Default)]
10pub struct ObservabilityConfig {
11 pub prometheus: Option<PrometheusConfig>,
12 pub tracing: Option<TracingConfig>,
13 pub otel: Option<crate::observability::otel::OtelConfig>,
14}
15
16#[cfg(feature = "observability-install")]
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub(crate) enum MetricsMode {
22 None,
23 PrometheusOnly,
24 OtelOnly,
25 Fanout,
26}
27
28#[cfg(feature = "observability-install")]
29impl MetricsMode {
30 pub(crate) fn select(prometheus: bool, otel_metrics: bool) -> Self {
31 match (prometheus, otel_metrics) {
32 (true, true) => MetricsMode::Fanout,
33 (true, false) => MetricsMode::PrometheusOnly,
34 (false, true) => MetricsMode::OtelOnly,
35 (false, false) => MetricsMode::None,
36 }
37 }
38}
39
40#[derive(Debug, Clone)]
41pub struct PrometheusConfig {
42 pub listen: String,
45 pub buckets: Option<Vec<f64>>,
48}
49
50#[derive(Debug, Clone)]
51pub struct TracingConfig {
52 pub level: String,
54}
55
56#[derive(Debug, Clone, Default)]
59pub struct InstallReport {
60 pub prometheus_listen: Option<String>,
61 pub prometheus_already_installed: bool,
62 pub tracing_already_installed: bool,
63 pub otel_installed: bool,
64 pub otel_signals: Vec<&'static str>,
65}
66
67#[derive(Debug, Error)]
68pub enum InstallError {
69 #[error("failed to bind Prometheus listener at {listen}: {source}")]
70 PrometheusBind {
71 listen: String,
72 #[source]
73 source: std::io::Error,
74 },
75 #[error("failed to install Prometheus recorder: {0}")]
76 PrometheusInstall(String),
77}
78
79#[cfg(feature = "observability-install")]
94pub fn install_observability(cfg: &ObservabilityConfig) -> Result<InstallReport, InstallError> {
95 let mut report = InstallReport::default();
96
97 #[cfg(feature = "otel")]
100 let without_otel;
101 #[cfg(feature = "otel")]
102 let cfg = if cfg.otel.is_some() && tokio::runtime::Handle::try_current().is_err() {
103 tracing::warn!("OTLP export needs a tokio runtime; install_observability skips it");
104 without_otel = ObservabilityConfig {
105 otel: None,
106 ..cfg.clone()
107 };
108 &without_otel
109 } else {
110 cfg
111 };
112
113 #[cfg(feature = "otel")]
116 let mut otel_tracer: Option<opentelemetry_sdk::trace::SdkTracerProvider> = None;
117
118 #[cfg(feature = "otel")]
120 let otel_metrics = cfg
121 .otel
122 .as_ref()
123 .map(|o| o.exports(crate::observability::otel::OtelSignal::Metrics))
124 .unwrap_or(false);
125 #[cfg(not(feature = "otel"))]
126 let otel_metrics = false;
127
128 let mode = MetricsMode::select(cfg.prometheus.is_some(), otel_metrics);
129
130 #[cfg(feature = "otel")]
132 let mut otel_meter: Option<opentelemetry_sdk::metrics::SdkMeterProvider> = None;
133
134 match mode {
135 MetricsMode::None => {}
136 MetricsMode::PrometheusOnly => {
137 install_prometheus_only(cfg.prometheus.as_ref().unwrap(), &mut report)?;
138 }
139 #[cfg(feature = "otel")]
140 MetricsMode::OtelOnly => {
141 if let Some(otel) = cfg.otel.as_ref() {
142 match crate::observability::otel::build_meter_provider(otel) {
143 Ok((mp, recorder)) => {
144 if metrics::set_global_recorder(recorder).is_err() {
145 tracing::warn!(
146 "metrics recorder already installed; OTLP metrics are not exported"
147 );
148 } else {
149 otel_meter = Some(mp);
150 report.otel_signals.push("metrics");
151 }
152 }
153 Err(e) => tracing::warn!("OTLP metrics exporter init failed; skipping: {e}"),
154 }
155 }
156 }
157 #[cfg(feature = "otel")]
158 MetricsMode::Fanout => {
159 install_fanout(
160 cfg.prometheus.as_ref().unwrap(),
161 cfg.otel.as_ref().unwrap(),
162 &mut report,
163 &mut otel_meter,
164 )?;
165 }
166 #[cfg(not(feature = "otel"))]
167 MetricsMode::OtelOnly | MetricsMode::Fanout => {
168 unreachable!("otel metrics mode selected without the otel feature")
169 }
170 }
171
172 #[cfg(feature = "otel")]
174 let otel_traces = cfg
175 .otel
176 .as_ref()
177 .is_some_and(|o| o.exports(crate::observability::otel::OtelSignal::Traces));
178 #[cfg(not(feature = "otel"))]
179 let otel_traces = false;
180 if cfg.tracing.is_some() || otel_traces {
181 use tracing_subscriber::EnvFilter;
182 use tracing_subscriber::layer::SubscriberExt;
183 use tracing_subscriber::util::SubscriberInitExt;
184
185 let level = cfg.tracing.as_ref().map_or("info", |t| t.level.as_str());
186 let make_filter = || EnvFilter::try_new(level).unwrap_or_else(|_| EnvFilter::new("info"));
187
188 #[cfg_attr(not(feature = "otel"), allow(unused_mut))]
191 let mut installed = false;
192
193 #[cfg(feature = "otel")]
194 if let (true, Some(otel)) = (otel_traces, cfg.otel.as_ref()) {
195 if crate::observability::otel::tracer_installed() {
196 installed = true;
197 } else {
198 match crate::observability::otel::build_trace_provider(otel) {
199 Ok(tp) => {
200 use opentelemetry::trace::TracerProvider as _;
201 use tracing_subscriber::Layer as _;
202 let tracer = tp.tracer("faucet");
203 crate::observability::otel::install_propagator();
204 let layer: crate::observability::otel::TraceLayer = Box::new(
205 tracing_opentelemetry::layer()
206 .with_tracer(tracer)
207 .and_then(crate::observability::otel::OtelErrorCountLayer),
208 );
209 match crate::observability::otel::fill_trace_slot(layer) {
212 Ok(()) => {
213 report.otel_signals.push("traces");
214 otel_tracer = Some(tp);
215 }
216 Err(layer) => {
217 let reg = tracing_subscriber::registry()
218 .with(layer)
219 .with(make_filter())
220 .with(
221 cfg.tracing.is_some().then(tracing_subscriber::fmt::layer),
222 );
223 if reg.try_init().is_err() {
224 tracing::warn!(
225 "tracing subscriber already installed and no trace \
226 layer slot registered; OTLP traces are not exported"
227 );
228 report.tracing_already_installed = true;
229 } else {
232 report.otel_signals.push("traces");
233 otel_tracer = Some(tp);
234 }
235 }
236 }
237 installed = true;
238 }
239 Err(e) => tracing::warn!(
240 "OTLP trace exporter init failed; traces are not exported: {e}"
241 ),
242 }
243 }
244 }
245
246 if !installed {
247 #[cfg(feature = "otel")]
253 let otel_error_layer = cfg
254 .otel
255 .as_ref()
256 .map(|o| {
257 o.exports(crate::observability::otel::OtelSignal::Traces)
258 || o.exports(crate::observability::otel::OtelSignal::Metrics)
259 })
260 .unwrap_or(false)
261 .then_some(crate::observability::otel::OtelErrorCountLayer);
262 #[cfg(not(feature = "otel"))]
263 let otel_error_layer: Option<tracing_subscriber::layer::Identity> = None;
264
265 let reg = tracing_subscriber::registry()
266 .with(make_filter())
267 .with(cfg.tracing.is_some().then(tracing_subscriber::fmt::layer))
268 .with(otel_error_layer);
269 if reg.try_init().is_err() {
270 tracing::warn!("tracing subscriber already installed; continuing");
274 report.tracing_already_installed = true;
275 }
276 }
277 }
278
279 #[cfg(feature = "otel")]
281 {
282 if otel_tracer.is_some() || otel_meter.is_some() {
283 crate::observability::otel::describe();
284 let _ = crate::observability::otel::set_guard(crate::observability::otel::OtelGuard {
285 tracer: otel_tracer,
286 meter: otel_meter,
287 });
288 report.otel_installed = true;
289 }
290 }
291
292 crate::observability::resilience::describe();
296 crate::observability::cleanup::describe();
297 crate::observability::drift::describe();
298 crate::observability::describe_roundtrip_metrics();
299 register_build_info();
300
301 Ok(report)
302}
303
304#[cfg(feature = "observability-install")]
308fn install_prometheus_only(
309 p: &PrometheusConfig,
310 report: &mut InstallReport,
311) -> Result<(), InstallError> {
312 use metrics_exporter_prometheus::{BuildError, PrometheusBuilder};
313
314 let listen: std::net::SocketAddr =
315 p.listen
316 .parse()
317 .map_err(|e: std::net::AddrParseError| InstallError::PrometheusBind {
318 listen: p.listen.clone(),
319 source: std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()),
320 })?;
321
322 const DEFAULT_BUCKETS: &[f64] = &[
323 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0, 10.0, 30.0, 60.0, 300.0,
324 ];
325 let buckets = p.buckets.as_deref().unwrap_or(DEFAULT_BUCKETS);
326
327 let builder = PrometheusBuilder::new()
328 .with_http_listener(listen)
329 .set_buckets(buckets)
330 .map_err(|e| InstallError::PrometheusInstall(e.to_string()))?;
331
332 match builder.install() {
333 Ok(()) => report.prometheus_listen = Some(p.listen.clone()),
334 Err(e) => match e {
338 BuildError::FailedToSetGlobalRecorder(_) => {
341 tracing::warn!("Prometheus recorder already installed; continuing");
342 report.prometheus_already_installed = true;
343 }
344 BuildError::FailedToCreateHTTPListener(msg) => {
350 return Err(InstallError::PrometheusBind {
351 listen: p.listen.clone(),
352 source: std::io::Error::other(msg),
353 });
354 }
355 other => return Err(InstallError::PrometheusInstall(other.to_string())),
356 },
357 }
358 Ok(())
359}
360
361#[cfg(all(feature = "observability-install", feature = "otel"))]
367fn install_fanout(
368 p: &PrometheusConfig,
369 otel: &crate::observability::otel::OtelConfig,
370 report: &mut InstallReport,
371 otel_meter: &mut Option<opentelemetry_sdk::metrics::SdkMeterProvider>,
372) -> Result<(), InstallError> {
373 use metrics_exporter_prometheus::{BuildError, PrometheusBuilder};
374 use metrics_util::layers::FanoutBuilder;
375
376 let listen: std::net::SocketAddr =
377 p.listen
378 .parse()
379 .map_err(|e: std::net::AddrParseError| InstallError::PrometheusBind {
380 listen: p.listen.clone(),
381 source: std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()),
382 })?;
383 const DEFAULT_BUCKETS: &[f64] = &[
384 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0, 10.0, 30.0, 60.0, 300.0,
385 ];
386 let buckets = p.buckets.as_deref().unwrap_or(DEFAULT_BUCKETS);
387
388 let (prom_recorder, prom_exporter) = PrometheusBuilder::new()
391 .with_http_listener(listen)
392 .set_buckets(buckets)
393 .map_err(|e| InstallError::PrometheusInstall(e.to_string()))?
394 .build()
395 .map_err(|e| match e {
396 BuildError::FailedToCreateHTTPListener(msg) => InstallError::PrometheusBind {
397 listen: p.listen.clone(),
398 source: std::io::Error::other(msg),
399 },
400 other => InstallError::PrometheusInstall(other.to_string()),
401 })?;
402
403 match crate::observability::otel::build_meter_provider(otel) {
404 Ok((mp, otel_recorder)) => {
405 let fanout = FanoutBuilder::default()
406 .add_recorder(prom_recorder)
407 .add_recorder(otel_recorder)
408 .build();
409 if metrics::set_global_recorder(fanout).is_err() {
410 tracing::warn!("metrics recorder already installed; continuing");
411 report.prometheus_already_installed = true;
412 } else {
413 report.prometheus_listen = Some(p.listen.clone());
414 report.otel_signals.push("metrics");
415 *otel_meter = Some(mp);
416 tokio::spawn(prom_exporter);
417 }
418 }
419 Err(e) => {
420 tracing::warn!("OTLP metrics exporter init failed; Prometheus-only: {e}");
423 if metrics::set_global_recorder(prom_recorder).is_err() {
424 report.prometheus_already_installed = true;
425 } else {
426 report.prometheus_listen = Some(p.listen.clone());
427 tokio::spawn(prom_exporter);
428 }
429 }
430 }
431 Ok(())
432}
433
434#[cfg(all(feature = "observability-install", feature = "otel"))]
446pub fn install_prometheus_with_otel_metrics(
447 prometheus: metrics_exporter_prometheus::PrometheusRecorder,
448 otel: &crate::observability::otel::OtelConfig,
449) -> Result<bool, InstallError> {
450 use metrics_util::layers::FanoutBuilder;
451 let already =
452 || InstallError::PrometheusInstall("a metrics recorder is already installed".into());
453 match crate::observability::otel::build_meter_provider(otel) {
454 Ok((mp, otel_recorder)) => {
455 let fanout = FanoutBuilder::default()
456 .add_recorder(prometheus)
457 .add_recorder(otel_recorder)
458 .build();
459 metrics::set_global_recorder(fanout).map_err(|_| already())?;
460 crate::observability::otel::keep_meter_provider(mp);
461 crate::observability::otel::describe();
462 Ok(true)
463 }
464 Err(e) => {
465 tracing::warn!("OTLP metrics exporter init failed; Prometheus-only: {e}");
466 metrics::set_global_recorder(prometheus).map_err(|_| already())?;
467 Ok(false)
468 }
469 }
470}
471
472#[cfg(not(feature = "observability-install"))]
474pub fn install_observability(_cfg: &ObservabilityConfig) -> Result<InstallReport, InstallError> {
475 crate::observability::resilience::describe();
476 crate::observability::cleanup::describe();
477 crate::observability::drift::describe();
478 crate::observability::describe_roundtrip_metrics();
479 crate::observability::otel::describe();
480 register_build_info();
481 Ok(InstallReport::default())
482}
483
484static BUILD_VERSION: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
486
487pub fn set_build_version(version: &'static str) -> bool {
497 let first = BUILD_VERSION.set(version).is_ok();
498 if first {
499 if version != env!("CARGO_PKG_VERSION") {
500 metrics::gauge!("faucet_build_info", "version" => env!("CARGO_PKG_VERSION")).set(0.0);
501 }
502 register_build_info();
503 }
504 first
505}
506
507pub fn build_version() -> &'static str {
510 BUILD_VERSION
511 .get()
512 .copied()
513 .unwrap_or(env!("CARGO_PKG_VERSION"))
514}
515
516pub fn register_build_info() {
527 metrics::gauge!(
528 "faucet_build_info",
529 "version" => build_version(),
530 )
531 .set(1.0);
532}
533
534#[cfg(all(test, feature = "observability-install"))]
535mod tests {
536 use super::*;
537 use std::sync::Mutex;
538
539 static LOCK: Mutex<()> = Mutex::new(());
540
541 #[cfg(feature = "otel")]
542 #[test]
543 fn otlp_outside_a_runtime_is_skipped_not_a_panic() {
544 let cfg = ObservabilityConfig {
545 otel: Some(crate::observability::otel::OtelConfig::default()),
546 ..Default::default()
547 };
548 let report = install_observability(&cfg).expect("no runtime is not an error");
549 assert!(report.otel_signals.is_empty());
550 assert!(!report.prometheus_already_installed);
551 }
552
553 #[test]
554 fn metrics_mode_selection() {
555 use super::MetricsMode;
556 assert_eq!(MetricsMode::select(true, true), MetricsMode::Fanout);
557 assert_eq!(
558 MetricsMode::select(true, false),
559 MetricsMode::PrometheusOnly
560 );
561 assert_eq!(MetricsMode::select(false, true), MetricsMode::OtelOnly);
562 assert_eq!(MetricsMode::select(false, false), MetricsMode::None);
563 }
564
565 #[test]
566 fn no_config_returns_empty_report() {
567 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
568 let r = install_observability(&ObservabilityConfig::default()).unwrap();
569 assert!(r.prometheus_listen.is_none());
570 assert!(!r.prometheus_already_installed);
571 assert!(!r.tracing_already_installed);
572 }
573
574 #[test]
575 fn malformed_listen_returns_bind_error() {
576 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
577 let cfg = ObservabilityConfig {
578 prometheus: Some(PrometheusConfig {
579 listen: "not-a-socket".into(),
580 buckets: None,
581 }),
582 tracing: None,
583 otel: None,
584 };
585 match install_observability(&cfg) {
586 Err(InstallError::PrometheusBind { .. }) => {}
587 other => panic!("expected PrometheusBind error, got {other:?}"),
588 }
589 }
590
591 #[test]
592 fn register_build_info_is_callable_and_idempotent() {
593 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
594 register_build_info();
597 register_build_info();
598 }
599
600 #[test]
601 fn install_prometheus_and_tracing_returns_ok() {
602 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
608 let cfg = ObservabilityConfig {
609 prometheus: Some(PrometheusConfig {
610 listen: "127.0.0.1:0".into(),
611 buckets: Some(vec![0.01, 0.1, 1.0]),
613 }),
614 tracing: Some(TracingConfig {
615 level: "info".into(),
616 }),
617 otel: None,
618 };
619 let report = install_observability(&cfg).expect("install must return Ok");
620 assert!(
622 report.prometheus_listen.is_some() || report.prometheus_already_installed,
623 "prometheus install must either bind or report already-installed"
624 );
625 }
626
627 #[test]
628 fn install_tracing_with_invalid_directive_falls_back_to_info() {
629 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
632 let cfg = ObservabilityConfig {
633 prometheus: None,
634 tracing: Some(TracingConfig {
635 level: "this is !!! not a valid filter".into(),
637 }),
638 otel: None,
639 };
640 install_observability(&cfg).expect("invalid tracing directive must not fail install");
641 }
642}