use serde_json::json;
use std::collections::HashMap;
use std::time::SystemTime;
#[derive(Clone, Debug)]
pub struct LogEntry {
pub timestamp: SystemTime,
pub level: String,
pub message: String,
pub method: Option<String>,
pub uri: Option<String>,
pub status: Option<u16>,
pub duration_ms: Option<u64>,
pub correlation_id: Option<String>,
pub trace_id: Option<String>,
pub span_id: Option<String>,
pub service_name: Option<String>,
pub service_version: Option<String>,
pub environment: Option<String>,
pub request_headers: Option<HashMap<String, String>>,
pub response_headers: Option<HashMap<String, String>>,
pub request_body: Option<String>,
pub response_body: Option<String>,
pub client_ip: Option<String>,
pub user_agent: Option<String>,
pub custom_fields: HashMap<String, String>,
pub error: Option<String>,
}
impl Default for LogEntry {
fn default() -> Self {
Self {
timestamp: SystemTime::now(),
level: "info".to_string(),
message: String::new(),
method: None,
uri: None,
status: None,
duration_ms: None,
correlation_id: None,
trace_id: None,
span_id: None,
service_name: None,
service_version: None,
environment: None,
request_headers: None,
response_headers: None,
request_body: None,
response_body: None,
client_ip: None,
user_agent: None,
custom_fields: HashMap::new(),
error: None,
}
}
}
impl LogEntry {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
..Default::default()
}
}
pub fn level(mut self, level: impl Into<String>) -> Self {
self.level = level.into();
self
}
pub fn method(mut self, method: impl Into<String>) -> Self {
self.method = Some(method.into());
self
}
pub fn uri(mut self, uri: impl Into<String>) -> Self {
self.uri = Some(uri.into());
self
}
pub fn status(mut self, status: u16) -> Self {
self.status = Some(status);
self
}
pub fn duration_ms(mut self, duration: u64) -> Self {
self.duration_ms = Some(duration);
self
}
pub fn correlation_id(mut self, id: impl Into<String>) -> Self {
self.correlation_id = Some(id.into());
self
}
pub fn trace_id(mut self, id: impl Into<String>) -> Self {
self.trace_id = Some(id.into());
self
}
pub fn span_id(mut self, id: impl Into<String>) -> Self {
self.span_id = Some(id.into());
self
}
pub fn service_name(mut self, name: impl Into<String>) -> Self {
self.service_name = Some(name.into());
self
}
pub fn field(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.custom_fields.insert(key.into(), value.into());
self
}
pub fn error(mut self, error: impl Into<String>) -> Self {
self.error = Some(error.into());
self.level = "error".to_string();
self
}
}
pub trait LogFormatter: Send + Sync {
fn format(&self, entry: &LogEntry) -> String;
}
#[derive(Clone, Debug, Default)]
pub struct JsonFormatter {
pub pretty: bool,
}
impl JsonFormatter {
pub fn new() -> Self {
Self::default()
}
pub fn pretty() -> Self {
Self { pretty: true }
}
}
impl LogFormatter for JsonFormatter {
fn format(&self, entry: &LogEntry) -> String {
let timestamp = entry
.timestamp
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let mut obj = json!({
"timestamp": timestamp,
"level": entry.level,
"message": entry.message,
});
if let Some(ref method) = entry.method {
obj["http.method"] = json!(method);
}
if let Some(ref uri) = entry.uri {
obj["http.url"] = json!(uri);
}
if let Some(status) = entry.status {
obj["http.status_code"] = json!(status);
}
if let Some(duration) = entry.duration_ms {
obj["duration_ms"] = json!(duration);
}
if let Some(ref correlation_id) = entry.correlation_id {
obj["correlation_id"] = json!(correlation_id);
}
if let Some(ref trace_id) = entry.trace_id {
obj["trace.id"] = json!(trace_id);
}
if let Some(ref span_id) = entry.span_id {
obj["span.id"] = json!(span_id);
}
if let Some(ref service_name) = entry.service_name {
obj["service.name"] = json!(service_name);
}
if let Some(ref service_version) = entry.service_version {
obj["service.version"] = json!(service_version);
}
if let Some(ref env) = entry.environment {
obj["environment"] = json!(env);
}
if let Some(ref client_ip) = entry.client_ip {
obj["client.ip"] = json!(client_ip);
}
if let Some(ref user_agent) = entry.user_agent {
obj["user_agent"] = json!(user_agent);
}
if let Some(ref error) = entry.error {
obj["error.message"] = json!(error);
}
if let Some(ref headers) = entry.request_headers {
obj["http.request.headers"] = json!(headers);
}
if let Some(ref headers) = entry.response_headers {
obj["http.response.headers"] = json!(headers);
}
for (key, value) in &entry.custom_fields {
obj[key] = json!(value);
}
if self.pretty {
serde_json::to_string_pretty(&obj).unwrap_or_default()
} else {
serde_json::to_string(&obj).unwrap_or_default()
}
}
}
#[derive(Clone, Debug, Default)]
pub struct DatadogFormatter;
impl DatadogFormatter {
pub fn new() -> Self {
Self
}
}
impl LogFormatter for DatadogFormatter {
fn format(&self, entry: &LogEntry) -> String {
let timestamp = entry
.timestamp
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let mut obj = json!({
"timestamp": timestamp,
"status": entry.level,
"message": entry.message,
});
if let Some(ref trace_id) = entry.trace_id {
obj["dd.trace_id"] = json!(trace_id);
}
if let Some(ref span_id) = entry.span_id {
obj["dd.span_id"] = json!(span_id);
}
if let Some(ref service_name) = entry.service_name {
obj["service"] = json!(service_name);
}
if let Some(ref env) = entry.environment {
obj["env"] = json!(env);
}
if let Some(ref service_version) = entry.service_version {
obj["version"] = json!(service_version);
}
if let Some(ref method) = entry.method {
obj["http.method"] = json!(method);
}
if let Some(ref uri) = entry.uri {
obj["http.url"] = json!(uri);
}
if let Some(status) = entry.status {
obj["http.status_code"] = json!(status);
}
if let Some(duration) = entry.duration_ms {
obj["duration"] = json!(duration * 1_000_000); }
if let Some(ref client_ip) = entry.client_ip {
obj["network.client.ip"] = json!(client_ip);
}
if let Some(ref user_agent) = entry.user_agent {
obj["http.useragent"] = json!(user_agent);
}
if let Some(ref error) = entry.error {
obj["error.message"] = json!(error);
obj["error.stack"] = json!(error);
}
for (key, value) in &entry.custom_fields {
obj[key] = json!(value);
}
serde_json::to_string(&obj).unwrap_or_default()
}
}
#[derive(Clone, Debug, Default)]
pub struct SplunkFormatter {
pub source: Option<String>,
pub sourcetype: Option<String>,
pub index: Option<String>,
}
impl SplunkFormatter {
pub fn new() -> Self {
Self::default()
}
pub fn source(mut self, source: impl Into<String>) -> Self {
self.source = Some(source.into());
self
}
pub fn sourcetype(mut self, sourcetype: impl Into<String>) -> Self {
self.sourcetype = Some(sourcetype.into());
self
}
pub fn index(mut self, index: impl Into<String>) -> Self {
self.index = Some(index.into());
self
}
}
impl LogFormatter for SplunkFormatter {
fn format(&self, entry: &LogEntry) -> String {
let timestamp = entry
.timestamp
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64();
let mut event = json!({
"level": entry.level,
"message": entry.message,
});
if let Some(ref method) = entry.method {
event["http_method"] = json!(method);
}
if let Some(ref uri) = entry.uri {
event["http_url"] = json!(uri);
}
if let Some(status) = entry.status {
event["http_status"] = json!(status);
}
if let Some(duration) = entry.duration_ms {
event["duration_ms"] = json!(duration);
}
if let Some(ref correlation_id) = entry.correlation_id {
event["correlation_id"] = json!(correlation_id);
}
if let Some(ref trace_id) = entry.trace_id {
event["trace_id"] = json!(trace_id);
}
if let Some(ref service_name) = entry.service_name {
event["service"] = json!(service_name);
}
if let Some(ref env) = entry.environment {
event["environment"] = json!(env);
}
if let Some(ref error) = entry.error {
event["error"] = json!(error);
}
for (key, value) in &entry.custom_fields {
event[key] = json!(value);
}
let mut obj = json!({
"time": timestamp,
"event": event,
});
if let Some(ref source) = self.source {
obj["source"] = json!(source);
}
if let Some(ref sourcetype) = self.sourcetype {
obj["sourcetype"] = json!(sourcetype);
}
if let Some(ref index) = self.index {
obj["index"] = json!(index);
}
if let Some(ref service_name) = entry.service_name {
obj["host"] = json!(service_name);
}
serde_json::to_string(&obj).unwrap_or_default()
}
}
#[derive(Clone, Debug, Default)]
pub struct LogfmtFormatter;
impl LogfmtFormatter {
pub fn new() -> Self {
Self
}
}
impl LogFormatter for LogfmtFormatter {
fn format(&self, entry: &LogEntry) -> String {
let mut parts = Vec::new();
let timestamp = entry
.timestamp
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
parts.push(format!("ts={}", timestamp));
parts.push(format!("level={}", entry.level));
parts.push(format!("msg=\"{}\"", escape_logfmt(&entry.message)));
if let Some(ref method) = entry.method {
parts.push(format!("method={}", method));
}
if let Some(ref uri) = entry.uri {
parts.push(format!("uri=\"{}\"", escape_logfmt(uri)));
}
if let Some(status) = entry.status {
parts.push(format!("status={}", status));
}
if let Some(duration) = entry.duration_ms {
parts.push(format!("duration_ms={}", duration));
}
if let Some(ref correlation_id) = entry.correlation_id {
parts.push(format!("correlation_id={}", correlation_id));
}
if let Some(ref trace_id) = entry.trace_id {
parts.push(format!("trace_id={}", trace_id));
}
if let Some(ref span_id) = entry.span_id {
parts.push(format!("span_id={}", span_id));
}
if let Some(ref service_name) = entry.service_name {
parts.push(format!("service={}", service_name));
}
if let Some(ref env) = entry.environment {
parts.push(format!("env={}", env));
}
if let Some(ref error) = entry.error {
parts.push(format!("error=\"{}\"", escape_logfmt(error)));
}
for (key, value) in &entry.custom_fields {
parts.push(format!("{}=\"{}\"", key, escape_logfmt(value)));
}
parts.join(" ")
}
}
fn escape_logfmt(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
#[test]
fn test_json_formatter() {
let entry = LogEntry::new("test message")
.method("GET")
.uri("/api/test")
.status(200)
.duration_ms(42)
.correlation_id("abc-123");
let formatter = JsonFormatter::new();
let output = formatter.format(&entry);
let parsed: Value = serde_json::from_str(&output).unwrap();
assert_eq!(parsed["message"], "test message");
assert_eq!(parsed["http.method"], "GET");
assert_eq!(parsed["http.status_code"], 200);
}
#[test]
fn test_datadog_formatter() {
let entry = LogEntry::new("test message")
.method("POST")
.status(201)
.trace_id("trace-123")
.span_id("span-456")
.service_name("my-service");
let formatter = DatadogFormatter::new();
let output = formatter.format(&entry);
let parsed: Value = serde_json::from_str(&output).unwrap();
assert_eq!(parsed["dd.trace_id"], "trace-123");
assert_eq!(parsed["dd.span_id"], "span-456");
assert_eq!(parsed["service"], "my-service");
}
#[test]
fn test_splunk_formatter() {
let entry = LogEntry::new("test message")
.method("GET")
.status(200)
.service_name("my-service");
let formatter = SplunkFormatter::new()
.source("rustapi")
.sourcetype("json")
.index("main");
let output = formatter.format(&entry);
let parsed: Value = serde_json::from_str(&output).unwrap();
assert!(parsed["time"].is_number());
assert_eq!(parsed["source"], "rustapi");
assert_eq!(parsed["sourcetype"], "json");
}
#[test]
fn test_logfmt_formatter() {
let entry = LogEntry::new("test message")
.method("GET")
.uri("/api/test")
.status(200)
.correlation_id("abc-123");
let formatter = LogfmtFormatter::new();
let output = formatter.format(&entry);
assert!(output.contains("level=info"));
assert!(output.contains("method=GET"));
assert!(output.contains("status=200"));
assert!(output.contains("correlation_id=abc-123"));
}
#[test]
fn test_log_entry_builder() {
let entry = LogEntry::new("test")
.level("warn")
.method("DELETE")
.uri("/api/item/1")
.status(204)
.field("custom", "value");
assert_eq!(entry.level, "warn");
assert_eq!(entry.method, Some("DELETE".to_string()));
assert_eq!(
entry.custom_fields.get("custom"),
Some(&"value".to_string())
);
}
}
#[cfg(test)]
mod property_tests {
use super::*;
use proptest::prelude::*;
use serde_json::Value;
fn log_level_strategy() -> impl Strategy<Value = String> {
prop_oneof![
Just("debug".to_string()),
Just("info".to_string()),
Just("warn".to_string()),
Just("error".to_string()),
]
}
fn http_method_strategy() -> impl Strategy<Value = String> {
prop_oneof![
Just("GET".to_string()),
Just("POST".to_string()),
Just("PUT".to_string()),
Just("DELETE".to_string()),
Just("PATCH".to_string()),
Just("HEAD".to_string()),
Just("OPTIONS".to_string()),
]
}
fn status_code_strategy() -> impl Strategy<Value = u16> {
prop_oneof![
200u16..=299, 300u16..=399, 400u16..=499, 500u16..=599, ]
}
fn uri_strategy() -> impl Strategy<Value = String> {
prop::string::string_regex("/api/[a-z]{3,10}(/[0-9]{1,5})?").unwrap()
}
fn message_strategy() -> impl Strategy<Value = String> {
prop::string::string_regex(r#"[a-zA-Z0-9 \n\r\t"'\\]{5,50}"#).unwrap()
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn prop_json_format_valid(
message in message_strategy(),
level in log_level_strategy(),
method in prop::option::of(http_method_strategy()),
uri in prop::option::of(uri_strategy()),
status in prop::option::of(status_code_strategy()),
) {
let mut entry = LogEntry::new(message.clone()).level(level.clone());
if let Some(m) = method {
entry = entry.method(m);
}
if let Some(u) = uri {
entry = entry.uri(u);
}
if let Some(s) = status {
entry = entry.status(s);
}
let formatter = JsonFormatter::new();
let output = formatter.format(&entry);
let parsed: Value = serde_json::from_str(&output)
.expect("JSON formatter must produce valid JSON");
prop_assert!(parsed.get("timestamp").is_some());
prop_assert!(parsed.get("level").is_some());
prop_assert!(parsed.get("message").is_some());
prop_assert_eq!(parsed["level"].as_str().unwrap(), level);
prop_assert_eq!(parsed["message"].as_str().unwrap(), message);
}
#[test]
fn prop_json_format_preserves_fields(
message in "[a-zA-Z0-9 ]{5,30}",
method in http_method_strategy(),
uri in uri_strategy(),
status in status_code_strategy(),
duration in 1u64..10000,
correlation_id in "[a-z0-9-]{10,20}",
trace_id in "[a-f0-9]{32}",
) {
let entry = LogEntry::new(message.clone())
.method(method.clone())
.uri(uri.clone())
.status(status)
.duration_ms(duration)
.correlation_id(correlation_id.clone())
.trace_id(trace_id.clone());
let formatter = JsonFormatter::new();
let output = formatter.format(&entry);
let parsed: Value = serde_json::from_str(&output).unwrap();
prop_assert_eq!(parsed["message"].clone(), message);
prop_assert_eq!(parsed["http.method"].clone(), method);
prop_assert_eq!(parsed["http.url"].clone(), uri);
prop_assert_eq!(parsed["http.status_code"].clone(), status);
prop_assert_eq!(parsed["duration_ms"].clone(), duration);
prop_assert_eq!(parsed["correlation_id"].clone(), correlation_id);
prop_assert_eq!(parsed["trace.id"].clone(), trace_id);
}
#[test]
fn prop_datadog_format_valid(
message in message_strategy(),
trace_id in "[a-f0-9]{32}",
span_id in "[a-f0-9]{16}",
service in "[a-z-]{5,15}",
) {
let entry = LogEntry::new(message.clone())
.trace_id(trace_id.clone())
.span_id(span_id.clone())
.service_name(service.clone());
let formatter = DatadogFormatter::new();
let output = formatter.format(&entry);
let parsed: Value = serde_json::from_str(&output)
.expect("Datadog formatter must produce valid JSON");
prop_assert_eq!(parsed["dd.trace_id"].clone(), trace_id);
prop_assert_eq!(parsed["dd.span_id"].clone(), span_id);
prop_assert_eq!(parsed["service"].clone(), service);
prop_assert_eq!(parsed["status"].clone(), "info");
}
#[test]
fn prop_datadog_duration_nanoseconds(duration_ms in 1u64..10000) {
let entry = LogEntry::new("test").duration_ms(duration_ms);
let formatter = DatadogFormatter::new();
let output = formatter.format(&entry);
let parsed: Value = serde_json::from_str(&output).unwrap();
let expected_ns = duration_ms * 1_000_000;
prop_assert_eq!(parsed["duration"].clone(), expected_ns);
}
#[test]
fn prop_splunk_format_valid(
message in message_strategy(),
source in "[a-z]{5,10}",
sourcetype in "[a-z]{5,10}",
index in "[a-z]{4,8}",
) {
let entry = LogEntry::new(message.clone());
let formatter = SplunkFormatter::new()
.source(source.clone())
.sourcetype(sourcetype.clone())
.index(index.clone());
let output = formatter.format(&entry);
let parsed: Value = serde_json::from_str(&output)
.expect("Splunk formatter must produce valid JSON");
prop_assert!(parsed.get("time").is_some());
prop_assert!(parsed.get("event").is_some());
prop_assert_eq!(parsed["source"].clone(), source);
prop_assert_eq!(parsed["sourcetype"].clone(), sourcetype);
prop_assert_eq!(parsed["index"].clone(), index);
let event = &parsed["event"];
prop_assert_eq!(event["message"].clone(), message);
}
#[test]
fn prop_splunk_timestamp_format(_seed in 0u32..100) {
let entry = LogEntry::new("test");
let formatter = SplunkFormatter::new();
let output = formatter.format(&entry);
let parsed: Value = serde_json::from_str(&output).unwrap();
prop_assert!(parsed["time"].is_f64() || parsed["time"].is_u64());
let time = parsed["time"].as_f64().unwrap();
prop_assert!(time > 1577836800.0 && time < 4102444800.0);
}
#[test]
fn prop_logfmt_format_valid(
message in "[a-zA-Z0-9 ]{5,30}",
method in http_method_strategy(),
status in status_code_strategy(),
) {
let entry = LogEntry::new(message.clone())
.method(method.clone())
.status(status);
let formatter = LogfmtFormatter::new();
let output = formatter.format(&entry);
prop_assert!(output.contains("ts="));
prop_assert!(output.contains("level="));
prop_assert!(output.contains("msg="));
let method_str = format!("method={}", method);
prop_assert!(output.contains(&method_str));
let status_str = format!("status={}", status);
prop_assert!(output.contains(&status_str));
}
#[test]
fn prop_logfmt_escapes_special_chars(
message in prop::string::string_regex(r#"[a-zA-Z0-9 \n\r"\\]{5,30}"#).unwrap()
) {
let entry = LogEntry::new(message.clone());
let formatter = LogfmtFormatter::new();
let output = formatter.format(&entry);
if message.contains('"') {
prop_assert!(output.contains(r#"\""#));
}
if message.contains('\n') {
prop_assert!(output.contains(r"\n"));
}
if message.contains('\r') {
prop_assert!(output.contains(r"\r"));
}
if message.contains('\\') {
prop_assert!(output.contains(r"\\"));
}
}
#[test]
fn prop_custom_fields_preserved(
message in "[a-zA-Z0-9 ]{5,20}",
key in "[a-z_]{5,10}",
value in "[a-zA-Z0-9]{5,15}",
) {
let entry = LogEntry::new(message).field(key.clone(), value.clone());
let json_formatter = JsonFormatter::new();
let json_output = json_formatter.format(&entry);
let json_parsed: Value = serde_json::from_str(&json_output).unwrap();
prop_assert_eq!(json_parsed[&key].clone(), value.clone());
let dd_formatter = DatadogFormatter::new();
let dd_output = dd_formatter.format(&entry);
let dd_parsed: Value = serde_json::from_str(&dd_output).unwrap();
prop_assert_eq!(dd_parsed[&key].clone(), value.clone());
let splunk_formatter = SplunkFormatter::new();
let splunk_output = splunk_formatter.format(&entry);
let splunk_parsed: Value = serde_json::from_str(&splunk_output).unwrap();
prop_assert_eq!(splunk_parsed["event"][&key].clone(), value.clone());
let logfmt_formatter = LogfmtFormatter::new();
let logfmt_output = logfmt_formatter.format(&entry);
let logfmt_expected = format!("{}=\"{}\"", key, value);
prop_assert!(logfmt_output.contains(&logfmt_expected));
}
#[test]
fn prop_error_formatting(
error_msg in message_strategy(),
) {
let entry = LogEntry::new("error occurred").error(error_msg.clone());
prop_assert_eq!(&entry.level, "error");
let json_formatter = JsonFormatter::new();
let json_output = json_formatter.format(&entry);
let json_parsed: Value = serde_json::from_str(&json_output).unwrap();
prop_assert_eq!(json_parsed["level"].clone(), "error");
prop_assert_eq!(json_parsed["error.message"].clone(), error_msg.clone());
let dd_formatter = DatadogFormatter::new();
let dd_output = dd_formatter.format(&entry);
let dd_parsed: Value = serde_json::from_str(&dd_output).unwrap();
prop_assert_eq!(dd_parsed["status"].clone(), "error");
prop_assert_eq!(dd_parsed["error.message"].clone(), error_msg);
}
#[test]
fn prop_json_pretty_valid(message in "[a-zA-Z0-9 ]{5,20}") {
let entry = LogEntry::new(message.clone());
let formatter = JsonFormatter::pretty();
let output = formatter.format(&entry);
let parsed: Value = serde_json::from_str(&output).unwrap();
prop_assert_eq!(parsed["message"].clone(), message);
prop_assert!(output.contains('\n'));
}
#[test]
fn prop_timestamps_reasonable(_seed in 0u32..100) {
let entry1 = LogEntry::new("test1");
std::thread::sleep(std::time::Duration::from_millis(1));
let entry2 = LogEntry::new("test2");
let formatter = JsonFormatter::new();
let output1 = formatter.format(&entry1);
let output2 = formatter.format(&entry2);
let parsed1: Value = serde_json::from_str(&output1).unwrap();
let parsed2: Value = serde_json::from_str(&output2).unwrap();
let ts1 = parsed1["timestamp"].as_u64().unwrap();
let ts2 = parsed2["timestamp"].as_u64().unwrap();
prop_assert!(ts2 >= ts1);
prop_assert!(ts1 > 1577836800000); prop_assert!(ts1 < 4102444800000); }
#[test]
fn prop_empty_fields_handled(message in "[a-zA-Z0-9 ]{5,20}") {
let entry = LogEntry::new(message.clone());
let json_output = JsonFormatter::new().format(&entry);
prop_assert!(serde_json::from_str::<Value>(&json_output).is_ok());
let dd_output = DatadogFormatter::new().format(&entry);
prop_assert!(serde_json::from_str::<Value>(&dd_output).is_ok());
let splunk_output = SplunkFormatter::new().format(&entry);
prop_assert!(serde_json::from_str::<Value>(&splunk_output).is_ok());
let logfmt_output = LogfmtFormatter::new().format(&entry);
prop_assert!(logfmt_output.contains("msg="));
}
}
}