stack: Telemetrie-Default nach Installationsform, Byte-Deckel und Session-Retention (schliesst #55)
Files changed: - .gitea/workflows/ci.yml - .gitignore - CHANGES.md - EVALS.md - INSTALL-MCP.md - INSTALL.md - VERSION - instructions/setup-instance.md - reports/CONTRACT.md - tools/CONTRACT.md - tools/chemenu/commands/doctor.py - tools/chemenu/config.py - tools/chemenu/mcp/server.py - tools/chemenu/telemetry/policy.py - tools/chemenu/telemetry/schema.py - tools/chemenu/telemetry/writer.py - tools/chemenu/tests/conftest.py - tools/chemenu/tests/test_doctor.py - tools/chemenu/tests/test_mcp_server.py - tools/chemenu/tests/test_telemetry_emit.py - tools/chemenu/tests/test_telemetry_policy.py - tools/chemenu/version.py
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
"""Telemetry on/off and its two quantity caps, resolved once per root.
|
||||
|
||||
One place to answer three questions, because `writer.py`, `wikitool doctor`
|
||||
and the MCP server's start-up guard must agree on the same answer for the
|
||||
same checkout - a second copy of this logic is exactly how the MCP server
|
||||
used to drift from the writer (it read `WIKI_TRACE` itself; fixed alongside
|
||||
this module, see AGENTS.md invariant 8).
|
||||
|
||||
**Precedence for `enabled`:** `WIKI_TRACE` (either direction) beats
|
||||
`.wikitool-telemetry.json`, which beats the installation-form default. The
|
||||
form is read off `config.RELEASE_STAMP_FILENAME`: present means an exported/
|
||||
distributed tree (default *off* - an operator never asked for telemetry),
|
||||
absent means the dev checkout this package ships from (default *on* - the
|
||||
traces are this stack's own measuring instrument, see EVALS.md).
|
||||
|
||||
**Caching.** Only the filesystem-derived half - the release stamp and the
|
||||
config file - is cached, keyed on the resolved root. `WIKI_TRACE` and the two
|
||||
`WIKI_TRACE_MAX_SESSION_BYTES`/`WIKI_TRACE_KEEP_SESSIONS` overrides are read
|
||||
fresh on every call: they are cheap (no I/O) and a test that flips one
|
||||
mid-run must see the new value immediately, not a cached one. A test that
|
||||
rewrites the release stamp or the config file at an already-resolved root
|
||||
calls `reset_cache()` itself, the same discipline `conventions.reset_cache()`
|
||||
already follows.
|
||||
|
||||
Stdlib only, like the rest of `chemenu.telemetry` - a hook handler imports
|
||||
this package on every tool call and must not need the venv.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from chemenu import config
|
||||
|
||||
DEFAULT_MAX_SESSION_BYTES = 5 * 1024 * 1024
|
||||
DEFAULT_KEEP_SESSIONS = 250
|
||||
|
||||
ENV_ENABLED = "WIKI_TRACE"
|
||||
ENV_MAX_SESSION_BYTES = "WIKI_TRACE_MAX_SESSION_BYTES"
|
||||
ENV_KEEP_SESSIONS = "WIKI_TRACE_KEEP_SESSIONS"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Policy:
|
||||
enabled: bool
|
||||
reason: str # human-readable - what decided `enabled`, for `doctor`
|
||||
max_session_bytes: int
|
||||
keep_sessions: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _RootDefaults:
|
||||
form_default: bool
|
||||
form_reason: str
|
||||
file_enabled: bool | None
|
||||
file_reason: str | None
|
||||
file_max_bytes: int | None
|
||||
file_keep_sessions: int | None
|
||||
|
||||
|
||||
_cache: dict[Path, _RootDefaults] = {}
|
||||
|
||||
|
||||
def reset_cache() -> None:
|
||||
_cache.clear()
|
||||
|
||||
|
||||
def _positive_int(value: object) -> int | None:
|
||||
try:
|
||||
parsed = int(value) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if parsed > 0 else None
|
||||
|
||||
|
||||
def _read_config(root: Path) -> dict:
|
||||
try:
|
||||
raw = (root / config.TELEMETRY_FILENAME).read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _root_defaults(root: Path) -> _RootDefaults:
|
||||
cached = _cache.get(root)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
distribution = (root / config.RELEASE_STAMP_FILENAME).is_file()
|
||||
form_default = not distribution
|
||||
form_reason = (
|
||||
f"no {config.RELEASE_STAMP_FILENAME} - dev checkout, default on"
|
||||
if form_default
|
||||
else f"{config.RELEASE_STAMP_FILENAME} present - distributed instance, default off"
|
||||
)
|
||||
|
||||
data = _read_config(root)
|
||||
file_enabled = bool(data["enabled"]) if "enabled" in data else None
|
||||
file_reason = (
|
||||
f"{config.TELEMETRY_FILENAME} sets enabled={file_enabled}"
|
||||
if file_enabled is not None
|
||||
else None
|
||||
)
|
||||
|
||||
result = _RootDefaults(
|
||||
form_default=form_default,
|
||||
form_reason=form_reason,
|
||||
file_enabled=file_enabled,
|
||||
file_reason=file_reason,
|
||||
file_max_bytes=_positive_int(data.get("max_session_bytes")),
|
||||
file_keep_sessions=_positive_int(data.get("keep_sessions")),
|
||||
)
|
||||
_cache[root] = result
|
||||
return result
|
||||
|
||||
|
||||
def resolve(root: "Path | str | None" = None) -> Policy:
|
||||
"""The effective policy for `root` (default: `config.ROOT`)."""
|
||||
resolved_root = Path(root).resolve() if root is not None else Path(config.ROOT).resolve()
|
||||
defaults = _root_defaults(resolved_root)
|
||||
|
||||
env_enabled = os.environ.get(ENV_ENABLED)
|
||||
if env_enabled is not None:
|
||||
enabled = env_enabled != "0"
|
||||
reason = f"{ENV_ENABLED}={env_enabled!r} overrides"
|
||||
elif defaults.file_enabled is not None:
|
||||
enabled = defaults.file_enabled
|
||||
reason = defaults.file_reason # type: ignore[assignment]
|
||||
else:
|
||||
enabled = defaults.form_default
|
||||
reason = defaults.form_reason
|
||||
|
||||
max_bytes = (
|
||||
_positive_int(os.environ.get(ENV_MAX_SESSION_BYTES))
|
||||
or defaults.file_max_bytes
|
||||
or DEFAULT_MAX_SESSION_BYTES
|
||||
)
|
||||
keep_sessions = (
|
||||
_positive_int(os.environ.get(ENV_KEEP_SESSIONS))
|
||||
or defaults.file_keep_sessions
|
||||
or DEFAULT_KEEP_SESSIONS
|
||||
)
|
||||
|
||||
return Policy(
|
||||
enabled=enabled,
|
||||
reason=reason,
|
||||
max_session_bytes=max_bytes,
|
||||
keep_sessions=keep_sessions,
|
||||
)
|
||||
@@ -57,6 +57,7 @@ OPTIONAL_EVENTS = frozenset(
|
||||
"publish.commit",
|
||||
"budget.state",
|
||||
"gate.cleared",
|
||||
"telemetry.limit",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
"""Appends trace events to `reports/telemetry/<session>/trace.jsonl`.
|
||||
|
||||
Two rules govern this module.
|
||||
Three rules govern this module.
|
||||
|
||||
**Telemetry never breaks the tool.** `emit()` swallows everything: a full disk,
|
||||
a permission error or a bug in a scrubber pattern must not turn a working
|
||||
`wikitool` command - or a hook wrapped around someone's tool call - into a
|
||||
failure. Tests call `write_event()` instead, which raises.
|
||||
failure. Tests call `write_event()` instead, which raises - and, unlike
|
||||
`emit()`, bypasses policy entirely: no `enabled()` check, no byte cap, no
|
||||
retention. A test wanting those exercises `emit()`.
|
||||
|
||||
**Append, do not rewrite.** The budget state is a whole-file document and is
|
||||
written with the temp-file + `os.replace` dance. A trace is append-only, so the
|
||||
equivalent guarantee is `O_APPEND` plus an exclusive lock: several processes
|
||||
write to one trace at once (the CLI in one, a hook handler per tool call in
|
||||
another), and a line must never land inside another line.
|
||||
|
||||
**Two quantity caps, both enforced in `emit()`, both fail-silent.** A byte cap
|
||||
per session trace (`policy.max_session_bytes()`), checked with one `stat`
|
||||
before every append; and a retention pass over `trace_root()`'s
|
||||
session directories, run only when a brand-new one is about to be created -
|
||||
never per event. See `chemenu.telemetry.policy` for where the numbers come
|
||||
from.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -22,18 +31,26 @@ from pathlib import Path
|
||||
from chemenu import config
|
||||
from chemenu.session import session_id as current_session_id
|
||||
from chemenu.session import session_slug
|
||||
from chemenu.telemetry import schema, scrub
|
||||
from chemenu.telemetry import policy, schema, scrub
|
||||
|
||||
TRACE_ROOT = config.REPORTS_DIR / "telemetry"
|
||||
TRACE_FILE = "trace.jsonl"
|
||||
|
||||
# The single-writer sentinel that elects which process records the
|
||||
# `telemetry.limit` marker for a session that has hit its byte cap - see
|
||||
# `_mark_limit_once`. Also what `_enforce_retention` deletes alongside
|
||||
# `trace.jsonl`: these two names are everything this policy owns per session
|
||||
# directory.
|
||||
LIMIT_MARKER = ".limit"
|
||||
LIMIT_EVENT = "telemetry.limit"
|
||||
|
||||
# Per-process counter. Not comparable across processes - see schema.py on how
|
||||
# to order a trace.
|
||||
_seq = 0
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
return os.environ.get("WIKI_TRACE", "1") != "0"
|
||||
return policy.resolve(config.ROOT).enabled
|
||||
|
||||
|
||||
def trace_root() -> Path:
|
||||
@@ -167,6 +184,64 @@ def _locked_write(handle, line: str) -> None:
|
||||
fcntl.flock(handle, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def _enforce_retention(root: Path, keep: int, exclude: str) -> None:
|
||||
"""Delete the oldest session directories' `trace.jsonl`/`.limit` so that,
|
||||
once the session about to be created lands, at most `keep` remain.
|
||||
|
||||
Runs once, right before a brand-new session directory would be created -
|
||||
never per event, so an active session is never re-scanned for its own
|
||||
writes. It reserves that new session's own slot up front (keeping
|
||||
`keep - 1` of what already exists) rather than trimming to `keep` and then
|
||||
letting the new one land as `keep + 1`: the latter never actually
|
||||
converges back to `keep` under a steady trickle of new sessions, one at a
|
||||
time, each pass only ever removing what the *previous* pass left over the
|
||||
limit. Sorted by `mtime`, most recent first, the same order
|
||||
`reader.sessions()` reads a trace root back in, so "oldest" here and
|
||||
"most recent" there agree.
|
||||
|
||||
`exclude` is the session about to be created - it does not exist on disk
|
||||
yet in the common case, but is named explicitly anyway so a session whose
|
||||
directory a concurrent process just created is never the one this pass
|
||||
removes, regardless of its mtime.
|
||||
|
||||
Deletes only the two files this policy owns per session; the directory
|
||||
itself is `rmdir`-ed only once empty, never `rmtree`-d. A foreign file
|
||||
left by something else in a session's directory keeps that directory, and
|
||||
everything else in it, standing - `reports/` holds local, non-recomputable
|
||||
data no retention pass has business deleting wholesale.
|
||||
"""
|
||||
if not root.is_dir():
|
||||
return
|
||||
candidates = [p for p in root.iterdir() if p.is_dir() and p.name != exclude]
|
||||
candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
for stale in candidates[max(keep - 1, 0):]:
|
||||
for name in (TRACE_FILE, LIMIT_MARKER):
|
||||
try:
|
||||
(stale / name).unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
try:
|
||||
stale.rmdir()
|
||||
except OSError:
|
||||
pass # not empty - something else lives here, leave it standing
|
||||
|
||||
|
||||
def _mark_limit_once(session_dir: Path, source: str, session: str, limit: int) -> None:
|
||||
"""Record `telemetry.limit` exactly once per session, however many further
|
||||
calls hit the cap afterwards.
|
||||
|
||||
The single-writer trick is the same one `_seed_session_header` uses:
|
||||
several processes can hit the cap on the same trace at once, and an
|
||||
`exists()` check would let more than one of them win.
|
||||
"""
|
||||
try:
|
||||
handle = open(session_dir / LIMIT_MARKER, "x", encoding="utf-8")
|
||||
except FileExistsError:
|
||||
return
|
||||
handle.close()
|
||||
write_event(source, LIMIT_EVENT, {"max_session_bytes": limit}, session=session)
|
||||
|
||||
|
||||
def emit(
|
||||
source: str,
|
||||
event: str,
|
||||
@@ -176,9 +251,22 @@ def emit(
|
||||
run_key: str | None = None,
|
||||
) -> None:
|
||||
"""Fire-and-forget. Records nothing and reports nothing if anything goes wrong."""
|
||||
if not enabled():
|
||||
pol = policy.resolve(config.ROOT)
|
||||
if not pol.enabled:
|
||||
return
|
||||
try:
|
||||
write_event(source, event, attrs, session=session, run_key=run_key)
|
||||
session_id = session or current_session_id()
|
||||
root = trace_root()
|
||||
session_dir = root / session_slug(session_id)
|
||||
if not session_dir.exists():
|
||||
_enforce_retention(root, pol.keep_sessions, exclude=session_dir.name)
|
||||
try:
|
||||
size = (session_dir / TRACE_FILE).stat().st_size
|
||||
except FileNotFoundError:
|
||||
size = 0
|
||||
if size >= pol.max_session_bytes:
|
||||
_mark_limit_once(session_dir, source, session_id, pol.max_session_bytes)
|
||||
return
|
||||
write_event(source, event, attrs, session=session_id, run_key=run_key)
|
||||
except Exception: # noqa: BLE001 - telemetry must never break the caller
|
||||
pass
|
||||
|
||||
Reference in New Issue
Block a user