use super::alerts::{resolve_snapshot_metric, KNOWN_METRIC_PATHS};
use super::*;
use crate::error::OptimError;
use std::collections::BTreeSet;
use std::io::Write;
use std::path::PathBuf;
type ExportRow = (u64, BTreeMap<String, f64>);
fn sanitize_metric_name(path: &str) -> String {
path.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect()
}
fn render_json(rows: &[ExportRow]) -> Result<String> {
let payload: Vec<serde_json::Value> = rows
.iter()
.map(|(timestamp, metrics)| {
let mut object = serde_json::Map::new();
object.insert("timestamp".to_string(), serde_json::Value::from(*timestamp));
for (path, value) in metrics {
object.insert(
path.clone(),
serde_json::Number::from_f64(*value)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null),
);
}
serde_json::Value::Object(object)
})
.collect();
serde_json::to_string_pretty(&payload)
.map_err(|error| OptimError::Other(format!("failed to serialise metrics as JSON: {error}")))
}
fn render_csv(rows: &[ExportRow]) -> String {
let mut columns: BTreeSet<&str> = BTreeSet::new();
for (_, metrics) in rows {
for path in metrics.keys() {
columns.insert(path.as_str());
}
}
let mut out = String::from("timestamp");
for column in &columns {
out.push(',');
out.push_str(column);
}
out.push('\n');
for (timestamp, metrics) in rows {
out.push_str(×tamp.to_string());
for column in &columns {
out.push(',');
if let Some(value) = metrics.get(*column) {
out.push_str(&format!("{value}"));
}
}
out.push('\n');
}
out
}
fn render_prometheus(rows: &[ExportRow]) -> String {
let mut out = String::new();
if let Some((timestamp, metrics)) = rows.last() {
for (path, value) in metrics {
let name = format!("optirs_streaming_{}", sanitize_metric_name(path));
out.push_str(&format!("# TYPE {name} gauge\n"));
out.push_str(&format!("{name} {value} {}\n", timestamp * 1000));
}
}
out
}
fn render_influx(rows: &[ExportRow]) -> String {
let mut out = String::new();
for (timestamp, metrics) in rows {
if metrics.is_empty() {
continue;
}
let fields: Vec<String> = metrics
.iter()
.map(|(path, value)| format!("{}={value}", sanitize_metric_name(path)))
.collect();
out.push_str(&format!(
"optirs_streaming {} {}\n",
fields.join(","),
timestamp * 1_000_000_000
));
}
out
}
fn format_extension(format: &ExportFormat) -> &'static str {
match format {
ExportFormat::Json => "json",
ExportFormat::Csv => "csv",
ExportFormat::Prometheus => "prom",
ExportFormat::InfluxDB => "influx",
ExportFormat::Parquet | ExportFormat::Custom { .. } => "bin",
}
}
impl<A: Float + Default + Clone + std::fmt::Debug + Send + Sync> StreamingMetricsCollector<A> {
pub fn export_metrics(&mut self) -> Result<Vec<PathBuf>> {
let now = SystemTime::now();
if let Some(last) = self.last_export {
if saturating_elapsed(now, last) < self.export_config.frequency {
return Ok(Vec::new());
}
}
self.export_metrics_now()
}
pub fn export_metrics_now(&mut self) -> Result<Vec<PathBuf>> {
self.last_export = Some(SystemTime::now());
self.export_metrics_inner()
}
fn export_metrics_inner(&mut self) -> Result<Vec<PathBuf>> {
match self.historical_data.compression_config.algorithm {
CompressionAlgorithm::Gzip | CompressionAlgorithm::Lz4 | CompressionAlgorithm::Zstd => {
if self.historical_data.compression_config.enabled {
return Err(OptimError::UnsupportedOperation(format!(
"export requested with byte-level compression ({:?}) but optirs-core \
bundles no compression codec; use CompressionAlgorithm::Custom for \
temporal compression or disable compression",
self.historical_data.compression_config.algorithm
)));
}
}
CompressionAlgorithm::None | CompressionAlgorithm::Custom => {}
}
let rows = self.export_rows();
if rows.is_empty() {
return Ok(Vec::new());
}
let formats = self.export_config.formats.clone();
let destinations = self.export_config.destinations.clone();
let mut written = Vec::new();
for format in &formats {
let payload = match format {
ExportFormat::Json => render_json(&rows)?,
ExportFormat::Csv => render_csv(&rows),
ExportFormat::Prometheus => render_prometheus(&rows),
ExportFormat::InfluxDB => render_influx(&rows),
ExportFormat::Parquet => {
return Err(OptimError::UnsupportedOperation(
"Parquet export requires a columnar writer that optirs-core does not \
depend on"
.to_string(),
));
}
ExportFormat::Custom { format } => {
return Err(OptimError::UnsupportedOperation(format!(
"no writer is registered for the custom export format '{format}'"
)));
}
};
for destination in &destinations {
match destination {
ExportDestination::File { path } => {
let mut file_path = PathBuf::from(path);
let extension = format_extension(format);
match file_path.extension() {
Some(existing) if existing == extension => {}
_ => {
let name = file_path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| "streaming_metrics".to_string());
file_path.set_file_name(format!("{name}.{extension}"));
}
}
if let Some(parent) = file_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).map_err(OptimError::IO)?;
}
}
let mut file = std::fs::File::create(&file_path).map_err(OptimError::IO)?;
file.write_all(payload.as_bytes()).map_err(OptimError::IO)?;
written.push(file_path);
}
ExportDestination::Database { .. }
| ExportDestination::S3 { .. }
| ExportDestination::Http { .. }
| ExportDestination::Kafka { .. } => {
return Err(OptimError::UnsupportedOperation(
"optirs-core has no database, object-store, HTTP or Kafka client; \
only file destinations can be written from this crate"
.to_string(),
));
}
}
}
}
Ok(written)
}
fn export_rows(&self) -> Vec<ExportRow> {
let batch = self.export_config.batch_size.max(1);
let mut rows: Vec<ExportRow> = Vec::new();
for (_key, snapshot) in self.historical_data.time_series.iter().rev().take(batch) {
let mut metrics = BTreeMap::new();
for path in KNOWN_METRIC_PATHS {
if let Some(value) = resolve_snapshot_metric(snapshot, path) {
if value.is_finite() {
metrics.insert((*path).to_string(), value);
}
}
}
rows.push((snapshot.timestamp, metrics));
}
rows.reverse();
rows
}
}