1use crate::DbPool;
2use base64::{Engine as _, engine::general_purpose::STANDARD};
3use chrono::DateTime;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7#[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#[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 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 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 if kind == 3 {
132 return SpanCategory::HttpClient;
133 }
134 if kind == 2 {
135 return SpanCategory::HttpServer;
136 }
137 }
138
139 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 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 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 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#[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 pub fn display_name(&self) -> String {
270 if let Some(ref method) = self.http_method {
272 let path = self
274 .http_url
275 .as_ref()
276 .and_then(|url| {
277 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 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 self.root_span_name.clone()
301 }
302 }
303
304 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 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 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
366fn 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 if let Ok(bytes) = STANDARD.decode(s) {
394 hex::encode(bytes)
395 } else {
396 s.to_string()
398 }
399}
400
401use crate::models::error as app_error;
406use sha2::{Digest, Sha256};
407
408pub 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
444fn 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 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 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_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", };
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 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 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#[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 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#[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 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 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 _ => {} }
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 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 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
1336const N_PLUS_1_THRESHOLD: usize = 5;
1341
1342fn 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 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 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 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 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
1396pub 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 issues.sort_by(|a, b| b.count.cmp(&a.count));
1429 issues
1430}
1431
1432pub 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 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 #[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 #[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 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}