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