82a22eaa93
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
156 lines
5.0 KiB
Python
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,
|
|
)
|