diff --git a/docs/telemetry.md b/docs/telemetry.md new file mode 100644 index 0000000..b464b88 --- /dev/null +++ b/docs/telemetry.md @@ -0,0 +1,77 @@ +# Install statistics + +ScreenTinker can optionally report how many screens an install runs. It is **off until you turn it +on**, and this page documents the whole of it. + +--- + +## What is sent + +Three fields. This is the complete payload: + +```json +{ + "instance_id": "9f2c1b6e-4a17-4c8e-9d3b-27a5e0f81c44", + "version": "1.9.34", + "screen_count": 42 +} +``` + +| Field | What it is | +|---|---| +| `instance_id` | A random UUID generated by your server on first use and kept in its own database. It carries no information about you — its only job is to let two reports from the same server be recognised as the same server, so a count is a count rather than a sum of duplicates. | +| `version` | The ScreenTinker version this server is running. | +| `screen_count` | How many displays have been paired with this server. | + +## What is not sent + +No hostnames, IP addresses or domains. No organization, workspace or user names. No email +addresses and no user count. No device names, locations or serial numbers. No content, filenames, +playlists or schedules. No logs and no configuration. + +The request is sent over HTTPS, and the receiving service does not record the source address. + +## Verifying that + +Rather than take the above on trust: + +- **In the product** — Settings → Install statistics shows the exact payload your server would + send, generated live from your own data, plus what it last actually sent and when. +- **In the source** — the payload is built in one function, `payload()` in + [`server/lib/telemetry.js`](../server/lib/telemetry.js). Every field that leaves your server + is in that object literal. `server/test/telemetry.test.js` fails if a field is added. +- **On the wire** — the destination is a single `POST`, overridable with `TELEMETRY_ENDPOINT`, so + you can point it at your own collector and read exactly what arrives. + +## Turning it on or off + +You are asked once, on the dashboard, if you are a platform administrator. Both answers are +remembered, so declining is permanent and you will not be asked again after an update. + +To change your mind at any time: **Settings → Install statistics**. + +Reports are sent at most once a day. Nothing is queued or retried — if your server is offline or +the request fails, that day is simply skipped. + +## Why we ask + +ScreenTinker is self-hostable, so most installs are invisible to us by design, and that is how it +should stay. The cost is that we genuinely cannot answer "how many screens run this?" — a question +that matters for arguing the project is worth continuing to build, and for deciding which players +deserve the next round of work. + +Sharing is a small, specific way to help with that. Declining is a completely reasonable answer and +changes nothing about how the product works. + +> **A note on honesty.** Because sharing is opt-in, any total we publish is a **floor** — "at least +> N screens" — never an estimate of the whole install base. Instances that opt in are not a random +> sample of those that don't, so the number is not something to extrapolate from, and we won't. + +## Running your own collector + +Set `TELEMETRY_COLLECTOR=1` and this server accepts reports at `POST /api/telemetry/report`, +storing them in a `telemetry_reports` table keyed by `instance_id`. The endpoint is inert unless +that variable is set, so a normal install never exposes it. + +Reports are upserted rather than appended — one row per install holding its latest report, not a +growing event log. diff --git a/frontend/js/api.js b/frontend/js/api.js index 0413f17..03c0665 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -266,6 +266,10 @@ export const api = { // #146: toggle the /api/status debug block exposure (platform-admin only). adminGetStatusDebug: () => request('/admin/status-debug'), adminSetStatusDebug: (enabled) => request('/admin/status-debug', { method: 'PUT', body: JSON.stringify({ enabled }) }), + // Opt-in install statistics. GET returns { state, payload, last_report } — payload is the exact + // body that would be sent, so the UI can show it rather than describe it. + adminGetTelemetry: () => request('/admin/telemetry'), + adminSetTelemetry: (enabled) => request('/admin/telemetry', { method: 'PUT', body: JSON.stringify({ enabled }) }), // Per-user workspace membership management (platform Users page modal). adminGetUserWorkspaces: (id) => request(`/admin/users/${id}/workspaces`), diff --git a/frontend/js/views/dashboard.js b/frontend/js/views/dashboard.js index a2b8cda..9708e54 100644 --- a/frontend/js/views/dashboard.js +++ b/frontend/js/views/dashboard.js @@ -1,7 +1,7 @@ import { api } from '../api.js'; import { on, off, requestScreenshot } from '../socket.js'; import { showToast } from '../components/toast.js'; -import { esc, livenessBadge } from '../utils.js'; +import { esc, livenessBadge, isPlatformAdmin } from '../utils.js'; import { t, tn } from '../i18n.js'; import * as gettingStarted from '../components/getting-started.js'; import { showDeviceOwnerQRModal } from '../components/device-owner-qr-modal.js'; @@ -290,6 +290,47 @@ function renderGroupSection(group, devices, playlists) { `; } +/* + * Asks, once, whether this install will share its screen count. Only a platform admin sees it, + * and only while the decision is genuinely unmade — BOTH answers persist, so it never returns + * after an update. Re-prompting is how telemetry earns its reputation and gets patched out. + */ +async function renderStatsPrompt(container) { + const user = JSON.parse(localStorage.getItem('user') || '{}'); + if (!isPlatformAdmin(user)) return; + + let info; + try { info = await api.adminGetTelemetry(); } catch { return; } + if (info.state !== 'unasked') return; + + const el = document.createElement('div'); + el.className = 'settings-section'; + el.style.cssText = 'margin-bottom:16px;display:flex;gap:16px;align-items:flex-start;flex-wrap:wrap'; + el.innerHTML = ` +
+ Help show how widely ScreenTinker is deployed? +

