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