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.
@@ -169,6 +169,14 @@ class DoctorState:
169
169
  # from the env; surfaced in the install.mode check. Defaulted (placed last)
170
170
  # so existing constructors stay valid and the prod path is unchanged.
171
171
  channel: str = "prod"
172
+ # Anonymous install-count telemetry (spec 2026-07-07): the resolved opt-out
173
+ # state + precedence reason from `resolve_telemetry_state`, computed by
174
+ # `doctor_gather_state` WITHOUT minting an install_id (read-only H1). Drives
175
+ # the always-OK `telemetry.state` check — a diagnostic surface, never a
176
+ # health failure. Defaulted (placed last) so existing constructors stay
177
+ # valid and default to the enabled (opt-out) posture.
178
+ telemetry_enabled: bool = True
179
+ telemetry_reason: str = "enabled"
172
180
 
173
181
 
174
182
  @dataclasses.dataclass(frozen=True)
@@ -1178,6 +1186,27 @@ def _check_safety_update_available(s: DoctorState) -> CheckResult:
1178
1186
  )
1179
1187
 
1180
1188
 
1189
+ def _check_telemetry(s: DoctorState) -> CheckResult:
1190
+ """Anonymous install-count telemetry opt-out state (spec 2026-07-07).
1191
+
1192
+ A read-only DIAGNOSTIC surface — "is telemetry on, and if off, why" — that
1193
+ is ALWAYS ``ok``, never WARN/FAIL. Being disabled (env kill switch,
1194
+ DO_NOT_TRACK, a dev checkout, or ``telemetry.enabled = false``) is a valid
1195
+ user choice, not a health problem, so this check must never change doctor's
1196
+ severity counts or exit code. The gather layer resolves `enabled`/`reason`
1197
+ via `resolve_telemetry_state` WITHOUT minting an install_id.
1198
+ """
1199
+ enabled = bool(s.telemetry_enabled)
1200
+ reason = s.telemetry_reason
1201
+ return CheckResult(
1202
+ id="telemetry.state", title="State",
1203
+ severity="ok",
1204
+ summary=f"{'enabled' if enabled else 'disabled'} ({reason})",
1205
+ remediation=None,
1206
+ details={"enabled": enabled, "reason": reason},
1207
+ )
1208
+
1209
+
1181
1210
  # Each entry is (category_id, category_title, ((check_id, evaluator_fn_name), ...)).
1182
1211
  # The dotted check_id is the stable JSON-contract ID (spec §5.2) AND the
1183
1212
  # fingerprint identity-slice key (spec §5.5). When an evaluator raises,
@@ -1226,6 +1255,9 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
1226
1255
  ("safety.update_suppress", "_check_safety_update_suppress"),
1227
1256
  ("safety.update_available", "_check_safety_update_available"),
1228
1257
  )),
1258
+ ("telemetry", "Telemetry", (
1259
+ ("telemetry.state", "_check_telemetry"),
1260
+ )),
1229
1261
  )
1230
1262
 
1231
1263
 
