mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 14:23:14 -06:00
Three changes, all about the same failure: sharing appears to be on while nothing actually arrives. SEND ON OPT-IN. Turning sharing on now reports immediately instead of waiting for the next daily tick. Two reasons: the operator is standing right there, and "nothing has been sent" for the next 24h reads as broken at exactly the moment someone is checking whether it works. It also means an egress-filtered network fails HERE, where we can name the host to unblock, rather than silently tonight where nobody is watching. NAME THE FAILURE. Failed attempts are now recorded separately from successes, so Settings can say which address did not answer and why, instead of showing an empty "nothing sent yet". A blocked outbound connection is the normal failure on a self-hosted box and is otherwise completely invisible — the operator cannot tell a firewall from a broken feature. A later success clears the complaint, so a stale warning never outlives the problem it describes. Docs gained a section on it, and the UI states plainly that nothing needs opening inbound. OPERATOR COLLECTOR, ADDITIVE. TELEMETRY_EXTRA_ENDPOINT lets an operator post the same three fields to their own collector. The naming is the point. It replaces TELEMETRY_ENDPOINT, which was a true override — and an override is the wrong shape here, because a variable called "endpoint" that silently redirected the report someone agreed to SHARE would make the opt-in mean something other than what the UI says. Our address is hard-wired and not overridable; theirs is explicitly additional and named so it cannot be mistaken for a replacement. Settings lists every destination a report goes to. The operator collector is independent of the sharing switch, because it is their server posting to their host and our opt-in has no business gating it. So an operator who wants internal fleet numbers with nothing leaving for us sets it and leaves sharing off — supported on purpose, and tested. Destinations are attempted separately: one unreachable collector must not cost the other its report. 1662/1662 pass. Tests pin the properties that matter: an operator collector never replaces the shared report, sharing-off still sends nothing to us whatever else is configured, one dead destination does not stop the other, and a failure records the address actually tried.
201 lines
8.7 KiB
JavaScript
201 lines
8.7 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'; // last SUCCESSFUL send
|
|
const KEY_LAST_ERROR = 'telemetry_last_error';// last FAILED attempt — see getLastError
|
|
|
|
/*
|
|
* Where reports go. TWO independent destinations, deliberately:
|
|
*
|
|
* SCREENTINKER_ENDPOINT hard-wired, and reached only when the operator has switched sharing on.
|
|
* Not overridable — an "override" that silently redirected the shared
|
|
* report would make the opt-in mean something different from what it says.
|
|
*
|
|
* TELEMETRY_EXTRA_ENDPOINT an operator's OWN collector, for their own fleet numbers. Additional,
|
|
* never a replacement, and named so it cannot be mistaken for one. It is
|
|
* sent independently of the sharing toggle: it is their server posting to
|
|
* their host, so our opt-in has no business gating it. An operator who
|
|
* wants internal statistics and nothing leaving for us sets this and
|
|
* leaves sharing off — that combination is supported on purpose.
|
|
*/
|
|
const SCREENTINKER_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;
|
|
}
|
|
}
|
|
|
|
/* The address an operator may need to allowlist for the shared report. Hard-wired. */
|
|
function endpoint() { return SCREENTINKER_ENDPOINT; }
|
|
|
|
/* The operator's own collector, if they configured one. Null when they have not. */
|
|
function extraEndpoint() { return process.env.TELEMETRY_EXTRA_ENDPOINT || null; }
|
|
|
|
/*
|
|
* Everywhere this report is going, right now, and why — so the UI can list every destination
|
|
* rather than implying there is only one. Sharing gates OUR endpoint alone.
|
|
*/
|
|
function destinations() {
|
|
const out = [];
|
|
if (state() === 'on') out.push({ url: endpoint(), kind: 'screentinker' });
|
|
const extra = extraEndpoint();
|
|
if (extra) out.push({ url: extra, kind: 'extra' });
|
|
return out;
|
|
}
|
|
|
|
/* 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; }
|
|
}
|
|
|
|
/*
|
|
* The last FAILED attempt, kept separately from the last success.
|
|
*
|
|
* A self-hosted server frequently sits behind egress filtering, so "enabled but nothing arrives"
|
|
* is the normal failure and it is otherwise completely silent — the operator sees "nothing has
|
|
* been sent" and has no way to tell a blocked firewall from a broken feature. Recording the
|
|
* failure lets the UI name the host that needs unblocking instead.
|
|
*/
|
|
function getLastError() {
|
|
const raw = appSettings.get(KEY_LAST_ERROR, null);
|
|
if (!raw) return null; // '' is how a success clears it
|
|
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 postTo(url, body) {
|
|
try {
|
|
const res = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
signal: AbortSignal.timeout(10000),
|
|
});
|
|
return res.ok ? { sent: true } : { sent: false, reason: `http_${res.status}` };
|
|
} catch (err) {
|
|
// Offline, DNS failure, blocked egress — all normal for a self-hosted box, none of them news
|
|
// in the log, but all worth surfacing in the UI so the operator can act on it.
|
|
return { sent: false, reason: err && err.name === 'TimeoutError' ? 'timeout' : 'network' };
|
|
}
|
|
}
|
|
|
|
async function report(db, { urls = null } = {}) {
|
|
// `urls` is a test seam. Normal callers get destinations() — sharing gates ours, an operator's
|
|
// own collector is independent of it.
|
|
const targets = urls || destinations();
|
|
if (!targets.length) return { sent: false, reason: 'not_enabled', results: [] };
|
|
|
|
const now = () => Math.floor(Date.now() / 1000);
|
|
const body = payload(db);
|
|
|
|
// Every destination is attempted, independently. One unreachable collector must not stop the
|
|
// other from receiving — a blocked corporate firewall on their host should not cost us the
|
|
// shared count, and our endpoint being down should not cost them their own fleet numbers.
|
|
const results = [];
|
|
for (const t of targets) results.push({ ...t, ...(await postTo(t.url, body)) });
|
|
|
|
const failed = results.filter(r => !r.sent);
|
|
if (results.some(r => r.sent)) appSettings.set(KEY_LAST, JSON.stringify({ at: now(), body, results }));
|
|
// Keep only a LIVE complaint: record what is still failing, and clear it once nothing is.
|
|
appSettings.set(KEY_LAST_ERROR, failed.length
|
|
? JSON.stringify({ at: now(), reason: failed[0].reason, url: failed[0].url, failed })
|
|
: '');
|
|
|
|
return { sent: failed.length === 0, results, body, reason: failed[0]?.reason };
|
|
}
|
|
|
|
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, endpoint, extraEndpoint, destinations, getLastReport, getLastError, start, stop };
|