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).
This commit is contained in:
Viktor Barzin 2026-02-23 21:30:51 +00:00
parent 35f1987ac1
commit 2357722e80
No known key found for this signature in database
GPG key ID: 0EB088298288D958
4 changed files with 271 additions and 5 deletions

View file

@ -20,7 +20,7 @@ from api.metrics_guard import MetricsGuardMiddleware
from api.security_headers import SecurityHeadersMiddleware
from api.origin_validator import OriginValidatorMiddleware
from dotenv import load_dotenv
from fastapi import Depends, FastAPI, HTTPException, Query
from fastapi import Depends, FastAPI, HTTPException, Query, Response
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel
from starlette.requests import Request
@ -151,9 +151,11 @@ async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONR
@app.get("/api/status")
async def get_status() -> dict[str, str | None]:
async def get_status(response: Response) -> dict[str, str | None]:
t0 = time.monotonic()
repository = ListingRepository(engine)
last_updated = repository.get_last_updated()
response.headers["Server-Timing"] = f"db_query;dur={(time.monotonic() - t0) * 1000:.1f}"
return {
"status": "OK",
"last_updated": last_updated.isoformat() if last_updated else None,
@ -163,12 +165,15 @@ async def get_status() -> dict[str, str | None]:
@app.get("/api/listing")
async def get_listing(
user: Annotated[User, Depends(get_current_user)],
response: Response,
limit: int = 5,
) -> dict[str, list]:
"""Get listings from the database."""
limit = min(limit, _rate_limit_config.listing_limit_cap)
repository = ListingRepository(engine)
t0 = time.monotonic()
result = await listing_service.get_listings(repository, limit=limit)
response.headers["Server-Timing"] = f"get_listings;dur={(time.monotonic() - t0) * 1000:.1f}"
logger.info(f"Fetched {result.total_count} listings for {user.email}")
return {"listings": result.listings}
@ -177,23 +182,30 @@ async def get_listing(
async def get_listing_geojson(
user: Annotated[User, Depends(get_current_user)],
query_parameters: Annotated[QueryParameters, Depends(get_query_parameters)],
response: Response,
limit: int | None = None,
decision_filter: str = "all",
) -> dict:
"""Get listings as GeoJSON for map display."""
timings: list[str] = []
t0_total = time.monotonic()
if limit is not None:
limit = min(limit, _rate_limit_config.geojson_limit_cap)
else:
limit = _rate_limit_config.geojson_limit_cap
repository = ListingRepository(engine)
t0 = time.monotonic()
result = await export_service.export_to_geojson(
repository,
query_parameters=query_parameters,
limit=limit,
)
timings.append(f"export_geojson;dur={(time.monotonic() - t0) * 1000:.1f}")
# Apply decision filtering
if decision_filter != "everything":
t0 = time.monotonic()
user_id = _get_user_id_safe(user.email)
if user_id is not None:
decision_repo = DecisionRepository(engine)
@ -208,7 +220,10 @@ async def get_listing_geojson(
)
]
result.data["features"] = features
timings.append(f"decision_filter;dur={(time.monotonic() - t0) * 1000:.1f}")
timings.append(f"total;dur={(time.monotonic() - t0_total) * 1000:.1f}")
response.headers["Server-Timing"] = ", ".join(timings)
return result.data
@ -525,7 +540,12 @@ async def stream_listing_geojson(
timings: list[str] = []
# Build POI distances lookup if requested
poi_distances_lookup = _build_poi_distances_lookup(user.email, query_parameters.listing_type) if include_poi_distances else None
if include_poi_distances:
t0 = time.monotonic()
poi_distances_lookup = _build_poi_distances_lookup(user.email, query_parameters.listing_type)
timings.append(f"poi_lookup;dur={(time.monotonic() - t0) * 1000:.1f}")
else:
poi_distances_lookup = None
t0 = time.monotonic()
cached_count = get_cached_count(query_parameters)
@ -533,7 +553,10 @@ async def stream_listing_geojson(
if cached_count is not None and cached_count > 0 and not include_poi_distances:
app_metrics.geojson_cache_operations.add(1, {"result": "hit"})
t0 = time.monotonic()
stale = is_cache_stale(query_parameters)
timings.append(f"stale_check;dur={(time.monotonic() - t0) * 1000:.1f}")
timings.append('source;desc="cache"')
if stale:
app_metrics.cache_stale_serves_total.add(1)
# Fire-and-forget background repopulation
@ -549,6 +572,7 @@ async def stream_listing_geojson(
)
else:
app_metrics.geojson_cache_operations.add(1, {"result": "miss"})
timings.append('source;desc="db"')
generator = _instrumented_stream(
_stream_from_db(
query_parameters, batch_size, limit, poi_distances_lookup,
@ -696,9 +720,13 @@ class ListingDetailResponse(BaseModel):
async def get_listing_detail(
user: Annotated[User, Depends(get_current_user)],
listing_id: int,
response: Response,
listing_type: str = Query(default="RENT"),
) -> ListingDetailResponse:
"""Get detailed information for a single listing."""
timings: list[str] = []
t0_total = time.monotonic()
repository = ListingRepository(engine)
lt = ListingType(listing_type)
t_step = time.monotonic()
@ -708,10 +736,12 @@ async def get_listing_detail(
app_metrics.listing_detail_step_duration_seconds.record(
time.monotonic() - t_step, {"step": "fetch_listing"}
)
timings.append(f"fetch_listing;dur={(time.monotonic() - t_step) * 1000:.1f}")
if not listings:
raise HTTPException(status_code=404, detail="Listing not found")
listing = listings[0]
t_parse = time.monotonic()
additional_info = listing.additional_info or {}
property_info = additional_info.get("property", {})
@ -782,6 +812,7 @@ async def get_listing_detail(
furnish_type_val = str(ft)
# Load user's decision for this listing
timings.append(f"parse_detail;dur={(time.monotonic() - t_parse) * 1000:.1f}")
t_step = time.monotonic()
decision_val: str | None = None
user_id = _get_user_id_safe(user.email)
@ -795,6 +826,7 @@ async def get_listing_detail(
app_metrics.listing_detail_step_duration_seconds.record(
time.monotonic() - t_step, {"step": "load_decision"}
)
timings.append(f"load_decision;dur={(time.monotonic() - t_step) * 1000:.1f}")
# Load POI distances
t_step = time.monotonic()
@ -818,6 +850,9 @@ async def get_listing_detail(
app_metrics.listing_detail_step_duration_seconds.record(
time.monotonic() - t_step, {"step": "load_poi_distances"}
)
timings.append(f"load_poi_distances;dur={(time.monotonic() - t_step) * 1000:.1f}")
timings.append(f"total;dur={(time.monotonic() - t0_total) * 1000:.1f}")
response.headers["Server-Timing"] = ", ".join(timings)
return ListingDetailResponse(
id=listing.id,