Skip to main content

fraiseql_core/runtime/
sql_logger.rs

1//! Structured SQL query logging and tracing.
2//!
3//! Provides comprehensive SQL query logging with:
4//! - Query parameters (sanitized for security)
5//! - Execution timing
6//! - Result metrics (rows affected)
7//! - Error tracking
8//! - Performance statistics
9
10use std::time::Instant;
11
12use serde::{Deserialize, Serialize};
13use tracing::{Level, debug, span, warn};
14
15/// SQL query log entry with execution details.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct SqlQueryLog {
18    /// Query ID for correlation
19    pub query_id: String,
20
21    /// SQL statement (truncated for very large queries)
22    pub sql: String,
23
24    /// Bound parameters count
25    pub param_count: usize,
26
27    /// Execution time in microseconds
28    pub duration_us: u64,
29
30    /// Number of rows affected/returned
31    pub rows_affected: Option<usize>,
32
33    /// Whether query executed successfully
34    pub success: bool,
35
36    /// Error message if query failed
37    pub error: Option<String>,
38
39    /// Database operation type (SELECT, INSERT, UPDATE, DELETE, etc.)
40    pub operation: SqlOperation,
41
42    /// Optional slow query warning threshold (microseconds)
43    pub slow_threshold_us: Option<u64>,
44
45    /// Whether this query exceeded slow threshold
46    pub was_slow: bool,
47}
48
49/// SQL operation type for classification.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[non_exhaustive]
52pub enum SqlOperation {
53    /// SELECT query
54    Select,
55    /// INSERT query
56    Insert,
57    /// UPDATE query
58    Update,
59    /// DELETE query
60    Delete,
61    /// Other operation (DDL, administrative)
62    Other,
63}
64
65impl SqlOperation {
66    /// Detect operation type from SQL string.
67    #[must_use]
68    pub fn from_sql(sql: &str) -> Self {
69        let trimmed = sql.trim_start().to_uppercase();
70
71        if trimmed.starts_with("SELECT") {
72            SqlOperation::Select
73        } else if trimmed.starts_with("INSERT") {
74            SqlOperation::Insert
75        } else if trimmed.starts_with("UPDATE") {
76            SqlOperation::Update
77        } else if trimmed.starts_with("DELETE") {
78            SqlOperation::Delete
79        } else {
80            SqlOperation::Other
81        }
82    }
83}
84
85impl std::fmt::Display for SqlOperation {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        match self {
88            SqlOperation::Select => write!(f, "SELECT"),
89            SqlOperation::Insert => write!(f, "INSERT"),
90            SqlOperation::Update => write!(f, "UPDATE"),
91            SqlOperation::Delete => write!(f, "DELETE"),
92            SqlOperation::Other => write!(f, "OTHER"),
93        }
94    }
95}
96
97/// Builder for creating SQL query logs.
98#[must_use = "call .finish_success() or .finish_error() to construct the final value"]
99pub struct SqlQueryLogBuilder {
100    query_id:          String,
101    sql:               String,
102    param_count:       usize,
103    start:             Instant,
104    slow_threshold_us: Option<u64>,
105}
106
107impl SqlQueryLogBuilder {
108    /// Create new SQL query log builder.
109    ///
110    /// # Arguments
111    /// * `query_id` - Unique identifier for the GraphQL query
112    /// * `sql` - The SQL statement (will be truncated if > 2000 chars)
113    /// * `param_count` - Number of bound parameters
114    pub fn new(query_id: &str, sql: &str, param_count: usize) -> Self {
115        // Char-boundary-safe: generated SQL can embed multi-byte literals from
116        // user input, so a fixed byte cut could panic (audit H20 class).
117        let truncated_sql = crate::utils::text::truncate_for_display(sql, 2000);
118
119        Self {
120            query_id: query_id.to_string(),
121            sql: truncated_sql,
122            param_count,
123            start: Instant::now(),
124            slow_threshold_us: None,
125        }
126    }
127
128    /// Set slow query threshold in microseconds.
129    pub const fn with_slow_threshold(mut self, threshold_us: u64) -> Self {
130        self.slow_threshold_us = Some(threshold_us);
131        self
132    }
133
134    /// Finish logging and create log entry (successful execution).
135    pub fn finish_success(self, rows_affected: Option<usize>) -> SqlQueryLog {
136        let duration_us = u64::try_from(self.start.elapsed().as_micros()).unwrap_or(u64::MAX);
137        let was_slow = self.slow_threshold_us.is_some_and(|t| duration_us > t);
138
139        let log = SqlQueryLog {
140            query_id: self.query_id.clone(),
141            sql: self.sql.clone(),
142            param_count: self.param_count,
143            duration_us,
144            rows_affected,
145            success: true,
146            error: None,
147            operation: SqlOperation::from_sql(&self.sql),
148            slow_threshold_us: self.slow_threshold_us,
149            was_slow,
150        };
151
152        if was_slow {
153            warn!(
154                query_id = %log.query_id,
155                operation = %log.operation,
156                duration_us = log.duration_us,
157                threshold_us = self.slow_threshold_us.unwrap_or(0),
158                "Slow SQL query detected"
159            );
160        } else {
161            debug!(
162                query_id = %log.query_id,
163                operation = %log.operation,
164                duration_us = log.duration_us,
165                params = log.param_count,
166                "SQL query executed"
167            );
168        }
169
170        log
171    }
172
173    /// Finish logging and create log entry (failed execution).
174    pub fn finish_error(self, error: &str) -> SqlQueryLog {
175        let duration_us = u64::try_from(self.start.elapsed().as_micros()).unwrap_or(u64::MAX);
176
177        let log = SqlQueryLog {
178            query_id: self.query_id.clone(),
179            sql: self.sql.clone(),
180            param_count: self.param_count,
181            duration_us,
182            rows_affected: None,
183            success: false,
184            error: Some(error.to_string()),
185            operation: SqlOperation::from_sql(&self.sql),
186            slow_threshold_us: self.slow_threshold_us,
187            was_slow: false,
188        };
189
190        warn!(
191            query_id = %log.query_id,
192            operation = %log.operation,
193            duration_us = log.duration_us,
194            error = error,
195            "SQL query failed"
196        );
197
198        log
199    }
200}
201
202impl SqlQueryLog {
203    /// Get log entry as a formatted string suitable for logging.
204    #[must_use]
205    pub fn to_log_string(&self) -> String {
206        if self.success {
207            format!(
208                "SQL {} query (query_id={}, duration_us={}, params={}, rows={:?})",
209                self.operation,
210                self.query_id,
211                self.duration_us,
212                self.param_count,
213                self.rows_affected
214            )
215        } else {
216            format!(
217                "SQL {} query FAILED (query_id={}, duration_us={}, error={})",
218                self.operation,
219                self.query_id,
220                self.duration_us,
221                self.error.as_ref().unwrap_or(&"Unknown".to_string())
222            )
223        }
224    }
225
226    /// Check if query was slow based on threshold.
227    #[must_use]
228    pub const fn is_slow(&self) -> bool {
229        self.was_slow
230    }
231
232    /// Get execution time in milliseconds (for human-friendly display).
233    #[must_use]
234    pub fn duration_ms(&self) -> f64 {
235        #[allow(clippy::cast_precision_loss)]
236        // Reason: duration_us is a microsecond counter used for display; f64 precision loss is
237        // acceptable
238        {
239            self.duration_us as f64 / 1000.0
240        }
241    }
242}
243
244/// Create a tracing span for SQL query execution.
245pub fn create_sql_span(query_id: &str, operation: SqlOperation) -> tracing::Span {
246    span!(
247        Level::DEBUG,
248        "sql_query",
249        query_id = query_id,
250        operation = %operation,
251    )
252}