anhlehong
chore: add upload timing diagnostics
9087b44
from __future__ import annotations
import os
import sys
import time
import uuid
from pathlib import Path
if __package__ in {None, ""}:
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from starlette.middleware.base import BaseHTTPMiddleware
from api.routes import admin, analyses, auth, exercises, health, templates
from core.config import get_settings
from core.logging import (
bind_request_context,
clear_request_context,
get_logger,
setup_logging,
)
from services.bootstrap import initialize_runtime
setup_logging()
_log = get_logger("app.api")
DIAGNOSTIC_RESPONSE_HEADERS = [
"x-request-id",
"x-ai-coach-request-body-wait-ms",
"x-ai-coach-handler-ms",
"x-ai-coach-ingest-mbps-est",
"x-ai-coach-upload-mode",
]
def _request_metrics(request: Request, request_started_at: float, duration_ms: float) -> dict[str, object]:
route_entered_at = getattr(request.state, "route_entered_at", None)
file_saved_at = getattr(request.state, "analysis_file_saved_at", None)
enqueued_at = getattr(request.state, "analysis_enqueued_at", None)
metrics: dict[str, object] = {}
if route_entered_at is not None:
body_wait_ms = round((route_entered_at - request_started_at) * 1000, 1)
metrics["request_body_wait_ms"] = body_wait_ms
metrics["request_handler_ms"] = round(max(duration_ms - body_wait_ms, 0), 1)
if file_saved_at is not None:
metrics["request_file_save_ms"] = round((file_saved_at - route_entered_at) * 1000, 1)
if enqueued_at is not None:
metrics["request_enqueue_ms"] = round((enqueued_at - route_entered_at) * 1000, 1)
content_length_raw = request.headers.get("content-length")
if content_length_raw and content_length_raw.isdigit():
content_length = int(content_length_raw)
metrics["content_length"] = content_length
if body_wait_ms > 0:
metrics["ingest_mbps_est"] = round((content_length * 8) / (body_wait_ms / 1000) / 1_000_000, 3)
return metrics
class RequestContextMiddleware(BaseHTTPMiddleware):
"""Tag every request with a request_id, log start/end with latency."""
async def dispatch(self, request: Request, call_next):
request_id = request.headers.get("x-request-id") or uuid.uuid4().hex[:12]
bind_request_context(
request_id=request_id,
method=request.method,
path=request.url.path,
)
start = time.perf_counter()
request.state.request_started_at = start
status_code = 500
try:
response = await call_next(request)
status_code = response.status_code
response.headers["x-request-id"] = request_id
# Cache-Control for static uploads — browser cache avoids re-fetch
path = request.url.path
if path.startswith("/uploads/templates"):
response.headers["Cache-Control"] = "public, max-age=2592000" # 30 days
elif path.startswith("/uploads/"):
response.headers["Cache-Control"] = "public, max-age=604800" # 7 days
return response
except Exception:
_log.exception("request_unhandled_exception")
raise
finally:
duration_ms = round((time.perf_counter() - start) * 1000, 1)
# Skip noisy access logs for static + health.
path = request.url.path
if not (path.startswith("/uploads") or path == "/health"):
extra_metrics = _request_metrics(request, start, duration_ms)
_log.info(
"request_finished",
status=status_code,
duration_ms=duration_ms,
**extra_metrics,
)
clear_request_context()
def _start_temp_template_cleanup_thread() -> None:
"""Background thread that removes expired temporary templates every 30 minutes."""
from threading import Thread
CLEANUP_INTERVAL = 30 * 60 # 30 minutes
def _loop() -> None:
while True:
time.sleep(CLEANUP_INTERVAL)
try:
from exercises.push_up.template_registry import cleanup_expired_temporary_templates
deleted = cleanup_expired_temporary_templates()
if deleted:
_log.info("temp_template_cleanup", deleted_count=len(deleted), deleted_ids=deleted)
except Exception:
_log.exception("temp_template_cleanup_error")
Thread(target=_loop, daemon=True, name="temp-template-cleanup").start()
def create_app() -> FastAPI:
settings = get_settings()
initialize_runtime(settings)
is_prod = os.getenv("ENV", "dev").lower() == "production"
api = FastAPI(
title=settings.app_name,
version=settings.api_version,
description="FastAPI backend for AI Coach Gym video analysis.",
docs_url=None if is_prod else "/docs",
redoc_url=None if is_prod else "/redoc",
openapi_url=None if is_prod else "/openapi.json",
)
api.add_middleware(RequestContextMiddleware)
api.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=DIAGNOSTIC_RESPONSE_HEADERS,
)
api.include_router(health.router)
api.include_router(auth.router, prefix="/auth", tags=["auth"])
api.include_router(exercises.router, prefix="/exercises", tags=["exercises"])
api.include_router(templates.router, prefix="/templates", tags=["templates"])
api.include_router(analyses.router, prefix="/analyses", tags=["analyses"])
api.include_router(admin.router, prefix="/admin", tags=["admin"])
api.mount(
settings.upload_url_prefix,
StaticFiles(directory=str(settings.upload_dir)),
name="uploads",
)
_start_temp_template_cleanup_thread()
return api
app = create_app()
if __name__ == "__main__":
import uvicorn
uvicorn.run("api.main:app", host="127.0.0.1", port=8000)