Skip to main content

otelite_api/api/
logs.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::{ErrorResponse, LogEntry, LogsResponse};
10use otelite_core::query::{Operator, QueryPredicate, QueryValue};
11use otelite_core::storage::QueryParams;
12use otelite_core::telemetry::LogRecord;
13use serde::{Deserialize, Serialize};
14
15/// Query parameters for log listing
16#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams)]
17pub struct LogsQuery {
18    /// Filter by severity level (e.g., "ERROR", "WARN", "INFO")
19    #[serde(default)]
20    pub severity: Option<String>,
21
22    /// Filter by resource attribute (e.g., "service.name=my-service")
23    #[serde(default)]
24    pub resource: Option<String>,
25
26    /// Full-text search in log body
27    #[serde(default)]
28    pub search: Option<String>,
29
30    /// Start time (Unix timestamp in nanoseconds)
31    #[serde(default)]
32    pub start_time: Option<i64>,
33
34    /// End time (Unix timestamp in nanoseconds)
35    #[serde(default)]
36    pub end_time: Option<i64>,
37
38    /// Filter by trace ID
39    #[serde(default)]
40    pub trace_id: Option<String>,
41
42    /// Filter by session ID (session.id attribute)
43    #[serde(default)]
44    pub session_id: Option<String>,
45
46    /// Filter by prompt ID (prompt.id attribute)
47    #[serde(default)]
48    pub prompt_id: Option<String>,
49
50    /// Filter by gen_ai.conversation.id attribute
51    #[serde(default)]
52    pub conversation_id: Option<String>,
53
54    /// Filter by model (matches gen_ai.request.model)
55    #[serde(default)]
56    pub model: Option<String>,
57
58    /// JSON-encoded list of attribute filters: [{"key":"x","op":"=","value":"y"}, ...]
59    /// Supported ops: "=" (Equal), "!=" (NotEqual)
60    #[serde(default)]
61    pub attrs: Option<String>,
62
63    /// Structured query predicate; multiple predicates joined with `&&`,
64    /// e.g. `body contains "timeout" && attributes.session.id = "abc"`.
65    /// A malformed expression returns 400.
66    #[serde(default)]
67    pub query: Option<String>,
68
69    /// Maximum number of results (default: 100, max: 1000)
70    #[serde(default = "default_limit")]
71    pub limit: usize,
72
73    /// Offset for pagination
74    #[serde(default)]
75    pub offset: usize,
76}
77
78fn default_limit() -> usize {
79    100
80}
81
82/// Handler for GET /api/logs
83#[utoipa::path(
84    get,
85    path = "/api/logs",
86    params(LogsQuery),
87    responses(
88        (status = 200, description = "List of logs matching query", body = LogsResponse),
89        (status = 400, description = "Invalid time range", body = ErrorResponse),
90        (status = 500, description = "Internal server error", body = ErrorResponse)
91    ),
92    tag = "logs"
93)]
94pub async fn list_logs(
95    State(state): State<AppState>,
96    Query(params): Query<LogsQuery>,
97) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
98    validate_time_range(params.start_time, params.end_time)?;
99
100    // Check cache first
101    let cache_key = QueryCache::make_key(&params);
102    if let Some(cached) = state.cache.logs.get(&cache_key) {
103        return Ok((
104            StatusCode::OK,
105            [("content-type", "application/json")],
106            cached,
107        )
108            .into_response());
109    }
110
111    // Validate and cap limit
112    let limit = params.limit.min(1000);
113
114    // Build query parameters (treat empty strings as absent filters)
115    let mut query = QueryParams {
116        start_time: params.start_time,
117        end_time: params.end_time,
118        limit: Some(limit),
119        search_text: params.search.filter(|s| !s.is_empty()),
120        trace_id: params.trace_id.filter(|s| !s.is_empty()),
121        ..Default::default()
122    };
123
124    // Parse severity filter if provided
125    if let Some(severity_str) = params.severity.as_deref().filter(|s| !s.is_empty()) {
126        query.min_severity = parse_severity(severity_str);
127    }
128
129    if let Some(ref sid) = params.session_id {
130        if !sid.is_empty() {
131            query.predicates.push(QueryPredicate {
132                field: "session.id".to_string(),
133                operator: Operator::Equal,
134                value: QueryValue::String(sid.clone()),
135            });
136        }
137    }
138
139    if let Some(ref pid) = params.prompt_id {
140        if !pid.is_empty() {
141            query.predicates.push(QueryPredicate {
142                field: "prompt.id".to_string(),
143                operator: Operator::Equal,
144                value: QueryValue::String(pid.clone()),
145            });
146        }
147    }
148
149    if let Some(ref cid) = params.conversation_id {
150        if !cid.is_empty() {
151            query.predicates.push(QueryPredicate {
152                field: "gen_ai.conversation.id".to_string(),
153                operator: Operator::Equal,
154                value: QueryValue::String(cid.clone()),
155            });
156        }
157    }
158
159    if let Some(ref model) = params.model {
160        if !model.is_empty() {
161            query.predicates.push(QueryPredicate {
162                field: "gen_ai.request.model".to_string(),
163                operator: Operator::Equal,
164                value: QueryValue::String(model.clone()),
165            });
166        }
167    }
168
169    if let Some(ref attrs_json) = params.attrs {
170        if !attrs_json.is_empty() {
171            #[derive(serde::Deserialize)]
172            struct AttrFilter {
173                key: String,
174                op: String,
175                value: Option<String>,
176            }
177            if let Ok(filters) = serde_json::from_str::<Vec<AttrFilter>>(attrs_json) {
178                for f in filters {
179                    let op = match f.op.as_str() {
180                        "=" => Operator::Equal,
181                        "!=" => Operator::NotEqual,
182                        _ => continue,
183                    };
184                    if let Some(v) = f.value {
185                        query.predicates.push(QueryPredicate {
186                            field: f.key,
187                            operator: op,
188                            value: QueryValue::String(v),
189                        });
190                    }
191                }
192            }
193        }
194    }
195
196    // Structured `query` predicate(s). Parse errors are a 400, never a
197    // silently-ignored filter.
198    if let Some(ref q) = params.query {
199        if !q.is_empty() {
200            query.predicates.extend(super::parse_query_param(q)?);
201        }
202    }
203
204    // Query logs from storage
205    let logs = state.storage.query_logs(&query).await.map_err(|e| {
206        (
207            StatusCode::INTERNAL_SERVER_ERROR,
208            Json(ErrorResponse::storage_error(format!("query logs: {}", e))),
209        )
210    })?;
211
212    // Filter by resource if specified (post-query filtering for now)
213    let filtered_logs: Vec<LogRecord> =
214        if let Some(resource_filter) = params.resource.as_deref().filter(|s| !s.is_empty()) {
215            logs.into_iter()
216                .filter(|log| matches_resource_filter(log, resource_filter))
217                .collect()
218        } else {
219            logs
220        };
221
222    // Apply offset for pagination
223    let total = filtered_logs.len();
224    let paginated_logs: Vec<LogRecord> = filtered_logs
225        .into_iter()
226        .skip(params.offset)
227        .take(limit)
228        .collect();
229
230    // Convert to API format, truncating bodies so list responses stay small.
231    const MAX_LIST_BODY: usize = 512;
232    let log_entries: Vec<LogEntry> = paginated_logs
233        .into_iter()
234        .map(|r| LogEntry::from(r).truncate_body(MAX_LIST_BODY))
235        .collect();
236
237    let response = LogsResponse {
238        logs: log_entries,
239        total,
240        limit,
241        offset: params.offset,
242    };
243
244    // Cache the response
245    if let Ok(json) = serde_json::to_string(&response) {
246        state.cache.logs.insert(cache_key, json.clone());
247        Ok((StatusCode::OK, [("content-type", "application/json")], json).into_response())
248    } else {
249        Ok(Json(response).into_response())
250    }
251}
252
253/// Handler for GET /api/logs/:timestamp
254/// Note: Using timestamp as ID since LogRecord doesn't have a separate ID field
255#[utoipa::path(
256    get,
257    path = "/api/logs/{timestamp}",
258    params(
259        ("timestamp" = i64, Path, description = "Log timestamp in nanoseconds")
260    ),
261    responses(
262        (status = 200, description = "Log entry", body = LogEntry),
263        (status = 404, description = "Log not found", body = ErrorResponse),
264        (status = 500, description = "Internal server error", body = ErrorResponse)
265    ),
266    tag = "logs"
267)]
268pub async fn get_log(
269    State(state): State<AppState>,
270    Path(timestamp): Path<i64>,
271) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
272    // Query logs around this timestamp
273    let query = QueryParams {
274        start_time: Some(timestamp),
275        end_time: Some(timestamp + 1),
276        limit: Some(1),
277        ..Default::default()
278    };
279
280    let logs = state.storage.query_logs(&query).await.map_err(|e| {
281        (
282            StatusCode::INTERNAL_SERVER_ERROR,
283            Json(ErrorResponse::storage_error(format!(
284                "query log by timestamp: {}",
285                e
286            ))),
287        )
288    })?;
289
290    let log = logs.into_iter().next().ok_or_else(|| {
291        (
292            StatusCode::NOT_FOUND,
293            Json(ErrorResponse::not_found(format!(
294                "Log at timestamp {}",
295                timestamp
296            ))),
297        )
298    })?;
299
300    Ok(Json(LogEntry::from(log)))
301}
302
303/// Export format for logs
304#[derive(Debug, Deserialize, utoipa::IntoParams)]
305pub struct ExportQuery {
306    /// Export format: "json" or "csv"
307    #[serde(default = "default_format")]
308    pub format: String,
309
310    /// Same filters as LogsQuery
311    #[serde(flatten)]
312    pub filters: LogsQuery,
313}
314
315fn default_format() -> String {
316    "json".to_string()
317}
318
319/// Handler for GET /api/logs/export
320#[utoipa::path(
321    get,
322    path = "/api/logs/export",
323    params(ExportQuery),
324    responses(
325        (status = 200, description = "Exported logs in requested format"),
326        (status = 400, description = "Invalid format parameter or time range", body = ErrorResponse),
327        (status = 500, description = "Internal server error", body = ErrorResponse)
328    ),
329    tag = "logs"
330)]
331pub async fn export_logs(
332    State(state): State<AppState>,
333    Query(params): Query<ExportQuery>,
334) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
335    validate_time_range(params.filters.start_time, params.filters.end_time)?;
336
337    // Build query parameters (no limit for export, but cap at 10000)
338    let mut query = QueryParams {
339        start_time: params.filters.start_time,
340        end_time: params.filters.end_time,
341        limit: Some(10000),
342        search_text: params.filters.search.clone().filter(|s| !s.is_empty()),
343        ..Default::default()
344    };
345
346    if let Some(severity_str) = params.filters.severity.as_deref().filter(|s| !s.is_empty()) {
347        query.min_severity = parse_severity(severity_str);
348    }
349
350    let logs = state.storage.query_logs(&query).await.map_err(|e| {
351        (
352            StatusCode::INTERNAL_SERVER_ERROR,
353            Json(ErrorResponse::storage_error(format!("export logs: {}", e))),
354        )
355    })?;
356
357    // Filter by resource if specified (skip empty string = no filter)
358    let filtered_logs: Vec<LogRecord> = if let Some(resource_filter) =
359        params.filters.resource.as_deref().filter(|s| !s.is_empty())
360    {
361        logs.into_iter()
362            .filter(|log| matches_resource_filter(log, resource_filter))
363            .collect()
364    } else {
365        logs
366    };
367
368    match params.format.as_str() {
369        "json" => {
370            let log_entries: Vec<LogEntry> =
371                filtered_logs.into_iter().map(LogEntry::from).collect();
372
373            Ok((
374                [
375                    ("Content-Type", "application/json"),
376                    ("Content-Disposition", "attachment; filename=\"logs.json\""),
377                ],
378                Json(log_entries),
379            )
380                .into_response())
381        },
382        "csv" => {
383            // Simple CSV export
384            let mut csv = String::from("timestamp,severity,body,trace_id,span_id\n");
385            for log in filtered_logs {
386                csv.push_str(&format!(
387                    "{},{},{},{},{}\n",
388                    log.timestamp,
389                    log.severity.as_str(),
390                    log.body.replace(',', ";").replace('\n', " "),
391                    log.trace_id.unwrap_or_default(),
392                    log.span_id.unwrap_or_default(),
393                ));
394            }
395
396            Ok((
397                [
398                    ("Content-Type", "text/csv"),
399                    ("Content-Disposition", "attachment; filename=\"logs.csv\""),
400                ],
401                csv,
402            )
403                .into_response())
404        },
405        _ => Err((
406            StatusCode::BAD_REQUEST,
407            Json(ErrorResponse::bad_request(
408                "Invalid format parameter. Use 'json' or 'csv'",
409            )),
410        )),
411    }
412}
413
414/// Parse severity string to SeverityLevel
415fn parse_severity(s: &str) -> Option<otelite_core::telemetry::log::SeverityLevel> {
416    use otelite_core::telemetry::log::SeverityLevel;
417    match s.to_uppercase().as_str() {
418        "TRACE" => Some(SeverityLevel::Trace),
419        "DEBUG" => Some(SeverityLevel::Debug),
420        "INFO" => Some(SeverityLevel::Info),
421        "WARN" => Some(SeverityLevel::Warn),
422        "ERROR" => Some(SeverityLevel::Error),
423        "FATAL" => Some(SeverityLevel::Fatal),
424        _ => None,
425    }
426}
427
428/// Check if log matches resource filter (simple key=value matching)
429fn matches_resource_filter(log: &LogRecord, filter: &str) -> bool {
430    if let Some(resource) = &log.resource {
431        if let Some((key, value)) = filter.split_once('=') {
432            return resource.attributes.get(key).is_some_and(|v| v == value);
433        }
434    }
435    false
436}