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///
308/// # 两种模式
309///
310/// - **内存模式(默认)**:`push` 仅记录到内存 `Vec`,不发起网络请求。用于单元测试。
311/// - **真实 HTTP PUT 模式**:启用 `push-gateway` feature 后,`push` 会向
312///   `config.endpoint` 发起 HTTP PUT 请求,推送指标文本到 Pushgateway。
313///
314/// # Feature 切换
315///
316/// ```toml
317/// [dependencies]
318/// sz-orm-observability = { version = "1.2", features = ["push-gateway"] }
319/// ```
320pub struct PushgatewayExporter {
321    config: PushgatewayConfig,
322    /// 已推送的指标文本快照(内存模式下的唯一存储,HTTP 模式下也保留用于审计)
323    pushed: RwLock<Vec<PushSnapshot>>,
324}
325
326/// 一次推送的快照
327#[derive(Debug, Clone)]
328pub struct PushSnapshot {
329    /// 推送时间戳(Unix 毫秒)
330    pub timestamp_ms: i64,
331    /// 推送的指标文本
332    pub metrics_text: String,
333    /// 作业名
334    pub job: String,
335    /// 实例名
336    pub instance: Option<String>,
337}
338
339impl PushgatewayExporter {
340    /// 创建新的 Pushgateway 导出器
341    pub fn new(config: PushgatewayConfig) -> Self {
342        Self {
343            config,
344            pushed: RwLock::new(Vec::new()),
345        }
346    }
347
348    /// 推送指标到 Pushgateway
349    ///
350    /// # 行为
351    ///
352    /// - **内存模式(默认)**:将指标文本记录到内存 `Vec`,不发起网络请求。
353    /// - **HTTP PUT 模式(`push-gateway` feature)**:向 `config.endpoint` 发起
354    ///   HTTP PUT 请求,URL 格式为 `{endpoint}/metrics/job/{job}[/instance/{instance}]`。
355    ///   请求体为 Prometheus exposition format 文本。
356    ///
357    /// # 错误
358    ///
359    /// HTTP 模式下,网络错误或非 2xx 响应码会返回 `Err`。
360    /// 内存模式永远返回 `Ok`。
361    pub fn push(&self, metrics_text: impl Into<String>) -> Result<(), String> {
362        let text = metrics_text.into();
363        let snapshot = PushSnapshot {
364            timestamp_ms: current_timestamp_ms(),
365            metrics_text: text.clone(),
366            job: self.config.job.clone(),
367            instance: self.config.instance.clone(),
368        };
369
370        // 记录到内存(两种模式都保留,用于审计/测试)
371        self.pushed.write().push(snapshot);
372
373        // 真实 HTTP PUT 推送(仅 push-gateway feature 启用时)
374        #[cfg(feature = "push-gateway")]
375        {
376            return self.push_http(&text);
377        }
378
379        // 内存模式:直接返回成功
380        #[cfg(not(feature = "push-gateway"))]
381        Ok(())
382    }
383
384    /// 真实 HTTP PUT 推送实现(仅 `push-gateway` feature 启用时编译)
385    #[cfg(feature = "push-gateway")]
386    fn push_http(&self, text: &str) -> Result<(), String> {
387        // 构造 Pushgateway URL: {endpoint}/metrics/job/{job}[/instance/{instance}]
388        let mut url = format!(
389            "{}/metrics/job/{}",
390            self.config.endpoint.trim_end_matches('/'),
391            url_encode(&self.config.job)
392        );
393        if let Some(ref instance) = self.config.instance {
394            url.push_str(&format!("/instance/{}", url_encode(instance)));
395        }
396
397        // 同步阻塞 HTTP PUT(Pushgateway 推送通常是低频操作)
398        // 使用 blocking client 避免要求调用方在 tokio runtime 内
399        let client = reqwest::blocking::Client::builder()
400            .timeout(std::time::Duration::from_secs(10))
401            .build()
402            .map_err(|e| format!("reqwest client build failed: {}", e))?;
403
404        let resp = client
405            .put(&url)
406            .header("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
407            .body(text.to_string())
408            .send()
409            .map_err(|e| format!("push to {} failed: {}", url, e))?;
410
411        let status = resp.status();
412        if status.is_success() {
413            Ok(())
414        } else {
415            let body = resp.text().unwrap_or_default();
416            Err(format!(
417                "push to {} returned non-2xx status {}: {}",
418                url, status, body
419            ))
420        }
421    }
422
423    /// 从 MetricsRegistry 渲染并推送
424    pub fn push_from_registry(&self, registry: &crate::MetricsRegistry) -> Result<(), String> {
425        let text = registry.render();
426        self.push(text)
427    }
428
429    /// 获取推送历史快照
430    pub fn snapshots(&self) -> Vec<PushSnapshot> {
431        self.pushed.read().clone()
432    }
433
434    /// 获取推送次数
435    pub fn push_count(&self) -> usize {
436        self.pushed.read().len()
437    }
438
439    /// 清空推送历史
440    pub fn clear(&self) {
441        self.pushed.write().clear();
442    }
443
444    /// 获取配置引用
445    pub fn config(&self) -> &PushgatewayConfig {
446        &self.config
447    }
448}
449
450fn current_timestamp_ms() -> i64 {
451    use std::time::{SystemTime, UNIX_EPOCH};
452    SystemTime::now()
453        .duration_since(UNIX_EPOCH)
454        .unwrap_or_default()
455        .as_millis() as i64
456}
457
458/// URL 路径段编码(用于 Pushgateway URL 中的 job/instance)
459///
460/// 将非字母数字字符编码为 `%XX` 格式,避免特殊字符破坏 URL 结构。
461#[allow(dead_code)]
462fn url_encode(s: &str) -> String {
463    let mut out = String::with_capacity(s.len());
464    for byte in s.bytes() {
465        match byte {
466            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
467                out.push(byte as char);
468            }
469            _ => {
470                out.push_str(&format!("%{:02X}", byte));
471            }
472        }
473    }
474    out
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480
481    // ===================== url_encode 测试(P2-3 回归) =====================
482
483    #[test]
484    fn test_url_encode_alphanumeric() {
485        assert_eq!(url_encode("job1"), "job1");
486        assert_eq!(url_encode("my-job"), "my-job");
487        assert_eq!(url_encode("job.test"), "job.test");
488        assert_eq!(url_encode("job_test"), "job_test");
489        assert_eq!(url_encode("job~test"), "job~test");
490    }
491
492    #[test]
493    fn test_url_encode_special_chars() {
494        // 空格 → %20
495        assert_eq!(url_encode("my job"), "my%20job");
496        // 斜杠 → %2F
497        assert_eq!(url_encode("a/b"), "a%2Fb");
498        // 中文字符 → 多字节 %XX
499        assert_eq!(url_encode("任务"), "%E4%BB%BB%E5%8A%A1");
500    }
501
502    #[test]
503    fn test_url_encode_empty() {
504        assert_eq!(url_encode(""), "");
505    }
506
507    // ===================== Pushgateway 内存模式测试 =====================
508
509    #[test]
510    fn test_pushgateway_memory_mode_records_snapshot() {
511        // 内存模式下 push 应记录快照,不发起网络请求
512        let exporter = PushgatewayExporter::new(PushgatewayConfig::default());
513        let result = exporter.push("# HELP test_metric\n");
514        assert!(result.is_ok());
515        assert_eq!(exporter.push_count(), 1);
516        let snap = &exporter.snapshots()[0];
517        assert_eq!(snap.metrics_text, "# HELP test_metric\n");
518        assert_eq!(snap.job, "sz-orm");
519    }
520
521    // ===================== Summary 测试 =====================
522
523    #[test]
524    fn test_summary_new_empty() {
525        let s = Summary::new("latency", "latency summary", vec![0.5, 0.9, 0.99]);
526        assert_eq!(s.count(), 0);
527        assert_eq!(s.sum(), 0.0);
528        assert!(s.quantile(0.5).is_none());
529    }
530
531    #[test]
532    fn test_summary_observe_single() {
533        let s = Summary::new("latency", "help", vec![0.5]);
534        s.observe(1.5);
535        assert_eq!(s.count(), 1);
536        assert!((s.sum() - 1.5).abs() < 1e-9);
537        assert!((s.quantile(0.5).unwrap() - 1.5).abs() < 1e-9);
538    }
539
540    #[test]
541    fn test_summary_observe_multiple_p50() {
542        let s = Summary::new("latency", "help", vec![0.5]);
543        for v in [1.0, 2.0, 3.0, 4.0, 5.0] {
544            s.observe(v);
545        }
546        // p50 of [1,2,3,4,5] -> rank=ceil(0.5*5)=3 -> samples[2]=3.0
547        assert!((s.quantile(0.5).unwrap() - 3.0).abs() < 1e-9);
548    }
549
550    #[test]
551    fn test_summary_observe_multiple_p99() {
552        let s = Summary::new("latency", "help", vec![0.99]);
553        for v in [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 100.0] {
554            s.observe(v);
555        }
556        // p99 of 10 samples -> rank=ceil(0.99*10)=10 -> samples[9]=100.0
557        assert!((s.quantile(0.99).unwrap() - 100.0).abs() < 1e-9);
558    }
559
560    #[test]
561    fn test_summary_quantile_out_of_range() {
562        let s = Summary::new("latency", "help", vec![0.5]);
563        s.observe(1.0);
564        assert!(s.quantile(-0.1).is_none());
565        assert!(s.quantile(1.1).is_none());
566    }
567
568    #[test]
569    fn test_summary_quantile_empty() {
570        let s = Summary::new("latency", "help", vec![0.5]);
571        assert!(s.quantile(0.5).is_none());
572    }
573
574    #[test]
575    fn test_summary_quantile_p0_and_p1() {
576        let s = Summary::new("latency", "help", vec![]);
577        for v in [10.0, 20.0, 30.0] {
578            s.observe(v);
579        }
580        // p0 -> rank=ceil(0*3)=0 -> max(0,1)=1 -> samples[0]=10
581        assert!((s.quantile(0.0).unwrap() - 10.0).abs() < 1e-9);
582        // p1 -> rank=ceil(1*3)=3 -> samples[2]=30
583        assert!((s.quantile(1.0).unwrap() - 30.0).abs() < 1e-9);
584    }
585
586    #[test]
587    fn test_summary_quantiles_all() {
588        let s = Summary::new("latency", "help", vec![0.5, 0.9, 0.99]);
589        for v in [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0] {
590            s.observe(v);
591        }
592        let qs = s.quantiles();
593        assert_eq!(qs.len(), 3);
594        assert!(qs.iter().all(|(_, v)| v.is_some()));
595
596        // 验证分位数值的合理性:p50 应在 5-6 之间,p90 应在 9-10 之间,p99 应为 10
597        let qmap: Vec<(f64, f64)> = qs
598            .iter()
599            .filter_map(|(q, v)| v.map(|val| (*q, val)))
600            .collect();
601        let lookup = |target: f64| -> f64 {
602            qmap.iter()
603                .find(|(q, _)| (*q - target).abs() < 1e-9)
604                .map(|(_, v)| *v)
605                .unwrap_or(f64::NAN)
606        };
607
608        let p50 = lookup(0.5);
609        assert!(
610            (5.0..=6.0).contains(&p50),
611            "p50 应在 5-6 之间,实际: {}",
612            p50
613        );
614
615        let p90 = lookup(0.9);
616        assert!(
617            (9.0..=10.0).contains(&p90),
618            "p90 应在 9-10 之间,实际: {}",
619            p90
620        );
621
622        let p99 = lookup(0.99);
623        assert!(
624            (9.0..=10.0).contains(&p99),
625            "p99 应在 9-10 之间,实际: {}",
626            p99
627        );
628    }
629
630    #[test]
631    fn test_summary_unsorted_input_stays_sorted() {
632        let s = Summary::new("latency", "help", vec![0.5]);
633        s.observe(50.0);
634        s.observe(10.0);
635        s.observe(30.0);
636        // p50 of sorted [10,30,50] -> rank=ceil(0.5*3)=2 -> samples[1]=30
637        assert!((s.quantile(0.5).unwrap() - 30.0).abs() < 1e-9);
638    }
639
640    #[test]
641    fn test_summary_render_contains_type() {
642        let s = Summary::new("latency", "latency help", vec![0.5, 0.99]);
643        s.observe(1.0);
644        let output = s.render();
645        assert!(output.contains("# HELP latency latency help"));
646        assert!(output.contains("# TYPE latency summary"));
647        assert!(output.contains("latency{quantile=\"0.5\"}"));
648        assert!(output.contains("latency{quantile=\"0.99\"}"));
649        assert!(output.contains("latency_sum"));
650        assert!(output.contains("latency_count"));
651    }
652
653    #[test]
654    fn test_summary_render_empty_shows_zero() {
655        let s = Summary::new("latency", "help", vec![0.5]);
656        let output = s.render();
657        // 空样本时分位数值为 0
658        assert!(output.contains("latency{quantile=\"0.5\"} 0"));
659        assert!(output.contains("latency_count 0"));
660    }
661
662    #[test]
663    fn test_summary_reset() {
664        let s = Summary::new("latency", "help", vec![0.5]);
665        s.observe(1.0);
666        s.observe(2.0);
667        assert_eq!(s.count(), 2);
668
669        s.reset();
670        assert_eq!(s.count(), 0);
671        assert!((s.sum() - 0.0).abs() < 1e-9);
672        assert!(s.quantile(0.5).is_none());
673    }
674
675    #[test]
676    fn test_summary_name() {
677        let s = Summary::new("my_metric", "help", vec![0.5]);
678        assert_eq!(s.name(), "my_metric");
679    }
680
681    // ===================== LabeledHistogram 测试 =====================
682
683    #[test]
684    fn test_labeled_histogram_new() {
685        let h = LabeledHistogram::new("requests", "help", vec![0.1, 0.5, 1.0]);
686        assert_eq!(h.label_combination_count(), 0);
687    }
688
689    #[test]
690    fn test_labeled_histogram_observe_single_label() {
691        let h = LabeledHistogram::new("requests", "help", vec![0.1, 0.5, 1.0]);
692        let mut labels = HashMap::new();
693        labels.insert("method".to_string(), "GET".to_string());
694
695        h.observe(&labels, 0.3);
696        assert_eq!(h.count(&labels), 1);
697        assert_eq!(h.label_combination_count(), 1);
698    }
699
700    #[test]
701    fn test_labeled_histogram_observe_multiple_labels() {
702        let h = LabeledHistogram::new("requests", "help", vec![0.1, 0.5, 1.0]);
703
704        let mut get_labels = HashMap::new();
705        get_labels.insert("method".to_string(), "GET".to_string());
706
707        let mut post_labels = HashMap::new();
708        post_labels.insert("method".to_string(), "POST".to_string());
709
710        h.observe(&get_labels, 0.1);
711        h.observe(&get_labels, 0.2);
712        h.observe(&post_labels, 0.5);
713
714        assert_eq!(h.count(&get_labels), 2);
715        assert_eq!(h.count(&post_labels), 1);
716        assert_eq!(h.label_combination_count(), 2);
717    }
718
719    #[test]
720    fn test_labeled_histogram_label_order_independent() {
721        let h = LabeledHistogram::new("requests", "help", vec![0.1, 1.0]);
722
723        let mut labels1 = HashMap::new();
724        labels1.insert("a".to_string(), "1".to_string());
725        labels1.insert("b".to_string(), "2".to_string());
726
727        let mut labels2 = HashMap::new();
728        labels2.insert("b".to_string(), "2".to_string());
729        labels2.insert("a".to_string(), "1".to_string());
730
731        h.observe(&labels1, 0.5);
732        // 标签顺序不同但键值对相同,应归入同一时间序列
733        assert_eq!(h.count(&labels2), 1);
734        assert_eq!(h.label_combination_count(), 1);
735    }
736
737    #[test]
738    fn test_labeled_histogram_count_missing_labels() {
739        let h = LabeledHistogram::new("requests", "help", vec![0.1, 1.0]);
740        let labels = HashMap::new();
741        assert_eq!(h.count(&labels), 0);
742    }
743
744    #[test]
745    fn test_labeled_histogram_render_contains_labels() {
746        let h = LabeledHistogram::new("requests", "request help", vec![0.1, 1.0]);
747        let mut labels = HashMap::new();
748        labels.insert("method".to_string(), "GET".to_string());
749        h.observe(&labels, 0.05);
750
751        let output = h.render();
752        assert!(output.contains("# HELP requests request help"));
753        assert!(output.contains("# TYPE requests histogram"));
754        assert!(output.contains("method=\"GET\""));
755        assert!(output.contains("requests_count"));
756        assert!(output.contains("requests_sum"));
757    }
758
759    #[test]
760    fn test_labeled_histogram_render_inf_bucket() {
761        let h = LabeledHistogram::new("req", "help", vec![0.1]);
762        let labels = HashMap::new();
763        h.observe(&labels, 0.05);
764        h.observe(&labels, 5.0);
765        let output = h.render();
766        assert!(output.contains("le=\"+Inf\""));
767    }
768
769    // ===================== PushgatewayExporter 测试 =====================
770
771    #[test]
772    fn test_pushgateway_config_default() {
773        let config = PushgatewayConfig::default();
774        assert_eq!(config.endpoint, "http://localhost:9091");
775        assert_eq!(config.job, "sz-orm");
776        assert!(config.instance.is_none());
777    }
778
779    #[test]
780    fn test_pushgateway_exporter_new() {
781        let exporter = PushgatewayExporter::new(PushgatewayConfig::default());
782        assert_eq!(exporter.push_count(), 0);
783        assert!(exporter.snapshots().is_empty());
784    }
785
786    #[test]
787    fn test_pushgateway_push_text() {
788        let exporter = PushgatewayExporter::new(PushgatewayConfig::default());
789        exporter.push("metric1 1\n").unwrap();
790        exporter.push("metric2 2\n").unwrap();
791
792        assert_eq!(exporter.push_count(), 2);
793        let snaps = exporter.snapshots();
794        assert_eq!(snaps.len(), 2);
795        assert_eq!(snaps[0].metrics_text, "metric1 1\n");
796        assert_eq!(snaps[1].metrics_text, "metric2 2\n");
797    }
798
799    #[test]
800    fn test_pushgateway_push_from_registry() {
801        let registry = crate::MetricsRegistry::new();
802        let counter = registry.register_counter("test_total", "test");
803        counter.inc();
804
805        let exporter = PushgatewayExporter::new(PushgatewayConfig::default());
806        exporter.push_from_registry(&registry).unwrap();
807
808        assert_eq!(exporter.push_count(), 1);
809        let snap = &exporter.snapshots()[0];
810        assert!(snap.metrics_text.contains("test_total"));
811    }
812
813    #[test]
814    fn test_pushgateway_snapshot_has_metadata() {
815        let config = PushgatewayConfig {
816            endpoint: "http://push:9091".to_string(),
817            job: "myjob".to_string(),
818            instance: Some("inst1".to_string()),
819        };
820        let exporter = PushgatewayExporter::new(config);
821        exporter.push("m 1\n").unwrap();
822
823        let snap = &exporter.snapshots()[0];
824        assert_eq!(snap.job, "myjob");
825        assert_eq!(snap.instance, Some("inst1".to_string()));
826        assert!(snap.timestamp_ms > 0);
827    }
828
829    #[test]
830    fn test_pushgateway_clear() {
831        let exporter = PushgatewayExporter::new(PushgatewayConfig::default());
832        exporter.push("m 1\n").unwrap();
833        assert_eq!(exporter.push_count(), 1);
834
835        exporter.clear();
836        assert_eq!(exporter.push_count(), 0);
837    }
838
839    #[test]
840    fn test_pushgateway_config_access() {
841        let config = PushgatewayConfig {
842            job: "custom".to_string(),
843            ..Default::default()
844        };
845        let exporter = PushgatewayExporter::new(config);
846        assert_eq!(exporter.config().job, "custom");
847    }
848}