/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see .
*/
use anyhow::{Result, ensure};
use async_trait::async_trait;
use clickhouse::{Client, Row};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use time::OffsetDateTime;
use tracing::info;
use ulid::Ulid;
use super::Storage;
use super::migrations::run_migrations;
use super::schemas::{
CounterMetric, CounterRequest, CrashEvent, CrashRequest, DataPoint, GaugeMetric, GaugeRequest,
HistogramPercentiles, HistogramRaw, HistogramRequest, convert_dimensions, dimensions_to_json,
};
use crate::config::Config;
pub fn hash_dimensions(dimensions: &serde_json::Map) -> String {
if dimensions.is_empty() {
return String::new();
}
let json = serde_json::to_string(dimensions).unwrap_or_default();
let mut hasher = Sha256::new();
hasher.update(json.as_bytes());
format!("{:x}", hasher.finalize())[..16].to_string()
}
fn sanitize_dimension_key(key: &str) -> Option {
if key.is_empty() || key.len() > 64 {
return None;
}
let sanitized: String = key
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '.' || *c == '-')
.collect();
if sanitized.is_empty() || sanitized != key {
None
} else {
Some(sanitized)
}
}
fn validate_identifier(name: &str) -> Result<()> {
ensure!(
name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
"Invalid ClickHouse identifier: {name}"
);
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Resolution {
Raw,
Hourly,
Daily,
}
impl Resolution {
pub fn from_str(s: Option<&str>) -> Self {
match s {
Some("hourly") => Self::Hourly,
Some("daily") => Self::Daily,
_ => Self::Raw,
}
}
}
#[derive(Clone)]
pub struct ClickHouseStorage {
client: Client,
database: String,
}
#[derive(Debug, Clone)]
pub struct LatestGaugeSummary {
pub dimensions: serde_json::Map,
pub value: f64,
pub label: String,
}
#[derive(Debug, Clone)]
pub struct CrashEventData {
pub id: String,
pub timestamp: i64,
pub guild_id: String,
pub stacktrace: String,
pub notified: bool,
}
#[derive(Row, Serialize, Deserialize)]
struct CounterQueryRow {
timestamp_bucket: i64,
group_key: String,
total: i64,
}
#[derive(Row, Serialize, Deserialize)]
struct AggregatedCounterQueryRow {
period_start: i64,
group_key: String,
total: i64,
}
#[derive(Row, Serialize, Deserialize)]
struct GaugeQueryRow {
timestamp: i64,
value: f64,
dimensions: Vec<(String, String)>,
}
#[derive(Row, Serialize, Deserialize)]
struct HistogramQueryRow {
timestamp_bucket: i64,
avg_value: f64,
}
#[derive(Row, Serialize, Deserialize)]
struct LatestGaugeRow {
dimensions_hash: String,
timestamp: i64,
value: f64,
dimensions: Vec<(String, String)>,
label: String,
}
#[derive(Row, Serialize, Deserialize)]
struct PercentilesRow {
count: u64,
avg: f64,
min: f64,
max: f64,
p50: f64,
p95: f64,
p99: f64,
}
#[async_trait]
impl Storage for ClickHouseStorage {
async fn check_health(&self) -> Result<()> {
self.client.query("SELECT 1").execute().await?;
Ok(())
}
async fn insert_counter(&self, req: CounterRequest) -> Result<()> {
self.insert_counter_impl(req).await
}
async fn insert_gauge(&self, req: GaugeRequest) -> Result<()> {
self.insert_gauge_impl(req).await
}
async fn insert_histogram(&self, req: HistogramRequest) -> Result<()> {
self.insert_histogram_impl(req).await
}
async fn insert_crash(&self, req: CrashRequest) -> Result {
self.insert_crash_impl(req).await
}
async fn mark_crash_notified(&self, id: &str) -> Result<()> {
self.mark_crash_notified_impl(id).await
}
async fn query_counters(
&self,
metric_name: &str,
start_ms: i64,
end_ms: i64,
group_by: Option<&str>,
resolution: Resolution,
) -> Result> {
self.query_counters_impl(metric_name, start_ms, end_ms, group_by, resolution)
.await
}
async fn query_gauges(
&self,
metric_name: &str,
start_ms: i64,
end_ms: i64,
) -> Result> {
self.query_gauges_impl(metric_name, start_ms, end_ms).await
}
async fn query_histograms(
&self,
metric_name: &str,
start_ms: i64,
end_ms: i64,
) -> Result> {
self.query_histograms_impl(metric_name, start_ms, end_ms)
.await
}
async fn query_histogram_percentiles(
&self,
metric_name: &str,
start_ms: i64,
end_ms: i64,
) -> Result