diff --git a/Justfile b/Justfile index 0962cb1..c0210a2 100644 --- a/Justfile +++ b/Justfile @@ -9,8 +9,8 @@ build-frontend: pnpm run -C web build # Auto-reloading backend server -backend services=default_services: - bacon --headless run -- -- --services "{{services}}" +backend *ARGS: + bacon --headless run -- -- {{ARGS}} # Production build build: @@ -19,10 +19,10 @@ build: # Run auto-reloading development build with release characteristics (frontend is embedded, non-auto-reloading) # This is useful for testing backend release-mode details. -dev-build services=default_services: build-frontend - bacon --headless run -- --profile dev-release -- --services "{{services}}" --tracing pretty +dev-build *ARGS='--services web --tracing pretty': build-frontend + bacon --headless run -- --profile dev-release -- {{ARGS}} # Auto-reloading development build for both frontend and backend # Will not notice if either the frontend/backend crashes, but will generally be resistant to stopping on their own. [parallel] -dev services=default_services: frontend (backend services) \ No newline at end of file +dev: frontend backend \ No newline at end of file diff --git a/README.md b/README.md index ab2ee22..0d150b7 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ pnpm install -C web # Install frontend dependencies cargo build # Build the backend just dev # Runs auto-reloading dev build -just dev bot,web # Runs auto-reloading dev build, running only the bot and web services +just dev --services bot,web # Runs auto-reloading dev build, running only the bot and web services just dev-build # Development build with release characteristics (frontend is embedded, non-auto-reloading) just build # Production build that embeds assets diff --git a/src/web/routes.rs b/src/web/routes.rs index 2ef0620..d0e6b43 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -2,6 +2,7 @@ use axum::{ Router, + body::Body, extract::{Request, State}, http::{HeaderMap, HeaderValue, StatusCode, Uri}, response::{Html, IntoResponse, Json, Response}, @@ -9,12 +10,13 @@ use axum::{ }; use http::header; use serde_json::{Value, json}; -use std::sync::Arc; +use std::{sync::Arc, time::Duration}; use tower_http::{ + classify::ServerErrorsFailureClass, cors::{Any, CorsLayer}, trace::TraceLayer, }; -use tracing::info; +use tracing::{Span, debug, info, warn}; use crate::web::assets::{WebAssets, get_asset_metadata_cached}; @@ -70,47 +72,74 @@ pub fn create_router(state: BannerState) -> Router { .route("/metrics", get(metrics)) .with_state(state); - if cfg!(debug_assertions) { - // Development mode: API routes only, frontend served by Vite dev server - Router::new() - .route("/", get(root)) - .nest("/api", api_router) - .layer( - CorsLayer::new() - .allow_origin(Any) - .allow_methods(Any) - .allow_headers(Any), - ) - .layer(TraceLayer::new_for_http()) - } else { - // Production mode: serve embedded assets and handle SPA routing - Router::new() - .route("/", get(root)) - .nest("/api", api_router) - .fallback(fallback) - .layer(TraceLayer::new_for_http()) - } -} + let mut router = Router::new().nest("/api", api_router); -async fn root() -> Response { if cfg!(debug_assertions) { - // Development mode: return API info - Json(json!({ - "message": "Banner Discord Bot API", - "version": "0.2.1", - "mode": "development", - "frontend": "http://localhost:3000", - "endpoints": { - "health": "/api/health", - "status": "/api/status", - "metrics": "/api/metrics" - } - })) - .into_response() + router = router.layer( + CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any), + ) } else { - // Production mode: serve the SPA index.html - handle_spa_fallback_with_headers(Uri::from_static("/"), HeaderMap::new()).await + router = router.fallback(fallback); } + + router.layer( + TraceLayer::new_for_http() + .make_span_with(|request: &Request| { + tracing::debug_span!("request", path = request.uri().path()) + }) + .on_request(()) + .on_body_chunk(()) + .on_eos(()) + .on_response( + |response: &Response, latency: Duration, _span: &Span| { + let latency_threshold = if cfg!(debug_assertions) { + Duration::from_millis(100) + } else { + Duration::from_millis(1000) + }; + + // Format latency, status, and code + let (latency_str, status, code) = ( + format!("{latency:.2?}"), + response.status().as_u16(), + format!( + "{} {}", + response.status().as_u16(), + response.status().canonical_reason().unwrap_or("??") + ), + ); + + // Log in warn if latency is above threshold, otherwise debug + if latency > latency_threshold { + warn!( + latency = latency_str, + status = status, + code = code, + "Response" + ); + } else { + debug!( + latency = latency_str, + status = status, + code = code, + "Response" + ); + } + }, + ) + .on_failure( + |error: ServerErrorsFailureClass, latency: Duration, _span: &Span| { + warn!( + error = ?error, + latency = format!("{latency:.2?}"), + "Request failed" + ); + }, + ), + ) } /// Handler that extracts request information for caching