@@ -0,0 +1,180 @@
1
+ """Opt-in backend phase-instrumentation collector (issue #276, Session A).
2
+
3
+ Stdlib-only. A thread-local nested-phase timing collector, gated on the
4
+ CCTALLY_PERF_TRACE env var. Near-noop when off: phase() returns a shared
5
+ _NULL_PHASE singleton — no allocation, no perf_counter, no stack push.
6
+
7
+ Two renderers sit on the same phase tree:
8
+ * flush_stderr(root) — CLI indented outline (stdout stays byte-identical).
9
+ * stash_last(root) — the dashboard freezes the completed tree (to_dict)
10
+ into a process-global slot for the loopback
11
+ /api/debug/backend endpoint to read.
12
+
13
+ This surface is a diagnostic, NOT a consumer contract: phase names, nesting,
14
+ and fields may change without a version bump.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import sys
20
+ import threading
21
+ import time
22
+
23
+ _FALSEY = {"", "0", "false", "no", "off"}
24
+ _ENABLED = os.environ.get("CCTALLY_PERF_TRACE", "").strip().lower() not in _FALSEY
25
+
26
+
27
+ def enabled() -> bool:
28
+ return _ENABLED
29
+
30
+
31
+ def set_enabled(value: bool) -> None:
32
+ """Flip tracing at runtime (tests; not used by the dashboard, which reads
33
+ the env at import time)."""
34
+ global _ENABLED
35
+ _ENABLED = bool(value)
36
+
37
+
38
+ _tls = threading.local()
39
+
40
+
41
+ def _stack():
42
+ s = getattr(_tls, "stack", None)
43
+ if s is None:
44
+ s = []
45
+ _tls.stack = s
46
+ return s
47
+
48
+
49
+ class Phase:
50
+ __slots__ = ("name", "elapsed_ms", "count", "meta", "children", "_start", "_stack")
51
+
52
+ def __init__(self, name, stack):
53
+ self.name = name
54
+ self.elapsed_ms = 0.0
55
+ self.count = None
56
+ self.meta = None
57
+ self.children = []
58
+ self._start = 0.0
59
+ self._stack = stack
60
+
61
+ def set_count(self, n):
62
+ self.count = int(n)
63
+
64
+ def set_meta(self, **kw):
65
+ if self.meta is None:
66
+ self.meta = {}
67
+ self.meta.update(kw)
68
+
69
+ def __enter__(self):
70
+ self._start = time.perf_counter()
71
+ self._stack.append(self)
72
+ return self
73
+
74
+ def __exit__(self, *exc):
75
+ self.elapsed_ms = (time.perf_counter() - self._start) * 1000.0
76
+ # Identity-aware unwind. If a nested phase leaked (its __exit__ was
77
+ # skipped — e.g. an exception escaped a manually CM-bracketed region),
78
+ # drop the leaked frames sitting above us so we never append a phase to
79
+ # its OWN children (which would make to_dict() self-recurse) and never
80
+ # strand a stack that hides the real root. Pop down to and including
81
+ # self; if self is not on the stack (double __exit__, or an ancestor
82
+ # already unwound us), do nothing.
83
+ stack = self._stack
84
+ if self not in stack:
85
+ return False
86
+ while stack and stack[-1] is not self:
87
+ stack.pop() # discard a leaked descendant frame
88
+ stack.pop() # pop self
89
+ if stack:
90
+ stack[-1].children.append(self)
91
+ else:
92
+ _tls.root = self # outermost phase closed -> the build root
93
+ return False
94
+
95
+ def to_dict(self):
96
+ d = {"name": self.name, "elapsed_ms": round(self.elapsed_ms, 3)}
97
+ if self.count is not None:
98
+ d["count"] = self.count
99
+ if self.meta:
100
+ d["meta"] = dict(self.meta)
101
+ if self.children:
102
+ d["children"] = [c.to_dict() for c in self.children]
103
+ return d
104
+
105
+
106
+ class _NullPhase:
107
+ """Shared no-op returned when tracing is off. No allocation per phase()."""
108
+
109
+ def set_count(self, n):
110
+ pass
111
+
112
+ def set_meta(self, **kw):
113
+ pass
114
+
115
+ def __enter__(self):
116
+ return self
117
+
118
+ def __exit__(self, *exc):
119
+ return False
120
+
121
+
122
+ _NULL_PHASE = _NullPhase()
123
+
124
+
125
+ def phase(name):
126
+ if not _ENABLED:
127
+ return _NULL_PHASE
128
+ return Phase(name, _stack())
129
+
130
+
131
+ def current_root():
132
+ return getattr(_tls, "root", None)
133
+
134
+
135
+ def reset_thread():
136
+ _tls.stack = []
137
+ _tls.root = None
138
+
139
+
140
+ def flush_stderr(root):
141
+ if root is None:
142
+ return
143
+ lines = []
144
+
145
+ def walk(p, depth):
146
+ indent = " " * depth
147
+ extra = ""
148
+ if p.count is not None:
149
+ extra += f" (count={p.count})"
150
+ if p.meta:
151
+ extra += " " + " ".join(f"{k}={v}" for k, v in p.meta.items())
152
+ lines.append(f"{indent}{p.name} {p.elapsed_ms:.1f}ms{extra}")
153
+ for c in p.children:
154
+ walk(c, depth + 1)
155
+
156
+ walk(root, 0)
157
+ sys.stderr.write("backend-perf:\n" + "\n".join(lines) + "\n")
158
+
159
+
160
+ # ── process-global last-completed-tree slot (dashboard -> endpoint) ──────────
161
+ # The writer freezes the tree with to_dict() then binds the module global in
162
+ # ONE statement; once bound the dict is never mutated (the next build binds a
163
+ # fresh dict). Assignment is atomic under the GIL, so the HTTP reader thread
164
+ # always sees a whole, immutable "last completed build".
165
+ _LAST_BACKEND_PERF = None
166
+
167
+
168
+ def stash_last(root, *, generation=None, generated_at=None):
169
+ global _LAST_BACKEND_PERF
170
+ if root is None:
171
+ return
172
+ _LAST_BACKEND_PERF = {
173
+ "generated_at": generated_at,
174
+ "generation": generation,
175
+ "phases": root.to_dict(),
176
+ }
177
+
178
+
179
+ def last_backend_perf():
180
+ return _LAST_BACKEND_PERF
@@ -55,6 +55,29 @@ def is_loopback(host: str) -> bool:
55
55
  return False
56
56
 
57
57
 
58
+ def debug_backend_allowed(peer_ip, host) -> bool:
59
+ """Gate for the loopback-only ``/api/debug/backend`` diagnostic endpoint
60
+ (issue #276, Session A).
61
+
62
+ STRICTER than the transcript gate — it never opens to the LAN. Two checks:
63
+
64
+ * PRIMARY: the TCP peer (``client_address[0]``) must be a loopback
65
+ literal. This is the unspoofable signal — the dashboard can bind
66
+ ``0.0.0.0`` (``dashboard.bind = lan``), and a ``Host``-header-only
67
+ check would let a LAN client connect to the LAN socket while sending
68
+ ``Host: 127.0.0.1``. The peer address cannot be spoofed that way.
69
+ * DEFENSE-IN-DEPTH: the ``Host`` authority must ALSO be an IP-literal
70
+ loopback (anti-DNS-rebinding) — a hostname ``Host`` (a rebinding
71
+ vector) is rejected even from a loopback peer.
72
+
73
+ ``dashboard.expose_transcripts`` is NEVER consulted — this surface is
74
+ loopback-only, ALWAYS. Fail closed on a missing/empty peer or Host.
75
+ """
76
+ if not is_loopback(peer_ip):
77
+ return False
78
+ return is_loopback(authority_host(host))
79
+
80
+
58
81
  def transcripts_allowed(bind_host, expose: bool) -> bool:
59
82
  """Are transcripts served AT ALL on this bind? Loopback bind always; a
60
83
  non-loopback (LAN) bind only under the explicit ``expose`` opt-in."""
package/bin/cctally CHANGED
@@ -214,6 +214,10 @@ make_week_ref = _cctally_core.make_week_ref
214
214
  _get_latest_row_for_week = _cctally_core._get_latest_row_for_week
215
215
  _reset_aware_floor = _cctally_core._reset_aware_floor
216
216
  get_latest_usage_for_week = _cctally_core.get_latest_usage_for_week
217
+ # Re-exported so the telemetry kernel's ``c._is_dev_checkout()`` accessor
218
+ # call resolves on the ``cctally`` module (and tests can monkeypatch it
219
+ # there). The canonical definition lives in _cctally_core.
220
+ _is_dev_checkout = _cctally_core._is_dev_checkout
217
221
 
218
222
  # === Path constants — re-exported from _cctally_core ================
219
223
  #
@@ -982,6 +986,8 @@ _is_update_check_due = _cctally_update._is_update_check_due
982
986
  _do_update_check = _cctally_update._do_update_check
983
987
  _spawn_background_update_check = _cctally_update._spawn_background_update_check
984
988
  cmd_update_check_internal = _cctally_update.cmd_update_check_internal
989
+ _spawn_background_telemetry_beat = _cctally_update._spawn_background_telemetry_beat
990
+ cmd_telemetry_beat_internal = _cctally_update.cmd_telemetry_beat_internal
985
991
  SKIP_USE_STATE_LATEST = _cctally_update.SKIP_USE_STATE_LATEST
986
992
  _format_update_command = _cctally_update._format_update_command
987
993
  _prerelease_note = _cctally_update._prerelease_note
@@ -1009,6 +1015,36 @@ _should_show_update_banner = _cctally_update._should_show_update_banner
1009
1015
  _format_update_banner = _cctally_update._format_update_banner
1010
1016
 
1011
1017
 
1018
+ # Anonymous install-count telemetry kernel (spec 2026-07-07). Eagerly
1019
+ # re-exported so tests reach the pure functions via the ``cctally``
1020
+ # namespace (mirroring the ``_check_npm_latest_version`` re-export above)
1021
+ # and so the kernel's moved bodies that call ``c.resolve_client_version``
1022
+ # / ``c.resolve_os_family`` / ``c._release_read_latest_release_version``
1023
+ # through the ``_cctally()`` accessor pick up any test monkeypatch on the
1024
+ # cctally module. Path constants (TELEMETRY_INSTALL_ID_PATH etc.) live on
1025
+ # _cctally_core and are read via ``_core()`` at call time; tests patch
1026
+ # them through ``monkeypatch.setattr(_cctally_core, "X", v)`` (conftest's
1027
+ # ``redirect_paths()`` covers the set).
1028
+ _cctally_telemetry = _load_sibling("_cctally_telemetry")
1029
+ resolve_telemetry_state = _cctally_telemetry.resolve_telemetry_state
1030
+ telemetry_token = _cctally_telemetry.telemetry_token
1031
+ current_period = _cctally_telemetry.current_period
1032
+ resolve_client_version = _cctally_telemetry.resolve_client_version
1033
+ resolve_os_family = _cctally_telemetry.resolve_os_family
1034
+ build_beat_payload = _cctally_telemetry.build_beat_payload
1035
+ read_install_id = _cctally_telemetry.read_install_id
1036
+ ensure_install_id = _cctally_telemetry.ensure_install_id
1037
+ reset_install_id = _cctally_telemetry.reset_install_id
1038
+ do_telemetry_beat = _cctally_telemetry.do_telemetry_beat
1039
+ telemetry_beat_due = _cctally_telemetry.telemetry_beat_due
1040
+ first_beat_grace_elapsed = _cctally_telemetry.first_beat_grace_elapsed
1041
+ mark_first_seen = _cctally_telemetry.mark_first_seen
1042
+ touch_last_beat = _cctally_telemetry.touch_last_beat
1043
+ notice_already_shown = _cctally_telemetry.notice_already_shown
1044
+ mark_notice_shown = _cctally_telemetry.mark_notice_shown
1045
+ cmd_telemetry = _cctally_telemetry.cmd_telemetry
1046
+
1047
+
1012
1048
  # Phase F #22: ``bin/_cctally_dashboard.py`` is loaded eagerly per spec §4.8
1013
1049
  # carve-out — ``tests/test_dashboard_*.py`` and ``tests/test_share_*.py``
1014
1050
  # reach into ~25 dashboard symbols via ``ns["X"]`` direct-dict reads and
@@ -2853,6 +2889,20 @@ def _post_command_update_hooks(command: str | None, args) -> None:
2853
2889
  # (it runs before the wrapper sets CCTALLY_DATA_DIR, so APP_DIR is
2854
2890
  # still cctally-dev at this point). Same rationale class as doctor.
2855
2891
  return
2892
+ if command in ("_update-check", "_telemetry-beat"):
2893
+ # Both hidden workers are detached and have already done their one job
2894
+ # in their own command handler; neither must re-enter this hook.
2895
+ # Without the guard the ``_update-check`` worker would fall through to
2896
+ # the telemetry gate below and (throttle-bounded) spawn a
2897
+ # ``_telemetry-beat`` — a worker spawning another worker. And
2898
+ # ``do_telemetry_beat`` stamps its throttle marker FIRST on every
2899
+ # non-disabled run (arm/grace/sent/failed), so ``telemetry_beat_due()``
2900
+ # already reads False right after a worker run and the parent gate is
2901
+ # bounded to <=1 spawn/window regardless — this early-return is the
2902
+ # belt-and-suspenders that keeps the two dedicated workers symmetric
2903
+ # and fully hook-free (an internal worker never needs banners or
2904
+ # background spawns anyway).
2905
+ return
2856
2906
  # Self-heal: reconcile current_version with the running binary's
2857
2907
  # CHANGELOG. Cheap (one CHANGELOG read, write only on the first
2858
2908
  # command after a manual upgrade). Runs before load_config so a
@@ -2865,6 +2915,54 @@ def _post_command_update_hooks(command: str | None, args) -> None:
2865
2915
  sys.stderr.write(_format_update_banner(state) + "\n")
2866
2916
  if _is_update_check_due(config):
2867
2917
  _spawn_background_update_check()
2918
+ # Anonymous install-count telemetry (spec 2026-07-07). Broad-but-
2919
+ # throttled, placed after the same early-returns that guard the update
2920
+ # check so it inherits every side-effect skip (read-only/no-mutation
2921
+ # commands, the CCTALLY_DISABLE_UPDATE_CHECK test seam, and the
2922
+ # ``_telemetry-beat`` self-re-entry guard above). The env kill switches
2923
+ # (CCTALLY_DISABLE_TELEMETRY / DO_NOT_TRACK), the config opt-out, and
2924
+ # the dev-checkout guard all live inside ``resolve_telemetry_state`` —
2925
+ # do not re-check them here.
2926
+ enabled, _reason = resolve_telemetry_state(config)
2927
+ if enabled and telemetry_beat_due():
2928
+ _spawn_background_telemetry_beat()
2929
+ _maybe_print_telemetry_notice(command, config)
2930
+
2931
+
2932
+ def _maybe_print_telemetry_notice(command: str | None, config) -> None:
2933
+ """One-time interactive notice that anonymous install-count telemetry
2934
+ is active (spec review finding #2). Banner-gated (NOT beat-gated): it
2935
+ prints once to an interactive stderr and, once marked, never again.
2936
+ Fully best-effort — the whole body is wrapped so any failure is
2937
+ swallowed and can't perturb the parent command.
2938
+
2939
+ Gates (all must hold to print):
2940
+ - telemetry enabled (opt-outs / config / dev-checkout suppress it);
2941
+ - not already shown (the ``TELEMETRY_NOTICE_SHOWN`` marker);
2942
+ - command not in ``_BANNER_SUPPRESSED_COMMANDS`` — the same quiet
2943
+ set the update banner honours (record-usage / hook-tick / blocks /
2944
+ etc.), so machine-consumed one-liners stay clean;
2945
+ - stderr is a real TTY — never pollute piped or redirected output.
2946
+ """
2947
+ try:
2948
+ enabled, _reason = resolve_telemetry_state(config)
2949
+ if not enabled or notice_already_shown():
2950
+ return
2951
+ if command in _BANNER_SUPPRESSED_COMMANDS:
2952
+ return
2953
+ if not (sys.stderr and sys.stderr.isatty()):
2954
+ return
2955
+ docs = "https://github.com/omrikais/cctally/blob/main/docs/telemetry.md"
2956
+ sys.stderr.write(
2957
+ "cctally counts anonymous active installs to gauge real usage.\n"
2958
+ "What's sent: a rotating, un-linkable token + version + OS family.\n"
2959
+ "No identity, no paths, no usage data, no IP stored. Auto-expires monthly.\n"
2960
+ "Opt out anytime: cctally telemetry off (or CCTALLY_DISABLE_TELEMETRY=1)\n"
2961
+ f"How it works: {docs}\n"
2962
+ )
2963
+ mark_notice_shown()
2964
+ except Exception:
2965
+ pass
2868
2966
 
2869
2967
 
2870
2968
  if __name__ == "__main__":
@@ -36,5 +36,9 @@ process.stdout.write(
36
36
  ' cctally setup\n' +
37
37
  '\nThis installs additive Claude Code hooks (~/.claude/settings.json)\n' +
38
38
  'and bootstraps the local SQLite cache (~/.local/share/cctally/).\n' +
39
+ '\ncctally counts anonymous active installs (a rotating monthly token +\n' +
40
+ 'version + OS family; no identity, paths, or usage data). Opt out with\n' +
41
+ '`cctally telemetry off`, CCTALLY_DISABLE_TELEMETRY=1, or DO_NOT_TRACK=1.\n' +
42
+ 'How it works: https://github.com/omrikais/cctally/blob/main/docs/telemetry.md\n' +
39
43
  '\nDetails: https://github.com/omrikais/cctally#installation\n\n'
40
44
  );