codeburn/app/electron/telemetry.ts
ozymandiashh db39157ea6 fix(telemetry): cap cli_error per kind per day, flush app_close on quit
Two robustness fixes from the #741 telemetry audit:

- cli_error had no rate limit: one install emitted 804 timeout events in a
  single day (56% of all events ever received). track() now caps cli_error
  at 20 per kind per calendar day. The day and per-kind counts persist in
  the telemetry state file (defensively loaded), so app relaunches within
  the same day cannot reset the budget; other event names are unaffected.

- app_close almost never fired (1 close vs 48 opens): the quit path raced
  the outbound POST. before-quit now defers quit once, tracks the close and
  flushes with a bounded wait (1500ms race), then quits. Every step of the
  handler is independently guarded so a synchronous failure can neither
  skip the flush nor wedge quitting, and a full queue evicts its oldest
  event rather than dropping app_close. Disabled or not-onboarded telemetry
  quits instantly.
2026-07-18 23:06:30 +03:00

297 lines
11 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
// Anonymous, consent-gated product telemetry for the desktop app ONLY.
// Runs entirely in the Electron main process. Like cli.ts, this module must
// NOT import `electron` so it stays unit-testable in plain node; main.ts
// injects the electron-derived bits (userData path, country, isPackaged).
//
// Privacy invariants (enforced here, not by the caller):
// - Nothing is ever sent before the user completes the onboarding consent
// screen, and nothing is sent while the toggle is off.
// - EU/EEA/UK/CH installs default the toggle OFF; everywhere else defaults ON.
// Either way the user decides on the consent screen.
// - The only identifier is a random UUID minted locally. No fingerprinting.
// - Events carry day-granularity timestamps only, and props pass a whitelist
// sanitizer (short strings / finite numbers / booleans, capped arrays).
// - Dev / unpackaged builds never send (CODEBURN_TELEMETRY_DEV=1 overrides
// for end-to-end testing).
export const TELEMETRY_ENDPOINT = 'https://api.codeburn.app/v1/telemetry'
export const TELEMETRY_SCHEMA = 1
// EU-27 + EEA (IS, LI, NO) + UK + CH: conservative "default off" region.
const DEFAULT_OFF_COUNTRIES = new Set([
'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR',
'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK',
'SI', 'ES', 'SE', 'IS', 'LI', 'NO', 'GB', 'CH',
])
export function defaultEnabledFor(country: string | null | undefined): boolean {
if (!country) return false // unknown region: be conservative
return !DEFAULT_OFF_COUNTRIES.has(country.toUpperCase())
}
export const EVENT_NAMES = new Set([
'app_open',
'app_close',
'section_view',
'cold_start',
'usage_snapshot',
'cli_error',
])
const MAX_QUEUE = 200
const MAX_CLI_ERRORS_PER_KIND_PER_DAY = 20
const MAX_STRING = 64
const MAX_ARRAY = 12
const MAX_KEYS = 16
export type TelemetryStatus = {
installId: string
country: string | null
enabled: boolean
defaultEnabled: boolean
/** True once the user has been through the onboarding consent screen. */
onboarded: boolean
}
type PersistedState = {
version: 1
installId: string
enabled: boolean
onboardedAt?: string
lastSnapshotDay?: string
cliErrorDay?: string
cliErrorCounts?: Record<string, number>
}
type Deps = {
/** Directory for the consent/state file (Electron userData in production). */
stateDir: string
/** ISO-3166 alpha-2 from the OS locale, or null when unknown. */
country: string | null
/** Only packaged builds send (unless CODEBURN_TELEMETRY_DEV=1). */
isPackaged: boolean
appVersion: string
platform?: string
arch?: string
endpoint?: string
fetchFn?: typeof fetch
now?: () => Date
}
type QueuedEvent = { name: string; day: string; props: Record<string, unknown> }
function dayKey(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
function loadCliErrorBudget(day: unknown, counts: unknown): Pick<PersistedState, 'cliErrorDay' | 'cliErrorCounts'> {
if (typeof day !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(day)) return {}
if (!counts || typeof counts !== 'object' || Array.isArray(counts)) return {}
const cleanCounts = Object.create(null) as Record<string, number>
for (const [kind, count] of Object.entries(counts)) {
if (!Number.isInteger(count) || (count as number) < 0 || (count as number) > MAX_CLI_ERRORS_PER_KIND_PER_DAY) return {}
cleanCounts[kind] = count as number
}
return { cliErrorDay: day, cliErrorCounts: cleanCounts }
}
function sanitizeValue(value: unknown): unknown | undefined {
if (typeof value === 'string') return value.slice(0, MAX_STRING)
if (typeof value === 'number') return Number.isFinite(value) ? value : undefined
if (typeof value === 'boolean') return value
return undefined
}
/** Whitelist sanitizer: primitives, plus one level of arrays-of-flat-objects. */
export function sanitizeProps(props: unknown): Record<string, unknown> {
if (!props || typeof props !== 'object' || Array.isArray(props)) return {}
const out: Record<string, unknown> = {}
let keys = 0
for (const [key, value] of Object.entries(props as Record<string, unknown>)) {
if (keys >= MAX_KEYS) break
const k = key.slice(0, MAX_STRING)
if (Array.isArray(value)) {
const items: Record<string, unknown>[] = []
for (const entry of value.slice(0, MAX_ARRAY)) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue
const flat: Record<string, unknown> = {}
let inner = 0
for (const [ik, iv] of Object.entries(entry as Record<string, unknown>)) {
if (inner >= MAX_KEYS) break
const sv = sanitizeValue(iv)
if (sv === undefined) continue
flat[ik.slice(0, MAX_STRING)] = sv
inner++
}
if (Object.keys(flat).length > 0) items.push(flat)
}
if (items.length > 0) { out[k] = items; keys++ }
continue
}
const sv = sanitizeValue(value)
if (sv === undefined) continue
out[k] = sv
keys++
}
return out
}
export class Telemetry {
private readonly deps: Required<Pick<Deps, 'stateDir' | 'country' | 'isPackaged' | 'appVersion'>> & Deps
private state: PersistedState
private queue: QueuedEvent[] = []
private openedAt: number
constructor(deps: Deps) {
this.deps = deps
this.state = this.load()
this.openedAt = Date.now()
}
private stateFile(): string {
return join(this.deps.stateDir, 'telemetry.v1.json')
}
private load(): PersistedState {
try {
const raw = JSON.parse(readFileSync(this.stateFile(), 'utf-8')) as Partial<PersistedState>
if (raw && raw.version === 1 && typeof raw.installId === 'string' && typeof raw.enabled === 'boolean') {
return {
version: 1,
installId: raw.installId,
enabled: raw.enabled,
onboardedAt: typeof raw.onboardedAt === 'string' ? raw.onboardedAt : undefined,
lastSnapshotDay: typeof raw.lastSnapshotDay === 'string' ? raw.lastSnapshotDay : undefined,
...loadCliErrorBudget(raw.cliErrorDay, raw.cliErrorCounts),
}
}
} catch { /* first run or unreadable — start fresh */ }
return { version: 1, installId: randomUUID(), enabled: defaultEnabledFor(this.deps.country) }
}
private save(): void {
try {
const file = this.stateFile()
if (!existsSync(dirname(file))) mkdirSync(dirname(file), { recursive: true })
writeFileSync(file, JSON.stringify(this.state), { mode: 0o600 })
} catch { /* consent must still work in-memory */ }
}
status(): TelemetryStatus {
return {
installId: this.state.installId,
country: this.deps.country,
enabled: this.state.enabled,
defaultEnabled: defaultEnabledFor(this.deps.country),
onboarded: this.state.onboardedAt !== undefined,
}
}
setEnabled(enabled: boolean): TelemetryStatus {
this.state.enabled = enabled
// Opting out mints a fresh id so past and future data cannot be linked.
if (!enabled) {
this.queue = []
this.state.installId = randomUUID()
}
this.save()
return this.status()
}
/** The onboarding consent screen's final decision. Unlocks sending. */
completeOnboarding(enabled: boolean): TelemetryStatus {
this.state.onboardedAt = new Date().toISOString()
const next = this.setEnabled(enabled)
this.track('app_open', {})
return next
}
private get canSend(): boolean {
if (!this.state.enabled || this.state.onboardedAt === undefined) return false
return this.deps.isPackaged || process.env.CODEBURN_TELEMETRY_DEV === '1'
}
/** Queue an event. Unknown names and junk props are dropped, never thrown. */
track(name: string, props: unknown): void {
if (!EVENT_NAMES.has(name)) return
if (!this.state.enabled) return
// usage_snapshot is an aggregate: at most one per calendar day.
const now = (this.deps.now ?? (() => new Date()))()
const day = dayKey(now)
if (name === 'usage_snapshot') {
if (this.state.lastSnapshotDay === day) return
this.state.lastSnapshotDay = day
this.save()
}
if (this.queue.length >= MAX_QUEUE) {
if (name !== 'app_close') return
this.queue.shift()
}
const sanitizedProps = sanitizeProps(props)
if (name === 'cli_error') {
if (this.state.cliErrorDay !== day) {
this.state.cliErrorDay = day
this.state.cliErrorCounts = Object.create(null) as Record<string, number>
}
const counts = this.state.cliErrorCounts ?? (this.state.cliErrorCounts = Object.create(null) as Record<string, number>)
const kind = typeof sanitizedProps.kind === 'string' ? sanitizedProps.kind : ''
const count = Object.prototype.hasOwnProperty.call(counts, kind) ? counts[kind]! : 0
if (count >= MAX_CLI_ERRORS_PER_KIND_PER_DAY) return
counts[kind] = count + 1
this.save()
}
this.queue.push({ name, day, props: sanitizedProps })
}
/** Record session duration; queued for the next (final) flush. */
trackClose(): void {
this.track('app_close', { sessionMinutes: Math.round((Date.now() - this.openedAt) / 60_000) })
}
/** Best-effort batch POST. Keeps the queue on failure, clears on success. */
async flush(): Promise<boolean> {
if (!this.canSend || this.queue.length === 0) return false
const events = this.queue
const body = JSON.stringify({
schema: TELEMETRY_SCHEMA,
installId: this.state.installId,
app: {
name: 'codeburn-desktop',
version: this.deps.appVersion,
platform: this.deps.platform ?? process.platform,
arch: this.deps.arch ?? process.arch,
country: this.deps.country,
},
events,
})
try {
const fetchFn = this.deps.fetchFn ?? fetch
const res = await fetchFn(this.deps.endpoint ?? TELEMETRY_ENDPOINT, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body,
})
if (!res.ok) {
// 4xx is a permanent rejection of this batch (schema drift, bad shape):
// retrying the same payload forever would wedge the queue at its cap.
// Drop it. 5xx/network are transient — keep the batch for the next beat.
if (res.status >= 400 && res.status < 500) this.queue = this.queue.filter(e => !events.includes(e))
return false
}
// Only drop what was sent; events tracked mid-flight stay queued.
this.queue = this.queue.filter(e => !events.includes(e))
return true
} catch {
return false
}
}
/** Visible for tests. */
get queueLength(): number {
return this.queue.length
}
}