Skip to main content

sonda_core/encoder/
prometheus.rs

1//! Prometheus text exposition format encoder.
2//!
3//! Implements the Prometheus text format version 0.0.4.
4//! Reference: <https://prometheus.io/docs/instrumenting/exposition_formats/>
5
6use std::io::Write as _;
7use std::time::UNIX_EPOCH;
8
9use crate::model::metric::MetricEvent;
10use crate::{EncoderError, SondaError};
11
12use super::Encoder;
13
14/// Encodes [`MetricEvent`]s into Prometheus text exposition format (version 0.0.4).
15///
16/// Output format for a metric with labels:
17/// ```text
18/// metric_name{label1="val1",label2="val2"} value timestamp_ms\n
19/// ```
20///
21/// Output format for a metric with no labels:
22/// ```text
23/// metric_name value timestamp_ms\n
24/// ```
25///
26/// The timestamp is in milliseconds since the Unix epoch (integer).
27///
28/// Label values are escaped: `\` → `\\`, `"` → `\"`, newline → `\n`.
29///
30/// When `precision` is set, metric values are formatted to the specified number
31/// of decimal places (e.g., precision=2 formats `99.60573` as `99.61`).
32pub struct PrometheusText {
33    /// Optional decimal precision for metric values.
34    precision: Option<u8>,
35}
36
37impl PrometheusText {
38    /// Create a new `PrometheusText` encoder.
39    ///
40    /// `precision` optionally limits the number of decimal places in metric values.
41    /// `None` preserves full `f64` precision (default behavior).
42    pub fn new(precision: Option<u8>) -> Self {
43        Self { precision }
44    }
45}
46
47impl Default for PrometheusText {
48    fn default() -> Self {
49        Self::new(None)
50    }
51}
52
53/// Escape a label value per Prometheus exposition format rules.
54///
55/// Escapes: `\` → `\\`, `"` → `\"`, newline (`\n`) → literal `\n` (two characters).
56fn escape_label_value(value: &str, buf: &mut Vec<u8>) {
57    for byte in value.bytes() {
58        match byte {
59            b'\\' => buf.extend_from_slice(b"\\\\"),
60            b'"' => buf.extend_from_slice(b"\\\""),
61            b'\n' => buf.extend_from_slice(b"\\n"),
62            other => buf.push(other),
63        }
64    }
65}
66
67impl Encoder for PrometheusText {
68    /// Encode a metric event into Prometheus text exposition format.
69    ///
70    /// Writes the formatted line into `buf`. Bytes are appended; the buffer is not
71    /// cleared before writing. Writes into the caller-provided buffer without
72    /// additional heap allocations.
73    fn encode_metric(&self, event: &MetricEvent, buf: &mut Vec<u8>) -> Result<(), SondaError> {
74        // Metric name
75        buf.extend_from_slice(event.name.as_bytes());
76
77        // Labels (only if non-empty)
78        if !event.labels.is_empty() {
79            buf.push(b'{');
80            let mut first = true;
81            for (key, value) in event.labels.iter() {
82                if !first {
83                    buf.push(b',');
84                }
85                first = false;
86                buf.extend_from_slice(key.as_bytes());
87                buf.extend_from_slice(b"=\"");
88                escape_label_value(value, buf);
89                buf.push(b'"');
90            }
91            buf.push(b'}');
92        }
93
94        // Space before value
95        buf.push(b' ');
96
97        // Value: write f64, optionally with fixed decimal precision
98        super::write_value(buf, event.value, self.precision);
99
100        // Timestamp in milliseconds since epoch
101        let timestamp_ms = event
102            .timestamp
103            .duration_since(UNIX_EPOCH)
104            .map_err(|e| SondaError::Encoder(EncoderError::TimestampBeforeEpoch(e)))?
105            .as_millis();
106
107        buf.push(b' ');
108        write!(buf, "{timestamp_ms}").expect("write to Vec<u8> is infallible");
109
110        buf.push(b'\n');
111
112        Ok(())
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::model::metric::{Labels, MetricEvent};
120    use std::time::{Duration, UNIX_EPOCH};
121
122    /// Helper: build a MetricEvent with a fixed timestamp for deterministic tests.
123    fn make_event(name: &str, value: f64, labels: Labels, timestamp_ms: u64) -> MetricEvent {
124        let ts = UNIX_EPOCH + Duration::from_millis(timestamp_ms);
125        MetricEvent::with_timestamp(name.to_string(), value, labels, ts).unwrap()
126    }
127
128    /// Helper: encode one event and return the result as a UTF-8 String.
129    fn encode_to_string(event: &MetricEvent) -> String {
130        let enc = PrometheusText::new(None);
131        let mut buf = Vec::new();
132        enc.encode_metric(event, &mut buf).unwrap();
133        String::from_utf8(buf).unwrap()
134    }
135
136    // --- Happy path: no labels ---
137
138    #[test]
139    fn no_labels_omits_braces() {
140        let labels = Labels::from_pairs(&[]).unwrap();
141        let event = make_event("up", 1.0, labels, 1_000_000);
142        let output = encode_to_string(&event);
143        assert_eq!(output, "up 1 1000000\n");
144    }
145
146    #[test]
147    fn no_labels_format_has_no_curly_braces() {
148        let labels = Labels::from_pairs(&[]).unwrap();
149        let event = make_event("requests_total", 42.0, labels, 0);
150        let output = encode_to_string(&event);
151        assert!(
152            !output.contains('{'),
153            "output should not contain braces: {output:?}"
154        );
155        assert!(
156            !output.contains('}'),
157            "output should not contain braces: {output:?}"
158        );
159    }
160
161    // --- Happy path: labels present ---
162
163    #[test]
164    fn single_label_produces_correct_format() {
165        let labels = Labels::from_pairs(&[("host", "server1")]).unwrap();
166        let event = make_event("up", 1.0, labels, 1_000_000);
167        let output = encode_to_string(&event);
168        assert_eq!(output, "up{host=\"server1\"} 1 1000000\n");
169    }
170
171    #[test]
172    fn two_labels_sorted_by_key_comma_separated() {
173        // Insert in reverse alphabetical order — BTreeMap must sort them.
174        let labels = Labels::from_pairs(&[("zone", "eu1"), ("host", "server1")]).unwrap();
175        let event = make_event("up", 1.0, labels, 1_000_000);
176        let output = encode_to_string(&event);
177        // "host" < "zone" alphabetically
178        assert_eq!(output, "up{host=\"server1\",zone=\"eu1\"} 1 1000000\n");
179    }
180
181    #[test]
182    fn labels_are_always_sorted_by_key() {
183        let labels =
184            Labels::from_pairs(&[("zone", "eu1"), ("env", "prod"), ("host", "t0-a1")]).unwrap();
185        let event = make_event("metric", 0.0, labels, 0);
186        let output = encode_to_string(&event);
187        // env < host < zone
188        assert!(
189            output.starts_with("metric{env=\"prod\",host=\"t0-a1\",zone=\"eu1\"}"),
190            "unexpected output: {output:?}"
191        );
192    }
193
194    // --- Regression anchor: hardcoded expected bytes ---
195
196    #[test]
197    fn regression_anchor_exact_byte_output_no_labels() {
198        let labels = Labels::from_pairs(&[]).unwrap();
199        // Timestamp: exactly 1_700_000_000_000 ms (i.e. 1_700_000_000 seconds since epoch)
200        let event = make_event("http_requests_total", 123.456, labels, 1_700_000_000_000);
201        let enc = PrometheusText::new(None);
202        let mut buf = Vec::new();
203        enc.encode_metric(&event, &mut buf).unwrap();
204        assert_eq!(buf, b"http_requests_total 123.456 1700000000000\n");
205    }
206
207    #[test]
208    fn regression_anchor_exact_byte_output_with_labels() {
209        let labels = Labels::from_pairs(&[("hostname", "t0-a1"), ("zone", "eu1")]).unwrap();
210        let event = make_event("interface_oper_state", 1.0, labels, 1_700_000_000_000);
211        let enc = PrometheusText::new(None);
212        let mut buf = Vec::new();
213        enc.encode_metric(&event, &mut buf).unwrap();
214        assert_eq!(
215            buf,
216            b"interface_oper_state{hostname=\"t0-a1\",zone=\"eu1\"} 1 1700000000000\n"
217        );
218    }
219
220    // --- Timestamp is milliseconds since epoch (integer, not float) ---
221
222    #[test]
223    fn timestamp_is_integer_milliseconds_since_epoch() {
224        let labels = Labels::from_pairs(&[]).unwrap();
225        // 1500 ms = 1.5 seconds since epoch
226        let event = make_event("up", 1.0, labels, 1500);
227        let output = encode_to_string(&event);
228        // Must end with "1 1500\n" — timestamp is an integer
229        assert!(
230            output.ends_with(" 1500\n"),
231            "timestamp should be integer ms: {output:?}"
232        );
233    }
234
235    #[test]
236    fn timestamp_at_epoch_zero_is_zero() {
237        let labels = Labels::from_pairs(&[]).unwrap();
238        let event = make_event("up", 1.0, labels, 0);
239        let output = encode_to_string(&event);
240        assert!(
241            output.ends_with(" 0\n"),
242            "timestamp at epoch should be 0: {output:?}"
243        );
244    }
245
246    #[test]
247    fn timestamp_does_not_include_decimal_point() {
248        let labels = Labels::from_pairs(&[]).unwrap();
249        let event = make_event("up", 1.0, labels, 1_234_567_890_123);
250        let output = encode_to_string(&event);
251        // Extract the timestamp portion (last token before newline)
252        let ts_str = output
253            .trim_end_matches('\n')
254            .split_whitespace()
255            .last()
256            .unwrap();
257        assert!(
258            !ts_str.contains('.'),
259            "timestamp must not contain decimal point: {ts_str:?}"
260        );
261    }
262
263    // --- Label value escaping ---
264
265    #[test]
266    fn label_value_with_double_quote_is_escaped() {
267        let labels = Labels::from_pairs(&[("label", "say \"hi\"")]).unwrap();
268        let event = make_event("metric", 1.0, labels, 0);
269        let output = encode_to_string(&event);
270        assert!(
271            output.contains(r#"label="say \"hi\"""#),
272            "double quote not escaped: {output:?}"
273        );
274    }
275
276    #[test]
277    fn label_value_with_backslash_is_escaped() {
278        let labels = Labels::from_pairs(&[("path", r"C:\Users\bob")]).unwrap();
279        let event = make_event("metric", 1.0, labels, 0);
280        let output = encode_to_string(&event);
281        // C:\Users\bob should become C:\\Users\\bob in the output
282        assert!(
283            output.contains(r#"path="C:\\Users\\bob""#),
284            "backslash not escaped: {output:?}"
285        );
286    }
287
288    #[test]
289    fn label_value_with_newline_is_escaped() {
290        let labels = Labels::from_pairs(&[("msg", "line1\nline2")]).unwrap();
291        let event = make_event("metric", 1.0, labels, 0);
292        let enc = PrometheusText::new(None);
293        let mut buf = Vec::new();
294        enc.encode_metric(&event, &mut buf).unwrap();
295        let output = String::from_utf8(buf).unwrap();
296        // The literal newline inside the label value must be rendered as \n (two chars)
297        assert!(
298            output.contains(r#"msg="line1\nline2""#),
299            "newline not escaped: {output:?}"
300        );
301        // The encoded line itself should have exactly one newline — the trailing one.
302        assert_eq!(
303            output.chars().filter(|&c| c == '\n').count(),
304            1,
305            "should have exactly one newline (the trailing one): {output:?}"
306        );
307    }
308
309    #[test]
310    fn label_value_with_all_three_escape_sequences() {
311        // backslash, double-quote, newline all in one value
312        let value = "a\\b\"c\nd";
313        let labels = Labels::from_pairs(&[("v", value)]).unwrap();
314        let event = make_event("metric", 1.0, labels, 0);
315        let enc = PrometheusText::new(None);
316        let mut buf = Vec::new();
317        enc.encode_metric(&event, &mut buf).unwrap();
318        let output = String::from_utf8(buf).unwrap();
319        assert!(
320            output.contains(r#"v="a\\b\"c\nd""#),
321            "combined escaping incorrect: {output:?}"
322        );
323    }
324
325    #[test]
326    fn label_value_with_no_special_chars_is_not_escaped() {
327        let labels = Labels::from_pairs(&[("env", "production")]).unwrap();
328        let event = make_event("metric", 1.0, labels, 0);
329        let output = encode_to_string(&event);
330        assert!(
331            output.contains(r#"env="production""#),
332            "plain value unexpectedly altered: {output:?}"
333        );
334    }
335
336    // --- Pre-epoch timestamp error ---
337
338    #[test]
339    fn pre_epoch_timestamp_returns_encoder_error() {
340        // SystemTime::UNIX_EPOCH - 1 second is before epoch
341        let before_epoch = UNIX_EPOCH - Duration::from_secs(1);
342        let labels = Labels::from_pairs(&[]).unwrap();
343        let event =
344            MetricEvent::with_timestamp("up".to_string(), 1.0, labels, before_epoch).unwrap();
345        let enc = PrometheusText::new(None);
346        let mut buf = Vec::new();
347        let result = enc.encode_metric(&event, &mut buf);
348        assert!(
349            matches!(result, Err(SondaError::Encoder(_))),
350            "expected Encoder error for pre-epoch timestamp, got: {result:?}"
351        );
352    }
353
354    // --- Buffer appending behaviour ---
355
356    #[test]
357    fn encode_appends_to_existing_buffer_content() {
358        let labels = Labels::from_pairs(&[]).unwrap();
359        let event = make_event("up", 1.0, labels, 0);
360        let enc = PrometheusText::new(None);
361        let mut buf = b"existing_content\n".to_vec();
362        enc.encode_metric(&event, &mut buf).unwrap();
363        let output = String::from_utf8(buf).unwrap();
364        assert!(
365            output.starts_with("existing_content\n"),
366            "encoder must append, not overwrite: {output:?}"
367        );
368        assert!(
369            output.ends_with("up 1 0\n"),
370            "appended content missing: {output:?}"
371        );
372    }
373
374    #[test]
375    fn encode_does_not_reallocate_when_buffer_pre_sized() {
376        let labels = Labels::from_pairs(&[]).unwrap();
377        let event = make_event("up", 1.0, labels, 0);
378        let enc = PrometheusText::new(None);
379        // Pre-allocate well beyond what a single line needs
380        let mut buf = Vec::with_capacity(1024);
381        let ptr_before = buf.as_ptr();
382        enc.encode_metric(&event, &mut buf).unwrap();
383        let ptr_after = buf.as_ptr();
384        assert_eq!(
385            ptr_before, ptr_after,
386            "buffer reallocated during encode — pointer changed"
387        );
388    }
389
390    // --- Output ends with newline ---
391
392    #[test]
393    fn output_ends_with_newline() {
394        let labels = Labels::from_pairs(&[("k", "v")]).unwrap();
395        let event = make_event("metric", 3.14, labels, 999);
396        let output = encode_to_string(&event);
397        assert!(
398            output.ends_with('\n'),
399            "output must end with newline: {output:?}"
400        );
401    }
402
403    // --- Send + Sync contract ---
404
405    #[test]
406    fn prometheus_text_encoder_is_send_and_sync() {
407        fn assert_send_sync<T: Send + Sync>() {}
408        assert_send_sync::<PrometheusText>();
409    }
410
411    // --- Factory and EncoderConfig ---
412
413    #[test]
414    fn create_encoder_returns_working_encoder_for_prometheus_text() {
415        use crate::encoder::{create_encoder, EncoderConfig};
416        let enc = create_encoder(&EncoderConfig::PrometheusText { precision: None }).unwrap();
417        let labels = Labels::from_pairs(&[]).unwrap();
418        let event = make_event("up", 1.0, labels, 1_000_000);
419        let mut buf = Vec::new();
420        enc.encode_metric(&event, &mut buf).unwrap();
421        let output = String::from_utf8(buf).unwrap();
422        assert_eq!(output, "up 1 1000000\n");
423    }
424
425    #[cfg(feature = "config")]
426    #[test]
427    fn encoder_config_deserialization_prometheus_text() {
428        use crate::encoder::EncoderConfig;
429        let config: EncoderConfig = serde_yaml_ng::from_str("type: prometheus_text").unwrap();
430        assert!(matches!(config, EncoderConfig::PrometheusText { .. }));
431    }
432
433    // --- Precision: None preserves full output ---
434
435    #[test]
436    fn precision_none_preserves_full_output() {
437        let enc = PrometheusText::new(None);
438        let labels = Labels::from_pairs(&[]).unwrap();
439        let event = make_event("cpu", 99.60573506572389, labels, 1_000_000);
440        let mut buf = Vec::new();
441        enc.encode_metric(&event, &mut buf).unwrap();
442        let output = String::from_utf8(buf).unwrap();
443        assert!(
444            output.starts_with("cpu 99.60573506572389 "),
445            "full precision must be preserved: {output:?}"
446        );
447    }
448
449    // --- Precision: 2 limits decimal places ---
450
451    #[test]
452    fn precision_two_limits_decimals() {
453        let enc = PrometheusText::new(Some(2));
454        let labels = Labels::from_pairs(&[]).unwrap();
455        let event = make_event("cpu", 99.60573, labels, 1_000_000);
456        let mut buf = Vec::new();
457        enc.encode_metric(&event, &mut buf).unwrap();
458        let output = String::from_utf8(buf).unwrap();
459        assert_eq!(output, "cpu 99.61 1000000\n");
460    }
461
462    #[test]
463    fn precision_zero_rounds_to_integer() {
464        let enc = PrometheusText::new(Some(0));
465        let labels = Labels::from_pairs(&[]).unwrap();
466        let event = make_event("up", 99.6, labels, 0);
467        let mut buf = Vec::new();
468        enc.encode_metric(&event, &mut buf).unwrap();
469        let output = String::from_utf8(buf).unwrap();
470        assert_eq!(output, "up 100 0\n");
471    }
472
473    #[test]
474    fn precision_two_preserves_trailing_zeros() {
475        let enc = PrometheusText::new(Some(2));
476        let labels = Labels::from_pairs(&[]).unwrap();
477        let event = make_event("up", 1.0, labels, 0);
478        let mut buf = Vec::new();
479        enc.encode_metric(&event, &mut buf).unwrap();
480        let output = String::from_utf8(buf).unwrap();
481        assert_eq!(output, "up 1.00 0\n");
482    }
483}