use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Copy)]
pub enum MetricValue {
Counter(f64),
Gauge(f64),
Histogram { sum: f64, count: u64 },
}
impl MetricValue {
#[must_use]
#[inline]
pub fn value(&self) -> f64 {
match self {
Self::Counter(v) | Self::Gauge(v) => *v,
Self::Histogram { sum, count } => {
if *count == 0 {
0.0
} else {
sum / (*count as f64)
}
}
}
}
#[must_use]
#[inline]
pub fn statsd_type(&self) -> &'static str {
match self {
Self::Counter(_) => "c",
Self::Gauge(_) => "g",
Self::Histogram { .. } => "h",
}
}
}
pub struct StatsDExporter {
namespace: String,
sample_rate: f64,
}
impl StatsDExporter {
#[must_use]
#[inline]
pub fn new(namespace: String) -> Self {
Self {
namespace,
sample_rate: 1.0,
}
}
#[must_use]
#[inline]
pub fn with_sample_rate(namespace: String, sample_rate: f64) -> Self {
Self {
namespace,
sample_rate: sample_rate.clamp(0.0, 1.0),
}
}
#[must_use]
pub fn format_metric(
&self,
name: &str,
value: MetricValue,
tags: &HashMap<&str, &str>,
) -> String {
let mut output = format!(
"{}.{}:{}|{}",
self.namespace,
name.replace('.', "_"),
value.value(),
value.statsd_type()
);
if (self.sample_rate - 1.0).abs() > f64::EPSILON {
output.push_str(&format!("|@{:.2}", self.sample_rate));
}
if !tags.is_empty() {
output.push_str("|#");
let tag_str: Vec<String> = tags.iter().map(|(k, v)| format!("{}:{}", k, v)).collect();
output.push_str(&tag_str.join(","));
}
output
}
#[must_use]
pub fn format_histogram(
&self,
name: &str,
sum: f64,
count: u64,
tags: &HashMap<&str, &str>,
) -> Vec<String> {
let mut metrics = Vec::new();
metrics.push(self.format_metric(&format!("{}_sum", name), MetricValue::Counter(sum), tags));
metrics.push(self.format_metric(
&format!("{}_count", name),
MetricValue::Counter(count as f64),
tags,
));
if count > 0 {
let avg = sum / (count as f64);
metrics.push(self.format_metric(
&format!("{}_avg", name),
MetricValue::Gauge(avg),
tags,
));
}
metrics
}
#[must_use]
pub fn format_batch(
&self,
metrics: &[(&str, MetricValue, HashMap<&str, &str>)],
) -> Vec<String> {
metrics
.iter()
.map(|(name, value, tags)| self.format_metric(name, *value, tags))
.collect()
}
}
impl Default for StatsDExporter {
fn default() -> Self {
Self::new("chie".to_string())
}
}
pub struct InfluxDBExporter {
measurement: String,
time_precision: TimePrecision,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TimePrecision {
Nanoseconds,
Microseconds,
Milliseconds,
Seconds,
}
impl TimePrecision {
#[must_use]
#[inline]
pub fn from_system_time(&self, time: SystemTime) -> u64 {
let duration = time.duration_since(UNIX_EPOCH).unwrap_or_default();
match self {
Self::Nanoseconds => duration.as_nanos() as u64,
Self::Microseconds => duration.as_micros() as u64,
Self::Milliseconds => duration.as_millis() as u64,
Self::Seconds => duration.as_secs(),
}
}
#[must_use]
#[inline]
pub fn as_str(&self) -> &'static str {
match self {
Self::Nanoseconds => "ns",
Self::Microseconds => "u",
Self::Milliseconds => "ms",
Self::Seconds => "s",
}
}
}
impl InfluxDBExporter {
#[must_use]
#[inline]
pub fn new(measurement: String) -> Self {
Self {
measurement,
time_precision: TimePrecision::Nanoseconds,
}
}
#[must_use]
#[inline]
pub fn with_precision(measurement: String, precision: TimePrecision) -> Self {
Self {
measurement,
time_precision: precision,
}
}
#[must_use]
pub fn format_metric(
&self,
field_name: &str,
value: MetricValue,
tags: &HashMap<&str, &str>,
timestamp: Option<u64>,
) -> String {
let mut output = self.measurement.clone();
if !tags.is_empty() {
for (key, val) in tags {
output.push(',');
output.push_str(&Self::escape_tag_key(key));
output.push('=');
output.push_str(&Self::escape_tag_value(val));
}
}
output.push(' ');
output.push_str(&Self::escape_field_key(field_name));
output.push('=');
match value {
MetricValue::Counter(v) | MetricValue::Gauge(v) => {
if v.fract().abs() < f64::EPSILON {
output.push_str(&format!("{}i", v as i64));
} else {
output.push_str(&v.to_string());
}
}
MetricValue::Histogram { sum, count } => {
if count > 0 {
output.push_str(&(sum / count as f64).to_string());
} else {
output.push_str("0.0");
}
}
}
let ts =
timestamp.unwrap_or_else(|| self.time_precision.from_system_time(SystemTime::now()));
output.push(' ');
output.push_str(&ts.to_string());
output
}
#[must_use]
pub fn format_histogram(
&self,
name: &str,
sum: f64,
count: u64,
tags: &HashMap<&str, &str>,
timestamp: Option<u64>,
) -> String {
let mut output = self.measurement.clone();
if !tags.is_empty() {
for (key, val) in tags {
output.push(',');
output.push_str(&Self::escape_tag_key(key));
output.push('=');
output.push_str(&Self::escape_tag_value(val));
}
}
output.push(' ');
output.push_str(&format!("{}_sum={},", Self::escape_field_key(name), sum));
output.push_str(&format!(
"{}_count={}i",
Self::escape_field_key(name),
count
));
if count > 0 {
let avg = sum / (count as f64);
output.push_str(&format!(",{}_avg={}", Self::escape_field_key(name), avg));
}
let ts =
timestamp.unwrap_or_else(|| self.time_precision.from_system_time(SystemTime::now()));
output.push(' ');
output.push_str(&ts.to_string());
output
}
#[must_use]
pub fn format_batch(
&self,
metrics: &[(&str, MetricValue, HashMap<&str, &str>)],
timestamp: Option<u64>,
) -> Vec<String> {
let ts =
timestamp.unwrap_or_else(|| self.time_precision.from_system_time(SystemTime::now()));
metrics
.iter()
.map(|(name, value, tags)| self.format_metric(name, *value, tags, Some(ts)))
.collect()
}
#[must_use]
#[inline]
fn escape_tag_key(key: &str) -> String {
key.replace(',', "\\,")
.replace('=', "\\=")
.replace(' ', "\\ ")
}
#[must_use]
#[inline]
fn escape_tag_value(value: &str) -> String {
value
.replace(',', "\\,")
.replace('=', "\\=")
.replace(' ', "\\ ")
}
#[must_use]
#[inline]
fn escape_field_key(key: &str) -> String {
key.replace(',', "\\,")
.replace('=', "\\=")
.replace(' ', "\\ ")
}
}
impl Default for InfluxDBExporter {
fn default() -> Self {
Self::new("chie_metrics".to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_metric_value_simple() {
let counter = MetricValue::Counter(42.0);
assert_eq!(counter.value(), 42.0);
assert_eq!(counter.statsd_type(), "c");
let gauge = MetricValue::Gauge(2.5);
assert_eq!(gauge.value(), 2.5);
assert_eq!(gauge.statsd_type(), "g");
}
#[test]
fn test_metric_value_histogram() {
let hist = MetricValue::Histogram {
sum: 100.0,
count: 10,
};
assert_eq!(hist.value(), 10.0); assert_eq!(hist.statsd_type(), "h");
let empty = MetricValue::Histogram { sum: 0.0, count: 0 };
assert_eq!(empty.value(), 0.0);
}
#[test]
fn test_statsd_simple_metric() {
let exporter = StatsDExporter::new("test".to_string());
let tags = HashMap::new();
let output = exporter.format_metric("requests", MetricValue::Counter(100.0), &tags);
assert_eq!(output, "test.requests:100|c");
}
#[test]
fn test_statsd_with_tags() {
let exporter = StatsDExporter::new("app".to_string());
let mut tags = HashMap::new();
tags.insert("host", "server1");
tags.insert("env", "prod");
let output = exporter.format_metric("latency", MetricValue::Gauge(42.5), &tags);
assert!(output.starts_with("app.latency:42.5|g|#"));
assert!(output.contains("host:server1"));
assert!(output.contains("env:prod"));
}
#[test]
fn test_statsd_sample_rate() {
let exporter = StatsDExporter::with_sample_rate("test".to_string(), 0.5);
let tags = HashMap::new();
let output = exporter.format_metric("requests", MetricValue::Counter(10.0), &tags);
assert_eq!(output, "test.requests:10|c|@0.50");
}
#[test]
fn test_statsd_histogram() {
let exporter = StatsDExporter::new("test".to_string());
let tags = HashMap::new();
let outputs = exporter.format_histogram("duration", 250.0, 50, &tags);
assert_eq!(outputs.len(), 3);
assert_eq!(outputs[0], "test.duration_sum:250|c");
assert_eq!(outputs[1], "test.duration_count:50|c");
assert_eq!(outputs[2], "test.duration_avg:5|g");
}
#[test]
fn test_statsd_batch() {
let exporter = StatsDExporter::new("batch".to_string());
let tags = HashMap::new();
let metrics = vec![
("metric1", MetricValue::Counter(1.0), tags.clone()),
("metric2", MetricValue::Gauge(2.0), tags.clone()),
];
let outputs = exporter.format_batch(&metrics);
assert_eq!(outputs.len(), 2);
assert_eq!(outputs[0], "batch.metric1:1|c");
assert_eq!(outputs[1], "batch.metric2:2|g");
}
#[test]
fn test_influxdb_simple_metric() {
let exporter = InfluxDBExporter::new("metrics".to_string());
let tags = HashMap::new();
let output = exporter.format_metric(
"requests",
MetricValue::Counter(100.0),
&tags,
Some(1609459200),
);
assert_eq!(output, "metrics requests=100i 1609459200");
}
#[test]
fn test_influxdb_with_tags() {
let exporter = InfluxDBExporter::new("metrics".to_string());
let mut tags = HashMap::new();
tags.insert("host", "server1");
tags.insert("region", "us-west");
let output =
exporter.format_metric("cpu", MetricValue::Gauge(75.5), &tags, Some(1609459200));
assert!(output.starts_with("metrics,"));
assert!(output.contains("host=server1"));
assert!(output.contains("region=us-west"));
assert!(output.contains(" cpu=75.5 1609459200"));
}
#[test]
fn test_influxdb_histogram() {
let exporter = InfluxDBExporter::new("metrics".to_string());
let tags = HashMap::new();
let output = exporter.format_histogram("latency", 1000.0, 20, &tags, Some(1609459200));
assert!(output.contains("latency_sum=1000"));
assert!(output.contains("latency_count=20i"));
assert!(output.contains("latency_avg=50"));
assert!(output.ends_with(" 1609459200"));
}
#[test]
fn test_influxdb_escaping() {
let exporter = InfluxDBExporter::new("metrics".to_string());
let mut tags = HashMap::new();
tags.insert("tag with space", "value,with=special");
let output = exporter.format_metric(
"field name",
MetricValue::Counter(1.0),
&tags,
Some(1609459200),
);
assert!(output.contains("tag\\ with\\ space=value\\,with\\=special"));
assert!(output.contains("field\\ name=1i"));
}
#[test]
fn test_influxdb_batch() {
let exporter = InfluxDBExporter::new("metrics".to_string());
let tags = HashMap::new();
let metrics = vec![
("metric1", MetricValue::Counter(1.0), tags.clone()),
("metric2", MetricValue::Gauge(2.5), tags.clone()),
];
let outputs = exporter.format_batch(&metrics, Some(1609459200));
assert_eq!(outputs.len(), 2);
assert_eq!(outputs[0], "metrics metric1=1i 1609459200");
assert_eq!(outputs[1], "metrics metric2=2.5 1609459200");
}
#[test]
fn test_time_precision() {
let precision = TimePrecision::Milliseconds;
assert_eq!(precision.as_str(), "ms");
let precision = TimePrecision::Seconds;
assert_eq!(precision.as_str(), "s");
}
#[test]
fn test_metric_name_sanitization() {
let exporter = StatsDExporter::new("test".to_string());
let tags = HashMap::new();
let output =
exporter.format_metric("http.requests.total", MetricValue::Counter(1.0), &tags);
assert_eq!(output, "test.http_requests_total:1|c");
}
}