use std::fmt;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Instant;
use crate::context::RequestContext;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum LogLevel {
Trace = 0,
Debug = 1,
Info = 2,
Warn = 3,
Error = 4,
}
impl LogLevel {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Trace => "trace",
Self::Debug => "debug",
Self::Info => "info",
Self::Warn => "warn",
Self::Error => "error",
}
}
#[must_use]
pub const fn as_char(&self) -> char {
match self {
Self::Trace => 'T',
Self::Debug => 'D',
Self::Info => 'I',
Self::Warn => 'W',
Self::Error => 'E',
}
}
}
impl fmt::Display for LogLevel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug)]
pub struct LogEntry {
pub level: LogLevel,
pub message: String,
pub request_id: u64,
pub region_id: String,
pub task_id: String,
pub target: Option<String>,
pub fields: Vec<(String, String)>,
pub timestamp_ns: u64,
}
impl LogEntry {
#[must_use]
pub fn new(ctx: &RequestContext, level: LogLevel, message: impl Into<String>) -> Self {
Self {
level,
message: message.into(),
request_id: ctx.request_id(),
region_id: format!("{:?}", ctx.region_id()),
task_id: format!("{:?}", ctx.task_id()),
target: None,
fields: Vec::new(),
timestamp_ns: 0, }
}
#[must_use]
pub fn target(mut self, target: impl Into<String>) -> Self {
self.target = Some(target.into());
self
}
#[must_use]
pub fn field(mut self, key: impl Into<String>, value: impl fmt::Display) -> Self {
if self.fields.len() < 16 {
self.fields.push((key.into(), value.to_string()));
}
self
}
#[must_use]
pub fn to_json(&self) -> String {
let mut json = format!(
r#"{{"timestamp_ns":{},"level":"{}","message":"{}","request_id":{},"region_id":"{}","task_id":"{}""#,
self.timestamp_ns,
self.level,
escape_json(&self.message),
self.request_id,
escape_json(&self.region_id),
escape_json(&self.task_id)
);
if let Some(ref target) = self.target {
json.push_str(&format!(r#","target":"{}""#, escape_json(target)));
}
if !self.fields.is_empty() {
json.push_str(r#","fields":{"#);
for (i, (k, v)) in self.fields.iter().enumerate() {
if i > 0 {
json.push(',');
}
json.push_str(&format!(r#""{}":"{}""#, escape_json(k), escape_json(v)));
}
json.push('}');
}
json.push('}');
json
}
#[must_use]
pub fn to_compact(&self) -> String {
let mut output = format!(
"[{}] req={} {}",
self.level.as_char(),
self.request_id,
self.message
);
if !self.fields.is_empty() {
output.push_str(" {");
for (i, (k, v)) in self.fields.iter().enumerate() {
if i > 0 {
output.push_str(", ");
}
output.push_str(&format!("{k}={v}"));
}
output.push('}');
}
output
}
}
fn escape_json(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t")
}
pub struct Span {
name: String,
request_id: u64,
start: Instant,
span_id: u64,
parent_id: Option<u64>,
ended: bool,
}
impl Span {
#[must_use]
pub fn new(ctx: &RequestContext, name: impl Into<String>) -> Self {
static SPAN_COUNTER: AtomicU64 = AtomicU64::new(1);
Self {
name: name.into(),
request_id: ctx.request_id(),
start: Instant::now(),
span_id: SPAN_COUNTER.fetch_add(1, Ordering::SeqCst),
parent_id: None,
ended: false,
}
}
#[must_use]
pub fn child(&self, ctx: &RequestContext, name: impl Into<String>) -> Self {
static SPAN_COUNTER: AtomicU64 = AtomicU64::new(1);
Self {
name: name.into(),
request_id: ctx.request_id(),
start: Instant::now(),
span_id: SPAN_COUNTER.fetch_add(1, Ordering::SeqCst),
parent_id: Some(self.span_id),
ended: false,
}
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn span_id(&self) -> u64 {
self.span_id
}
#[must_use]
pub fn parent_id(&self) -> Option<u64> {
self.parent_id
}
#[must_use]
pub fn elapsed(&self) -> std::time::Duration {
self.start.elapsed()
}
pub fn end(&mut self) -> std::time::Duration {
let duration = self.elapsed();
if !self.ended {
self.ended = true;
}
duration
}
#[must_use]
pub fn to_json(&self) -> String {
let duration = self.elapsed();
let mut json = format!(
r#"{{"span_id":{},"name":"{}","request_id":{},"duration_us":{}"#,
self.span_id,
escape_json(&self.name),
self.request_id,
duration.as_micros()
);
if let Some(parent) = self.parent_id {
json.push_str(&format!(r#","parent_id":{parent}"#));
}
json.push('}');
json
}
}
impl Drop for Span {
fn drop(&mut self) {
if !self.ended {
self.end();
}
}
}
pub struct AutoSpan {
inner: Span,
ctx: RequestContext,
config: LogConfig,
}
impl AutoSpan {
#[must_use]
pub fn new(ctx: &RequestContext, name: impl Into<String>) -> Self {
Self::new_with_config(ctx, LogConfig::default(), name)
}
#[must_use]
pub fn new_with_config(
ctx: &RequestContext,
config: LogConfig,
name: impl Into<String>,
) -> Self {
Self {
inner: Span::new(ctx, name),
ctx: ctx.clone(),
config,
}
}
}
impl Drop for AutoSpan {
fn drop(&mut self) {
let duration = self.inner.end();
let logger = RequestLogger::new(&self.ctx, self.config.clone());
logger.debug_with_fields("Span ended", |e| {
let mut e = e.target(module_path!());
e = e.field("span", self.inner.name());
e = e.field("span_id", self.inner.span_id());
if let Some(parent_id) = self.inner.parent_id() {
e = e.field("parent_id", parent_id);
}
e.field("duration_us", duration.as_micros())
});
}
}
#[derive(Debug, Clone)]
pub struct LogConfig {
pub min_level: LogLevel,
pub json_output: bool,
pub include_target: bool,
pub max_fields: usize,
}
impl Default for LogConfig {
fn default() -> Self {
Self {
min_level: LogLevel::Info,
json_output: true,
include_target: true,
max_fields: 16,
}
}
}
impl LogConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn level(mut self, level: LogLevel) -> Self {
self.min_level = level;
self
}
#[must_use]
pub fn json_output(mut self, json: bool) -> Self {
self.json_output = json;
self
}
#[must_use]
pub fn include_target(mut self, include: bool) -> Self {
self.include_target = include;
self
}
#[must_use]
pub fn max_fields(mut self, max: usize) -> Self {
self.max_fields = max;
self
}
#[must_use]
pub fn development() -> Self {
Self {
min_level: LogLevel::Debug,
json_output: false,
include_target: true,
max_fields: 16,
}
}
#[must_use]
pub fn production() -> Self {
Self {
min_level: LogLevel::Info,
json_output: true,
include_target: true,
max_fields: 16,
}
}
#[must_use]
pub fn testing() -> Self {
Self {
min_level: LogLevel::Trace,
json_output: true,
include_target: true,
max_fields: 16,
}
}
}
use std::sync::atomic::AtomicUsize;
static GLOBAL_LOG_LEVEL: AtomicUsize = AtomicUsize::new(LogLevel::Info as usize);
#[inline]
#[must_use]
pub fn global_log_level() -> LogLevel {
match GLOBAL_LOG_LEVEL.load(Ordering::Relaxed) {
0 => LogLevel::Trace,
1 => LogLevel::Debug,
2 => LogLevel::Info,
3 => LogLevel::Warn,
_ => LogLevel::Error,
}
}
pub fn set_global_log_level(level: LogLevel) {
GLOBAL_LOG_LEVEL.store(level as usize, Ordering::Relaxed);
}
#[inline]
#[must_use]
pub fn level_enabled(level: LogLevel) -> bool {
level >= global_log_level()
}
pub struct RequestLogger<'a> {
ctx: &'a RequestContext,
config: LogConfig,
}
impl<'a> RequestLogger<'a> {
#[must_use]
pub fn new(ctx: &'a RequestContext, config: LogConfig) -> Self {
Self { ctx, config }
}
#[inline]
#[must_use]
pub fn is_enabled(&self, level: LogLevel) -> bool {
level >= self.config.min_level && level_enabled(level)
}
pub fn emit(&self, entry: LogEntry) {
if !self.is_enabled(entry.level) {
return;
}
let output = if self.config.json_output {
entry.to_json()
} else {
entry.to_compact()
};
eprintln!("{output}");
}
pub fn trace(&self, message: impl Into<String>) {
if self.is_enabled(LogLevel::Trace) {
self.emit(LogEntry::new(self.ctx, LogLevel::Trace, message));
}
}
pub fn debug(&self, message: impl Into<String>) {
if self.is_enabled(LogLevel::Debug) {
self.emit(LogEntry::new(self.ctx, LogLevel::Debug, message));
}
}
pub fn info(&self, message: impl Into<String>) {
if self.is_enabled(LogLevel::Info) {
self.emit(LogEntry::new(self.ctx, LogLevel::Info, message));
}
}
pub fn warn(&self, message: impl Into<String>) {
if self.is_enabled(LogLevel::Warn) {
self.emit(LogEntry::new(self.ctx, LogLevel::Warn, message));
}
}
pub fn error(&self, message: impl Into<String>) {
if self.is_enabled(LogLevel::Error) {
self.emit(LogEntry::new(self.ctx, LogLevel::Error, message));
}
}
pub fn trace_with_fields<F>(&self, message: impl Into<String>, f: F)
where
F: FnOnce(LogEntry) -> LogEntry,
{
if self.is_enabled(LogLevel::Trace) {
let entry = f(LogEntry::new(self.ctx, LogLevel::Trace, message));
self.emit(entry);
}
}
pub fn debug_with_fields<F>(&self, message: impl Into<String>, f: F)
where
F: FnOnce(LogEntry) -> LogEntry,
{
if self.is_enabled(LogLevel::Debug) {
let entry = f(LogEntry::new(self.ctx, LogLevel::Debug, message));
self.emit(entry);
}
}
pub fn info_with_fields<F>(&self, message: impl Into<String>, f: F)
where
F: FnOnce(LogEntry) -> LogEntry,
{
if self.is_enabled(LogLevel::Info) {
let entry = f(LogEntry::new(self.ctx, LogLevel::Info, message));
self.emit(entry);
}
}
pub fn warn_with_fields<F>(&self, message: impl Into<String>, f: F)
where
F: FnOnce(LogEntry) -> LogEntry,
{
if self.is_enabled(LogLevel::Warn) {
let entry = f(LogEntry::new(self.ctx, LogLevel::Warn, message));
self.emit(entry);
}
}
pub fn error_with_fields<F>(&self, message: impl Into<String>, f: F)
where
F: FnOnce(LogEntry) -> LogEntry,
{
if self.is_enabled(LogLevel::Error) {
let entry = f(LogEntry::new(self.ctx, LogLevel::Error, message));
self.emit(entry);
}
}
#[must_use]
pub fn span(&self, name: impl Into<String>) -> Span {
Span::new(self.ctx, name)
}
#[must_use]
pub fn span_auto(&self, name: impl Into<String>) -> AutoSpan {
AutoSpan::new_with_config(self.ctx, self.config.clone(), name)
}
}
#[macro_export]
macro_rules! log_trace {
($ctx:expr, $msg:expr) => {
$crate::logging::LogEntry::new($ctx, $crate::logging::LogLevel::Trace, $msg)
.target(module_path!())
};
($ctx:expr, $msg:expr, $($key:ident => $value:expr),+ $(,)?) => {
$crate::logging::LogEntry::new($ctx, $crate::logging::LogLevel::Trace, $msg)
.target(module_path!())
$(.field(stringify!($key), $value))+
};
($ctx:expr, $fmt:expr, $($arg:tt)*) => {
$crate::logging::LogEntry::new($ctx, $crate::logging::LogLevel::Trace, format!($fmt, $($arg)*))
.target(module_path!())
};
}
#[macro_export]
macro_rules! log_debug {
($ctx:expr, $msg:expr) => {
$crate::logging::LogEntry::new($ctx, $crate::logging::LogLevel::Debug, $msg)
.target(module_path!())
};
($ctx:expr, $msg:expr, $($key:ident => $value:expr),+ $(,)?) => {
$crate::logging::LogEntry::new($ctx, $crate::logging::LogLevel::Debug, $msg)
.target(module_path!())
$(.field(stringify!($key), $value))+
};
($ctx:expr, $fmt:expr, $($arg:tt)*) => {
$crate::logging::LogEntry::new($ctx, $crate::logging::LogLevel::Debug, format!($fmt, $($arg)*))
.target(module_path!())
};
}
#[macro_export]
macro_rules! log_info {
($ctx:expr, $msg:expr) => {
$crate::logging::LogEntry::new($ctx, $crate::logging::LogLevel::Info, $msg)
.target(module_path!())
};
($ctx:expr, $msg:expr, $($key:ident => $value:expr),+ $(,)?) => {
$crate::logging::LogEntry::new($ctx, $crate::logging::LogLevel::Info, $msg)
.target(module_path!())
$(.field(stringify!($key), $value))+
};
($ctx:expr, $fmt:expr, $($arg:tt)*) => {
$crate::logging::LogEntry::new($ctx, $crate::logging::LogLevel::Info, format!($fmt, $($arg)*))
.target(module_path!())
};
}
#[macro_export]
macro_rules! log_warn {
($ctx:expr, $msg:expr) => {
$crate::logging::LogEntry::new($ctx, $crate::logging::LogLevel::Warn, $msg)
.target(module_path!())
};
($ctx:expr, $msg:expr, $($key:ident => $value:expr),+ $(,)?) => {
$crate::logging::LogEntry::new($ctx, $crate::logging::LogLevel::Warn, $msg)
.target(module_path!())
$(.field(stringify!($key), $value))+
};
($ctx:expr, $fmt:expr, $($arg:tt)*) => {
$crate::logging::LogEntry::new($ctx, $crate::logging::LogLevel::Warn, format!($fmt, $($arg)*))
.target(module_path!())
};
}
#[macro_export]
macro_rules! log_error {
($ctx:expr, $msg:expr) => {
$crate::logging::LogEntry::new($ctx, $crate::logging::LogLevel::Error, $msg)
.target(module_path!())
};
($ctx:expr, $msg:expr, $($key:ident => $value:expr),+ $(,)?) => {
$crate::logging::LogEntry::new($ctx, $crate::logging::LogLevel::Error, $msg)
.target(module_path!())
$(.field(stringify!($key), $value))+
};
($ctx:expr, $fmt:expr, $($arg:tt)*) => {
$crate::logging::LogEntry::new($ctx, $crate::logging::LogLevel::Error, format!($fmt, $($arg)*))
.target(module_path!())
};
}
#[cfg(test)]
mod tests {
use super::*;
use asupersync::Cx;
fn test_context() -> crate::context::RequestContext {
let cx = Cx::for_testing();
crate::context::RequestContext::new(cx, 12345)
}
#[test]
fn log_level_ordering() {
assert!(LogLevel::Trace < LogLevel::Debug);
assert!(LogLevel::Debug < LogLevel::Info);
assert!(LogLevel::Info < LogLevel::Warn);
assert!(LogLevel::Warn < LogLevel::Error);
}
#[test]
fn log_level_display() {
assert_eq!(LogLevel::Info.as_str(), "info");
assert_eq!(LogLevel::Error.as_char(), 'E');
}
#[test]
fn log_entry_json() {
let ctx = test_context();
let entry = LogEntry::new(&ctx, LogLevel::Info, "Test message")
.target("test::module")
.field("user_id", 42)
.field("action", "login");
let json = entry.to_json();
assert!(json.contains(r#""level":"info""#));
assert!(json.contains(r#""message":"Test message""#));
assert!(json.contains(r#""request_id":12345"#));
assert!(json.contains(r#""target":"test::module""#));
assert!(json.contains(r#""user_id":"42""#));
assert!(json.contains(r#""action":"login""#));
}
#[test]
fn log_entry_compact() {
let ctx = test_context();
let entry =
LogEntry::new(&ctx, LogLevel::Warn, "Something happened").field("error_code", "E001");
let compact = entry.to_compact();
assert!(compact.starts_with("[W] req=12345"));
assert!(compact.contains("Something happened"));
assert!(compact.contains("error_code=E001"));
}
#[test]
fn span_timing() {
let ctx = test_context();
let mut span = Span::new(&ctx, "test_operation");
std::thread::sleep(std::time::Duration::from_millis(1));
let duration = span.end();
assert!(duration.as_micros() >= 1000);
assert!(span.ended);
}
#[test]
fn span_child() {
let ctx = test_context();
let parent = Span::new(&ctx, "parent");
let child = parent.child(&ctx, "child");
assert_eq!(child.parent_id(), Some(parent.span_id()));
}
#[test]
fn span_json() {
let ctx = test_context();
let span = Span::new(&ctx, "db_query");
let json = span.to_json();
assert!(json.contains(r#""name":"db_query""#));
assert!(json.contains(r#""request_id":12345"#));
}
#[test]
fn log_config_presets() {
let dev = LogConfig::development();
assert_eq!(dev.min_level, LogLevel::Debug);
assert!(!dev.json_output);
let prod = LogConfig::production();
assert_eq!(prod.min_level, LogLevel::Info);
assert!(prod.json_output);
let test = LogConfig::testing();
assert_eq!(test.min_level, LogLevel::Trace);
}
#[test]
fn escape_json_special_chars() {
assert_eq!(escape_json("hello\nworld"), "hello\\nworld");
assert_eq!(escape_json(r#"say "hi""#), r#"say \"hi\""#);
assert_eq!(escape_json("tab\there"), "tab\\there");
}
#[test]
fn log_macro_basic() {
let ctx = test_context();
let entry = log_info!(&ctx, "Basic message");
assert_eq!(entry.level, LogLevel::Info);
assert_eq!(entry.message, "Basic message");
assert_eq!(entry.request_id, 12345);
}
#[test]
fn log_macro_with_fields() {
let ctx = test_context();
let entry = log_info!(&ctx, "With fields", user_id => 42, action => "login");
assert_eq!(entry.fields.len(), 2);
assert_eq!(entry.fields[0], ("user_id".to_string(), "42".to_string()));
assert_eq!(entry.fields[1], ("action".to_string(), "login".to_string()));
}
#[test]
fn log_macro_format_string() {
let ctx = test_context();
let item_id = 99;
let entry = log_debug!(&ctx, "Processing item {}", item_id);
assert_eq!(entry.level, LogLevel::Debug);
assert_eq!(entry.message, "Processing item 99");
}
#[test]
fn autospan_creates_and_ends_on_drop() {
let ctx = test_context();
let start = std::time::Instant::now();
{
let _span = AutoSpan::new(&ctx, "short_operation");
std::thread::sleep(std::time::Duration::from_millis(1));
}
let elapsed = start.elapsed();
assert!(elapsed.as_millis() >= 1);
}
#[test]
fn autospan_captures_request_id() {
let ctx = test_context();
let span = AutoSpan::new(&ctx, "test_span");
assert_eq!(span.ctx.request_id(), 12345);
}
#[test]
fn span_nested_three_levels_deep() {
let ctx = test_context();
let parent = Span::new(&ctx, "level_1");
let child = parent.child(&ctx, "level_2");
let grandchild = child.child(&ctx, "level_3");
assert!(parent.parent_id().is_none());
assert_eq!(child.parent_id(), Some(parent.span_id()));
assert_eq!(grandchild.parent_id(), Some(child.span_id()));
assert_eq!(parent.name(), "level_1");
assert_eq!(child.name(), "level_2");
assert_eq!(grandchild.name(), "level_3");
}
#[test]
fn span_nested_five_levels_deep() {
let ctx = test_context();
let level1 = Span::new(&ctx, "request_handler");
let level2 = level1.child(&ctx, "middleware");
let level3 = level2.child(&ctx, "auth_check");
let level4 = level3.child(&ctx, "token_validation");
let level5 = level4.child(&ctx, "signature_verify");
assert!(level1.parent_id().is_none());
assert_eq!(level2.parent_id(), Some(level1.span_id()));
assert_eq!(level3.parent_id(), Some(level2.span_id()));
assert_eq!(level4.parent_id(), Some(level3.span_id()));
assert_eq!(level5.parent_id(), Some(level4.span_id()));
}
#[test]
fn span_sibling_spans_have_same_parent() {
let ctx = test_context();
let parent = Span::new(&ctx, "parent");
let sibling1 = parent.child(&ctx, "sibling_a");
let sibling2 = parent.child(&ctx, "sibling_b");
let sibling3 = parent.child(&ctx, "sibling_c");
assert_eq!(sibling1.parent_id(), Some(parent.span_id()));
assert_eq!(sibling2.parent_id(), Some(parent.span_id()));
assert_eq!(sibling3.parent_id(), Some(parent.span_id()));
assert_ne!(sibling1.span_id(), sibling2.span_id());
assert_ne!(sibling2.span_id(), sibling3.span_id());
assert_ne!(sibling1.span_id(), sibling3.span_id());
}
#[test]
fn span_ids_are_globally_unique() {
let ctx = test_context();
let span1 = Span::new(&ctx, "span1");
let span2 = Span::new(&ctx, "span2");
let span3 = Span::new(&ctx, "span3");
assert_ne!(span1.span_id(), span2.span_id());
assert_ne!(span2.span_id(), span3.span_id());
assert_ne!(span1.span_id(), span3.span_id());
}
#[test]
fn span_request_id_propagates_to_children() {
let ctx = test_context();
let parent = Span::new(&ctx, "parent");
let child = parent.child(&ctx, "child");
let grandchild = child.child(&ctx, "grandchild");
assert_eq!(parent.request_id, 12345);
assert_eq!(child.request_id, 12345);
assert_eq!(grandchild.request_id, 12345);
}
#[test]
fn span_json_includes_parent_id_when_present() {
let ctx = test_context();
let parent = Span::new(&ctx, "parent");
let child = parent.child(&ctx, "child");
let parent_json = parent.to_json();
assert!(!parent_json.contains("parent_id"));
let child_json = child.to_json();
assert!(child_json.contains("parent_id"));
assert!(child_json.contains(&parent.span_id().to_string()));
}
#[test]
fn span_elapsed_increases_over_time() {
let ctx = test_context();
let span = Span::new(&ctx, "timing_test");
let elapsed1 = span.elapsed();
std::thread::sleep(std::time::Duration::from_millis(2));
let elapsed2 = span.elapsed();
assert!(elapsed2 > elapsed1);
}
#[test]
fn span_end_returns_final_duration() {
let ctx = test_context();
let mut span = Span::new(&ctx, "duration_test");
std::thread::sleep(std::time::Duration::from_millis(1));
let duration = span.end();
assert!(duration.as_millis() >= 1);
assert!(span.ended);
}
#[test]
fn span_multiple_end_calls_idempotent() {
let ctx = test_context();
let mut span = Span::new(&ctx, "multi_end_test");
std::thread::sleep(std::time::Duration::from_millis(1));
let duration1 = span.end();
let duration2 = span.end();
let duration3 = span.end();
assert!(duration1.as_micros() > 0);
assert!(duration2.as_micros() > 0);
assert!(duration3.as_micros() > 0);
}
#[test]
fn span_drop_ends_span_automatically() {
let ctx = test_context();
{
let span = Span::new(&ctx, "auto_end_test");
assert!(!span.ended);
}
}
#[test]
fn nested_spans_timing_accumulates_correctly() {
let ctx = test_context();
let start = std::time::Instant::now();
let mut parent = Span::new(&ctx, "parent");
std::thread::sleep(std::time::Duration::from_millis(2));
{
let mut child = parent.child(&ctx, "child");
std::thread::sleep(std::time::Duration::from_millis(2));
let child_duration = child.end();
assert!(child_duration.as_millis() >= 2);
}
let parent_duration = parent.end();
assert!(parent_duration.as_millis() >= 4);
let total = start.elapsed();
assert!(total.as_millis() >= 4);
}
}