Files
webmail/lib/desktop/desktop-telemetry.ts
T
Linus Rath 3d5b02b75f fix: stop a page reporting once consent goes, and let nothing on disk aim the desktop app's telemetry
A review found that an open page kept sending to a DSN after consent was
taken back, that a state file or config file could name where telemetry
goes, and that `BULWARK_TELEMETRY=off` did nothing on the desktop build.
A due heartbeat now waits a random 30 s to 10 min, a refusal from the
collector isn't retried, crash files go at sign-out and account switch,
and server builds parse stacks as they did.

Existing assertions changed in the desktop telemetry tests: the catch-up
wait is now a range, the endpoint comes from the shell, and the platform
is `desktop` against the collector's patched branch.
2026-11-03 17:38:04 +01:00

166 lines
6.3 KiB
TypeScript

/**
* What the desktop app's telemetry and crash reports agree on across the webmail and
* the shell: when consent counts, what a heartbeat from the shell holds, and when one
* is due.
*
* No DOM, no Node, no imports: the desktop repo keeps a byte-for-byte copy in
* src/shared/desktop-telemetry.ts. Main can't import lib/telemetry, which needs the
* store and node:dns, and in instance mode no server runs inside the app. It holds no
* host name either: the shell has its own constant, and lib/telemetry/types.ts has this
* side's.
*
* The collector decides the shape (repos/dashboard/telemetry-collector/src/validate.ts).
* Schema 1 drops fields it doesn't know and schema 2 refuses the whole heartbeat over
* one, so mode and channel travel in `build`, the one free field it keeps. It stores
* `platform: 'desktop'` as `unknown` until it has been patched.
*/
export type DesktopTelemetryMode = 'standalone' | 'instance';
export type DesktopTelemetryChannel = 'stable' | 'beta';
export type DesktopOsFamily = 'linux' | 'darwin' | 'windows' | 'unknown';
/** What main tells the embedded server, by message. Nothing in a page can write it. */
export interface DesktopServerPrivacy {
telemetry: boolean;
crashReports: boolean;
/** The desktop app's version. The webmail's own is in `NEXT_PUBLIC_APP_VERSION`. */
appVersion: string;
/** `desktop.<mode>.<channel>`. */
build: string;
/** Only a test build of the shell sets this. Nothing on disk can: see `telemetryEndpoint`. */
endpoint?: string;
}
/**
* How soon after a start a heartbeat that's due goes out. The server's own hour is right
* for a server: an app quit within an hour of every start would never send. Random, so
* the collector can't read when someone starts their computer from when it arrives.
*/
export const DESKTOP_CATCH_UP_MIN_MS = 30_000;
export const DESKTOP_CATCH_UP_MAX_MS = 600_000;
export function desktopCatchUpMs(random: () => number = Math.random): number {
return DESKTOP_CATCH_UP_MIN_MS + Math.floor(random() * (DESKTOP_CATCH_UP_MAX_MS - DESKTOP_CATCH_UP_MIN_MS));
}
/** A failed send tries again this much later, not a day later. */
export const DESKTOP_RETRY_MS = 60 * 60_000;
export const DESKTOP_HEARTBEAT_INTERVAL_MS = 24 * 60 * 60_000;
/** The start of a main-process frame's path. Only the shell's own bundle gets it. */
export const DESKTOP_FRAME_PREFIX = '/desktop/';
/** On the version of everything a desktop app reports. The collector's version field allows it. */
export const DESKTOP_VERSION_SUFFIX = '+desktop';
/** A policy that forces something off. A recommendation doesn't: the user's choice beats it. */
export function forcedOffByPolicy(entry: { value: boolean; locked: boolean }): boolean {
return entry.locked && entry.value === false;
}
/**
* Whether something opt-in is on. Only a yes counts: no answer is off, and a policy
* allowing it still leaves the question to the user.
*/
export function consentInForce(choice: boolean | null | undefined, policy: { value: boolean; locked: boolean }): boolean {
return choice === true && !forcedOffByPolicy(policy);
}
export function desktopBuildTag(mode: DesktopTelemetryMode, channel: DesktopTelemetryChannel): string {
return `desktop.${mode}.${channel}`;
}
/** Forced beats the user's choice, which beats what the version says. */
export function desktopChannel(
policy: { value: DesktopTelemetryChannel; locked: boolean },
choice: unknown,
appVersion: string,
): DesktopTelemetryChannel {
if (policy.locked) return policy.value;
if (choice === 'stable' || choice === 'beta') return choice;
return appVersion.includes('-') ? 'beta' : 'stable';
}
export function desktopOsFamily(platform: string): DesktopOsFamily {
if (platform === 'win32') return 'windows';
if (platform === 'darwin') return 'darwin';
if (platform === 'linux') return 'linux';
return 'unknown';
}
/** How long until the next heartbeat, given when the last one went out. */
export function heartbeatDelayMs(lastSentAt: number | null | undefined, now: number, catchUp: number = desktopCatchUpMs()): number {
if (!lastSentAt || lastSentAt > now) return catchUp;
const left = lastSentAt + DESKTOP_HEARTBEAT_INTERVAL_MS - now;
return left > 0 ? left : catchUp;
}
export interface DesktopPingInput {
/** The shell's own UUID, never an instance's. */
instanceId: string;
appVersion: string;
nodeVersion: string;
osFamily: DesktopOsFamily;
mode: DesktopTelemetryMode;
channel: DesktopTelemetryChannel;
now: number;
}
/**
* The shell's own heartbeat, for when no server runs inside the app. Features and counts
* are the instance's to report, so these are all empty and nothing is counted twice.
*/
export function buildDesktopPing(input: DesktopPingInput) {
return {
schema: '1' as const,
instance_id: input.instanceId,
ts: new Date(input.now).toISOString(),
version: input.appVersion,
build: desktopBuildTag(input.mode, input.channel),
platform: 'desktop',
node_version: input.nodeVersion,
os_family: input.osFamily,
stalwart_version: null,
features: { calendar: false, contacts: false, files: false, extensions: false, oauth_enabled: false, smime_enabled: false },
counts: { accounts: '0', accounts_active_7d: '0', extensions_installed: 0, themes_installed: 0 },
uptime_days: 0,
};
}
export function desktopErrorCountBucket(count: number): string {
if (count <= 1) return '1';
if (count <= 5) return '2-5';
if (count <= 10) return '6-10';
if (count <= 50) return '11-50';
if (count <= 200) return '51-200';
return '201+';
}
export interface DesktopMainError {
name: string;
/** 16 hex digits. */
fingerprint: string;
version: string;
count: number;
}
/**
* Main's errors when no server runs inside the app: the collector's /v1/errors takes
* this without a heartbeat, so it works with telemetry off. Name and count only. The
* collector refuses a whole batch over a frame outside the webmail's bundles.
*/
export function buildDesktopErrorBatch(instanceId: string, now: number, errors: readonly DesktopMainError[]) {
return {
schema: '1' as const,
instance_id: instanceId,
day: new Date(now).toISOString().slice(0, 10),
errors: errors.map((error) => ({
name: error.name,
fingerprint: error.fingerprint,
frames: [],
version: error.version,
count: desktopErrorCountBucket(error.count),
days: 1,
})),
};
}