LCOV - code coverage report
Current view: top level - antares-broker/src - telemetry.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 53.6 % 209 112
Test Date: 2026-11-02 13:21:27 Functions: 38.5 % 52 20

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! Tracing + Prometheus metrics + env-gated OTLP span export.
       3              : //!
       4              : //! Naming follows Prometheus conventions with the `antares_` prefix and
       5              : //! unit suffixes. The `metrics` facade is what core crates speak; THIS
       6              : //! module is the only place an exporter exists (only the
       7              : //! composition root knows). `/q/metrics` renders the Prometheus text
       8              : //! format via the closure wired onto AppState.
       9              : //!
      10              : //! ALL of it sits behind a RUNTIME switch: ANTARES_TELEMETRY=1 installs
      11              : //! the recorder, the sampler and (with ANTARES_OTLP_ENDPOINT) the OTLP
      12              : //! pipeline at startup; the default constructs NONE of it — `metrics::`
      13              : //! macro calls no-op without a recorder (zero allocations), and
      14              : //! /q/metrics answers 404. One build, lean by default; flip the
      15              : //! env and restart where a dashboard actually scrapes.
      16              : //!
      17              : //! OTLP: set ANTARES_OTLP_ENDPOINT (e.g. http://collector:4318/v1/traces)
      18              : //! and spans flow out over OTLP/HTTP, log records to its `v1/logs` twin
      19              : //! with the same resource; unset (the default) costs nothing.
      20              : //! tokio-console: cargo feature `console` + RUSTFLAGS="--cfg tokio_unstable"
      21              : //! (the layer only arms when BOTH are present — an --all-features build
      22              : //! without the RUSTFLAGS must not panic at startup).
      23              : 
      24              : use std::sync::Arc;
      25              : 
      26              : /// What /q/metrics renders through — exporter type erased so `main` builds
      27              : /// identically with and without the `telemetry` feature.
      28              : pub type MetricsRender = Arc<dyn Fn() -> String + Send + Sync>;
      29              : 
      30              : /// Is the observability stack switched on for this process? The default is
      31              : /// off, so ANY value that is not an explicit off spelling arms it — a knob
      32              : /// that recognized only `1|true|on` disabled the whole stack on `TRUE`
      33              : /// without a word.
      34          107 : pub fn enabled() -> bool {
      35          107 :     std::env::var("ANTARES_TELEMETRY").is_ok_and(|v| !crate::is_off(&v))
      36          107 : }
      37              : 
      38              : /// Strip `user:password@` userinfo (RFC 3986 clause 3.2.1) out of a URL before
      39              : /// it is logged. A string without an authority component — no scheme, or an
      40              : /// `@` that belongs to the path — comes back byte-identical.
      41           14 : fn redact_url(url: &str) -> String {
      42           14 :     let Some((scheme, rest)) = url.split_once("://") else {
      43            4 :         return url.to_owned();
      44              :     };
      45           10 :     let (authority, path) = match rest.find('/') {
      46           10 :         Some(i) => rest.split_at(i),
      47            0 :         None => (rest, ""),
      48              :     };
      49           10 :     match authority.rsplit_once('@') {
      50            6 :         Some((_, host)) => format!("{scheme}://{host}{path}"),
      51            4 :         None => url.to_owned(),
      52              :     }
      53           14 : }
      54              : 
      55              : /// An exporter that refuses its endpoint prints the endpoint back verbatim
      56              : /// (`invalid URI {0}`), so a rejected URL carrying `user:password@` userinfo
      57              : /// (RFC 3986 clause 3.2.1) reaches the startup error the way it would reach a
      58              : /// log line. The endpoint is known at the call site, so the failure carries
      59              : /// its redacted form instead.
      60            4 : fn no_userinfo(message: String, endpoint: &str) -> String {
      61            4 :     let safe = redact_url(endpoint);
      62            4 :     if safe == endpoint {
      63            0 :         message
      64              :     } else {
      65            4 :         message.replace(endpoint, &safe)
      66              :     }
      67            4 : }
      68              : 
      69              : /// The OTLP/HTTP logs endpoint paired with a traces endpoint: the standard
      70              : /// `v1/traces` suffix becomes `v1/logs`; any other URL is used as given.
      71            6 : fn logs_endpoint(traces: &str) -> String {
      72            6 :     match traces.strip_suffix("/v1/traces") {
      73            4 :         Some(base) => format!("{base}/v1/logs"),
      74            2 :         None => traces.to_owned(),
      75              :     }
      76            6 : }
      77              : 
      78              : /// Install the tracing subscriber stack and (ANTARES_TELEMETRY=1) the
      79              : /// Prometheus recorder. Call once, before the runtime spins up anything
      80              : /// measurable. Returns the /q/metrics render closure, or None when the
      81              : /// switch is off — in which case nothing telemetry-shaped is allocated.
      82           59 : pub fn init() -> Result<Option<MetricsRender>, Box<dyn std::error::Error>> {
      83              :     use tracing_subscriber::layer::SubscriberExt;
      84              :     use tracing_subscriber::util::SubscriberInitExt;
      85              : 
      86           59 :     let env_filter =
      87           59 :         tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into());
      88           59 :     let fmt = tracing_subscriber::fmt::layer();
      89              : 
      90              :     // The collector endpoint is a URL and may carry `user:password@` userinfo
      91              :     // (RFC 3986 clause 3.2.1); it is logged at startup, so the credential is
      92              :     // stripped first.
      93              :     // Env-gated OTLP pipeline — needs the switch AND an endpoint. Spans and
      94              :     // log records share one resource so a collector joins them.
      95           59 :     let (otlp, logs) = match std::env::var("ANTARES_OTLP_ENDPOINT") {
      96            0 :         Ok(endpoint) if enabled() => {
      97              :             use opentelemetry::trace::TracerProvider as _;
      98              :             use opentelemetry_otlp::WithExportConfig as _;
      99              :             use tracing_subscriber::Layer as _;
     100            0 :             let resource = opentelemetry_sdk::Resource::builder()
     101            0 :                 .with_service_name("antares")
     102            0 :                 .build();
     103            0 :             let exporter = opentelemetry_otlp::SpanExporter::builder()
     104            0 :                 .with_http()
     105            0 :                 .with_endpoint(endpoint.clone())
     106            0 :                 .build()
     107            0 :                 .map_err(|e| no_userinfo(e.to_string(), &endpoint))?;
     108            0 :             let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder()
     109            0 :                 .with_batch_exporter(exporter)
     110            0 :                 .with_resource(resource.clone())
     111            0 :                 .build();
     112            0 :             let tracer = provider.tracer("antares");
     113              :             // Log records: batch exporter on its own thread with a bounded
     114              :             // queue, so a dead collector drops records instead of stalling
     115              :             // a request. The exporter's own HTTP stack is filtered out of
     116              :             // the bridge, else every export would log another export.
     117            0 :             let logs_endpoint = logs_endpoint(&endpoint);
     118            0 :             let log_exporter = opentelemetry_otlp::LogExporter::builder()
     119            0 :                 .with_http()
     120            0 :                 .with_endpoint(logs_endpoint.clone())
     121            0 :                 .build()
     122            0 :                 .map_err(|e| no_userinfo(e.to_string(), &logs_endpoint))?;
     123            0 :             let logger_provider = opentelemetry_sdk::logs::SdkLoggerProvider::builder()
     124            0 :                 .with_batch_exporter(log_exporter)
     125            0 :                 .with_resource(resource)
     126            0 :                 .build();
     127            0 :             let bridge = opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge::new(
     128            0 :                 &logger_provider,
     129              :             )
     130            0 :             .with_filter(tracing_subscriber::filter::filter_fn(|m| {
     131            0 :                 !["opentelemetry", "hyper", "reqwest", "h2", "tonic"]
     132            0 :                     .iter()
     133            0 :                     .any(|t| m.target().starts_with(t))
     134            0 :             }));
     135            0 :             tracing::info!(
     136            0 :                 endpoint = redact_url(&endpoint),
     137              :                 "OTLP span and log export enabled"
     138              :             );
     139            0 :             (
     140            0 :                 Some(tracing_opentelemetry::layer().with_tracer(tracer)),
     141            0 :                 Some(bridge),
     142            0 :             )
     143              :         }
     144           59 :         _ => (None, None),
     145              :     };
     146              : 
     147              :     #[cfg(all(feature = "console", tokio_unstable))]
     148              :     let console = Some(console_subscriber::spawn());
     149              :     #[cfg(not(all(feature = "console", tokio_unstable)))]
     150           59 :     let console: Option<tracing_subscriber::layer::Identity> = None;
     151              : 
     152           59 :     tracing_subscriber::registry()
     153           59 :         .with(env_filter)
     154           59 :         .with(fmt)
     155           59 :         .with(otlp)
     156           59 :         .with(logs)
     157           59 :         .with(console)
     158           59 :         .init();
     159              : 
     160           59 :     if !enabled() {
     161              :         // An endpoint without the switch is a configuration the operator
     162              :         // believes is exporting: say so rather than drop it silently. Logged
     163              :         // here because the subscriber above is what makes a log visible.
     164           59 :         if let Ok(endpoint) = std::env::var("ANTARES_OTLP_ENDPOINT") {
     165            0 :             tracing::warn!(
     166            0 :                 endpoint = redact_url(&endpoint),
     167              :                 "ANTARES_OTLP_ENDPOINT is set but ANTARES_TELEMETRY is off — \
     168              :                  no spans or logs are exported"
     169              :             );
     170           59 :         }
     171           59 :         return Ok(None); // the recorder, registry and sampler are never built
     172            0 :     }
     173            0 :     let handle = metrics_exporter_prometheus::PrometheusBuilder::new()
     174            0 :         .set_buckets(LATENCY_BUCKETS_SECONDS)?
     175            0 :         .install_recorder()?;
     176            0 :     describe();
     177            0 :     Ok(Some(Arc::new(move || handle.render())))
     178           59 : }
     179              : 
     180              : /// Bucket bounds, in seconds, for every histogram this binary registers —
     181              : /// both are request-shaped latencies.
     182              : ///
     183              : /// Without them the exporter renders a histogram as a rolling summary, whose
     184              : /// quantiles are computed over a sliding window it owns rather than over the
     185              : /// scrape: an idle window reports `0` for every quantile while `_count` and
     186              : /// `_sum` keep climbing, a busy one reports only the last window, and neither
     187              : /// can be aggregated across instances or read back over a rollout. Buckets
     188              : /// are cumulative and belong to the scrape, so `histogram_quantile()` works
     189              : /// on them.
     190              : ///
     191              : /// The range is set from measured service time: single-digit milliseconds
     192              : /// when the broker is healthy, tens of seconds once the accept path is the
     193              : /// bottleneck. The exporter's own default stops at 10 s, which files every
     194              : /// slow request under `+Inf` — precisely the requests a latency dashboard
     195              : /// exists to show.
     196              : const LATENCY_BUCKETS_SECONDS: &[f64] = &[
     197              :     0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0,
     198              : ];
     199              : 
     200              : /// Metric metadata — Prometheus-convention names (antares_ prefix, unit suffixes).
     201            0 : fn describe() {
     202              :     use metrics::{describe_counter, describe_gauge, describe_histogram, Unit};
     203            0 :     describe_counter!(
     204              :         "antares_http_requests_total",
     205              :         Unit::Count,
     206              :         "HTTP requests served, by method and status class"
     207              :     );
     208            0 :     describe_histogram!(
     209              :         "antares_http_request_duration_seconds",
     210              :         Unit::Seconds,
     211              :         "HTTP request service time"
     212              :     );
     213            0 :     describe_counter!(
     214              :         "antares_change_final_delivery_total",
     215              :         Unit::Count,
     216              :         "change messages handed to the matcher on their last allowed delivery; one that then fails is dropped by the server"
     217              :     );
     218            0 :     describe_counter!(
     219              :         "antares_policy_failures_total",
     220              :         Unit::Count,
     221              :         "policy decisions the seam had to make itself because the engine did not, by reason"
     222              :     );
     223            0 :     describe_counter!(
     224              :         "antares_notifications_sent_total",
     225              :         Unit::Count,
     226              :         "notifications delivered successfully, by sink scheme"
     227              :     );
     228            0 :     describe_counter!(
     229              :         "antares_notifications_failed_total",
     230              :         Unit::Count,
     231              :         "notification deliveries that failed, by sink scheme"
     232              :     );
     233            0 :     describe_histogram!(
     234              :         "antares_change_lag_seconds",
     235              :         Unit::Seconds,
     236              :         "bus=nats: change-event age (stream publish -> matcher processing)"
     237              :     );
     238            0 :     describe_gauge!(
     239              :         "antares_draining",
     240              :         Unit::Count,
     241              :         "1 while this instance drains — a roll is visible on a dashboard"
     242              :     );
     243            0 :     describe_gauge!(
     244              :         "antares_uptime_seconds",
     245              :         Unit::Seconds,
     246              :         "seconds since process start"
     247              :     );
     248            0 :     describe_gauge!(
     249              :         "antares_memory_allocated_bytes",
     250              :         Unit::Bytes,
     251              :         "jemalloc allocated (live) bytes"
     252              :     );
     253            0 :     describe_gauge!(
     254              :         "antares_memory_resident_bytes",
     255              :         Unit::Bytes,
     256              :         "jemalloc resident bytes (RSS ~ live x1.2 is the section-2.1 target)"
     257              :     );
     258            0 :     describe_gauge!(
     259              :         "antares_commit_queue_depth",
     260              :         Unit::Count,
     261              :         "file mode: writers queued behind the single redb committer"
     262              :     );
     263            0 :     describe_histogram!(
     264              :         "antares_pg_transaction_begin_seconds",
     265              :         Unit::Seconds,
     266              :         "postgres: time to obtain a pooled connection and open a transaction \
     267              :          — the pool wait plus one BEGIN round trip, so this is where pool \
     268              :          pressure shows"
     269              :     );
     270            0 :     describe_counter!(
     271              :         "antares_pg_pool_timeouts_total",
     272              :         Unit::Count,
     273              :         "postgres: acquire timeouts — the request was answered 503 with Retry-After"
     274              :     );
     275            0 :     describe_gauge!(
     276              :         "antares_limit_rejections_total",
     277              :         Unit::Count,
     278              :         "bounds-wall rejections, by limit"
     279              :     );
     280            0 : }
     281              : 
     282              : /// The 5 s gauge sampler: process-level state that has no natural
     283              : /// increment site. Spawned once per process from `run`. With the switch
     284              : /// off there is no recorder to feed — no task is spawned at all.
     285           12 : pub fn spawn_sampler(state: antares_api::AppState) {
     286           12 :     if !enabled() {
     287           12 :         return;
     288            0 :     }
     289            0 :     tokio::spawn(async move {
     290            0 :         let mut tick = tokio::time::interval(std::time::Duration::from_secs(5));
     291              :         loop {
     292            0 :             tick.tick().await;
     293            0 :             metrics::gauge!("antares_uptime_seconds").set(state.started.elapsed().as_secs_f64());
     294            0 :             metrics::gauge!("antares_draining").set(f64::from(u8::from(
     295            0 :                 state.draining.load(std::sync::atomic::Ordering::Relaxed),
     296              :             )));
     297            0 :             if let Some(mem) = &state.mem_stats {
     298            0 :                 let m = mem();
     299            0 :                 if let Some(a) = m.get("allocatedBytes").and_then(serde_json::Value::as_u64) {
     300            0 :                     metrics::gauge!("antares_memory_allocated_bytes").set(a as f64);
     301            0 :                 }
     302            0 :                 if let Some(r) = m.get("residentBytes").and_then(serde_json::Value::as_u64) {
     303            0 :                     metrics::gauge!("antares_memory_resident_bytes").set(r as f64);
     304            0 :                 }
     305            0 :             }
     306            0 :             if let Some((depth, _peak)) = state.store.commit_queue() {
     307            0 :                 metrics::gauge!("antares_commit_queue_depth").set(depth as f64);
     308            0 :             }
     309              :             // Limit counters live in LimitStats (incremented at rejection
     310              :             // sites); exported here so the wall is observable BEFORE users
     311              :             // hit it.
     312            0 :             if let Some(map) = state.limits.snapshot().as_object() {
     313            0 :                 for (key, n) in map {
     314            0 :                     if let Some(limit) = key.strip_prefix("rejected") {
     315            0 :                         if let Some(n) = n.as_u64() {
     316            0 :                             metrics::gauge!(
     317            0 :                                 "antares_limit_rejections_total",
     318            0 :                                 "limit" => limit.to_owned()
     319            0 :                             )
     320            0 :                             .set(n as f64);
     321            0 :                         }
     322            0 :                     }
     323              :                 }
     324            0 :             }
     325              :         }
     326              :     });
     327           12 : }
     328              : #[cfg(test)]
     329              : mod logs_endpoint_tests {
     330              :     #[test]
     331            2 :     fn traces_suffix_becomes_logs_anything_else_is_kept() {
     332            2 :         assert_eq!(
     333            2 :             super::logs_endpoint("http://c:4318/v1/traces"),
     334              :             "http://c:4318/v1/logs"
     335              :         );
     336            2 :         assert_eq!(
     337            2 :             super::logs_endpoint("http://c:4318/otlp"),
     338              :             "http://c:4318/otlp"
     339              :         );
     340            2 :     }
     341              : }
     342              : 
     343              : #[cfg(test)]
     344              : mod tests {
     345              :     use super::*;
     346              : 
     347              :     /// The switch is the whole stack's gate, and the environment is
     348              :     /// process-global — every spelling is asserted in ONE test. The DEFAULT
     349              :     /// is off, so only an explicit off value may keep it off: a knob that
     350              :     /// recognized `1|true|on` alone disabled the whole observability stack on
     351              :     /// `TRUE` without a word.
     352              :     #[test]
     353            2 :     fn telemetry_switch_is_off_by_default_and_tolerant_of_spelling() {
     354            2 :         std::env::remove_var("ANTARES_TELEMETRY");
     355            2 :         assert!(!enabled(), "the default must allocate nothing");
     356           16 :         for on in ["1", "true", "on", "TRUE", "On", "yes", "1 ", " 1"] {
     357           16 :             std::env::set_var("ANTARES_TELEMETRY", on);
     358           16 :             assert!(enabled(), "ANTARES_TELEMETRY={on:?} must arm the stack");
     359              :         }
     360           18 :         for off in ["0", "false", "off", "", " ", "FALSE", "Off", "no", " 0 "] {
     361           18 :             std::env::set_var("ANTARES_TELEMETRY", off);
     362           18 :             assert!(
     363           18 :                 !enabled(),
     364              :                 "ANTARES_TELEMETRY={off:?} must NOT arm the stack"
     365              :             );
     366              :         }
     367            2 :         std::env::remove_var("ANTARES_TELEMETRY");
     368            2 :     }
     369              : 
     370              :     /// A collector endpoint is a URL and may carry `user:password@` userinfo
     371              :     /// (RFC 3986 clause 3.2.1). It is logged at startup, so the credential must be
     372              :     /// stripped before it reaches the log.
     373              :     #[test]
     374            2 :     fn otlp_endpoint_userinfo_never_reaches_the_log() {
     375            2 :         let redacted = redact_url("http://otel:s3cr3t@collector.internal:4318/v1/traces");
     376            2 :         assert!(
     377            2 :             !redacted.contains("s3cr3t") && !redacted.contains("otel:"),
     378              :             "userinfo leaked into the log line: {redacted}"
     379              :         );
     380            2 :         assert!(
     381            2 :             redacted.contains("collector.internal:4318/v1/traces"),
     382              :             "the useful part of the endpoint must survive: {redacted}"
     383              :         );
     384              :         // No userinfo: byte-identical, including an '@' that belongs to the
     385              :         // path rather than the authority.
     386            8 :         for plain in [
     387            2 :             "http://collector:4318/v1/traces",
     388            2 :             "https://collector:4318/v1/@traces",
     389            2 :             "collector:4318",
     390            2 :             "",
     391            2 :         ] {
     392            8 :             assert_eq!(redact_url(plain), plain, "rewrote a credential-free URL");
     393              :         }
     394            2 :     }
     395              : 
     396              :     /// An exporter that refuses its endpoint prints the endpoint back
     397              :     /// verbatim, so the credential survives the build failure and lands in
     398              :     /// the startup error `main` prints — the leak `redact_url` closes on the
     399              :     /// two paths that succeed. Both exporters are built from the same
     400              :     /// operator-supplied URL, so both have to lose it.
     401              :     #[test]
     402            2 :     fn a_rejected_collector_endpoint_loses_its_password() {
     403              :         use opentelemetry_otlp::WithExportConfig as _;
     404              :         // A space is not a legal URI character, so the build fails with no
     405              :         // collector to reach — and the rejected endpoint rides the error.
     406            2 :         let endpoint = "http://otel:s3cr3t@collector .internal:4318/v1/traces";
     407            2 :         let Err(spans) = opentelemetry_otlp::SpanExporter::builder()
     408            2 :             .with_http()
     409            2 :             .with_endpoint(endpoint)
     410            2 :             .build()
     411              :         else {
     412            0 :             panic!("a URI carrying a space is rejected")
     413              :         };
     414            2 :         let logs_endpoint = logs_endpoint(endpoint);
     415            2 :         let Err(logs) = opentelemetry_otlp::LogExporter::builder()
     416            2 :             .with_http()
     417            2 :             .with_endpoint(logs_endpoint.clone())
     418            2 :             .build()
     419              :         else {
     420            0 :             panic!("a URI carrying a space is rejected")
     421              :         };
     422            4 :         for (raw, needle) in [
     423            2 :             (spans.to_string(), endpoint),
     424            2 :             (logs.to_string(), logs_endpoint.as_str()),
     425            2 :         ] {
     426            4 :             let safe = no_userinfo(raw, needle);
     427            4 :             assert!(!safe.contains("s3cr3t"), "the password reached it: {safe}");
     428            4 :             assert!(!safe.contains("otel:"), "the userinfo reached it: {safe}");
     429            4 :             assert!(
     430            4 :                 safe.contains("collector"),
     431              :                 "the destination stays readable: {safe}"
     432              :             );
     433              :         }
     434            2 :     }
     435              : 
     436              :     /// Metric label cardinality: the only labelled instrument this module
     437              :     /// feeds is the limit-rejection gauge, whose label comes from a fixed key
     438              :     /// set in the bounds snapshot — never from a client-controlled string
     439              :     /// (a tenant, a URI or a header would blow up the time-series count).
     440              :     #[test]
     441            2 :     fn limit_rejection_labels_are_a_closed_identifier_set() {
     442            2 :         let snapshot = antares_api::bounds::LimitStats::default().snapshot();
     443            2 :         let labels: Vec<String> = snapshot
     444            2 :             .as_object()
     445            2 :             .expect("snapshot is an object")
     446            2 :             .keys()
     447           68 :             .filter_map(|k| k.strip_prefix("rejected").map(str::to_owned))
     448            2 :             .collect();
     449            2 :         assert_eq!(
     450            2 :             labels.len(),
     451              :             3,
     452              :             "the rejection label set changed — keep it closed and bounded: {labels:?}"
     453              :         );
     454            6 :         for l in &labels {
     455            6 :             assert!(
     456           66 :                 l.chars().all(|c| c.is_ascii_alphanumeric()),
     457              :                 "label value {l:?} is not a fixed identifier"
     458              :             );
     459              :         }
     460            2 :     }
     461              : }
        

Generated by: LCOV version 2.0-1