codeburn/app/electron/telemetry.test.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

260 lines
11 KiB
TypeScript

// @vitest-environment node
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defaultEnabledFor, Telemetry } from './telemetry'
let dir: string
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'cb-telemetry-'))
delete process.env.CODEBURN_TELEMETRY_DEV
})
afterEach(() => {
rmSync(dir, { recursive: true, force: true })
delete process.env.CODEBURN_TELEMETRY_DEV
})
function make(over: Partial<ConstructorParameters<typeof Telemetry>[0]> = {}) {
const posts: Array<{ url: string; body: unknown }> = []
const fetchFn = vi.fn(async (url: unknown, init?: { body?: unknown }) => {
posts.push({ url: String(url), body: JSON.parse(String(init?.body)) })
return { ok: true } as Response
}) as unknown as typeof fetch
const telemetry = new Telemetry({
stateDir: dir,
country: 'US',
isPackaged: true,
appVersion: '1.0.0',
fetchFn,
...over,
})
return { telemetry, posts, fetchFn }
}
describe('regional consent defaults', () => {
it('defaults OFF in the EU/EEA/UK/CH and for unknown regions, ON elsewhere', () => {
for (const c of ['DE', 'FR', 'NL', 'SE', 'GB', 'CH', 'NO', 'IS']) expect(defaultEnabledFor(c), c).toBe(false)
for (const c of ['US', 'CA', 'JP', 'AU', 'BR', 'IN']) expect(defaultEnabledFor(c), c).toBe(true)
expect(defaultEnabledFor(null)).toBe(false)
expect(defaultEnabledFor(undefined)).toBe(false)
})
it('a fresh EU install starts disabled; a fresh US install starts enabled', () => {
const eu = new Telemetry({ stateDir: join(dir, 'eu'), country: 'DE', isPackaged: true, appVersion: '1' })
expect(eu.status()).toMatchObject({ enabled: false, defaultEnabled: false, onboarded: false })
const us = new Telemetry({ stateDir: join(dir, 'us'), country: 'US', isPackaged: true, appVersion: '1' })
expect(us.status()).toMatchObject({ enabled: true, defaultEnabled: true, onboarded: false })
})
})
describe('consent gating', () => {
it('never sends before onboarding completes, even when enabled', async () => {
const { telemetry, posts } = make()
telemetry.track('app_open', {})
expect(await telemetry.flush()).toBe(false)
expect(posts.length).toBe(0)
})
it('sends after onboarding, and stops (dropping the queue) when disabled', async () => {
const { telemetry, posts } = make()
telemetry.completeOnboarding(true)
telemetry.track('section_view', { section: 'spend' })
expect(await telemetry.flush()).toBe(true)
expect(posts.length).toBe(1)
telemetry.setEnabled(false)
telemetry.track('section_view', { section: 'models' })
expect(telemetry.queueLength).toBe(0)
expect(await telemetry.flush()).toBe(false)
expect(posts.length).toBe(1)
})
it('opting out rotates the install id so history cannot be linked', () => {
const { telemetry } = make()
const before = telemetry.status().installId
telemetry.setEnabled(false)
const after = telemetry.status().installId
expect(after).not.toBe(before)
})
it('unpackaged (dev) builds never send unless CODEBURN_TELEMETRY_DEV=1', async () => {
const { telemetry, posts } = make({ isPackaged: false })
telemetry.completeOnboarding(true)
expect(await telemetry.flush()).toBe(false)
expect(posts.length).toBe(0)
process.env.CODEBURN_TELEMETRY_DEV = '1'
telemetry.track('app_open', {})
expect(await telemetry.flush()).toBe(true)
expect(posts.length).toBe(1)
})
it('persists consent + install id across instances', () => {
const { telemetry } = make()
const id = telemetry.completeOnboarding(true).installId
const reloaded = new Telemetry({ stateDir: dir, country: 'US', isPackaged: true, appVersion: '1' })
expect(reloaded.status()).toMatchObject({ installId: id, enabled: true, onboarded: true })
const raw = JSON.parse(readFileSync(join(dir, 'telemetry.v1.json'), 'utf-8'))
expect(raw.installId).toBe(id)
})
})
describe('events', () => {
it('drops unknown event names and sanitizes props', async () => {
const { telemetry, posts } = make()
telemetry.completeOnboarding(true) // queues app_open
telemetry.track('totally_made_up', { a: 1 })
telemetry.track('section_view', {
section: 'x'.repeat(500),
junk: { nested: 'object' },
fn: () => {},
nan: NaN,
ok: 42,
})
await telemetry.flush()
const body = posts[0]!.body as { events: Array<{ name: string; day: string; props: Record<string, unknown> }> }
expect(body.events.map(e => e.name)).toEqual(['app_open', 'section_view'])
const props = body.events[1]!.props
expect((props.section as string).length).toBe(64)
expect(props.junk).toBeUndefined()
expect(props.fn).toBeUndefined()
expect(props.nan).toBeUndefined()
expect(props.ok).toBe(42)
// Day-granularity timestamps only.
expect(body.events[0]!.day).toMatch(/^\d{4}-\d{2}-\d{2}$/)
})
it('stamps events with the LOCAL calendar day, not the UTC day', async () => {
const savedTZ = process.env.TZ
process.env.TZ = 'America/Los_Angeles' // UTC-07/08
try {
// 05:00Z is still the previous local day in LA (22:00). UTC-bucketing would
// stamp 2026-07-17; the local calendar day is 2026-07-16.
const instant = new Date('2026-07-17T05:00:00Z')
const { telemetry, posts } = make({ now: () => instant })
telemetry.completeOnboarding(true) // queues app_open at `instant`
await telemetry.flush()
const body = posts[0]!.body as { events: Array<{ day: string }> }
expect(body.events[0]!.day).toBe('2026-07-16')
} finally {
if (savedTZ === undefined) delete process.env.TZ
else process.env.TZ = savedTZ
}
})
it('caps usage_snapshot at one per calendar day', () => {
const { telemetry } = make()
telemetry.completeOnboarding(true)
const before = telemetry.queueLength
telemetry.track('usage_snapshot', { costBucket: '1-10' })
telemetry.track('usage_snapshot', { costBucket: '10-50' })
expect(telemetry.queueLength).toBe(before + 1)
})
it('caps cli_error per kind per local day while leaving other events unaffected', async () => {
let instant = new Date('2026-07-17T12:00:00')
const { telemetry, posts } = make({ now: () => instant })
telemetry.completeOnboarding(true)
for (let i = 0; i < 25; i++) telemetry.track('cli_error', { kind: 'timeout', cmd: 'status' })
telemetry.track('cli_error', { kind: 'not-found', cmd: 'status' })
telemetry.track('section_view', { section: 'spend' })
expect(telemetry.queueLength).toBe(23) // app_open + 20 timeout + one other kind + one other event
instant = new Date('2026-07-18T12:00:00')
telemetry.track('cli_error', { kind: 'timeout', cmd: 'status' })
expect(telemetry.queueLength).toBe(24)
await telemetry.flush()
const body = posts[0]!.body as { events: Array<{ name: string; day: string; props: Record<string, unknown> }> }
const timeoutEvents = body.events.filter(event => event.name === 'cli_error' && event.props.kind === 'timeout')
expect(timeoutEvents.filter(event => event.day === '2026-07-17')).toHaveLength(20)
expect(timeoutEvents.filter(event => event.day === '2026-07-18')).toHaveLength(1)
expect(body.events.filter(event => event.name === 'cli_error' && event.props.kind === 'not-found')).toHaveLength(1)
expect(body.events.filter(event => event.name === 'section_view')).toHaveLength(1)
})
it('persists the cli_error cap across restarts on the same local day', () => {
const instant = new Date('2026-07-17T12:00:00')
const { telemetry } = make({ now: () => instant })
telemetry.completeOnboarding(true)
for (let i = 0; i < 20; i++) telemetry.track('cli_error', { kind: 'timeout', cmd: 'status' })
const reloaded = new Telemetry({ stateDir: dir, country: 'US', isPackaged: true, appVersion: '1', now: () => instant })
reloaded.track('cli_error', { kind: 'timeout', cmd: 'status' })
expect(reloaded.queueLength).toBe(0)
reloaded.track('cli_error', { kind: 'not-found', cmd: 'status' })
expect(reloaded.queueLength).toBe(1)
const raw = JSON.parse(readFileSync(join(dir, 'telemetry.v1.json'), 'utf-8'))
expect(raw).toMatchObject({ cliErrorDay: '2026-07-17', cliErrorCounts: { timeout: 20, 'not-found': 1 } })
})
it('falls back to fresh cli_error counters when the persisted budget is malformed', () => {
const instant = new Date('2026-07-17T12:00:00')
const { telemetry } = make({ now: () => instant })
telemetry.completeOnboarding(true)
const stateFile = join(dir, 'telemetry.v1.json')
const raw = JSON.parse(readFileSync(stateFile, 'utf-8'))
writeFileSync(stateFile, JSON.stringify({ ...raw, cliErrorDay: '2026-07-17', cliErrorCounts: { timeout: 'many' } }))
const reloaded = new Telemetry({ stateDir: dir, country: 'US', isPackaged: true, appVersion: '1', now: () => instant })
for (let i = 0; i < 25; i++) reloaded.track('cli_error', { kind: 'timeout', cmd: 'status' })
expect(reloaded.queueLength).toBe(20)
expect(reloaded.status()).toMatchObject({ enabled: true, onboarded: true })
})
it('evicts the oldest event so app_close survives a full queue', async () => {
const { telemetry, posts } = make()
telemetry.completeOnboarding(true)
for (let i = 0; i < 199; i++) telemetry.track('section_view', { section: `section-${i}` })
telemetry.track('section_view', { section: 'dropped-at-capacity' })
expect(telemetry.queueLength).toBe(200)
telemetry.trackClose()
expect(telemetry.queueLength).toBe(200)
await telemetry.flush()
const body = posts[0]!.body as { events: Array<{ name: string; props: Record<string, unknown> }> }
expect(body.events).toHaveLength(200)
expect(body.events[0]).toMatchObject({ name: 'section_view', props: { section: 'section-0' } })
expect(body.events.at(-1)?.name).toBe('app_close')
expect(body.events.some(event => event.props.section === 'dropped-at-capacity')).toBe(false)
})
it('keeps the queue on a transient failure (5xx) and clears it on success', async () => {
let ok = false
const fetchFn = vi.fn(async () => ({ ok, status: 503 })) as unknown as typeof fetch
const { telemetry } = make({ fetchFn })
telemetry.completeOnboarding(true)
expect(await telemetry.flush()).toBe(false)
expect(telemetry.queueLength).toBe(1)
ok = true
expect(await telemetry.flush()).toBe(true)
expect(telemetry.queueLength).toBe(0)
})
it('drops a permanently rejected batch (4xx) instead of wedging the queue', async () => {
const fetchFn = vi.fn(async () => ({ ok: false, status: 400 })) as unknown as typeof fetch
const { telemetry } = make({ fetchFn })
telemetry.completeOnboarding(true)
expect(await telemetry.flush()).toBe(false)
expect(telemetry.queueLength).toBe(0)
})
it('batches with the wire contract: schema, installId, app block, events', async () => {
const { telemetry, posts } = make()
telemetry.completeOnboarding(true)
await telemetry.flush()
const body = posts[0]!.body as Record<string, unknown>
expect(body.schema).toBe(1)
expect(typeof body.installId).toBe('string')
expect(body.app).toMatchObject({ name: 'codeburn-desktop', version: '1.0.0', country: 'US' })
expect(Array.isArray(body.events)).toBe(true)
})
})