feat(5.6): observabilitate + jurnal aplicatie + lifecycle trimiteri blocate
Implementeaza PRD 5.6 complet (14 stories, TDD). Doua axe:
Lifecycle trimiteri blocate (Val A):
- submissions_admin.py: sterge/repune scoped (404 cross-account inaintea lui 409 stare)
- reactivare dedup peste `error` cu CAS (WHERE id=? AND status='error'), creds noi in
submissions + accounts.rar_creds_enc; worker invalideaza sesiunea RAR la creds proaspete
(JWT 30h vechi nu mai trimite cu parola gresita); camp aditiv `reactivated:true`
- retentie randuri blocate 30z; purge_expired exclude queued/sending; purge_after curatat
la reactivare/requeue
- API DELETE /v1/prezentari/{id} + /repune (200+JSON); UI butoane + bulk + banner actionabil
Observabilitate:
- app/observ.py log_event: dublu canal app_events (DB) + RotatingFileHandler per-proces,
redactare creds/PII la scriere (redact_pii/vin_partial)
- request_id middleware + X-Request-ID pe toate raspunsurile
- handler global excepții -> 500 envelope 6-chei + request_id (traceback doar in jurnal)
- audit cerere API (api_prezentari/api_auth_esuat) + audit worker (rar_login/tranzitii)
- tab "Jurnal" filtrabil scoped (non-admin doar contul sau); retentie jurnal 90z
- rar_error expus in GET /v1/prezentari/{id} (recovery observabil)
pytest -q: 741 passed, 0 failed. Docs: PRD raport VERIFY, contract endpointuri noi, ROADMAP.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
147
app/observ.py
Normal file
147
app/observ.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""Logger structurat central (PRD 5.6 US-003).
|
||||
|
||||
Singurul punct prin care se emit evenimente de aplicatie: garanteaza format,
|
||||
redactare si dublul canal (app_events in DB + log text rotativ) consistente si
|
||||
imposibil de ocolit. Best-effort ca `notify_signup`: o cadere a jurnalului NU
|
||||
doboara cererea/worker-ul.
|
||||
|
||||
Redactare la SCRIERE (nu la afisare): toate valorile trec prin `redact_pii`
|
||||
(creds/token mascate integral, VIN/nr partial) inainte de persistare (US-007).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import json
|
||||
import logging
|
||||
import logging.handlers
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from .config import get_settings
|
||||
from .db import get_connection, insert_app_event
|
||||
from .security import redact_pii, scrub_text
|
||||
|
||||
# request_id al cererii curente (US-002). Setat de middleware-ul HTTP; disponibil
|
||||
# in handlerul de erori (US-001) si aici, fara a polua semnaturile de functii.
|
||||
request_id_var: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
||||
"request_id", default=None
|
||||
)
|
||||
|
||||
_LEVELS = {"DEBUG": 10, "INFO": 20, "WARNING": 30, "WARN": 30, "ERROR": 40, "CRITICAL": 50}
|
||||
|
||||
# Sursa implicita a evenimentelor pentru procesul curent. API = 'api' (default);
|
||||
# worker-ul cheama set_source('worker') la pornire (T5: fisier per-proces).
|
||||
_DEFAULT_SOURCE = "api"
|
||||
|
||||
_loggers: dict[str, logging.Logger] = {}
|
||||
|
||||
|
||||
def set_source(sursa: str) -> None:
|
||||
"""Fixeaza sursa implicita a evenimentelor (apelata o data de worker la start)."""
|
||||
global _DEFAULT_SOURCE
|
||||
_DEFAULT_SOURCE = sursa
|
||||
|
||||
|
||||
def _text_logger(sursa: str) -> logging.Logger:
|
||||
"""Logger cu RotatingFileHandler pe fisier per-proces (app-<sursa>.log).
|
||||
|
||||
Rotatia pe dimensiune e in aplicatie (decizie §5) — nu depindem de deploy.
|
||||
Cheia de cache include calea: la schimbarea log_dir (teste) se creeaza un logger
|
||||
nou, fara a acumula handlere duplicate pe acelasi fisier.
|
||||
"""
|
||||
settings = get_settings()
|
||||
path = settings.log_dir / f"app-{sursa}.log"
|
||||
key = str(path)
|
||||
lg = _loggers.get(key)
|
||||
if lg is not None:
|
||||
return lg
|
||||
lg = logging.getLogger(f"autopass.events::{key}")
|
||||
lg.setLevel(logging.DEBUG)
|
||||
lg.propagate = False
|
||||
try:
|
||||
settings.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
path,
|
||||
maxBytes=settings.log_file_max_bytes,
|
||||
backupCount=settings.log_file_backup_count,
|
||||
encoding="utf-8",
|
||||
)
|
||||
handler.setFormatter(logging.Formatter("%(asctime)s %(message)s"))
|
||||
lg.addHandler(handler)
|
||||
except Exception: # noqa: BLE001 — fisier indisponibil nu trebuie sa doboare logul DB
|
||||
pass
|
||||
_loggers[key] = lg
|
||||
return lg
|
||||
|
||||
|
||||
def _purge_after(days: int) -> str:
|
||||
"""now (UTC) + days, in formatul SQLite datetime('now') ('YYYY-MM-DD HH:MM:SS')."""
|
||||
return (datetime.now(timezone.utc) + timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def log_event(
|
||||
tip: str,
|
||||
*,
|
||||
nivel: str = "INFO",
|
||||
account_id: int | None = None,
|
||||
cod: str | None = None,
|
||||
mesaj: str | None = None,
|
||||
context: dict | None = None,
|
||||
sursa: str | None = None,
|
||||
request_id: str | None = None,
|
||||
conn: Any = None,
|
||||
) -> None:
|
||||
"""Emite un eveniment: un rand `app_events` + o linie in logul text (acelasi continut redactat).
|
||||
|
||||
- `tip`: text liber documentat (lista extensibila, decizie §5).
|
||||
- `nivel`: DEBUG|INFO|WARNING|ERROR|CRITICAL. Sub `AUTOPASS_LOG_LEVEL` -> ignorat.
|
||||
- `context`: metadate (submission_id, count, status...) — NU payload PII integral.
|
||||
- `conn`: reutilizeaza conexiunea apelantului pe calea fierbinte (evita contentie WAL, T4);
|
||||
None -> deschide/inchide o conexiune proprie.
|
||||
Best-effort: orice exceptie e inghitita (jurnalul nu trebuie sa rupa fluxul).
|
||||
"""
|
||||
try:
|
||||
settings = get_settings()
|
||||
min_lvl = _LEVELS.get((settings.log_level or "INFO").upper(), 20)
|
||||
lvl = (nivel or "INFO").upper()
|
||||
if _LEVELS.get(lvl, 20) < min_lvl:
|
||||
return
|
||||
|
||||
src = sursa or _DEFAULT_SOURCE
|
||||
rid = request_id if request_id is not None else request_id_var.get()
|
||||
mesaj_red = scrub_text(mesaj) if isinstance(mesaj, str) else mesaj
|
||||
ctx_red = redact_pii(context) if context else None
|
||||
ctx_json = (
|
||||
json.dumps(ctx_red, ensure_ascii=False, default=str) if ctx_red is not None else None
|
||||
)
|
||||
purge_after = _purge_after(int(settings.log_retention_days))
|
||||
|
||||
own = conn is None
|
||||
c = conn or get_connection()
|
||||
try:
|
||||
insert_app_event(
|
||||
c,
|
||||
request_id=rid,
|
||||
account_id=account_id,
|
||||
sursa=src,
|
||||
tip=tip,
|
||||
nivel=lvl,
|
||||
cod=cod,
|
||||
mesaj=mesaj_red,
|
||||
context_json=ctx_json,
|
||||
purge_after=purge_after,
|
||||
)
|
||||
finally:
|
||||
if own:
|
||||
c.close()
|
||||
|
||||
line = (
|
||||
f"[{src}] tip={tip} nivel={lvl} cont={account_id} cod={cod} "
|
||||
f"rid={rid} {mesaj_red or ''}"
|
||||
)
|
||||
if ctx_json:
|
||||
line += f" ctx={ctx_json}"
|
||||
_text_logger(src).info(scrub_text(line))
|
||||
except Exception: # noqa: BLE001 — jurnal best-effort (ca notify_signup)
|
||||
pass
|
||||
Reference in New Issue
Block a user