1use std::io::Write as _;
7use std::time::UNIX_EPOCH;
8
9use crate::model::metric::MetricEvent;
10use crate::{EncoderError, SondaError};
11
12use super::Encoder;
13
14pub struct PrometheusText {
33 precision: Option<u8>,
35}
36
37impl PrometheusText {
38 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
53fn 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 fn encode_metric(&self, event: &MetricEvent, buf: &mut Vec<u8>) -> Result<(), SondaError> {
74 buf.extend_from_slice(event.name.as_bytes());
76
77 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 buf.push(b' ');
96
97 super::write_value(buf, event.value, self.precision);
99
100 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 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 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 #[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 #[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 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 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 assert!(
189 output.starts_with("metric{env=\"prod\",host=\"t0-a1\",zone=\"eu1\"}"),
190 "unexpected output: {output:?}"
191 );
192 }
193
194 #[test]
197 fn regression_anchor_exact_byte_output_no_labels() {
198 let labels = Labels::from_pairs(&[]).unwrap();
199 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 #[test]
223 fn timestamp_is_integer_milliseconds_since_epoch() {
224 let labels = Labels::from_pairs(&[]).unwrap();
225 let event = make_event("up", 1.0, labels, 1500);
227 let output = encode_to_string(&event);
228 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 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 #[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 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 assert!(
298 output.contains(r#"msg="line1\nline2""#),
299 "newline not escaped: {output:?}"
300 );
301 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 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 #[test]
339 fn pre_epoch_timestamp_returns_encoder_error() {
340 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 #[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 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 #[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 #[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 #[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 #[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 #[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}