use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExportFormat {
Json,
Csv,
OpenMetrics,
LineProtocol,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MetricType {
Counter,
Gauge,
Histogram,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetricPoint {
pub name: String,
pub metric_type: MetricType,
pub value: f64,
pub labels: HashMap<String, String>,
pub help: Option<String>,
pub timestamp_ms: u64,
}
impl MetricPoint {
pub fn counter(name: impl Into<String>, value: f64) -> Self {
Self {
name: name.into(),
metric_type: MetricType::Counter,
value,
labels: HashMap::new(),
help: None,
timestamp_ms: 0,
}
}
pub fn gauge(name: impl Into<String>, value: f64) -> Self {
Self {
name: name.into(),
metric_type: MetricType::Gauge,
value,
labels: HashMap::new(),
help: None,
timestamp_ms: 0,
}
}
pub fn with_label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.labels.insert(key.into(), value.into());
self
}
pub fn with_help(mut self, help: impl Into<String>) -> Self {
self.help = Some(help.into());
self
}
pub fn with_timestamp(mut self, ts_ms: u64) -> Self {
self.timestamp_ms = ts_ms;
self
}
}
pub struct MetricsExporter;
impl MetricsExporter {
pub fn export(metrics: &[MetricPoint], format: ExportFormat) -> String {
match format {
ExportFormat::Json => Self::export_json(metrics),
ExportFormat::Csv => Self::export_csv(metrics),
ExportFormat::OpenMetrics => Self::export_open_metrics(metrics),
ExportFormat::LineProtocol => Self::export_line_protocol(metrics),
}
}
fn export_json(metrics: &[MetricPoint]) -> String {
serde_json::to_string_pretty(metrics).unwrap_or_else(|_| "[]".to_string())
}
fn export_csv(metrics: &[MetricPoint]) -> String {
let mut out = String::from("name,type,value,labels\n");
for m in metrics {
let labels_str = if m.labels.is_empty() {
String::new()
} else {
let pairs: Vec<String> = m.labels.iter().map(|(k, v)| format!("{k}={v}")).collect();
pairs.join(";")
};
let type_str = match m.metric_type {
MetricType::Counter => "counter",
MetricType::Gauge => "gauge",
MetricType::Histogram => "histogram",
};
out.push_str(&format!(
"{},{},{},{}\n",
m.name, type_str, m.value, labels_str
));
}
out
}
fn export_open_metrics(metrics: &[MetricPoint]) -> String {
let mut out = String::with_capacity(1024);
let mut seen_names: HashMap<String, bool> = HashMap::new();
for m in metrics {
if !seen_names.contains_key(&m.name) {
if let Some(help) = &m.help {
out.push_str(&format!("# HELP {} {}\n", m.name, help));
}
let type_str = match m.metric_type {
MetricType::Counter => "counter",
MetricType::Gauge => "gauge",
MetricType::Histogram => "histogram",
};
out.push_str(&format!("# TYPE {} {}\n", m.name, type_str));
seen_names.insert(m.name.clone(), true);
}
if m.labels.is_empty() {
out.push_str(&format!("{} {}\n", m.name, format_value(m.value)));
} else {
let labels_str: Vec<String> = m
.labels
.iter()
.map(|(k, v)| format!("{k}=\"{v}\""))
.collect();
out.push_str(&format!(
"{}{{{}}} {}\n",
m.name,
labels_str.join(","),
format_value(m.value)
));
}
}
out.push_str("# EOF\n");
out
}
fn export_line_protocol(metrics: &[MetricPoint]) -> String {
let mut out = String::with_capacity(1024);
for m in metrics {
let mut line = m.name.clone();
if !m.labels.is_empty() {
let mut sorted: Vec<(&String, &String)> = m.labels.iter().collect();
sorted.sort_by_key(|(k, _)| *k);
for (k, v) in &sorted {
line.push_str(&format!(",{k}={v}"));
}
}
line.push_str(&format!(" value={}", format_value(m.value)));
if m.timestamp_ms > 0 {
line.push_str(&format!(" {}", m.timestamp_ms * 1_000_000)); }
out.push_str(&line);
out.push('\n');
}
out
}
}
fn format_value(v: f64) -> String {
if v == v.floor() && v.abs() < 1e15 {
format!("{}", v as i64)
} else {
format!("{v}")
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn sample_metrics() -> Vec<MetricPoint> {
vec![
MetricPoint::counter("requests_total", 42.0)
.with_label("method", "GET")
.with_help("Total requests"),
MetricPoint::gauge("active_connections", 5.0).with_help("Current active connections"),
MetricPoint::counter("errors_total", 3.0).with_label("code", "500"),
]
}
#[test]
fn test_json_export() {
let output = MetricsExporter::export(&sample_metrics(), ExportFormat::Json);
let parsed: Vec<MetricPoint> = serde_json::from_str(&output).unwrap();
assert_eq!(parsed.len(), 3);
assert_eq!(parsed[0].name, "requests_total");
}
#[test]
fn test_csv_export() {
let output = MetricsExporter::export(&sample_metrics(), ExportFormat::Csv);
let lines: Vec<&str> = output.lines().collect();
assert_eq!(lines[0], "name,type,value,labels");
assert!(lines[1].contains("requests_total"));
assert!(lines[1].contains("counter"));
}
#[test]
fn test_open_metrics_export() {
let output = MetricsExporter::export(&sample_metrics(), ExportFormat::OpenMetrics);
assert!(output.contains("# HELP requests_total Total requests"));
assert!(output.contains("# TYPE requests_total counter"));
assert!(output.contains("requests_total{method=\"GET\"} 42"));
assert!(output.contains("# EOF"));
}
#[test]
fn test_line_protocol_export() {
let output = MetricsExporter::export(&sample_metrics(), ExportFormat::LineProtocol);
assert!(output.contains("requests_total,method=GET value=42"));
assert!(output.contains("active_connections value=5"));
}
#[test]
fn test_empty_metrics() {
let output = MetricsExporter::export(&[], ExportFormat::Json);
assert_eq!(output, "[]");
let csv = MetricsExporter::export(&[], ExportFormat::Csv);
assert!(csv.starts_with("name,type,value,labels"));
}
#[test]
fn test_counter_constructor() {
let m = MetricPoint::counter("test", 10.0);
assert_eq!(m.metric_type, MetricType::Counter);
assert_eq!(m.value, 10.0);
}
#[test]
fn test_gauge_constructor() {
let m = MetricPoint::gauge("test", 5.5);
assert_eq!(m.metric_type, MetricType::Gauge);
assert_eq!(m.value, 5.5);
}
#[test]
fn test_labels() {
let m = MetricPoint::counter("test", 1.0)
.with_label("a", "b")
.with_label("c", "d");
assert_eq!(m.labels.len(), 2);
}
#[test]
fn test_help_text() {
let m = MetricPoint::counter("test", 1.0).with_help("Help text");
assert_eq!(m.help.unwrap(), "Help text");
}
#[test]
fn test_timestamp() {
let m = MetricPoint::counter("test", 1.0).with_timestamp(1234567890);
assert_eq!(m.timestamp_ms, 1234567890);
}
#[test]
fn test_line_protocol_timestamp() {
let metrics = vec![MetricPoint::counter("test", 1.0).with_timestamp(1000)];
let output = MetricsExporter::export(&metrics, ExportFormat::LineProtocol);
assert!(output.contains("1000000000")); }
#[test]
fn test_open_metrics_dedup() {
let metrics = vec![
MetricPoint::counter("test", 1.0)
.with_label("a", "1")
.with_help("Test"),
MetricPoint::counter("test", 2.0).with_label("a", "2"),
];
let output = MetricsExporter::export(&metrics, ExportFormat::OpenMetrics);
assert_eq!(output.matches("# TYPE test counter").count(), 1);
}
#[test]
fn test_open_metrics_no_labels() {
let metrics = vec![MetricPoint::gauge("temp", 72.5)];
let output = MetricsExporter::export(&metrics, ExportFormat::OpenMetrics);
assert!(output.contains("temp 72.5"));
}
#[test]
fn test_csv_labels() {
let metrics = vec![MetricPoint::counter("test", 1.0)
.with_label("a", "1")
.with_label("b", "2")];
let output = MetricsExporter::export(&metrics, ExportFormat::Csv);
assert!(output.contains("a=1") || output.contains("b=2"));
}
#[test]
fn test_format_value_integer() {
assert_eq!(format_value(42.0), "42");
assert_eq!(format_value(0.0), "0");
}
#[test]
fn test_format_value_float() {
assert_eq!(format_value(2.719), "2.719");
}
#[test]
fn test_metric_point_serializable() {
let m = MetricPoint::counter("test", 42.0).with_label("env", "prod");
let json = serde_json::to_string(&m).unwrap();
assert!(json.contains("\"name\":\"test\""));
let restored: MetricPoint = serde_json::from_str(&json).unwrap();
assert_eq!(restored.value, 42.0);
}
#[test]
fn test_format_serializable() {
let f = ExportFormat::Json;
let json = serde_json::to_string(&f).unwrap();
assert_eq!(json, "\"json\"");
}
#[test]
fn test_same_name_different_labels() {
let metrics = vec![
MetricPoint::counter("http_requests", 100.0).with_label("status", "200"),
MetricPoint::counter("http_requests", 5.0).with_label("status", "500"),
];
let om = MetricsExporter::export(&metrics, ExportFormat::OpenMetrics);
assert!(om.contains("status=\"200\""));
assert!(om.contains("status=\"500\""));
}
#[test]
fn test_line_protocol_sorted_labels() {
let m = vec![MetricPoint::counter("test", 1.0)
.with_label("z", "1")
.with_label("a", "2")];
let output = MetricsExporter::export(&m, ExportFormat::LineProtocol);
let line = output.lines().next().unwrap();
let comma_pos_a = line.find(",a=").unwrap();
let comma_pos_z = line.find(",z=").unwrap();
assert!(comma_pos_a < comma_pos_z);
}
}