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")]
92pub fn install_observability(cfg: &ObservabilityConfig) -> Result<InstallReport, InstallError> {
93 let mut report = InstallReport::default();
94
95 #[cfg(feature = "otel")]
98 let mut otel_tracer: Option<opentelemetry_sdk::trace::SdkTracerProvider> = None;
99
100 #[cfg(feature = "otel")]
102 let otel_metrics = cfg
103 .otel
104 .as_ref()
105 .map(|o| o.exports(crate::observability::otel::OtelSignal::Metrics))
106 .unwrap_or(false);
107 #[cfg(not(feature = "otel"))]
108 let otel_metrics = false;
109
110 let mode = MetricsMode::select(cfg.prometheus.is_some(), otel_metrics);
111
112 #[cfg(feature = "otel")]
114 let mut otel_meter: Option<opentelemetry_sdk::metrics::SdkMeterProvider> = None;
115
116 match mode {
117 MetricsMode::None => {}
118 MetricsMode::PrometheusOnly => {
119 install_prometheus_only(cfg.prometheus.as_ref().unwrap(), &mut report)?;
120 }
121 #[cfg(feature = "otel")]
122 MetricsMode::OtelOnly => {
123 if let Some(otel) = cfg.otel.as_ref() {
124 match crate::observability::otel::build_meter_provider(otel) {
125 Ok((mp, recorder)) => {
126 if metrics::set_global_recorder(recorder).is_err() {
127 tracing::warn!("metrics recorder already installed; continuing");
128 report.prometheus_already_installed = true;
129 } else {
130 otel_meter = Some(mp);
131 report.otel_signals.push("metrics");
132 }
133 }
134 Err(e) => tracing::warn!("OTLP metrics exporter init failed; skipping: {e}"),
135 }
136 }
137 }
138 #[cfg(feature = "otel")]
139 MetricsMode::Fanout => {
140 install_fanout(
141 cfg.prometheus.as_ref().unwrap(),
142 cfg.otel.as_ref().unwrap(),
143 &mut report,
144 &mut otel_meter,
145 )?;
146 }
147 #[cfg(not(feature = "otel"))]
148 MetricsMode::OtelOnly | MetricsMode::Fanout => {
149 unreachable!("otel metrics mode selected without the otel feature")
150 }
151 }
152
153 if let Some(t) = cfg.tracing.as_ref() {
155 use tracing_subscriber::EnvFilter;
156 use tracing_subscriber::layer::SubscriberExt;
157 use tracing_subscriber::util::SubscriberInitExt;
158
159 let make_filter =
160 || EnvFilter::try_new(&t.level).unwrap_or_else(|_| EnvFilter::new("info"));
161
162 #[cfg_attr(not(feature = "otel"), allow(unused_mut))]
165 let mut installed = false;
166
167 #[cfg(feature = "otel")]
168 {
169 let otel_traces = cfg
170 .otel
171 .as_ref()
172 .map(|o| o.exports(crate::observability::otel::OtelSignal::Traces))
173 .unwrap_or(false);
174 if let (true, Some(otel)) = (otel_traces, cfg.otel.as_ref()) {
175 match crate::observability::otel::build_trace_provider(otel) {
176 Ok(tp) => {
177 use opentelemetry::trace::TracerProvider as _;
178 let tracer = tp.tracer("faucet");
179 crate::observability::otel::install_propagator();
180 let reg = tracing_subscriber::registry()
181 .with(make_filter())
182 .with(tracing_subscriber::fmt::layer())
183 .with(tracing_opentelemetry::layer().with_tracer(tracer))
184 .with(crate::observability::otel::OtelErrorCountLayer);
185 if reg.try_init().is_err() {
186 tracing::warn!("tracing subscriber already installed; continuing");
187 report.tracing_already_installed = true;
188 } else {
191 report.otel_signals.push("traces");
192 otel_tracer = Some(tp);
193 }
194 installed = true;
195 }
196 Err(e) => tracing::warn!("OTLP trace exporter init failed; logs-only: {e}"),
197 }
198 }
199 }
200
201 if !installed {
202 #[cfg(feature = "otel")]
208 let otel_error_layer = cfg
209 .otel
210 .as_ref()
211 .map(|o| {
212 o.exports(crate::observability::otel::OtelSignal::Traces)
213 || o.exports(crate::observability::otel::OtelSignal::Metrics)
214 })
215 .unwrap_or(false)
216 .then_some(crate::observability::otel::OtelErrorCountLayer);
217 #[cfg(not(feature = "otel"))]
218 let otel_error_layer: Option<tracing_subscriber::layer::Identity> = None;
219
220 let reg = tracing_subscriber::registry()
221 .with(make_filter())
222 .with(tracing_subscriber::fmt::layer())
223 .with(otel_error_layer);
224 if reg.try_init().is_err() {
225 tracing::warn!("tracing subscriber already installed; continuing");
229 report.tracing_already_installed = true;
230 }
231 }
232 }
233
234 #[cfg(feature = "otel")]
236 {
237 if otel_tracer.is_some() || otel_meter.is_some() {
238 crate::observability::otel::describe();
239 let _ = crate::observability::otel::set_guard(crate::observability::otel::OtelGuard {
240 tracer: otel_tracer,
241 meter: otel_meter,
242 });
243 report.otel_installed = true;
244 }
245 }
246
247 crate::observability::resilience::describe();
251 crate::observability::drift::describe();
252 register_build_info();
253
254 Ok(report)
255}
256
257#[cfg(feature = "observability-install")]
261fn install_prometheus_only(
262 p: &PrometheusConfig,
263 report: &mut InstallReport,
264) -> Result<(), InstallError> {
265 use metrics_exporter_prometheus::{BuildError, PrometheusBuilder};
266
267 let listen: std::net::SocketAddr =
268 p.listen
269 .parse()
270 .map_err(|e: std::net::AddrParseError| InstallError::PrometheusBind {
271 listen: p.listen.clone(),
272 source: std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()),
273 })?;
274
275 const DEFAULT_BUCKETS: &[f64] = &[
276 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0, 10.0, 30.0, 60.0, 300.0,
277 ];
278 let buckets = p.buckets.as_deref().unwrap_or(DEFAULT_BUCKETS);
279
280 let builder = PrometheusBuilder::new()
281 .with_http_listener(listen)
282 .set_buckets(buckets)
283 .map_err(|e| InstallError::PrometheusInstall(e.to_string()))?;
284
285 match builder.install() {
286 Ok(()) => report.prometheus_listen = Some(p.listen.clone()),
287 Err(e) => match e {
291 BuildError::FailedToSetGlobalRecorder(_) => {
294 tracing::warn!("Prometheus recorder already installed; continuing");
295 report.prometheus_already_installed = true;
296 }
297 BuildError::FailedToCreateHTTPListener(msg) => {
303 return Err(InstallError::PrometheusBind {
304 listen: p.listen.clone(),
305 source: std::io::Error::other(msg),
306 });
307 }
308 other => return Err(InstallError::PrometheusInstall(other.to_string())),
309 },
310 }
311 Ok(())
312}
313
314#[cfg(all(feature = "observability-install", feature = "otel"))]
320fn install_fanout(
321 p: &PrometheusConfig,
322 otel: &crate::observability::otel::OtelConfig,
323 report: &mut InstallReport,
324 otel_meter: &mut Option<opentelemetry_sdk::metrics::SdkMeterProvider>,
325) -> Result<(), InstallError> {
326 use metrics_exporter_prometheus::{BuildError, PrometheusBuilder};
327 use metrics_util::layers::FanoutBuilder;
328
329 let listen: std::net::SocketAddr =
330 p.listen
331 .parse()
332 .map_err(|e: std::net::AddrParseError| InstallError::PrometheusBind {
333 listen: p.listen.clone(),
334 source: std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()),
335 })?;
336 const DEFAULT_BUCKETS: &[f64] = &[
337 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0, 10.0, 30.0, 60.0, 300.0,
338 ];
339 let buckets = p.buckets.as_deref().unwrap_or(DEFAULT_BUCKETS);
340
341 let (prom_recorder, prom_exporter) = PrometheusBuilder::new()
344 .with_http_listener(listen)
345 .set_buckets(buckets)
346 .map_err(|e| InstallError::PrometheusInstall(e.to_string()))?
347 .build()
348 .map_err(|e| match e {
349 BuildError::FailedToCreateHTTPListener(msg) => InstallError::PrometheusBind {
350 listen: p.listen.clone(),
351 source: std::io::Error::other(msg),
352 },
353 other => InstallError::PrometheusInstall(other.to_string()),
354 })?;
355
356 match crate::observability::otel::build_meter_provider(otel) {
357 Ok((mp, otel_recorder)) => {
358 let fanout = FanoutBuilder::default()
359 .add_recorder(prom_recorder)
360 .add_recorder(otel_recorder)
361 .build();
362 if metrics::set_global_recorder(fanout).is_err() {
363 tracing::warn!("metrics recorder already installed; continuing");
364 report.prometheus_already_installed = true;
365 } else {
366 report.prometheus_listen = Some(p.listen.clone());
367 report.otel_signals.push("metrics");
368 *otel_meter = Some(mp);
369 tokio::spawn(prom_exporter);
370 }
371 }
372 Err(e) => {
373 tracing::warn!("OTLP metrics exporter init failed; Prometheus-only: {e}");
376 if metrics::set_global_recorder(prom_recorder).is_err() {
377 report.prometheus_already_installed = true;
378 } else {
379 report.prometheus_listen = Some(p.listen.clone());
380 tokio::spawn(prom_exporter);
381 }
382 }
383 }
384 Ok(())
385}
386
387#[cfg(not(feature = "observability-install"))]
389pub fn install_observability(_cfg: &ObservabilityConfig) -> Result<InstallReport, InstallError> {
390 crate::observability::resilience::describe();
391 crate::observability::drift::describe();
392 crate::observability::otel::describe();
393 register_build_info();
394 Ok(InstallReport::default())
395}
396
397pub fn register_build_info() {
407 metrics::gauge!(
408 "faucet_build_info",
409 "version" => env!("CARGO_PKG_VERSION"),
410 )
411 .set(1.0);
412}
413
414#[cfg(all(test, feature = "observability-install"))]
415mod tests {
416 use super::*;
417 use std::sync::Mutex;
418
419 static LOCK: Mutex<()> = Mutex::new(());
420
421 #[test]
422 fn metrics_mode_selection() {
423 use super::MetricsMode;
424 assert_eq!(MetricsMode::select(true, true), MetricsMode::Fanout);
425 assert_eq!(
426 MetricsMode::select(true, false),
427 MetricsMode::PrometheusOnly
428 );
429 assert_eq!(MetricsMode::select(false, true), MetricsMode::OtelOnly);
430 assert_eq!(MetricsMode::select(false, false), MetricsMode::None);
431 }
432
433 #[test]
434 fn no_config_returns_empty_report() {
435 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
436 let r = install_observability(&ObservabilityConfig::default()).unwrap();
437 assert!(r.prometheus_listen.is_none());
438 assert!(!r.prometheus_already_installed);
439 assert!(!r.tracing_already_installed);
440 }
441
442 #[test]
443 fn malformed_listen_returns_bind_error() {
444 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
445 let cfg = ObservabilityConfig {
446 prometheus: Some(PrometheusConfig {
447 listen: "not-a-socket".into(),
448 buckets: None,
449 }),
450 tracing: None,
451 otel: None,
452 };
453 match install_observability(&cfg) {
454 Err(InstallError::PrometheusBind { .. }) => {}
455 other => panic!("expected PrometheusBind error, got {other:?}"),
456 }
457 }
458
459 #[test]
460 fn register_build_info_is_callable_and_idempotent() {
461 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
462 register_build_info();
465 register_build_info();
466 }
467
468 #[test]
469 fn install_prometheus_and_tracing_returns_ok() {
470 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
476 let cfg = ObservabilityConfig {
477 prometheus: Some(PrometheusConfig {
478 listen: "127.0.0.1:0".into(),
479 buckets: Some(vec![0.01, 0.1, 1.0]),
481 }),
482 tracing: Some(TracingConfig {
483 level: "info".into(),
484 }),
485 otel: None,
486 };
487 let report = install_observability(&cfg).expect("install must return Ok");
488 assert!(
490 report.prometheus_listen.is_some() || report.prometheus_already_installed,
491 "prometheus install must either bind or report already-installed"
492 );
493 }
494
495 #[test]
496 fn install_tracing_with_invalid_directive_falls_back_to_info() {
497 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
500 let cfg = ObservabilityConfig {
501 prometheus: None,
502 tracing: Some(TracingConfig {
503 level: "this is !!! not a valid filter".into(),
505 }),
506 otel: None,
507 };
508 install_observability(&cfg).expect("invalid tracing directive must not fail install");
509 }
510}