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}