Skip to main content

otelite_api/api/
traces.rs

1use super::validate_time_range;
2use crate::server::{AppState, QueryCache};
3use axum::{
4    extract::{Path, Query, State},
5    http::StatusCode,
6    response::IntoResponse,
7    Json,
8};
9use otelite_core::api::{
10    ErrorResponse, LogEntry, LogsResponse, SpanEntry, TraceDetail, TraceEntry, TracesResponse,
11};
12use otelite_core::query::{Operator, QueryPredicate, QueryValue};
13use otelite_core::storage::QueryParams;
14use serde::{Deserialize, Serialize};
15
16/// Query parameters for trace listing
17#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams)]
18pub struct TracesQuery {
19    /// Filter by trace ID
20    #[serde(default)]
21    pub trace_id: Option<String>,
22
23    /// Filter by service name
24    #[serde(default)]
25    pub service: Option<String>,
26
27    /// Filter by resource attribute (format: key=value)
28    #[serde(default)]
29    pub resource: Option<String>,
30
31    /// Full-text search in span names
32    #[serde(default)]
33    pub search: Option<String>,
34
35    /// Filter by session ID (session.id attribute)
36    #[serde(default)]
37    pub session_id: Option<String>,
38
39    /// Filter by gen_ai.conversation.id attribute
40    #[serde(default)]
41    pub conversation_id: Option<String>,
42
43    /// Filter by model (matches gen_ai.request.model)
44    #[serde(default)]
45    pub model: Option<String>,
46
47    /// JSON-encoded list of attribute filters: [{"key":"x","op":"=","value":"y"}, ...]
48    /// Supported ops: "=" (Equal), "!=" (NotEqual)
49    #[serde(default)]
50    pub attrs: Option<String>,
51
52    /// Structured query predicate; multiple predicates joined with `&&`,
53    /// e.g. `name = "claude_code.llm_request" && attributes.attempt >= 2`.
54    /// A malformed expression returns 400.
55    #[serde(default)]
56    pub query: Option<String>,
57
58    /// Start time (Unix timestamp in nanoseconds)
59    #[serde(default)]
60    pub start_time: Option<i64>,
61
62    /// End time (Unix timestamp in nanoseconds)
63    #[serde(default)]
64    pub end_time: Option<i64>,
65
66    /// Maximum number of results (default: 100, max: 1000)
67    #[serde(default = "default_limit")]
68    pub limit: usize,
69
70    /// Offset for pagination
71    #[serde(default)]
72    pub offset: usize,
73}
74
75fn default_limit() -> usize {
76    100
77}
78
79/// Handler for GET /api/traces
80#[utoipa::path(
81    get,
82    path = "/api/traces",
83    params(TracesQuery),
84    responses(
85        (status = 200, description = "List of traces matching query", body = TracesResponse),
86        (status = 400, description = "Invalid time range", body = ErrorResponse),
87        (status = 500, description = "Internal server error", body = ErrorResponse)
88    ),
89    tag = "traces"
90)]
91pub async fn list_traces(
92    State(state): State<AppState>,
93    Query(params): Query<TracesQuery>,
94) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
95    validate_time_range(params.start_time, params.end_time)?;
96
97    // Check cache first
98    let cache_key = QueryCache::make_key(&params);
99    if let Some(cached) = state.cache.traces.get(&cache_key) {
100        return Ok((
101            StatusCode::OK,
102            [("content-type", "application/json")],
103            cached,
104        )
105            .into_response());
106    }
107
108    // Validate and cap limit
109    let limit = params.limit.min(1000);
110
111    // Build query parameters
112    let mut query = QueryParams {
113        start_time: params.start_time,
114        end_time: params.end_time,
115        trace_id: params.trace_id.clone(),
116        ..Default::default()
117    };
118
119    if let Some(ref sid) = params.session_id {
120        if !sid.is_empty() {
121            query.predicates.push(QueryPredicate {
122                field: "session.id".to_string(),
123                operator: Operator::Equal,
124                value: QueryValue::String(sid.clone()),
125            });
126        }
127    }
128
129    if let Some(ref cid) = params.conversation_id {
130        if !cid.is_empty() {
131            query.predicates.push(QueryPredicate {
132                field: "gen_ai.conversation.id".to_string(),
133                operator: Operator::Equal,
134                value: QueryValue::String(cid.clone()),
135            });
136        }
137    }
138
139    if let Some(ref model) = params.model {
140        if !model.is_empty() {
141            query.predicates.push(QueryPredicate {
142                field: "gen_ai.request.model".to_string(),
143                operator: Operator::Equal,
144                value: QueryValue::String(model.clone()),
145            });
146        }
147    }
148
149    if let Some(ref attrs_json) = params.attrs {
150        if !attrs_json.is_empty() {
151            #[derive(serde::Deserialize)]
152            struct AttrFilter {
153                key: String,
154                op: String,
155                value: Option<String>,
156            }
157            if let Ok(filters) = serde_json::from_str::<Vec<AttrFilter>>(attrs_json) {
158                for f in filters {
159                    let op = match f.op.as_str() {
160                        "=" => Operator::Equal,
161                        "!=" => Operator::NotEqual,
162                        _ => continue,
163                    };
164                    if let Some(v) = f.value {
165                        query.predicates.push(QueryPredicate {
166                            field: f.key,
167                            operator: op,
168                            value: QueryValue::String(v),
169                        });
170                    }
171                }
172            }
173        }
174    }
175
176    // `search` filters on span names and `service`/`resource` on resource
177    // attributes — both via the structured-predicate path so they constrain
178    // trace selection exactly as any other predicate does.
179    if let Some(ref s) = params.search {
180        if !s.is_empty() {
181            query.predicates.push(QueryPredicate {
182                field: "name".to_string(),
183                operator: Operator::Contains,
184                value: QueryValue::String(s.clone()),
185            });
186        }
187    }
188    if let Some(ref svc) = params.service {
189        if !svc.is_empty() {
190            query.predicates.push(QueryPredicate {
191                field: "resource.service.name".to_string(),
192                operator: Operator::Equal,
193                value: QueryValue::String(svc.clone()),
194            });
195        }
196    }
197    if let Some(ref kv) = params.resource {
198        if let Some((k, v)) = kv.split_once('=') {
199            if !k.is_empty() && !v.is_empty() {
200                query.predicates.push(QueryPredicate {
201                    field: format!("resource.{k}"),
202                    operator: Operator::Equal,
203                    value: QueryValue::String(v.to_string()),
204                });
205            }
206        }
207    }
208
209    // Structured `query` predicate(s). Parse errors are a 400, never a
210    // silently-ignored filter.
211    if let Some(ref q) = params.query {
212        if !q.is_empty() {
213            query.predicates.extend(super::parse_query_param(q)?);
214        }
215    }
216
217    // Per-trace summaries straight from storage (#251): the old path
218    // fetched and parsed every span of the selected traces (2.9M spans
219    // for a 1 h window on a production database) to derive these eight
220    // fields; the summaries are computed in SQL instead.
221    let mut trace_entries: Vec<TraceEntry> = state
222        .storage
223        .query_trace_summaries(&query, limit)
224        .await
225        .map_err(|e| {
226            (
227                StatusCode::INTERNAL_SERVER_ERROR,
228                Json(ErrorResponse::storage_error(format!(
229                    "query trace summaries: {}",
230                    e
231                ))),
232            )
233        })?;
234
235    // Sort by start time (newest first)
236    trace_entries.sort_by_key(|b| std::cmp::Reverse(b.start_time));
237
238    // Apply pagination
239    let total = trace_entries.len();
240    let paginated_traces: Vec<TraceEntry> = trace_entries
241        .into_iter()
242        .skip(params.offset)
243        .take(limit)
244        .collect();
245
246    let response = TracesResponse {
247        traces: paginated_traces,
248        total,
249        limit,
250        offset: params.offset,
251    };
252
253    // Cache the response
254    if let Ok(json) = serde_json::to_string(&response) {
255        state.cache.traces.insert(cache_key, json.clone());
256        Ok((StatusCode::OK, [("content-type", "application/json")], json).into_response())
257    } else {
258        Ok(Json(response).into_response())
259    }
260}
261
262/// Handler for GET /api/traces/:trace_id
263#[utoipa::path(
264    get,
265    path = "/api/traces/{trace_id}",
266    params(
267        ("trace_id" = String, Path, description = "Trace ID")
268    ),
269    responses(
270        (status = 200, description = "Trace details with all spans", body = TraceDetail),
271        (status = 404, description = "Trace not found", body = ErrorResponse),
272        (status = 500, description = "Internal server error", body = ErrorResponse)
273    ),
274    tag = "traces"
275)]
276pub async fn get_trace(
277    State(state): State<AppState>,
278    Path(trace_id): Path<String>,
279) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
280    // Query all spans for this trace
281    let query = QueryParams {
282        trace_id: Some(trace_id.clone()),
283        limit: Some(1000), // Max spans per trace
284        ..Default::default()
285    };
286
287    let spans = state.storage.query_spans(&query).await.map_err(|e| {
288        (
289            StatusCode::INTERNAL_SERVER_ERROR,
290            Json(ErrorResponse::storage_error(format!(
291                "query trace spans: {}",
292                e
293            ))),
294        )
295    })?;
296
297    if spans.is_empty() {
298        return Err((
299            StatusCode::NOT_FOUND,
300            Json(ErrorResponse::not_found(format!("Trace '{}'", trace_id))),
301        ));
302    }
303
304    let start_time = spans.iter().map(|s| s.start_time).min().unwrap_or(0);
305    let end_time = spans.iter().map(|s| s.end_time).max().unwrap_or(0);
306    let duration = end_time - start_time;
307
308    let service_names: Vec<String> = {
309        let mut names: Vec<String> = spans
310            .iter()
311            .filter_map(|s| s.resource.as_ref())
312            .filter_map(|r| r.attributes.get("service.name"))
313            .cloned()
314            .collect::<std::collections::HashSet<_>>()
315            .into_iter()
316            .collect();
317        names.sort();
318        names
319    };
320
321    let span_entries: Vec<SpanEntry> = spans.into_iter().map(SpanEntry::from).collect();
322
323    let span_count = span_entries.len();
324
325    let trace_detail = TraceDetail {
326        trace_id,
327        spans: span_entries,
328        start_time,
329        end_time,
330        duration,
331        span_count,
332        service_names,
333    };
334
335    Ok(Json(trace_detail))
336}
337
338/// Handler for GET /api/traces/{trace_id}/logs
339///
340/// Returns all logs associated with a trace, enabling single-round-trip
341/// trace→log correlation without a separate API call.
342#[utoipa::path(
343    get,
344    path = "/api/traces/{trace_id}/logs",
345    params(
346        ("trace_id" = String, Path, description = "Trace ID"),
347        ("limit" = Option<usize>, Query, description = "Maximum number of log results (default: 100, max: 1000)"),
348        ("search" = Option<String>, Query, description = "Full-text search in log body")
349    ),
350    responses(
351        (status = 200, description = "Logs associated with the trace", body = LogsResponse),
352        (status = 500, description = "Internal server error", body = ErrorResponse)
353    ),
354    tag = "traces"
355)]
356pub async fn get_trace_logs(
357    State(state): State<AppState>,
358    Path(trace_id): Path<String>,
359    Query(params): Query<TraceLogsQuery>,
360) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
361    let limit = params.limit.unwrap_or(100).min(1000);
362
363    let query = QueryParams {
364        trace_id: Some(trace_id.clone()),
365        search_text: params.search.clone().filter(|s| !s.is_empty()),
366        limit: Some(limit),
367        ..Default::default()
368    };
369
370    let logs = state.storage.query_logs(&query).await.map_err(|e| {
371        (
372            StatusCode::INTERNAL_SERVER_ERROR,
373            Json(ErrorResponse::storage_error(format!(
374                "query logs for trace {}: {}",
375                trace_id, e
376            ))),
377        )
378    })?;
379
380    let log_entries: Vec<LogEntry> = logs.into_iter().map(LogEntry::from).collect();
381    let total = log_entries.len();
382
383    Ok(Json(LogsResponse {
384        logs: log_entries,
385        total,
386        limit,
387        offset: 0,
388    }))
389}
390
391/// Query parameters for GET /api/traces/{trace_id}/logs
392#[derive(Debug, Deserialize, utoipa::IntoParams)]
393pub struct TraceLogsQuery {
394    /// Maximum number of log results (default: 100, max: 1000)
395    #[serde(default)]
396    pub limit: Option<usize>,
397    /// Full-text search in log body (e.g. "api_request_body" to find request metadata)
398    #[serde(default)]
399    pub search: Option<String>,
400}
401
402/// Export format for traces
403#[derive(Debug, Deserialize, utoipa::IntoParams)]
404pub struct ExportQuery {
405    /// Export format: "json"
406    #[serde(default = "default_format")]
407    pub format: String,
408
409    /// Same filters as TracesQuery
410    #[serde(flatten)]
411    pub filters: TracesQuery,
412}
413
414fn default_format() -> String {
415    "json".to_string()
416}
417
418/// Handler for GET /api/traces/export
419#[utoipa::path(
420    get,
421    path = "/api/traces/export",
422    params(ExportQuery),
423    responses(
424        (status = 200, description = "Exported traces in JSON format"),
425        (status = 400, description = "Invalid format parameter or time range", body = ErrorResponse),
426        (status = 500, description = "Internal server error", body = ErrorResponse)
427    ),
428    tag = "traces"
429)]
430pub async fn export_traces(
431    State(state): State<AppState>,
432    Query(params): Query<ExportQuery>,
433) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
434    validate_time_range(params.filters.start_time, params.filters.end_time)?;
435
436    // Build query parameters (no limit for export, but cap at 10000)
437    let query = QueryParams {
438        start_time: params.filters.start_time,
439        end_time: params.filters.end_time,
440        limit: Some(10000),
441        trace_id: params.filters.trace_id.clone().filter(|s| !s.is_empty()),
442        ..Default::default()
443    };
444
445    let spans = state.storage.query_spans(&query).await.map_err(|e| {
446        (
447            StatusCode::INTERNAL_SERVER_ERROR,
448            Json(ErrorResponse::storage_error(format!(
449                "export traces: {}",
450                e
451            ))),
452        )
453    })?;
454
455    match params.format.as_str() {
456        "json" => {
457            let span_entries: Vec<SpanEntry> = spans.into_iter().map(SpanEntry::from).collect();
458
459            Ok((
460                [
461                    ("Content-Type", "application/json"),
462                    (
463                        "Content-Disposition",
464                        "attachment; filename=\"traces.json\"",
465                    ),
466                ],
467                Json(span_entries),
468            )
469                .into_response())
470        },
471        _ => Err((
472            StatusCode::BAD_REQUEST,
473            Json(ErrorResponse::bad_request(
474                "Invalid format parameter. Use 'json'",
475            )),
476        )),
477    }
478}