wrongmove/api/poi_routes.py
Viktor Barzin 2357722e80
Add Server-Timing headers to all API endpoints for per-request latency breakdown
Instrument every API endpoint with Server-Timing headers so sub-operation
durations are visible in browser DevTools Network tab. Also adds Grafana
dashboard panels for per-endpoint latency comparison (p50/p95 timeseries
and p95 ranking bar gauge).
2026-02-23 21:30:51 +00:00

313 lines
11 KiB
Python

import logging
import time
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Response
from pydantic import BaseModel, Field
from api.auth import User, get_current_user
from database import engine
from models.listing import ListingType
from repositories.poi_repository import POIRepository
from repositories.user_repository import UserRepository
from services import poi_service, task_service
logger = logging.getLogger("uvicorn")
poi_router = APIRouter(prefix="/api/poi", tags=["poi"])
class CreatePOIRequest(BaseModel):
name: str = Field(max_length=200)
address: str = Field(max_length=500)
latitude: float = Field(ge=-90, le=90)
longitude: float = Field(ge=-180, le=180)
class UpdatePOIRequest(BaseModel):
name: str | None = Field(default=None, max_length=200)
address: str | None = Field(default=None, max_length=500)
latitude: float | None = Field(default=None, ge=-90, le=90)
longitude: float | None = Field(default=None, ge=-180, le=180)
class POIResponse(BaseModel):
id: int
name: str
address: str
latitude: float
longitude: float
created_at: str
class CalculateRequest(BaseModel):
travel_modes: list[str] # WALK, BICYCLE, TRANSIT
listing_type: ListingType = ListingType.RENT
listing_ids: list[int] | None = None
class POIDistanceResponse(BaseModel):
poi_id: int
poi_name: str
travel_mode: str
duration_seconds: int
distance_meters: int
def _get_user_id(user: User) -> int:
"""Resolve auth User to database user ID."""
user_repo = UserRepository(engine)
db_user = user_repo.get_user_by_email(user.email)
if db_user is None:
# Auto-create user on first POI interaction
db_user = user_repo.create_user(user.email)
if db_user.id is None:
raise HTTPException(status_code=500, detail="Failed to create user")
return db_user.id
def _poi_to_response(poi: "poi_service.PointOfInterest") -> POIResponse:
return POIResponse(
id=poi.id, # type: ignore[arg-type]
name=poi.name,
address=poi.address,
latitude=poi.latitude,
longitude=poi.longitude,
created_at=poi.created_at.isoformat(),
)
@poi_router.get("", response_model=list[POIResponse])
async def list_pois(
user: Annotated[User, Depends(get_current_user)],
response: Response,
) -> list[POIResponse]:
"""List all POIs for the current user."""
timings: list[str] = []
t0_total = time.monotonic()
t0 = time.monotonic()
user_id = _get_user_id(user)
timings.append(f"user_lookup;dur={(time.monotonic() - t0) * 1000:.1f}")
repo = POIRepository(engine)
t0 = time.monotonic()
pois = poi_service.get_user_pois(repo, user_id)
timings.append(f"fetch_pois;dur={(time.monotonic() - t0) * 1000:.1f}")
timings.append(f"total;dur={(time.monotonic() - t0_total) * 1000:.1f}")
response.headers["Server-Timing"] = ", ".join(timings)
return [_poi_to_response(p) for p in pois]
@poi_router.post("", response_model=POIResponse)
async def create_poi(
user: Annotated[User, Depends(get_current_user)],
body: CreatePOIRequest,
response: Response,
) -> POIResponse:
"""Create a new POI."""
timings: list[str] = []
t0_total = time.monotonic()
t0 = time.monotonic()
user_id = _get_user_id(user)
timings.append(f"user_lookup;dur={(time.monotonic() - t0) * 1000:.1f}")
repo = POIRepository(engine)
t0 = time.monotonic()
result = poi_service.create_poi(
repo,
user_id=user_id,
name=body.name,
address=body.address,
latitude=body.latitude,
longitude=body.longitude,
)
timings.append(f"create;dur={(time.monotonic() - t0) * 1000:.1f}")
timings.append(f"total;dur={(time.monotonic() - t0_total) * 1000:.1f}")
response.headers["Server-Timing"] = ", ".join(timings)
return _poi_to_response(result.poi)
@poi_router.put("/{poi_id}", response_model=POIResponse)
async def update_poi(
user: Annotated[User, Depends(get_current_user)],
poi_id: int,
body: UpdatePOIRequest,
response: Response,
) -> POIResponse:
"""Update an existing POI."""
timings: list[str] = []
t0_total = time.monotonic()
t0 = time.monotonic()
user_id = _get_user_id(user)
timings.append(f"user_lookup;dur={(time.monotonic() - t0) * 1000:.1f}")
repo = POIRepository(engine)
t0 = time.monotonic()
result = poi_service.update_poi(
repo,
poi_id=poi_id,
user_id=user_id,
name=body.name,
address=body.address,
latitude=body.latitude,
longitude=body.longitude,
)
timings.append(f"update;dur={(time.monotonic() - t0) * 1000:.1f}")
if result is None:
raise HTTPException(status_code=404, detail="POI not found")
timings.append(f"total;dur={(time.monotonic() - t0_total) * 1000:.1f}")
response.headers["Server-Timing"] = ", ".join(timings)
return _poi_to_response(result.poi)
@poi_router.delete("/{poi_id}")
async def delete_poi(
user: Annotated[User, Depends(get_current_user)],
poi_id: int,
response: Response,
) -> dict[str, bool]:
"""Delete a POI and its associated distances."""
timings: list[str] = []
t0_total = time.monotonic()
t0 = time.monotonic()
user_id = _get_user_id(user)
timings.append(f"user_lookup;dur={(time.monotonic() - t0) * 1000:.1f}")
repo = POIRepository(engine)
t0 = time.monotonic()
deleted = poi_service.delete_poi(repo, poi_id, user_id)
timings.append(f"delete;dur={(time.monotonic() - t0) * 1000:.1f}")
if not deleted:
raise HTTPException(status_code=404, detail="POI not found")
timings.append(f"total;dur={(time.monotonic() - t0_total) * 1000:.1f}")
response.headers["Server-Timing"] = ", ".join(timings)
return {"success": True}
@poi_router.post("/{poi_id}/calculate")
async def calculate_distances(
user: Annotated[User, Depends(get_current_user)],
poi_id: int,
body: CalculateRequest,
response: Response,
) -> dict[str, str]:
"""Trigger distance calculation for a POI."""
timings: list[str] = []
t0_total = time.monotonic()
t0 = time.monotonic()
user_id = _get_user_id(user)
timings.append(f"user_lookup;dur={(time.monotonic() - t0) * 1000:.1f}")
repo = POIRepository(engine)
# Verify POI exists and belongs to user
t0 = time.monotonic()
poi = poi_service.get_poi(repo, poi_id)
if poi is None or poi.user_id != user_id:
raise HTTPException(status_code=404, detail="POI not found")
timings.append(f"verify_poi;dur={(time.monotonic() - t0) * 1000:.1f}")
t0 = time.monotonic()
result = poi_service.trigger_calculation(
poi_id=poi_id,
travel_modes=body.travel_modes,
listing_type=body.listing_type,
user_email=user.email,
listing_ids=body.listing_ids,
)
timings.append(f"trigger;dur={(time.monotonic() - t0) * 1000:.1f}")
if result.task_id:
task_service.add_task_for_user(user.email, result.task_id)
timings.append(f"total;dur={(time.monotonic() - t0_total) * 1000:.1f}")
response.headers["Server-Timing"] = ", ".join(timings)
return {"task_id": result.task_id or "", "message": result.message}
@poi_router.get("/distances/bulk")
async def get_bulk_distances(
user: Annotated[User, Depends(get_current_user)],
response: Response,
listing_type: ListingType = ListingType.RENT,
) -> dict[int, list[POIDistanceResponse]]:
"""Get all POI distances for the current user, keyed by listing ID."""
timings: list[str] = []
t0_total = time.monotonic()
t0 = time.monotonic()
user_id = _get_user_id(user)
timings.append(f"user_lookup;dur={(time.monotonic() - t0) * 1000:.1f}")
repo = POIRepository(engine)
t0 = time.monotonic()
pois = {p.id: p for p in poi_service.get_user_pois(repo, user_id)}
timings.append(f"fetch_pois;dur={(time.monotonic() - t0) * 1000:.1f}")
if not pois:
timings.append(f"total;dur={(time.monotonic() - t0_total) * 1000:.1f}")
response.headers["Server-Timing"] = ", ".join(timings)
return {}
from repositories.listing_repository import ListingRepository
from database import engine as db_engine
listing_repo = ListingRepository(db_engine)
t0 = time.monotonic()
all_ids = list(listing_repo.get_listing_ids(listing_type))
timings.append(f"fetch_ids;dur={(time.monotonic() - t0) * 1000:.1f}")
if not all_ids:
timings.append(f"total;dur={(time.monotonic() - t0_total) * 1000:.1f}")
response.headers["Server-Timing"] = ", ".join(timings)
return {}
t0 = time.monotonic()
distances = repo.get_distances_for_listings(all_ids, listing_type, user_id)
timings.append(f"fetch_distances;dur={(time.monotonic() - t0) * 1000:.1f}")
result: dict[int, list[POIDistanceResponse]] = {}
for d in distances:
poi_name = pois[d.poi_id].name if d.poi_id in pois else "Unknown"
result.setdefault(d.listing_id, []).append(
POIDistanceResponse(
poi_id=d.poi_id,
poi_name=poi_name,
travel_mode=d.travel_mode,
duration_seconds=d.duration_seconds,
distance_meters=d.distance_meters,
)
)
timings.append(f"total;dur={(time.monotonic() - t0_total) * 1000:.1f}")
response.headers["Server-Timing"] = ", ".join(timings)
return result
@poi_router.get("/distances")
async def get_distances(
user: Annotated[User, Depends(get_current_user)],
listing_id: int,
response: Response,
listing_type: ListingType = ListingType.RENT,
) -> list[POIDistanceResponse]:
"""Get POI distances for a specific listing."""
timings: list[str] = []
t0_total = time.monotonic()
t0 = time.monotonic()
user_id = _get_user_id(user)
timings.append(f"user_lookup;dur={(time.monotonic() - t0) * 1000:.1f}")
repo = POIRepository(engine)
t0 = time.monotonic()
poi_repo_pois = {
p.id: p for p in poi_service.get_user_pois(repo, user_id)
}
timings.append(f"fetch_pois;dur={(time.monotonic() - t0) * 1000:.1f}")
t0 = time.monotonic()
distances = poi_service.get_distances_for_listing(
repo, listing_id, listing_type, user_id
)
timings.append(f"fetch_distances;dur={(time.monotonic() - t0) * 1000:.1f}")
timings.append(f"total;dur={(time.monotonic() - t0_total) * 1000:.1f}")
response.headers["Server-Timing"] = ", ".join(timings)
return [
POIDistanceResponse(
poi_id=d.poi_id,
poi_name=poi_repo_pois[d.poi_id].name if d.poi_id in poi_repo_pois else "Unknown",
travel_mode=d.travel_mode,
duration_seconds=d.duration_seconds,
distance_meters=d.distance_meters,
)
for d in distances
]