Skip to main content

teaql_runtime/
log_formatter.rs

1use crate::event::RawAuditEvent;
2use teaql_core::TraceNode;
3
4/// Represents a log entry for SQL execution
5pub use crate::context::{SqlLogEntry, SqlLogOperation};
6
7/// A trait for defining how logs should be formatted before being output
8pub trait LogFormatter: Send + Sync {
9    /// Format an SQL log entry along with its trace chain
10    fn format_sql_log(&self, trace_chain: &[TraceNode], entry: &SqlLogEntry) -> String;
11
12    /// Format an audit or mutation event log
13    fn format_audit_log(&self, event: &RawAuditEvent) -> String;
14}
15
16/// A human-readable log formatter, designed for developers and operators.
17/// Formats time, elapsed duration, and entity changes cleanly.
18pub struct HumanReaderFormatter;
19
20impl HumanReaderFormatter {
21    fn format_trace_chain(&self, trace_chain: &[TraceNode]) -> String {
22        (!trace_chain.is_empty())
23            .then(|| {
24                trace_chain
25                    .iter()
26                    .enumerate()
27                    .map(|(level, n)| {
28                        format!("{}:{:?}:{}={}", level, n.kind, n.entity_type, n.comment)
29                    })
30                    .collect::<Vec<_>>()
31                    .join(" -> ")
32            })
33            .unwrap_or_default()
34    }
35}
36
37impl LogFormatter for HumanReaderFormatter {
38    fn format_sql_log(&self, trace_chain: &[TraceNode], entry: &SqlLogEntry) -> String {
39        let ts = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S%.3f");
40        let trace_str = self.format_trace_chain(trace_chain);
41        let trace_display = (!trace_str.is_empty())
42            .then(|| format!(" - [{}]", trace_str))
43            .unwrap_or_default();
44
45        let elapsed_us = (entry.elapsed.as_secs_f64() * 1_000_000.0).round() as u64;
46        let intent = format!(
47            "comment={:?} purpose={:?} auditReason={:?}",
48            entry.comment, entry.purpose, entry.audit_reason
49        );
50        format!(
51            "[{}]-[{:>5}µs]-[DEBUG]-SqlLogEntry{} - [{}] {}\n          Parameterized SQL: {} params={:?}\n          Debug SQL: {}",
52            ts,
53            elapsed_us,
54            trace_display,
55            entry.result_summary,
56            intent,
57            entry.sql.replace('\n', " "),
58            entry.params,
59            entry.debug_sql.replace('\n', " ")
60        )
61    }
62
63    fn format_audit_log(&self, event: &RawAuditEvent) -> String {
64        let ts = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S%.3f");
65        let trace_str = self.format_trace_chain(&event.trace_chain);
66        let trace_display = (!trace_str.is_empty())
67            .then(|| format!(" (Trace: {})", trace_str))
68            .unwrap_or_default();
69
70        let mut field_changes = Vec::new();
71        for change in &event.changes {
72            if change.field.starts_with('_') {
73                continue;
74            }
75            let val = change
76                .new_value
77                .as_ref()
78                .map(|v| format!("{:?}", v))
79                .unwrap_or_else(|| "null".to_string());
80            field_changes.push(format!("{}: {}", change.field, val));
81        }
82        let fields_part = (!field_changes.is_empty())
83            .then(|| format!(" {{{}}}", field_changes.join(", ")))
84            .unwrap_or_default();
85
86        let mut entity_id = "Unknown".to_string();
87        if let Some(vals) = &event.new_values {
88            if let Some(id_val) = vals.get("id") {
89                entity_id = format!("{:?}", id_val);
90            }
91        }
92
93        format!(
94            "[{}]-[AUDIT]-Entity [{}:{}] {:?}{}{}",
95            ts, event.entity, entity_id, event.kind, trace_display, fields_part
96        )
97    }
98}
99
100/// A structured or debug formatter intended for machine consumption or fallback
101pub struct DebugReaderFormatter;
102
103impl DebugReaderFormatter {
104    fn format_trace_chain(&self, trace_chain: &[TraceNode]) -> String {
105        match trace_chain.is_empty() {
106            true => "(Trace: None)".to_string(),
107            false => format!(
108                "(Trace: {})",
109                trace_chain
110                    .iter()
111                    .enumerate()
112                    .map(|(level, n)| {
113                        format!("{}:{:?}:{}={}", level, n.kind, n.entity_type, n.comment)
114                    })
115                    .collect::<Vec<_>>()
116                    .join(" -> ")
117            ),
118        }
119    }
120}
121
122impl LogFormatter for DebugReaderFormatter {
123    fn format_sql_log(&self, trace_chain: &[TraceNode], entry: &SqlLogEntry) -> String {
124        let trace_str = self.format_trace_chain(trace_chain);
125        format!("[SQL_LOG] {} - Event: {:?}", trace_str, entry)
126    }
127
128    fn format_audit_log(&self, event: &RawAuditEvent) -> String {
129        let trace_str = self.format_trace_chain(&event.trace_chain);
130        format!("[AUDIT_LOG] {} - Event: {:?}", trace_str, event)
131    }
132}
133
134/// Factory pattern for instantiating the correct log formatter
135pub struct LogFormatterFactory;
136
137impl LogFormatterFactory {
138    /// Returns a singleton reference to the configured LogFormatter.
139    /// It dynamically switches based on the TEAQL_LOG_FORMAT environment variable.
140    pub fn get_formatter() -> &'static (dyn LogFormatter + Send + Sync) {
141        static FORMATTER: std::sync::OnceLock<Box<dyn LogFormatter + Send + Sync>> =
142            std::sync::OnceLock::new();
143        FORMATTER
144            .get_or_init(|| {
145                let format =
146                    std::env::var("TEAQL_LOG_FORMAT").unwrap_or_else(|_| "human".to_string());
147                match format.as_str() {
148                    "json" | "debug" => Box::new(DebugReaderFormatter),
149                    _ => Box::new(HumanReaderFormatter),
150                }
151            })
152            .as_ref()
153    }
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub enum LogLevel {
158    Silent,
159    Summary,
160    Full,
161    FullWithPayload,
162}
163
164impl LogLevel {
165    pub fn parse(s: &str, default: LogLevel) -> Self {
166        match s {
167            "_silent" => LogLevel::Silent,
168            "_summary" => LogLevel::Summary,
169            "_full" => LogLevel::Full,
170            "_full_with_payload" => LogLevel::FullWithPayload,
171            _ => default,
172        }
173    }
174}
175
176pub struct LogConfig {
177    pub audit_level: LogLevel,
178    pub sql_level: LogLevel,
179    pub tool_level: LogLevel,
180    pub audit_entities: Option<Vec<String>>,
181    pub sql_tables: Option<Vec<String>>,
182    pub tool_focus: Option<Vec<String>>,
183}
184
185impl LogConfig {
186    pub fn load() -> Self {
187        let audit_level = LogLevel::parse(
188            &std::env::var("TEAQL_AUDIT_LOG").unwrap_or_default(),
189            LogLevel::Full,
190        );
191        let sql_level = LogLevel::parse(
192            &std::env::var("TEAQL_SQL_LOG").unwrap_or_default(),
193            LogLevel::Summary,
194        );
195        let tool_level = LogLevel::parse(
196            &std::env::var("TEAQL_TOOL_LOG").unwrap_or_default(),
197            LogLevel::Full,
198        );
199
200        let audit_entities = std::env::var("TEAQL_AUDIT_LOG_ENTITIES")
201            .ok()
202            .map(|s| s.split(',').map(|s| s.trim().to_string()).collect());
203        let sql_tables = std::env::var("TEAQL_SQL_LOG_TABLES")
204            .ok()
205            .map(|s| s.split(',').map(|s| s.trim().to_string()).collect());
206        let tool_focus = std::env::var("TEAQL_TOOL_LOG_FOCUS")
207            .ok()
208            .map(|s| s.split(',').map(|s| s.trim().to_string()).collect());
209
210        Self {
211            audit_level,
212            sql_level,
213            tool_level,
214            audit_entities,
215            sql_tables,
216            tool_focus,
217        }
218    }
219
220    pub fn should_log_audit(&self, entity: &str) -> bool {
221        if self.audit_level == LogLevel::Silent {
222            return false;
223        }
224        if let Some(entities) = &self.audit_entities {
225            if !entities.iter().any(|e| e.eq_ignore_ascii_case(entity)) {
226                return false;
227            }
228        }
229        true
230    }
231
232    pub fn should_log_sql(&self, sql: &str) -> bool {
233        if self.sql_level == LogLevel::Silent {
234            return false;
235        }
236        if let Some(tables) = &self.sql_tables {
237            let sql_lower = sql.to_ascii_lowercase();
238            if !tables
239                .iter()
240                .any(|t| sql_lower.contains(&t.to_ascii_lowercase()))
241            {
242                return false;
243            }
244        }
245        true
246    }
247
248    pub fn should_log_tool(&self, module: &str) -> bool {
249        if self.tool_level == LogLevel::Silent {
250            return false;
251        }
252        if let Some(focus) = &self.tool_focus {
253            if !focus.iter().any(|f| f.eq_ignore_ascii_case(module)) {
254                return false;
255            }
256        }
257        true
258    }
259}
260
261/// Manager that handles reading the endpoint environment variable and dispatching to the factory
262pub struct LogManager;
263
264static LOG_ENDPOINT: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
265static HEADER_WRITTEN: std::sync::Once = std::sync::Once::new();
266
267const EXTREME_TEST_FLAG: &str =
268    "__i_agree_to_disable_runtime_trace_only_for_extreme_performance_testing";
269
270impl LogManager {
271    pub fn config() -> &'static LogConfig {
272        static CONFIG: std::sync::OnceLock<LogConfig> = std::sync::OnceLock::new();
273        CONFIG.get_or_init(LogConfig::load)
274    }
275
276    fn get_log_endpoint() -> Option<&'static str> {
277        LOG_ENDPOINT
278            .get_or_init(|| {
279                let mode = std::env::var("TEAQL_TRACE_MODE").unwrap_or_default();
280                if mode == "off" {
281                    let ack = std::env::var("TEAQL_TRACE_OFF_ACK").unwrap_or_default();
282                    if ack == EXTREME_TEST_FLAG {
283                        return Some("off".to_string());
284                    }
285                    // If they didn't sign the waiver, ignore the off request and fallthrough
286                }
287
288                std::env::var("TEAQL_LOG_ENDPOINT")
289                    .ok()
290                    .filter(|v| !v.is_empty())
291                    .or_else(|| {
292                        if let Ok(val) = std::env::var("TEAQL_DOMAIN") {
293                            if !val.is_empty() {
294                                return Some(format!("{}.log", val));
295                            }
296                        }
297                        let exe_name = std::env::current_exe()
298                            .ok()
299                            .and_then(|p| p.file_name().map(|s| s.to_string_lossy().into_owned()))
300                            .unwrap_or_else(|| "teaql".to_string());
301                        Some(format!("{}.log", exe_name))
302                    })
303            })
304            .as_deref()
305    }
306
307    fn write_header_if_needed(endpoint: &str) {
308        if endpoint == "off" {
309            return;
310        }
311        HEADER_WRITTEN.call_once(|| {
312            let header = include_str!("log_header.txt");
313            match endpoint {
314                "stdout" => println!("{}", header),
315                path => {
316                    if let Ok(mut file) = std::fs::OpenOptions::new()
317                        .create(true)
318                        .append(true)
319                        .open(path)
320                    {
321                        use std::io::Write;
322                        let _ = writeln!(file, "{}", header);
323                    }
324                }
325            }
326        });
327    }
328
329    fn write_to_file(content: &str) {
330        if let Some(endpoint) = Self::get_log_endpoint() {
331            if endpoint == "off" {
332                return;
333            }
334
335            Self::write_header_if_needed(endpoint);
336
337            match endpoint {
338                "stdout" => println!("{}", content),
339                path => {
340                    if let Ok(mut file) = std::fs::OpenOptions::new()
341                        .create(true)
342                        .append(true)
343                        .open(path)
344                    {
345                        use std::io::Write;
346                        let _ = writeln!(file, "{}", content);
347                    }
348                }
349            }
350        }
351    }
352
353    pub fn write_sql_log(trace_chain: &[TraceNode], entry: &SqlLogEntry) {
354        if !Self::config().should_log_sql(&entry.sql) {
355            return;
356        }
357        if let Some(endpoint) = Self::get_log_endpoint() {
358            if endpoint == "off" {
359                return;
360            }
361            let content = LogFormatterFactory::get_formatter().format_sql_log(trace_chain, entry);
362            Self::write_to_file(&content);
363        }
364    }
365
366    pub fn write_audit_log(event: &RawAuditEvent) {
367        if !Self::config().should_log_audit(&event.entity) {
368            return;
369        }
370        if let Some(endpoint) = Self::get_log_endpoint() {
371            if endpoint == "off" {
372                return;
373            }
374            let content = LogFormatterFactory::get_formatter().format_audit_log(event);
375            Self::write_to_file(&content);
376        }
377    }
378}