Files
chemenu/tools/chemenu/telemetry/policy.py
T
torben 82a22eaa93
CI / verify (push) Successful in 53s
Release / release (push) Successful in 36s
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
2026-09-10 23:38:22 +02:00

156 lines
5.0 KiB
Python

"""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,
)