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#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams)]
18pub struct TracesQuery {
19 #[serde(default)]
21 pub trace_id: Option<String>,
22
23 #[serde(default)]
25 pub service: Option<String>,
26
27 #[serde(default)]
29 pub resource: Option<String>,
30
31 #[serde(default)]
33 pub search: Option<String>,
34
35 #[serde(default)]
37 pub session_id: Option<String>,
38
39 #[serde(default)]
41 pub conversation_id: Option<String>,
42
43 #[serde(default)]
45 pub model: Option<String>,
46
47 #[serde(default)]
50 pub attrs: Option<String>,
51
52 #[serde(default)]
56 pub query: Option<String>,
57
58 #[serde(default)]
60 pub start_time: Option<i64>,
61
62 #[serde(default)]
64 pub end_time: Option<i64>,
65
66 #[serde(default = "default_limit")]
68 pub limit: usize,
69
70 #[serde(default)]
72 pub offset: usize,
73}
74
75fn default_limit() -> usize {
76 100
77}
78
79#[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 let cache_key = QueryCache::make_key(¶ms);
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 let limit = params.limit.min(1000);
110
111 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 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 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 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 trace_entries.sort_by_key(|b| std::cmp::Reverse(b.start_time));
237
238 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 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#[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 let query = QueryParams {
282 trace_id: Some(trace_id.clone()),
283 limit: Some(1000), ..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#[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#[derive(Debug, Deserialize, utoipa::IntoParams)]
393pub struct TraceLogsQuery {
394 #[serde(default)]
396 pub limit: Option<usize>,
397 #[serde(default)]
399 pub search: Option<String>,
400}
401
402#[derive(Debug, Deserialize, utoipa::IntoParams)]
404pub struct ExportQuery {
405 #[serde(default = "default_format")]
407 pub format: String,
408
409 #[serde(flatten)]
411 pub filters: TracesQuery,
412}
413
414fn default_format() -> String {
415 "json".to_string()
416}
417
418#[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 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}