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#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams)]
17pub struct LogsQuery {
18 #[serde(default)]
20 pub severity: Option<String>,
21
22 #[serde(default)]
24 pub resource: Option<String>,
25
26 #[serde(default)]
28 pub search: Option<String>,
29
30 #[serde(default)]
32 pub start_time: Option<i64>,
33
34 #[serde(default)]
36 pub end_time: Option<i64>,
37
38 #[serde(default)]
40 pub trace_id: Option<String>,
41
42 #[serde(default)]
44 pub session_id: Option<String>,
45
46 #[serde(default)]
48 pub prompt_id: Option<String>,
49
50 #[serde(default)]
52 pub conversation_id: Option<String>,
53
54 #[serde(default)]
56 pub model: Option<String>,
57
58 #[serde(default)]
61 pub attrs: Option<String>,
62
63 #[serde(default)]
67 pub query: Option<String>,
68
69 #[serde(default = "default_limit")]
71 pub limit: usize,
72
73 #[serde(default)]
75 pub offset: usize,
76}
77
78fn default_limit() -> usize {
79 100
80}
81
82#[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 let cache_key = QueryCache::make_key(¶ms);
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 let limit = params.limit.min(1000);
113
114 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 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 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 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 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 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 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 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#[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 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#[derive(Debug, Deserialize, utoipa::IntoParams)]
305pub struct ExportQuery {
306 #[serde(default = "default_format")]
308 pub format: String,
309
310 #[serde(flatten)]
312 pub filters: LogsQuery,
313}
314
315fn default_format() -> String {
316 "json".to_string()
317}
318
319#[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 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 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 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
414fn 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
428fn 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}