"""Per-tool-call usage logging (one JSONL line per invocation). Trimmed from zerto-docs-rag/docs_mcp/usage.py. Captures: timestamp, tool name, args (commodity / source / delivery / etc — all non-PII), success flag, error class on failure, elapsed ms. Useful for: spotting hot tools, seeing which queries fail upstream, weekly summaries. Writes to ``$USAGE_LOG_DIR/usage-YYYY-MM-DD.jsonl``, one file per day, auto-rotated. Old files beyond ``USAGE_LOG_KEEP_DAYS`` are deleted on each write. """ from __future__ import annotations import json import logging import os import time from contextlib import contextmanager from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any log = logging.getLogger(__name__) def _log_dir() -> Path | None: raw = os.environ.get("USAGE_LOG_DIR", "") if not raw: return None p = Path(raw) try: p.mkdir(parents=True, exist_ok=True) except OSError as e: log.warning("usage log dir %s unavailable: %s", raw, e) return None return p def _keep_days() -> int: try: return int(os.environ.get("USAGE_LOG_KEEP_DAYS", "90")) except ValueError: return 90 def _prune(dir_: Path) -> None: cutoff = (datetime.now(timezone.utc) - timedelta(days=_keep_days())).date() for f in dir_.glob("usage-*.jsonl"): try: date_part = f.stem.removeprefix("usage-") file_date = datetime.strptime(date_part, "%Y-%m-%d").date() if file_date < cutoff: f.unlink() except (ValueError, OSError): continue def write(record: dict[str, Any]) -> None: dir_ = _log_dir() if dir_ is None: return today = datetime.now(timezone.utc).date().isoformat() path = dir_ / f"usage-{today}.jsonl" try: with path.open("a", encoding="utf-8") as fp: json.dump(record, fp, default=str) fp.write("\n") _prune(dir_) except OSError as e: log.warning("usage log write failed: %s", e) @contextmanager def track(tool: str, **fields: Any): """Wrap a tool body. Logs on both success and failure with elapsed ms.""" started = time.perf_counter() record: dict[str, Any] = { "ts": datetime.now(timezone.utc).isoformat(timespec="seconds"), "tool": tool, **fields, } try: yield record record["ok"] = True except Exception as e: record["ok"] = False record["error_class"] = type(e).__name__ record["error_msg"] = str(e)[:200] raise finally: record["elapsed_ms"] = int((time.perf_counter() - started) * 1000) write(record)