"""Logger structurat central. 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: 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. """ 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. Setat de middleware-ul HTTP; disponibil in # handlerul de erori 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 (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-.log). Rotatia pe dimensiune e in aplicatie — 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). - `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); 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