use crate::server::{AppState, QueryCache};
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use otelite_core::api::{ErrorResponse, LogEntry, LogsResponse};
use otelite_core::storage::QueryParams;
use otelite_core::telemetry::LogRecord;
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams)]
pub struct LogsQuery {
#[serde(default)]
pub severity: Option<String>,
#[serde(default)]
pub resource: Option<String>,
#[serde(default)]
pub search: Option<String>,
#[serde(default)]
pub start_time: Option<i64>,
#[serde(default)]
pub end_time: Option<i64>,
#[serde(default = "default_limit")]
pub limit: usize,
#[serde(default)]
pub offset: usize,
}
fn default_limit() -> usize {
100
}
#[utoipa::path(
get,
path = "/api/logs",
params(LogsQuery),
responses(
(status = 200, description = "List of logs matching query", body = LogsResponse),
(status = 500, description = "Internal server error", body = ErrorResponse)
),
tag = "logs"
)]
pub async fn list_logs(
State(state): State<AppState>,
Query(params): Query<LogsQuery>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
let cache_key = QueryCache::make_key(¶ms);
if let Some(cached) = state.cache.logs.get(&cache_key) {
return Ok((
StatusCode::OK,
[("content-type", "application/json")],
cached,
)
.into_response());
}
let limit = params.limit.min(1000);
let mut query = QueryParams {
start_time: params.start_time,
end_time: params.end_time,
limit: Some(limit),
search_text: params.search.filter(|s| !s.is_empty()),
..Default::default()
};
if let Some(severity_str) = params.severity.as_deref().filter(|s| !s.is_empty()) {
query.min_severity = parse_severity(severity_str);
}
let logs = state.storage.query_logs(&query).await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse::storage_error(format!("query logs: {}", e))),
)
})?;
let filtered_logs: Vec<LogRecord> =
if let Some(resource_filter) = params.resource.as_deref().filter(|s| !s.is_empty()) {
logs.into_iter()
.filter(|log| matches_resource_filter(log, resource_filter))
.collect()
} else {
logs
};
let total = filtered_logs.len();
let paginated_logs: Vec<LogRecord> = filtered_logs
.into_iter()
.skip(params.offset)
.take(limit)
.collect();
let log_entries: Vec<LogEntry> = paginated_logs.into_iter().map(LogEntry::from).collect();
let response = LogsResponse {
logs: log_entries,
total,
limit,
offset: params.offset,
};
if let Ok(json) = serde_json::to_string(&response) {
state.cache.logs.insert(cache_key, json.clone());
Ok((StatusCode::OK, [("content-type", "application/json")], json).into_response())
} else {
Ok(Json(response).into_response())
}
}
#[utoipa::path(
get,
path = "/api/logs/{timestamp}",
params(
("timestamp" = i64, Path, description = "Log timestamp in nanoseconds")
),
responses(
(status = 200, description = "Log entry", body = LogEntry),
(status = 404, description = "Log not found", body = ErrorResponse),
(status = 500, description = "Internal server error", body = ErrorResponse)
),
tag = "logs"
)]
pub async fn get_log(
State(state): State<AppState>,
Path(timestamp): Path<i64>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
let query = QueryParams {
start_time: Some(timestamp),
end_time: Some(timestamp + 1),
limit: Some(1),
..Default::default()
};
let logs = state.storage.query_logs(&query).await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse::storage_error(format!(
"query log by timestamp: {}",
e
))),
)
})?;
let log = logs.into_iter().next().ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse::not_found(format!(
"Log at timestamp {}",
timestamp
))),
)
})?;
Ok(Json(LogEntry::from(log)))
}
#[derive(Debug, Deserialize, utoipa::IntoParams)]
pub struct ExportQuery {
#[serde(default = "default_format")]
pub format: String,
#[serde(flatten)]
pub filters: LogsQuery,
}
fn default_format() -> String {
"json".to_string()
}
#[utoipa::path(
get,
path = "/api/logs/export",
params(ExportQuery),
responses(
(status = 200, description = "Exported logs in requested format"),
(status = 400, description = "Invalid format parameter", body = ErrorResponse),
(status = 500, description = "Internal server error", body = ErrorResponse)
),
tag = "logs"
)]
pub async fn export_logs(
State(state): State<AppState>,
Query(params): Query<ExportQuery>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
let mut query = QueryParams {
start_time: params.filters.start_time,
end_time: params.filters.end_time,
limit: Some(10000),
search_text: params.filters.search.clone().filter(|s| !s.is_empty()),
..Default::default()
};
if let Some(severity_str) = params.filters.severity.as_deref().filter(|s| !s.is_empty()) {
query.min_severity = parse_severity(severity_str);
}
let logs = state.storage.query_logs(&query).await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse::storage_error(format!("export logs: {}", e))),
)
})?;
let filtered_logs: Vec<LogRecord> = if let Some(resource_filter) =
params.filters.resource.as_deref().filter(|s| !s.is_empty())
{
logs.into_iter()
.filter(|log| matches_resource_filter(log, resource_filter))
.collect()
} else {
logs
};
match params.format.as_str() {
"json" => {
let log_entries: Vec<LogEntry> =
filtered_logs.into_iter().map(LogEntry::from).collect();
Ok((
[
("Content-Type", "application/json"),
("Content-Disposition", "attachment; filename=\"logs.json\""),
],
Json(log_entries),
)
.into_response())
},
"csv" => {
let mut csv = String::from("timestamp,severity,body,trace_id,span_id\n");
for log in filtered_logs {
csv.push_str(&format!(
"{},{},{},{},{}\n",
log.timestamp,
log.severity.as_str(),
log.body.replace(',', ";").replace('\n', " "),
log.trace_id.unwrap_or_default(),
log.span_id.unwrap_or_default(),
));
}
Ok((
[
("Content-Type", "text/csv"),
("Content-Disposition", "attachment; filename=\"logs.csv\""),
],
csv,
)
.into_response())
},
_ => Err((
StatusCode::BAD_REQUEST,
Json(ErrorResponse::bad_request(
"Invalid format parameter. Use 'json' or 'csv'",
)),
)),
}
}
fn parse_severity(s: &str) -> Option<otelite_core::telemetry::log::SeverityLevel> {
use otelite_core::telemetry::log::SeverityLevel;
match s.to_uppercase().as_str() {
"TRACE" => Some(SeverityLevel::Trace),
"DEBUG" => Some(SeverityLevel::Debug),
"INFO" => Some(SeverityLevel::Info),
"WARN" => Some(SeverityLevel::Warn),
"ERROR" => Some(SeverityLevel::Error),
"FATAL" => Some(SeverityLevel::Fatal),
_ => None,
}
}
fn matches_resource_filter(log: &LogRecord, filter: &str) -> bool {
if let Some(resource) = &log.resource {
if let Some((key, value)) = filter.split_once('=') {
return resource.attributes.get(key).is_some_and(|v| v == value);
}
}
false
}