add structured logging

This commit is contained in:
2026-08-18 21:28:48 +01:00
parent 4f3995da17
commit 2879fecf70
8 changed files with 233 additions and 101 deletions
+54 -51
View File
@@ -1,6 +1,6 @@
use crate::db;
use crate::types::{
Config, LandingDto, MonthlyAggregate, PriceTrend, SpeciesDto, SummaryDto, TopSpecies,
Config, LandingDto, LookupDto, MonthlyAggregate, PriceTrend, SpeciesDto, SummaryDto, TopSpecies,
};
use axum::{
Json, Router,
@@ -394,50 +394,51 @@ async fn get_summary(
async fn export_parquet(State(state): State<AppState>) -> ApiResult<Response> {
let conn = state.conn.clone();
let (file_path, file) =
tokio::task::spawn_blocking(move || -> ApiResult<(String, std::fs::File)> {
let conn = conn.blocking_lock();
let file_path = tokio::task::spawn_blocking(move || -> ApiResult<String> {
let conn = conn.blocking_lock();
// Use builder to ensure consistent naming for cleanup
let tmp = tempfile::Builder::new()
.prefix("hagfish-")
.suffix(".parquet")
.tempfile()
.map_err(|e| ApiError {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: format!("Failed to create temp file: {e}"),
})?;
let (_kept_file, path) = tmp.keep().map_err(|e| ApiError {
let tmp = tempfile::Builder::new()
.prefix("hagfish-")
.suffix(".parquet")
.tempfile()
.map_err(|e| ApiError {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: format!("Failed to persist temp file: {e}"),
message: format!("Failed to create temp file: {e}"),
})?;
let path_str = path
.to_str()
.ok_or_else(|| ApiError {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: "Temp path contains invalid UTF-8".to_string(),
})?
.to_string();
let (_kept_file, path) = tmp.keep().map_err(|e| ApiError {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: format!("Failed to persist temp file: {e}"),
})?;
db::export_parquet(&conn, &path_str)?;
let file = std::fs::File::open(&path_str).map_err(|e| ApiError {
let path_str = path
.to_str()
.ok_or_else(|| ApiError {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: format!("Failed to open exported file: {e}"),
})?;
message: "Temp path contains invalid UTF-8".to_string(),
})?
.to_string();
Ok((path_str, file))
})
db::export_parquet(&conn, &path_str)?;
drop(conn);
Ok(path_str)
})
.await
.map_err(|e| ApiError {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: format!("Task join error: {e}"),
})??;
let file = tokio::fs::File::open(&file_path)
.await
.map_err(|e| ApiError {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: format!("Task join error: {e}"),
})??;
message: format!("Failed to open exported file: {e}"),
})?;
let tokio_file = tokio::fs::File::from(file);
let stream = tokio_util::io::ReaderStream::new(tokio_file);
let stream = tokio_util::io::ReaderStream::new(file);
let body = Body::from_stream(stream);
let cleanup_path = file_path.clone();
@@ -448,7 +449,7 @@ async fn export_parquet(State(state): State<AppState>) -> ApiResult<Response> {
}
});
Ok(Response::builder()
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/octet-stream")
.header(
@@ -456,7 +457,10 @@ async fn export_parquet(State(state): State<AppState>) -> ApiResult<Response> {
"attachment; filename=\"landings.parquet\"",
)
.body(body)
.unwrap())
.map_err(|e| ApiError {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: format!("Failed to build response: {e}"),
})
}
async fn static_handler(uri: axum::http::Uri) -> Response {
@@ -498,13 +502,13 @@ async fn static_handler(uri: axum::http::Uri) -> Response {
}
}
async fn get_zones(State(state): State<AppState>) -> ApiResult<Json<Vec<SpeciesDto>>> {
async fn get_zones(State(state): State<AppState>) -> ApiResult<Json<Vec<LookupDto>>> {
let conn = state.conn.clone();
let rows = tokio::task::spawn_blocking(move || -> db::Result<Vec<SpeciesDto>> {
let rows = tokio::task::spawn_blocking(move || -> db::Result<Vec<LookupDto>> {
let conn = conn.blocking_lock();
let mut stmt = conn.prepare("SELECT code, label FROM zone ORDER BY code")?;
let rows = stmt.query_map([], |row| {
Ok(SpeciesDto {
Ok(LookupDto {
code: row.get(0)?,
label: row.get(1)?,
})
@@ -520,13 +524,13 @@ async fn get_zones(State(state): State<AppState>) -> ApiResult<Json<Vec<SpeciesD
Ok(Json(rows))
}
async fn get_gear(State(state): State<AppState>) -> ApiResult<Json<Vec<SpeciesDto>>> {
async fn get_gear(State(state): State<AppState>) -> ApiResult<Json<Vec<LookupDto>>> {
let conn = state.conn.clone();
let rows = tokio::task::spawn_blocking(move || -> db::Result<Vec<SpeciesDto>> {
let rows = tokio::task::spawn_blocking(move || -> db::Result<Vec<LookupDto>> {
let conn = conn.blocking_lock();
let mut stmt = conn.prepare("SELECT code, label FROM gear ORDER BY code")?;
let rows = stmt.query_map([], |row| {
Ok(SpeciesDto {
Ok(LookupDto {
code: row.get(0)?,
label: row.get(1)?,
})
@@ -962,7 +966,7 @@ mod tests {
#[tokio::test]
async fn test_concurrent_export_requests() {
let state = test_state();
let app = build_router(state.clone());
let app = build_router(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
@@ -970,12 +974,11 @@ mod tests {
axum::serve(listener, app).await.unwrap();
});
let resp1 = reqwest::get(format!("http://{addr}/api/export.parquet"))
.await
.unwrap();
let resp2 = reqwest::get(format!("http://{addr}/api/export.parquet"))
.await
.unwrap();
let url = format!("http://{addr}/api/export.parquet");
let (resp1, resp2) = tokio::join!(reqwest::get(&url), reqwest::get(&url));
let resp1 = resp1.unwrap();
let resp2 = resp2.unwrap();
assert_eq!(resp1.status(), StatusCode::OK);
assert_eq!(resp2.status(), StatusCode::OK);
@@ -985,8 +988,8 @@ mod tests {
assert_eq!(&bytes1[..4], b"PAR1");
assert_eq!(&bytes2[..4], b"PAR1");
assert_ne!(bytes1.len(), 0);
assert_ne!(bytes2.len(), 0);
assert!(!bytes1.is_empty());
assert!(!bytes2.is_empty());
}
#[tokio::test]