Files
rar-autopass/app/observ.py
Claude Agent c842e3352a 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>
2026-06-23 18:45:39 +00:00

148 lines
5.2 KiB
Python

"""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