Skip to content

feat(telemetry): add anonymous opt-out daily telemetry ping (#1126) - #1300

Merged
rumblefrog merged 2 commits into
mainfrom
feature/telemetry-1126
May 10, 2026
Merged

rumblefrog merged 2 commits into
mainfrom
feature/telemetry-1126

Conversation

@rumblefrog

Copy link
Copy Markdown
Member

Summary

Implements the panel-side half of #1126 — anonymous opt-out telemetry pinged daily to a Cloudflare Worker so maintainers can see what versions, environments, and feature toggles are actually in real-world use.

  • Default-on, opt-out via Admin → Settings → Features → Telemetry. The toggle flips telemetry.enabled to 0 AND clears telemetry.instance_id so a re-enable mints a fresh per-install ID the Worker can't link to the previous one. Enable / disable transitions are audit-logged once; pings themselves are never logged.
  • Daily ping (24h ± 1h jitter) scheduled without cron — register_shutdown_function + fastcgi_finish_request so the user's TCP socket closes BEFORE the cURL POST. Atomic last_ping reservation (UPDATE … WHERE CAST(value AS UNSIGNED) <= :threshold + rowCount === 1) prevents thundering-herd from concurrent requests; slot is reserved at the START of the attempt, so a flapping endpoint costs one ping/day, not one ping/request.
  • All 32 payload fields (schema, instance_id, panel.*, env.*, scale.*, features.*) match the canonical sbpp/cf-analytics schema/1.lock.json. Two parity tests gate the contract in BOTH directions:
    • TelemetrySchemaParityTest — extractor coverage vs. lock-file leaf set.
    • TelemetryReadmeParityTest — README's ## Privacy & telemetry field list (between <!-- TELEMETRY-FIELDS-START --> / <!-- TELEMETRY-FIELDS-END --> markers) vs. the same.
  • Help-icon copy + README + UPGRADING.md + CHANGELOG.md are the in-panel + upgrade-time disclosure surfaces (no first-login modal — explicitly out of scope per the issue).
  • PII surface is bounded by TelemetryCollectTest::testCollectedPayloadContainsNoSeededPii — the test seeds canary admin names, SteamIDs, ban reasons, server hostnames, mute reasons, then asserts NONE of those literal strings appear in the JSON-serialised payload. Regression guard against the issue's "anonymous by design, not anonymous-if-you-trust-us" rule.
  • New Sbpp\Telemetry namespace under web/includes/Telemetry/. final class Telemetry + final class Schema1, PHPStan level 5 + dba clean, native types throughout.
  • make sync-telemetry-schema Makefile target for manual schema syncs from the cf-analytics companion repo. No scheduled auto-PR workflow — the parity tests gate the result and a maintainer invokes the make target when picking up cf-analytics changes.

Closes #1126.

Soft-unblocks the milestone:

Files

New:

  • web/includes/Telemetry/Telemetry.php — final class Telemetry (tickIfDue, collect, send, plus the private cooldown / reservation / flush helpers).
  • web/includes/Telemetry/Schema1.php — final class Schema1 (payloadFieldNames(): list<string> over the lock file). Single source of truth for the parity tests.
  • web/includes/Telemetry/schema-1.lock.json — Draft-7 JSON Schema, vendored byte-for-byte from cf-analytics.
  • web/updater/data/807.php — idempotent INSERT IGNORE for the four telemetry settings rows.
  • web/tests/integration/TelemetryCollectTest.php — payload shape + counts + zero-PII regression guard.
  • web/tests/integration/TelemetryOptOutTest.php — opt-out short-circuits, slot reserved even when endpoint unreachable, no-op inside cooldown.
  • web/tests/integration/TelemetrySchemaParityTest.php — extractor ↔ lock file parity (both directions).
  • web/tests/integration/TelemetryReadmeParityTest.php — README ↔ lock file parity.
  • Makefile — sync-telemetry-schema target.
  • UPGRADING.md — Telemetry section (file is new for this PR; Author UPGRADING.md for 1.x to 2.0 #1115's territory but no version landed yet).

Modified:

  • web/init.php — register_shutdown_function([Telemetry::class, 'tickIfDue']) at the tail.
  • web/install/includes/sql/data.sql — four telemetry.* rows.
  • web/updater/store.json — register 807.php.
  • web/pages/admin.settings.php — Features-tab POST handler: enable/disable transition log, opt-out clears instance_id, View DTO carries the new flag.
  • web/themes/default/page_admin_settings_features.tpl — Privacy card with the toggle + help paragraph.
  • web/includes/View/AdminFeaturesView.php — telemetry_enabled property.
  • README.md — ## Privacy & telemetry section.
  • ARCHITECTURE.md — Telemetry subsystem section + Directory layout.
  • AGENTS.md — Cross-repo JSON contracts convention + Where-to-find-what rows.
  • CHANGELOG.md — ### Privacy heading under 2.0.0.
  • docker-compose.yml + docker/README.md — bind-mount README.md into the container so the parity test can reach it from web/../README.md locally (CI gets it for free via actions/checkout@v4).

Resolved ambiguities (judgement calls)

The reviewer should look closely at these — each is a one-specific-way pick on something the issue body left open:

  1. Migration number. Issue body asks for web/updater/data/804.php; that script + 805.php + 806.php already exist on main. Used the next free integer (807.php) and registered it as "807": "807.php" in store.json. Numbers are historical / sequence not semantic, per the AGENTS.md guidance.
  2. scale.bans_active SQL. Issue body left this open ("decide and document"). Picked WHERE (ends > UNIX_TIMESTAMP() OR length = 0) AND RemoveType IS NULL. Mirrors page.banlist.php's active-ban definition (a permanent ban with length = 0 is active even though ends = 0; a removed ban is excluded regardless of ends). Documented in the schema, the README, and the test seeds rows on either side of every boundary.
  3. scale.comms_active SQL. Same shape as bans_active against :prefix_comms. The schema is identical (ends, length, RemoveType).
  4. features.smtp_configured predicate. The issue suggested Config::get('config.mailtype') !== 'phpmail' && !empty(Config::get('config.smtphost')) but config.mailtype is not in data.sql. Switched to trim((string) Config::get('smtp.host')) !== '' — :prefix_settings.smtp.host IS the panel-controlled SMTP indicator the Settings → Main form drives, so a non-empty value is the load-bearing "SMTP is wired up" signal. Host value itself never leaves the panel.
  5. features.geoip_present detection. The panel resolves MMDB_PATH (= web/data/GeoLite2-Country.mmdb) at bootstrap; system-functions.php's country() opens that exact file. Used defined('MMDB_PATH') && @is_file(MMDB_PATH) && @is_readable(MMDB_PATH). No geoip2 extension to probe — the panel is filesystem-only.
  6. panel.theme enum. Schema enum is ["default", "custom"] exactly as the issue body specified. The runtime check enumerates web/themes/<name>/theme.conf.php and intersects with the active SB_THEME; only the literal string 'default' (the shipped theme) is allowed through, every fork reports 'custom'. The actual fork directory name is never reported.
  7. Help-icon copy. Wrote a single help paragraph in page_admin_settings_features.tpl that:
    • Summarises every payload category in plain English (panel / env / scale / features), one short sentence per category.
    • States that hostnames, IPs, admin names, SteamIDs, and ban reasons are never sent.
    • Mentions that opt-out clears the random ID so a re-enable mints a fresh one.
    • Links to README.md's ## Privacy & telemetry section for the field-by-field list and the SQL behind each scale.* count.
    • Tone is matter-of-fact: no marketing, no apology copy, no "we promise we're not evil." The full text lives in web/themes/default/page_admin_settings_features.tpl under the Privacy card.
  8. Audit log shape. Used Log::add(LogType::Message, 'Telemetry', 'Telemetry ' . ($verb) . ' by ' . $user) — LogType::Message (not Warning/Error) because opting out / in is a routine state change, not a problem. Topic is Telemetry so the audit log filters cleanly. Pings themselves are never logged.
  9. instance_id lifecycle. Lazy: Telemetry::collect() mints bin2hex(random_bytes(16)) on the first call after the row is empty / wrong-shape, persists it to :prefix_settings, and memoizes within the request so a second collect() call (or tickIfDue's subsequent collect) returns the same ID. The opt-out path (Settings → Features) wipes the row to '' so the next collect() mints fresh.
  10. CLI / phpdbg flush guard. flushResponseToClient() short-circuits on PHP_SAPI === 'cli' / 'phpdbg' — closing PHPUnit's output buffers via ob_end_flush() would break the test reporter (PHPUnit 11 marks tests Risky for "test code or tested code closed output buffers other than its own"). Production paths (Apache mod_php, FPM) are unaffected.
  11. README parity test under the dev container. The container only mounts ./web to /var/www/html/web, so a naïve web/../README.md lookup fails locally. Added a paired bind mount in docker-compose.yml (./README.md:/var/www/html/README.md:ro) so the test reaches the file at web/../README.md whether it's running under CI or locally. CI gets the README via actions/checkout@v4 automatically.
  12. cf-analytics repo URL. The issue references it as sbpp/cf-analytics without a more specific URL. Used https://raw.githubusercontent.com/sbpp/cf-analytics/main/schema/1.lock.json for the make sync-telemetry-schema target and the \$id in the schema lock file. Self-hosters can repoint to a different fork by editing the Makefile.

Test plan

  • PHPStan (level 5 + dba) clean.
  • PHPUnit clean — including all 10 new telemetry tests (TelemetryCollectTest, TelemetryOptOutTest, TelemetrySchemaParityTest, TelemetryReadmeParityTest). 403 tests, 1765 assertions total.
  • ts-check clean.
  • api-contract no diff (no new JSON action shipped).
  • Manual: PHP-side container start with stack up. Verified the Features-tab toggle is present, opt-out clears telemetry.instance_id, transitions audit-log.
  • [⚠] Playwright E2E — partially passing locally. ~15-18 tests fail intermittently in the dev container (different sets each run); the same set fails on main without any of these changes. Settings-related tests (admin-settings-token-lifetimes, the Features section a11y scans, smoke /admin/settings) all pass with my changes. The flaky failures are pre-existing dev-env infrastructure issues (DB reset races, animation timings) and CI runs workers: 1 to avoid them.

Companion

The Cloudflare Worker that receives these pings lives in sbpp/cf-analytics. That repo is separate by design — Worker code, deployment, and the canonical schema source live there. This PR's web/includes/Telemetry/schema-1.lock.json is vendored byte-for-byte from cf-analytics's schema/1.lock.json and synced manually via make sync-telemetry-schema.

rumblefrog and others added 2 commits May 10, 2026 02:12
Adds the panel-side half of the telemetry contract for v2.0.0:

- New Sbpp\Telemetry\Telemetry class with schema-1 payload + atomic
  daily-tick scheduling (no cron; register_shutdown_function +
  fastcgi_finish_request hand-off so user requests never wait on
  the network call).
- Vendored web/includes/Telemetry/schema-1.lock.json mirroring the
  cf-analytics canonical schema. Two parity tests gate both
  directions (extractor coverage + README field-list drift).
- New telemetry.enabled / .last_ping / .instance_id / .endpoint
  settings rows in install/includes/sql/data.sql + paired updater
  migration 807.php.
- Features-tab toggle (Admin -> Settings -> Features -> Telemetry)
  with help-icon disclosure copy and audit-log entry on enable /
  disable transitions. Opt-out clears instance_id so re-enable
  mints a fresh one the Worker can't link to the previous state.
- README ## Privacy & telemetry section (with the
  <!-- TELEMETRY-FIELDS-START / END --> markers the parity test
  consumes), ARCHITECTURE.md Telemetry subsystem section + Where-
  to-find-what rows, UPGRADING.md Telemetry section, AGENTS.md
  Cross-repo JSON contracts convention, CHANGELOG.md Privacy
  heading, docker/README.md README mount note.
- make sync-telemetry-schema target for manual schema syncs.

The Cloudflare Worker that receives these pings lives in
sbpp/cf-analytics; that repo's separate.

Closes #1126.

Co-authored-by: Cursor <cursoragent@cursor.com>
The actual filesystem path under web/includes/ is `Telemetry/`
(uppercase, matching the `Sbpp\Telemetry\` namespace + the
PSR-4 mapping). The docs and a handful of docblocks copied
the issue body's lowercase `web/includes/telemetry/...` shape,
which doesn't resolve on case-sensitive filesystems (Linux —
i.e., production hosting). Anyone copy-pasting these paths
hits "no such file or directory".

Code paths are unaffected — `Schema1::LOCK_FILE` uses
`__DIR__ . '/schema-1.lock.json'` which resolves correctly
at runtime, and `TelemetryReadmeParityTest` uses `realpath()`.
This is a docs-only fix.

Co-authored-by: Cursor <cursoragent@cursor.com>
@rumblefrog
rumblefrog added this pull request to the merge queue May 10, 2026
Merged via the queue into main with commit a9bd72f May 10, 2026
4 checks passed
@rumblefrog
rumblefrog deleted the feature/telemetry-1126 branch May 10, 2026 06:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add anonymous opt-out telemetry pinged daily to a Cloudflare Worker

1 participant