chie_core/
metrics_exporter.rs

1//! Custom metrics exporters for external monitoring systems.
2//!
3//! This module provides exporters for various monitoring formats including
4//! StatsD and InfluxDB line protocol.
5//!
6//! # Supported Formats
7//!
8//! - **StatsD**: Simple UDP-based metrics protocol
9//! - **InfluxDB**: Time-series database line protocol
10//!
11//! # Example
12//!
13//! ```
14//! use chie_core::metrics_exporter::{MetricsExporter, ExportFormat, MetricValue};
15//!
16//! let exporter = MetricsExporter::new(ExportFormat::StatsD);
17//!
18//! // Export a counter
19//! let output = exporter.export_counter("chie.chunks.stored", 42, &[("node", "node1")]);
20//! assert!(output.contains("chie.chunks.stored"));
21//!
22//! // Export a gauge
23//! let output = exporter.export_gauge("chie.storage.used_bytes", 1024000, &[]);
24//! assert!(output.contains("1024000"));
25//! ```
26
27use std::collections::HashMap;
28use std::time::{SystemTime, UNIX_EPOCH};
29
30/// Type alias for metric batch entries.
31type MetricBatchEntry = (String, MetricValue, Vec<(String, String)>);
32
33/// Supported metrics export formats.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum ExportFormat {
36    /// StatsD format (metric_name:value|type|@sample_rate|#tags).
37    StatsD,
38    /// InfluxDB line protocol (measurement,tag=value field=value timestamp).
39    InfluxDB,
40}
41
42/// Metric value types.
43#[derive(Debug, Clone, Copy, PartialEq)]
44pub enum MetricValue {
45    /// Counter - monotonically increasing value.
46    Counter(u64),
47    /// Gauge - arbitrary value that can go up or down.
48    Gauge(i64),
49    /// Timing - duration in milliseconds.
50    Timing(u64),
51    /// Histogram - value for distribution analysis.
52    Histogram(f64),
53}
54
55impl MetricValue {
56    /// Get the StatsD type suffix.
57    #[must_use]
58    #[inline]
59    const fn statsd_type(&self) -> &'static str {
60        match self {
61            Self::Counter(_) => "c",
62            Self::Gauge(_) => "g",
63            Self::Timing(_) => "ms",
64            Self::Histogram(_) => "h",
65        }
66    }
67
68    /// Get the numeric value as a string.
69    #[must_use]
70    #[inline]
71    fn value_string(&self) -> String {
72        match self {
73            Self::Counter(v) => v.to_string(),
74            Self::Gauge(v) => v.to_string(),
75            Self::Timing(v) => v.to_string(),
76            Self::Histogram(v) => v.to_string(),
77        }
78    }
79}
80
81/// Metrics exporter for external monitoring systems.
82pub struct MetricsExporter {
83    format: ExportFormat,
84    default_tags: HashMap<String, String>,
85}
86
87impl MetricsExporter {
88    /// Create a new metrics exporter with the specified format.
89    #[must_use]
90    pub fn new(format: ExportFormat) -> Self {
91        Self {
92            format,
93            default_tags: HashMap::new(),
94        }
95    }
96
97    /// Create a new exporter with default tags.
98    #[must_use]
99    pub fn with_tags(format: ExportFormat, tags: &[(&str, &str)]) -> Self {
100        let default_tags: HashMap<String, String> = tags
101            .iter()
102            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
103            .collect();
104
105        Self {
106            format,
107            default_tags,
108        }
109    }
110
111    /// Add a default tag to all exported metrics.
112    pub fn add_default_tag(&mut self, key: String, value: String) {
113        self.default_tags.insert(key, value);
114    }
115
116    /// Export a counter metric.
117    #[must_use]
118    pub fn export_counter(&self, name: &str, value: u64, tags: &[(&str, &str)]) -> String {
119        self.export_metric(name, MetricValue::Counter(value), tags)
120    }
121
122    /// Export a gauge metric.
123    #[must_use]
124    pub fn export_gauge(&self, name: &str, value: i64, tags: &[(&str, &str)]) -> String {
125        self.export_metric(name, MetricValue::Gauge(value), tags)
126    }
127
128    /// Export a timing metric.
129    #[must_use]
130    pub fn export_timing(&self, name: &str, duration_ms: u64, tags: &[(&str, &str)]) -> String {
131        self.export_metric(name, MetricValue::Timing(duration_ms), tags)
132    }
133
134    /// Export a histogram metric.
135    #[must_use]
136    pub fn export_histogram(&self, name: &str, value: f64, tags: &[(&str, &str)]) -> String {
137        self.export_metric(name, MetricValue::Histogram(value), tags)
138    }
139
140    /// Export a generic metric.
141    #[must_use]
142    pub fn export_metric(&self, name: &str, value: MetricValue, tags: &[(&str, &str)]) -> String {
143        match self.format {
144            ExportFormat::StatsD => self.format_statsd(name, value, tags),
145            ExportFormat::InfluxDB => self.format_influxdb(name, value, tags),
146        }
147    }
148
149    /// Format a metric in StatsD format.
150    #[must_use]
151    fn format_statsd(&self, name: &str, value: MetricValue, tags: &[(&str, &str)]) -> String {
152        let mut parts = vec![format!("{}:{}", name, value.value_string())];
153        parts.push(value.statsd_type().to_string());
154
155        // Add tags if any
156        let all_tags = self.merge_tags(tags);
157        if !all_tags.is_empty() {
158            let tag_str: Vec<String> = all_tags
159                .iter()
160                .map(|(k, v)| format!("{}:{}", k, v))
161                .collect();
162            parts.push(format!("#{}", tag_str.join(",")));
163        }
164
165        parts.join("|")
166    }
167
168    /// Format a metric in InfluxDB line protocol format.
169    #[must_use]
170    fn format_influxdb(&self, name: &str, value: MetricValue, tags: &[(&str, &str)]) -> String {
171        let all_tags = self.merge_tags(tags);
172
173        // Measurement with tags
174        let mut measurement = name.to_string();
175        if !all_tags.is_empty() {
176            let tag_str: Vec<String> = all_tags
177                .iter()
178                .map(|(k, v)| format!("{}={}", escape_influx_key(k), escape_influx_value(v)))
179                .collect();
180            measurement.push(',');
181            measurement.push_str(&tag_str.join(","));
182        }
183
184        // Field
185        let field_name = "value";
186        let field_value = value.value_string();
187
188        // Timestamp (nanoseconds)
189        let timestamp = SystemTime::now()
190            .duration_since(UNIX_EPOCH)
191            .unwrap_or_default()
192            .as_nanos();
193
194        format!(
195            "{} {}={} {}",
196            measurement, field_name, field_value, timestamp
197        )
198    }
199
200    /// Merge default tags with provided tags.
201    #[must_use]
202    fn merge_tags(&self, tags: &[(&str, &str)]) -> HashMap<String, String> {
203        let mut all_tags = self.default_tags.clone();
204        for (k, v) in tags {
205            all_tags.insert((*k).to_string(), (*v).to_string());
206        }
207        all_tags
208    }
209
210    /// Export multiple metrics at once.
211    #[must_use]
212    pub fn export_batch(&self, metrics: &[MetricBatchEntry]) -> Vec<String> {
213        metrics
214            .iter()
215            .map(|(name, value, tags)| {
216                let tag_refs: Vec<(&str, &str)> =
217                    tags.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
218                self.export_metric(name, *value, &tag_refs)
219            })
220            .collect()
221    }
222}
223
224/// Escape InfluxDB measurement/tag keys.
225#[must_use]
226#[inline]
227fn escape_influx_key(s: &str) -> String {
228    s.replace(',', "\\,")
229        .replace('=', "\\=")
230        .replace(' ', "\\ ")
231}
232
233/// Escape InfluxDB tag values.
234#[must_use]
235#[inline]
236fn escape_influx_value(s: &str) -> String {
237    s.replace(',', "\\,")
238        .replace('=', "\\=")
239        .replace(' ', "\\ ")
240}
241
242/// A builder for batch metric exports.
243pub struct MetricsBatch {
244    metrics: Vec<MetricBatchEntry>,
245}
246
247impl Default for MetricsBatch {
248    fn default() -> Self {
249        Self::new()
250    }
251}
252
253impl MetricsBatch {
254    /// Create a new batch.
255    #[must_use]
256    pub fn new() -> Self {
257        Self {
258            metrics: Vec::new(),
259        }
260    }
261
262    /// Add a counter to the batch.
263    pub fn add_counter(mut self, name: String, value: u64, tags: Vec<(String, String)>) -> Self {
264        self.metrics.push((name, MetricValue::Counter(value), tags));
265        self
266    }
267
268    /// Add a gauge to the batch.
269    pub fn add_gauge(mut self, name: String, value: i64, tags: Vec<(String, String)>) -> Self {
270        self.metrics.push((name, MetricValue::Gauge(value), tags));
271        self
272    }
273
274    /// Add a timing to the batch.
275    pub fn add_timing(
276        mut self,
277        name: String,
278        duration_ms: u64,
279        tags: Vec<(String, String)>,
280    ) -> Self {
281        self.metrics
282            .push((name, MetricValue::Timing(duration_ms), tags));
283        self
284    }
285
286    /// Add a histogram value to the batch.
287    pub fn add_histogram(mut self, name: String, value: f64, tags: Vec<(String, String)>) -> Self {
288        self.metrics
289            .push((name, MetricValue::Histogram(value), tags));
290        self
291    }
292
293    /// Export the batch using the provided exporter.
294    #[must_use]
295    pub fn export(&self, exporter: &MetricsExporter) -> Vec<String> {
296        exporter.export_batch(&self.metrics)
297    }
298
299    /// Get the number of metrics in the batch.
300    #[must_use]
301    #[inline]
302    pub fn len(&self) -> usize {
303        self.metrics.len()
304    }
305
306    /// Check if the batch is empty.
307    #[must_use]
308    #[inline]
309    pub fn is_empty(&self) -> bool {
310        self.metrics.is_empty()
311    }
312}
313
314/// Common metrics that can be exported.
315pub struct CommonMetrics;
316
317impl CommonMetrics {
318    /// Export storage metrics.
319    #[must_use]
320    pub fn storage_metrics(
321        exporter: &MetricsExporter,
322        used_bytes: u64,
323        total_bytes: u64,
324        chunk_count: u64,
325    ) -> Vec<String> {
326        vec![
327            exporter.export_gauge("chie.storage.used_bytes", used_bytes as i64, &[]),
328            exporter.export_gauge("chie.storage.total_bytes", total_bytes as i64, &[]),
329            exporter.export_gauge("chie.storage.chunks_count", chunk_count as i64, &[]),
330        ]
331    }
332
333    /// Export bandwidth metrics.
334    #[must_use]
335    pub fn bandwidth_metrics(
336        exporter: &MetricsExporter,
337        bytes_sent: u64,
338        bytes_received: u64,
339        requests_served: u64,
340    ) -> Vec<String> {
341        vec![
342            exporter.export_counter("chie.bandwidth.bytes_sent", bytes_sent, &[]),
343            exporter.export_counter("chie.bandwidth.bytes_received", bytes_received, &[]),
344            exporter.export_counter("chie.bandwidth.requests_served", requests_served, &[]),
345        ]
346    }
347
348    /// Export performance metrics.
349    #[must_use]
350    pub fn performance_metrics(
351        exporter: &MetricsExporter,
352        avg_latency_ms: u64,
353        p95_latency_ms: u64,
354        p99_latency_ms: u64,
355    ) -> Vec<String> {
356        vec![
357            exporter.export_timing("chie.performance.latency.avg", avg_latency_ms, &[]),
358            exporter.export_timing("chie.performance.latency.p95", p95_latency_ms, &[]),
359            exporter.export_timing("chie.performance.latency.p99", p99_latency_ms, &[]),
360        ]
361    }
362
363    /// Export cache metrics.
364    #[must_use]
365    pub fn cache_metrics(
366        exporter: &MetricsExporter,
367        hits: u64,
368        misses: u64,
369        evictions: u64,
370    ) -> Vec<String> {
371        vec![
372            exporter.export_counter("chie.cache.hits", hits, &[]),
373            exporter.export_counter("chie.cache.misses", misses, &[]),
374            exporter.export_counter("chie.cache.evictions", evictions, &[]),
375        ]
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    #[test]
384    fn test_statsd_counter() {
385        let exporter = MetricsExporter::new(ExportFormat::StatsD);
386        let output = exporter.export_counter("test.counter", 42, &[]);
387        assert_eq!(output, "test.counter:42|c");
388    }
389
390    #[test]
391    fn test_statsd_gauge() {
392        let exporter = MetricsExporter::new(ExportFormat::StatsD);
393        let output = exporter.export_gauge("test.gauge", -10, &[]);
394        assert_eq!(output, "test.gauge:-10|g");
395    }
396
397    #[test]
398    fn test_statsd_timing() {
399        let exporter = MetricsExporter::new(ExportFormat::StatsD);
400        let output = exporter.export_timing("test.timing", 250, &[]);
401        assert_eq!(output, "test.timing:250|ms");
402    }
403
404    #[test]
405    fn test_statsd_with_tags() {
406        let exporter = MetricsExporter::new(ExportFormat::StatsD);
407        let output =
408            exporter.export_counter("test.counter", 1, &[("host", "server1"), ("env", "prod")]);
409        assert!(output.contains("test.counter:1|c|#"));
410        assert!(output.contains("host:server1"));
411        assert!(output.contains("env:prod"));
412    }
413
414    #[test]
415    fn test_influxdb_format() {
416        let exporter = MetricsExporter::new(ExportFormat::InfluxDB);
417        let output = exporter.export_counter("test_counter", 42, &[("host", "server1")]);
418        assert!(output.contains("test_counter,host=server1"));
419        assert!(output.contains("value=42"));
420    }
421
422    #[test]
423    fn test_default_tags() {
424        let exporter = MetricsExporter::with_tags(
425            ExportFormat::StatsD,
426            &[("app", "chie"), ("version", "0.1.0")],
427        );
428        let output = exporter.export_counter("test.counter", 1, &[]);
429        assert!(output.contains("app:chie"));
430        assert!(output.contains("version:0.1.0"));
431    }
432
433    #[test]
434    fn test_metrics_batch() {
435        let batch = MetricsBatch::new()
436            .add_counter("counter".to_string(), 10, vec![])
437            .add_gauge("gauge".to_string(), -5, vec![])
438            .add_timing("timing".to_string(), 100, vec![]);
439
440        assert_eq!(batch.len(), 3);
441        assert!(!batch.is_empty());
442
443        let exporter = MetricsExporter::new(ExportFormat::StatsD);
444        let output = batch.export(&exporter);
445        assert_eq!(output.len(), 3);
446    }
447
448    #[test]
449    fn test_common_storage_metrics() {
450        let exporter = MetricsExporter::new(ExportFormat::StatsD);
451        let metrics = CommonMetrics::storage_metrics(&exporter, 1024, 2048, 10);
452        assert_eq!(metrics.len(), 3);
453        assert!(metrics[0].contains("chie.storage.used_bytes"));
454        assert!(metrics[1].contains("chie.storage.total_bytes"));
455        assert!(metrics[2].contains("chie.storage.chunks_count"));
456    }
457
458    #[test]
459    fn test_influx_escaping() {
460        assert_eq!(escape_influx_key("test,key"), "test\\,key");
461        assert_eq!(escape_influx_key("test=key"), "test\\=key");
462        assert_eq!(escape_influx_key("test key"), "test\\ key");
463    }
464
465    #[test]
466    fn test_metric_value_types() {
467        assert_eq!(MetricValue::Counter(1).statsd_type(), "c");
468        assert_eq!(MetricValue::Gauge(1).statsd_type(), "g");
469        assert_eq!(MetricValue::Timing(1).statsd_type(), "ms");
470        assert_eq!(MetricValue::Histogram(1.0).statsd_type(), "h");
471    }
472}