Add structured logging throughout the backend

Request-level logging middleware (method/path/status/duration),
per-module loggers in routes/services (nearby search radius
expansion, 404s, name search), LOG_LEVEL setting wired through
docker-compose, and seed_loader converted from print() to logging.

Verified: pytest passes, and manually confirmed log lines appear for
both successful and 404 requests via `docker compose logs api`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
vrubelroman 2026-07-09 20:10:10 +00:00
parent 188bf01d6e
commit be67902b45
11 changed files with 114 additions and 4 deletions

View file

@ -1,3 +1,5 @@
import logging
from fastapi import APIRouter, Depends
from geoalchemy2 import Geometry
from sqlalchemy import cast, func, select
@ -8,6 +10,8 @@ from app.models.city import City
from app.schemas.city import CityOut
from app.schemas.common import LatLon, LocalizedName
logger = logging.getLogger("guidecity.cities")
router = APIRouter(tags=["cities"])
@ -31,4 +35,5 @@ def list_cities(db: Session = Depends(get_db)) -> list[CityOut]:
center=center,
)
)
logger.debug("listed %d cities", len(out))
return out

View file

@ -1,3 +1,5 @@
import logging
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
@ -8,6 +10,8 @@ from app.schemas.nearby import NearbyPlace, NearbyResponse
from app.schemas.place import ContentOut
from app.services.nearby_search import find_nearby
logger = logging.getLogger("guidecity.nearby")
router = APIRouter(tags=["nearby"])
@ -32,6 +36,9 @@ def nearby(
),
db: Session = Depends(get_db),
) -> NearbyResponse:
if speed is not None or heading is not None:
logger.debug("nearby called with reserved v2 params speed=%s heading=%s (ignored)", speed, heading)
result = find_nearby(
db,
lat=lat,

View file

@ -1,3 +1,5 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from geoalchemy2 import Geometry
from sqlalchemy import and_, cast, func, select
@ -13,6 +15,8 @@ from app.schemas.common import LatLon
from app.schemas.place import ContentOut, PlaceDetail, PlaceListItem
from app.services.geocoding import search_places
logger = logging.getLogger("guidecity.places")
router = APIRouter(tags=["places"])
@ -56,6 +60,7 @@ def get_place(
)
row = db.execute(stmt).first()
if row is None:
logger.warning("place_id=%d not found for lang=%s length=%s", place_id, lang, length)
raise HTTPException(status_code=404, detail="Place not found")
place, content, lon, lat = row
@ -84,6 +89,7 @@ def list_city_places(
) -> list[PlaceListItem]:
city = db.scalar(select(City).where(City.slug == city_slug))
if city is None:
logger.warning("city_slug=%r not found", city_slug)
raise HTTPException(status_code=404, detail="City not found")
stmt = (
@ -111,6 +117,7 @@ def list_city_places(
stmt = stmt.where(PlaceContent.title.ilike(f"%{q}%"))
rows = db.execute(stmt).all()
logger.debug("city_slug=%r listing -> %d place(s)", city_slug, len(rows))
return [
PlaceListItem(id=p.id, slug=p.slug, category=p.category, name=pc.title, location=LatLon(lat=lat, lon=lon))
for p, pc, lon, lat in rows

View file

@ -6,6 +6,7 @@ class Settings(BaseSettings):
database_url: str = "postgresql+psycopg://guidecity:guidecity@localhost:5432/guidecity"
api_v1_prefix: str = "/api/v1"
log_level: str = "INFO"
settings = Settings()

View file

@ -1,4 +1,11 @@
import logging
import time
from collections.abc import Awaitable, Callable
from starlette.requests import Request
from starlette.responses import Response
request_logger = logging.getLogger("guidecity.http")
def configure_logging(level: int = logging.INFO) -> None:
@ -6,3 +13,25 @@ def configure_logging(level: int = logging.INFO) -> None:
level=level,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
async def log_requests(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
"""Logs method/path/status/duration for every request handled by the app."""
start = time.perf_counter()
try:
response = await call_next(request)
except Exception:
duration_ms = (time.perf_counter() - start) * 1000
request_logger.exception(
"%s %s -> unhandled exception (%.1fms)", request.method, request.url.path, duration_ms
)
raise
duration_ms = (time.perf_counter() - start) * 1000
request_logger.info(
"%s %s -> %d (%.1fms)", request.method, request.url.path, response.status_code, duration_ms
)
return response

View file

@ -1,9 +1,26 @@
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.api.routes import cities, health, nearby, places
from app.config import settings
from app.core.logging import configure_logging, log_requests
app = FastAPI(title="guideCity API", version="0.1.0")
configure_logging(level=getattr(logging, settings.log_level.upper(), logging.INFO))
logger = logging.getLogger("guidecity")
@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
logger.info("guideCity API startup complete (log_level=%s)", settings.log_level)
yield
logger.info("guideCity API shutting down")
app = FastAPI(title="guideCity API", version="0.1.0", lifespan=lifespan)
app.middleware("http")(log_requests)
app.include_router(health.router, prefix=settings.api_v1_prefix)
app.include_router(cities.router, prefix=settings.api_v1_prefix)

View file

@ -4,6 +4,8 @@ Used when the user denies location permission and types a place of interest,
or when 2GIS's own geocoding API isn't wired into a given client flow yet.
"""
import logging
from geoalchemy2 import Geometry
from sqlalchemy import and_, cast, func, select
from sqlalchemy.orm import Session
@ -13,6 +15,8 @@ from app.models.enums import ContentLanguage, ContentLength
from app.models.place import Place
from app.models.place_content import PlaceContent
logger = logging.getLogger("guidecity.geocoding")
def search_places(
db: Session,
@ -44,4 +48,6 @@ def search_places(
if city_slug is not None:
stmt = stmt.join(City, City.id == Place.city_id).where(City.slug == city_slug)
return db.execute(stmt).all()
results = db.execute(stmt).all()
logger.info("name search q=%r city_slug=%s lang=%s -> %d result(s)", q, city_slug, language, len(results))
return results

View file

@ -10,6 +10,7 @@ results toward the user's direction of travel and to pick a shorter content
Not implemented in this iteration callers should not pass them yet.
"""
import logging
from dataclasses import dataclass
from geoalchemy2 import Geometry
@ -21,6 +22,8 @@ from app.models.enums import ContentLanguage, ContentLength
from app.models.place import Place
from app.models.place_content import PlaceContent
logger = logging.getLogger("guidecity.nearby_search")
@dataclass
class NearbyResult:
@ -44,6 +47,14 @@ def find_nearby(
city_id: int | None = None
if city_slug is not None:
city_id = db.scalar(select(City.id).where(City.slug == city_slug))
if city_id is None:
logger.warning("city_slug=%r not found; searching across all cities", city_slug)
logger.debug(
"nearby search starting: lat=%.5f lon=%.5f city_slug=%s lang=%s length=%s "
"min_results=%d initial_radius_m=%d step_m=%d max_radius_m=%d",
lat, lon, city_slug, language, length, min_results, initial_radius_m, step_m, max_radius_m,
)
origin = func.ST_GeogFromText(f"SRID=4326;POINT({lon} {lat})")
distance_expr = func.ST_Distance(Place.location, origin)
@ -75,8 +86,16 @@ def find_nearby(
while True:
stmt = base_stmt.where(func.ST_DWithin(Place.location, origin, radius))
rows = db.execute(stmt).all()
logger.debug("radius=%dm -> %d place(s) found", radius, len(rows))
if len(rows) >= min_results or radius >= max_radius_m:
break
radius += step_m
if len(rows) < min_results:
logger.warning(
"hit max_radius_m=%dm with only %d/%d place(s) found near lat=%.5f lon=%.5f",
max_radius_m, len(rows), min_results, lat, lon,
)
logger.info("nearby search done: radius=%dm count=%d", radius, len(rows))
return NearbyResult(search_radius_m=radius, rows=rows)