gendesign/backend/app/main.py
Light1YT eab7e3b9f3
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m57s
CI / backend-tests (pull_request) Successful in 12m12s
fix(backend): emit app.* INFO logs to stdout on the API process (#1926)
App-level logs (logging.getLogger("app.*")) had no StreamHandler, so their
INFO was lost — `docker logs gendesign-backend-1` showed only uvicorn loggers.
Useful lines (e.g. "OSRM road-distance applied", RBAC, analyze) were invisible
when debugging on the VPS (only Sentry/GlitchTip received them as breadcrumbs).

main.py now attaches one StreamHandler to the root "app" logger at
APP_LOG_LEVEL (default INFO), idempotent (marker guard), propagate=True so the
Sentry LoggingIntegration on root still gets breadcrumbs/events and there's no
stdout double (root has no stdout handler). API process only — celery worker
(celery_app.py) untouched, since its loaders log per-sync and would be noisy.
Flood-safe on the API: the analyze hot path logs INFO per-request (parcels.py
has 3 logger.info), not per-row.

Adds tests/test_app_logging.py (handler present, level INFO, propagate intact).

Refs #1926
2026-06-27 07:43:49 +05:00

224 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""GenDesign FastAPI application entrypoint."""
import logging
import os
import re
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
import sentry_sdk
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, Response
from sentry_sdk.integrations.celery import CeleryIntegration
from sentry_sdk.integrations.fastapi import FastApiIntegration
from sentry_sdk.integrations.httpx import HttpxIntegration
from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from sentry_sdk.integrations.starlette import StarletteIntegration
from app.api.v1 import (
admin_cadastre,
admin_etl,
admin_jobs,
admin_leads,
admin_scrape,
admin_weight_profiles,
analytics,
chat,
concepts,
custom_pois,
insights,
landing,
locations,
market,
me,
own_projects,
parcels,
photos,
pilot,
ping,
trade_in,
users,
)
from app.core.audit_middleware import audit_log_middleware
from app.core.auth import get_role
from app.core.config import settings
from app.observability.sentry_scrub import scrub_sensitive_query
logger = logging.getLogger(__name__)
# App-level логи в stdout контейнера (#1926 observability). По умолчанию у app.*
# логгеров нет StreamHandler → их INFO теряется (в `docker logs` видны только
# uvicorn-логгеры), поэтому полезные строки (напр. "OSRM road-distance applied",
# RBAC, analyze) невидимы при отладке на VPS. Вешаем один StreamHandler на корневой
# "app" логгер на уровне APP_LOG_LEVEL (default INFO). propagate оставляем True →
# Sentry/GlitchTip LoggingIntegration (хендлер на root) продолжает получать
# breadcrumbs/events. Двойного stdout нет: у root своего stdout-хендлера нет.
# Идемпотентно (маркер _gd_app_stream — guard против дубля при reload/повторном
# импорте). Только API-процесс (main.py); worker (celery_app.py) НЕ трогаем —
# там loader'ы чатятся per-sync, INFO бы зашумил.
_app_logger = logging.getLogger("app")
_app_logger.setLevel(getattr(logging, os.getenv("APP_LOG_LEVEL", "INFO").upper(), logging.INFO))
if not any(getattr(_h, "_gd_app_stream", False) for _h in _app_logger.handlers):
_stream = logging.StreamHandler()
_stream.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s"))
_stream._gd_app_stream = True # type: ignore[attr-defined] # idempotency marker
_app_logger.addHandler(_stream)
# Инициализируем SDK до создания FastAPI app, чтобы все инструменты
# (middleware, маршруты) видели активный client с самого старта процесса.
# GlitchTip не поддерживает profiling — profiles_sample_rate=0.0.
if settings.glitchtip_dsn:
sentry_sdk.init(
dsn=settings.glitchtip_dsn,
environment=settings.environment,
release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown",
traces_sample_rate=settings.glitchtip_traces_sample_rate,
profiles_sample_rate=0.0,
send_default_pii=False,
before_send=scrub_sensitive_query,
before_send_transaction=scrub_sensitive_query,
integrations=[
StarletteIntegration(),
FastApiIntegration(),
CeleryIntegration(monitor_beat_tasks=True),
SqlalchemyIntegration(),
HttpxIntegration(),
LoggingIntegration(level=logging.INFO, event_level=logging.ERROR),
],
)
logger.info(
"GlitchTip SDK initialised (env=%s, traces=%.2f)",
settings.environment,
settings.glitchtip_traces_sample_rate,
)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
yield
app = FastAPI(title="GenDesign API", version="0.1.0", lifespan=lifespan)
# §19 audit log (#962, EPIC18): аудит sensitive actions (analyze/forecast/export).
# Регистрируем ПЕРВЫМ → Starlette применяет HTTP-middleware в LIFO-порядке
# (add_middleware insert(0); build_middleware_stack wraps reversed), поэтому
# rbac_guard (зарегистрирован НИЖЕ) оказывается ВНЕШНИМ слоем, а audit — внутри:
# request flow = rbac_guard → audit → router. Аудитим ТОЛЬКО запросы, которые
# rbac_guard уже пропустил (его 401/403 short-circuit'ы не доходят до audit).
# Best-effort: сбой записи не ломает запрос (см. app/core/audit_middleware.py).
app.middleware("http")(audit_log_middleware)
# RBAC: defense-in-depth поверх Caddy basic_auth + X-Authenticated-User
# (см. app/core/auth.py + auth/roles.yaml). Правила:
# 1) Любой non-public path требует X-Authenticated-User — иначе 401
# (Caddy пробрасывает заголовок только для авторизованных юзеров; нет
# заголовка = локальный curl мимо Caddy = доступ запрещён).
# 2) Юзер должен быть в roles.yaml — иначе 403 («неизвестный юзер ничего
# не видит»). Это закрывает кейс если Caddy basic_auth пропустил кого-то
# из снятой записи, или прокси-подмена заголовка.
# 3) /api/v1/admin/* — только role=admin, иначе 403.
# Public paths без auth (/health, /docs, /openapi.json) пропускаем без проверки —
# X-Authenticated-User там просто не приходит из Caddy.
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
_PUBLIC_PATHS = frozenset({"/health", "/api/v1/ping", "/docs", "/redoc", "/openapi.json"})
@app.middleware("http")
async def rbac_guard(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
# Test-mode bypass: pytest бьёт по app мимо Caddy → нет X-Authenticated-User.
# СТРОГО gated на settings.testing (default False) — прод RBAC не затронут.
# RBAC-логика покрыта отдельно в tests/test_rbac.py (своя копия middleware).
if settings.testing:
return await call_next(request)
path = request.url.path
if path in _PUBLIC_PATHS:
return await call_next(request)
username = request.headers.get("X-Authenticated-User")
if not username:
# Любой non-public path без auth-header → 401. Локальный curl мимо Caddy
# или прокси-фронт без header_up. 401 точнее чем 403 — "сначала
# аутентифицируйся".
return JSONResponse(
status_code=401,
content={"detail": "no authenticated user (Caddy basic_auth required)"},
)
try:
role = get_role(username)
except KeyError:
# Юзер в Caddy basic_auth, но не в roles.yaml → 403 на ВСЁ.
# Decided 2026-05-25: «человек без ролей вообще ничего не видит».
logger.warning("RBAC: unknown user %r tried %s", username, path)
return JSONResponse(
status_code=403,
content={"detail": "user not in roles config"},
)
if _ADMIN_API_RE.match(path) and role != "admin":
logger.info("RBAC: blocked %s (role=%s) from %s", username, role, path)
return JSONResponse(
status_code=403,
content={"detail": "admin only"},
)
return await call_next(request)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(concepts.router, prefix="/api/v1/concepts", tags=["concepts"])
app.include_router(chat.router, prefix="/api/v1/chat", tags=["chat"])
app.include_router(parcels.router, prefix="/api/v1/parcels", tags=["parcels"])
app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"])
app.include_router(market.router, prefix="/api/v1/market", tags=["market"])
app.include_router(admin_scrape.router, prefix="/api/v1/admin/scrape", tags=["admin"])
app.include_router(admin_jobs.router, prefix="/api/v1/admin/jobs", tags=["admin"])
app.include_router(admin_leads.router, prefix="/api/v1/admin/leads", tags=["admin"])
app.include_router(
admin_weight_profiles.router,
prefix="/api/v1/admin/site-finder/weight-profiles",
tags=["admin", "site-finder"],
)
app.include_router(photos.router, prefix="/api/v1/photos", tags=["photos"])
app.include_router(custom_pois.router, prefix="/api/v1/custom-pois", tags=["custom-pois"])
app.include_router(locations.router, prefix="/api/v1/locations", tags=["locations"])
app.include_router(insights.router, prefix="/api/v1/insights", tags=["insights"])
app.include_router(own_projects.router, prefix="/api/v1/own-projects", tags=["own-projects"])
app.include_router(
admin_cadastre.router,
prefix="/api/v1/admin/cadastre",
tags=["admin", "cadastre"],
)
app.include_router(
admin_etl.router,
prefix="/api/v1/admin/etl",
tags=["admin", "etl"],
)
app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"])
app.include_router(landing.router, prefix="/api/v1", tags=["landing"])
app.include_router(pilot.router, prefix="/api/v1/pilot", tags=["pilot"])
app.include_router(users.router, prefix="/api/v1", tags=["users"])
app.include_router(me.router, prefix="/api/v1", tags=["me"])
app.include_router(ping.router, prefix="/api/v1", tags=["ping"])
@app.get("/health")
async def health() -> dict[str, str]:
return {
"status": "ok",
"environment": settings.environment,
"version": app.version,
}