mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 06:16:20 -06:00
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.
133 lines
5.2 KiB
JavaScript
133 lines
5.2 KiB
JavaScript
'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 };
|