hyperstack_interpreter/
canonical_log.rs

1//! Canonical Structured Logging
2//!
3//! Accumulates context throughout event processing, emits ONE log line at the end.
4//!
5//! When the `otel` feature is enabled, trace context (trace_id, span_id) is automatically
6//! included in emitted logs for correlation with distributed traces.
7
8use serde::Serialize;
9use serde_json::{json, Value};
10use std::collections::HashMap;
11use std::time::Instant;
12
13#[cfg(feature = "otel")]
14use opentelemetry::trace::TraceContextExt;
15#[cfg(feature = "otel")]
16use tracing_opentelemetry::OpenTelemetrySpanExt;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
19pub enum LogLevel {
20    Trace,
21    Debug,
22    #[default]
23    Info,
24    Warn,
25    Error,
26}
27
28pub struct CanonicalLog {
29    data: HashMap<String, Value>,
30    start: Instant,
31    level: LogLevel,
32    emitted: bool,
33}
34
35impl CanonicalLog {
36    pub fn new() -> Self {
37        Self {
38            data: HashMap::new(),
39            start: Instant::now(),
40            level: LogLevel::Info,
41            emitted: false,
42        }
43    }
44
45    pub fn set(&mut self, key: impl Into<String>, value: impl Serialize) -> &mut Self {
46        if let Ok(v) = serde_json::to_value(value) {
47            self.data.insert(key.into(), v);
48        }
49        self
50    }
51
52    pub fn set_level(&mut self, level: LogLevel) -> &mut Self {
53        self.level = level;
54        self
55    }
56
57    pub fn inc(&mut self, key: &str, amount: i64) -> &mut Self {
58        let current = self.data.get(key).and_then(|v| v.as_i64()).unwrap_or(0);
59        self.data.insert(key.to_string(), json!(current + amount));
60        self
61    }
62
63    pub fn duration_ms(&self) -> f64 {
64        self.start.elapsed().as_secs_f64() * 1000.0
65    }
66
67    pub fn suppress(&mut self) {
68        self.emitted = true;
69    }
70
71    pub fn emit(mut self) {
72        self.do_emit();
73    }
74
75    fn do_emit(&mut self) {
76        if self.emitted {
77            return;
78        }
79        self.emitted = true;
80
81        self.data
82            .insert("duration_ms".to_string(), json!(self.duration_ms()));
83
84        #[cfg(feature = "otel")]
85        {
86            let span = tracing::Span::current();
87            let context = span.context();
88            let span_ref = context.span();
89            let span_context = span_ref.span_context();
90            if span_context.is_valid() {
91                self.data.insert(
92                    "trace_id".to_string(),
93                    json!(format!("{:032x}", span_context.trace_id())),
94                );
95                self.data.insert(
96                    "span_id".to_string(),
97                    json!(format!("{:016x}", span_context.span_id())),
98                );
99            }
100        }
101
102        // Emit as a structured field so OTEL/Axiom can parse it, rather than embedding JSON in message body
103        let canonical = serde_json::to_string(&self.data).unwrap_or_else(|_| "{}".to_string());
104
105        match self.level {
106            LogLevel::Trace => {
107                tracing::trace!(target: "hyperstack::canonical", canonical = %canonical, "canonical_event")
108            }
109            LogLevel::Debug => {
110                tracing::debug!(target: "hyperstack::canonical", canonical = %canonical, "canonical_event")
111            }
112            LogLevel::Info => {
113                tracing::info!(target: "hyperstack::canonical", canonical = %canonical, "canonical_event")
114            }
115            LogLevel::Warn => {
116                tracing::warn!(target: "hyperstack::canonical", canonical = %canonical, "canonical_event")
117            }
118            LogLevel::Error => {
119                tracing::error!(target: "hyperstack::canonical", canonical = %canonical, "canonical_event")
120            }
121        }
122    }
123}
124
125impl Default for CanonicalLog {
126    fn default() -> Self {
127        Self::new()
128    }
129}
130
131impl Drop for CanonicalLog {
132    fn drop(&mut self) {
133        self.do_emit();
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn test_basic_usage() {
143        let mut log = CanonicalLog::new();
144        log.set("event_type", "BuyIxState")
145            .set("slot", 12345)
146            .set("mutations", 3);
147        log.suppress();
148        assert!(log.data.contains_key("event_type"));
149    }
150
151    #[test]
152    fn test_increment() {
153        let mut log = CanonicalLog::new();
154        log.inc("cache_hits", 1);
155        log.inc("cache_hits", 1);
156        log.inc("cache_hits", 1);
157        log.suppress();
158        assert_eq!(log.data.get("cache_hits"), Some(&json!(3)));
159    }
160}