use std::collections::BTreeMap;
use serde::ser::SerializeMap;
use serde::Serialize;
use crate::model::log::LogEvent;
use crate::model::metric::{Labels, MetricEvent};
use crate::{EncoderError, SondaError};
use super::Encoder;
pub struct JsonLines {
precision: Option<u8>,
}
impl JsonLines {
pub fn new(precision: Option<u8>) -> Self {
Self { precision }
}
}
impl Default for JsonLines {
fn default() -> Self {
Self::new(None)
}
}
struct LabelsRef<'a>(&'a Labels);
impl<'a> Serialize for LabelsRef<'a> {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(Some(self.0.len()))?;
for (k, v) in self.0.iter() {
map.serialize_entry(k, v)?;
}
map.end()
}
}
struct StringMapRef<'a>(&'a BTreeMap<String, String>);
impl<'a> Serialize for StringMapRef<'a> {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(Some(self.0.len()))?;
for (k, v) in self.0.iter() {
map.serialize_entry(k.as_str(), v.as_str())?;
}
map.end()
}
}
#[derive(Serialize)]
struct JsonMetric<'a> {
name: &'a str,
value: f64,
labels: LabelsRef<'a>,
timestamp: &'a str,
}
#[derive(Serialize)]
struct JsonLog<'a> {
timestamp: &'a str,
severity: &'a str,
message: &'a str,
labels: LabelsRef<'a>,
fields: StringMapRef<'a>,
}
impl Encoder for JsonLines {
fn encode_metric(&self, event: &MetricEvent, buf: &mut Vec<u8>) -> Result<(), SondaError> {
let ts_bytes = super::format_rfc3339_millis_array(event.timestamp)?;
let timestamp =
std::str::from_utf8(&ts_bytes).expect("RFC 3339 timestamp is always valid UTF-8");
let value = match self.precision {
None => event.value,
Some(n) => {
let factor = 10f64.powi(n as i32);
(event.value * factor).round() / factor
}
};
let record = JsonMetric {
name: &event.name,
value,
labels: LabelsRef(&event.labels),
timestamp,
};
serde_json::to_writer(&mut *buf, &record)
.map_err(|e| SondaError::Encoder(EncoderError::SerializationFailed(e)))?;
buf.push(b'\n');
Ok(())
}
fn encode_log(&self, event: &LogEvent, buf: &mut Vec<u8>) -> Result<(), SondaError> {
let ts_bytes = super::format_rfc3339_millis_array(event.timestamp)?;
let timestamp =
std::str::from_utf8(&ts_bytes).expect("RFC 3339 timestamp is always valid UTF-8");
let severity_str = match event.severity {
crate::model::log::Severity::Trace => "trace",
crate::model::log::Severity::Debug => "debug",
crate::model::log::Severity::Info => "info",
crate::model::log::Severity::Warn => "warn",
crate::model::log::Severity::Error => "error",
crate::model::log::Severity::Fatal => "fatal",
};
let record = JsonLog {
timestamp,
severity: severity_str,
message: &event.message,
labels: LabelsRef(&event.labels),
fields: StringMapRef(&event.fields),
};
serde_json::to_writer(&mut *buf, &record)
.map_err(|e| SondaError::Encoder(EncoderError::SerializationFailed(e)))?;
buf.push(b'\n');
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::metric::{Labels, MetricEvent};
use std::time::{Duration, UNIX_EPOCH};
fn make_event(
name: &str,
value: f64,
labels: &[(&str, &str)],
timestamp: std::time::SystemTime,
) -> MetricEvent {
let labels = Labels::from_pairs(labels).unwrap();
MetricEvent::with_timestamp(name.to_string(), value, labels, timestamp).unwrap()
}
#[test]
fn output_is_valid_json_parseable_by_serde_json() {
let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
let event = make_event("cpu_usage", 0.75, &[("host", "srv1")], ts);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_metric(&event, &mut buf).unwrap();
let line = String::from_utf8(buf).unwrap();
let line = line.trim_end_matches('\n');
let parsed: serde_json::Value = serde_json::from_str(line).expect("must be valid JSON");
assert!(parsed.is_object(), "output must be a JSON object");
}
#[test]
fn roundtrip_name_matches_original_event() {
let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
let event = make_event("http_requests", 42.0, &[], ts);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_metric(&event, &mut buf).unwrap();
let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(parsed["name"], "http_requests");
}
#[test]
fn roundtrip_value_matches_original_event() {
let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
let event = make_event("latency", 3.14, &[], ts);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_metric(&event, &mut buf).unwrap();
let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
assert!((parsed["value"].as_f64().unwrap() - 3.14).abs() < f64::EPSILON);
}
#[test]
fn roundtrip_labels_match_original_event() {
let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
let event = make_event("metric", 1.0, &[("env", "prod"), ("host", "srv1")], ts);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_metric(&event, &mut buf).unwrap();
let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(parsed["labels"]["env"], "prod");
assert_eq!(parsed["labels"]["host"], "srv1");
}
#[test]
fn roundtrip_timestamp_matches_original_event() {
let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
let event = make_event("up", 1.0, &[], ts);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_metric(&event, &mut buf).unwrap();
let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(
parsed["timestamp"], "2023-11-14T22:13:20.000Z",
"timestamp must be RFC 3339 with millisecond precision"
);
}
#[test]
fn empty_labels_produces_empty_json_object() {
let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
let event = make_event("up", 1.0, &[], ts);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_metric(&event, &mut buf).unwrap();
let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(
parsed["labels"],
serde_json::json!({}),
"empty labels must be an empty JSON object"
);
}
#[test]
fn each_encoded_line_ends_with_newline() {
let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
let event = make_event("up", 1.0, &[], ts);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_metric(&event, &mut buf).unwrap();
assert_eq!(
*buf.last().unwrap(),
b'\n',
"line must terminate with newline"
);
}
#[test]
fn multiple_events_each_end_with_newline() {
let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
for i in 0..3u64 {
let event = make_event("up", i as f64, &[], ts + Duration::from_millis(i));
encoder.encode_metric(&event, &mut buf).unwrap();
}
let text = String::from_utf8(buf).unwrap();
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 3, "must produce exactly 3 lines");
for line in &lines {
serde_json::from_str::<serde_json::Value>(line).expect("each line must be valid JSON");
}
}
#[test]
fn multiple_encodes_accumulate_in_same_buffer() {
let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
let event1 = make_event("metric_a", 1.0, &[], ts);
let event2 = make_event("metric_b", 2.0, &[], ts + Duration::from_millis(1));
encoder.encode_metric(&event1, &mut buf).unwrap();
encoder.encode_metric(&event2, &mut buf).unwrap();
let text = String::from_utf8(buf).unwrap();
assert!(
text.contains("metric_a"),
"buffer must contain first metric name"
);
assert!(
text.contains("metric_b"),
"buffer must contain second metric name"
);
}
#[test]
fn timestamp_uses_rfc3339_format_with_millisecond_precision() {
let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_123);
let event = make_event("up", 1.0, &[], ts);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_metric(&event, &mut buf).unwrap();
let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
let ts_str = parsed["timestamp"].as_str().unwrap();
assert!(ts_str.ends_with('Z'), "timestamp must end with Z: {ts_str}");
assert!(
ts_str.contains('T'),
"timestamp must contain T separator: {ts_str}"
);
assert_eq!(
ts_str.len(),
24,
"timestamp must be exactly 24 chars: {ts_str}"
);
assert!(
ts_str.contains(".123"),
"milliseconds must be .123: {ts_str}"
);
}
#[test]
fn timestamp_at_unix_epoch_formats_correctly() {
let ts = UNIX_EPOCH;
let event = make_event("up", 1.0, &[], ts);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_metric(&event, &mut buf).unwrap();
let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(parsed["timestamp"], "1970-01-01T00:00:00.000Z");
}
#[test]
fn timestamp_with_zero_milliseconds_shows_dot_zero_zero_zero() {
let ts = UNIX_EPOCH + Duration::from_secs(1);
let event = make_event("up", 1.0, &[], ts);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_metric(&event, &mut buf).unwrap();
let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(parsed["timestamp"], "1970-01-01T00:00:01.000Z");
}
#[test]
fn regression_anchor_single_label_exact_output() {
let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
let event = make_event("http_requests", 100.0, &[("endpoint", "api")], ts);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_metric(&event, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert_eq!(
output,
"{\"name\":\"http_requests\",\"value\":100.0,\"labels\":{\"endpoint\":\"api\"},\"timestamp\":\"2026-03-20T12:00:00.000Z\"}\n"
);
}
#[test]
fn regression_anchor_no_labels_exact_output() {
let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
let event = make_event("up", 1.0, &[], ts);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_metric(&event, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert_eq!(
output,
"{\"name\":\"up\",\"value\":1.0,\"labels\":{},\"timestamp\":\"2023-11-14T22:13:20.000Z\"}\n"
);
}
#[test]
fn regression_anchor_multiple_labels_sorted_in_output() {
let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
let event = make_event(
"cpu",
0.5,
&[("zone", "eu1"), ("host", "srv1"), ("env", "prod")],
ts,
);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_metric(&event, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert_eq!(
output,
"{\"name\":\"cpu\",\"value\":0.5,\"labels\":{\"env\":\"prod\",\"host\":\"srv1\",\"zone\":\"eu1\"},\"timestamp\":\"2023-11-14T22:13:20.000Z\"}\n"
);
}
#[test]
fn json_fields_appear_in_consistent_order() {
let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
let event = make_event("metric", 1.0, &[("k", "v")], ts);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_metric(&event, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
let line = output.trim_end_matches('\n');
let name_pos = line.find("\"name\"").unwrap();
let value_pos = line.find("\"value\"").unwrap();
let labels_pos = line.find("\"labels\"").unwrap();
let timestamp_pos = line.find("\"timestamp\"").unwrap();
assert!(name_pos < value_pos, "name must come before value");
assert!(value_pos < labels_pos, "value must come before labels");
assert!(
labels_pos < timestamp_pos,
"labels must come before timestamp"
);
}
#[test]
fn json_lines_encoder_is_send_and_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<JsonLines>();
}
#[test]
fn encoder_config_json_lines_creates_encoder_via_factory() {
use crate::encoder::{create_encoder, EncoderConfig};
let config = EncoderConfig::JsonLines { precision: None };
let encoder = create_encoder(&config).unwrap();
let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
let event = make_event("up", 1.0, &[], ts);
let mut buf = Vec::new();
encoder.encode_metric(&event, &mut buf).unwrap();
let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(parsed["name"], "up");
}
fn fmt_ts(ts: std::time::SystemTime) -> String {
let mut buf = Vec::new();
super::super::format_rfc3339_millis(ts, &mut buf).unwrap();
String::from_utf8(buf).unwrap()
}
fn fmt_ts_array(ts: std::time::SystemTime) -> String {
let arr = super::super::format_rfc3339_millis_array(ts).unwrap();
std::str::from_utf8(&arr).unwrap().to_string()
}
#[test]
fn format_rfc3339_millis_epoch_returns_correct_string() {
assert_eq!(fmt_ts(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
}
#[test]
fn format_rfc3339_millis_known_timestamp_2026_03_20_returns_correct_string() {
let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
assert_eq!(fmt_ts(ts), "2026-03-20T12:00:00.000Z");
}
#[test]
fn format_rfc3339_millis_preserves_milliseconds() {
let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_456);
let result = fmt_ts(ts);
assert!(
result.ends_with(".456Z"),
"must end with .456Z but got: {result}"
);
}
#[test]
fn format_rfc3339_millis_midnight_boundary() {
let ts = UNIX_EPOCH + Duration::from_millis(86_399_999);
assert_eq!(fmt_ts(ts), "1970-01-01T23:59:59.999Z");
}
#[test]
fn format_rfc3339_millis_start_of_day_plus_one_second() {
let ts = UNIX_EPOCH + Duration::from_secs(86400); assert_eq!(fmt_ts(ts), "1970-01-02T00:00:00.000Z");
}
#[test]
fn format_rfc3339_millis_leap_year_feb_29() {
let ts = UNIX_EPOCH + Duration::from_secs(1_709_164_800);
assert_eq!(fmt_ts(ts), "2024-02-29T00:00:00.000Z");
}
#[test]
fn format_rfc3339_millis_end_of_year_dec_31() {
let ts = UNIX_EPOCH + Duration::from_millis(1_704_067_199_999);
assert_eq!(fmt_ts(ts), "2023-12-31T23:59:59.999Z");
}
#[test]
fn format_rfc3339_millis_array_matches_buffer_output() {
let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_456);
assert_eq!(fmt_ts(ts), fmt_ts_array(ts));
}
#[test]
fn format_rfc3339_millis_array_is_valid_utf8() {
let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
let arr = super::super::format_rfc3339_millis_array(ts).unwrap();
assert!(
std::str::from_utf8(&arr).is_ok(),
"array output must be valid UTF-8"
);
}
#[test]
fn format_rfc3339_millis_array_length_is_24() {
let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
let arr = super::super::format_rfc3339_millis_array(ts).unwrap();
assert_eq!(
arr.len(),
24,
"RFC 3339 millis timestamp must be exactly 24 bytes"
);
}
fn make_log_event(
severity: crate::model::log::Severity,
message: &str,
fields: &[(&str, &str)],
ts: std::time::SystemTime,
) -> crate::model::log::LogEvent {
let mut map = std::collections::BTreeMap::new();
for (k, v) in fields {
map.insert(k.to_string(), v.to_string());
}
crate::model::log::LogEvent::with_timestamp(
ts,
severity,
message.to_string(),
crate::model::metric::Labels::default(),
map,
)
}
#[test]
fn encode_log_produces_valid_json() {
use crate::model::log::Severity;
let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
let event = make_log_event(Severity::Info, "hello world", &[], ts);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_log(&event, &mut buf).unwrap();
let line = String::from_utf8(buf).unwrap();
let line = line.trim_end_matches('\n');
let parsed: serde_json::Value =
serde_json::from_str(line).expect("encode_log output must be valid JSON");
assert!(parsed.is_object(), "output must be a JSON object");
}
#[test]
fn encode_log_includes_timestamp_field() {
use crate::model::log::Severity;
let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
let event = make_log_event(Severity::Info, "msg", &[], ts);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_log(&event, &mut buf).unwrap();
let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
assert!(
parsed.get("timestamp").is_some(),
"encode_log output must include 'timestamp' field"
);
}
#[test]
fn encode_log_includes_severity_field() {
use crate::model::log::Severity;
let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
let event = make_log_event(Severity::Warn, "msg", &[], ts);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_log(&event, &mut buf).unwrap();
let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
assert!(
parsed.get("severity").is_some(),
"encode_log output must include 'severity' field"
);
}
#[test]
fn encode_log_includes_message_field() {
use crate::model::log::Severity;
let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
let event = make_log_event(Severity::Info, "test message here", &[], ts);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_log(&event, &mut buf).unwrap();
let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
assert!(
parsed.get("message").is_some(),
"encode_log output must include 'message' field"
);
}
#[test]
fn encode_log_includes_fields_field() {
use crate::model::log::Severity;
let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
let event = make_log_event(Severity::Info, "msg", &[("ip", "10.0.0.1")], ts);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_log(&event, &mut buf).unwrap();
let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
assert!(
parsed.get("fields").is_some(),
"encode_log output must include 'fields' field"
);
}
#[test]
fn encode_log_severity_info_is_lowercase() {
use crate::model::log::Severity;
let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
let event = make_log_event(Severity::Info, "msg", &[], ts);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_log(&event, &mut buf).unwrap();
let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(
parsed["severity"], "info",
"severity must be lowercase 'info'"
);
}
#[test]
fn encode_log_severity_error_is_lowercase() {
use crate::model::log::Severity;
let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
let event = make_log_event(Severity::Error, "msg", &[], ts);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_log(&event, &mut buf).unwrap();
let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(parsed["severity"], "error");
}
#[test]
fn encode_log_severity_warn_is_lowercase() {
use crate::model::log::Severity;
let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
let event = make_log_event(Severity::Warn, "msg", &[], ts);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_log(&event, &mut buf).unwrap();
let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(parsed["severity"], "warn");
}
#[test]
fn encode_log_severity_trace_is_lowercase() {
use crate::model::log::Severity;
let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
let event = make_log_event(Severity::Trace, "msg", &[], ts);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_log(&event, &mut buf).unwrap();
let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(parsed["severity"], "trace");
}
#[test]
fn encode_log_severity_debug_is_lowercase() {
use crate::model::log::Severity;
let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
let event = make_log_event(Severity::Debug, "msg", &[], ts);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_log(&event, &mut buf).unwrap();
let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(parsed["severity"], "debug");
}
#[test]
fn encode_log_severity_fatal_is_lowercase() {
use crate::model::log::Severity;
let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
let event = make_log_event(Severity::Fatal, "msg", &[], ts);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_log(&event, &mut buf).unwrap();
let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(parsed["severity"], "fatal");
}
#[test]
fn encode_log_roundtrip_message_matches_original() {
use crate::model::log::Severity;
let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
let event = make_log_event(Severity::Info, "Request from 10.0.0.1", &[], ts);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_log(&event, &mut buf).unwrap();
let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(parsed["message"], "Request from 10.0.0.1");
}
#[test]
fn encode_log_roundtrip_fields_match_original() {
use crate::model::log::Severity;
let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
let event = make_log_event(
Severity::Info,
"req",
&[("ip", "10.0.0.1"), ("endpoint", "/api")],
ts,
);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_log(&event, &mut buf).unwrap();
let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(parsed["fields"]["ip"], "10.0.0.1");
assert_eq!(parsed["fields"]["endpoint"], "/api");
}
#[test]
fn encode_log_roundtrip_timestamp_matches_original() {
use crate::model::log::Severity;
let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
let event = make_log_event(Severity::Info, "msg", &[], ts);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_log(&event, &mut buf).unwrap();
let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(
parsed["timestamp"], "2026-03-20T12:00:00.000Z",
"roundtrip timestamp must match"
);
}
#[test]
fn encode_log_empty_fields_produces_empty_json_object() {
use crate::model::log::Severity;
let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
let event = make_log_event(Severity::Info, "msg", &[], ts);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_log(&event, &mut buf).unwrap();
let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(
parsed["fields"],
serde_json::json!({}),
"empty fields must serialize as empty JSON object"
);
}
#[test]
fn encode_log_line_ends_with_newline() {
use crate::model::log::Severity;
let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
let event = make_log_event(Severity::Info, "msg", &[], ts);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_log(&event, &mut buf).unwrap();
assert_eq!(
*buf.last().unwrap(),
b'\n',
"encode_log line must end with newline"
);
}
#[test]
fn encode_log_fields_appear_in_spec_order() {
use crate::model::log::Severity;
let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
let event = make_log_event(Severity::Info, "msg", &[("k", "v")], ts);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_log(&event, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
let line = output.trim_end_matches('\n');
let ts_pos = line.find("\"timestamp\"").unwrap();
let sev_pos = line.find("\"severity\"").unwrap();
let msg_pos = line.find("\"message\"").unwrap();
let labels_pos = line.find("\"labels\"").unwrap();
let fields_pos = line.find("\"fields\"").unwrap();
assert!(ts_pos < sev_pos, "timestamp must come before severity");
assert!(sev_pos < msg_pos, "severity must come before message");
assert!(msg_pos < labels_pos, "message must come before labels");
assert!(labels_pos < fields_pos, "labels must come before fields");
}
#[test]
fn encode_log_regression_anchor_simple_info_event() {
use crate::model::log::Severity;
let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
let event = make_log_event(Severity::Info, "Request from 10.0.0.1", &[], ts);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_log(&event, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert_eq!(
output,
"{\"timestamp\":\"2026-03-20T12:00:00.000Z\",\"severity\":\"info\",\"message\":\"Request from 10.0.0.1\",\"labels\":{},\"fields\":{}}\n"
);
}
#[test]
fn encode_log_regression_anchor_with_fields() {
use crate::model::log::Severity;
let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
let event = make_log_event(
Severity::Error,
"db timeout",
&[("endpoint", "/api"), ("ip", "10.0.0.1")],
ts,
);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_log(&event, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert_eq!(
output,
"{\"timestamp\":\"2026-03-20T12:00:00.000Z\",\"severity\":\"error\",\"message\":\"db timeout\",\"labels\":{},\"fields\":{\"endpoint\":\"/api\",\"ip\":\"10.0.0.1\"}}\n"
);
}
fn make_log_event_with_labels(
severity: crate::model::log::Severity,
message: &str,
labels: &[(&str, &str)],
fields: &[(&str, &str)],
ts: std::time::SystemTime,
) -> crate::model::log::LogEvent {
let mut field_map = std::collections::BTreeMap::new();
for (k, v) in fields {
field_map.insert(k.to_string(), v.to_string());
}
let label_set = crate::model::metric::Labels::from_pairs(labels).unwrap();
crate::model::log::LogEvent::with_timestamp(
ts,
severity,
message.to_string(),
label_set,
field_map,
)
}
#[test]
fn encode_log_with_labels_includes_labels_in_json() {
use crate::model::log::Severity;
let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
let event = make_log_event_with_labels(
Severity::Info,
"labeled event",
&[("device", "wlan0"), ("hostname", "router_01")],
&[],
ts,
);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_log(&event, &mut buf).unwrap();
let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(parsed["labels"]["device"], "wlan0");
assert_eq!(parsed["labels"]["hostname"], "router_01");
}
#[test]
fn encode_log_labels_are_sorted_by_key() {
use crate::model::log::Severity;
let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
let event = make_log_event_with_labels(
Severity::Info,
"sorted labels",
&[("zone", "eu1"), ("env", "prod"), ("app", "sonda")],
&[],
ts,
);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_log(&event, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
let line = output.trim_end_matches('\n');
let app_pos = line.find("\"app\"").unwrap();
let env_pos = line.find("\"env\"").unwrap();
let zone_pos = line.find("\"zone\"").unwrap();
assert!(
app_pos < env_pos,
"app must come before env in sorted labels"
);
assert!(
env_pos < zone_pos,
"env must come before zone in sorted labels"
);
}
#[test]
fn encode_log_with_empty_labels_produces_empty_labels_object() {
use crate::model::log::Severity;
let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
let event = make_log_event(Severity::Info, "no labels", &[], ts);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_log(&event, &mut buf).unwrap();
let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(
parsed["labels"],
serde_json::json!({}),
"empty labels must serialize as empty JSON object"
);
}
#[test]
fn encode_log_regression_anchor_with_labels_exact_output() {
use crate::model::log::Severity;
let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
let event = make_log_event_with_labels(
Severity::Info,
"Request from 10.0.0.1",
&[("device", "wlan0")],
&[("ip", "10.0.0.1")],
ts,
);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_log(&event, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert_eq!(
output,
"{\"timestamp\":\"2026-03-20T12:00:00.000Z\",\"severity\":\"info\",\"message\":\"Request from 10.0.0.1\",\"labels\":{\"device\":\"wlan0\"},\"fields\":{\"ip\":\"10.0.0.1\"}}\n"
);
}
#[test]
fn encode_log_with_labels_and_fields_both_present() {
use crate::model::log::Severity;
let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
let event = make_log_event_with_labels(
Severity::Error,
"timeout",
&[("env", "prod")],
&[("endpoint", "/api")],
ts,
);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_log(&event, &mut buf).unwrap();
let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(parsed["labels"]["env"], "prod");
assert_eq!(parsed["fields"]["endpoint"], "/api");
}
#[test]
fn prometheus_encoder_encode_log_still_returns_not_supported_after_slice_2_3() {
use crate::encoder::{create_encoder, EncoderConfig};
let encoder = create_encoder(&EncoderConfig::PrometheusText { precision: None }).unwrap();
let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
let event = make_log_event(crate::model::log::Severity::Info, "should fail", &[], ts);
let mut buf = Vec::new();
let result = encoder.encode_log(&event, &mut buf);
assert!(
result.is_err(),
"prometheus encoder must still return error for encode_log"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("not supported"),
"error must mention 'not supported', got: {msg}"
);
assert!(buf.is_empty(), "buffer must remain empty on error");
}
#[test]
fn precision_two_rounds_json_value() {
let encoder = JsonLines::new(Some(2));
let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
let event = make_event("cpu", 99.60573, &[], ts);
let mut buf = Vec::new();
encoder.encode_metric(&event, &mut buf).unwrap();
let line = String::from_utf8(buf).unwrap();
let line = line.trim_end_matches('\n');
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
let value = parsed["value"].as_f64().unwrap();
assert!((value - 99.61).abs() < 1e-10, "expected 99.61, got {value}");
}
#[test]
fn precision_none_preserves_full_value_in_json() {
let encoder = JsonLines::new(None);
let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
let event = make_event("cpu", 99.60573506572389, &[], ts);
let mut buf = Vec::new();
encoder.encode_metric(&event, &mut buf).unwrap();
let line = String::from_utf8(buf).unwrap();
let line = line.trim_end_matches('\n');
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
let value = parsed["value"].as_f64().unwrap();
assert!(
(value - 99.60573506572389).abs() < 1e-11,
"full precision must be preserved: {value}"
);
}
#[test]
fn precision_zero_rounds_to_whole_number_in_json() {
let encoder = JsonLines::new(Some(0));
let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
let event = make_event("up", 42.9, &[], ts);
let mut buf = Vec::new();
encoder.encode_metric(&event, &mut buf).unwrap();
let line = String::from_utf8(buf).unwrap();
let line = line.trim_end_matches('\n');
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
let value = parsed["value"].as_f64().unwrap();
assert!((value - 43.0).abs() < 1e-10, "expected 43.0, got {value}");
}
#[test]
fn labels_ref_serializes_empty_labels_as_empty_object() {
let labels = Labels::from_pairs(&[]).unwrap();
let wrapper = super::LabelsRef(&labels);
let json = serde_json::to_string(&wrapper).unwrap();
assert_eq!(json, "{}");
}
#[test]
fn labels_ref_serializes_single_label() {
let labels = Labels::from_pairs(&[("host", "srv1")]).unwrap();
let wrapper = super::LabelsRef(&labels);
let json = serde_json::to_string(&wrapper).unwrap();
assert_eq!(json, r#"{"host":"srv1"}"#);
}
#[test]
fn labels_ref_serializes_multiple_labels_in_sorted_order() {
let labels =
Labels::from_pairs(&[("zone", "eu1"), ("env", "prod"), ("host", "srv1")]).unwrap();
let wrapper = super::LabelsRef(&labels);
let json = serde_json::to_string(&wrapper).unwrap();
assert_eq!(json, r#"{"env":"prod","host":"srv1","zone":"eu1"}"#);
}
#[test]
fn labels_ref_handles_values_with_special_json_characters() {
let labels = Labels::from_pairs(&[("msg", "hello \"world\"")]).unwrap();
let wrapper = super::LabelsRef(&labels);
let json = serde_json::to_string(&wrapper).unwrap();
assert_eq!(json, r#"{"msg":"hello \"world\""}"#);
}
#[test]
fn string_map_ref_serializes_empty_map_as_empty_object() {
let map = BTreeMap::new();
let wrapper = super::StringMapRef(&map);
let json = serde_json::to_string(&wrapper).unwrap();
assert_eq!(json, "{}");
}
#[test]
fn string_map_ref_serializes_entries_in_sorted_order() {
let mut map = BTreeMap::new();
map.insert("z_key".to_string(), "last".to_string());
map.insert("a_key".to_string(), "first".to_string());
map.insert("m_key".to_string(), "middle".to_string());
let wrapper = super::StringMapRef(&map);
let json = serde_json::to_string(&wrapper).unwrap();
assert_eq!(json, r#"{"a_key":"first","m_key":"middle","z_key":"last"}"#);
}
#[test]
fn encode_metric_many_labels_produces_sorted_json_object() {
let pairs: Vec<(&str, &str)> = vec![
("j", "10"),
("i", "9"),
("h", "8"),
("g", "7"),
("f", "6"),
("e", "5"),
("d", "4"),
("c", "3"),
("b", "2"),
("a", "1"),
];
let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
let event = make_event("multi", 1.0, &pairs, ts);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_metric(&event, &mut buf).unwrap();
let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
let labels = parsed["labels"].as_object().unwrap();
let keys: Vec<&String> = labels.keys().collect();
assert_eq!(
keys,
&["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"],
"labels must be sorted alphabetically"
);
}
#[test]
fn encode_log_many_fields_produces_sorted_json_object() {
use crate::model::log::Severity;
let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
let event = make_log_event(
Severity::Info,
"multi-field",
&[
("z_field", "last"),
("a_field", "first"),
("m_field", "mid"),
],
ts,
);
let encoder = JsonLines::new(None);
let mut buf = Vec::new();
encoder.encode_log(&event, &mut buf).unwrap();
let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
let fields = parsed["fields"].as_object().unwrap();
let keys: Vec<&String> = fields.keys().collect();
assert_eq!(
keys,
&["a_field", "m_field", "z_field"],
"fields must be sorted alphabetically"
);
}
}