Files
chemenu/tools/chemenu/telemetry/policy.py
T
torbenandClaude Code db8f90c229
CI / verify (push) Failing after 7m41s
CI / pwsh (push) Successful in 2m32s
Release / release (push) Successful in 37s
Release / wiki-mirror (push) Skipped
settings: the four per-feature files .wikitool-remotes/-telemetry/-upload/-tasks.json move into the three tiers and disappear; new command config migrate; 10.0.0-beta.1 (#208)
Files changed:
- .gitignore
- AGENTS.md
- CHANGES.md
- INSTALL.md
- VERSION
- docs/explanation/knowledge-and-commitment.md
- docs/explanation/telemetry-and-evaluation.md
- docs/explanation/why-gates-are-code.md
- docs/how-to/run-the-weekly-review.md
- docs/how-to/set-up-the-mcp-server.md
- docs/how-to/trace-and-score-sessions.md
- docs/how-to/troubleshoot.md
- docs/reference/commands/README.md
- docs/reference/commands/configuration.md
- docs/reference/commands/finding-and-checking.md
- docs/reference/commands/git.md
- docs/reference/commands/instance-health.md
- docs/reference/commands/pages.md
- docs/reference/commands/raw-material-and-uploads.md
- docs/reference/configuration.md
- docs/reference/exit-codes.md
- instructions/dev/doc-pull-through.md
- instructions/dev/tracker-testing.md
- instructions/gates.md
- instructions/ingest-queue.md
- instructions/mcp-read-server.md
- instructions/migrate-corpus.md
- instructions/migrations/10.0.0-settings-tiers.md
- instructions/setup-instance.md
- raw/CONTRACT.md
- reports/CONTRACT.md
- tools/CONTRACT.md
- tools/README.md
- tools/bugreport.py
- tools/chemenu/cli_contract.py
- tools/chemenu/commands/_util.py
- tools/chemenu/commands/config_cmd.py
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/export_cmd.py
- tools/chemenu/commands/git_publish.py
- tools/chemenu/commands/new_page.py
- tools/chemenu/commands/review_cmd.py
- tools/chemenu/commands/task_cmd.py
- tools/chemenu/commands/upload_cmd.py
- tools/chemenu/config.py
- tools/chemenu/config_shapes.py
- tools/chemenu/legacy_settings.py
- tools/chemenu/mcp/__main__.py
- tools/chemenu/mcp/server.py
- tools/chemenu/reference_doc.py
- tools/chemenu/review.py
- tools/chemenu/settings.py
- tools/chemenu/tasks/__init__.py
- tools/chemenu/tasks/caldav.py
- tools/chemenu/tasks/config.py
- tools/chemenu/tasks/superproductivity.py
- tools/chemenu/telemetry/policy.py
- tools/chemenu/tests/conftest.py
- tools/chemenu/tests/record_sp_fixtures.py
- tools/chemenu/tests/test_bugreport.py
- tools/chemenu/tests/test_caldav.py
- tools/chemenu/tests/test_config_shapes.py
- tools/chemenu/tests/test_doctor.py
- tools/chemenu/tests/test_git_publish.py
- tools/chemenu/tests/test_legacy_settings.py
- tools/chemenu/tests/test_mcp_server.py
- tools/chemenu/tests/test_new_page.py
- tools/chemenu/tests/test_review.py
- tools/chemenu/tests/test_settings.py
- tools/chemenu/tests/test_sp_recorded.py
- tools/chemenu/tests/test_superproductivity.py
- tools/chemenu/tests/test_task_cmd.py
- tools/chemenu/tests/test_tasks_config.py
- tools/chemenu/tests/test_telemetry_policy.py
- tools/chemenu/tests/test_tracker_live.py
- tools/chemenu/tests/test_upload.py
- tools/chemenu/tests/tracker_live.py
- tools/chemenu/upload.py

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
2026-10-11 06:54:35 +02:00

174 lines
6.2 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
the `telemetry` section of `.wikitool-local.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
docs/explanation/telemetry-and-evaluation.md).
**Caching.** Only the filesystem-derived half - the release stamp and the
local settings 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 local settings 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, config_shapes
# The section's shape - and these two defaults with it - is the record in
# `chemenu.config_shapes`, which is stdlib-only for exactly this import.
DEFAULT_MAX_SESSION_BYTES = config_shapes.TELEMETRY.field("max_session_bytes").default
DEFAULT_KEEP_SESSIONS = config_shapes.TELEMETRY.field("keep_sessions").default
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 _given(checked: "config_shapes.Checked", name: str) -> int | None:
"""A cap the file sets and the record accepts, else None - so the
environment and the default decide, as for an absent file."""
return checked.values[name] if name in checked.given else None
def _read_config(root: Path) -> dict:
"""The `telemetry` section of `.wikitool-local.json`, read as leniently as
the hook needs: whatever cannot be read - the file, the JSON, the section -
is an absent section. Nothing else of the file is looked at, and the
telemetry file of an earlier version is not looked at at all: `doctor` and
`config migrate` deal with it, this hot path never fails on it."""
try:
raw = (root / config.LOCAL_SETTINGS_FILENAME).read_text(encoding="utf-8")
except OSError:
return {}
try:
data = json.loads(raw)
except json.JSONDecodeError:
return {}
section = data.get(config_shapes.TELEMETRY.key) if isinstance(data, dict) else None
return section if isinstance(section, 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"
)
# Silent on the hot path: a value the record does not accept is dropped,
# never an error. `doctor` reports it (`config_shapes.deviations`).
checked = config_shapes.check(config_shapes.TELEMETRY, _read_config(root))
file_enabled = checked.values.get("enabled")
file_reason = (
f"{config.LOCAL_SETTINGS_FILENAME} sets telemetry.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=_given(checked, "max_session_bytes"),
file_keep_sessions=_given(checked, "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,
)