Skip to main content

sz_orm_observability/
summary.rs

1//! Summary 指标与 Pushgateway 导出
2//!
3//! 提供 Prometheus Summary 指标类型(基于分位数)与 Pushgateway 导出器。
4//!
5//! ## Summary vs Histogram
6//!
7//! - **Histogram**:预定义 bucket 边界,适合已知分布范围的指标(如延迟)。
8//! - **Summary**:客户端计算分位数,适合需要精确 p50/p90/p99 的场景,
9//!   但无法跨实例聚合。
10//!
11//! ## Pushgateway
12//!
13//! Pushgateway 用于短生命周期任务的指标推送:任务结束后将指标推送到
14//! Pushgateway,Prometheus 再从 Pushgateway 拉取。本模块提供内存模拟
15//! 实现,不进行真实网络发送,便于测试与本地开发。
16
17use parking_lot::RwLock;
18use std::collections::HashMap;
19use std::sync::Arc;
20
21/// Summary 指标,基于排序数组计算分位数。
22///
23/// 与 Prometheus Summary 类似,记录所有观测值并按需计算分位数。
24/// 适合样本量可控的场景;超大规模样本应考虑 T-Digest 近似算法。
25pub struct Summary {
26    name: String,
27    help: String,
28    /// 预配置的分位数列表(如 [0.5, 0.9, 0.99])
29    quantiles: Vec<f64>,
30    /// 已排序的观测样本
31    samples: Arc<RwLock<Vec<f64>>>,
32    /// 样本总和
33    sum: Arc<RwLock<f64>>,
34    /// 样本计数
35    count: Arc<RwLock<u64>>,
36}
37
38impl Summary {
39    /// 创建新的 Summary 指标
40    ///
41    /// # 参数
42    /// - `name`:指标名(如 `request_duration_seconds`)
43    /// - `help`:帮助文本
44    /// - `quantiles`:要计算的分位数列表(如 `[0.5, 0.9, 0.99]`)
45    pub fn new(name: impl Into<String>, help: impl Into<String>, quantiles: Vec<f64>) -> Self {
46        Self {
47            name: name.into(),
48            help: help.into(),
49            quantiles,
50            samples: Arc::new(RwLock::new(Vec::new())),
51            sum: Arc::new(RwLock::new(0.0)),
52            count: Arc::new(RwLock::new(0)),
53        }
54    }
55
56    /// 观测一个值
57    pub fn observe(&self, value: f64) {
58        let mut samples = self.samples.write();
59        let pos = samples.partition_point(|&v| v < value);
60        samples.insert(pos, value);
61
62        let mut sum = self.sum.write();
63        *sum += value;
64
65        let mut count = self.count.write();
66        *count += 1;
67    }
68
69    /// 计算指定分位数的值(0.0..=1.0)
70    ///
71    /// 使用最近排名法(Nearest Rank):`rank = ceil(p * n)`,至少为 1。
72    /// 返回 `None` 表示无样本或分位数超出范围。
73    pub fn quantile(&self, q: f64) -> Option<f64> {
74        if !(0.0..=1.0).contains(&q) {
75            return None;
76        }
77        let samples = self.samples.read();
78        if samples.is_empty() {
79            return None;
80        }
81        let n = samples.len();
82        let rank = ((q * n as f64).ceil() as usize).max(1).min(n);
83        Some(samples[rank - 1])
84    }
85
86    /// 计算所有预配置分位数,返回 (分位数, 值) 列表
87    pub fn quantiles(&self) -> Vec<(f64, Option<f64>)> {
88        self.quantiles
89            .iter()
90            .map(|&q| (q, self.quantile(q)))
91            .collect()
92    }
93
94    /// 样本计数
95    pub fn count(&self) -> u64 {
96        *self.count.read()
97    }
98
99    /// 样本总和
100    pub fn sum(&self) -> f64 {
101        *self.sum.read()
102    }
103
104    /// 指标名
105    pub fn name(&self) -> &str {
106        &self.name
107    }
108
109    /// 渲染为 Prometheus 文本格式
110    pub fn render(&self) -> String {
111        let samples = self.samples.read();
112        let sum = *self.sum.read();
113        let count = *self.count.read();
114
115        let mut output = String::new();
116        output.push_str(&format!("# HELP {} {}\n", self.name, self.help));
117        output.push_str(&format!("# TYPE {} summary\n", self.name));
118
119        for &q in &self.quantiles {
120            let value = if samples.is_empty() {
121                0.0
122            } else {
123                let n = samples.len();
124                let rank = ((q * n as f64).ceil() as usize).max(1).min(n);
125                samples[rank - 1]
126            };
127            output.push_str(&format!("{}{{quantile=\"{}\"}} {}\n", self.name, q, value));
128        }
129
130        output.push_str(&format!("{}_sum {}\n", self.name, sum));
131        output.push_str(&format!("{}_count {}\n", self.name, count));
132        output
133    }
134
135    /// 重置所有样本
136    pub fn reset(&self) {
137        let mut samples = self.samples.write();
138        samples.clear();
139        *self.sum.write() = 0.0;
140        *self.count.write() = 0;
141    }
142}
143
144/// 带标签的 Histogram 扩展。
145///
146/// 标准 [`crate::Histogram`] 不支持在同一指标名下按标签区分。
147/// `LabeledHistogram` 通过标签集合区分同一指标名的不同时间序列。
148pub struct LabeledHistogram {
149    /// 指标名
150    name: String,
151    /// 帮助文本
152    help: String,
153    /// bucket 边界
154    buckets: Vec<f64>,
155    /// 按标签键排序后的拼接字符串索引的子直方图
156    series: RwLock<HashMap<String, LabeledSeries>>,
157}
158
159/// 单个标签组合对应的直方图数据
160#[derive(Debug, Clone)]
161struct LabeledSeries {
162    /// 标签键值对(已排序)
163    labels: Vec<(String, String)>,
164    /// 各 bucket 的累计计数
165    counts: Vec<u64>,
166    /// 样本总和
167    sum: f64,
168    /// 样本计数
169    count: u64,
170}
171
172impl LabeledSeries {
173    fn new(labels: Vec<(String, String)>, bucket_count: usize) -> Self {
174        Self {
175            labels,
176            counts: vec![0; bucket_count],
177            sum: 0.0,
178            count: 0,
179        }
180    }
181
182    fn label_key(labels: &[(String, String)]) -> String {
183        labels
184            .iter()
185            .map(|(k, v)| format!("{}=\"{}\"", k, v.replace('"', "\\\"")))
186            .collect::<Vec<_>>()
187            .join(",")
188    }
189}
190
191impl LabeledHistogram {
192    /// 创建带标签的直方图
193    ///
194    /// # 参数
195    /// - `name`:指标名
196    /// - `help`:帮助文本
197    /// - `buckets`:bucket 边界(无需包含 +Inf,会自动追加)
198    pub fn new(name: impl Into<String>, help: impl Into<String>, mut buckets: Vec<f64>) -> Self {
199        buckets.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
200        if !buckets.contains(&f64::INFINITY) {
201            buckets.push(f64::INFINITY);
202        }
203        Self {
204            name: name.into(),
205            help: help.into(),
206            buckets,
207            series: RwLock::new(HashMap::new()),
208        }
209    }
210
211    /// 观测一个带标签的值
212    ///
213    /// # 参数
214    /// - `labels`:标签键值对(顺序无关,内部会排序)
215    /// - `value`:观测值
216    pub fn observe(&self, labels: &HashMap<String, String>, value: f64) {
217        let mut sorted_labels: Vec<(String, String)> =
218            labels.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
219        sorted_labels.sort_by(|a, b| a.0.cmp(&b.0));
220
221        let key = LabeledSeries::label_key(&sorted_labels);
222        let mut series = self.series.write();
223        let entry = series
224            .entry(key)
225            .or_insert_with(|| LabeledSeries::new(sorted_labels.clone(), self.buckets.len()));
226
227        for (i, bucket) in self.buckets.iter().enumerate() {
228            if value <= *bucket {
229                entry.counts[i] += 1;
230            }
231        }
232        entry.sum += value;
233        entry.count += 1;
234    }
235
236    /// 获取指定标签组合的样本计数
237    pub fn count(&self, labels: &HashMap<String, String>) -> u64 {
238        let mut sorted_labels: Vec<(String, String)> =
239            labels.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
240        sorted_labels.sort_by(|a, b| a.0.cmp(&b.0));
241        let key = LabeledSeries::label_key(&sorted_labels);
242        self.series.read().get(&key).map(|s| s.count).unwrap_or(0)
243    }
244
245    /// 获取所有标签组合数量
246    pub fn label_combination_count(&self) -> usize {
247        self.series.read().len()
248    }
249
250    /// 渲染为 Prometheus 文本格式
251    pub fn render(&self) -> String {
252        let series = self.series.read();
253        let mut output = String::new();
254        output.push_str(&format!("# HELP {} {}\n", self.name, self.help));
255        output.push_str(&format!("# TYPE {} histogram\n", self.name));
256
257        for s in series.values() {
258            let label_str = LabeledSeries::label_key(&s.labels);
259            for (i, bucket) in self.buckets.iter().enumerate() {
260                if *bucket == f64::INFINITY {
261                    output.push_str(&format!(
262                        "{}_bucket{{{},le=\"+Inf\"}} {}\n",
263                        self.name, label_str, s.counts[i]
264                    ));
265                } else {
266                    output.push_str(&format!(
267                        "{}_bucket{{{},le=\"{}\"}} {}\n",
268                        self.name, label_str, bucket, s.counts[i]
269                    ));
270                }
271            }
272            output.push_str(&format!("{}_sum{{{}}} {}\n", self.name, label_str, s.sum));
273            output.push_str(&format!(
274                "{}_count{{{}}} {}\n",
275                self.name, label_str, s.count
276            ));
277        }
278
279        output
280    }
281}
282
283/// Pushgateway 导出配置
284#[derive(Debug, Clone)]
285pub struct PushgatewayConfig {
286    /// Pushgateway 地址(如 `http://localhost:9091`)
287    pub endpoint: String,
288    /// 作业名(job label)
289    pub job: String,
290    /// 实例标签(可选)
291    pub instance: Option<String>,
292}
293
294impl Default for PushgatewayConfig {
295    fn default() -> Self {
296        Self {
297            endpoint: "http://localhost:9091".to_string(),
298            job: "sz-orm".to_string(),
299            instance: None,
300        }
301    }
302}
303
304/// Pushgateway 导出器(内存模拟)。
305///
306/// 模拟将指标推送到 Prometheus Pushgateway 的行为。
307/// 实际网络发送被替换为内存记录,便于测试验证。
308pub struct PushgatewayExporter {
309    config: PushgatewayConfig,
310    /// 已推送的指标文本快照
311    pushed: RwLock<Vec<PushSnapshot>>,
312}
313
314/// 一次推送的快照
315#[derive(Debug, Clone)]
316pub struct PushSnapshot {
317    /// 推送时间戳(Unix 毫秒)
318    pub timestamp_ms: i64,
319    /// 推送的指标文本
320    pub metrics_text: String,
321    /// 作业名
322    pub job: String,
323    /// 实例名
324    pub instance: Option<String>,
325}
326
327impl PushgatewayExporter {
328    /// 创建新的 Pushgateway 导出器
329    pub fn new(config: PushgatewayConfig) -> Self {
330        Self {
331            config,
332            pushed: RwLock::new(Vec::new()),
333        }
334    }
335
336    /// 模拟推送指标到 Pushgateway
337    ///
338    /// 将渲染后的指标文本记录到内存,返回推送是否成功。
339    /// 实际实现中此处会发起 HTTP PUT 请求。
340    pub fn push(&self, metrics_text: impl Into<String>) -> Result<(), String> {
341        let snapshot = PushSnapshot {
342            timestamp_ms: current_timestamp_ms(),
343            metrics_text: metrics_text.into(),
344            job: self.config.job.clone(),
345            instance: self.config.instance.clone(),
346        };
347        let mut pushed = self.pushed.write();
348        pushed.push(snapshot);
349        Ok(())
350    }
351
352    /// 从 MetricsRegistry 渲染并推送
353    pub fn push_from_registry(&self, registry: &crate::MetricsRegistry) -> Result<(), String> {
354        let text = registry.render();
355        self.push(text)
356    }
357
358    /// 获取推送历史快照
359    pub fn snapshots(&self) -> Vec<PushSnapshot> {
360        self.pushed.read().clone()
361    }
362
363    /// 获取推送次数
364    pub fn push_count(&self) -> usize {
365        self.pushed.read().len()
366    }
367
368    /// 清空推送历史
369    pub fn clear(&self) {
370        self.pushed.write().clear();
371    }
372
373    /// 获取配置引用
374    pub fn config(&self) -> &PushgatewayConfig {
375        &self.config
376    }
377}
378
379fn current_timestamp_ms() -> i64 {
380    use std::time::{SystemTime, UNIX_EPOCH};
381    SystemTime::now()
382        .duration_since(UNIX_EPOCH)
383        .unwrap_or_default()
384        .as_millis() as i64
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    // ===================== Summary 测试 =====================
392
393    #[test]
394    fn test_summary_new_empty() {
395        let s = Summary::new("latency", "latency summary", vec![0.5, 0.9, 0.99]);
396        assert_eq!(s.count(), 0);
397        assert_eq!(s.sum(), 0.0);
398        assert!(s.quantile(0.5).is_none());
399    }
400
401    #[test]
402    fn test_summary_observe_single() {
403        let s = Summary::new("latency", "help", vec![0.5]);
404        s.observe(1.5);
405        assert_eq!(s.count(), 1);
406        assert!((s.sum() - 1.5).abs() < 1e-9);
407        assert!((s.quantile(0.5).unwrap() - 1.5).abs() < 1e-9);
408    }
409
410    #[test]
411    fn test_summary_observe_multiple_p50() {
412        let s = Summary::new("latency", "help", vec![0.5]);
413        for v in [1.0, 2.0, 3.0, 4.0, 5.0] {
414            s.observe(v);
415        }
416        // p50 of [1,2,3,4,5] -> rank=ceil(0.5*5)=3 -> samples[2]=3.0
417        assert!((s.quantile(0.5).unwrap() - 3.0).abs() < 1e-9);
418    }
419
420    #[test]
421    fn test_summary_observe_multiple_p99() {
422        let s = Summary::new("latency", "help", vec![0.99]);
423        for v in [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 100.0] {
424            s.observe(v);
425        }
426        // p99 of 10 samples -> rank=ceil(0.99*10)=10 -> samples[9]=100.0
427        assert!((s.quantile(0.99).unwrap() - 100.0).abs() < 1e-9);
428    }
429
430    #[test]
431    fn test_summary_quantile_out_of_range() {
432        let s = Summary::new("latency", "help", vec![0.5]);
433        s.observe(1.0);
434        assert!(s.quantile(-0.1).is_none());
435        assert!(s.quantile(1.1).is_none());
436    }
437
438    #[test]
439    fn test_summary_quantile_empty() {
440        let s = Summary::new("latency", "help", vec![0.5]);
441        assert!(s.quantile(0.5).is_none());
442    }
443
444    #[test]
445    fn test_summary_quantile_p0_and_p1() {
446        let s = Summary::new("latency", "help", vec![]);
447        for v in [10.0, 20.0, 30.0] {
448            s.observe(v);
449        }
450        // p0 -> rank=ceil(0*3)=0 -> max(0,1)=1 -> samples[0]=10
451        assert!((s.quantile(0.0).unwrap() - 10.0).abs() < 1e-9);
452        // p1 -> rank=ceil(1*3)=3 -> samples[2]=30
453        assert!((s.quantile(1.0).unwrap() - 30.0).abs() < 1e-9);
454    }
455
456    #[test]
457    fn test_summary_quantiles_all() {
458        let s = Summary::new("latency", "help", vec![0.5, 0.9, 0.99]);
459        for v in [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0] {
460            s.observe(v);
461        }
462        let qs = s.quantiles();
463        assert_eq!(qs.len(), 3);
464        assert!(qs.iter().all(|(_, v)| v.is_some()));
465    }
466
467    #[test]
468    fn test_summary_unsorted_input_stays_sorted() {
469        let s = Summary::new("latency", "help", vec![0.5]);
470        s.observe(50.0);
471        s.observe(10.0);
472        s.observe(30.0);
473        // p50 of sorted [10,30,50] -> rank=ceil(0.5*3)=2 -> samples[1]=30
474        assert!((s.quantile(0.5).unwrap() - 30.0).abs() < 1e-9);
475    }
476
477    #[test]
478    fn test_summary_render_contains_type() {
479        let s = Summary::new("latency", "latency help", vec![0.5, 0.99]);
480        s.observe(1.0);
481        let output = s.render();
482        assert!(output.contains("# HELP latency latency help"));
483        assert!(output.contains("# TYPE latency summary"));
484        assert!(output.contains("latency{quantile=\"0.5\"}"));
485        assert!(output.contains("latency{quantile=\"0.99\"}"));
486        assert!(output.contains("latency_sum"));
487        assert!(output.contains("latency_count"));
488    }
489
490    #[test]
491    fn test_summary_render_empty_shows_zero() {
492        let s = Summary::new("latency", "help", vec![0.5]);
493        let output = s.render();
494        // 空样本时分位数值为 0
495        assert!(output.contains("latency{quantile=\"0.5\"} 0"));
496        assert!(output.contains("latency_count 0"));
497    }
498
499    #[test]
500    fn test_summary_reset() {
501        let s = Summary::new("latency", "help", vec![0.5]);
502        s.observe(1.0);
503        s.observe(2.0);
504        assert_eq!(s.count(), 2);
505
506        s.reset();
507        assert_eq!(s.count(), 0);
508        assert!((s.sum() - 0.0).abs() < 1e-9);
509        assert!(s.quantile(0.5).is_none());
510    }
511
512    #[test]
513    fn test_summary_name() {
514        let s = Summary::new("my_metric", "help", vec![0.5]);
515        assert_eq!(s.name(), "my_metric");
516    }
517
518    // ===================== LabeledHistogram 测试 =====================
519
520    #[test]
521    fn test_labeled_histogram_new() {
522        let h = LabeledHistogram::new("requests", "help", vec![0.1, 0.5, 1.0]);
523        assert_eq!(h.label_combination_count(), 0);
524    }
525
526    #[test]
527    fn test_labeled_histogram_observe_single_label() {
528        let h = LabeledHistogram::new("requests", "help", vec![0.1, 0.5, 1.0]);
529        let mut labels = HashMap::new();
530        labels.insert("method".to_string(), "GET".to_string());
531
532        h.observe(&labels, 0.3);
533        assert_eq!(h.count(&labels), 1);
534        assert_eq!(h.label_combination_count(), 1);
535    }
536
537    #[test]
538    fn test_labeled_histogram_observe_multiple_labels() {
539        let h = LabeledHistogram::new("requests", "help", vec![0.1, 0.5, 1.0]);
540
541        let mut get_labels = HashMap::new();
542        get_labels.insert("method".to_string(), "GET".to_string());
543
544        let mut post_labels = HashMap::new();
545        post_labels.insert("method".to_string(), "POST".to_string());
546
547        h.observe(&get_labels, 0.1);
548        h.observe(&get_labels, 0.2);
549        h.observe(&post_labels, 0.5);
550
551        assert_eq!(h.count(&get_labels), 2);
552        assert_eq!(h.count(&post_labels), 1);
553        assert_eq!(h.label_combination_count(), 2);
554    }
555
556    #[test]
557    fn test_labeled_histogram_label_order_independent() {
558        let h = LabeledHistogram::new("requests", "help", vec![0.1, 1.0]);
559
560        let mut labels1 = HashMap::new();
561        labels1.insert("a".to_string(), "1".to_string());
562        labels1.insert("b".to_string(), "2".to_string());
563
564        let mut labels2 = HashMap::new();
565        labels2.insert("b".to_string(), "2".to_string());
566        labels2.insert("a".to_string(), "1".to_string());
567
568        h.observe(&labels1, 0.5);
569        // 标签顺序不同但键值对相同,应归入同一时间序列
570        assert_eq!(h.count(&labels2), 1);
571        assert_eq!(h.label_combination_count(), 1);
572    }
573
574    #[test]
575    fn test_labeled_histogram_count_missing_labels() {
576        let h = LabeledHistogram::new("requests", "help", vec![0.1, 1.0]);
577        let labels = HashMap::new();
578        assert_eq!(h.count(&labels), 0);
579    }
580
581    #[test]
582    fn test_labeled_histogram_render_contains_labels() {
583        let h = LabeledHistogram::new("requests", "request help", vec![0.1, 1.0]);
584        let mut labels = HashMap::new();
585        labels.insert("method".to_string(), "GET".to_string());
586        h.observe(&labels, 0.05);
587
588        let output = h.render();
589        assert!(output.contains("# HELP requests request help"));
590        assert!(output.contains("# TYPE requests histogram"));
591        assert!(output.contains("method=\"GET\""));
592        assert!(output.contains("requests_count"));
593        assert!(output.contains("requests_sum"));
594    }
595
596    #[test]
597    fn test_labeled_histogram_render_inf_bucket() {
598        let h = LabeledHistogram::new("req", "help", vec![0.1]);
599        let labels = HashMap::new();
600        h.observe(&labels, 0.05);
601        h.observe(&labels, 5.0);
602        let output = h.render();
603        assert!(output.contains("le=\"+Inf\""));
604    }
605
606    // ===================== PushgatewayExporter 测试 =====================
607
608    #[test]
609    fn test_pushgateway_config_default() {
610        let config = PushgatewayConfig::default();
611        assert_eq!(config.endpoint, "http://localhost:9091");
612        assert_eq!(config.job, "sz-orm");
613        assert!(config.instance.is_none());
614    }
615
616    #[test]
617    fn test_pushgateway_exporter_new() {
618        let exporter = PushgatewayExporter::new(PushgatewayConfig::default());
619        assert_eq!(exporter.push_count(), 0);
620        assert!(exporter.snapshots().is_empty());
621    }
622
623    #[test]
624    fn test_pushgateway_push_text() {
625        let exporter = PushgatewayExporter::new(PushgatewayConfig::default());
626        exporter.push("metric1 1\n").unwrap();
627        exporter.push("metric2 2\n").unwrap();
628
629        assert_eq!(exporter.push_count(), 2);
630        let snaps = exporter.snapshots();
631        assert_eq!(snaps.len(), 2);
632        assert_eq!(snaps[0].metrics_text, "metric1 1\n");
633        assert_eq!(snaps[1].metrics_text, "metric2 2\n");
634    }
635
636    #[test]
637    fn test_pushgateway_push_from_registry() {
638        let registry = crate::MetricsRegistry::new();
639        let counter = registry.register_counter("test_total", "test");
640        counter.inc();
641
642        let exporter = PushgatewayExporter::new(PushgatewayConfig::default());
643        exporter.push_from_registry(&registry).unwrap();
644
645        assert_eq!(exporter.push_count(), 1);
646        let snap = &exporter.snapshots()[0];
647        assert!(snap.metrics_text.contains("test_total"));
648    }
649
650    #[test]
651    fn test_pushgateway_snapshot_has_metadata() {
652        let config = PushgatewayConfig {
653            endpoint: "http://push:9091".to_string(),
654            job: "myjob".to_string(),
655            instance: Some("inst1".to_string()),
656        };
657        let exporter = PushgatewayExporter::new(config);
658        exporter.push("m 1\n").unwrap();
659
660        let snap = &exporter.snapshots()[0];
661        assert_eq!(snap.job, "myjob");
662        assert_eq!(snap.instance, Some("inst1".to_string()));
663        assert!(snap.timestamp_ms > 0);
664    }
665
666    #[test]
667    fn test_pushgateway_clear() {
668        let exporter = PushgatewayExporter::new(PushgatewayConfig::default());
669        exporter.push("m 1\n").unwrap();
670        assert_eq!(exporter.push_count(), 1);
671
672        exporter.clear();
673        assert_eq!(exporter.push_count(), 0);
674    }
675
676    #[test]
677    fn test_pushgateway_config_access() {
678        let config = PushgatewayConfig {
679            job: "custom".to_string(),
680            ..Default::default()
681        };
682        let exporter = PushgatewayExporter::new(config);
683        assert_eq!(exporter.config().job, "custom");
684    }
685}