167 lines
6.3 KiB
Python
167 lines
6.3 KiB
Python
"""Lightweight file-based audit + error logging for Thermograph.
|
|
|
|
Two log streams, written as JSON Lines (one self-contained JSON object per line):
|
|
|
|
* ``logs/audit/audit-<date>.jsonl`` — one line per graded run, recording the
|
|
total wall-clock duration, a per-phase timing breakdown, how many days the run
|
|
covered (the recent-window size and the historical span), and a ``run_type``
|
|
tag of **full** (a fresh ~45-year history fetch happened) or **partial** (the
|
|
history came from cache, so only the recent days were fetched).
|
|
|
|
* ``logs/errors/errors-<date>.jsonl`` — a *separate folder* for anything that
|
|
went wrong: each upstream failure is tagged ``retry`` (another attempt will be
|
|
made) or ``error`` (final failure), and whole-run failures are tagged ``error``.
|
|
|
|
Everything is best-effort: logging never raises into the request path.
|
|
"""
|
|
import contextlib
|
|
import contextvars
|
|
import datetime
|
|
import json
|
|
import os
|
|
import threading
|
|
import time
|
|
import uuid
|
|
|
|
import paths
|
|
|
|
LOG_ROOT = paths.LOGS_DIR
|
|
AUDIT_DIR = os.path.join(LOG_ROOT, "audit")
|
|
ERROR_DIR = os.path.join(LOG_ROOT, "errors")
|
|
ACCESS_DIR = os.path.join(LOG_ROOT, "access")
|
|
HEARTBEAT_DIR = os.path.join(LOG_ROOT, "heartbeat")
|
|
ACTIVITY_DIR = os.path.join(LOG_ROOT, "activity")
|
|
|
|
_lock = threading.Lock()
|
|
|
|
# Set for the duration of a run so error/retry logs from deep in the fetch layer
|
|
# can be correlated back to the run that triggered them.
|
|
current_run_id: "contextvars.ContextVar[str | None]" = contextvars.ContextVar(
|
|
"thermograph_run_id", default=None
|
|
)
|
|
|
|
|
|
def _now_iso() -> str:
|
|
return datetime.datetime.now().astimezone().isoformat(timespec="milliseconds")
|
|
|
|
|
|
def _today() -> str:
|
|
return datetime.date.today().isoformat()
|
|
|
|
|
|
def _append(path: str, record: dict) -> None:
|
|
try:
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
line = json.dumps(record, default=str)
|
|
with _lock:
|
|
with open(path, "a", encoding="utf-8") as f:
|
|
f.write(line + "\n")
|
|
except Exception: # noqa: BLE001 - logging must never break the request
|
|
pass
|
|
|
|
|
|
def log_event(tag: str, record: dict) -> dict:
|
|
"""Record an error or retry in the separate errors folder.
|
|
|
|
``tag`` is conventionally ``"retry"`` or ``"error"``.
|
|
"""
|
|
rec = {"ts": _now_iso(), "tag": tag, "run_id": current_run_id.get(), **record}
|
|
_append(os.path.join(ERROR_DIR, f"errors-{_today()}.jsonl"), rec)
|
|
return rec
|
|
|
|
|
|
def log_activity(tag: str, record: dict) -> dict:
|
|
"""Record a non-error application event — auth, subscriptions, notification
|
|
delivery, app lifecycle, slow requests — to a dedicated *activity* stream,
|
|
kept separate from the errors folder so these never pollute error dashboards.
|
|
``tag`` names the event (e.g. ``"auth.login"``, ``"notify.pass"``). Carries no
|
|
PII: user ids are UUIDs, and no emails/request bodies/push endpoints. Best-effort."""
|
|
rec = {"ts": _now_iso(), "tag": tag, "run_id": current_run_id.get(), **record}
|
|
_append(os.path.join(ACTIVITY_DIR, f"activity-{_today()}.jsonl"), rec)
|
|
return rec
|
|
|
|
|
|
def log_access(record: dict) -> None:
|
|
"""Append one request line (client IP, method, path, status) to the daily access
|
|
log, so request/IP data is retained for later analysis. Best-effort; never raises
|
|
into the request path."""
|
|
_append(os.path.join(ACCESS_DIR, f"access-{_today()}.jsonl"), {"ts": _now_iso(), **record})
|
|
|
|
|
|
def log_heartbeat(daemon: str, interval_s: float) -> None:
|
|
"""Emit one liveness beat for a background daemon (e.g. the subscription
|
|
notifier) to the heartbeat log stream. This is the log-shipping counterpart to
|
|
``metrics.record_heartbeat``: the observability stack tails these JSONL files, so
|
|
a beat here becomes a queryable ``tag="heartbeat"`` signal in Loki/Grafana — the
|
|
dashboard can tell a live daemon from a dead one by whether beats keep arriving
|
|
within ``interval_s``, without polling the in-process metrics endpoint. Best-effort."""
|
|
_append(
|
|
os.path.join(HEARTBEAT_DIR, f"heartbeat-{_today()}.jsonl"),
|
|
{"ts": _now_iso(), "level": "info", "tag": "heartbeat",
|
|
"daemon": daemon, "interval_s": interval_s},
|
|
)
|
|
|
|
|
|
class RunAudit:
|
|
"""Times a graded run and writes one audit line when the ``with`` block exits.
|
|
|
|
Usage::
|
|
|
|
with audit.RunAudit(endpoint="grade", lat=..., lon=...) as run:
|
|
with run.phase("history"):
|
|
...
|
|
run.set(run_type="full", history_rows=n)
|
|
return response
|
|
"""
|
|
|
|
def __init__(self, **params):
|
|
self.run_id = uuid.uuid4().hex[:12]
|
|
self.fields = {"run_id": self.run_id, **params}
|
|
self.phases: dict[str, float] = {}
|
|
self._t0 = time.perf_counter()
|
|
self._token = None
|
|
|
|
def __enter__(self) -> "RunAudit":
|
|
self._token = current_run_id.set(self.run_id)
|
|
return self
|
|
|
|
def set(self, **kw) -> "RunAudit":
|
|
"""Attach extra fields (e.g. run_type, history span) to the audit record."""
|
|
self.fields.update(kw)
|
|
return self
|
|
|
|
@contextlib.contextmanager
|
|
def phase(self, name: str):
|
|
"""Time a named part of the run; the duration (ms) lands in ``phases``."""
|
|
start = time.perf_counter()
|
|
try:
|
|
yield
|
|
finally:
|
|
self.phases[name] = round((time.perf_counter() - start) * 1000, 1)
|
|
|
|
def __exit__(self, exc_type, exc, tb) -> bool:
|
|
total = round((time.perf_counter() - self._t0) * 1000, 1)
|
|
status = "ok"
|
|
if exc is not None:
|
|
status = "error"
|
|
self.fields["error"] = repr(exc)
|
|
code = getattr(exc, "status_code", None)
|
|
if code is not None:
|
|
self.fields["http_status"] = code
|
|
# Mirror whole-run failures into the separate errors folder.
|
|
log_event("error", {
|
|
"phase": "run",
|
|
"error": repr(exc),
|
|
**{k: self.fields.get(k) for k in ("endpoint", "lat", "lon", "cell_id")},
|
|
})
|
|
record = {
|
|
"ts": _now_iso(),
|
|
"status": status,
|
|
"duration_ms": total,
|
|
"phases": self.phases,
|
|
**self.fields,
|
|
}
|
|
_append(os.path.join(AUDIT_DIR, f"audit-{_today()}.jsonl"), record)
|
|
if self._token is not None:
|
|
current_run_id.reset(self._token)
|
|
return False # never suppress the exception
|