+ Because most installs are private, we can't tell how many screens are out there. Sharing + sends a random ID, the version, and how many screens you run — nothing else, ever. + You can change this any time in Settings. +

+
+
+ + +
+ `; + container.prepend(el); + + const answer = async (enabled) => { + try { await api.adminSetTelemetry(enabled); } catch { /* leave it unasked; it can ask again later */ return; } + el.remove(); + if (enabled) showToast('Thank you — sharing install statistics', 'success'); + }; + el.querySelector('#statsYes').addEventListener('click', () => answer(true)); + el.querySelector('#statsNo').addEventListener('click', () => answer(false)); +} + export function render(container) { container.innerHTML = ` + ${isSuperAdmin ? ` +
+

Install statistics

+

Loading…

+
+ ` : ''} + ${isSuperAdmin ? `

${t('settings.platform_admin_link')} ${t('nav.admin')} ${t('settings.platform_admin_page_suffix')}

` : ''}
@@ -275,6 +282,7 @@ export async function render(container) { if (isAdmin) { loadUsers(); loadWhiteLabel(); + loadTelemetry(); // Support token generator document.getElementById('generateSupportBtn')?.addEventListener('click', async () => { @@ -547,6 +555,55 @@ export async function render(container) { * Only instance-wide providers appear. An organization's provider is chosen by a customer and * must not be attachable to a platform account; the server refuses it too. */ + /* + * Install statistics. Shows the ACTUAL payload rather than a description of it — the whole + * proposition is "you can check instead of trusting us", and the code is public, so a sentence + * that didn't match the bytes would be found. Also shows what was last really sent. + */ + async function loadTelemetry() { + const box = document.getElementById('telemetryBody'); + if (!box) return; + let info; + try { info = await api.adminGetTelemetry(); } + catch { box.innerHTML = `

Unavailable.

`; return; } + + const on = info.state === 'on'; + const sent = info.last_report + ? `Last sent ${new Date(info.last_report.at * 1000).toLocaleString()}.` + : 'Nothing has been sent.'; + + box.innerHTML = ` +

+ ScreenTinker can't see how widely it's deployed, because most installs are private by + design. Sharing lets us say how many screens are running — nothing more. +

+ +

+ Everything that would be sent, in full: +

+
${esc(JSON.stringify(info.payload, null, 2))}
+

+ No names, addresses, content, or user details. The ID is random and identifies the install + only so repeat reports aren't counted twice. ${esc(sent)} +

+ `; + + document.getElementById('telemetryToggle')?.addEventListener('change', async (e) => { + const enabled = e.target.checked; + try { + await api.adminSetTelemetry(enabled); + showToast(enabled ? 'Sharing install statistics — thank you' : 'Install statistics off', 'success'); + loadTelemetry(); + } catch { + e.target.checked = !enabled; + showToast('Could not save that setting', 'error'); + } + }); + } + async function loadSsoLink() { const block = document.getElementById('ssoLinkBlock'); if (!block) return; diff --git a/server/db/database.js b/server/db/database.js index bb65649..1a9b657 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -641,6 +641,19 @@ const migrations = [ // it can do nothing and must be respected. 'ALTER TABLE devices ADD COLUMN capabilities TEXT', + // Opt-in install statistics, COLLECTOR side only — inert unless TELEMETRY_COLLECTOR=1, which + // is the hosted deployment. Keyed by instance_id and upserted rather than appended, so it is a + // table of current state ("this install last reported N screens") rather than an event log that + // grows without bound on a box nobody prunes. Answering "how many screens are deployed" needs + // the latest row per install, never the history. + `CREATE TABLE IF NOT EXISTS telemetry_reports ( + instance_id TEXT PRIMARY KEY, + version TEXT, + screen_count INTEGER NOT NULL DEFAULT 0, + first_seen INTEGER NOT NULL, + last_seen INTEGER NOT NULL + )`, + ]; // Apply each ALTER idempotently. A "duplicate column name" / "already exists" // error means the column is already present (expected on a migrated DB) - benign. diff --git a/server/lib/telemetry.js b/server/lib/telemetry.js new file mode 100644 index 0000000..40c7201 --- /dev/null +++ b/server/lib/telemetry.js @@ -0,0 +1,132 @@ +'use strict'; + +/* + * Opt-in install statistics. + * + * WHY THIS EXISTS: there is no way to answer "how many screens run ScreenTinker?" — the product is + * self-hostable by design, so most installs are invisible to us on purpose. This asks, once, and + * only reports if the operator says yes. + * + * WHAT IS SENT — the whole payload, three fields: + * + * { instance_id, version, screen_count } + * + * and nothing else. No hostnames, no addresses, no organization or user names, no device names, + * no content or filenames, no user counts. The list is short on purpose: every field added costs + * participation, and participation is the only thing that makes the resulting number worth + * quoting. Anyone can verify it — the payload is built in `payload()` below, in one place, and + * `getLastReport()` shows an operator the exact bytes last sent. + * + * `instance_id` is a random UUID generated on first use and kept in app_settings. It carries no + * information about the install; its only job is to let two reports from the same server be + * recognised as the same server, so a count is a count rather than a sum of duplicates. That does + * make a report PSEUDONYMOUS rather than anonymous, and the wording shown to operators says so. + * + * ⚠️ Restoring a backup or cloning a VM carries the id with it, so two installs report as one. + * Deliberate: under-counting is the honest failure here, and the alternative (re-identifying on + * some hardware signal) means collecting exactly the kind of thing this file promises not to. + * + * ⚠️ Opt-in populations are self-selected, so the total is a FLOOR — "at least N screens" — never + * a basis for extrapolating a fleet size. + */ + +const crypto = require('crypto'); +const appSettings = require('./app-settings'); + +const KEY_ID = 'telemetry_instance_id'; +const KEY_ENABLED = 'telemetry_enabled'; // unset = never asked +const KEY_LAST = 'telemetry_last_report'; + +const DEFAULT_ENDPOINT = 'https://stats.screentinker.com/api/telemetry/report'; +const REPORT_INTERVAL_MS = 24 * 60 * 60 * 1000; // daily; this is a count, not a metric +const FIRST_REPORT_DELAY_MS = 5 * 60 * 1000; // let boot settle before any outbound call + +let timer = null; + +/* The instance's own id, minted on first read. Stable for the life of the install. */ +function instanceId() { + let id = appSettings.get(KEY_ID, null); + if (!id) { + id = crypto.randomUUID(); + appSettings.set(KEY_ID, id); + } + return id; +} + +/* + * 'unasked' | 'on' | 'off'. The distinction matters: 'unasked' is what the prompt keys on, and a + * declined install must be remembered as 'off' rather than falling back to 'unasked' and being + * asked again on every update — re-prompting is how telemetry gets patched out by annoyed admins. + */ +function state() { + const v = appSettings.get(KEY_ENABLED, undefined); + if (v === undefined) return 'unasked'; + return (v === 'true' || v === '1') ? 'on' : 'off'; +} + +function setEnabled(enabled) { + appSettings.setBool(KEY_ENABLED, !!enabled); + return state(); +} + +/* Every field that leaves this install, built in one place so it can be audited at a glance. */ +function payload(db) { + return { + instance_id: instanceId(), + version: require('../version'), + screen_count: countScreens(db), + }; +} + +// Devices that have actually been paired — a provisioning row nobody ever connected is not a +// screen, and counting it would overstate exactly the number this exists to state honestly. +function countScreens(db) { + try { + return db.prepare('SELECT COUNT(*) AS c FROM devices WHERE device_token IS NOT NULL').get().c; + } catch (_) { + return 0; + } +} + +/* What was last sent, and when. Surfaced in Settings so an operator can check rather than trust. */ +function getLastReport() { + const raw = appSettings.get(KEY_LAST, null); + if (!raw) return null; + try { return JSON.parse(raw); } catch (_) { return null; } +} + +/* + * Send one report. Returns {sent:false, reason} rather than throwing — a stats call must never be + * able to affect the running server, so every failure path here is quiet and local. + */ +async function report(db, { endpoint = process.env.TELEMETRY_ENDPOINT || DEFAULT_ENDPOINT } = {}) { + if (state() !== 'on') return { sent: false, reason: 'not_enabled' }; + + const body = payload(db); + try { + const res = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(10000), + }); + if (!res.ok) return { sent: false, reason: `http_${res.status}`, body }; + appSettings.set(KEY_LAST, JSON.stringify({ at: Math.floor(Date.now() / 1000), body })); + return { sent: true, body }; + } catch (err) { + // Offline, DNS failure, blocked egress — all normal for a self-hosted box, none of them news. + return { sent: false, reason: err && err.name === 'TimeoutError' ? 'timeout' : 'network', body }; + } +} + +function start(db) { + if (timer) return; + const tick = () => { report(db).catch(() => {}); }; + setTimeout(tick, FIRST_REPORT_DELAY_MS).unref?.(); + timer = setInterval(tick, REPORT_INTERVAL_MS); + timer.unref?.(); // never hold the process open for a stats timer +} + +function stop() { if (timer) { clearInterval(timer); timer = null; } } + +module.exports = { instanceId, state, setEnabled, payload, report, getLastReport, start, stop }; diff --git a/server/routes/admin.js b/server/routes/admin.js index 0e4741d..1d45c62 100644 --- a/server/routes/admin.js +++ b/server/routes/admin.js @@ -426,6 +426,29 @@ router.put('/status-debug', requirePlatformAdmin, (req, res) => { res.json({ enabled }); }); +// ===================== Opt-in install statistics ===================== +// Returns the decision state, the EXACT payload that would be sent, and what was last actually +// sent. Handing over the real payload rather than a description is the point: an operator can +// check instead of trusting a sentence, and the code is public so a mismatch would be visible. +const telemetry = require('../lib/telemetry'); + +router.get('/telemetry', requirePlatformAdmin, (req, res) => { + res.json({ + state: telemetry.state(), // 'unasked' | 'on' | 'off' + payload: telemetry.payload(db), // what WOULD be sent, right now + last_report: telemetry.getLastReport(), // what was actually sent, and when + }); +}); + +router.put('/telemetry', requirePlatformAdmin, (req, res) => { + // Both answers are recorded. Declining must persist as 'off' rather than staying 'unasked', + // or the prompt returns after every update — which is how telemetry earns its bad name. + const enabled = !!req.body.enabled; + const state = telemetry.setEnabled(enabled); + logActivity(req.user.id, 'admin_set_telemetry', `enabled: ${enabled}`, null, getClientIp(req), null); + res.json({ state, payload: telemetry.payload(db) }); +}); + // ===================== Version update indicator ===================== // check-update = requireAdmin — a read-only GHCR poll, operational. // trigger-update = requirePlatformAdmin — it runs `docker compose up -d` on the diff --git a/server/server.js b/server/server.js index 981fd25..b4530da 100644 --- a/server/server.js +++ b/server/server.js @@ -977,6 +977,34 @@ app.get('/api/version', (req, res) => { // Public status page app.use('/api/status', require('./routes/status')); +/* + * Opt-in install statistics — COLLECTOR side. Inert unless TELEMETRY_COLLECTOR=1, so a normal + * self-hosted install never exposes this at all; only the deployment that gathers the numbers + * turns it on. Deliberately unauthenticated: a self-hosted instance has no credential with us, + * and issuing one would mean an enrolment handshake for what is a three-integer postcard. + * + * Upsert keyed on instance_id, so a install that reports daily occupies one row forever rather + * than 365 a year. Nothing here reads or stores the request IP — receiving one is unavoidable, + * logging it would quietly make a pseudonymous report an identifiable one. + */ +if (process.env.TELEMETRY_COLLECTOR === '1') { + app.post('/api/telemetry/report', express.json({ limit: '2kb' }), (req, res) => { + const { instance_id: id, version, screen_count: screens } = req.body || {}; + // Validate rather than trust: this endpoint is open, so a malformed or hostile body must + // land as a 400, never as a row that poisons the count it exists to produce. + if (typeof id !== 'string' || !/^[0-9a-f-]{36}$/i.test(id)) return res.status(400).json({ error: 'bad instance_id' }); + if (version != null && (typeof version !== 'string' || version.length > 40)) return res.status(400).json({ error: 'bad version' }); + if (!Number.isInteger(screens) || screens < 0 || screens > 100000) return res.status(400).json({ error: 'bad screen_count' }); + db.prepare(`INSERT INTO telemetry_reports (instance_id, version, screen_count, first_seen, last_seen) + VALUES (?, ?, ?, strftime('%s','now'), strftime('%s','now')) + ON CONFLICT(instance_id) DO UPDATE SET + version = excluded.version, screen_count = excluded.screen_count, last_seen = excluded.last_seen`) + .run(id, version || null, screens); + res.json({ ok: true }); + }); + console.log('[telemetry] collector enabled at POST /api/telemetry/report'); +} + // #146 BILLING: Usage Report on its OWN route (NOT part of /api/status — billing is revenue // data and a heavier aggregate than the hot status path). bearerAuth is the dual front door: // a 'billing:read' API token (Bearer st_...) OR a JWT session both reach it; the route's @@ -1214,6 +1242,7 @@ startContentExpiry(io); const { startAlertService } = require('./services/alerts'); startAlertService(io); + // Start activation-nudge sweep (T+3 onboarding nudge; gated on HOSTED_INSTANCE) const { startActivationNudge } = require('./services/activationNudge'); startActivationNudge(); @@ -1248,6 +1277,13 @@ process.on('SIGINT', () => gracefulShutdown('SIGINT')); // Handle provisioning via WebSocket notification const { db } = require('./db/database'); + +// Opt-in install statistics — REPORTER side. Sends nothing until an operator says yes; the timer +// is unref'd so it can never hold the process open, and every failure path is silent and local. +// Must sit AFTER the `db` binding above: `const` is hoisted but uninitialised, so calling this +// earlier in the file throws "Cannot access 'db' before initialization" at load. +require('./lib/telemetry').start(db); + const originalProvisionRoute = require('./routes/provisioning'); // #161: device-owner QR provisioning. Returns the AOSP provisioning payload (DPC component + APK diff --git a/server/test/telemetry.test.js b/server/test/telemetry.test.js new file mode 100644 index 0000000..51de6ec --- /dev/null +++ b/server/test/telemetry.test.js @@ -0,0 +1,132 @@ +'use strict'; + +// Opt-in install statistics. The promises this feature makes are all negative ones — it does not +// send until asked, it does not send more than three fields, it does not ask twice — and a +// negative promise is exactly the kind that rots silently. These bites pin each one. + +const { test, after, mock } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'telemetry-')); +process.env.DATA_DIR = tmp; +process.env.NODE_ENV = 'test'; + +const { db } = require('../db/database'); +const appSettings = require('../lib/app-settings'); +const telemetry = require('../lib/telemetry'); + +after(() => { telemetry.stop(); fs.rmSync(tmp, { recursive: true, force: true }); }); + +function reset() { + db.prepare('DELETE FROM app_settings').run(); + appSettings.__reload(); +} + +test('an install that has not been asked reports nothing', async () => { + reset(); + assert.equal(telemetry.state(), 'unasked'); + + const spy = mock.method(globalThis, 'fetch', async () => { throw new Error('must not be called'); }); + try { + const r = await telemetry.report(db); + assert.deepEqual(r, { sent: false, reason: 'not_enabled' }); + assert.equal(spy.mock.callCount(), 0, 'no outbound request may be made before consent'); + } finally { spy.mock.restore(); } +}); + +test('declining is remembered, so the prompt does not return after an update', () => { + reset(); + telemetry.setEnabled(false); + assert.equal(telemetry.state(), 'off', 'a decline must persist as off, never fall back to unasked'); + appSettings.__reload(); // survives a restart + assert.equal(telemetry.state(), 'off'); +}); + +test('a declined install still reports nothing', async () => { + reset(); + telemetry.setEnabled(false); + const spy = mock.method(globalThis, 'fetch', async () => { throw new Error('must not be called'); }); + try { + assert.deepEqual(await telemetry.report(db), { sent: false, reason: 'not_enabled' }); + assert.equal(spy.mock.callCount(), 0); + } finally { spy.mock.restore(); } +}); + +test('the payload is exactly three fields, and no more', async () => { + reset(); + const body = telemetry.payload(db); + assert.deepEqual(Object.keys(body).sort(), ['instance_id', 'screen_count', 'version'], + 'adding a field here is a privacy decision, not a refactor — it must fail this test first'); + assert.match(body.instance_id, /^[0-9a-f-]{36}$/i); + assert.equal(typeof body.version, 'string'); + assert.equal(typeof body.screen_count, 'number'); +}); + +test('the instance id is stable across reads and restarts', () => { + reset(); + const first = telemetry.instanceId(); + assert.equal(telemetry.instanceId(), first, 'must not mint a new id per call'); + appSettings.__reload(); + assert.equal(telemetry.instanceId(), first, 'must survive a restart, or every install counts twice'); +}); + +test('screen_count counts paired displays, not provisioning rows', () => { + reset(); + db.prepare('DELETE FROM devices').run(); + const ins = db.prepare("INSERT INTO devices (id, name, pairing_code, device_token, status) VALUES (?, ?, ?, ?, 'offline')"); + ins.run('d1', 'One', '111111', 'tok1'); + ins.run('d2', 'Two', '222222', 'tok2'); + // Never paired: a provisioning row nobody connected is not a deployed screen. + db.prepare("INSERT INTO devices (id, name, pairing_code, device_token, status) VALUES ('d3','Three','333333',NULL,'offline')").run(); + + assert.equal(telemetry.payload(db).screen_count, 2); + db.prepare('DELETE FROM devices').run(); +}); + +test('when enabled it sends exactly the payload, and records what it sent', async () => { + reset(); + telemetry.setEnabled(true); + + let seen = null; + const spy = mock.method(globalThis, 'fetch', async (url, opts) => { + seen = { url, body: JSON.parse(opts.body), method: opts.method }; + return { ok: true, status: 200 }; + }); + try { + const r = await telemetry.report(db, { endpoint: 'https://example.test/report' }); + assert.equal(r.sent, true); + assert.equal(seen.method, 'POST'); + assert.equal(seen.url, 'https://example.test/report'); + assert.deepEqual(Object.keys(seen.body).sort(), ['instance_id', 'screen_count', 'version'], + 'the bytes on the wire must match the audited payload, not a superset'); + + // An operator can check rather than trust: what was sent is retrievable verbatim. + const last = telemetry.getLastReport(); + assert.deepEqual(last.body, seen.body); + assert.equal(typeof last.at, 'number'); + } finally { spy.mock.restore(); } +}); + +test('a failed send is quiet and local — never throws, never records a phantom report', async () => { + reset(); + telemetry.setEnabled(true); + const spy = mock.method(globalThis, 'fetch', async () => { throw new Error('ECONNREFUSED'); }); + try { + const r = await telemetry.report(db, { endpoint: 'https://example.test/report' }); + assert.equal(r.sent, false); + assert.equal(r.reason, 'network'); + assert.equal(telemetry.getLastReport(), null, 'a failed send must not look like a successful one'); + } finally { spy.mock.restore(); } + + // An HTTP error is likewise not a success. + const spy2 = mock.method(globalThis, 'fetch', async () => ({ ok: false, status: 503 })); + try { + const r = await telemetry.report(db, { endpoint: 'https://example.test/report' }); + assert.equal(r.sent, false); + assert.equal(r.reason, 'http_503'); + assert.equal(telemetry.getLastReport(), null); + } finally { spy2.mock.restore(); } +});