Skip to main content

vcl_protocol/
prometheus_metrics.rs

1//! # VCL Prometheus Metrics Export
2//!
3//! Exports VCL Protocol statistics in Prometheus format for monitoring
4//! with Grafana, Alertmanager, and other observability tools.
5//!
6//! ## Example
7//!
8//! ```rust,ignore
9//! use vcl_protocol::prometheus_metrics::VCLPrometheusExporter;
10//!
11//! let exporter = VCLPrometheusExporter::new().unwrap();
12//! exporter.update_bytes_sent(1024);
13//! exporter.update_packets_sent(1);
14//!
15//! // Get metrics in Prometheus text format
16//! let output = exporter.render();
17//! println!("{}", output);
18//! ```
19
20use prometheus::{
21    Registry, Counter, Gauge, Histogram, HistogramOpts, Opts,
22    TextEncoder, Encoder,
23};
24use crate::error::VCLError;
25use crate::metrics::VCLMetrics;
26use crate::tunnel::TunnelStats;
27use tracing::debug;
28
29/// Prometheus metrics exporter for VCL Protocol.
30///
31/// Exposes all VCL metrics in Prometheus text format,
32/// ready to be scraped by a Prometheus server.
33pub struct VCLPrometheusExporter {
34    registry: Registry,
35
36    // ─── Traffic counters ──────────────────────────────────────────────────────
37    bytes_sent:     Counter,
38    bytes_received: Counter,
39    packets_sent:   Counter,
40    packets_received: Counter,
41    packets_retransmitted: Counter,
42    packets_dropped: Counter,
43
44    // ─── Connection gauges ─────────────────────────────────────────────────────
45    connections_active: Gauge,
46    reconnect_count:    Counter,
47    handshakes_total:   Counter,
48    key_rotations_total: Counter,
49
50    // ─── Performance gauges ────────────────────────────────────────────────────
51    loss_rate:           Gauge,
52    rtt_seconds:         Gauge,
53    cwnd_packets:        Gauge,
54    obfuscation_overhead: Gauge,
55    mtu_bytes:           Gauge,
56
57    // ─── DNS counters ──────────────────────────────────────────────────────────
58    dns_queries_total:   Counter,
59    dns_blocked_total:   Counter,
60    dns_cache_hits:      Counter,
61
62    // ─── Fragment counters ─────────────────────────────────────────────────────
63    fragments_sent:        Counter,
64    fragments_reassembled: Counter,
65
66    // ─── RTT histogram ────────────────────────────────────────────────────────
67    rtt_histogram: Histogram,
68
69    // ─── Tunnel state gauge ───────────────────────────────────────────────────
70    /// 0=Stopped, 1=Connecting, 2=Connected, 3=Reconnecting, 4=Failed
71    tunnel_state: Gauge,
72}
73
74impl VCLPrometheusExporter {
75    /// Create a new exporter with its own Prometheus registry.
76    pub fn new() -> Result<Self, VCLError> {
77        let registry = Registry::new();
78
79        macro_rules! counter {
80            ($name:expr, $help:expr) => {{
81                let c = Counter::with_opts(Opts::new($name, $help))
82                    .map_err(|e| VCLError::IoError(format!("Prometheus counter: {}", e)))?;
83                registry.register(Box::new(c.clone()))
84                    .map_err(|e| VCLError::IoError(format!("Prometheus register: {}", e)))?;
85                c
86            }};
87        }
88
89        macro_rules! gauge {
90            ($name:expr, $help:expr) => {{
91                let g = Gauge::with_opts(Opts::new($name, $help))
92                    .map_err(|e| VCLError::IoError(format!("Prometheus gauge: {}", e)))?;
93                registry.register(Box::new(g.clone()))
94                    .map_err(|e| VCLError::IoError(format!("Prometheus register: {}", e)))?;
95                g
96            }};
97        }
98
99        let rtt_histogram = Histogram::with_opts(
100            HistogramOpts::new("vcl_rtt_seconds", "Round-trip time in seconds")
101                .buckets(vec![0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0]),
102        ).map_err(|e| VCLError::IoError(format!("Prometheus histogram: {}", e)))?;
103        registry.register(Box::new(rtt_histogram.clone()))
104            .map_err(|e| VCLError::IoError(format!("Prometheus register: {}", e)))?;
105
106        debug!("VCLPrometheusExporter initialized");
107
108        Ok(VCLPrometheusExporter {
109            bytes_sent:            counter!("vcl_bytes_sent_total",           "Total bytes sent"),
110            bytes_received:        counter!("vcl_bytes_received_total",       "Total bytes received"),
111            packets_sent:          counter!("vcl_packets_sent_total",         "Total packets sent"),
112            packets_received:      counter!("vcl_packets_received_total",     "Total packets received"),
113            packets_retransmitted: counter!("vcl_packets_retransmitted_total","Total retransmitted packets"),
114            packets_dropped:       counter!("vcl_packets_dropped_total",      "Total dropped packets"),
115            connections_active:    gauge!  ("vcl_connections_active",         "Currently active connections"),
116            reconnect_count:       counter!("vcl_reconnects_total",           "Total reconnection attempts"),
117            handshakes_total:      counter!("vcl_handshakes_total",           "Total handshakes completed"),
118            key_rotations_total:   counter!("vcl_key_rotations_total",        "Total key rotations"),
119            loss_rate:             gauge!  ("vcl_loss_rate",                  "Current packet loss rate 0.0-1.0"),
120            rtt_seconds:           gauge!  ("vcl_rtt_seconds_current",        "Current smoothed RTT in seconds"),
121            cwnd_packets:          gauge!  ("vcl_cwnd_packets",               "Current congestion window size"),
122            obfuscation_overhead:  gauge!  ("vcl_obfuscation_overhead_ratio", "Obfuscation overhead ratio"),
123            mtu_bytes:             gauge!  ("vcl_mtu_bytes",                  "Current path MTU in bytes"),
124            dns_queries_total:     counter!("vcl_dns_queries_total",          "Total DNS queries intercepted"),
125            dns_blocked_total:     counter!("vcl_dns_blocked_total",          "Total DNS queries blocked"),
126            dns_cache_hits:        counter!("vcl_dns_cache_hits_total",       "Total DNS cache hits"),
127            fragments_sent:        counter!("vcl_fragments_sent_total",       "Total fragmented messages sent"),
128            fragments_reassembled: counter!("vcl_fragments_reassembled_total","Total fragments reassembled"),
129            tunnel_state:          gauge!  ("vcl_tunnel_state",               "Tunnel state: 0=Stopped 1=Connecting 2=Connected 3=Reconnecting 4=Failed"),
130            rtt_histogram,
131            registry,
132        })
133    }
134
135    // ─── Manual update methods ────────────────────────────────────────────────
136
137    /// Record bytes sent.
138    pub fn update_bytes_sent(&self, bytes: u64) {
139        self.bytes_sent.inc_by(bytes as f64);
140    }
141
142    /// Record bytes received.
143    pub fn update_bytes_received(&self, bytes: u64) {
144        self.bytes_received.inc_by(bytes as f64);
145    }
146
147    /// Record packets sent.
148    pub fn update_packets_sent(&self, count: u64) {
149        self.packets_sent.inc_by(count as f64);
150    }
151
152    /// Record packets received.
153    pub fn update_packets_received(&self, count: u64) {
154        self.packets_received.inc_by(count as f64);
155    }
156
157    /// Record a retransmission.
158    pub fn update_retransmit(&self) {
159        self.packets_retransmitted.inc();
160    }
161
162    /// Record dropped packets.
163    pub fn update_dropped(&self, count: u64) {
164        self.packets_dropped.inc_by(count as f64);
165    }
166
167    /// Set active connection count.
168    pub fn set_connections_active(&self, count: f64) {
169        self.connections_active.set(count);
170    }
171
172    /// Record a reconnect.
173    pub fn update_reconnect(&self) {
174        self.reconnect_count.inc();
175    }
176
177    /// Record a handshake.
178    pub fn update_handshake(&self) {
179        self.handshakes_total.inc();
180    }
181
182    /// Record a key rotation.
183    pub fn update_key_rotation(&self) {
184        self.key_rotations_total.inc();
185    }
186
187    /// Set current loss rate.
188    pub fn set_loss_rate(&self, rate: f64) {
189        self.loss_rate.set(rate);
190    }
191
192    /// Set current RTT in seconds.
193    pub fn set_rtt_seconds(&self, rtt: f64) {
194        self.rtt_seconds.set(rtt);
195        self.rtt_histogram.observe(rtt);
196    }
197
198    /// Set current congestion window.
199    pub fn set_cwnd(&self, cwnd: f64) {
200        self.cwnd_packets.set(cwnd);
201    }
202
203    /// Set obfuscation overhead ratio.
204    pub fn set_obfuscation_overhead(&self, ratio: f64) {
205        self.obfuscation_overhead.set(ratio);
206    }
207
208    /// Set current MTU.
209    pub fn set_mtu(&self, mtu: u16) {
210        self.mtu_bytes.set(mtu as f64);
211    }
212
213    /// Record DNS queries intercepted.
214    pub fn update_dns_queries(&self, count: u64) {
215        self.dns_queries_total.inc_by(count as f64);
216    }
217
218    /// Record DNS queries blocked.
219    pub fn update_dns_blocked(&self, count: u64) {
220        self.dns_blocked_total.inc_by(count as f64);
221    }
222
223    /// Record DNS cache hits.
224    pub fn update_dns_cache_hits(&self, count: u64) {
225        self.dns_cache_hits.inc_by(count as f64);
226    }
227
228    /// Record fragments sent.
229    pub fn update_fragments_sent(&self, count: u64) {
230        self.fragments_sent.inc_by(count as f64);
231    }
232
233    /// Record fragments reassembled.
234    pub fn update_fragments_reassembled(&self, count: u64) {
235        self.fragments_reassembled.inc_by(count as f64);
236    }
237
238    /// Set tunnel state (0=Stopped, 1=Connecting, 2=Connected, 3=Reconnecting, 4=Failed).
239    pub fn set_tunnel_state(&self, state: f64) {
240        self.tunnel_state.set(state);
241    }
242
243    // ─── Bulk update from VCLMetrics ──────────────────────────────────────────
244
245    /// Update all counters from a [`VCLMetrics`] snapshot.
246    ///
247    /// Counters are incremental — call this each time you want to push
248    /// the delta since last call. For simplicity this resets and re-adds
249    /// the full values (idempotent for Prometheus pull model).
250    pub fn update_from_metrics(&self, m: &VCLMetrics) {
251        // Note: Prometheus counters can only increase.
252        // We use inc_by with the current total — this works correctly
253        // if called once, or can be used with a delta tracker.
254        self.set_loss_rate(m.loss_rate());
255        self.set_obfuscation_overhead(0.0); // set externally
256
257        if let Some(rtt) = m.avg_rtt() {
258            self.set_rtt_seconds(rtt.as_secs_f64());
259        }
260        if let Some(cwnd) = m.avg_cwnd() {
261            self.set_cwnd(cwnd);
262        }
263    }
264
265    /// Update all gauges and counters from a [`TunnelStats`] snapshot.
266    pub fn update_from_tunnel_stats(&self, stats: &TunnelStats) {
267        self.set_loss_rate(stats.loss_rate);
268        self.set_obfuscation_overhead(stats.obfuscation_overhead);
269        self.set_mtu(stats.mtu);
270
271        if let Some(rtt) = stats.keepalive_rtt {
272            self.set_rtt_seconds(rtt.as_secs_f64());
273        }
274
275        let state_val = match stats.state {
276            crate::tunnel::TunnelState::Stopped      => 0.0,
277            crate::tunnel::TunnelState::Connecting   => 1.0,
278            crate::tunnel::TunnelState::Connected    => 2.0,
279            crate::tunnel::TunnelState::Reconnecting => 3.0,
280            crate::tunnel::TunnelState::Failed       => 4.0,
281        };
282        self.set_tunnel_state(state_val);
283    }
284
285    // ─── Render ───────────────────────────────────────────────────────────────
286
287    /// Render all metrics in Prometheus text exposition format.
288    ///
289    /// Serve this on an HTTP endpoint (e.g. `/metrics`) for Prometheus to scrape.
290    pub fn render(&self) -> String {
291        let encoder = TextEncoder::new();
292        let metric_families = self.registry.gather();
293        let mut output = Vec::new();
294        encoder.encode(&metric_families, &mut output)
295            .unwrap_or_default();
296        String::from_utf8(output).unwrap_or_default()
297    }
298
299    /// Returns the underlying [`Registry`] for advanced use.
300    pub fn registry(&self) -> &Registry {
301        &self.registry
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use std::time::Duration;
309
310    fn exporter() -> VCLPrometheusExporter {
311        VCLPrometheusExporter::new().unwrap()
312    }
313
314    #[test]
315    fn test_new() {
316        let e = exporter();
317        let output = e.render();
318        assert!(!output.is_empty());
319    }
320
321    #[test]
322    fn test_render_contains_metric_names() {
323        let e = exporter();
324        let output = e.render();
325        assert!(output.contains("vcl_bytes_sent_total"));
326        assert!(output.contains("vcl_bytes_received_total"));
327        assert!(output.contains("vcl_packets_sent_total"));
328        assert!(output.contains("vcl_loss_rate"));
329        assert!(output.contains("vcl_rtt_seconds_current"));
330        assert!(output.contains("vcl_tunnel_state"));
331        assert!(output.contains("vcl_dns_queries_total"));
332        assert!(output.contains("vcl_mtu_bytes"));
333    }
334
335    #[test]
336    fn test_update_bytes_sent() {
337        let e = exporter();
338        e.update_bytes_sent(1024);
339        e.update_bytes_sent(512);
340        let output = e.render();
341        assert!(output.contains("vcl_bytes_sent_total 1536"));
342    }
343
344    #[test]
345    fn test_update_bytes_received() {
346        let e = exporter();
347        e.update_bytes_received(2048);
348        let output = e.render();
349        assert!(output.contains("vcl_bytes_received_total 2048"));
350    }
351
352    #[test]
353    fn test_update_packets() {
354        let e = exporter();
355        e.update_packets_sent(10);
356        e.update_packets_received(8);
357        let output = e.render();
358        assert!(output.contains("vcl_packets_sent_total 10"));
359        assert!(output.contains("vcl_packets_received_total 8"));
360    }
361
362    #[test]
363    fn test_update_retransmit() {
364        let e = exporter();
365        e.update_retransmit();
366        e.update_retransmit();
367        let output = e.render();
368        assert!(output.contains("vcl_packets_retransmitted_total 2"));
369    }
370
371    #[test]
372    fn test_update_dropped() {
373        let e = exporter();
374        e.update_dropped(5);
375        let output = e.render();
376        assert!(output.contains("vcl_packets_dropped_total 5"));
377    }
378
379    #[test]
380    fn test_set_loss_rate() {
381        let e = exporter();
382        e.set_loss_rate(0.05);
383        let output = e.render();
384        assert!(output.contains("vcl_loss_rate 0.05"));
385    }
386
387    #[test]
388    fn test_set_rtt() {
389        let e = exporter();
390        e.set_rtt_seconds(0.042);
391        let output = e.render();
392        assert!(output.contains("vcl_rtt_seconds_current 0.042"));
393        assert!(output.contains("vcl_rtt_seconds_bucket"));
394    }
395
396    #[test]
397    fn test_set_mtu() {
398        let e = exporter();
399        e.set_mtu(1420);
400        let output = e.render();
401        assert!(output.contains("vcl_mtu_bytes 1420"));
402    }
403
404    #[test]
405    fn test_set_connections_active() {
406        let e = exporter();
407        e.set_connections_active(3.0);
408        let output = e.render();
409        assert!(output.contains("vcl_connections_active 3"));
410    }
411
412    #[test]
413    fn test_dns_metrics() {
414        let e = exporter();
415        e.update_dns_queries(100);
416        e.update_dns_blocked(10);
417        e.update_dns_cache_hits(50);
418        let output = e.render();
419        assert!(output.contains("vcl_dns_queries_total 100"));
420        assert!(output.contains("vcl_dns_blocked_total 10"));
421        assert!(output.contains("vcl_dns_cache_hits_total 50"));
422    }
423
424    #[test]
425    fn test_fragment_metrics() {
426        let e = exporter();
427        e.update_fragments_sent(20);
428        e.update_fragments_reassembled(18);
429        let output = e.render();
430        assert!(output.contains("vcl_fragments_sent_total 20"));
431        assert!(output.contains("vcl_fragments_reassembled_total 18"));
432    }
433
434    #[test]
435    fn test_tunnel_state_connected() {
436        let e = exporter();
437        e.set_tunnel_state(2.0);
438        let output = e.render();
439        assert!(output.contains("vcl_tunnel_state 2"));
440    }
441
442    #[test]
443    fn test_reconnect_counter() {
444        let e = exporter();
445        e.update_reconnect();
446        e.update_reconnect();
447        e.update_reconnect();
448        let output = e.render();
449        assert!(output.contains("vcl_reconnects_total 3"));
450    }
451
452    #[test]
453    fn test_handshake_counter() {
454        let e = exporter();
455        e.update_handshake();
456        let output = e.render();
457        assert!(output.contains("vcl_handshakes_total 1"));
458    }
459
460    #[test]
461    fn test_key_rotation_counter() {
462        let e = exporter();
463        e.update_key_rotation();
464        e.update_key_rotation();
465        let output = e.render();
466        assert!(output.contains("vcl_key_rotations_total 2"));
467    }
468
469    #[test]
470    fn test_obfuscation_overhead() {
471        let e = exporter();
472        e.set_obfuscation_overhead(0.15);
473        let output = e.render();
474        assert!(output.contains("vcl_obfuscation_overhead_ratio 0.15"));
475    }
476
477    #[test]
478    fn test_update_from_metrics() {
479        let mut m = VCLMetrics::new();
480        m.record_sent(1000);
481        m.record_rtt_sample(Duration::from_millis(42));
482        let e = exporter();
483        e.update_from_metrics(&m);
484        let output = e.render();
485        assert!(output.contains("vcl_rtt_seconds_current"));
486        assert!(output.contains("vcl_loss_rate"));
487    }
488
489    #[test]
490    fn test_cwnd_gauge() {
491        let e = exporter();
492        e.set_cwnd(32.0);
493        let output = e.render();
494        assert!(output.contains("vcl_cwnd_packets 32"));
495    }
496
497    #[test]
498    fn test_render_is_valid_utf8() {
499        let e = exporter();
500        e.update_bytes_sent(42);
501        let output = e.render();
502        assert!(output.is_ascii() || !output.is_empty());
503    }
504
505    #[test]
506    fn test_multiple_exporters_independent() {
507        let e1 = exporter();
508        let e2 = exporter();
509        e1.update_bytes_sent(100);
510        e2.update_bytes_sent(200);
511        let o1 = e1.render();
512        let o2 = e2.render();
513        assert!(o1.contains("vcl_bytes_sent_total 100"));
514        assert!(o2.contains("vcl_bytes_sent_total 200"));
515    }
516}