1use std::collections::HashMap;
34use std::time::{SystemTime, UNIX_EPOCH};
35
36#[derive(Debug, Clone, Copy)]
38pub enum MetricValue {
39 Counter(f64),
41 Gauge(f64),
43 Histogram { sum: f64, count: u64 },
45}
46
47impl MetricValue {
48 #[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 #[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
76pub struct StatsDExporter {
80 namespace: String,
82 sample_rate: f64,
84}
85
86impl StatsDExporter {
87 #[must_use]
89 #[inline]
90 pub fn new(namespace: String) -> Self {
91 Self {
92 namespace,
93 sample_rate: 1.0,
94 }
95 }
96
97 #[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 #[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 if (self.sample_rate - 1.0).abs() > f64::EPSILON {
133 output.push_str(&format!("|@{:.2}", self.sample_rate));
134 }
135
136 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 #[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 metrics.push(self.format_metric(&format!("{}_sum", name), MetricValue::Counter(sum), tags));
159
160 metrics.push(self.format_metric(
162 &format!("{}_count", name),
163 MetricValue::Counter(count as f64),
164 tags,
165 ));
166
167 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 #[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
199pub struct InfluxDBExporter {
204 measurement: String,
206 time_precision: TimePrecision,
208}
209
210#[derive(Debug, Clone, Copy, PartialEq, Eq)]
212pub enum TimePrecision {
213 Nanoseconds,
215 Microseconds,
217 Milliseconds,
219 Seconds,
221}
222
223impl TimePrecision {
224 #[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 #[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 #[must_use]
254 #[inline]
255 pub fn new(measurement: String) -> Self {
256 Self {
257 measurement,
258 time_precision: TimePrecision::Nanoseconds,
259 }
260 }
261
262 #[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 #[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 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 output.push_str(&Self::escape_field_key(field_name));
306 output.push('=');
307
308 match value {
309 MetricValue::Counter(v) | MetricValue::Gauge(v) => {
310 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 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 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 #[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 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 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 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 #[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 #[must_use]
400 #[inline]
401 fn escape_tag_key(key: &str) -> String {
402 key.replace(',', "\\,")
403 .replace('=', "\\=")
404 .replace(' ', "\\ ")
405 }
406
407 #[must_use]
409 #[inline]
410 fn escape_tag_value(value: &str) -> String {
411 value
412 .replace(',', "\\,")
413 .replace('=', "\\=")
414 .replace(' ', "\\ ")
415 }
416
417 #[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); assert_eq!(hist.statsd_type(), "h");
456
457 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 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}