mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 06:16:20 -06:00
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.
This commit is contained in:
parent
8b162ecce2
commit
e9bd8ac8af
77
docs/telemetry.md
Normal file
77
docs/telemetry.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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`),
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
<div style="flex:1;min-width:260px">
|
||||
<strong>Help show how widely ScreenTinker is deployed?</strong>
|
||||
<p style="color:var(--text-muted);font-size:13px;margin:6px 0 0">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-primary btn-sm" id="statsYes">Share</button>
|
||||
<button class="btn btn-secondary btn-sm" id="statsNo">No thanks</button>
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<div class="page-header">
|
||||
|
|
@ -432,6 +473,10 @@ export function render(container) {
|
|||
// Load everything
|
||||
loadDashboard();
|
||||
|
||||
// Ask once about sharing install statistics. Fire-and-forget: it prepends itself if and only
|
||||
// if the decision is still unmade, and a failure here must never affect the dashboard.
|
||||
renderStatsPrompt(container).catch(() => {});
|
||||
|
||||
// Real-time updates
|
||||
statusHandler = (data) => {
|
||||
const b = livenessBadge(data, { short: true }); // list = concise label; tooltip carries the full text
|
||||
|
|
|
|||
|
|
@ -169,6 +169,13 @@ export async function render(container) {
|
|||
<div id="licenseSection"><p style="color:var(--text-muted);font-size:13px">${t('settings.license_mit')}</p></div>
|
||||
</div>
|
||||
|
||||
${isSuperAdmin ? `
|
||||
<div class="settings-section" id="telemetrySection">
|
||||
<h3>Install statistics</h3>
|
||||
<div id="telemetryBody"><p style="color:var(--text-muted);font-size:13px">Loading…</p></div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${isSuperAdmin ? `<p style="font-size:12px;color:var(--text-muted);margin-bottom:12px">${t('settings.platform_admin_link')} <a href="#/admin" style="color:var(--accent)">${t('nav.admin')}</a> ${t('settings.platform_admin_page_suffix')}</p>` : ''}
|
||||
|
||||
<div class="settings-section">
|
||||
|
|
@ -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 = `<p style="color:var(--text-muted);font-size:13px">Unavailable.</p>`; 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 = `
|
||||
<p style="color:var(--text-muted);font-size:13px;margin-bottom:12px">
|
||||
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.
|
||||
</p>
|
||||
<label style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
|
||||
<input type="checkbox" id="telemetryToggle" ${on ? 'checked' : ''}>
|
||||
Share install statistics
|
||||
</label>
|
||||
<p style="color:var(--text-muted);font-size:12px;margin-bottom:6px">
|
||||
Everything that would be sent, in full:
|
||||
</p>
|
||||
<pre style="background:var(--bg-input,rgba(0,0,0,.2));padding:10px;border-radius:var(--radius);font-size:12px;overflow-x:auto;margin-bottom:8px">${esc(JSON.stringify(info.payload, null, 2))}</pre>
|
||||
<p style="color:var(--text-muted);font-size:12px">
|
||||
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)}
|
||||
</p>
|
||||
`;
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
132
server/lib/telemetry.js
Normal file
132
server/lib/telemetry.js
Normal file
|
|
@ -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 };
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
132
server/test/telemetry.test.js
Normal file
132
server/test/telemetry.test.js
Normal file
|
|
@ -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(); }
|
||||
});
|
||||
Loading…
Reference in a new issue