cctally 1.63.0 → 1.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,362 @@
1
+ """Anonymous install-count telemetry kernel (see spec 2026-07-07).
2
+
3
+ Pure-ish: state resolution, token derivation, and payload construction are
4
+ side-effect-free; only the beat path mints an ``install_id`` / touches the
5
+ on-disk markers / makes a network call. Stdlib only.
6
+
7
+ Privacy posture (spec 2026-07-07):
8
+ - No IP, no username, no path, no session content ever leaves the machine.
9
+ - The only durable identifier is a random UUID ``install_id`` stored 0600
10
+ under APP_DIR; it is NEVER transmitted. What we send is a per-month
11
+ rotating token ``sha256(install_id:YYYY-MM:pepper)[:32]`` — one-way and
12
+ unlinkable across months, so the server can de-dupe within a month
13
+ without ever seeing the id.
14
+ - Fully opt-out: ``CCTALLY_DISABLE_TELEMETRY``, the ``DO_NOT_TRACK``
15
+ convention, dev checkouts, and a ``telemetry.enabled = false`` config
16
+ key each disable it (see ``resolve_telemetry_state``).
17
+ - Network errors are swallowed — telemetry never affects the user's UX.
18
+
19
+ Cross-module symbols (``_is_dev_checkout``, ``resolve_client_version``,
20
+ ``resolve_os_family``, ``_release_read_latest_release_version``) are reached
21
+ through the call-time ``_cctally()`` accessor so tests' ``monkeypatch`` on the
22
+ ``cctally`` module namespace propagates into these bodies. Path/constant
23
+ reads go through the ``_core()`` accessor so a redirected APP_DIR (tests,
24
+ dev-instance isolation) is honored.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import datetime as _dt
30
+ import hashlib
31
+ import json
32
+ import os
33
+ import platform
34
+ import sys
35
+ import uuid
36
+ import urllib.request
37
+
38
+
39
+ def _cctally():
40
+ """Resolve the current ``cctally`` module at call-time (spec §5.5)."""
41
+ return sys.modules["cctally"]
42
+
43
+
44
+ def _core():
45
+ import _cctally_core
46
+ return _cctally_core
47
+
48
+
49
+ def _truthy_env(name: str) -> bool:
50
+ """A ``1``/``true``/``yes``/any-non-empty env value is truthy; unset,
51
+ empty, ``0``, ``false``, ``no`` are falsey."""
52
+ v = os.environ.get(name)
53
+ return v is not None and v.strip().lower() not in ("", "0", "false", "no")
54
+
55
+
56
+ def resolve_telemetry_state(config: dict) -> tuple[bool, str]:
57
+ """Return ``(enabled, reason)`` — side-effect-free.
58
+
59
+ Precedence (first match wins): env opt-out, DO_NOT_TRACK, dev checkout,
60
+ config opt-out, else enabled. Never mints or touches any file.
61
+ """
62
+ c = _cctally()
63
+ if _truthy_env("CCTALLY_DISABLE_TELEMETRY"):
64
+ return (False, "env-disabled")
65
+ if _truthy_env("DO_NOT_TRACK"):
66
+ return (False, "do-not-track")
67
+ if c._is_dev_checkout():
68
+ return (False, "dev-checkout")
69
+ tele = (config or {}).get("telemetry") or {}
70
+ if tele.get("enabled") is False:
71
+ return (False, "config-disabled")
72
+ return (True, "enabled")
73
+
74
+
75
+ def current_period(now: _dt.datetime | None = None) -> str:
76
+ """Current rotation period as ``"YYYY-MM"`` in UTC."""
77
+ now = now or _dt.datetime.now(_dt.timezone.utc)
78
+ return now.astimezone(_dt.timezone.utc).strftime("%Y-%m")
79
+
80
+
81
+ def telemetry_token(install_id: str, period: str) -> str:
82
+ """One-way, month-rotating token: ``sha256(id:period:pepper)[:32]``.
83
+
84
+ Deterministic within a period, unlinkable across periods, and never
85
+ reveals ``install_id``."""
86
+ pepper = _core().TELEMETRY_PEPPER
87
+ raw = f"{install_id}:{period}:{pepper}".encode("utf-8")
88
+ return hashlib.sha256(raw).hexdigest()[:32]
89
+
90
+
91
+ def read_install_id() -> str | None:
92
+ """Read the persisted install_id, or ``None`` if absent/empty. Never mints."""
93
+ p = _core().TELEMETRY_INSTALL_ID_PATH
94
+ try:
95
+ return p.read_text(encoding="utf-8").strip() or None
96
+ except OSError:
97
+ return None
98
+
99
+
100
+ def ensure_install_id() -> str:
101
+ """Return the install_id, minting a random UUID at 0600 if missing."""
102
+ p = _core().TELEMETRY_INSTALL_ID_PATH
103
+ existing = read_install_id()
104
+ if existing:
105
+ return existing
106
+ p.parent.mkdir(parents=True, exist_ok=True)
107
+ iid = str(uuid.uuid4())
108
+ # Atomically create at 0600 (O_EXCL) rather than write-then-chmod, so the
109
+ # id file is never briefly world-readable in the umask window. install_id
110
+ # is a non-secret, resettable UUID that never leaves the machine, but 0600
111
+ # matches the cache-db sidecar-hardening posture. On a create race the
112
+ # loser gets FileExistsError and reads the winner's value.
113
+ try:
114
+ fd = os.open(p, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
115
+ except FileExistsError:
116
+ return read_install_id() or iid
117
+ except OSError:
118
+ # Any other open failure: fall back to a best-effort plain write.
119
+ p.write_text(iid + "\n", encoding="utf-8")
120
+ return iid
121
+ try:
122
+ os.write(fd, (iid + "\n").encode("utf-8"))
123
+ finally:
124
+ os.close(fd)
125
+ return iid
126
+
127
+
128
+ def reset_install_id() -> str:
129
+ """Discard any existing install_id and mint a fresh one."""
130
+ p = _core().TELEMETRY_INSTALL_ID_PATH
131
+ try:
132
+ p.unlink()
133
+ except OSError:
134
+ pass
135
+ return ensure_install_id()
136
+
137
+
138
+ def resolve_client_version() -> str:
139
+ """The stamped client semver, or ``"unknown"`` when unstamped."""
140
+ c = _cctally()
141
+ cur = c._release_read_latest_release_version()
142
+ return cur[0] if cur else "unknown"
143
+
144
+
145
+ def resolve_os_family() -> str:
146
+ """Coarse OS family: ``macos`` | ``linux`` | ``windows`` | ``other``."""
147
+ s = platform.system().lower()
148
+ return {"darwin": "macos", "linux": "linux", "windows": "windows"}.get(s, "other")
149
+
150
+
151
+ def build_beat_payload(install_id: str, *, now: _dt.datetime | None = None) -> dict:
152
+ """The minimal beat body: ``{t, v, os}`` — token, client version, OS family.
153
+
154
+ Version/OS are read through the ``cctally`` accessor so a test's
155
+ ``monkeypatch.setattr(cctally, "resolve_client_version", ...)`` drives the
156
+ output (the re-export lives on the ``cctally`` module, not this kernel's
157
+ namespace)."""
158
+ c = _cctally()
159
+ return {
160
+ "t": telemetry_token(install_id, current_period(now)),
161
+ "v": c.resolve_client_version(),
162
+ "os": c.resolve_os_family(),
163
+ }
164
+
165
+
166
+ def _marker_age_seconds(path, now: _dt.datetime | None = None) -> float | None:
167
+ """Seconds since ``path``'s mtime, or ``None`` when the marker is absent."""
168
+ try:
169
+ mtime = path.stat().st_mtime
170
+ except OSError:
171
+ return None
172
+ now = now or _dt.datetime.now(_dt.timezone.utc)
173
+ return now.timestamp() - mtime
174
+
175
+
176
+ def telemetry_beat_due(now=None) -> bool:
177
+ """True when no beat has been *attempted* within the throttle window.
178
+
179
+ The last-beat marker records the last beat ATTEMPT (not just the last
180
+ successful send): ``do_telemetry_beat`` stamps it on every non-disabled
181
+ run — arm, grace, sent, or failed — so this predicate bounds the parent
182
+ spawn gate to at most one worker per window regardless of outcome (a
183
+ network outage / undeployed endpoint can't churn re-spawns)."""
184
+ age = _marker_age_seconds(_core().TELEMETRY_LAST_BEAT_PATH, now)
185
+ return age is None or age >= _core().TELEMETRY_BEAT_THROTTLE_SECONDS
186
+
187
+
188
+ def first_beat_grace_elapsed(now=None) -> bool:
189
+ """True once the first-seen marker is at least the grace window old."""
190
+ age = _marker_age_seconds(_core().TELEMETRY_FIRST_SEEN_PATH, now)
191
+ return age is not None and age >= _core().TELEMETRY_FIRST_BEAT_GRACE_SECONDS
192
+
193
+
194
+ def _touch(path) -> None:
195
+ path.parent.mkdir(parents=True, exist_ok=True)
196
+ path.touch()
197
+
198
+
199
+ def mark_first_seen(now=None) -> None:
200
+ """Arm the first-seen marker once; subsequent calls are no-ops so the
201
+ grace window is measured from genuine first eligibility."""
202
+ p = _core().TELEMETRY_FIRST_SEEN_PATH
203
+ if _marker_age_seconds(p) is None:
204
+ _touch(p)
205
+
206
+
207
+ def touch_last_beat(now=None) -> None:
208
+ """Stamp the last-beat-attempt marker to now (resets the throttle window).
209
+
210
+ Called at the top of every non-disabled ``do_telemetry_beat`` run so the
211
+ marker tracks the last ATTEMPT, not just the last successful send."""
212
+ _touch(_core().TELEMETRY_LAST_BEAT_PATH)
213
+
214
+
215
+ def notice_already_shown() -> bool:
216
+ return _core().TELEMETRY_NOTICE_SHOWN_PATH.exists()
217
+
218
+
219
+ def mark_notice_shown() -> None:
220
+ _touch(_core().TELEMETRY_NOTICE_SHOWN_PATH)
221
+
222
+
223
+ def _endpoint() -> str:
224
+ return os.environ.get("CCTALLY_TELEMETRY_ENDPOINT") or _core().TELEMETRY_ENDPOINT_DEFAULT
225
+
226
+
227
+ def do_telemetry_beat(config: dict, *, now=None, endpoint=None) -> str:
228
+ """Orchestrate arm -> grace -> beat. Returns a status string:
229
+
230
+ ``disabled:<reason>`` (opt-out), ``armed`` (first eligibility recorded),
231
+ ``grace`` (still inside the first-beat grace window), ``sent`` (POST
232
+ succeeded), or ``failed`` (network error, swallowed).
233
+
234
+ Throttling to at most one beat per window is enforced at the PARENT
235
+ spawn gate (``_post_command_update_hooks``), which only spawns this
236
+ worker when ``telemetry_beat_due()`` is True. To make that bound hold
237
+ regardless of outcome, the last-beat-attempt marker is stamped FIRST —
238
+ immediately after the opt-out check, before the arm/grace/send branches
239
+ (mirroring ``_do_update_check``'s touch-the-marker-first crash-safety
240
+ pattern). Without this, the marker was stamped only on a successful
241
+ send, so the parent gate stayed True through the entire 24h grace
242
+ window and through any sustained outage (e.g. the endpoint not yet
243
+ deployed) — re-spawning a fresh detached worker on EVERY command,
244
+ including hot paths (statusline/record-usage/hook-tick).
245
+
246
+ Only the network POST is wrapped, so this swallows connection / DNS /
247
+ HTTP / timeout errors from the beat send (returning ``failed``); it is
248
+ not a blanket "never raises" — a defect in the pure marker/id helpers
249
+ would still surface (and the ``_telemetry-beat`` worker wraps the whole
250
+ call in its own try/except as belt-and-suspenders)."""
251
+ enabled, reason = resolve_telemetry_state(config)
252
+ if not enabled:
253
+ return f"disabled:{reason}"
254
+ # Stamp the last-beat-attempt marker FIRST (crash-safe, outcome-
255
+ # independent) so the parent spawn gate is bounded to <=1 worker per
256
+ # throttle window whether this run arms, waits out grace, sends, or
257
+ # fails. The internal ``telemetry_beat_due`` re-check that used to sit
258
+ # below the grace branch is intentionally gone: the marker was just
259
+ # touched, so it would always read fresh here.
260
+ touch_last_beat(now)
261
+ # Arm on first eligibility; do NOT beat until the grace window elapses.
262
+ if _marker_age_seconds(_core().TELEMETRY_FIRST_SEEN_PATH) is None:
263
+ mark_first_seen(now)
264
+ ensure_install_id()
265
+ return "armed"
266
+ if not first_beat_grace_elapsed(now):
267
+ return "grace"
268
+ iid = ensure_install_id()
269
+ payload = build_beat_payload(iid, now=now)
270
+ try:
271
+ req = urllib.request.Request(
272
+ endpoint or _endpoint(),
273
+ data=json.dumps(payload).encode("utf-8"),
274
+ headers={
275
+ "Content-Type": "application/json",
276
+ "User-Agent": f"cctally-telemetry/{payload['v']}",
277
+ },
278
+ method="POST",
279
+ )
280
+ with urllib.request.urlopen(req, timeout=3):
281
+ pass
282
+ # NOTE: the marker was already stamped at the top of this function
283
+ # (touch-first), so no second touch is needed on the success path.
284
+ return "sent"
285
+ except Exception:
286
+ return "failed" # swallow — telemetry never affects UX
287
+
288
+
289
+ def _config_set_ns(key: str, value: str) -> argparse.Namespace:
290
+ """Synthesize the ``argparse.Namespace`` ``cmd_config``'s ``set`` path
291
+ consumes (``action``/``key``/``value``/``emit_json``). Routing ``on``/``off``
292
+ through the real config setter keeps a single validation + locking
293
+ chokepoint rather than re-implementing the read-modify-write here."""
294
+ return argparse.Namespace(action="set", key=key, value=value, emit_json=False)
295
+
296
+
297
+ def cmd_telemetry(args) -> int:
298
+ """`cctally telemetry [on|off|reset]` + bare status (`--json`).
299
+
300
+ ``on``/``off`` flip the ``telemetry.enabled`` config key through the real
301
+ ``cmd_config`` setter (its bool validation + atomic write). ``reset`` mints
302
+ a fresh ``install_id``. The bare/status path is strictly READ-ONLY — it
303
+ resolves the opt-out state from a RAW guarded ``config.json`` read (NOT
304
+ ``load_config()``, which would auto-create config.json on a fresh install)
305
+ and previews the month token WITHOUT ever minting an id (it calls
306
+ ``read_install_id``, never ``ensure_install_id``). It writes nothing.
307
+ """
308
+ c = _cctally()
309
+ action = getattr(args, "action", None)
310
+ if action == "off":
311
+ return c.cmd_config(_config_set_ns("telemetry.enabled", "false"))
312
+ if action == "on":
313
+ return c.cmd_config(_config_set_ns("telemetry.enabled", "true"))
314
+ if action == "reset":
315
+ reset_install_id()
316
+ print("telemetry: install id reset")
317
+ return 0
318
+
319
+ # bare / status — strictly read-only. Resolve the opt-out state from a
320
+ # RAW guarded config read (mirroring _cctally_doctor's telemetry gather),
321
+ # NOT load_config(): load_config() calls ensure_dirs() and auto-creates
322
+ # config.json on a fresh install, which would contradict the documented
323
+ # "strictly read-only" / "never mints an install_id, writes config, or
324
+ # sends a beat" contract (docs/telemetry.md, docs/commands/telemetry.md).
325
+ # A missing/corrupt config degrades to `{}` (env/dev precedence still
326
+ # resolves correctly).
327
+ raw_config: dict = {}
328
+ try:
329
+ cfg_path = _core().CONFIG_PATH
330
+ if cfg_path.exists():
331
+ loaded = json.loads(cfg_path.read_text(encoding="utf-8"))
332
+ if isinstance(loaded, dict):
333
+ raw_config = loaded
334
+ except Exception:
335
+ raw_config = {}
336
+ enabled, reason = resolve_telemetry_state(raw_config)
337
+ iid = read_install_id()
338
+ period = current_period()
339
+ token = telemetry_token(iid, period) if iid else None
340
+ info = {
341
+ "enabled": enabled,
342
+ "reason": reason,
343
+ "version": c.resolve_client_version(),
344
+ "os": c.resolve_os_family(),
345
+ "period": period,
346
+ "token_preview": token,
347
+ "fields": ["token", "version", "os"],
348
+ }
349
+ if getattr(args, "json", False):
350
+ print(json.dumps(info))
351
+ return 0
352
+ print(f"telemetry: {'enabled' if enabled else 'disabled'} ({reason})")
353
+ print(
354
+ f" sends: rotating monthly token + version ({info['version']}) "
355
+ f"+ os ({info['os']})"
356
+ )
357
+ print(f" token this month: {token or '(not yet armed)'}")
358
+ print(
359
+ " opt out: cctally telemetry off | "
360
+ "CCTALLY_DISABLE_TELEMETRY=1 | DO_NOT_TRACK=1"
361
+ )
362
+ return 0