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(tracing_subscriber::fmt::layer());
221 if reg.try_init().is_err() {
222 tracing::warn!(
223 "tracing subscriber already installed and no trace \
224 layer slot registered; OTLP traces are not exported"
225 );
226 report.tracing_already_installed = true;
227 } else {
230 report.otel_signals.push("traces");
231 otel_tracer = Some(tp);
232 }
233 }
234 }
235 installed = true;
236 }
237 Err(e) => tracing::warn!("OTLP trace exporter init failed; logs-only: {e}"),
238 }
239 }
240 }
241
242 if !installed {
243 #[cfg(feature = "otel")]
249 let otel_error_layer = cfg
250 .otel
251 .as_ref()
252 .map(|o| {
253 o.exports(crate::observability::otel::OtelSignal::Traces)
254 || o.exports(crate::observability::otel::OtelSignal::Metrics)
255 })
256 .unwrap_or(false)
257 .then_some(crate::observability::otel::OtelErrorCountLayer);
258 #[cfg(not(feature = "otel"))]
259 let otel_error_layer: Option<tracing_subscriber::layer::Identity> = None;
260
261 let reg = tracing_subscriber::registry()
262 .with(make_filter())
263 .with(tracing_subscriber::fmt::layer())
264 .with(otel_error_layer);
265 if reg.try_init().is_err() {
266 tracing::warn!("tracing subscriber already installed; continuing");
270 report.tracing_already_installed = true;
271 }
272 }
273 }
274
275 #[cfg(feature = "otel")]
277 {
278 if otel_tracer.is_some() || otel_meter.is_some() {
279 crate::observability::otel::describe();
280 let _ = crate::observability::otel::set_guard(crate::observability::otel::OtelGuard {
281 tracer: otel_tracer,
282 meter: otel_meter,
283 });
284 report.otel_installed = true;
285 }
286 }
287
288 crate::observability::resilience::describe();
292 crate::observability::cleanup::describe();
293 crate::observability::drift::describe();
294 crate::observability::describe_roundtrip_metrics();
295 register_build_info();
296
297 Ok(report)
298}
299
300#[cfg(feature = "observability-install")]
304fn install_prometheus_only(
305 p: &PrometheusConfig,
306 report: &mut InstallReport,
307) -> Result<(), InstallError> {
308 use metrics_exporter_prometheus::{BuildError, PrometheusBuilder};
309
310 let listen: std::net::SocketAddr =
311 p.listen
312 .parse()
313 .map_err(|e: std::net::AddrParseError| InstallError::PrometheusBind {
314 listen: p.listen.clone(),
315 source: std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()),
316 })?;
317
318 const DEFAULT_BUCKETS: &[f64] = &[
319 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0, 10.0, 30.0, 60.0, 300.0,
320 ];
321 let buckets = p.buckets.as_deref().unwrap_or(DEFAULT_BUCKETS);
322
323 let builder = PrometheusBuilder::new()
324 .with_http_listener(listen)
325 .set_buckets(buckets)
326 .map_err(|e| InstallError::PrometheusInstall(e.to_string()))?;
327
328 match builder.install() {
329 Ok(()) => report.prometheus_listen = Some(p.listen.clone()),
330 Err(e) => match e {
334 BuildError::FailedToSetGlobalRecorder(_) => {
337 tracing::warn!("Prometheus recorder already installed; continuing");
338 report.prometheus_already_installed = true;
339 }
340 BuildError::FailedToCreateHTTPListener(msg) => {
346 return Err(InstallError::PrometheusBind {
347 listen: p.listen.clone(),
348 source: std::io::Error::other(msg),
349 });
350 }
351 other => return Err(InstallError::PrometheusInstall(other.to_string())),
352 },
353 }
354 Ok(())
355}
356
357#[cfg(all(feature = "observability-install", feature = "otel"))]
363fn install_fanout(
364 p: &PrometheusConfig,
365 otel: &crate::observability::otel::OtelConfig,
366 report: &mut InstallReport,
367 otel_meter: &mut Option<opentelemetry_sdk::metrics::SdkMeterProvider>,
368) -> Result<(), InstallError> {
369 use metrics_exporter_prometheus::{BuildError, PrometheusBuilder};
370 use metrics_util::layers::FanoutBuilder;
371
372 let listen: std::net::SocketAddr =
373 p.listen
374 .parse()
375 .map_err(|e: std::net::AddrParseError| InstallError::PrometheusBind {
376 listen: p.listen.clone(),
377 source: std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()),
378 })?;
379 const DEFAULT_BUCKETS: &[f64] = &[
380 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0, 10.0, 30.0, 60.0, 300.0,
381 ];
382 let buckets = p.buckets.as_deref().unwrap_or(DEFAULT_BUCKETS);
383
384 let (prom_recorder, prom_exporter) = PrometheusBuilder::new()
387 .with_http_listener(listen)
388 .set_buckets(buckets)
389 .map_err(|e| InstallError::PrometheusInstall(e.to_string()))?
390 .build()
391 .map_err(|e| match e {
392 BuildError::FailedToCreateHTTPListener(msg) => InstallError::PrometheusBind {
393 listen: p.listen.clone(),
394 source: std::io::Error::other(msg),
395 },
396 other => InstallError::PrometheusInstall(other.to_string()),
397 })?;
398
399 match crate::observability::otel::build_meter_provider(otel) {
400 Ok((mp, otel_recorder)) => {
401 let fanout = FanoutBuilder::default()
402 .add_recorder(prom_recorder)
403 .add_recorder(otel_recorder)
404 .build();
405 if metrics::set_global_recorder(fanout).is_err() {
406 tracing::warn!("metrics recorder already installed; continuing");
407 report.prometheus_already_installed = true;
408 } else {
409 report.prometheus_listen = Some(p.listen.clone());
410 report.otel_signals.push("metrics");
411 *otel_meter = Some(mp);
412 tokio::spawn(prom_exporter);
413 }
414 }
415 Err(e) => {
416 tracing::warn!("OTLP metrics exporter init failed; Prometheus-only: {e}");
419 if metrics::set_global_recorder(prom_recorder).is_err() {
420 report.prometheus_already_installed = true;
421 } else {
422 report.prometheus_listen = Some(p.listen.clone());
423 tokio::spawn(prom_exporter);
424 }
425 }
426 }
427 Ok(())
428}
429
430#[cfg(all(feature = "observability-install", feature = "otel"))]
442pub fn install_prometheus_with_otel_metrics(
443 prometheus: metrics_exporter_prometheus::PrometheusRecorder,
444 otel: &crate::observability::otel::OtelConfig,
445) -> Result<bool, InstallError> {
446 use metrics_util::layers::FanoutBuilder;
447 let already =
448 || InstallError::PrometheusInstall("a metrics recorder is already installed".into());
449 match crate::observability::otel::build_meter_provider(otel) {
450 Ok((mp, otel_recorder)) => {
451 let fanout = FanoutBuilder::default()
452 .add_recorder(prometheus)
453 .add_recorder(otel_recorder)
454 .build();
455 metrics::set_global_recorder(fanout).map_err(|_| already())?;
456 crate::observability::otel::keep_meter_provider(mp);
457 crate::observability::otel::describe();
458 Ok(true)
459 }
460 Err(e) => {
461 tracing::warn!("OTLP metrics exporter init failed; Prometheus-only: {e}");
462 metrics::set_global_recorder(prometheus).map_err(|_| already())?;
463 Ok(false)
464 }
465 }
466}
467
468#[cfg(not(feature = "observability-install"))]
470pub fn install_observability(_cfg: &ObservabilityConfig) -> Result<InstallReport, InstallError> {
471 crate::observability::resilience::describe();
472 crate::observability::cleanup::describe();
473 crate::observability::drift::describe();
474 crate::observability::describe_roundtrip_metrics();
475 crate::observability::otel::describe();
476 register_build_info();
477 Ok(InstallReport::default())
478}
479
480static BUILD_VERSION: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
482
483pub fn set_build_version(version: &'static str) -> bool {
492 let first = BUILD_VERSION.set(version).is_ok();
493 if first {
494 register_build_info();
495 }
496 first
497}
498
499pub fn build_version() -> &'static str {
502 BUILD_VERSION
503 .get()
504 .copied()
505 .unwrap_or(env!("CARGO_PKG_VERSION"))
506}
507
508pub fn register_build_info() {
519 metrics::gauge!(
520 "faucet_build_info",
521 "version" => build_version(),
522 )
523 .set(1.0);
524}
525
526#[cfg(all(test, feature = "observability-install"))]
527mod tests {
528 use super::*;
529 use std::sync::Mutex;
530
531 static LOCK: Mutex<()> = Mutex::new(());
532
533 #[cfg(feature = "otel")]
534 #[test]
535 fn otlp_outside_a_runtime_is_skipped_not_a_panic() {
536 let cfg = ObservabilityConfig {
537 otel: Some(crate::observability::otel::OtelConfig::default()),
538 ..Default::default()
539 };
540 let report = install_observability(&cfg).expect("no runtime is not an error");
541 assert!(report.otel_signals.is_empty());
542 assert!(!report.prometheus_already_installed);
543 }
544
545 #[test]
546 fn metrics_mode_selection() {
547 use super::MetricsMode;
548 assert_eq!(MetricsMode::select(true, true), MetricsMode::Fanout);
549 assert_eq!(
550 MetricsMode::select(true, false),
551 MetricsMode::PrometheusOnly
552 );
553 assert_eq!(MetricsMode::select(false, true), MetricsMode::OtelOnly);
554 assert_eq!(MetricsMode::select(false, false), MetricsMode::None);
555 }
556
557 #[test]
558 fn no_config_returns_empty_report() {
559 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
560 let r = install_observability(&ObservabilityConfig::default()).unwrap();
561 assert!(r.prometheus_listen.is_none());
562 assert!(!r.prometheus_already_installed);
563 assert!(!r.tracing_already_installed);
564 }
565
566 #[test]
567 fn malformed_listen_returns_bind_error() {
568 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
569 let cfg = ObservabilityConfig {
570 prometheus: Some(PrometheusConfig {
571 listen: "not-a-socket".into(),
572 buckets: None,
573 }),
574 tracing: None,
575 otel: None,
576 };
577 match install_observability(&cfg) {
578 Err(InstallError::PrometheusBind { .. }) => {}
579 other => panic!("expected PrometheusBind error, got {other:?}"),
580 }
581 }
582
583 #[test]
584 fn register_build_info_is_callable_and_idempotent() {
585 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
586 register_build_info();
589 register_build_info();
590 }
591
592 #[test]
593 fn install_prometheus_and_tracing_returns_ok() {
594 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
600 let cfg = ObservabilityConfig {
601 prometheus: Some(PrometheusConfig {
602 listen: "127.0.0.1:0".into(),
603 buckets: Some(vec![0.01, 0.1, 1.0]),
605 }),
606 tracing: Some(TracingConfig {
607 level: "info".into(),
608 }),
609 otel: None,
610 };
611 let report = install_observability(&cfg).expect("install must return Ok");
612 assert!(
614 report.prometheus_listen.is_some() || report.prometheus_already_installed,
615 "prometheus install must either bind or report already-installed"
616 );
617 }
618
619 #[test]
620 fn install_tracing_with_invalid_directive_falls_back_to_info() {
621 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
624 let cfg = ObservabilityConfig {
625 prometheus: None,
626 tracing: Some(TracingConfig {
627 level: "this is !!! not a valid filter".into(),
629 }),
630 otel: None,
631 };
632 install_observability(&cfg).expect("invalid tracing directive must not fail install");
633 }
634}