Skip to main content

fraiseql_core/runtime/
query_tracing.rs

1//! Query execution tracing and observability instrumentation.
2//!
3//! Provides structured tracing for query compilation, validation, and execution phases.
4//! Integrates with the tracing crate for OpenTelemetry-compatible span collection.
5
6use std::time::Instant;
7
8use serde::{Deserialize, Serialize};
9use tracing::{Level, debug, span, warn};
10
11use crate::error::Result;
12
13/// Trace span for a query compilation phase.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct QueryPhaseSpan {
16    /// Name of the phase (e.g., "parse", "validate", "execute").
17    pub phase: String,
18
19    /// Duration in microseconds.
20    pub duration_us: u64,
21
22    /// Whether the phase succeeded.
23    pub success: bool,
24
25    /// Optional error message if phase failed.
26    pub error: Option<String>,
27}
28
29/// Complete trace for a query execution.
30///
31/// Tracks all phases from initial parse through execution,
32/// enabling performance analysis and debugging.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct QueryExecutionTrace {
35    /// Unique query ID for tracing correlation.
36    pub query_id: String,
37
38    /// Query string (truncated for large queries).
39    pub query: String,
40
41    /// List of phase spans (parse, validate, execute, etc.).
42    pub phases: Vec<QueryPhaseSpan>,
43
44    /// Total execution time in microseconds.
45    pub total_duration_us: u64,
46
47    /// Whether execution succeeded.
48    pub success: bool,
49
50    /// Optional error message.
51    pub error: Option<String>,
52
53    /// Number of results returned.
54    pub result_count: Option<usize>,
55}
56
57/// Builder for creating and tracking query execution traces.
58///
59/// # Example
60///
61/// ```rust
62/// use fraiseql_core::runtime::query_tracing::QueryTraceBuilder;
63///
64/// let mut builder = QueryTraceBuilder::new("query_123", "{ user { id name } }");
65///
66/// // Record parse phase (2.5ms = 2500 microseconds)
67/// builder.record_phase_success("parse", 2500);
68///
69/// // Record validate phase (3ms = 3000 microseconds)
70/// builder.record_phase_success("validate", 3000);
71///
72/// // Record execute phase (7ms = 7000 microseconds)
73/// builder.record_phase_success("execute", 7000);
74///
75/// // Finalize trace with result count
76/// let trace = builder.finish(true, None, Some(42))?;
77/// assert_eq!(trace.success, true);
78/// assert_eq!(trace.result_count, Some(42));
79/// println!("Query took {} microseconds", trace.total_duration_us);
80/// # Ok::<(), Box<dyn std::error::Error>>(())
81/// ```
82#[must_use = "call .finish() to construct the final value"]
83pub struct QueryTraceBuilder {
84    query_id: String,
85    query:    String,
86    phases:   Vec<QueryPhaseSpan>,
87    start:    Instant,
88}
89
90impl QueryTraceBuilder {
91    /// Create new query trace builder.
92    ///
93    /// # Arguments
94    ///
95    /// * `query_id` - Unique ID for query correlation
96    /// * `query` - Query string (will be truncated if very long)
97    pub fn new(query_id: &str, query: &str) -> Self {
98        let query_str = if query.len() > 500 {
99            format!("{}...", &query[..500])
100        } else {
101            query.to_string()
102        };
103
104        Self {
105            query_id: query_id.to_string(),
106            query:    query_str,
107            phases:   Vec::new(),
108            start:    Instant::now(),
109        }
110    }
111
112    /// Record a phase that completed successfully.
113    ///
114    /// # Arguments
115    ///
116    /// * `phase_name` - Name of phase (e.g., "parse", "validate")
117    /// * `duration_us` - Duration in microseconds
118    pub fn record_phase_success(&mut self, phase_name: &str, duration_us: u64) {
119        self.phases.push(QueryPhaseSpan {
120            phase: phase_name.to_string(),
121            duration_us,
122            success: true,
123            error: None,
124        });
125
126        debug!(phase = phase_name, duration_us = duration_us, "Query phase completed");
127    }
128
129    /// Record a phase that failed.
130    ///
131    /// # Arguments
132    ///
133    /// * `phase_name` - Name of phase (e.g., "parse", "validate")
134    /// * `duration_us` - Duration in microseconds
135    /// * `error` - Error message
136    pub fn record_phase_error(&mut self, phase_name: &str, duration_us: u64, error: &str) {
137        self.phases.push(QueryPhaseSpan {
138            phase: phase_name.to_string(),
139            duration_us,
140            success: false,
141            error: Some(error.to_string()),
142        });
143
144        warn!(
145            phase = phase_name,
146            duration_us = duration_us,
147            error = error,
148            "Query phase failed"
149        );
150    }
151
152    /// Finish tracing and create final trace record.
153    ///
154    /// # Arguments
155    ///
156    /// * `success` - Whether query executed successfully
157    /// * `error` - Optional error message
158    /// * `result_count` - Number of results returned (if applicable)
159    ///
160    /// # Errors
161    ///
162    /// Currently infallible; reserved for future extension (e.g., sink flush failures).
163    ///
164    /// # Returns
165    ///
166    /// Complete query execution trace
167    pub fn finish(
168        self,
169        success: bool,
170        error: Option<&str>,
171        result_count: Option<usize>,
172    ) -> Result<QueryExecutionTrace> {
173        let total_duration_us = u64::try_from(self.start.elapsed().as_micros()).unwrap_or(u64::MAX);
174
175        Ok(QueryExecutionTrace {
176            query_id: self.query_id.clone(),
177            query: self.query,
178            phases: self.phases,
179            total_duration_us,
180            success,
181            error: error.map(|e| e.to_string()),
182            result_count,
183        })
184    }
185
186    /// Get query ID for logging/correlation.
187    pub fn query_id(&self) -> &str {
188        &self.query_id
189    }
190
191    /// Get current elapsed time in microseconds.
192    pub fn elapsed_us(&self) -> u64 {
193        u64::try_from(self.start.elapsed().as_micros()).unwrap_or(u64::MAX)
194    }
195}
196
197impl QueryExecutionTrace {
198    /// Get average phase duration in microseconds.
199    pub fn average_phase_duration_us(&self) -> u64 {
200        if self.phases.is_empty() {
201            0
202        } else {
203            self.phases.iter().map(|p| p.duration_us).sum::<u64>() / self.phases.len() as u64
204        }
205    }
206
207    /// Get slowest phase.
208    pub fn slowest_phase(&self) -> Option<&QueryPhaseSpan> {
209        self.phases.iter().max_by_key(|p| p.duration_us)
210    }
211
212    /// Get trace as log-friendly string.
213    ///
214    /// Suitable for structured logging or monitoring dashboards.
215    pub fn to_log_string(&self) -> String {
216        let status = if self.success { "success" } else { "error" };
217        let phases_str = self
218            .phases
219            .iter()
220            .map(|p| format!("{}={}us", p.phase, p.duration_us))
221            .collect::<Vec<_>>()
222            .join(" ");
223
224        let error_str = self.error.as_ref().map(|e| format!(" error={}", e)).unwrap_or_default();
225
226        format!(
227            "query_id={} status={} total={}us phases=[{}]{}",
228            self.query_id, status, self.total_duration_us, phases_str, error_str
229        )
230    }
231}
232
233/// Create a tracing span for query execution.
234///
235/// Automatically enters a span and returns it for use with `.in_scope()`.
236///
237/// # Example
238///
239/// ```rust,no_run
240/// use fraiseql_core::runtime::query_tracing::create_query_span;
241///
242/// # fn example() {
243/// let span = create_query_span("query_123", "{ user { id } }");
244/// let _enter = span.enter();
245/// // Tracing will be recorded within this scope
246/// # }
247/// ```
248pub fn create_query_span(query_id: &str, query_text: &str) -> tracing::Span {
249    span!(
250        Level::DEBUG,
251        "query_execute",
252        query_id = query_id,
253        query = truncate_query(query_text, 100),
254    )
255}
256
257/// Create a tracing span for a specific phase.
258///
259/// # Arguments
260///
261/// * `phase_name` - Name of phase (e.g., "parse", "validate", "execute")
262/// * `query_id` - Query ID for correlation
263pub fn create_phase_span(phase_name: &str, query_id: &str) -> tracing::Span {
264    span!(Level::DEBUG, "query_phase", phase = phase_name, query_id = query_id)
265}
266
267/// Truncate query string to specified length.
268///
269/// Useful for logging to avoid truncating long queries.
270fn truncate_query(query: &str, max_len: usize) -> String {
271    if query.len() > max_len {
272        format!("{}...", &query[..max_len])
273    } else {
274        query.to_string()
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
281
282    use super::*;
283
284    #[test]
285    fn test_trace_builder_new() {
286        let builder = QueryTraceBuilder::new("query_1", "{ user { id } }");
287        assert_eq!(builder.query_id, "query_1");
288        assert_eq!(builder.query, "{ user { id } }");
289        assert!(builder.phases.is_empty());
290    }
291
292    #[test]
293    fn test_trace_builder_truncate_long_query() {
294        let long_query = "a".repeat(600);
295        let builder = QueryTraceBuilder::new("query_1", &long_query);
296        assert!(builder.query.len() < 600);
297        assert!(builder.query.ends_with("..."));
298    }
299
300    #[test]
301    fn test_record_phase_success() {
302        let mut builder = QueryTraceBuilder::new("query_1", "{ user { id } }");
303        builder.record_phase_success("parse", 100);
304        builder.record_phase_success("validate", 50);
305
306        assert_eq!(builder.phases.len(), 2);
307        assert_eq!(builder.phases[0].phase, "parse");
308        assert_eq!(builder.phases[0].duration_us, 100);
309        assert!(builder.phases[0].success);
310    }
311
312    #[test]
313    fn test_record_phase_error() {
314        let mut builder = QueryTraceBuilder::new("query_1", "{ user { id } }");
315        builder.record_phase_error("parse", 100, "Invalid syntax");
316
317        assert_eq!(builder.phases.len(), 1);
318        assert_eq!(builder.phases[0].phase, "parse");
319        assert!(!builder.phases[0].success);
320        assert_eq!(builder.phases[0].error, Some("Invalid syntax".to_string()));
321    }
322
323    #[test]
324    fn test_finish_success() {
325        let mut builder = QueryTraceBuilder::new("query_1", "{ user { id } }");
326        builder.record_phase_success("parse", 100);
327        builder.record_phase_success("execute", 500);
328
329        let trace = builder.finish(true, None, Some(10)).unwrap();
330        assert!(trace.success);
331        assert_eq!(trace.query_id, "query_1");
332        assert_eq!(trace.phases.len(), 2);
333        assert_eq!(trace.result_count, Some(10));
334        // total_duration_us is wall-clock time, may vary depending on system speed
335    }
336
337    #[test]
338    fn test_finish_error() {
339        let mut builder = QueryTraceBuilder::new("query_1", "{ user { id } }");
340        builder.record_phase_success("parse", 100);
341        builder.record_phase_error("execute", 50, "Database connection failed");
342
343        let trace = builder.finish(false, Some("Database connection failed"), None).unwrap();
344        assert!(!trace.success);
345        assert_eq!(trace.error, Some("Database connection failed".to_string()));
346    }
347
348    #[test]
349    fn test_average_phase_duration() {
350        let mut builder = QueryTraceBuilder::new("query_1", "{ user { id } }");
351        builder.record_phase_success("parse", 100);
352        builder.record_phase_success("validate", 200);
353        builder.record_phase_success("execute", 300);
354
355        let trace = builder.finish(true, None, None).unwrap();
356        assert_eq!(trace.average_phase_duration_us(), 200);
357    }
358
359    #[test]
360    fn test_slowest_phase() {
361        let mut builder = QueryTraceBuilder::new("query_1", "{ user { id } }");
362        builder.record_phase_success("parse", 100);
363        builder.record_phase_success("execute", 500);
364        builder.record_phase_success("cache_check", 50);
365
366        let trace = builder.finish(true, None, None).unwrap();
367        let slowest = trace.slowest_phase().unwrap();
368        assert_eq!(slowest.phase, "execute");
369        assert_eq!(slowest.duration_us, 500);
370    }
371
372    #[test]
373    fn test_to_log_string_success() {
374        let mut builder = QueryTraceBuilder::new("query_1", "{ user { id } }");
375        builder.record_phase_success("parse", 100);
376        builder.record_phase_success("execute", 500);
377
378        let trace = builder.finish(true, None, Some(5)).unwrap();
379        let log_str = trace.to_log_string();
380        assert!(log_str.contains("query_id=query_1"));
381        assert!(log_str.contains("status=success"));
382        assert!(log_str.contains("parse=100us"));
383        assert!(log_str.contains("execute=500us"));
384    }
385
386    #[test]
387    fn test_to_log_string_error() {
388        let mut builder = QueryTraceBuilder::new("query_1", "{ user { id } }");
389        builder.record_phase_success("parse", 100);
390        builder.record_phase_error("validate", 50, "Type mismatch");
391
392        let trace = builder.finish(false, Some("Type mismatch"), None).unwrap();
393        let log_str = trace.to_log_string();
394        assert!(log_str.contains("status=error"));
395        assert!(log_str.contains("error=Type mismatch"));
396    }
397
398    #[test]
399    fn test_average_phase_duration_empty() {
400        let builder = QueryTraceBuilder::new("query_1", "{ user { id } }");
401        let trace = builder.finish(true, None, None).unwrap();
402        assert_eq!(trace.average_phase_duration_us(), 0);
403    }
404
405    #[test]
406    fn test_elapsed_us() {
407        let builder = QueryTraceBuilder::new("query_1", "{ user { id } }");
408        let elapsed = builder.elapsed_us();
409        // Elapsed time should be non-negative (u64 is always >= 0)
410        let _ = elapsed;
411    }
412
413    #[test]
414    fn test_trace_serialization() {
415        let mut builder = QueryTraceBuilder::new("query_1", "{ user { id } }");
416        builder.record_phase_success("parse", 100);
417
418        let trace = builder.finish(true, None, Some(5)).unwrap();
419        let json = serde_json::to_string(&trace).expect("serialize should work");
420        let restored: QueryExecutionTrace =
421            serde_json::from_str(&json).expect("deserialize should work");
422
423        assert_eq!(restored.query_id, trace.query_id);
424        assert_eq!(restored.phases.len(), trace.phases.len());
425    }
426
427    #[test]
428    fn test_query_phase_span_serialize() {
429        let span = QueryPhaseSpan {
430            phase:       "parse".to_string(),
431            duration_us: 100,
432            success:     true,
433            error:       None,
434        };
435
436        let json = serde_json::to_string(&span).expect("serialize should work");
437        let restored: QueryPhaseSpan =
438            serde_json::from_str(&json).expect("deserialize should work");
439
440        assert_eq!(restored.phase, span.phase);
441        assert_eq!(restored.duration_us, span.duration_us);
442    }
443
444    #[test]
445    fn test_truncate_query_helper() {
446        assert_eq!(truncate_query("hello", 100), "hello");
447        assert!(truncate_query(&"a".repeat(200), 50).ends_with("..."));
448    }
449}