stack: Telemetrie-Default nach Installationsform, Byte-Deckel und Session-Retention (schliesst #55)
CI / verify (push) Successful in 53s
Release / release (push) Successful in 36s

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:
2026-09-10 23:38:22 +02:00
parent 71446c0f29
commit 82a22eaa93
22 changed files with 801 additions and 44 deletions
+155
View File
@@ -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,
)
+1
View File
@@ -57,6 +57,7 @@ OPTIONAL_EVENTS = frozenset(
"publish.commit",
"budget.state",
"gate.cleared",
"telemetry.limit",
}
)
+94 -6
View File
@@ -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