Skip to main content

mini_apm/models/
span.rs

1use crate::DbPool;
2use base64::{Engine as _, engine::general_purpose::STANDARD};
3use chrono::DateTime;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7// ============================================================================
8// OTLP/HTTP JSON Ingestion Types (matching OTLP protobuf JSON mapping)
9// ============================================================================
10
11#[derive(Debug, Deserialize)]
12#[serde(rename_all = "camelCase")]
13pub struct OtlpTraceRequest {
14    pub resource_spans: Vec<ResourceSpans>,
15}
16
17#[derive(Debug, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct ResourceSpans {
20    pub resource: Option<Resource>,
21    pub scope_spans: Option<Vec<ScopeSpans>>,
22}
23
24#[derive(Debug, Deserialize)]
25pub struct Resource {
26    pub attributes: Option<Vec<KeyValue>>,
27}
28
29#[derive(Debug, Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub struct ScopeSpans {
32    pub scope: Option<InstrumentationScope>,
33    pub spans: Vec<OtlpSpan>,
34}
35
36#[derive(Debug, Deserialize)]
37pub struct InstrumentationScope {
38    pub name: Option<String>,
39    pub version: Option<String>,
40}
41
42#[derive(Debug, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub struct OtlpSpan {
45    pub trace_id: String,
46    pub span_id: String,
47    pub parent_span_id: Option<String>,
48    pub name: String,
49    pub kind: Option<i32>,
50    pub start_time_unix_nano: String,
51    pub end_time_unix_nano: String,
52    pub attributes: Option<Vec<KeyValue>>,
53    pub events: Option<Vec<SpanEvent>>,
54    pub status: Option<SpanStatus>,
55}
56
57#[derive(Debug, Clone, Deserialize, Serialize)]
58pub struct KeyValue {
59    pub key: String,
60    pub value: AttributeValue,
61}
62
63#[derive(Debug, Clone, Deserialize, Serialize)]
64#[serde(rename_all = "camelCase")]
65pub struct AttributeValue {
66    pub string_value: Option<String>,
67    pub int_value: Option<String>,
68    pub double_value: Option<f64>,
69    pub bool_value: Option<bool>,
70    pub array_value: Option<ArrayValue>,
71}
72
73#[derive(Debug, Clone, Deserialize, Serialize)]
74pub struct ArrayValue {
75    pub values: Option<Vec<AttributeValue>>,
76}
77
78#[derive(Debug, Clone, Deserialize, Serialize)]
79#[serde(rename_all = "camelCase")]
80pub struct SpanEvent {
81    pub name: String,
82    pub time_unix_nano: Option<String>,
83    pub attributes: Option<Vec<KeyValue>>,
84}
85
86#[derive(Debug, Deserialize)]
87pub struct SpanStatus {
88    pub code: Option<i32>,
89    pub message: Option<String>,
90}
91
92// ============================================================================
93// Internal Types
94// ============================================================================
95
96#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
97#[serde(rename_all = "snake_case")]
98pub enum SpanCategory {
99    HttpServer,
100    HttpClient,
101    Db,
102    View,
103    Search,
104    Job,
105    Command,
106    Internal,
107}
108
109impl SpanCategory {
110    pub fn from_attributes(name: &str, kind: i32, attributes: &HashMap<String, String>) -> Self {
111        // Check for database spans first
112        if attributes.contains_key("db.system") || attributes.contains_key("db.statement") {
113            let db_system = attributes
114                .get("db.system")
115                .map(|s| s.as_str())
116                .unwrap_or("");
117            if db_system == "elasticsearch" || db_system == "opensearch" {
118                return SpanCategory::Search;
119            }
120            return SpanCategory::Db;
121        }
122
123        // Check for HTTP spans
124        let has_http = attributes.contains_key("http.url")
125            || attributes.contains_key("http.method")
126            || attributes.contains_key("url.full")
127            || attributes.contains_key("http.request.method");
128
129        if has_http {
130            // kind: 2 = SERVER, 3 = CLIENT
131            if kind == 3 {
132                return SpanCategory::HttpClient;
133            }
134            if kind == 2 {
135                return SpanCategory::HttpServer;
136            }
137        }
138
139        // Check for view rendering
140        if name.starts_with("render_template")
141            || name.starts_with("render_partial")
142            || name.starts_with("render_collection")
143            || name.contains(".erb")
144            || name.contains(".haml")
145            || name.contains(".slim")
146            || name.contains("ActionView")
147        {
148            return SpanCategory::View;
149        }
150
151        // Check for messaging/job spans
152        // kind: 4 = PRODUCER, 5 = CONSUMER
153        if kind == 4 || kind == 5 {
154            return SpanCategory::Job;
155        }
156        if attributes.contains_key("messaging.system")
157            || attributes.contains_key("messaging.destination.name")
158        {
159            return SpanCategory::Job;
160        }
161
162        // Check by name patterns
163        let name_lower = name.to_lowercase();
164        if name_lower.contains("sidekiq")
165            || name_lower.contains("activejob")
166            || name_lower.contains("active_job")
167            || name_lower.contains("perform")
168        {
169            return SpanCategory::Job;
170        }
171
172        // Command runners: rake, thor, make, etc.
173        if name_lower.starts_with("rake:")
174            || name_lower.starts_with("rake ")
175            || name_lower.contains("rake::task")
176            || name_lower.starts_with("thor:")
177            || name_lower.starts_with("make:")
178        {
179            return SpanCategory::Command;
180        }
181
182        SpanCategory::Internal
183    }
184
185    pub fn as_str(&self) -> &'static str {
186        match self {
187            SpanCategory::HttpServer => "http_server",
188            SpanCategory::HttpClient => "http_client",
189            SpanCategory::Db => "db",
190            SpanCategory::View => "view",
191            SpanCategory::Search => "search",
192            SpanCategory::Job => "job",
193            SpanCategory::Command => "command",
194            SpanCategory::Internal => "internal",
195        }
196    }
197
198    pub fn parse(s: &str) -> Self {
199        match s {
200            "http_server" => SpanCategory::HttpServer,
201            "http_client" => SpanCategory::HttpClient,
202            "db" => SpanCategory::Db,
203            "view" => SpanCategory::View,
204            "search" => SpanCategory::Search,
205            "job" => SpanCategory::Job,
206            "command" => SpanCategory::Command,
207            _ => SpanCategory::Internal,
208        }
209    }
210}
211
212#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
213#[serde(rename_all = "snake_case")]
214pub enum RootSpanType {
215    Web,
216    Job,
217    Command,
218}
219
220impl RootSpanType {
221    pub fn from_category(category: SpanCategory) -> Option<Self> {
222        match category {
223            SpanCategory::HttpServer => Some(RootSpanType::Web),
224            SpanCategory::Job => Some(RootSpanType::Job),
225            SpanCategory::Command => Some(RootSpanType::Command),
226            _ => None,
227        }
228    }
229
230    pub fn as_str(&self) -> &'static str {
231        match self {
232            RootSpanType::Web => "web",
233            RootSpanType::Job => "job",
234            RootSpanType::Command => "command",
235        }
236    }
237
238    pub fn parse(s: &str) -> Option<Self> {
239        match s {
240            "web" => Some(RootSpanType::Web),
241            "job" => Some(RootSpanType::Job),
242            "command" => Some(RootSpanType::Command),
243            _ => None,
244        }
245    }
246}
247
248// ============================================================================
249// Display Types for UI
250// ============================================================================
251
252#[derive(Debug, Clone, Serialize)]
253pub struct TraceSummary {
254    pub trace_id: String,
255    pub root_span_name: String,
256    pub root_span_type: Option<RootSpanType>,
257    pub duration_ms: f64,
258    pub span_count: i64,
259    pub status_code: i32,
260    pub service_name: Option<String>,
261    pub http_method: Option<String>,
262    pub http_url: Option<String>,
263    pub http_status_code: Option<i32>,
264    pub happened_at: String,
265}
266
267impl TraceSummary {
268    /// Returns a clean, human-readable name for the trace
269    pub fn display_name(&self) -> String {
270        // For web requests, show "METHOD /path"
271        if let Some(ref method) = self.http_method {
272            // Extract just the path from the URL if present
273            let path = self
274                .http_url
275                .as_ref()
276                .and_then(|url| {
277                    // Parse URL to get just the path
278                    if let Some(pos) = url.find("://") {
279                        let after_scheme = &url[pos + 3..];
280                        after_scheme.find('/').map(|p| &after_scheme[p..])
281                    } else if url.starts_with('/') {
282                        Some(url.as_str())
283                    } else {
284                        None
285                    }
286                })
287                .unwrap_or_else(|| {
288                    // Fallback: extract path from span name if it starts with method
289                    let name = &self.root_span_name;
290                    if name.starts_with(method) {
291                        name[method.len()..].trim()
292                    } else {
293                        name.as_str()
294                    }
295                });
296
297            format!("{} {}", method, path)
298        } else {
299            // For jobs/rake tasks, just use the span name as-is
300            self.root_span_name.clone()
301        }
302    }
303
304    /// Returns a CSS class for the status
305    pub fn status_class(&self) -> &'static str {
306        if let Some(code) = self.http_status_code {
307            if code >= 500 {
308                "status-error"
309            } else if code >= 400 {
310                "status-warning"
311            } else {
312                "status-ok"
313            }
314        } else if self.status_code == 2 {
315            "status-error"
316        } else {
317            "status-ok"
318        }
319    }
320
321    /// Returns a human-readable status label
322    pub fn status_label(&self) -> String {
323        if let Some(code) = self.http_status_code {
324            code.to_string()
325        } else if self.status_code == 2 {
326            "Error".to_string()
327        } else {
328            "OK".to_string()
329        }
330    }
331
332    /// Returns duration in ms rounded to nearest integer
333    pub fn duration_ms_rounded(&self) -> i64 {
334        self.duration_ms.round() as i64
335    }
336}
337
338#[derive(Debug, Clone, Serialize)]
339pub struct TraceDetail {
340    pub trace_id: String,
341    pub spans: Vec<SpanDisplay>,
342    pub total_duration_ms: f64,
343    pub root_span: Option<SpanDisplay>,
344}
345
346#[derive(Debug, Clone, Serialize)]
347pub struct SpanDisplay {
348    pub id: i64,
349    pub span_id: String,
350    pub parent_span_id: Option<String>,
351    pub name: String,
352    pub category: SpanCategory,
353    pub duration_ms: f64,
354    pub offset_ms: f64,
355    pub offset_percent: f64,
356    pub width_percent: f64,
357    pub depth: i32,
358    pub status_code: i32,
359    pub http_method: Option<String>,
360    pub http_status_code: Option<i32>,
361    pub db_operation: Option<String>,
362    pub db_system: Option<String>,
363    pub db_statement: Option<String>,
364}
365
366// ============================================================================
367// Helper Functions
368// ============================================================================
369
370fn parse_attributes(attrs: &Option<Vec<KeyValue>>) -> HashMap<String, String> {
371    let mut map = HashMap::new();
372    if let Some(attrs) = attrs {
373        for kv in attrs {
374            let value = if let Some(ref v) = kv.value.string_value {
375                v.clone()
376            } else if let Some(ref v) = kv.value.int_value {
377                v.clone()
378            } else if let Some(v) = kv.value.double_value {
379                v.to_string()
380            } else if let Some(v) = kv.value.bool_value {
381                v.to_string()
382            } else {
383                continue;
384            };
385            map.insert(kv.key.clone(), value);
386        }
387    }
388    map
389}
390
391fn decode_id(s: &str) -> String {
392    // OTLP can send IDs as base64 - try to decode
393    if let Ok(bytes) = STANDARD.decode(s) {
394        hex::encode(bytes)
395    } else {
396        // Already hex or some other format
397        s.to_string()
398    }
399}
400
401// ============================================================================
402// Database Operations
403// ============================================================================
404
405use crate::models::error as app_error;
406use sha2::{Digest, Sha256};
407
408/// Backfill errors from existing spans that have exception events
409/// This is useful for extracting errors from spans that were ingested before error extraction was added
410pub fn backfill_errors_from_spans(pool: &DbPool) -> anyhow::Result<usize> {
411    let conn = pool.get()?;
412    let mut stmt = conn.prepare(
413        r#"
414        SELECT project_id, trace_id, events_json, happened_at
415        FROM spans
416        WHERE events_json IS NOT NULL
417          AND events_json != '[]'
418          AND events_json LIKE '%exception%'
419        "#,
420    )?;
421
422    let mut count = 0;
423    let rows = stmt.query_map([], |row| {
424        Ok((
425            row.get::<_, Option<i64>>(0)?,
426            row.get::<_, String>(1)?,
427            row.get::<_, String>(2)?,
428            row.get::<_, String>(3)?,
429        ))
430    })?;
431
432    for row in rows {
433        let (project_id, trace_id, events_json, happened_at) = row?;
434        if let Ok(events) = serde_json::from_str::<Vec<SpanEvent>>(&events_json) {
435            let events_opt = Some(events);
436            extract_and_insert_errors(pool, &events_opt, &trace_id, &happened_at, project_id);
437            count += 1;
438        }
439    }
440
441    Ok(count)
442}
443
444/// Extract exception events from OTLP span and insert as errors
445fn extract_and_insert_errors(
446    pool: &DbPool,
447    events: &Option<Vec<SpanEvent>>,
448    trace_id: &str,
449    happened_at: &str,
450    project_id: Option<i64>,
451) {
452    let events = match events {
453        Some(e) => e,
454        None => return,
455    };
456
457    for event in events {
458        if event.name != "exception" {
459            continue;
460        }
461
462        let attrs = parse_attributes(&event.attributes);
463        let exception_type = match attrs.get("exception.type") {
464            Some(t) => t.clone(),
465            None => continue,
466        };
467        let message = attrs.get("exception.message").cloned().unwrap_or_default();
468        let stacktrace = attrs
469            .get("exception.stacktrace")
470            .cloned()
471            .unwrap_or_default();
472        let backtrace: Vec<String> = stacktrace.lines().map(|s| s.to_string()).collect();
473
474        // Generate fingerprint from exception type + first backtrace line
475        let first_line = backtrace.first().map(|s| s.as_str()).unwrap_or("");
476        let mut hasher = Sha256::new();
477        hasher.update(format!("{}:{}", exception_type, first_line));
478        let fingerprint = format!("{:x}", hasher.finalize());
479
480        let incoming_error = app_error::IncomingError {
481            exception_class: exception_type,
482            message,
483            backtrace,
484            fingerprint,
485            request_id: Some(trace_id.to_string()),
486            user_id: None,
487            params: None,
488            timestamp: Some(happened_at.to_string()),
489            source_context: None,
490        };
491
492        if let Err(e) = app_error::insert(pool, &incoming_error, project_id) {
493            tracing::warn!("Failed to insert error from span event: {}", e);
494        }
495    }
496}
497
498pub fn insert_otlp_batch(
499    pool: &DbPool,
500    request: &OtlpTraceRequest,
501    project_id: Option<i64>,
502) -> anyhow::Result<usize> {
503    let conn = pool.get()?;
504    let mut count = 0;
505
506    for resource_span in &request.resource_spans {
507        let resource_attrs = parse_attributes(
508            &resource_span
509                .resource
510                .as_ref()
511                .and_then(|r| r.attributes.clone()),
512        );
513        let service_name = resource_attrs.get("service.name").cloned();
514        let resource_json = serde_json::to_string(&resource_attrs)?;
515
516        let scope_spans = match &resource_span.scope_spans {
517            Some(ss) => ss,
518            None => continue,
519        };
520
521        for scope_span in scope_spans {
522            for otlp_span in &scope_span.spans {
523                let attrs = parse_attributes(&otlp_span.attributes);
524                let kind = otlp_span.kind.unwrap_or(0);
525                let category = SpanCategory::from_attributes(&otlp_span.name, kind, &attrs);
526
527                let is_root = otlp_span.parent_span_id.is_none()
528                    || otlp_span
529                        .parent_span_id
530                        .as_ref()
531                        .map(|s| s.is_empty())
532                        .unwrap_or(true);
533                let root_span_type = if is_root {
534                    RootSpanType::from_category(category)
535                } else {
536                    None
537                };
538
539                let trace_id = decode_id(&otlp_span.trace_id);
540                let span_id = decode_id(&otlp_span.span_id);
541                let parent_span_id = otlp_span
542                    .parent_span_id
543                    .as_ref()
544                    .filter(|s| !s.is_empty())
545                    .map(|s| decode_id(s));
546
547                let start_nano: i64 = otlp_span.start_time_unix_nano.parse()?;
548                let end_nano: i64 = otlp_span.end_time_unix_nano.parse()?;
549                let duration_ms = (end_nano - start_nano) as f64 / 1_000_000.0;
550
551                let happened_at = DateTime::from_timestamp_nanos(start_nano)
552                    .format("%Y-%m-%dT%H:%M:%S%.3fZ")
553                    .to_string();
554
555                let status_code = otlp_span.status.as_ref().and_then(|s| s.code).unwrap_or(0);
556                let status_message = otlp_span.status.as_ref().and_then(|s| s.message.clone());
557
558                // Extract denormalized fields
559                let http_method = attrs
560                    .get("http.method")
561                    .or_else(|| attrs.get("http.request.method"))
562                    .cloned();
563                let http_url = attrs
564                    .get("http.url")
565                    .or_else(|| attrs.get("url.full"))
566                    .or_else(|| attrs.get("http.target"))
567                    .cloned();
568                let http_status: Option<i32> = attrs
569                    .get("http.status_code")
570                    .or_else(|| attrs.get("http.response.status_code"))
571                    .and_then(|s| s.parse().ok());
572                let db_system = attrs.get("db.system").cloned();
573                let db_statement = attrs.get("db.statement").cloned();
574                let db_operation = attrs.get("db.operation").cloned();
575                let messaging_system = attrs.get("messaging.system").cloned();
576                let messaging_operation = attrs
577                    .get("messaging.operation")
578                    .or_else(|| attrs.get("messaging.destination.name"))
579                    .cloned();
580                let request_id = attrs
581                    .get("http.request_id")
582                    .or_else(|| attrs.get("request_id"))
583                    .cloned();
584
585                let attrs_json = serde_json::to_string(&attrs)?;
586                let events_json = otlp_span
587                    .events
588                    .as_ref()
589                    .map(serde_json::to_string)
590                    .transpose()?;
591
592                conn.execute(
593                    r#"
594                    INSERT OR REPLACE INTO spans
595                    (project_id, trace_id, span_id, parent_span_id,
596                     start_time_unix_nano, end_time_unix_nano, duration_ms, name, kind,
597                     status_code, status_message, span_category, root_span_type,
598                     service_name, http_method, http_url, http_status_code,
599                     db_system, db_statement, db_operation,
600                     messaging_system, messaging_operation, request_id,
601                     attributes_json, events_json, resource_attributes_json, happened_at)
602                    VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13,
603                            ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23,
604                            ?24, ?25, ?26, ?27)
605                    "#,
606                    rusqlite::params![
607                        project_id,
608                        trace_id,
609                        span_id,
610                        parent_span_id,
611                        start_nano,
612                        end_nano,
613                        duration_ms,
614                        otlp_span.name,
615                        kind,
616                        status_code,
617                        status_message,
618                        category.as_str(),
619                        root_span_type.map(|r| r.as_str()),
620                        service_name,
621                        http_method,
622                        http_url,
623                        http_status,
624                        db_system,
625                        db_statement,
626                        db_operation,
627                        messaging_system,
628                        messaging_operation,
629                        request_id,
630                        attrs_json,
631                        events_json,
632                        resource_json,
633                        happened_at,
634                    ],
635                )?;
636                count += 1;
637
638                // Extract errors from exception events
639                extract_and_insert_errors(
640                    pool,
641                    &otlp_span.events,
642                    &trace_id,
643                    &happened_at,
644                    project_id,
645                );
646            }
647        }
648    }
649
650    Ok(count)
651}
652
653pub fn list_traces(
654    pool: &DbPool,
655    project_id: Option<i64>,
656    root_type_filter: Option<RootSpanType>,
657    limit: i64,
658) -> anyhow::Result<Vec<TraceSummary>> {
659    list_traces_filtered(
660        pool,
661        project_id,
662        root_type_filter,
663        None,
664        None,
665        None,
666        "recent",
667        limit,
668    )
669}
670
671#[allow(clippy::too_many_arguments)]
672pub fn list_traces_filtered(
673    pool: &DbPool,
674    project_id: Option<i64>,
675    root_type_filter: Option<RootSpanType>,
676    since: Option<&str>,
677    search: Option<&str>,
678    min_duration_ms: Option<f64>,
679    sort_by: &str,
680    limit: i64,
681) -> anyhow::Result<Vec<TraceSummary>> {
682    list_traces_paginated(
683        pool,
684        project_id,
685        root_type_filter,
686        since,
687        search,
688        min_duration_ms,
689        sort_by,
690        limit,
691        0,
692    )
693}
694
695#[allow(clippy::too_many_arguments)]
696pub fn list_traces_paginated(
697    pool: &DbPool,
698    project_id: Option<i64>,
699    root_type_filter: Option<RootSpanType>,
700    since: Option<&str>,
701    search: Option<&str>,
702    min_duration_ms: Option<f64>,
703    sort_by: &str,
704    limit: i64,
705    offset: i64,
706) -> anyhow::Result<Vec<TraceSummary>> {
707    let conn = pool.get()?;
708
709    let order_clause = match sort_by {
710        "duration" => "s.duration_ms DESC",
711        "spans" => "span_count DESC",
712        _ => "s.happened_at DESC", // default: recent
713    };
714
715    let sql = format!(
716        r#"
717        SELECT
718            s.trace_id,
719            s.name as root_span_name,
720            s.root_span_type,
721            s.duration_ms,
722            (SELECT COUNT(*) FROM spans s2 WHERE s2.trace_id = s.trace_id) as span_count,
723            s.status_code,
724            s.service_name,
725            s.http_method,
726            s.http_url,
727            s.http_status_code,
728            strftime('%Y-%m-%d %H:%M', s.happened_at) as happened_at
729        FROM spans s
730        WHERE s.parent_span_id IS NULL
731          AND (?1 IS NULL OR s.project_id = ?1)
732          AND (?2 IS NULL OR s.root_span_type = ?2)
733          AND (?3 IS NULL OR s.happened_at >= ?3)
734          AND (?4 IS NULL OR s.name LIKE '%' || ?4 || '%' OR s.http_url LIKE '%' || ?4 || '%')
735          AND (?5 IS NULL OR s.duration_ms >= ?5)
736        ORDER BY {}
737        LIMIT ?6 OFFSET ?7
738        "#,
739        order_clause
740    );
741
742    let root_type_str = root_type_filter.map(|r| r.as_str());
743    let mut stmt = conn.prepare(&sql)?;
744    let traces = stmt
745        .query_map(
746            rusqlite::params![
747                project_id,
748                root_type_str,
749                since,
750                search,
751                min_duration_ms,
752                limit,
753                offset
754            ],
755            |row| {
756                Ok(TraceSummary {
757                    trace_id: row.get(0)?,
758                    root_span_name: row.get(1)?,
759                    root_span_type: row
760                        .get::<_, Option<String>>(2)?
761                        .and_then(|s| RootSpanType::parse(&s)),
762                    duration_ms: row.get(3)?,
763                    span_count: row.get(4)?,
764                    status_code: row.get(5)?,
765                    service_name: row.get(6)?,
766                    http_method: row.get(7)?,
767                    http_url: row.get(8)?,
768                    http_status_code: row.get(9)?,
769                    happened_at: row.get(10)?,
770                })
771            },
772        )?
773        .collect::<Result<Vec<_>, _>>()?;
774
775    Ok(traces)
776}
777
778pub fn count_traces_filtered(
779    pool: &DbPool,
780    project_id: Option<i64>,
781    root_type_filter: Option<RootSpanType>,
782    since: Option<&str>,
783    search: Option<&str>,
784    min_duration_ms: Option<f64>,
785) -> anyhow::Result<i64> {
786    let conn = pool.get()?;
787
788    let root_type_str = root_type_filter.map(|r| r.as_str());
789    let count: i64 = conn.query_row(
790        r#"
791        SELECT COUNT(*)
792        FROM spans s
793        WHERE s.parent_span_id IS NULL
794          AND (?1 IS NULL OR s.project_id = ?1)
795          AND (?2 IS NULL OR s.root_span_type = ?2)
796          AND (?3 IS NULL OR s.happened_at >= ?3)
797          AND (?4 IS NULL OR s.name LIKE '%' || ?4 || '%' OR s.http_url LIKE '%' || ?4 || '%')
798          AND (?5 IS NULL OR s.duration_ms >= ?5)
799        "#,
800        rusqlite::params![project_id, root_type_str, since, search, min_duration_ms],
801        |row| row.get(0),
802    )?;
803
804    Ok(count)
805}
806
807pub fn get_trace(pool: &DbPool, trace_id: &str) -> anyhow::Result<Option<TraceDetail>> {
808    let conn = pool.get()?;
809
810    let mut stmt = conn.prepare(
811        r#"
812        SELECT id, span_id, parent_span_id, name, span_category,
813               duration_ms, start_time_unix_nano, status_code,
814               http_method, http_status_code, db_operation, db_system, db_statement
815        FROM spans
816        WHERE trace_id = ?1
817        ORDER BY start_time_unix_nano ASC
818        "#,
819    )?;
820
821    #[allow(clippy::type_complexity)]
822    let spans: Vec<(
823        i64,
824        String,
825        Option<String>,
826        String,
827        String,
828        f64,
829        i64,
830        i32,
831        Option<String>,
832        Option<i32>,
833        Option<String>,
834        Option<String>,
835        Option<String>,
836    )> = stmt
837        .query_map([trace_id], |row| {
838            Ok((
839                row.get(0)?,
840                row.get(1)?,
841                row.get(2)?,
842                row.get(3)?,
843                row.get(4)?,
844                row.get(5)?,
845                row.get(6)?,
846                row.get(7)?,
847                row.get(8)?,
848                row.get(9)?,
849                row.get(10)?,
850                row.get(11)?,
851                row.get(12)?,
852            ))
853        })?
854        .collect::<Result<Vec<_>, _>>()?;
855
856    if spans.is_empty() {
857        return Ok(None);
858    }
859
860    // Find trace start time and total duration
861    let trace_start = spans.iter().map(|s| s.6).min().unwrap_or(0);
862    let trace_end = spans
863        .iter()
864        .map(|s| s.6 + (s.5 * 1_000_000.0) as i64)
865        .max()
866        .unwrap_or(0);
867    let total_duration_ms = (trace_end - trace_start) as f64 / 1_000_000.0;
868
869    // Build span hierarchy for depth calculation
870    let parent_map: HashMap<String, Option<String>> =
871        spans.iter().map(|s| (s.1.clone(), s.2.clone())).collect();
872
873    fn compute_depth(
874        span_id: &str,
875        parent_map: &HashMap<String, Option<String>>,
876        depth_cache: &mut HashMap<String, i32>,
877    ) -> i32 {
878        if let Some(&cached) = depth_cache.get(span_id) {
879            return cached;
880        }
881        let depth = match parent_map.get(span_id).and_then(|p| p.as_ref()) {
882            Some(parent_id) => compute_depth(parent_id, parent_map, depth_cache) + 1,
883            None => 0,
884        };
885        depth_cache.insert(span_id.to_string(), depth);
886        depth
887    }
888
889    let mut depth_cache = HashMap::new();
890
891    let display_spans: Vec<SpanDisplay> = spans
892        .iter()
893        .map(|s| {
894            let offset_ns = s.6 - trace_start;
895            let offset_ms = offset_ns as f64 / 1_000_000.0;
896            let offset_percent = if total_duration_ms > 0.0 {
897                (offset_ms / total_duration_ms) * 100.0
898            } else {
899                0.0
900            };
901            let width_percent = if total_duration_ms > 0.0 {
902                (s.5 / total_duration_ms) * 100.0
903            } else {
904                100.0
905            };
906            let depth = compute_depth(&s.1, &parent_map, &mut depth_cache);
907
908            SpanDisplay {
909                id: s.0,
910                span_id: s.1.clone(),
911                parent_span_id: s.2.clone(),
912                name: s.3.clone(),
913                category: SpanCategory::parse(&s.4),
914                duration_ms: s.5,
915                offset_ms,
916                offset_percent,
917                width_percent,
918                depth,
919                status_code: s.7,
920                http_method: s.8.clone(),
921                http_status_code: s.9,
922                db_operation: s.10.clone(),
923                db_system: s.11.clone(),
924                db_statement: s.12.clone(),
925            }
926        })
927        .collect();
928
929    let root_span = display_spans.iter().find(|s| s.depth == 0).cloned();
930
931    Ok(Some(TraceDetail {
932        trace_id: trace_id.to_string(),
933        spans: display_spans,
934        total_duration_ms,
935        root_span,
936    }))
937}
938
939pub fn delete_before(pool: &DbPool, before: &str) -> anyhow::Result<usize> {
940    let conn = pool.get()?;
941    let deleted = conn.execute("DELETE FROM spans WHERE happened_at < ?1", [before])?;
942    Ok(deleted)
943}
944
945pub fn count_since(pool: &DbPool, project_id: Option<i64>, since: &str) -> anyhow::Result<i64> {
946    let conn = pool.get()?;
947    let count: i64 = conn.query_row(
948        "SELECT COUNT(*) FROM spans WHERE parent_span_id IS NULL AND (?1 IS NULL OR project_id = ?1) AND happened_at >= ?2",
949        rusqlite::params![project_id, since],
950        |row| row.get(0),
951    )?;
952    Ok(count)
953}
954
955// ============================================================================
956// Dashboard Stats (from root spans)
957// ============================================================================
958
959#[derive(Debug, Clone, Serialize)]
960pub struct LatencyStats {
961    pub avg_ms: i64,
962    pub p95_ms: i64,
963    pub p99_ms: i64,
964}
965
966pub fn latency_stats_since(
967    pool: &DbPool,
968    project_id: Option<i64>,
969    since: &str,
970) -> anyhow::Result<LatencyStats> {
971    let conn = pool.get()?;
972    let mut stmt = conn.prepare(
973        "SELECT duration_ms FROM spans WHERE parent_span_id IS NULL AND happened_at >= ?1 AND (?2 IS NULL OR project_id = ?2) ORDER BY duration_ms ASC",
974    )?;
975
976    let values: Vec<f64> = stmt
977        .query_map(rusqlite::params![since, project_id], |row| row.get(0))?
978        .collect::<Result<Vec<_>, _>>()?;
979
980    if values.is_empty() {
981        return Ok(LatencyStats {
982            avg_ms: 0,
983            p95_ms: 0,
984            p99_ms: 0,
985        });
986    }
987
988    let avg = values.iter().sum::<f64>() / values.len() as f64;
989    let p95_idx = ((0.95 * (values.len() as f64 - 1.0)).round() as usize).min(values.len() - 1);
990    let p99_idx = ((0.99 * (values.len() as f64 - 1.0)).round() as usize).min(values.len() - 1);
991
992    Ok(LatencyStats {
993        avg_ms: avg.round() as i64,
994        p95_ms: values[p95_idx].round() as i64,
995        p99_ms: values[p99_idx].round() as i64,
996    })
997}
998
999pub fn slow_traces(
1000    pool: &DbPool,
1001    project_id: Option<i64>,
1002    threshold_ms: f64,
1003    limit: i64,
1004) -> anyhow::Result<Vec<TraceSummary>> {
1005    let conn = pool.get()?;
1006    let mut stmt = conn.prepare(
1007        r#"
1008        SELECT
1009            s.trace_id,
1010            s.name as root_span_name,
1011            s.root_span_type,
1012            s.duration_ms,
1013            (SELECT COUNT(*) FROM spans s2 WHERE s2.trace_id = s.trace_id) as span_count,
1014            s.status_code,
1015            s.service_name,
1016            s.http_method,
1017            s.http_url,
1018            s.http_status_code,
1019            strftime('%Y-%m-%d %H:%M', s.happened_at) as happened_at
1020        FROM spans s
1021        WHERE s.parent_span_id IS NULL
1022          AND s.duration_ms >= ?1
1023          AND (?2 IS NULL OR s.project_id = ?2)
1024        ORDER BY s.duration_ms DESC
1025        LIMIT ?3
1026        "#,
1027    )?;
1028
1029    let traces = stmt
1030        .query_map(rusqlite::params![threshold_ms, project_id, limit], |row| {
1031            Ok(TraceSummary {
1032                trace_id: row.get(0)?,
1033                root_span_name: row.get(1)?,
1034                root_span_type: row
1035                    .get::<_, Option<String>>(2)?
1036                    .and_then(|s| RootSpanType::parse(&s)),
1037                duration_ms: row.get(3)?,
1038                span_count: row.get(4)?,
1039                status_code: row.get(5)?,
1040                service_name: row.get(6)?,
1041                http_method: row.get(7)?,
1042                http_url: row.get(8)?,
1043                http_status_code: row.get(9)?,
1044                happened_at: row.get(10)?,
1045            })
1046        })?
1047        .collect::<Result<Vec<_>, _>>()?;
1048
1049    Ok(traces)
1050}
1051
1052#[derive(Debug, Clone, Serialize)]
1053pub struct TimeSeriesPoint {
1054    pub hour: String,
1055    pub count: i64,
1056    pub avg_ms: f64,
1057    pub error_count: i64,
1058}
1059
1060pub fn hourly_stats(
1061    pool: &DbPool,
1062    project_id: Option<i64>,
1063    hours: i64,
1064) -> anyhow::Result<Vec<TimeSeriesPoint>> {
1065    let conn = pool.get()?;
1066    let mut stmt = conn.prepare(
1067        r#"
1068        SELECT
1069            strftime('%Y-%m-%d %H:00', happened_at) as hour,
1070            COUNT(*) as count,
1071            COALESCE(AVG(duration_ms), 0) as avg_ms,
1072            SUM(CASE WHEN status_code = 2 OR http_status_code >= 500 THEN 1 ELSE 0 END) as error_count
1073        FROM spans
1074        WHERE parent_span_id IS NULL
1075          AND (?1 IS NULL OR project_id = ?1)
1076          AND happened_at >= datetime('now', '-' || ?2 || ' hours')
1077        GROUP BY strftime('%Y-%m-%d %H:00', happened_at)
1078        ORDER BY hour ASC
1079        "#,
1080    )?;
1081
1082    let data_points: std::collections::HashMap<String, TimeSeriesPoint> = stmt
1083        .query_map(rusqlite::params![project_id, hours], |row| {
1084            Ok(TimeSeriesPoint {
1085                hour: row.get(0)?,
1086                count: row.get(1)?,
1087                avg_ms: row.get(2)?,
1088                error_count: row.get(3)?,
1089            })
1090        })?
1091        .filter_map(|r| r.ok())
1092        .map(|p| (p.hour.clone(), p))
1093        .collect();
1094
1095    // Fill in all hours with zeros for missing data
1096    let mut points = Vec::with_capacity(hours as usize);
1097    for i in (0..hours).rev() {
1098        let hour = chrono::Utc::now() - chrono::Duration::hours(i);
1099        let hour_key = hour.format("%Y-%m-%d %H:00").to_string();
1100        points.push(
1101            data_points
1102                .get(&hour_key)
1103                .cloned()
1104                .unwrap_or(TimeSeriesPoint {
1105                    hour: hour_key,
1106                    count: 0,
1107                    avg_ms: 0.0,
1108                    error_count: 0,
1109                }),
1110        );
1111    }
1112
1113    Ok(points)
1114}
1115
1116// ============================================================================
1117// Routes Stats (aggregated by endpoint)
1118// ============================================================================
1119
1120#[derive(Debug, Clone, Serialize)]
1121pub struct RouteSummary {
1122    pub path: String,
1123    pub method: String,
1124    pub request_count: i64,
1125    pub avg_ms: i64,
1126    pub p95_ms: i64,
1127    pub p99_ms: i64,
1128    pub max_ms: i64,
1129    pub min_ms: i64,
1130    pub avg_db_ms: i64,
1131    pub avg_db_count: i64,
1132    pub error_count: i64,
1133    pub error_rate: f64,
1134}
1135
1136pub fn routes_summary(
1137    pool: &DbPool,
1138    project_id: Option<i64>,
1139    since: &str,
1140    search: Option<&str>,
1141    sort: &str,
1142    limit: i64,
1143) -> anyhow::Result<Vec<RouteSummary>> {
1144    let conn = pool.get()?;
1145
1146    // Get unique routes with basic stats
1147    let mut stmt = conn.prepare(
1148        r#"
1149        SELECT
1150            COALESCE(name, http_url, 'unknown') as path,
1151            COALESCE(http_method, 'GET') as method,
1152            COUNT(*) as request_count,
1153            AVG(duration_ms) as avg_ms,
1154            MAX(duration_ms) as max_ms,
1155            MIN(duration_ms) as min_ms,
1156            SUM(CASE WHEN status_code = 2 OR http_status_code >= 500 THEN 1 ELSE 0 END) as error_count
1157        FROM spans
1158        WHERE parent_span_id IS NULL
1159          AND root_span_type = 'web'
1160          AND (?1 IS NULL OR project_id = ?1)
1161          AND happened_at >= ?2
1162          AND (?3 IS NULL OR name LIKE '%' || ?3 || '%' OR http_url LIKE '%' || ?3 || '%')
1163        GROUP BY COALESCE(name, http_url, 'unknown'), COALESCE(http_method, 'GET')
1164        ORDER BY request_count DESC
1165        LIMIT ?4
1166        "#,
1167    )?;
1168
1169    let routes: Vec<(String, String, i64, f64, f64, f64, i64)> = stmt
1170        .query_map(rusqlite::params![project_id, since, search, limit], |row| {
1171            Ok((
1172                row.get(0)?,
1173                row.get(1)?,
1174                row.get(2)?,
1175                row.get(3)?,
1176                row.get(4)?,
1177                row.get(5)?,
1178                row.get(6)?,
1179            ))
1180        })?
1181        .collect::<Result<Vec<_>, _>>()?;
1182
1183    let mut result = Vec::new();
1184    for (path, method, request_count, avg_ms, max_ms, min_ms, error_count) in routes {
1185        let (p95, p99) = calculate_route_percentiles(&conn, project_id, &path, since)?;
1186        let (avg_db_ms, avg_db_count) = calculate_route_db_stats(&conn, project_id, &path, since)?;
1187        let error_rate = if request_count > 0 {
1188            (error_count as f64 / request_count as f64) * 100.0
1189        } else {
1190            0.0
1191        };
1192        result.push(RouteSummary {
1193            path,
1194            method,
1195            request_count,
1196            avg_ms: avg_ms.round() as i64,
1197            p95_ms: p95,
1198            p99_ms: p99,
1199            max_ms: max_ms.round() as i64,
1200            min_ms: min_ms.round() as i64,
1201            avg_db_ms,
1202            avg_db_count,
1203            error_count,
1204            error_rate,
1205        });
1206    }
1207
1208    // Sort by requested field
1209    match sort {
1210        "avg" => result.sort_by(|a, b| b.avg_ms.cmp(&a.avg_ms)),
1211        "p95" => result.sort_by(|a, b| b.p95_ms.cmp(&a.p95_ms)),
1212        "p99" => result.sort_by(|a, b| b.p99_ms.cmp(&a.p99_ms)),
1213        "max" => result.sort_by(|a, b| b.max_ms.cmp(&a.max_ms)),
1214        "db" => result.sort_by(|a, b| b.avg_db_ms.cmp(&a.avg_db_ms)),
1215        "errors" => result.sort_by(|a, b| b.error_count.cmp(&a.error_count)),
1216        _ => {} // default: already sorted by request_count
1217    }
1218
1219    Ok(result)
1220}
1221
1222pub fn routes_count(
1223    pool: &DbPool,
1224    project_id: Option<i64>,
1225    since: &str,
1226    search: Option<&str>,
1227) -> anyhow::Result<i64> {
1228    let conn = pool.get()?;
1229    let count: i64 = conn.query_row(
1230        r#"
1231        SELECT COUNT(DISTINCT COALESCE(name, http_url, 'unknown') || COALESCE(http_method, 'GET'))
1232        FROM spans
1233        WHERE parent_span_id IS NULL
1234          AND root_span_type = 'web'
1235          AND (?1 IS NULL OR project_id = ?1)
1236          AND happened_at >= ?2
1237          AND (?3 IS NULL OR name LIKE '%' || ?3 || '%' OR http_url LIKE '%' || ?3 || '%')
1238        "#,
1239        rusqlite::params![project_id, since, search],
1240        |row| row.get(0),
1241    )?;
1242    Ok(count)
1243}
1244
1245fn calculate_route_percentiles(
1246    conn: &r2d2::PooledConnection<r2d2_sqlite::SqliteConnectionManager>,
1247    project_id: Option<i64>,
1248    path: &str,
1249    since: &str,
1250) -> anyhow::Result<(i64, i64)> {
1251    let mut stmt = conn.prepare(
1252        r#"
1253        SELECT duration_ms
1254        FROM spans
1255        WHERE parent_span_id IS NULL
1256          AND COALESCE(name, http_url, 'unknown') = ?1
1257          AND (?2 IS NULL OR project_id = ?2)
1258          AND happened_at >= ?3
1259        ORDER BY duration_ms ASC
1260        "#,
1261    )?;
1262
1263    let values: Vec<f64> = stmt
1264        .query_map(rusqlite::params![path, project_id, since], |row| row.get(0))?
1265        .collect::<Result<Vec<_>, _>>()?;
1266
1267    if values.is_empty() {
1268        return Ok((0, 0));
1269    }
1270
1271    let p95_idx = ((0.95 * (values.len() as f64 - 1.0)).round() as usize).min(values.len() - 1);
1272    let p99_idx = ((0.99 * (values.len() as f64 - 1.0)).round() as usize).min(values.len() - 1);
1273
1274    Ok((
1275        values[p95_idx].round() as i64,
1276        values[p99_idx].round() as i64,
1277    ))
1278}
1279
1280fn calculate_route_db_stats(
1281    conn: &r2d2::PooledConnection<r2d2_sqlite::SqliteConnectionManager>,
1282    project_id: Option<i64>,
1283    path: &str,
1284    since: &str,
1285) -> anyhow::Result<(i64, i64)> {
1286    // Get all trace_ids for this route
1287    let mut stmt = conn.prepare(
1288        r#"
1289        SELECT trace_id
1290        FROM spans
1291        WHERE parent_span_id IS NULL
1292          AND COALESCE(name, http_url, 'unknown') = ?1
1293          AND (?2 IS NULL OR project_id = ?2)
1294          AND happened_at >= ?3
1295        "#,
1296    )?;
1297
1298    let trace_ids: Vec<String> = stmt
1299        .query_map(rusqlite::params![path, project_id, since], |row| row.get(0))?
1300        .collect::<Result<Vec<_>, _>>()?;
1301
1302    if trace_ids.is_empty() {
1303        return Ok((0, 0));
1304    }
1305
1306    // Calculate average DB time and count across these traces
1307    let placeholders = trace_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
1308    let sql = format!(
1309        r#"
1310        SELECT
1311            COALESCE(AVG(db_total_ms), 0) as avg_db_ms,
1312            COALESCE(AVG(db_count), 0) as avg_db_count
1313        FROM (
1314            SELECT
1315                trace_id,
1316                SUM(duration_ms) as db_total_ms,
1317                COUNT(*) as db_count
1318            FROM spans
1319            WHERE trace_id IN ({})
1320              AND span_category = 'db'
1321            GROUP BY trace_id
1322        )
1323        "#,
1324        placeholders
1325    );
1326
1327    let mut stmt = conn.prepare(&sql)?;
1328    let result: (f64, f64) = stmt
1329        .query_row(rusqlite::params_from_iter(trace_ids.iter()), |row| {
1330            Ok((row.get(0)?, row.get(1)?))
1331        })?;
1332
1333    Ok((result.0.round() as i64, result.1.round() as i64))
1334}
1335
1336// ============================================================================
1337// N+1 Query Detection
1338// ============================================================================
1339
1340const N_PLUS_1_THRESHOLD: usize = 5;
1341
1342/// Normalize a SQL statement by replacing literal values with placeholders
1343/// This helps group similar queries together
1344fn normalize_sql(sql: &str) -> String {
1345    let mut result = String::new();
1346    let mut chars = sql.chars().peekable();
1347    let mut in_string = false;
1348    let mut string_char = ' ';
1349
1350    while let Some(c) = chars.next() {
1351        if in_string {
1352            // Skip until end of string
1353            if c == string_char && chars.peek() != Some(&string_char) {
1354                result.push('?');
1355                in_string = false;
1356            } else if c == string_char && chars.peek() == Some(&string_char) {
1357                // Escaped quote
1358                chars.next();
1359            }
1360        } else if c == '\'' || c == '"' {
1361            in_string = true;
1362            string_char = c;
1363        } else if c.is_ascii_digit()
1364            && (result.ends_with(' ')
1365                || result.ends_with('=')
1366                || result.ends_with('(')
1367                || result.ends_with(',')
1368                || result.is_empty())
1369        {
1370            // Skip numbers that appear to be values
1371            while chars
1372                .peek()
1373                .map(|ch| ch.is_ascii_digit() || *ch == '.')
1374                .unwrap_or(false)
1375            {
1376                chars.next();
1377            }
1378            result.push('?');
1379        } else {
1380            result.push(c);
1381        }
1382    }
1383
1384    // Normalize whitespace
1385    result.split_whitespace().collect::<Vec<_>>().join(" ")
1386}
1387
1388#[derive(Debug, Clone, Serialize)]
1389pub struct NPlus1Issue {
1390    pub pattern: String,
1391    pub count: usize,
1392    pub total_duration_ms: f64,
1393    pub span_ids: Vec<String>,
1394}
1395
1396/// Detect N+1 query patterns in a trace
1397pub fn detect_n_plus_1(spans: &[SpanDisplay]) -> Vec<NPlus1Issue> {
1398    let mut pattern_counts: HashMap<String, (usize, f64, Vec<String>)> = HashMap::new();
1399
1400    for span in spans {
1401        if span.category == SpanCategory::Db {
1402            if let Some(ref statement) = span.db_statement {
1403                let pattern = normalize_sql(statement);
1404                let entry = pattern_counts
1405                    .entry(pattern)
1406                    .or_insert((0, 0.0, Vec::new()));
1407                entry.0 += 1;
1408                entry.1 += span.duration_ms;
1409                entry.2.push(span.span_id.clone());
1410            }
1411        }
1412    }
1413
1414    let mut issues: Vec<NPlus1Issue> = pattern_counts
1415        .into_iter()
1416        .filter(|(_, (count, _, _))| *count >= N_PLUS_1_THRESHOLD)
1417        .map(
1418            |(pattern, (count, total_duration_ms, span_ids))| NPlus1Issue {
1419                pattern,
1420                count,
1421                total_duration_ms,
1422                span_ids,
1423            },
1424        )
1425        .collect();
1426
1427    // Sort by count descending
1428    issues.sort_by(|a, b| b.count.cmp(&a.count));
1429    issues
1430}
1431
1432/// Check if a trace has N+1 issues (for list view)
1433pub fn has_n_plus_1(pool: &DbPool, trace_id: &str) -> bool {
1434    let conn = match pool.get() {
1435        Ok(c) => c,
1436        Err(_) => return false,
1437    };
1438
1439    // Count DB spans grouped by normalized statement pattern
1440    let result: Result<i64, _> = conn.query_row(
1441        r#"
1442        SELECT COUNT(*) FROM (
1443            SELECT db_statement, COUNT(*) as cnt
1444            FROM spans
1445            WHERE trace_id = ?1 AND span_category = 'db' AND db_statement IS NOT NULL
1446            GROUP BY db_statement
1447            HAVING cnt >= ?2
1448        )
1449        "#,
1450        rusqlite::params![trace_id, N_PLUS_1_THRESHOLD as i64],
1451        |row| row.get(0),
1452    );
1453
1454    result.unwrap_or(0) > 0
1455}
1456
1457#[cfg(test)]
1458mod tests {
1459    use super::*;
1460
1461    #[test]
1462    fn test_normalize_sql_strings() {
1463        let sql = "SELECT * FROM users WHERE name = 'John'";
1464        assert_eq!(normalize_sql(sql), "SELECT * FROM users WHERE name = ?");
1465    }
1466
1467    #[test]
1468    fn test_normalize_sql_numbers() {
1469        let sql = "SELECT * FROM users WHERE id = 123";
1470        assert_eq!(normalize_sql(sql), "SELECT * FROM users WHERE id = ?");
1471    }
1472
1473    #[test]
1474    fn test_normalize_sql_mixed() {
1475        let sql = "SELECT * FROM orders WHERE user_id = 42 AND status = 'pending'";
1476        assert_eq!(
1477            normalize_sql(sql),
1478            "SELECT * FROM orders WHERE user_id = ? AND status = ?"
1479        );
1480    }
1481
1482    #[test]
1483    fn test_normalize_sql_in_clause() {
1484        let sql = "SELECT * FROM users WHERE id IN (1, 2, 3)";
1485        assert_eq!(
1486            normalize_sql(sql),
1487            "SELECT * FROM users WHERE id IN (?, ?, ?)"
1488        );
1489    }
1490
1491    // SpanCategory tests
1492    #[test]
1493    fn test_span_category_db() {
1494        let mut attrs = HashMap::new();
1495        attrs.insert("db.system".to_string(), "postgresql".to_string());
1496        assert_eq!(
1497            SpanCategory::from_attributes("SELECT users", 0, &attrs),
1498            SpanCategory::Db
1499        );
1500    }
1501
1502    #[test]
1503    fn test_span_category_elasticsearch() {
1504        let mut attrs = HashMap::new();
1505        attrs.insert("db.system".to_string(), "elasticsearch".to_string());
1506        assert_eq!(
1507            SpanCategory::from_attributes("search", 0, &attrs),
1508            SpanCategory::Search
1509        );
1510    }
1511
1512    #[test]
1513    fn test_span_category_http_server() {
1514        let mut attrs = HashMap::new();
1515        attrs.insert("http.method".to_string(), "GET".to_string());
1516        assert_eq!(
1517            SpanCategory::from_attributes("GET /users", 2, &attrs),
1518            SpanCategory::HttpServer
1519        );
1520    }
1521
1522    #[test]
1523    fn test_span_category_http_client() {
1524        let mut attrs = HashMap::new();
1525        attrs.insert(
1526            "http.url".to_string(),
1527            "https://api.example.com".to_string(),
1528        );
1529        assert_eq!(
1530            SpanCategory::from_attributes("HTTP GET", 3, &attrs),
1531            SpanCategory::HttpClient
1532        );
1533    }
1534
1535    #[test]
1536    fn test_span_category_job() {
1537        let mut attrs = HashMap::new();
1538        attrs.insert("messaging.system".to_string(), "sidekiq".to_string());
1539        assert_eq!(
1540            SpanCategory::from_attributes("MyJob.perform", 0, &attrs),
1541            SpanCategory::Job
1542        );
1543    }
1544
1545    #[test]
1546    fn test_span_category_command_rake() {
1547        let attrs = HashMap::new();
1548        assert_eq!(
1549            SpanCategory::from_attributes("rake db:migrate", 0, &attrs),
1550            SpanCategory::Command
1551        );
1552        assert_eq!(
1553            SpanCategory::from_attributes("rake:db:migrate", 0, &attrs),
1554            SpanCategory::Command
1555        );
1556    }
1557
1558    #[test]
1559    fn test_span_category_command_thor() {
1560        let attrs = HashMap::new();
1561        assert_eq!(
1562            SpanCategory::from_attributes("thor:generate:model", 0, &attrs),
1563            SpanCategory::Command
1564        );
1565    }
1566
1567    #[test]
1568    fn test_span_category_view() {
1569        let attrs = HashMap::new();
1570        assert_eq!(
1571            SpanCategory::from_attributes("render_template users/index.html.erb", 0, &attrs),
1572            SpanCategory::View
1573        );
1574        assert_eq!(
1575            SpanCategory::from_attributes("render_partial _header.html.erb", 0, &attrs),
1576            SpanCategory::View
1577        );
1578    }
1579
1580    #[test]
1581    fn test_span_category_roundtrip() {
1582        for category in [
1583            SpanCategory::HttpServer,
1584            SpanCategory::HttpClient,
1585            SpanCategory::Db,
1586            SpanCategory::View,
1587            SpanCategory::Search,
1588            SpanCategory::Job,
1589            SpanCategory::Command,
1590            SpanCategory::Internal,
1591        ] {
1592            assert_eq!(SpanCategory::parse(category.as_str()), category);
1593        }
1594    }
1595
1596    // RootSpanType tests
1597    #[test]
1598    fn test_root_span_type_from_category() {
1599        assert_eq!(
1600            RootSpanType::from_category(SpanCategory::HttpServer),
1601            Some(RootSpanType::Web)
1602        );
1603        assert_eq!(
1604            RootSpanType::from_category(SpanCategory::Job),
1605            Some(RootSpanType::Job)
1606        );
1607        assert_eq!(
1608            RootSpanType::from_category(SpanCategory::Command),
1609            Some(RootSpanType::Command)
1610        );
1611        assert_eq!(RootSpanType::from_category(SpanCategory::Db), None);
1612        assert_eq!(RootSpanType::from_category(SpanCategory::Internal), None);
1613    }
1614
1615    #[test]
1616    fn test_root_span_type_roundtrip() {
1617        for root_type in [RootSpanType::Web, RootSpanType::Job, RootSpanType::Command] {
1618            assert_eq!(RootSpanType::parse(root_type.as_str()), Some(root_type));
1619        }
1620        assert_eq!(RootSpanType::parse("invalid"), None);
1621    }
1622
1623    // TraceSummary display tests
1624    fn make_trace_summary(
1625        root_span_name: &str,
1626        http_method: Option<&str>,
1627        http_url: Option<&str>,
1628        http_status_code: Option<i32>,
1629        status_code: i32,
1630    ) -> TraceSummary {
1631        TraceSummary {
1632            trace_id: "abc123".to_string(),
1633            root_span_name: root_span_name.to_string(),
1634            root_span_type: Some(RootSpanType::Web),
1635            duration_ms: 100.0,
1636            span_count: 5,
1637            status_code,
1638            service_name: None,
1639            http_method: http_method.map(|s| s.to_string()),
1640            http_url: http_url.map(|s| s.to_string()),
1641            http_status_code,
1642            happened_at: "2024-01-01 12:00".to_string(),
1643        }
1644    }
1645
1646    #[test]
1647    fn test_display_name_with_full_url() {
1648        let trace = make_trace_summary(
1649            "GET /users",
1650            Some("GET"),
1651            Some("https://example.com/users"),
1652            Some(200),
1653            1,
1654        );
1655        assert_eq!(trace.display_name(), "GET /users");
1656    }
1657
1658    #[test]
1659    fn test_display_name_with_path_only() {
1660        let trace = make_trace_summary("GET /orders", Some("GET"), Some("/orders"), Some(200), 1);
1661        assert_eq!(trace.display_name(), "GET /orders");
1662    }
1663
1664    #[test]
1665    fn test_display_name_extracts_from_span_name() {
1666        let trace = make_trace_summary("POST /api/items", Some("POST"), None, Some(201), 1);
1667        assert_eq!(trace.display_name(), "POST /api/items");
1668    }
1669
1670    #[test]
1671    fn test_display_name_job_without_http() {
1672        let trace = TraceSummary {
1673            trace_id: "abc123".to_string(),
1674            root_span_name: "OrderMailer.confirmation_email".to_string(),
1675            root_span_type: Some(RootSpanType::Job),
1676            duration_ms: 100.0,
1677            span_count: 5,
1678            status_code: 1,
1679            service_name: None,
1680            http_method: None,
1681            http_url: None,
1682            http_status_code: None,
1683            happened_at: "2024-01-01 12:00".to_string(),
1684        };
1685        assert_eq!(trace.display_name(), "OrderMailer.confirmation_email");
1686    }
1687
1688    #[test]
1689    fn test_status_class_success() {
1690        let trace = make_trace_summary("GET /", Some("GET"), None, Some(200), 1);
1691        assert_eq!(trace.status_class(), "status-ok");
1692    }
1693
1694    #[test]
1695    fn test_status_class_client_error() {
1696        let trace = make_trace_summary("GET /", Some("GET"), None, Some(404), 1);
1697        assert_eq!(trace.status_class(), "status-warning");
1698    }
1699
1700    #[test]
1701    fn test_status_class_server_error() {
1702        let trace = make_trace_summary("GET /", Some("GET"), None, Some(500), 2);
1703        assert_eq!(trace.status_class(), "status-error");
1704    }
1705
1706    #[test]
1707    fn test_status_class_otlp_error() {
1708        let trace = make_trace_summary("process", None, None, None, 2);
1709        assert_eq!(trace.status_class(), "status-error");
1710    }
1711
1712    #[test]
1713    fn test_status_class_ok_without_http() {
1714        let trace = make_trace_summary("process", None, None, None, 1);
1715        assert_eq!(trace.status_class(), "status-ok");
1716    }
1717
1718    #[test]
1719    fn test_status_label_http_code() {
1720        let trace = make_trace_summary("GET /", Some("GET"), None, Some(201), 1);
1721        assert_eq!(trace.status_label(), "201");
1722    }
1723
1724    #[test]
1725    fn test_status_label_error() {
1726        let trace = make_trace_summary("process", None, None, None, 2);
1727        assert_eq!(trace.status_label(), "Error");
1728    }
1729
1730    #[test]
1731    fn test_status_label_ok() {
1732        let trace = make_trace_summary("process", None, None, None, 1);
1733        assert_eq!(trace.status_label(), "OK");
1734    }
1735}