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        let truncated_sql = if sql.len() > 2000 {
116            format!("{}...", &sql[..2000])
117        } else {
118            sql.to_string()
119        };
120
121        Self {
122            query_id: query_id.to_string(),
123            sql: truncated_sql,
124            param_count,
125            start: Instant::now(),
126            slow_threshold_us: None,
127        }
128    }
129
130    /// Set slow query threshold in microseconds.
131    pub const fn with_slow_threshold(mut self, threshold_us: u64) -> Self {
132        self.slow_threshold_us = Some(threshold_us);
133        self
134    }
135
136    /// Finish logging and create log entry (successful execution).
137    pub fn finish_success(self, rows_affected: Option<usize>) -> SqlQueryLog {
138        let duration_us = u64::try_from(self.start.elapsed().as_micros()).unwrap_or(u64::MAX);
139        let was_slow = self.slow_threshold_us.is_some_and(|t| duration_us > t);
140
141        let log = SqlQueryLog {
142            query_id: self.query_id.clone(),
143            sql: self.sql.clone(),
144            param_count: self.param_count,
145            duration_us,
146            rows_affected,
147            success: true,
148            error: None,
149            operation: SqlOperation::from_sql(&self.sql),
150            slow_threshold_us: self.slow_threshold_us,
151            was_slow,
152        };
153
154        if was_slow {
155            warn!(
156                query_id = %log.query_id,
157                operation = %log.operation,
158                duration_us = log.duration_us,
159                threshold_us = self.slow_threshold_us.unwrap_or(0),
160                "Slow SQL query detected"
161            );
162        } else {
163            debug!(
164                query_id = %log.query_id,
165                operation = %log.operation,
166                duration_us = log.duration_us,
167                params = log.param_count,
168                "SQL query executed"
169            );
170        }
171
172        log
173    }
174
175    /// Finish logging and create log entry (failed execution).
176    pub fn finish_error(self, error: &str) -> SqlQueryLog {
177        let duration_us = u64::try_from(self.start.elapsed().as_micros()).unwrap_or(u64::MAX);
178
179        let log = SqlQueryLog {
180            query_id: self.query_id.clone(),
181            sql: self.sql.clone(),
182            param_count: self.param_count,
183            duration_us,
184            rows_affected: None,
185            success: false,
186            error: Some(error.to_string()),
187            operation: SqlOperation::from_sql(&self.sql),
188            slow_threshold_us: self.slow_threshold_us,
189            was_slow: false,
190        };
191
192        warn!(
193            query_id = %log.query_id,
194            operation = %log.operation,
195            duration_us = log.duration_us,
196            error = error,
197            "SQL query failed"
198        );
199
200        log
201    }
202}
203
204impl SqlQueryLog {
205    /// Get log entry as a formatted string suitable for logging.
206    #[must_use]
207    pub fn to_log_string(&self) -> String {
208        if self.success {
209            format!(
210                "SQL {} query (query_id={}, duration_us={}, params={}, rows={:?})",
211                self.operation,
212                self.query_id,
213                self.duration_us,
214                self.param_count,
215                self.rows_affected
216            )
217        } else {
218            format!(
219                "SQL {} query FAILED (query_id={}, duration_us={}, error={})",
220                self.operation,
221                self.query_id,
222                self.duration_us,
223                self.error.as_ref().unwrap_or(&"Unknown".to_string())
224            )
225        }
226    }
227
228    /// Check if query was slow based on threshold.
229    #[must_use]
230    pub const fn is_slow(&self) -> bool {
231        self.was_slow
232    }
233
234    /// Get execution time in milliseconds (for human-friendly display).
235    #[must_use]
236    pub fn duration_ms(&self) -> f64 {
237        #[allow(clippy::cast_precision_loss)]
238        // Reason: duration_us is a microsecond counter used for display; f64 precision loss is
239        // acceptable
240        {
241            self.duration_us as f64 / 1000.0
242        }
243    }
244}
245
246/// Create a tracing span for SQL query execution.
247pub fn create_sql_span(query_id: &str, operation: SqlOperation) -> tracing::Span {
248    span!(
249        Level::DEBUG,
250        "sql_query",
251        query_id = query_id,
252        operation = %operation,
253    )
254}