8Audit JSONL logger and AI-Trace decorator for Phase 4B ``oct git``
9commands. Writes one JSON object per line to
10``<project>/logs/git-audit-YYYYMMDD-HHMMSS.jsonl`` with size + count
11rotation, and provides the :func:`audited` decorator that every
12``oct git`` Click command is wrapped in so that entry/exit trace events
13and a persistent audit row are recorded even if the command crashes.
17- Define :class:`AuditRecord` — the exact on-disk schema (blueprint
19- Implement :class:`AuditLogger` with size-based rotation (5 MB) and
20 count-based pruning (keep the 20 newest files).
21- Redact credentials from any string value written to the log by
22 deferring to :func:`oct.core.git._redact_git_url`.
23- Emit AI-Trace shadow events via ``_dbg("GIT", msg, 4)`` — file-only,
24 off by default; see blueprint §12.3 and Phase 4B design decision D7.
30 L1 — errors: rotation IO failures, directory-create failures
31 L2 — lifecycle: file rotated, files pruned
32 L4 — deep trace: every ``write()`` call and every command start/end
36- This module does not import ``click``, does not shell out, and does
37 not touch any git state. It only reads config-less arguments passed
39- :meth:`AuditLogger.write` must never raise. On any IO failure it
40 logs an error via ``_dbg`` and returns silently — the audit log is
41 *observability*, not a correctness path.
42- Filenames follow ``git-audit-YYYYMMDD-HHMMSS.jsonl`` strictly so that
43 lexical ordering matches chronological ordering for pruning.
44- Every ``AuditRecord`` field is JSON-serialisable. ``args`` is always
45 passed through credential redaction before serialisation.
48from __future__
import annotations
53from dataclasses
import asdict, dataclass, field
54from datetime
import datetime, timezone
55from pathlib
import Path
56from typing
import Any, Callable
58from oct.core.diagnostics
import _dbg
59from oct.core.git
import _redact_git_url
60from oct.core.option_c_dir
import resolve_logs_dir
69AUDIT_MAX_FILES: int = 20
74AUDIT_MAX_SIZE_BYTES: int = 5 * 1024 * 1024
78_AUDIT_TS_FORMAT: str =
"%Y%m%d-%H%M%S"
81_AUDIT_FILENAME_PREFIX: str =
"git-audit-"
82_AUDIT_FILENAME_SUFFIX: str =
".jsonl"
83_AUDIT_GLOB: str = f
"{_AUDIT_FILENAME_PREFIX}*{_AUDIT_FILENAME_SUFFIX}"
91_AUDIT_SUBDIR: str =
"logs"
101 """Schema for one audit row. Serialised via :func:`dataclasses.asdict`.
103 Every field is either JSON-primitive or a list/dict of JSON-primitives
104 so that ``json.dumps(asdict(record))`` is guaranteed to succeed.
105 ``args`` is credential-redacted by the caller before being assigned
106 (see :func:`_redact_args`).
108 Fields whose value is unknown at record-construction time may be
109 left at their default; missing fields are emitted as ``null`` in
115 args: list[str] = field(default_factory=list)
116 branch: str |
None =
None
117 staged_files: int = 0
118 checks_passed: bool =
False
119 secrets_detected: bool =
False
120 lint_violations: int = 0
121 format_violations: int = 0
122 exit_code: int |
None =
None
128 per_subproject_results: list[dict] = field(default_factory=list)
132 """Return the current UTC time as an ISO 8601 string.
134 Uses ``datetime.now(timezone.utc)`` rather than ``datetime.utcnow``
135 (deprecated in 3.12+) and appends a literal ``Z`` so readers can
136 round-trip the value without timezone ambiguity.
138 return datetime.now(timezone.utc).strftime(
"%Y-%m-%dT%H:%M:%SZ")
141def _redact_args(argv: list[str] | tuple[str, ...] |
None) -> list[str]:
142 """Run each argv element through :func:`_redact_git_url`.
144 This is defensive: an ``oct git`` command rarely receives a remote
145 URL as a positional arg, but a user *could* pass one via future
146 subcommands (e.g. ``oct git init --remote``). Applying the redactor
147 unconditionally costs a regex match per arg and closes the hole.
153 if isinstance(item, str):
154 out.append(_redact_git_url(item))
156 out.append(str(item))
166 """Append-only rotating JSONL logger for ``oct git`` commands.
168 One instance corresponds to one project root. The constructor does
169 not open a file — the first :meth:`write` call lazily materialises
170 the ``logs/`` directory and the current audit file. This lets a
171 command that never reaches the write stage (e.g. a CLI parse error)
172 leave no side-effects on disk.
176 - **Size rotation:** before each write, if the current file is at or
177 above :data:`AUDIT_MAX_SIZE_BYTES`, close it and open a new
178 timestamped file. The rotating write itself goes into the new
179 file, not the old one.
180 - **Count pruning:** after any rotation, if the number of
181 ``git-audit-*.jsonl`` files in the logs directory exceeds
182 :data:`AUDIT_MAX_FILES`, delete the oldest (lexical order) until
183 the count is within the limit.
187 Any IO error during :meth:`write` — creating the directory, opening
188 the file, serialising the record, writing the line, or rotating —
189 is caught and logged via ``_dbg("GIT", ..., 1)``. The method never
190 raises; audit logging is best-effort observability and must not
191 mask command failures.
195 func =
"AuditLogger.__init__"
202 _dbg(
"GIT", func, f
"project_root={self._project_root}", 4)
207 """Create ``<project>/logs/`` on demand. Idempotent."""
209 self.
_logs_dir.mkdir(parents=
True, exist_ok=
True)
212 """Return a fresh ``git-audit-YYYYMMDD-HHMMSS.jsonl`` path.
214 Uses the current UTC timestamp. If the resulting filename
215 already exists (unlikely but possible at sub-second rotation or
216 in a fast loop), disambiguate by appending a monotonic suffix
217 ``-<counter>`` so no two rotations collide.
219 ts = datetime.now(timezone.utc).strftime(_AUDIT_TS_FORMAT)
220 base = f
"{_AUDIT_FILENAME_PREFIX}{ts}{_AUDIT_FILENAME_SUFFIX}"
223 while candidate.exists():
226 / f
"{_AUDIT_FILENAME_PREFIX}{ts}-{counter}{_AUDIT_FILENAME_SUFFIX}"
232 """Return the current audit file's size in bytes, 0 if missing."""
241 """Open a fresh file if the current one is missing or full.
243 Called at the top of :meth:`write`. After opening a new file,
244 delegates to :meth:`_prune_old_files` so count-based rotation
245 runs at the same moment size-based rotation does (cheapest
248 func =
"AuditLogger._rotate_if_needed"
252 _dbg(
"GIT", func, f
"opened initial file={self._current_path.name}", 2)
256 _dbg(
"GIT", func, f
"size rotation from={self._current_path.name}", 2)
262 """Return all ``git-audit-*.jsonl`` files in sorted order.
264 Sort is lexical, which matches chronological thanks to the
265 fixed-width ``YYYYMMDD-HHMMSS`` timestamp format.
269 return sorted(self.
_logs_dir.glob(_AUDIT_GLOB))
272 """Delete the oldest files until count <= :data:`AUDIT_MAX_FILES`.
274 Deletion is best-effort: an ``OSError`` from a concurrent
275 reader holding a handle on Windows is logged but not re-raised.
277 func =
"AuditLogger._prune_old_files"
279 excess = len(files) - AUDIT_MAX_FILES
282 victims = files[:excess]
286 _dbg(
"GIT", func, f
"pruned {path.name}", 2)
287 except OSError
as exc:
288 _dbg(
"GIT", func, f
"prune failed {path.name}: {exc}", 1)
292 def write(self, record: AuditRecord) ->
None:
293 """Serialise ``record`` and append it as one JSONL line.
295 Never raises. On IO failure, logs an error via ``_dbg`` at
296 level 1 and returns. The caller is never blocked by audit log
299 func =
"AuditLogger.write"
307 line = json.dumps(asdict(record), ensure_ascii=
False)
311 _dbg(
"GIT", func, f
"wrote record exit_code={record.exit_code}", 4)
312 except OSError
as exc:
313 _dbg(
"GIT", func, f
"audit write failed: {exc}", 1)
314 except (TypeError, ValueError)
as exc:
315 _dbg(
"GIT", func, f
"audit serialize failed: {exc}", 1)
319 """Current audit filename (``None`` before first write)."""
328def audited(cmd_name: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
329 """Decorator that wraps an ``oct git`` Click command.
333 1. Emit ``_dbg("GIT", "command_start ...", 4)`` before the command.
334 2. Run the wrapped function.
335 3. In a ``finally`` block, emit ``_dbg("GIT", "command_end ...", 4)``
336 and write an :class:`AuditRecord` into the project's audit log.
338 The wrapped command is responsible for populating
339 ``ctx.obj["_audit_record"]`` during its run — the decorator reads
340 that slot at the end and writes whatever is there, falling back to
341 a minimal record (with ``exit_code=None`` and ``checks_passed=False``)
342 if the command crashed before populating it. This guarantees every
343 command invocation leaves exactly one audit row.
348 Human-readable command identifier for the trace and audit row,
349 e.g. ``"oct git check"``.
352 def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
354 def wrapper(ctx, *args, **kwargs):
357 func = f
"audited::{cmd_name}"
358 start = time.monotonic()
359 _dbg(
"GIT", func, f
"command_start cmd={cmd_name}", 4)
365 if not isinstance(getattr(ctx,
"obj",
None), dict):
374 return fn(ctx, *args, **kwargs)
376 duration_ms = int((time.monotonic() - start) * 1000)
377 record: AuditRecord = ctx.obj.get(
"_audit_record")
or AuditRecord(
381 if not record.timestamp:
383 if not record.command:
384 record.command = cmd_name
385 record.duration_ms = duration_ms
390 psr = ctx.obj.get(
"_per_subproject_results")
391 if isinstance(psr, list):
392 record.per_subproject_results = psr
395 f
"command_end cmd={cmd_name} exit_code={record.exit_code} "
396 f
"duration_ms={duration_ms}",
407 """Best-effort extraction of the project root from a Click context.
411 1. ``ctx.obj["project_root"]`` — the canonical slot set by the
412 top-level ``cli`` group (see [oct/oct/cli.py](../cli.py)).
413 2. ``ctx.obj["root"]`` — older convention used by a couple of
415 3. :func:`oct.core.project_root.get_project_root` — the formal
416 resolver, which consults ``--root-dir`` and walks upward.
417 4. ``Path.cwd()`` as the absolute last resort.
419 Any exception is swallowed and falls through to the next step so
420 the decorator never blocks command execution on a resolution error.
422 obj = getattr(ctx,
"obj",
None)
423 if isinstance(obj, dict):
424 for key
in (
"project_root",
"root"):
425 candidate = obj.get(key)
428 return Path(candidate)
429 except (TypeError, ValueError):
432 from oct.core.project_root
import get_project_root
433 return Path(get_project_root(ctx))
None _rotate_if_needed(self)
Path|None current_path(self)
None __init__(self, Path project_root)
list[Path] _list_existing_files(self)
None write(self, AuditRecord record)
int _current_file_size(self)
None _prune_old_files(self)
None _ensure_logs_dir(self)
Path _resolve_project_root_from_ctx(Any ctx)
list[str] _redact_args(list[str]|tuple[str,...]|None argv)
Callable[[Callable[..., Any]], Callable[..., Any]] audited(str cmd_name)