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