Logs de diagnostic et rapprochement SumUp ↔ bénévole
- Une ligne de log par requête (route modèle, statut, durée, code d'erreur, compte), identifiant X-Request-ID repris dans chaque log et affiché à l'utilisateur sous une erreur, exceptions non gérées tracées (500 JSON) - Journal d'accès d'uvicorn coupé : il exposait le jeton du formulaire public - Logs métier : paiement SumUp, connexion, import, plannings, solveur - Nom du bénévole dans la description du checkout SumUp - Code et identifiant de transaction SumUp enregistrés au paiement (migration 0023), affichés sur la fiche et dans l'export Excel Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d722a81a87
commit
d0fa1e109c
@@ -0,0 +1,130 @@
|
||||
"""Journal des requêtes HTTP — une ligne par requête, pour diagnostiquer
|
||||
après coup une erreur rencontrée par un utilisateur (voir CLAUDE.md § 8,
|
||||
RGPD : identifiants et codes seulement).
|
||||
|
||||
- Chaque requête reçoit un identifiant (`X-Request-ID`, repris s'il est
|
||||
fourni et bien formé, généré sinon), renvoyé dans l'en-tête de la réponse
|
||||
et préfixé à toutes les lignes de log émises pendant son traitement
|
||||
(`app/logging_config.py:request_id_var`).
|
||||
- La ligne d'accès donne la route **modèle** (`/editions/{edition_id}/…`) et
|
||||
ses paramètres d'identifiants, jamais l'URL brute : la chaîne de requête
|
||||
peut porter un jeton (`access_token` du formulaire public), et les
|
||||
paramètres de chemin nommés `*token*` (inscription en attente) sont omis.
|
||||
- Code d'erreur métier (`detail.code`) et compte connecté ajoutés quand ils
|
||||
existent ; détails d'une erreur de validation 422 (emplacement + type,
|
||||
jamais la valeur saisie).
|
||||
- Une exception non gérée est journalisée avec sa trace et renvoie un 500
|
||||
JSON `{detail: {code: "error.internal", request_id}}` plutôt qu'un texte
|
||||
brut.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.exception_handlers import (
|
||||
http_exception_handler,
|
||||
request_validation_exception_handler,
|
||||
)
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from app.logging_config import request_id_var
|
||||
|
||||
logger = logging.getLogger("app.request")
|
||||
|
||||
REQUEST_ID_HEADER = "X-Request-ID"
|
||||
_INCOMING_ID = re.compile(r"^[A-Za-z0-9-]{8,64}$")
|
||||
# Sondes de santé (Docker, Caddy) : pas une ligne INFO toutes les 10 s.
|
||||
_QUIET_PATHS = {"/health"}
|
||||
|
||||
|
||||
def _route_label(request: Request) -> str:
|
||||
route = request.scope.get("route")
|
||||
template = getattr(route, "path", None) or "<non routé>"
|
||||
params = {
|
||||
key: value
|
||||
for key, value in (request.scope.get("path_params") or {}).items()
|
||||
if "token" not in key
|
||||
}
|
||||
if not params:
|
||||
return template
|
||||
return f"{template} " + " ".join(f"{k}={v}" for k, v in params.items())
|
||||
|
||||
|
||||
def install_request_logging(app: FastAPI) -> None:
|
||||
@app.middleware("http")
|
||||
async def log_requests(request: Request, call_next):
|
||||
incoming = request.headers.get(REQUEST_ID_HEADER, "")
|
||||
request_id = incoming if _INCOMING_ID.match(incoming) else uuid.uuid4().hex[:12]
|
||||
reset = request_id_var.set(request_id)
|
||||
started = time.monotonic()
|
||||
try:
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Exception non gérée sur %s %s", request.method, _route_label(request)
|
||||
)
|
||||
response = JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": {"code": "error.internal", "request_id": request_id}},
|
||||
)
|
||||
duration_ms = (time.monotonic() - started) * 1000
|
||||
response.headers[REQUEST_ID_HEADER] = request_id
|
||||
|
||||
status_code = response.status_code
|
||||
if status_code >= 500:
|
||||
level = logging.ERROR
|
||||
elif status_code >= 400:
|
||||
level = logging.WARNING
|
||||
elif request.url.path in _QUIET_PATHS or request.method == "OPTIONS":
|
||||
level = logging.DEBUG
|
||||
else:
|
||||
level = logging.INFO
|
||||
extras = []
|
||||
error_code = getattr(request.state, "error_code", None)
|
||||
if error_code:
|
||||
extras.append(f"code={error_code}")
|
||||
account_id = getattr(request.state, "account_id", None)
|
||||
if account_id:
|
||||
extras.append(f"compte={account_id}")
|
||||
logger.log(
|
||||
level,
|
||||
"%s %s → %d (%d ms)%s",
|
||||
request.method,
|
||||
_route_label(request),
|
||||
status_code,
|
||||
duration_ms,
|
||||
(" " + " ".join(extras)) if extras else "",
|
||||
)
|
||||
return response
|
||||
finally:
|
||||
request_id_var.reset(reset)
|
||||
|
||||
@app.exception_handler(StarletteHTTPException)
|
||||
async def log_http_exception(request: Request, exc: HTTPException):
|
||||
detail = exc.detail
|
||||
if isinstance(detail, dict) and detail.get("code"):
|
||||
request.state.error_code = detail["code"]
|
||||
violations = detail.get("violations")
|
||||
if violations:
|
||||
# Codes de violation seulement (`SHIFT_OVERLAP`…) : les
|
||||
# paramètres peuvent porter des horaires, pas utiles ici.
|
||||
request.state.error_code += " violations=" + ",".join(
|
||||
sorted({str(v.get("code")) for v in violations if isinstance(v, dict)})
|
||||
)
|
||||
return await http_exception_handler(request, exc)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def log_validation_error(request: Request, exc: RequestValidationError):
|
||||
# Emplacement + type d'erreur, jamais `input` (la valeur saisie, qui
|
||||
# peut être un nom, un email…).
|
||||
request.state.error_code = "validation " + "; ".join(
|
||||
f"{'.'.join(str(p) for p in err.get('loc', ()))}:{err.get('type')}"
|
||||
for err in exc.errors()[:10]
|
||||
)
|
||||
return await request_validation_exception_handler(request, exc)
|
||||
Reference in New Issue
Block a user