screentinker/server/test/telemetry.test.js
screentinker e9bd8ac8af
Opt-in install statistics (#267)
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 — and
should stay that way. This asks once, and reports only if the operator says yes.

The entire payload is three fields:

    { instance_id, version, screen_count }

instance_id is a random UUID minted on first use and kept in app_settings. It
carries nothing about the install; its only job is to let two reports from the
same server be recognised as one server, so a count is a count rather than a sum
of duplicates. That makes a report pseudonymous rather than anonymous, and the
wording shown to operators says so rather than claiming otherwise.

The payload is short on purpose. Every field added costs participation, and
participation is the only thing that makes the resulting number worth quoting.
Player-platform counts were considered and left out: release assets are already
published per platform, so GitHub's per-asset download counts answer "where should
effort go" at zero privacy cost and without asking anyone for anything.

Verifiability is the feature, not the copy. Settings shows the ACTUAL payload this
server would send, generated live from its own data, plus what it last really sent
and when. The payload is built in one function so a reviewer can check it at a
glance, and the test fails if a field is ever added.

Both answers persist. Declining is remembered as 'off' rather than falling back to
'unasked', so the prompt cannot return after an update — re-prompting is how
telemetry earns its reputation and gets patched out.

Collector side is inert unless TELEMETRY_COLLECTOR=1, so a normal install never
exposes the endpoint. Reports upsert on instance_id rather than appending, so an
install reporting daily occupies one row rather than 365 a year. The source IP is
never read or stored — receiving one is unavoidable, logging it would quietly turn
a pseudonymous report into an identifiable one.

Tests pin the negative promises, which are the ones that rot silently: sends
nothing before consent, sends nothing after a decline, payload is exactly three
keys, id survives a restart, a failed send never records a phantom report. Screen
count excludes unpaired provisioning rows, which would otherwise overstate the one
number this exists to state honestly.

docs/telemetry.md documents the payload, what is not sent, how to verify it, and
that any published total is a floor rather than a basis for extrapolation.

1657/1657 pass.
2026-08-13 17:27:10 -05:00

133 lines
5.4 KiB
JavaScript

'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(); }
});