Repository navigation
Conversation
Implements the server-side half of plans/PHONE_HOME_TELEMETRY_PLAN.md:
the periodic POST to the dreamteam-telemetry collector, matching that
repo's validateEnvelope/validateDigiscriptMetrics shape exactly.
- services/telemetry_service.py: TelemetryService mirrors the existing
VersionChecker pattern (AsyncHTTPClient + PeriodicCallback, start/stop
lifecycle, never raises). Sends after a jittered initial delay, then
~daily; retries failed sends 3x with exponential backoff without
blocking startup or request handling.
- Envelope: instance_id (lazily-generated UUIDv4, persisted in
settings), app_version, os_platform/arch, deployment_method
(docker vs bare-metal, via /.dockerenv), days_since_install,
checkin_count, error_count_recent (via a small logging.Handler
counting ERROR+ records since the last check-in).
- DigiScript metrics: shows/users/sessions counts bucketed into
ranges (never exact counts), rbac_enabled (any row across the
dynamically-created rbac_* tables), collab_editing_used
(hardcoded false for now — drafts are transient, so a current-row
count isn't a reliable "ever used" signal; needs a persistent flag
from the save path to do properly).
- digi_server/settings.py: four new settings — telemetry_enabled
(opt-out, user-editable, "Telemetry" category) and three hidden
internal ones (telemetry_instance_id, telemetry_first_seen_at,
telemetry_checkin_count).
- digi_server/app_server.py: start_telemetry_service/
stop_telemetry_service/toggle_telemetry wired the same way as the
existing version-checker pattern. Checks the DIGISCRIPT_TELEMETRY
env var first (independent override), then the telemetry_enabled
setting, before ever starting the service.
Not included here: the docs/ transparency page and the Grafana
dashboard — both still open per the plan doc's "Implementation
sketch".
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Client Test Results102 tests 102 ✅ 0s ⏱️ Results for commit e5d75d9. ♻️ This comment has been updated with latest results. |
Client V3 Test Results375 tests 375 ✅ 3s ⏱️ Results for commit e5d75d9. ♻️ This comment has been updated with latest results. |
Python Test Results 1 files 1 suites 1m 25s ⏱️ Results for commit e5d75d9. ♻️ This comment has been updated with latest results. |
Playwright E2E Results (firefox)246 tests 246 ✅ 2m 34s ⏱️ Results for commit e5d75d9. ♻️ This comment has been updated with latest results. |
Playwright E2E Results (chromium)246 tests 246 ✅ 2m 36s ⏱️ Results for commit e5d75d9. ♻️ This comment has been updated with latest results. |
telemetry_instance_id, telemetry_first_seen_at, and telemetry_checkin_count move from the file-based hierarchical Settings system (which backs the editable digiscript.json) into the DB-backed SystemSettings key/value table, the same place jwt_secret and cookie_secret already live. That file is meant to be user-editable; these three are internal state nobody should be hand-editing, and the file's auto-reload watcher means a manual edit would silently take effect. telemetry_enabled stays in the file-based settings since it genuinely is a user-facing preference, surfaced in System Settings. _build_payload now does its SystemSettings reads in the same DB session as the shows/users/sessions/rbac count queries, rather than a separate async settings round-trip — one less thing, and these three no longer trigger a WS SETTINGS_CHANGED broadcast to every client on every check-in, which they had no real reason to do. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Removed comments about telemetry instance variables to clarify settings management.
Tim020
left a comment
There was a problem hiding this comment.
Review round 1
Head SHA reviewed: c56e5cdb0d5acf8bbe597f26f29141a1db766b48
Verdict: changes needed. This is a well-structured, privacy-conscious telemetry service with solid test coverage for its happy paths, but there are two real lifecycle bugs in the start/stop machinery that should be fixed before merge, plus a metric that will over-report for most installs.
Previous round follow-up: none — this is round 1, no prior threads.
SonarQube: Quality gate PASSES (project key dreamteamprod_DigiScript, analysis confirmed fresh for this head SHA). No failing conditions, 0 security hotspots, 0% duplication. 3 open MINOR issues, all rule python:S7503 ("async function never awaits") in telemetry_service.py at lines 85, 103, 166 — covered in the inline comments below. None of these block the gate.
New findings: 2 Critical, 4 Important, 6 Suggestion (2 Sonar-sourced). None could not be anchored to a diff line, but a few broader points are summarized here:
- Test gaps (
server/test/services/test_telemetry_service.py): the jitter →_schedule_first_check_in→_first_check_in→PeriodicCallbackchain is never actually driven in any test (only thatstart_telemetry_service()setstelemetry_serviceto non-None), so the bug in the first Critical comment below would pass the whole suite. Also untested:toggle_telemetry()'s sync wrapper / the settingscallback_fnwiring itself (only the async_toggle_telemetryis called directly),_compute_rbac_enabledwith an actual non-default RBAC role assigned (only the all-false default case is covered — would have caught the rbac_enabled finding below), exact retry attempt-count/backoff-timing assertions, and concurrency edge cases (check_in()racingstop(), double-start()). - Docs: per this repo's own "Important Notes from Tim" in CLAUDE.md, a new user-facing setting + env var should come with a
docs/page describing what's collected and how to opt out (both the setting andDIGISCRIPT_TELEMETRY). None was added in this PR. - Docstring coverage is inconsistent across the new private helpers:
__init__has a full reST docstring,_get_or_create_instance_idhas a partial one, but its structural twin_get_or_create_first_seen_at, plus_build_payload,_get_checkin_countand_set_checkin_count, have none at all. Not blocking, but worth a pass given CLAUDE.md's reST docstring convention. - The module docstring's reference to the
dreamteam-telemetrycollector repo's exact validator function names (validateEnvelope/validateDigiscriptMetrics) is a reasonable authoritative-contract pointer, but is comment-rot risk if that repo's code moves — low priority, just flagging.
Strengths:
- Deliberate, well-reasoned privacy design: no show/user content, random UUID instance id, bucketed counts, clear settings help text that matches what's actually sent (verified field-by-field).
check_in()'s "never raises" contract largely holds up under tracing, and the outerexcept Exceptionaround_build_payload()is correctly-scoped best-effort suppression with full traceback logging (.exception()), not silent swallowing.- Good WHY comments: the
collab_editing_used: Falsehardcoding rationale, and the env-var-still-overrides-the-setting note in_toggle_telemetry, both verified accurate against the code. - DB session handling (short sync
sessionmaker()blocks, noawaitwhile a session is open, SQLAlchemy 2.0select()API) follows this codebase's established async/DB pattern correctly, and every session use goes through a context manager that rolls back/closes in afinallyregardless of exception — no resource leaks found. - Bucket-boundary tests and payload-shape tests against the collector's contract are thorough and will catch accidental field drift.
🤖 Generated with Claude Code
| if not IOLoop.current(): | ||
| get_logger().error( | ||
| "Unable to toggle telemetry as there is no current IOLoop" | ||
| ) | ||
| else: | ||
| IOLoop.current().add_callback(self._toggle_telemetry) |
There was a problem hiding this comment.
Suggestion: this guard is unreachable, and the real failure mode it's meant to catch is worse than what it currently does nothing for.
IOLoop.current() called with its default instance=True creates and returns a new IOLoop when none exists, rather than returning None/falsy (confirmed against the installed Tornado source). So if not IOLoop.current(): can never be true in normal operation — this error-logging branch is dead code, giving false confidence that "no IOLoop" is being defended against. The actual failure mode (calling from a thread/context with no running loop) would instead silently create a throwaway orphan loop, schedule _toggle_telemetry on it, and that callback would simply never run on the real server loop — with no error logged at all, which is a worse silent failure than the one this code appears to handle. Not blocking today if toggle_telemetry() is always invoked from the server's own IOLoop thread, but consider removing the dead branch or replacing it with an actual thread/loop-identity check.
🤖 Generated with Claude Code
There was a problem hiding this comment.
Agreed this is dead code — not fixing it here, with a reason: toggle_telemetry() copies this exact if not IOLoop.current(): error; else: add_callback(...) pattern verbatim from four pre-existing call sites in this same file (toggle_mdns_advertising, regen_logging, validate_has_admin, show_changed), none of which this PR touches. Fixing it only in the new telemetry code would leave the file internally inconsistent — four call sites still doing the (confirmed) wrong thing right next to one that doesn't. A repo-wide cleanup of this pattern (plus whatever the "real failure mode" guard should actually look like, per your point about orphaned loops) seems like the right scope for a separate PR rather than folding it into this one.
Critical fixes (telemetry_service.py): - stop() couldn't cancel the pending startup chain: the call_later handle for the jittered first check-in was never stored. Disabling telemetry mid-jitter (up to 1 hour window) or during a retry sleep let _first_check_in run anyway and start an orphaned PeriodicCallback nothing could ever reach to cancel. Now stores the handle, cancels it in stop(), and both start()/stop() are idempotent via _started/ _stopped flags. _first_check_in's lone check_in() call also gets a defensive try/except backstop, independent of check_in()'s own "never raises" handling. - AsyncHTTPClient() without force_instance=True returns one client cached per IOLoop, which VersionChecker also constructs and uses. stop() closing it would break VersionChecker's HTTP client too the moment an admin toggles telemetry off at runtime. Now constructed with force_instance=True. Important fixes: - check_in(): the local checkin_count DB write ran inside the same try/except as the HTTP POST, so a local failure (DB locked, etc.) was indistinguishable from a network failure — logged identically, retried by re-POSTing the already-sent payload (a duplicate submission retrying can't fix), and logged at .warning() without a traceback so it never reached ERROR level. Moved outside the retry loop, logged separately with .exception(). - rbac_enabled: creating a cue type auto-grants the creator a role on it (controllers/api/v1/show/cues.py), so "any row exists" read True for nearly every real install from the moment someone created a single cue type — the docstring's "beyond the built-in admin" framing didn't hold. Now counts distinct users holding any role; the auto-grant always targets the creator alone, so >1 distinct user is a real signal of deliberate RBAC administration. - app_server.py: start_telemetry_service() only guarded construction, not start() itself, so a second call (e.g. from a rapid settings toggle) could schedule a second independent schedule. Now guarded inside TelemetryService.start() itself via the _started flag above. - Module docstring pointed at plans/PHONE_HOME_TELEMETRY_PLAN.md, which is gitignored and absent from every checkout but the author's own — a dead reference for every other reader. Rewritten to inline the key privacy/design points and point at PR #1481 instead, which survives in git history. Suggestions addressed: - start()/stop()/_build_payload() were declared async with no actual await in their bodies (SonarQube python:S7503 x3) — now plain def, with awaits dropped at their call sites. - DIGISCRIPT_TELEMETRY only matched 0/false/no; broadened to also accept off/disabled/n. telemetry_enabled's help text now notes the env var overrides the setting even while it shows as enabled. - Dropped a pure-WHAT docstring line on _toggle_telemetry, keeping just the WHY comment below it. Not fixed (reply posted with reason): the `if not IOLoop.current():` guard pattern is dead code (IOLoop.current() never returns falsy), but it's copied verbatim from four existing call sites in this same file (toggle_mdns_advertising, regen_logging, validate_has_admin, show_changed) — fixing it only in new code would be inconsistent; a repo-wide cleanup is a separate PR. Also: - docs/pages/telemetry.md: new page covering exactly what's collected, what's never sent, and both opt-out mechanisms — linked from docs/index.md and cross-referenced from the System Settings section of docs/pages/user_config.md. - New/expanded tests covering the lifecycle fixes (stop during the jitter window, double-start idempotency, the shared-HTTP-client fix), the local-persistence-failure-doesn't-retry fix, rbac_enabled under both a single-auto-grant and a multi-user scenario, the broadened env var spellings, and toggle_telemetry's sync-wrapper dispatch via IOLoop.add_callback. Full backend suite: 1294 passed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Review round 1 — all findings addressedReplied inline on each of the 11 anchored comments (2 Critical, 4 Important, 5 Suggestion) with what changed and the commit it landed in — see those threads for specifics. One (the dead On the broader, non-anchored points from the review summary:
Full backend suite: 1295 passed. Latest commit: eeee7cb. |
Tim020
left a comment
There was a problem hiding this comment.
Review round 2
Head SHA reviewed: eeee7cb311fc7eff93118ba5d50c1a0daa3a443c
Verdict: close to ready. Every Critical from round 1 is genuinely fixed, with good test coverage pinning each one down. No new Critical issues. Two new Important findings below are worth a look before merge, but neither is a regression — they're residual edge cases in logic that was already substantially improved this round.
Previous round follow-up: 10 of 11 round-1 threads verified fixed and resolved; 1 still open.
- Fixed & resolved (10): both Critical lifecycle bugs (stop() not cancelling the pending check-in; the shared
AsyncHTTPClientsingleton breakingVersionChecker), the DB-write-conflated-with-network-failure bug, therbac_enabledovercounting-almost-every-install bug (including a residual revoke-but-row-persists nuance the author caught and fixed in a follow-up commit before I could even raise it), the deadplans/doc reference, the double-start guard, the_toggle_telemetryWHAT-only docstring, the narrow env-var matching, and both SonarQubepython:S7503suggestions. - Still open (1): the dead/unreachable
IOLoop.current()falsy-check intoggle_telemetry()(app_server.py:749-754) — not touched this round, still a minor dead-code suggestion, not blocking.
SonarQube: Quality gate PASSES. Zero open issues of any kind (the 3 round-1 python:S7503 MINOR issues are confirmed gone), zero security hotspots, zero duplication. Analysis is fresh for the reviewed head.
New findings: 2 Important, 5 Suggestion. Both Important findings are anchored below.
server/services/telemetry_service.pylines 353-384 (_compute_rbac_enabled): fixed for the single-lone-admin case, but two different people each independently creating a cue type (each auto-granted a role on their own cue type) still makesrbac_enabledreadTruewith zero deliberate RBAC administration.server/services/telemetry_service.pylines 186-210 (check_in()'s retry loop): empirically confirmed (by reproduction) that a concurrentstop()firing mid-retry nullsself._http_client, and the next attempt(s) throwAttributeErrorthat gets logged as a misleading "check-in failed" warning, burning the remaining retry budget instead of recognizing the service stopped. Doesn't crash (the broadexceptcatches it), but produces confusing logs.
Five Suggestions (all in the test file, not blocking): test_stop_during_jitter_window_prevents_first_check_in's comment overclaims that it covers "the exact race" when it only exercises the guard flags sequentially after stop() has fully completed; test_toggle_telemetry_sync_wrapper_dispatches_via_ioloop's fixed await asyncio.sleep(0.05) is a minor flaky-test risk under CI load; test_start_telemetry_service_is_safe_to_call_twice's docstring claims relevance to toggle_telemetry's IOLoop dispatch path that it doesn't actually exercise; a latent (not currently reachable) issue where restarting a stopped TelemetryService instance would silently fail to send anything, since stop() nulls _http_client and start() doesn't rebuild it; and several test classes (TestTelemetryServiceState, TestTelemetryServicePayload, TestTelemetryServiceCheckIn) construct a TelemetryService — and so a force_instance=True AsyncHTTPClient — in setUp with no tearDown calling .stop(), leaking clients across tests (only TestTelemetryServiceLifecycle does this correctly).
Strengths: the round-1 fixes are well-targeted and well-tested — test_http_client_uses_force_instance, test_rbac_enabled_true_for_multiple_distinct_users/test_rbac_enabled_false_when_all_roles_revoked, and test_check_in_does_not_retry_a_local_persistence_failure all genuinely pin down the behavior they claim (verified via mutation testing). The author caught and fixed the RBAC revoke-row-persistence nuance proactively, in a follow-up commit, before this review got to it. Missing docstrings flagged in round 1's body (on _get_or_create_first_seen_at, _get_checkin_count, _set_checkin_count, _compute_rbac_enabled, and now _build_payload) have all been added.
🤖 Generated with Claude Code
… race Important fixes (telemetry_service.py): - rbac_enabled: round 1's distinct-user-count fix still read True when two unrelated people each independently created their own cue type — each auto-granted a role on their own creation, which is still "more than one distinct user with an active role" despite zero deliberate administration. No row-level signal can tell these apart: neither the rbac_* tables nor the resources they reference record how a grant was made. Replaced the heuristic entirely with a persisted flag (telemetry_rbac_deliberately_used, SystemSettings-backed) set only by RBACRolesGrantHandler (controllers/api/v1/rbac.py) — the sole @require_admin-gated role-grant endpoint, as opposed to the auto-grant call sites in cues.py/shows.py/scripts.py. - check_in()'s retry loop: a concurrent stop() firing during the backoff sleep nulls _http_client; the next attempt's .fetch() on None raised AttributeError, caught by the broad except and logged as a misleading "check-in failed" warning. Now checks self._stopped / self._http_client is None at the top of each retry attempt and bails out cleanly instead. Also fixed a latent issue the reviewer flagged as not-currently- reachable but worth closing now: start() didn't rebuild _http_client after a prior stop() nulled it, so a stop()-then-start() (e.g. toggling the setting off then back on) would start the service but silently fail every send against a None client. Test-file suggestions (all five, not blocking but addressed): - Replaced the two RBAC tests with ones matching the new flag-based metric, including the two-independent-auto-grants case that proves the false positive is actually gone. - test_stop_during_jitter_window_prevents_first_check_in's comment no longer overclaims it exercises a true concurrent race (it replays the same code path sequentially). - test_toggle_telemetry_sync_wrapper_dispatches_via_ioloop now polls (bounded, 1s max) instead of a fixed asyncio.sleep(0.05), removing the CI-load flakiness risk. - test_start_telemetry_service_is_safe_to_call_twice's docstring no longer claims to exercise toggle_telemetry's IOLoop dispatch path, which it doesn't. - Added tearDown (calling .stop()) to the three test classes that were constructing a TelemetryService — and so a force_instance=True AsyncHTTPClient — without ever closing it. New tests: test_check_in_aborts_cleanly_if_stopped_mid_retry, test_restart_after_stop_rebuilds_http_client, test_rbac_enabled_false_without_deliberate_grant, test_rbac_enabled_true_after_deliberate_grant. Full backend suite: 1296 passed (including existing test/controllers/api/v1/test_rbac.py, unaffected by the new telemetry call in the grant handler). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Review round 2 — both Important findings fixed, all 5 Suggestions addressedSince neither Important finding was anchored as a separate inline thread this round (both described in the review body prose rather than as GitHub review comments), replying here instead of inline.
Also fixed, since I was already in this code: the latent restart bug you flagged as a Suggestion ( All 5 test-file Suggestions addressed: the jitter-test comment no longer claims to cover "the exact race" (it replays the guard sequentially, not concurrently); the toggle-dispatch test now polls (bounded) instead of a fixed Full backend suite: 1296 passed, including the existing |
Tim020
left a comment
There was a problem hiding this comment.
Review round 3
Head SHA reviewed: f81756ebdbf27a5b994cc33541edbd40d84824c7
Verdict: close to ready — one Important finding worth fixing, nothing blocking. This round is another strong pass: every one of round 2's 6 findings is genuinely fixed, including a root-cause redesign (not a patch) of the rbac_enabled metric.
Previous round follow-up: all 6 round-2 findings verified fixed and resolved (none disputed, none incomplete).
_compute_rbac_enabledwas redesigned around a persistedRBAC_DELIBERATE_USE_KEYflag set only from the@require_admin-gated grant endpoint, rather than counting rows — this fully eliminates the two-independent-creators false positive (not just the single-creator case round 1 fixed), confirmed by new tests covering both the negative and positive case.check_in()'s retry loop now checksself._stopped or self._http_client is Nonebefore every attempt, cleanly aborting instead of producing a misleadingAttributeError-as-failure log when a concurrentstop()fires mid-retry.start()now rebuildsself._http_clientifNone(from a priorstop()), fixing a silent-failure-on-restart bug.- Three test-quality nits (an overclaiming comment, a flaky fixed
asyncio.sleep(0.05), a misleading docstring) were all cleanly fixed with accurate comments and a bounded poll loop. Test hygiene (missingtearDown/stop()leaking HTTP clients) was also fixed across all three affected test classes.
The 1 thread still open from round 1 (the dead/unreachable IOLoop.current() falsy-check in toggle_telemetry(), app_server.py:749-754) remains untouched this round — still a minor non-blocking suggestion.
SonarQube: Quality gate PASSES. Zero open issues, zero security hotspots, zero duplication. Analysis confirmed fresh for this exact head SHA.
New findings: 1 Important, 1 Suggestion.
server/controllers/api/v1/rbac.pylines 213-219: the newmark_rbac_deliberately_used()call has no error handling, so a transient DB failure there turns an already-successfully-committed RBAC grant into a reported 500 for the admin — and silently skips the WSGET_CURRENT_RBACrefresh notification to the grantee's connected clients too, since that runs later in the same method.server/services/telemetry_service.pylines 371-390: minor docstring nit — the flag is forward-looking only (it can't retroactively capture RBAC administration that happened before this code shipped), worth a one-line caveat.
Also noted, not blocking, no inline anchor: no test exercises the real rbac.py → flag wiring end-to-end through the actual HTTP endpoint — the telemetry tests call TelemetryService.mark_rbac_deliberately_used(session) directly, bypassing the controller. A test in the existing server/test/controllers/api/v1/test_rbac.py doing a real grant via the endpoint and then checking the flag would close this gap.
Strengths: the project's own established "telemetry must never affect real app behavior" convention (visible in check_in()'s own carefully-isolated DB-write try/except) is exactly what's missing at the one new call site outside the telemetry module itself — this reads as an oversight in otherwise very consistent code, not a pattern problem. The RBAC redesign is a genuinely better design than the row-counting heuristic it replaced, and the fix-test pairing across all three rounds has been thorough throughout.
🤖 Generated with Claude Code
Important fix: RBACRolesGrantHandler's mark_rbac_deliberately_used() call had no error handling, so a transient DB failure there would turn an already-successful RBAC grant into a reported 500 for the admin, and skip the WS GET_CURRENT_RBAC notification that runs after it in the same method. Wrapped in its own try/except, logged with .exception(), matching this project's own established pattern for isolating telemetry writes (see TelemetryService.check_in). Suggestion addressed: noted in _compute_rbac_enabled's docstring that the RBAC_DELIBERATE_USE_KEY flag is forward-looking only — an install that administered RBAC before this flag shipped reads False until the next deliberate grant. Also closed the non-blocking coverage gap the review flagged: added test_successful_grant_marks_rbac_deliberately_used_for_telemetry to test/controllers/api/v1/test_rbac.py, exercising the real HTTP grant endpoint end-to-end rather than only unit-testing mark_rbac_deliberately_used() directly, which bypassed RBACRolesGrantHandler's wiring to it entirely. Full backend suite: 1297 passed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Review round 3 — both findings fixed, coverage gap closedNeither finding was anchored as a separate inline thread, so replying here. Important — Suggestion — forward-looking-only caveat: added to Also closed the non-blocking gap you flagged (no test through the real endpoint): added Full backend suite: 1297 passed. Latest commit: 5cefa2c. |
Tim020
left a comment
There was a problem hiding this comment.
Review round 4
Head SHA reviewed: 5cefa2c05a1f9d1f4370db0066c5e06dfb0ae02a
Verdict: ready to merge. This round's diff is small and purely addresses round 3's two findings — nothing else changed, and both fixes are correct.
Previous round follow-up: both round-3 findings verified fixed and resolved.
- The uncaught-exception issue in
rbac.py's grant handler (Important) is fixed: the telemetry flag write is now wrapped in its own try/except with a WHY comment, matching the establishedcheck_in()pattern, so a failure there can no longer turn a successful RBAC grant into a reported error or skip the WSGET_CURRENT_RBACnotification. The accompanying test gap noted alongside that finding is also closed —test_successful_grant_marks_rbac_deliberately_used_for_telemetrynow exercises the real HTTP endpoint end-to-end, not just the static method in isolation. - The docstring forward-looking-only caveat (Suggestion) is fixed, worded almost exactly as suggested.
The 1 thread still open since round 1 (the dead/unreachable IOLoop.current() falsy-check in toggle_telemetry(), app_server.py:749-754) remains untouched — still a minor non-blocking suggestion, not revisited this round since nothing in this diff touched it.
SonarQube: Quality gate PASSES. Zero open issues, zero security hotspots, zero duplication. Analysis confirmed fresh for this exact head SHA.
New findings: none. No new Critical/Important/Suggestion issues found in this round's diff.
Strengths: across all four rounds, every finding raised was either fixed correctly (often with a better root-cause fix than the minimal suggestion, as with the rbac_enabled redesign) or a genuine non-issue conceded with reasoning. Test coverage was extended alongside every fix, including closing gaps noted only in review-body prose rather than as formal inline threads (the controller-level RBAC wiring test). This PR is in good shape to merge.
🤖 Generated with Claude Code
Both places the Playwright E2E suite spawns a real server via main.py (global-setup.ts's initial launch, and db-snapshot.ts's retry-restart hook) passed no env override, so the spawned process inherited whatever was in the shell — and nothing sets DIGISCRIPT_TELEMETRY anywhere today. That means every E2E run, in GitHub Actions and locally, started a real TelemetryService that would phone home to the live collector, not just a hypothetical risk: the pytest suite never hits this (DigiScriptTestCase never calls app.configure(), so TelemetryService never starts there), but the E2E suite runs the genuine main.py startup path. Fixed by passing DIGISCRIPT_TELEMETRY=0 in both spawn() calls' env. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The actual gap: check-database-migrations.yml and docker-image-build.yml both `docker run` the real digiscript image (ENTRYPOINT ["python3", "main.py"] — the genuine production startup path, configure() and all) on every pull_request, with no env override. Every PR opened against this repo would have started a real TelemetryService phoning home to the live collector. The E2E fix in the previous commit addressed a real but different gap (Playwright's own server spawns); it wasn't what was actually being asked about here. Checked the other two workflows that reference "digiscript"/docker for the same thing: build-server.yml only compiles PyInstaller binaries and docker-publish.yml only builds/pushes the image — neither ever runs the server, so neither needed a change. Fixed both docker run invocations with -e DIGISCRIPT_TELEMETRY=0. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Telemetry previously defaulted on at server boot, even for a brand new install with no admin yet — before anyone had a chance to see or change the setting. Now: - digi_server/app_server.py: start_telemetry_service() gates on the existing has_admin_user setting, in addition to the env var and telemetry_enabled checks it already had. A fresh install never starts telemetry at boot. - utils/web/user_admin.py: create_user() — the one function both /api/v1/auth/create and /api/v2/users already share — saves an optional telemetry_enabled field from the request body, but only on the very first user creation (has_any_users is False), right after has_admin_user flips True. It then explicitly re-calls start_telemetry_service(), which is what first clears the new gate. Older clients/direct API callers that omit the field still get the existing default (opt-out, enabled). - CreateUser.vue in both client/ and client-v3/: added a telemetry checkbox, shown only when isFirstAdmin, pre-checked to match the server default, with a link to the new docs/pages/telemetry.md page (served via each client's own /help/:slug viewer — /ui-old/ prefixed for the legacy client). The field is only included in the create-user payload where it's actually meaningful. Tests: - server/test/controllers/api/v2/test_users.py: first-run creation with telemetry on/off/omitted, and confirms the field has no effect on a second (non-first) user creation. - server/test/services/test_telemetry_service.py: dedicated has_admin_user gate tests, plus a setUp() on the existing startup test class so its tests (which exercise the env-var/telemetry_enabled gating specifically) aren't broken by the new gate. - client-v3/e2e/tests/01-first-run.spec.ts: checkbox visible and checked by default on the real first-run screen. - client-v3/e2e/tests/03-system-config.spec.ts: the saved preference is actually visible and correct in System Settings afterward, not just in local form state. Verified: full backend suite (1303 tests), full Playwright E2E suite (241 tests, Chromium), both clients' Vitest suites, both clients' lint/typecheck/build — all green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Added: first-run telemetry opt-in/out, gated startOn request, telemetry no longer starts at boot on a fresh install before an admin account exists. The choice now lives on the existing first-run "create an admin user" screen in both
New coverage: backend tests for the first-run save/gate behavior, and new Playwright assertions in |
|
Tim020
left a comment
There was a problem hiding this comment.
Review round 5
Head SHA reviewed: e5d75d90f80017624918a06f9d9ac1371f5d8888
Verdict: changes needed. This round adds a substantial new feature — a first-run telemetry opt-in/opt-out checkbox on the admin-creation screen in both clients, gated server-side on a new has_admin_user check so telemetry can never start before first-run setup completes. The core design is sound and the gating logic itself is correct (verified the settings-callback interaction and startup ordering don't race), but two Important issues and a few Suggestions turned up.
Previous round follow-up: nothing to validate this round — this is new feature work, not a reply to an earlier finding. The 1 thread still open since round 1 (the dead/unreachable IOLoop.current() falsy-check in toggle_telemetry(), now at app_server.py:765) remains untouched, unrelated to this round's diff.
SonarQube: Quality gate PASSES. Zero open issues, zero security hotspots, zero duplication. Analysis confirmed fresh for this exact head SHA (ran 15 seconds after the commit).
New findings: 2 Important, 3 Suggestion.
- Both clients: the new "See what's collected" link on the first-run screen opens
/help/telemetryin a new tab, but that tab is itself a fresh load of the app with no admin yet — so it renders the same "create an admin user" screen instead of the help page, since bothApp.vues gate onhas_admin_user === falseunconditionally, before any route is matched. The one way to read the disclosure before deciding is broken at exactly the moment it matters. user_admin.py's newstart_telemetry_service()call fires for every first-user creation through/api/v1/auth/createand/api/v2/users— including the ~77+ call sites ofDigiScriptTestCase._create_and_login_adminused throughout the backend test suite. Nothing setsDIGISCRIPT_TELEMETRY=0for pytest, andconftest.py'stearDownnever stops the service, so every such test leaks an_ErrorCountHandleronto the process-global logger (confirmed:get_logger()returns a module-level singleton shared across the whole pytest-xdist worker) and an unclosedAsyncHTTPClient, accumulating for the life of the worker process — plus a non-zero (if small per-test) chance of an actual network POST firing mid-suite.
Three Suggestions (all low-risk, not blocking): the Vue 2 client always sends telemetry_enabled in the user-creation payload regardless of relevance, while Vue 3 explicitly omits it for non-first-admin creation — a deliberate-but-divergent parity gap and latent footgun if the server-side has_any_users gate is ever loosened; bool(telemetry_enabled) in user_admin.py would silently coerce a direct API caller's string "false" to True (both real clients send actual booleans, so this only affects hand-written API calls); and 03-system-config.spec.ts's new test claims to confirm "the default saved during first-run" but can't actually distinguish that from the UI's own default, since both happen to be True.
Strengths: the has_admin_user gate itself is correctly placed and doesn't race with existing installs (verified the settings-callback deferral via IOLoop.add_callback and the __init__-time load order). The new unit tests in test_users.py and test_telemetry_service.py exercise real behavior, not trivial mocks. Docs and e2e coverage were added alongside the feature as this project's own conventions ask for — the gaps found are in the details, not the approach.
🤖 Generated with Claude Code
| <div class="text-muted small"> | ||
| Helps guide development. No show content, user data, or other identifying information is | ||
| ever sent. | ||
| <a href="/help/telemetry" target="_blank" rel="noopener">See what's collected</a> |
There was a problem hiding this comment.
Important: this link can't actually be reached during first-run — clicking it just shows the same "create an admin user" screen again, not the help page.
target="_blank" opens /help/telemetry in a new tab, which loads the app fresh. App.vue (lines 119-121) gates on settings.has_admin_user === false before any route is matched — it renders the first-run <CreateUser> block unconditionally whenever no admin exists yet, regardless of path. Since no admin exists yet (that's the whole premise of this screen), the new tab shows "Welcome to DigiScript / create an admin user" instead of the privacy disclosure. The new docs text ("The choice is presented right there... nothing is ever sent before that screen has actually been completed") implies this link works, but it's unreachable at exactly the point someone would want to check it before deciding.
Fix: add a route exception to the first-run gate in App.vue, e.g. v-else-if="settings?.has_admin_user === false && !route.path.startsWith('/help')" (adjust to however route/$route is accessed in this component), so /help/* can render normally even pre-first-run. A short inline summary or modal would also work and avoids touching the routing gate at all.
🤖 Generated with Claude Code
| <div class="text-muted small"> | ||
| Helps guide development. No show content, user data, or other identifying information is | ||
| ever sent. | ||
| <a href="/ui-old/help/telemetry" target="_blank" rel="noopener">See what's collected</a> |
There was a problem hiding this comment.
Important: same issue as the Vue 3 client — this link is unreachable during first-run.
App.vue (line 113: v-else-if="SETTINGS && SETTINGS.has_admin_user === false") gates unconditionally on has_admin_user, before any route is matched, so opening /ui-old/help/telemetry in a new tab during first-run just re-renders the "create an admin user" screen instead of the help page. See the companion finding on client-v3/src/components/user/CreateUser.vue:43 for the full explanation — the fix needs to land in both clients' App.vue for parity.
🤖 Generated with Claude Code
| if is_admin: | ||
| await controller.application.digi_settings.set("has_admin_user", True) | ||
|
|
||
| if not has_any_users: | ||
| # First-run setup: save the admin's telemetry preference (if the form | ||
| # submitted one — older clients or direct API callers may not) before | ||
| # telemetry is ever allowed to start. start_telemetry_service() itself | ||
| # gates on has_admin_user, so nothing could have started before this | ||
| # point; this call is what first lets it through that gate. | ||
| telemetry_enabled = data.get("telemetry_enabled", None) | ||
| if telemetry_enabled is not None: | ||
| await controller.application.digi_settings.set( | ||
| "telemetry_enabled", bool(telemetry_enabled) | ||
| ) | ||
| await controller.application.start_telemetry_service() |
There was a problem hiding this comment.
Important: this new call starts a real TelemetryService on every first-admin creation in the backend test suite, with no cleanup — a real resource leak, not just a theoretical one.
/api/v1/auth/create (via controllers/api/v1/auth/user.py) and /api/v2/users both route through this function, and DigiScriptTestCase._create_and_login_admin — used across roughly 77+ call sites in the backend test suite — hits exactly this path. Nothing sets DIGISCRIPT_TELEMETRY=0 anywhere in the pytest environment (pyproject.toml, conftest.py, and python-test.yml are all silent on it), and telemetry_enabled defaults to True, so start_telemetry_service() genuinely starts a live service in every such test. conftest.py's tearDown never calls stop_telemetry_service(), so each test leaks:
- An
_ErrorCountHandleradded toget_logger()— confirmed this returns the module-levelloggersingleton (digi_server/logger.py), shared across every test in the same pytest-xdist worker process, so these accumulate for the life of the worker. - An unclosed
AsyncHTTPClient(force_instance=True). - A scheduled first check-in after
random.uniform(0, 3600)seconds — individually low-probability, but across hundreds of tests per CI run, not negligible, and a real network POST to the live collector from CI would be a privacy/correctness problem in its own right.
This is exactly the class of "shared global state across tests" hazard this project's own test-isolation conventions (see CLAUDE.md) are otherwise careful about.
Two smaller, related points while you're in this block:
- No try/except around
start_telemetry_service()here — a failure (unlikely, but see the analogous RBAC-grant fix from round 3) would turn an already-successful user creation into a reported 500. bool(telemetry_enabled)at line 115 coerces non-bool input — a direct API caller sending the string"false"gets coerced toTrue. Both real clients send actual booleans, so this only affects hand-written API calls, but worth a type check or letting the setting's own validation reject it.
Fix for the main issue: set DIGISCRIPT_TELEMETRY=0 in DigiScriptTestCase.setUp (restoring/clearing it in tearDown), and/or call self._app.telemetry_service.stop() in conftest.py's tearDown if it's set.
🤖 Generated with Claude Code
| // Matches the server's own default (digi_server/settings.py's telemetry_enabled). | ||
| // Always present in state and sent with every create request, but only meaningful | ||
| // (and only read) by the server on the very first user creation — harmless no-op | ||
| // otherwise. | ||
| telemetry_enabled: true, |
There was a problem hiding this comment.
Suggestion: this client always sends telemetry_enabled on every user-creation request, unlike client-v3's CreateUser.vue, which explicitly omits the field unless isFirstAdmin (...(props.isFirstAdmin ? { telemetry_enabled: ... } : {})).
This is confirmed deliberate (per the comment here) and currently harmless, since the server only reads the field inside if not has_any_users: — but it's a parity divergence between the two clients for the same feature, and a latent footgun if that server-side gate is ever loosened or refactored (every subsequent user creation would silently resend telemetry_enabled: true, which could unintentionally re-enable a setting an admin had since turned off, if the server ever started trusting this field more broadly).
Fix: build the request payload explicitly, matching client-v3's pattern: { username, password, is_admin, ...(this.isFirstAdmin ? { telemetry_enabled: this.state.telemetry_enabled } : {}) }, and update the comment accordingly. Not blocking, but worth the parity fix while this is fresh.
🤖 Generated with Claude Code
| test('Telemetry category can be expanded, reflecting the default saved during first-run', async () => { | ||
| // Collapsed by default — click the header to expand | ||
| await page.locator('.card-header:has-text("Telemetry")').click(); | ||
| const telemetryCheckbox = page.locator('#telemetry_enabled-input'); | ||
| await expect(telemetryCheckbox).toBeVisible({ timeout: 5_000 }); | ||
| // 01-first-run.spec.ts left this checked (the default), confirming the first-run | ||
| // checkbox's value actually reached the saved setting, not just the local form state. | ||
| await expect(telemetryCheckbox).toBeChecked(); | ||
| }); |
There was a problem hiding this comment.
Suggestion: this test's name and comment claim more than it actually proves.
"Reflecting the default saved during first-run" would also pass if the checkbox's value never reached the server at all — the UI's own default and the server's own default are both True, so this test can't distinguish "the first-run choice was persisted" from "the settings page just shows its own hardcoded default." It doesn't catch a regression where the first-run submission silently stopped writing the setting.
Fix: in 01-first-run.spec.ts, uncheck the telemetry checkbox before saving (safe now that e2e runs with DIGISCRIPT_TELEMETRY=0), then assert not.toBeChecked() here in 03-system-config.spec.ts. That actually proves the value round-trips through the save.
🤖 Generated with Claude Code



Summary
Server-side half of the anonymous usage-telemetry feature designed in
plans/PHONE_HOME_TELEMETRY_PLAN.md. The collector side (Cloudflare Worker/D1/Analytics Engine, shared across dreamteamprod apps) is already deployed and live athttps://telemetry.digiscript.dreamteamprod.co.uk— see thedreamteam-telemetryrepo.services/telemetry_service.py:TelemetryService, mirroring the existingVersionCheckerpattern (AsyncHTTPClient+PeriodicCallback, start/stop lifecycle, never raises on failure). Sends a check-in after a jittered initial delay, then roughly daily; retries failed sends up to 3x with exponential backoff, never blocking startup or request handling.instance_id(lazily-generated UUIDv4, persisted in settings — never derived from anything identifying),app_version,os_platform/os_arch,deployment_method(docker vs bare-metal, via/.dockerenv),days_since_install,checkin_count,error_count_recent(counted via a smalllogging.Handlerattached to the root logger, read-and-reset each check-in).rbac_enabled(any row across the dynamically-createdrbac_*tables) andcollab_editing_used(hardcodedfalsefor now; see note below).validateEnvelope/validateDigiscriptMetricsexactly — the collector rejects anything that doesn't.digi_server/settings.py: four new settings —telemetry_enabled(opt-out by default, user-editable, new "Telemetry" category) and three hidden internal ones (telemetry_instance_id,telemetry_first_seen_at,telemetry_checkin_count).digi_server/app_server.py:start_telemetry_service/stop_telemetry_service/toggle_telemetry, wired the same way as the existing version-checker and following the same live-toggle pattern other settings use. Checks theDIGISCRIPT_TELEMETRYenv var first (an independent override), then thetelemetry_enabledsetting, before the service is ever started.Known gap
collab_editing_usedis hardcodedfalse. The collaborative editor's drafts (ScriptDraft/RoomManager) are transient — cleaned up on save/discard — so a current-row count would read near-zero even for heavy users. Doing this properly needs a persistent "ever used" flag set from the save path, which is out of scope here.Not included
The
docs/transparency page and the Grafana dashboard panel — both still open per the plan doc's "Implementation sketch".Test plan
ruff check/ruff format --checkcleanTelemetryService(buckets, instance-id/first-seen-at persistence, payload shape, check-in retry/backoff behavior, env-var/setting gating) — 14 tests, all passingpytest, 1284 tests) passes