use serde::{Deserialize, Serialize};
use tracing::Level;
use crate::ConnectionId;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StructuredLogEvent {
pub timestamp: u64,
pub level: LogLevel,
pub target: String,
pub message: String,
pub fields: Vec<(String, String)>,
pub span_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trace_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub connection_id: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum LogLevel {
ERROR,
WARN,
INFO,
DEBUG,
TRACE,
}
impl From<Level> for LogLevel {
fn from(level: Level) -> Self {
match level {
Level::ERROR => Self::ERROR,
Level::WARN => Self::WARN,
Level::INFO => Self::INFO,
Level::DEBUG => Self::DEBUG,
Level::TRACE => Self::TRACE,
}
}
}
impl StructuredLogEvent {
pub fn new(level: Level, target: impl Into<String>, message: impl Into<String>) -> Self {
Self {
timestamp: crate::tracing::timestamp_now(),
level: level.into(),
target: target.into(),
message: message.into(),
fields: Vec::new(),
span_id: None,
trace_id: None,
connection_id: None,
}
}
pub fn with_field(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.fields.push((key.into(), value.into()));
self
}
pub fn with_fields(mut self, fields: Vec<(String, String)>) -> Self {
self.fields.extend(fields);
self
}
pub fn with_span_id(mut self, span_id: impl Into<String>) -> Self {
self.span_id = Some(span_id.into());
self
}
pub fn with_trace_id(mut self, trace_id: impl Into<String>) -> Self {
self.trace_id = Some(trace_id.into());
self
}
pub fn with_connection_id(mut self, conn_id: &ConnectionId) -> Self {
self.connection_id = Some(format!("{conn_id:?}"));
self
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
}
pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
}
pub struct StructuredEventBuilder {
event: StructuredLogEvent,
}
impl StructuredEventBuilder {
pub fn new(level: Level, target: &str, message: &str) -> Self {
Self {
event: StructuredLogEvent::new(level, target, message),
}
}
pub fn field(mut self, key: &str, value: &str) -> Self {
self.event = self.event.with_field(key, value);
self
}
pub fn field_num<T: std::fmt::Display>(mut self, key: &str, value: T) -> Self {
self.event = self.event.with_field(key, value.to_string());
self
}
pub fn field_bool(mut self, key: &str, value: bool) -> Self {
self.event = self.event.with_field(key, value.to_string());
self
}
pub fn field_opt<T: std::fmt::Display>(mut self, key: &str, value: Option<T>) -> Self {
if let Some(v) = value {
self.event = self.event.with_field(key, v.to_string());
}
self
}
pub fn connection_id(mut self, conn_id: &ConnectionId) -> Self {
self.event = self.event.with_connection_id(conn_id);
self
}
pub fn span_id(mut self, span_id: &str) -> Self {
self.event = self.event.with_span_id(span_id);
self
}
pub fn build(self) -> StructuredLogEvent {
self.event
}
}
#[allow(dead_code)]
pub(super) fn format_as_json(event: &super::LogEvent) -> String {
let structured = StructuredLogEvent {
timestamp: crate::tracing::timestamp_now(),
level: event.level.into(),
target: event.target.clone(),
message: event.message.clone(),
fields: event
.fields
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
span_id: event.span_id.clone(),
trace_id: None,
connection_id: None,
};
structured.to_json().unwrap_or_else(|_| {
format!(
r#"{{"error":"failed to serialize event","message":"{}"}}"#,
event.message
)
})
}
pub fn parse_structured_fields(
format_str: &str,
args: &[&dyn std::fmt::Display],
) -> Vec<(String, String)> {
let mut fields = Vec::new();
let parts = format_str.split("{}");
let mut arg_idx = 0;
for (i, part) in parts.enumerate() {
if i > 0 && arg_idx < args.len() {
if let Some(field_name) = extract_field_name(part) {
fields.push((field_name, args[arg_idx].to_string()));
}
arg_idx += 1;
}
}
fields
}
fn extract_field_name(text: &str) -> Option<String> {
let trimmed = text.trim();
if let Some(idx) = trimmed.rfind('=') {
let name = trimmed[..idx].trim();
if !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_') {
return Some(name.to_string());
}
}
if let Some(idx) = trimmed.rfind(':') {
let name = trimmed[..idx].trim();
if !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_') {
return Some(name.to_string());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_structured_event_builder() {
let event = StructuredEventBuilder::new(Level::INFO, "test", "Test message")
.field("key1", "value1")
.field_num("count", 42)
.field_bool("enabled", true)
.field_opt("optional", Some("present"))
.field_opt::<String>("missing", None)
.build();
assert_eq!(event.level, LogLevel::INFO);
assert_eq!(event.target, "test");
assert_eq!(event.message, "Test message");
assert_eq!(event.fields.len(), 4);
assert!(
event
.fields
.contains(&("key1".to_string(), "value1".to_string()))
);
assert!(
event
.fields
.contains(&("count".to_string(), "42".to_string()))
);
assert!(
event
.fields
.contains(&("enabled".to_string(), "true".to_string()))
);
assert!(
event
.fields
.contains(&("optional".to_string(), "present".to_string()))
);
}
#[test]
fn test_json_serialization() {
let event = StructuredLogEvent::new(Level::ERROR, "test::module", "Error occurred")
.with_field("error_code", "E001")
.with_field("details", "Connection timeout");
let json = event.to_json().unwrap();
assert!(json.contains(r#""level":"ERROR""#));
assert!(json.contains(r#""target":"test::module""#));
assert!(json.contains(r#""message":"Error occurred""#));
assert!(json.contains(r#""error_code","E001""#));
}
#[test]
fn log_level_converts_all_tracing_levels() {
assert_eq!(LogLevel::from(Level::ERROR), LogLevel::ERROR);
assert_eq!(LogLevel::from(Level::WARN), LogLevel::WARN);
assert_eq!(LogLevel::from(Level::INFO), LogLevel::INFO);
assert_eq!(LogLevel::from(Level::DEBUG), LogLevel::DEBUG);
assert_eq!(LogLevel::from(Level::TRACE), LogLevel::TRACE);
}
#[test]
fn structured_event_chain_sets_optional_identifiers() {
let conn_id = ConnectionId::new(&[1, 2, 3, 4]);
let event = StructuredLogEvent::new(Level::DEBUG, "target", "message")
.with_fields(vec![
("alpha".to_string(), "one".to_string()),
("beta".to_string(), "two".to_string()),
])
.with_span_id("span-1")
.with_trace_id("trace-1")
.with_connection_id(&conn_id);
assert_eq!(event.fields.len(), 2);
assert_eq!(event.span_id.as_deref(), Some("span-1"));
assert_eq!(event.trace_id.as_deref(), Some("trace-1"));
assert!(event.connection_id.is_some());
}
#[test]
fn pretty_json_includes_optional_fields_when_present() {
let event = StructuredLogEvent::new(Level::INFO, "pretty", "message")
.with_trace_id("trace-2")
.with_span_id("span-2");
let json = event.to_json_pretty().unwrap();
assert!(json.contains("\n"));
assert!(json.contains(r#""trace_id": "trace-2""#));
assert!(json.contains(r#""span_id": "span-2""#));
}
#[test]
fn json_omits_trace_and_connection_when_absent() {
let event = StructuredLogEvent::new(Level::INFO, "target", "message");
let value: serde_json::Value = serde_json::from_str(&event.to_json().unwrap()).unwrap();
assert!(value.get("trace_id").is_none());
assert!(value.get("connection_id").is_none());
assert!(value.get("span_id").is_some());
}
#[test]
fn builder_sets_connection_and_span_ids() {
let conn_id = ConnectionId::new(&[9, 8, 7, 6]);
let event = StructuredEventBuilder::new(Level::WARN, "builder", "built")
.connection_id(&conn_id)
.span_id("span-builder")
.build();
assert_eq!(event.level, LogLevel::WARN);
assert_eq!(event.span_id.as_deref(), Some("span-builder"));
assert!(event.connection_id.is_some());
}
#[test]
fn extract_field_name_accepts_equals_and_colon_forms() {
assert_eq!(extract_field_name(" peer_id="), Some("peer_id".to_string()));
assert_eq!(extract_field_name("rtt_ms:"), Some("rtt_ms".to_string()));
assert_eq!(extract_field_name("not valid="), None);
assert_eq!(extract_field_name("="), None);
}
#[test]
fn parse_structured_fields_uses_following_literal_names() {
let first = 7;
let second = "ok";
let fields = parse_structured_fields("ignored={} rtt_ms={} status", &[&first, &second]);
assert_eq!(fields, vec![("rtt_ms".to_string(), "7".to_string())]);
}
#[test]
fn format_as_json_converts_plain_log_event() {
use std::collections::HashMap;
let mut fields = HashMap::new();
fields.insert("key".to_string(), "value".to_string());
let event = super::super::LogEvent {
timestamp: crate::Instant::now(),
level: Level::TRACE,
target: "plain".to_string(),
message: "converted".to_string(),
fields,
span_id: Some("span-plain".to_string()),
};
let value: serde_json::Value = serde_json::from_str(&format_as_json(&event)).unwrap();
assert_eq!(value["level"], "TRACE");
assert_eq!(value["target"], "plain");
assert_eq!(value["message"], "converted");
assert_eq!(value["fields"][0][0], "key");
assert_eq!(value["fields"][0][1], "value");
assert_eq!(value["span_id"], "span-plain");
}
}