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,4 @@
POSTGRES_USER=guidecity POSTGRES_USER=guidecity
POSTGRES_PASSWORD=changeme POSTGRES_PASSWORD=changeme
POSTGRES_DB=guidecity POSTGRES_DB=guidecity
LOG_LEVEL=INFO

View file

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

View file

@ -1,3 +1,5 @@
import logging
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -8,6 +10,8 @@ from app.schemas.nearby import NearbyPlace, NearbyResponse
from app.schemas.place import ContentOut from app.schemas.place import ContentOut
from app.services.nearby_search import find_nearby from app.services.nearby_search import find_nearby
logger = logging.getLogger("guidecity.nearby")
router = APIRouter(tags=["nearby"]) router = APIRouter(tags=["nearby"])
@ -32,6 +36,9 @@ def nearby(
), ),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> NearbyResponse: ) -> 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( result = find_nearby(
db, db,
lat=lat, lat=lat,

View file

@ -1,3 +1,5 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from geoalchemy2 import Geometry from geoalchemy2 import Geometry
from sqlalchemy import and_, cast, func, select 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.schemas.place import ContentOut, PlaceDetail, PlaceListItem
from app.services.geocoding import search_places from app.services.geocoding import search_places
logger = logging.getLogger("guidecity.places")
router = APIRouter(tags=["places"]) router = APIRouter(tags=["places"])
@ -56,6 +60,7 @@ def get_place(
) )
row = db.execute(stmt).first() row = db.execute(stmt).first()
if row is None: 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") raise HTTPException(status_code=404, detail="Place not found")
place, content, lon, lat = row place, content, lon, lat = row
@ -84,6 +89,7 @@ def list_city_places(
) -> list[PlaceListItem]: ) -> list[PlaceListItem]:
city = db.scalar(select(City).where(City.slug == city_slug)) city = db.scalar(select(City).where(City.slug == city_slug))
if city is None: if city is None:
logger.warning("city_slug=%r not found", city_slug)
raise HTTPException(status_code=404, detail="City not found") raise HTTPException(status_code=404, detail="City not found")
stmt = ( stmt = (
@ -111,6 +117,7 @@ def list_city_places(
stmt = stmt.where(PlaceContent.title.ilike(f"%{q}%")) stmt = stmt.where(PlaceContent.title.ilike(f"%{q}%"))
rows = db.execute(stmt).all() rows = db.execute(stmt).all()
logger.debug("city_slug=%r listing -> %d place(s)", city_slug, len(rows))
return [ return [
PlaceListItem(id=p.id, slug=p.slug, category=p.category, name=pc.title, location=LatLon(lat=lat, lon=lon)) 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 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" database_url: str = "postgresql+psycopg://guidecity:guidecity@localhost:5432/guidecity"
api_v1_prefix: str = "/api/v1" api_v1_prefix: str = "/api/v1"
log_level: str = "INFO"
settings = Settings() settings = Settings()

View file

@ -1,4 +1,11 @@
import logging 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: def configure_logging(level: int = logging.INFO) -> None:
@ -6,3 +13,25 @@ def configure_logging(level: int = logging.INFO) -> None:
level=level, level=level,
format="%(asctime)s %(levelname)s %(name)s %(message)s", 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 fastapi import FastAPI
from app.api.routes import cities, health, nearby, places from app.api.routes import cities, health, nearby, places
from app.config import settings 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(health.router, prefix=settings.api_v1_prefix)
app.include_router(cities.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. or when 2GIS's own geocoding API isn't wired into a given client flow yet.
""" """
import logging
from geoalchemy2 import Geometry from geoalchemy2 import Geometry
from sqlalchemy import and_, cast, func, select from sqlalchemy import and_, cast, func, select
from sqlalchemy.orm import Session 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 import Place
from app.models.place_content import PlaceContent from app.models.place_content import PlaceContent
logger = logging.getLogger("guidecity.geocoding")
def search_places( def search_places(
db: Session, db: Session,
@ -44,4 +48,6 @@ def search_places(
if city_slug is not None: if city_slug is not None:
stmt = stmt.join(City, City.id == Place.city_id).where(City.slug == city_slug) 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. Not implemented in this iteration callers should not pass them yet.
""" """
import logging
from dataclasses import dataclass from dataclasses import dataclass
from geoalchemy2 import Geometry 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 import Place
from app.models.place_content import PlaceContent from app.models.place_content import PlaceContent
logger = logging.getLogger("guidecity.nearby_search")
@dataclass @dataclass
class NearbyResult: class NearbyResult:
@ -44,6 +47,14 @@ def find_nearby(
city_id: int | None = None city_id: int | None = None
if city_slug is not None: if city_slug is not None:
city_id = db.scalar(select(City.id).where(City.slug == city_slug)) 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})") origin = func.ST_GeogFromText(f"SRID=4326;POINT({lon} {lat})")
distance_expr = func.ST_Distance(Place.location, origin) distance_expr = func.ST_Distance(Place.location, origin)
@ -75,8 +86,16 @@ def find_nearby(
while True: while True:
stmt = base_stmt.where(func.ST_DWithin(Place.location, origin, radius)) stmt = base_stmt.where(func.ST_DWithin(Place.location, origin, radius))
rows = db.execute(stmt).all() 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: if len(rows) >= min_results or radius >= max_radius_m:
break break
radius += step_m 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) return NearbyResult(search_radius_m=radius, rows=rows)

View file

@ -6,26 +6,33 @@ Usage:
""" """
import argparse import argparse
import logging
from pathlib import Path from pathlib import Path
import yaml import yaml
from app.core.logging import configure_logging
from app.db.session import SessionLocal from app.db.session import SessionLocal
from app.models.city import City from app.models.city import City
from app.models.place import Place from app.models.place import Place
from app.models.place_content import PlaceContent from app.models.place_content import PlaceContent
logger = logging.getLogger("guidecity.seed_loader")
def load_cities(path: Path) -> None: def load_cities(path: Path) -> None:
logger.info("loading cities from %s", path)
data = yaml.safe_load(path.read_text(encoding="utf-8")) data = yaml.safe_load(path.read_text(encoding="utf-8"))
db = SessionLocal() db = SessionLocal()
try: try:
for c in data["cities"]: for c in data["cities"]:
city = db.query(City).filter(City.slug == c["slug"]).one_or_none() city = db.query(City).filter(City.slug == c["slug"]).one_or_none()
if city is None: if city is None:
logger.debug("creating new city slug=%r", c["slug"])
city = City(slug=c["slug"], name_ru=c["name_ru"], name_en=c["name_en"]) city = City(slug=c["slug"], name_ru=c["name_ru"], name_en=c["name_en"])
db.add(city) db.add(city)
else: else:
logger.debug("updating existing city slug=%r", c["slug"])
city.name_ru = c["name_ru"] city.name_ru = c["name_ru"]
city.name_en = c["name_en"] city.name_en = c["name_en"]
center = c.get("center") center = c.get("center")
@ -33,7 +40,10 @@ def load_cities(path: Path) -> None:
city.center_point = f"SRID=4326;POINT({center['lon']} {center['lat']})" city.center_point = f"SRID=4326;POINT({center['lon']} {center['lat']})"
db.flush() db.flush()
db.commit() db.commit()
print(f"Loaded {len(data['cities'])} cities from {path.name}") logger.info("loaded %d cities from %s", len(data["cities"]), path.name)
except Exception:
logger.exception("failed loading cities from %s", path)
raise
finally: finally:
db.close() db.close()
@ -79,6 +89,7 @@ def _upsert_content(db, place_id: int, language: str, length: str, title: str, b
def load_places(path: Path) -> None: def load_places(path: Path) -> None:
logger.info("loading places from %s", path)
data = yaml.safe_load(path.read_text(encoding="utf-8")) data = yaml.safe_load(path.read_text(encoding="utf-8"))
city_slug = data["city"] city_slug = data["city"]
@ -86,11 +97,13 @@ def load_places(path: Path) -> None:
try: try:
city = db.query(City).filter(City.slug == city_slug).one_or_none() city = db.query(City).filter(City.slug == city_slug).one_or_none()
if city is None: if city is None:
logger.error("city %r not found; run --cities seed/cities.yaml first", city_slug)
raise SystemExit( raise SystemExit(
f"City '{city_slug}' not found. Run `--cities seed/cities.yaml` first." f"City '{city_slug}' not found. Run `--cities seed/cities.yaml` first."
) )
for place_data in data["places"]: for place_data in data["places"]:
logger.debug("upserting place slug=%r", place_data["slug"])
place = _upsert_place(db, city.id, place_data) place = _upsert_place(db, city.id, place_data)
titles = place_data.get("title", {}) titles = place_data.get("title", {})
for language, lengths in place_data["content"].items(): for language, lengths in place_data["content"].items():
@ -99,12 +112,16 @@ def load_places(path: Path) -> None:
_upsert_content(db, place.id, language, length, title, body.strip()) _upsert_content(db, place.id, language, length, title, body.strip())
db.commit() db.commit()
print(f"Loaded {len(data['places'])} places from {path.name}") logger.info("loaded %d places from %s", len(data["places"]), path.name)
except Exception:
logger.exception("failed loading places from %s", path)
raise
finally: finally:
db.close() db.close()
def main() -> None: def main() -> None:
configure_logging()
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
group = parser.add_mutually_exclusive_group(required=True) group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--cities", help="Path to a cities YAML file") group.add_argument("--cities", help="Path to a cities YAML file")

View file

@ -19,6 +19,7 @@ services:
build: ./backend build: ./backend
environment: environment:
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
ports: ports:
- "8000:8000" - "8000:8000"
depends_on: depends_on: