Skip to main content

phantom_protocol/observability/
config.rs

1//! Observability configuration.
2//!
3//! Configuration is captured at `Observability::new` time and frozen for the
4//! lifetime of the instance. The values it carries (namespace prefix,
5//! histogram bucket boundaries) are formatted into instrument names and
6//! applied to instruments on construction, so freezing avoids per-call cost.
7//!
8//! Env-var conventions follow the OpenTelemetry SDK spec where applicable,
9//! with `PHANTOM_TELEMETRY_*` for project-specific knobs. See
10//! `docs/observability/refactor-plan.md` §7 for the full ENV reference.
11
12use std::borrow::Cow;
13
14/// Observability configuration.
15///
16/// Cheap to construct, `Clone`-able, and `Send + Sync`. The captured
17/// `namespace` is used as a prefix for every OTel instrument name
18/// (`"{namespace}.session.packets"`, etc.). Default prefix is `"phantom"`.
19#[derive(Debug, Clone)]
20pub struct ObservabilityConfig {
21    /// Instrument-name prefix. Default `"phantom"`.
22    ///
23    /// Populated from `PHANTOM_TELEMETRY_NAMESPACE` by [`Self::from_env`].
24    ///
25    /// Note: this prefixes **metric instrument names** only. `tracing`
26    /// span names are compile-time string literals (`phantom.handshake.*`,
27    /// `phantom.listener.*`) and are not affected by this knob.
28    pub namespace: Cow<'static, str>,
29
30    /// Bucket boundaries for latency instruments
31    /// (`handshake.duration`, `path.validation.duration`).
32    pub histogram: HistogramConfig,
33}
34
35/// Explicit bucket boundaries (in seconds) for latency histograms
36/// (`{ns}.handshake.duration`, `{ns}.path.validation.duration`).
37///
38/// Applied directly to the OTel `Histogram` instrument via
39/// `f64_histogram(...).with_boundaries(...)` — a version-stable API across
40/// the `opentelemetry` 0.27–0.32 line. (Base-2 exponential aggregation
41/// would need an SDK View; the View API is still in flux across these
42/// versions, so explicit boundaries are the supported choice for now.)
43#[derive(Debug, Clone)]
44pub struct HistogramConfig {
45    /// Bucket upper bounds, seconds, ascending.
46    pub boundaries: Vec<f64>,
47}
48
49impl Default for HistogramConfig {
50    fn default() -> Self {
51        // Latency-tuned for a post-quantum handshake: ~2-50 ms typical,
52        // up to multi-second under PoW back-pressure / CPU saturation.
53        Self {
54            boundaries: vec![
55                0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0,
56            ],
57        }
58    }
59}
60
61impl Default for ObservabilityConfig {
62    fn default() -> Self {
63        Self {
64            namespace: Cow::Borrowed("phantom"),
65            histogram: HistogramConfig::default(),
66        }
67    }
68}
69
70impl ObservabilityConfig {
71    /// Construct a config from environment variables. Unset or empty
72    /// variables fall back to defaults.
73    ///
74    /// To disable telemetry at runtime, build without the `telemetry-otel`
75    /// Cargo feature, or simply do not point `OTEL_EXPORTER_OTLP_ENDPOINT`
76    /// at a reachable collector — the SDK's bounded export queue then drops
77    /// telemetry at near-zero cost.
78    pub fn from_env() -> Self {
79        let namespace = std::env::var("PHANTOM_TELEMETRY_NAMESPACE")
80            .ok()
81            .filter(|s| !s.is_empty())
82            .map(Cow::Owned)
83            .unwrap_or(Cow::Borrowed("phantom"));
84        Self {
85            namespace,
86            histogram: HistogramConfig::default(),
87        }
88    }
89
90    /// Begin a programmatic builder for [`ObservabilityConfig`].
91    pub fn builder() -> ObservabilityConfigBuilder {
92        ObservabilityConfigBuilder::default()
93    }
94}
95
96/// Builder for [`ObservabilityConfig`].
97#[derive(Debug, Default)]
98pub struct ObservabilityConfigBuilder {
99    namespace: Option<Cow<'static, str>>,
100    histogram: Option<HistogramConfig>,
101}
102
103impl ObservabilityConfigBuilder {
104    /// Override the instrument-name prefix.
105    pub fn namespace<S: Into<Cow<'static, str>>>(mut self, ns: S) -> Self {
106        self.namespace = Some(ns.into());
107        self
108    }
109
110    /// Override the histogram bucket boundaries.
111    pub fn histogram(mut self, h: HistogramConfig) -> Self {
112        self.histogram = Some(h);
113        self
114    }
115
116    /// Finalize the configuration.
117    pub fn build(self) -> ObservabilityConfig {
118        let mut cfg = ObservabilityConfig::default();
119        if let Some(ns) = self.namespace {
120            cfg.namespace = ns;
121        }
122        if let Some(h) = self.histogram {
123            cfg.histogram = h;
124        }
125        cfg
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn default_namespace_is_phantom() {
135        let cfg = ObservabilityConfig::default();
136        assert_eq!(cfg.namespace.as_ref(), "phantom");
137        // Default histogram boundaries are ascending and non-empty.
138        let b = &cfg.histogram.boundaries;
139        assert!(!b.is_empty());
140        assert!(b.windows(2).all(|w| w[0] < w[1]), "boundaries must ascend");
141    }
142
143    #[test]
144    fn builder_overrides_namespace() {
145        let cfg = ObservabilityConfig::builder().namespace("myapp").build();
146        assert_eq!(cfg.namespace.as_ref(), "myapp");
147    }
148
149    #[test]
150    fn builder_overrides_histogram() {
151        let cfg = ObservabilityConfig::builder()
152            .histogram(HistogramConfig {
153                boundaries: vec![0.001, 0.01, 0.1, 1.0],
154            })
155            .build();
156        assert_eq!(cfg.histogram.boundaries.len(), 4);
157    }
158}