chie_core/
custom_exporters.rs

1//! Custom metrics exporters for StatsD and InfluxDB.
2//!
3//! This module provides exporters that convert metrics to StatsD and InfluxDB formats,
4//! enabling integration with popular monitoring systems beyond Prometheus.
5//!
6//! # Features
7//!
8//! - StatsD format export (UDP-friendly)
9//! - InfluxDB Line Protocol export
10//! - Tag/label support for both formats
11//! - Batch export optimization
12//! - Zero-copy conversions where possible
13//!
14//! # Example
15//!
16//! ```
17//! use chie_core::custom_exporters::{StatsDExporter, InfluxDBExporter, MetricValue};
18//! use std::collections::HashMap;
19//!
20//! // Export to StatsD format
21//! let statsd = StatsDExporter::new("chie".to_string());
22//! let mut tags = HashMap::new();
23//! tags.insert("node", "node1");
24//! let output = statsd.format_metric("requests", MetricValue::Counter(100.0), &tags);
25//! // Output: chie.requests:100|c|#node:node1
26//!
27//! // Export to InfluxDB Line Protocol
28//! let influx = InfluxDBExporter::new("chie_metrics".to_string());
29//! let output = influx.format_metric("requests", MetricValue::Gauge(42.0), &tags, Some(1609459200));
30//! // Output: chie_metrics,node=node1 requests=42.0 1609459200
31//! ```
32
33use std::collections::HashMap;
34use std::time::{SystemTime, UNIX_EPOCH};
35
36/// Metric value types for export.
37#[derive(Debug, Clone, Copy)]
38pub enum MetricValue {
39    /// Counter value (monotonically increasing).
40    Counter(f64),
41    /// Gauge value (can go up or down).
42    Gauge(f64),
43    /// Histogram sum and count.
44    Histogram { sum: f64, count: u64 },
45}
46
47impl MetricValue {
48    /// Get the numeric value for simple metrics.
49    #[must_use]
50    #[inline]
51    pub fn value(&self) -> f64 {
52        match self {
53            Self::Counter(v) | Self::Gauge(v) => *v,
54            Self::Histogram { sum, count } => {
55                if *count == 0 {
56                    0.0
57                } else {
58                    sum / (*count as f64)
59                }
60            }
61        }
62    }
63
64    /// Get the StatsD type suffix.
65    #[must_use]
66    #[inline]
67    pub fn statsd_type(&self) -> &'static str {
68        match self {
69            Self::Counter(_) => "c",
70            Self::Gauge(_) => "g",
71            Self::Histogram { .. } => "h",
72        }
73    }
74}
75
76/// StatsD format exporter.
77///
78/// Exports metrics in StatsD format: `namespace.metric:value|type|#tags`
79pub struct StatsDExporter {
80    /// Namespace prefix for all metrics.
81    namespace: String,
82    /// Default sample rate (0.0 to 1.0).
83    sample_rate: f64,
84}
85
86impl StatsDExporter {
87    /// Create a new StatsD exporter with a namespace.
88    #[must_use]
89    #[inline]
90    pub fn new(namespace: String) -> Self {
91        Self {
92            namespace,
93            sample_rate: 1.0,
94        }
95    }
96
97    /// Create a new StatsD exporter with custom sample rate.
98    #[must_use]
99    #[inline]
100    pub fn with_sample_rate(namespace: String, sample_rate: f64) -> Self {
101        Self {
102            namespace,
103            sample_rate: sample_rate.clamp(0.0, 1.0),
104        }
105    }
106
107    /// Format a single metric in StatsD format.
108    ///
109    /// # Format
110    /// `namespace.metric_name:value|type|#tag1:val1,tag2:val2`
111    ///
112    /// # Arguments
113    /// * `name` - Metric name
114    /// * `value` - Metric value and type
115    /// * `tags` - Optional tags (key-value pairs)
116    #[must_use]
117    pub fn format_metric(
118        &self,
119        name: &str,
120        value: MetricValue,
121        tags: &HashMap<&str, &str>,
122    ) -> String {
123        let mut output = format!(
124            "{}.{}:{}|{}",
125            self.namespace,
126            name.replace('.', "_"),
127            value.value(),
128            value.statsd_type()
129        );
130
131        // Add sample rate if not 1.0
132        if (self.sample_rate - 1.0).abs() > f64::EPSILON {
133            output.push_str(&format!("|@{:.2}", self.sample_rate));
134        }
135
136        // Add tags if present
137        if !tags.is_empty() {
138            output.push_str("|#");
139            let tag_str: Vec<String> = tags.iter().map(|(k, v)| format!("{}:{}", k, v)).collect();
140            output.push_str(&tag_str.join(","));
141        }
142
143        output
144    }
145
146    /// Format histogram metrics with detailed statistics.
147    #[must_use]
148    pub fn format_histogram(
149        &self,
150        name: &str,
151        sum: f64,
152        count: u64,
153        tags: &HashMap<&str, &str>,
154    ) -> Vec<String> {
155        let mut metrics = Vec::new();
156
157        // Sum
158        metrics.push(self.format_metric(&format!("{}_sum", name), MetricValue::Counter(sum), tags));
159
160        // Count
161        metrics.push(self.format_metric(
162            &format!("{}_count", name),
163            MetricValue::Counter(count as f64),
164            tags,
165        ));
166
167        // Average (if count > 0)
168        if count > 0 {
169            let avg = sum / (count as f64);
170            metrics.push(self.format_metric(
171                &format!("{}_avg", name),
172                MetricValue::Gauge(avg),
173                tags,
174            ));
175        }
176
177        metrics
178    }
179
180    /// Batch format multiple metrics.
181    #[must_use]
182    pub fn format_batch(
183        &self,
184        metrics: &[(&str, MetricValue, HashMap<&str, &str>)],
185    ) -> Vec<String> {
186        metrics
187            .iter()
188            .map(|(name, value, tags)| self.format_metric(name, *value, tags))
189            .collect()
190    }
191}
192
193impl Default for StatsDExporter {
194    fn default() -> Self {
195        Self::new("chie".to_string())
196    }
197}
198
199/// InfluxDB Line Protocol exporter.
200///
201/// Exports metrics in InfluxDB Line Protocol format:
202/// `measurement,tag1=value1,tag2=value2 field1=value1,field2=value2 timestamp`
203pub struct InfluxDBExporter {
204    /// Measurement name (table/series name).
205    measurement: String,
206    /// Precision for timestamps (nanoseconds, microseconds, milliseconds, seconds).
207    time_precision: TimePrecision,
208}
209
210/// Time precision for InfluxDB timestamps.
211#[derive(Debug, Clone, Copy, PartialEq, Eq)]
212pub enum TimePrecision {
213    /// Nanoseconds (ns).
214    Nanoseconds,
215    /// Microseconds (u, ยต).
216    Microseconds,
217    /// Milliseconds (ms).
218    Milliseconds,
219    /// Seconds (s).
220    Seconds,
221}
222
223impl TimePrecision {
224    /// Convert SystemTime to timestamp in this precision.
225    #[must_use]
226    #[inline]
227    pub fn from_system_time(&self, time: SystemTime) -> u64 {
228        let duration = time.duration_since(UNIX_EPOCH).unwrap_or_default();
229
230        match self {
231            Self::Nanoseconds => duration.as_nanos() as u64,
232            Self::Microseconds => duration.as_micros() as u64,
233            Self::Milliseconds => duration.as_millis() as u64,
234            Self::Seconds => duration.as_secs(),
235        }
236    }
237
238    /// Get the precision as a string for InfluxDB API.
239    #[must_use]
240    #[inline]
241    pub fn as_str(&self) -> &'static str {
242        match self {
243            Self::Nanoseconds => "ns",
244            Self::Microseconds => "u",
245            Self::Milliseconds => "ms",
246            Self::Seconds => "s",
247        }
248    }
249}
250
251impl InfluxDBExporter {
252    /// Create a new InfluxDB exporter with a measurement name.
253    #[must_use]
254    #[inline]
255    pub fn new(measurement: String) -> Self {
256        Self {
257            measurement,
258            time_precision: TimePrecision::Nanoseconds,
259        }
260    }
261
262    /// Create a new InfluxDB exporter with custom time precision.
263    #[must_use]
264    #[inline]
265    pub fn with_precision(measurement: String, precision: TimePrecision) -> Self {
266        Self {
267            measurement,
268            time_precision: precision,
269        }
270    }
271
272    /// Format a single metric in InfluxDB Line Protocol.
273    ///
274    /// # Format
275    /// `measurement,tag1=value1 field=value timestamp`
276    ///
277    /// # Arguments
278    /// * `field_name` - Field name (metric name)
279    /// * `value` - Metric value
280    /// * `tags` - Optional tags
281    /// * `timestamp` - Optional UNIX timestamp (uses current time if None)
282    #[must_use]
283    pub fn format_metric(
284        &self,
285        field_name: &str,
286        value: MetricValue,
287        tags: &HashMap<&str, &str>,
288        timestamp: Option<u64>,
289    ) -> String {
290        let mut output = self.measurement.clone();
291
292        // Add tags
293        if !tags.is_empty() {
294            for (key, val) in tags {
295                output.push(',');
296                output.push_str(&Self::escape_tag_key(key));
297                output.push('=');
298                output.push_str(&Self::escape_tag_value(val));
299            }
300        }
301
302        output.push(' ');
303
304        // Add field
305        output.push_str(&Self::escape_field_key(field_name));
306        output.push('=');
307
308        match value {
309            MetricValue::Counter(v) | MetricValue::Gauge(v) => {
310                // Use integer format if value is a whole number
311                if v.fract().abs() < f64::EPSILON {
312                    output.push_str(&format!("{}i", v as i64));
313                } else {
314                    output.push_str(&v.to_string());
315                }
316            }
317            MetricValue::Histogram { sum, count } => {
318                // For histograms, output average
319                if count > 0 {
320                    output.push_str(&(sum / count as f64).to_string());
321                } else {
322                    output.push_str("0.0");
323                }
324            }
325        }
326
327        // Add timestamp
328        let ts =
329            timestamp.unwrap_or_else(|| self.time_precision.from_system_time(SystemTime::now()));
330        output.push(' ');
331        output.push_str(&ts.to_string());
332
333        output
334    }
335
336    /// Format histogram with detailed fields.
337    #[must_use]
338    pub fn format_histogram(
339        &self,
340        name: &str,
341        sum: f64,
342        count: u64,
343        tags: &HashMap<&str, &str>,
344        timestamp: Option<u64>,
345    ) -> String {
346        let mut output = self.measurement.clone();
347
348        // Add tags
349        if !tags.is_empty() {
350            for (key, val) in tags {
351                output.push(',');
352                output.push_str(&Self::escape_tag_key(key));
353                output.push('=');
354                output.push_str(&Self::escape_tag_value(val));
355            }
356        }
357
358        output.push(' ');
359
360        // Add multiple fields
361        output.push_str(&format!("{}_sum={},", Self::escape_field_key(name), sum));
362        output.push_str(&format!(
363            "{}_count={}i",
364            Self::escape_field_key(name),
365            count
366        ));
367
368        if count > 0 {
369            let avg = sum / (count as f64);
370            output.push_str(&format!(",{}_avg={}", Self::escape_field_key(name), avg));
371        }
372
373        // Add timestamp
374        let ts =
375            timestamp.unwrap_or_else(|| self.time_precision.from_system_time(SystemTime::now()));
376        output.push(' ');
377        output.push_str(&ts.to_string());
378
379        output
380    }
381
382    /// Batch format multiple metrics with the same timestamp.
383    #[must_use]
384    pub fn format_batch(
385        &self,
386        metrics: &[(&str, MetricValue, HashMap<&str, &str>)],
387        timestamp: Option<u64>,
388    ) -> Vec<String> {
389        let ts =
390            timestamp.unwrap_or_else(|| self.time_precision.from_system_time(SystemTime::now()));
391
392        metrics
393            .iter()
394            .map(|(name, value, tags)| self.format_metric(name, *value, tags, Some(ts)))
395            .collect()
396    }
397
398    /// Escape tag keys (special chars: comma, equals, space).
399    #[must_use]
400    #[inline]
401    fn escape_tag_key(key: &str) -> String {
402        key.replace(',', "\\,")
403            .replace('=', "\\=")
404            .replace(' ', "\\ ")
405    }
406
407    /// Escape tag values (special chars: comma, equals, space).
408    #[must_use]
409    #[inline]
410    fn escape_tag_value(value: &str) -> String {
411        value
412            .replace(',', "\\,")
413            .replace('=', "\\=")
414            .replace(' ', "\\ ")
415    }
416
417    /// Escape field keys (special chars: comma, equals, space).
418    #[must_use]
419    #[inline]
420    fn escape_field_key(key: &str) -> String {
421        key.replace(',', "\\,")
422            .replace('=', "\\=")
423            .replace(' ', "\\ ")
424    }
425}
426
427impl Default for InfluxDBExporter {
428    fn default() -> Self {
429        Self::new("chie_metrics".to_string())
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    #[test]
438    fn test_metric_value_simple() {
439        let counter = MetricValue::Counter(42.0);
440        assert_eq!(counter.value(), 42.0);
441        assert_eq!(counter.statsd_type(), "c");
442
443        let gauge = MetricValue::Gauge(2.5);
444        assert_eq!(gauge.value(), 2.5);
445        assert_eq!(gauge.statsd_type(), "g");
446    }
447
448    #[test]
449    fn test_metric_value_histogram() {
450        let hist = MetricValue::Histogram {
451            sum: 100.0,
452            count: 10,
453        };
454        assert_eq!(hist.value(), 10.0); // average
455        assert_eq!(hist.statsd_type(), "h");
456
457        // Zero count
458        let empty = MetricValue::Histogram { sum: 0.0, count: 0 };
459        assert_eq!(empty.value(), 0.0);
460    }
461
462    #[test]
463    fn test_statsd_simple_metric() {
464        let exporter = StatsDExporter::new("test".to_string());
465        let tags = HashMap::new();
466
467        let output = exporter.format_metric("requests", MetricValue::Counter(100.0), &tags);
468        assert_eq!(output, "test.requests:100|c");
469    }
470
471    #[test]
472    fn test_statsd_with_tags() {
473        let exporter = StatsDExporter::new("app".to_string());
474        let mut tags = HashMap::new();
475        tags.insert("host", "server1");
476        tags.insert("env", "prod");
477
478        let output = exporter.format_metric("latency", MetricValue::Gauge(42.5), &tags);
479        assert!(output.starts_with("app.latency:42.5|g|#"));
480        assert!(output.contains("host:server1"));
481        assert!(output.contains("env:prod"));
482    }
483
484    #[test]
485    fn test_statsd_sample_rate() {
486        let exporter = StatsDExporter::with_sample_rate("test".to_string(), 0.5);
487        let tags = HashMap::new();
488
489        let output = exporter.format_metric("requests", MetricValue::Counter(10.0), &tags);
490        assert_eq!(output, "test.requests:10|c|@0.50");
491    }
492
493    #[test]
494    fn test_statsd_histogram() {
495        let exporter = StatsDExporter::new("test".to_string());
496        let tags = HashMap::new();
497
498        let outputs = exporter.format_histogram("duration", 250.0, 50, &tags);
499        assert_eq!(outputs.len(), 3);
500        assert_eq!(outputs[0], "test.duration_sum:250|c");
501        assert_eq!(outputs[1], "test.duration_count:50|c");
502        assert_eq!(outputs[2], "test.duration_avg:5|g");
503    }
504
505    #[test]
506    fn test_statsd_batch() {
507        let exporter = StatsDExporter::new("batch".to_string());
508        let tags = HashMap::new();
509
510        let metrics = vec![
511            ("metric1", MetricValue::Counter(1.0), tags.clone()),
512            ("metric2", MetricValue::Gauge(2.0), tags.clone()),
513        ];
514
515        let outputs = exporter.format_batch(&metrics);
516        assert_eq!(outputs.len(), 2);
517        assert_eq!(outputs[0], "batch.metric1:1|c");
518        assert_eq!(outputs[1], "batch.metric2:2|g");
519    }
520
521    #[test]
522    fn test_influxdb_simple_metric() {
523        let exporter = InfluxDBExporter::new("metrics".to_string());
524        let tags = HashMap::new();
525
526        let output = exporter.format_metric(
527            "requests",
528            MetricValue::Counter(100.0),
529            &tags,
530            Some(1609459200),
531        );
532        assert_eq!(output, "metrics requests=100i 1609459200");
533    }
534
535    #[test]
536    fn test_influxdb_with_tags() {
537        let exporter = InfluxDBExporter::new("metrics".to_string());
538        let mut tags = HashMap::new();
539        tags.insert("host", "server1");
540        tags.insert("region", "us-west");
541
542        let output =
543            exporter.format_metric("cpu", MetricValue::Gauge(75.5), &tags, Some(1609459200));
544        assert!(output.starts_with("metrics,"));
545        assert!(output.contains("host=server1"));
546        assert!(output.contains("region=us-west"));
547        assert!(output.contains(" cpu=75.5 1609459200"));
548    }
549
550    #[test]
551    fn test_influxdb_histogram() {
552        let exporter = InfluxDBExporter::new("metrics".to_string());
553        let tags = HashMap::new();
554
555        let output = exporter.format_histogram("latency", 1000.0, 20, &tags, Some(1609459200));
556        assert!(output.contains("latency_sum=1000"));
557        assert!(output.contains("latency_count=20i"));
558        assert!(output.contains("latency_avg=50"));
559        assert!(output.ends_with(" 1609459200"));
560    }
561
562    #[test]
563    fn test_influxdb_escaping() {
564        let exporter = InfluxDBExporter::new("metrics".to_string());
565        let mut tags = HashMap::new();
566        tags.insert("tag with space", "value,with=special");
567
568        let output = exporter.format_metric(
569            "field name",
570            MetricValue::Counter(1.0),
571            &tags,
572            Some(1609459200),
573        );
574        assert!(output.contains("tag\\ with\\ space=value\\,with\\=special"));
575        assert!(output.contains("field\\ name=1i"));
576    }
577
578    #[test]
579    fn test_influxdb_batch() {
580        let exporter = InfluxDBExporter::new("metrics".to_string());
581        let tags = HashMap::new();
582
583        let metrics = vec![
584            ("metric1", MetricValue::Counter(1.0), tags.clone()),
585            ("metric2", MetricValue::Gauge(2.5), tags.clone()),
586        ];
587
588        let outputs = exporter.format_batch(&metrics, Some(1609459200));
589        assert_eq!(outputs.len(), 2);
590        assert_eq!(outputs[0], "metrics metric1=1i 1609459200");
591        assert_eq!(outputs[1], "metrics metric2=2.5 1609459200");
592    }
593
594    #[test]
595    fn test_time_precision() {
596        let precision = TimePrecision::Milliseconds;
597        assert_eq!(precision.as_str(), "ms");
598
599        let precision = TimePrecision::Seconds;
600        assert_eq!(precision.as_str(), "s");
601    }
602
603    #[test]
604    fn test_metric_name_sanitization() {
605        let exporter = StatsDExporter::new("test".to_string());
606        let tags = HashMap::new();
607
608        // Dots in metric names should be replaced with underscores in StatsD
609        let output =
610            exporter.format_metric("http.requests.total", MetricValue::Counter(1.0), &tags);
611        assert_eq!(output, "test.http_requests_total:1|c");
612    }
613}