feat: telemetry for CLI error causes, provider read failures and update results (#1702)

cli_error gains duration, exit, a locally classified reason and the scoped provider. New provider_read_fail (desktop) and update_result (desktop, menubar, tray) events, enums only.
This commit is contained in:
Resham Joshi 2026-10-09 12:47:49 +02:00 • committed by GitHub
parent a0e66e3fcd
commit b876a311e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 647 additions and 42 deletions

View file

@ -503,7 +503,9 @@ The other events are name-only:
| `app_open`, `app_close` | Session length in whole minutes |
| `section_view` | Which section you opened (`overview`, `spend`, …) |
| `cold_start` | Milliseconds to the first painted overview, and whether it timed out |
| `cli_error` | Error kind and the command name, capped at 20 per kind per day |
| `cli_error` | Error kind, the command name, how long it ran as a range (`<1s` to `120s+`), the exit code or signal, a reason label (`lock-busy`, `oom`, `eacces`, `enoent`, `network`, `parse`, `shutdown`, `serve`, `other`) picked on your machine from the error text, which is never sent, and the provider when the read was for one provider. Capped at 20 per kind per day |
| `provider_read_fail` | A provider whose data could not be read: its name, the stage (`locate` or `parse`) and the error kind (`eacces`, `busy`, `enoent`, `malformed`, `error`). Once a day per provider, stage and kind |
| `update_result` | How a one-click update went: the version before and after, and `ok`, `download_fail`, `verify_fail` or `install_fail`. Also sent by the macOS menu bar app and the Windows tray |
| `optimize_apply` | The finding id you took a fix for (`unused-mcp`, `claude-md-too-long`, …) and the fix type |
| `plan_set` | Provider and plan preset id |
| `export` | Format (`csv` or `json`) and provider |

View file

@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { autoUpdateSupported, createAutoUpdateChecker, WINDOWS_AUTO_UPDATE, type Updater } from './auto-update'
import { autoUpdateSupported, createAutoUpdateChecker, downloadFailOutcome, WINDOWS_AUTO_UPDATE, type Updater } from './auto-update'
import type { UpdateStatus } from './updates'
function fakeUpdater(version: string | null) {
@ -81,4 +81,35 @@ describe('createAutoUpdateChecker', () => {
updater.checkForUpdates.mockRejectedValueOnce(new Error('offline'))
expect((await checker.check()).install).toBe('available')
})
it('reports a failed download as download_fail or verify_fail, and marks the install before quitting', async () => {
const results: unknown[] = []
const installs: unknown[] = []
const updater = fakeUpdater('0.9.27')
updater.downloadUpdate.mockRejectedValueOnce(new Error('net::ERR_CONNECTION_RESET'))
const checker = createAutoUpdateChecker({
updater,
currentVersion: '0.9.26',
onChange: () => {},
onDownloadFail: (outcome, from, to) => results.push({ outcome, from, to }),
onInstall: (from, to) => installs.push({ from, to }),
})
await checker.check()
await checker.download()
expect(results).toEqual([{ outcome: 'download_fail', from: '0.9.26', to: '0.9.27' }])
await checker.check()
await checker.download()
expect(installs).toEqual([{ from: '0.9.26', to: '0.9.27' }])
checker.install()
expect(installs).toEqual([{ from: '0.9.26', to: '0.9.27' }, { from: '0.9.26', to: '0.9.27' }])
expect(updater.quitAndInstall).toHaveBeenCalledTimes(1)
})
it('tells a refused checksum or code signature from a failed download', () => {
expect(downloadFailOutcome(Object.assign(new Error('x'), { code: 'ERR_CHECKSUM_MISMATCH' }))).toBe('verify_fail')
expect(downloadFailOutcome(new Error('sha512 checksum mismatch, expected a, got b'))).toBe('verify_fail')
expect(downloadFailOutcome(new Error('Code signature at URL file:///x did not pass validation'))).toBe('verify_fail')
expect(downloadFailOutcome(new Error('HttpError: 404'))).toBe('download_fail')
})
})

View file

@ -38,11 +38,25 @@ export type AutoUpdateChecker = UpdateChecker & {
install(): void
}
/** A failed download is a verify failure when the checksum or Squirrel's code signature check refused it. */
export function downloadFailOutcome(err: unknown): 'download_fail' | 'verify_fail' {
const code = (err as { code?: unknown } | null)?.code
const message = err instanceof Error ? err.message : String(err)
return code === 'ERR_CHECKSUM_MISMATCH' || /checksum|sha512|signature|did not pass validation/i.test(message)
? 'verify_fail'
: 'download_fail'
}
export function createAutoUpdateChecker(opts: {
updater: Updater
currentVersion: string
onChange: (status: UpdateStatus) => void
now?: () => number
/** A download that failed, as an outcome enum. */
onDownloadFail?: (outcome: 'download_fail' | 'verify_fail', from: string, to: string) => void
/** Called when the update is downloaded and again just before quitting into it: from then
* on it installs, at the latest on the next quit. */
onInstall?: (from: string, to: string) => void
}): AutoUpdateChecker {
const { updater, currentVersion, onChange } = opts
const now = opts.now ?? (() => Date.now())
@ -55,7 +69,9 @@ export function createAutoUpdateChecker(opts: {
const set = (next: UpdateStatus) => { status = next; onChange(status) }
updater.on('update-downloaded', () => {
if (status.install === 'downloading') set({ ...status, install: 'ready' })
if (status.install !== 'downloading') return
opts.onInstall?.(currentVersion, status.latestVersion ?? '')
set({ ...status, install: 'ready' })
})
const check = (): Promise<UpdateStatus> => {
@ -94,7 +110,8 @@ export function createAutoUpdateChecker(opts: {
set({ ...status, install: 'downloading' })
try {
await updater.downloadUpdate()
} catch {
} catch (err) {
opts.onDownloadFail?.(downloadFailOutcome(err), currentVersion, status.latestVersion ?? '')
// Fall back to the manual download link rather than a button that keeps failing.
const { install: _dropped, ...rest } = status
set(rest)
@ -102,7 +119,9 @@ export function createAutoUpdateChecker(opts: {
return status
},
install() {
if (status.install === 'ready') updater.quitAndInstall()
if (status.install !== 'ready') return
opts.onInstall?.(currentVersion, status.latestVersion ?? '')
updater.quitAndInstall()
},
}
}

View file

@ -8,7 +8,7 @@ import type { CompanionStatus, MenubarCompanion } from './menubar'
import { NO_MAC_MENUBAR, type MacMenubar } from './mac-menubar'
import { readOptimizeSnapshot, sameLocalDay, writeOptimizeSnapshot, type OptimizeBlock, type OptimizeSnapshot } from './optimize-store'
import { sanitizeError, type getQuota } from './quota'
import type { Telemetry } from './telemetry'
import { cliErrorReason, durationBucket, type Telemetry } from './telemetry'
import type { UpdateStatus } from './updates'
// The IPC bridge's channel -> argv mapping, free of any `electron` import so a
@ -393,20 +393,29 @@ function toEnvelopeError(err: unknown): { kind: string; message: string; stage?:
return { kind: 'nonzero', message: sanitizeError(err instanceof Error ? err.message : String(err)) }
}
const PROVIDER_ISSUE_KINDS = new Set(['eacces', 'busy', 'enoent', 'malformed', 'error'])
/**
* Props for a `cli_error` telemetry event. Deliberately carries only
* non-sensitive enums so the event is diagnosable without a repro yet leaks
* nothing: `cmd` is the CLI subcommand (argv[0], a fixed literal like 'status'/
* 'sessions' — never the full args, which can hold paths), and `detail` is the
* not-found resolution/spawn stage. The error's `message` (which may contain a
* path or stderr) is never read here — only `kind` and the stage enum are.
* path or stderr) only ever reaches cliErrorReason, which reduces it to a fixed
* label. `ms` is a duration bucket, `exit` a one-shot child's exit code or
* signal name, and `provider` appears only when the read was scoped to one.
*/
function cliErrorProps(err: unknown, cmd: string | undefined): Record<string, unknown> {
const props: Record<string, unknown> = {}
function cliErrorProps(err: unknown, argv: readonly string[] | undefined, startedAt: number): Record<string, unknown> {
const props: Record<string, unknown> = { ms: durationBucket(Date.now() - startedAt) }
const cmd = argv?.[0]
if (cmd) props.cmd = cmd
const providerAt = argv ? argv.indexOf('--provider') : -1
if (providerAt >= 0 && argv![providerAt + 1]) props.provider = argv![providerAt + 1]
if (err instanceof CliError) {
props.kind = err.kind
if (err.kind === 'not-found' && err.detail) props.detail = err.detail
if (err.exit !== undefined) props.exit = err.exit
if (err.kind === 'nonzero') props.reason = cliErrorReason(err.message)
} else {
props.kind = 'nonzero'
}
@ -492,6 +501,21 @@ export function createBridgeHandlers(deps: Deps): Record<string, Handler> {
return filterArgs(transientProject ? scopeFilter([`=${transientProject}`])(filter) : filter)
}
const telemetry = deps.telemetry ?? null
// The CLI lists providers it could not read on the payload; forwarded as enums only,
// and the telemetry budget keeps it to one per provider per day.
const trackProviderIssues = <T>(payload: T): T => {
const issues = (payload as { providerIssues?: unknown } | null)?.providerIssues
if (Array.isArray(issues)) {
for (const issue of issues.slice(0, 50)) {
const { provider, stage, kind } = (issue ?? {}) as Record<string, unknown>
if (typeof provider !== 'string' || !/^[a-z0-9-]{1,40}$/.test(provider)) continue
if (stage !== 'locate' && stage !== 'parse') continue
if (typeof kind !== 'string' || !PROVIDER_ISSUE_KINDS.has(kind)) continue
telemetry?.track('provider_read_fail', { provider, stage, kind })
}
}
return payload
}
// Flips true after the first overview fetch succeeds. Until then, every
// overview fetch runs cold (long timeout + progress streaming); the shared
// spawnCli coalescing means concurrent same-arg re-polls join one child.
@ -532,12 +556,12 @@ export function createBridgeHandlers(deps: Deps): Record<string, Handler> {
}
const run = (build: (...args: any[]) => string[], backgroundIndex?: number): Handler => async (...args: any[]) => {
let cmd: string | undefined
let argv: string[] | undefined
const startedAt = Date.now()
try {
const background = backgroundIndex !== undefined && args[backgroundIndex] === true
// `background` is renderer scheduling metadata, not a CLI argument.
const argv = build(...(backgroundIndex === undefined ? args : args.slice(0, backgroundIndex)))
cmd = argv[0]
argv = build(...(backgroundIndex === undefined ? args : args.slice(0, backgroundIndex)))
const baseOpts = readOpts()
return {
ok: true,
@ -547,7 +571,7 @@ export function createBridgeHandlers(deps: Deps): Record<string, Handler> {
}
} catch (err) {
const error = coldError(err)
telemetry?.track('cli_error', cliErrorProps(err, cmd))
telemetry?.track('cli_error', cliErrorProps(err, argv, startedAt))
return { ok: false, error }
}
}
@ -608,6 +632,7 @@ export function createBridgeHandlers(deps: Deps): Record<string, Handler> {
const now = Date.now()
if (cached && now - computedAt < maxAge && sameLocalDay(computedAt, now)) return { ok: true, value: cached }
}
const startedAt = Date.now()
try {
// Background priority, which only applies to the one-shot fallback path:
// a serve-routed command (this one is `status`) is dispatched before
@ -624,7 +649,7 @@ export function createBridgeHandlers(deps: Deps): Record<string, Handler> {
return { ok: true, value: snapshot }
} catch (err) {
const error = coldError(err)
telemetry?.track('cli_error', cliErrorProps(err, 'status'))
telemetry?.track('cli_error', cliErrorProps(err, argv, startedAt))
return { ok: false, error }
}
}
@ -635,15 +660,17 @@ export function createBridgeHandlers(deps: Deps): Record<string, Handler> {
const getOverview: Handler = async (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean, scope?: string) => {
coldStartBegan ??= Date.now()
const priority: SpawnPriority | undefined = background ? 'background' : undefined
const startedAt = Date.now()
let args: string[] | undefined
try {
const args = buildOverviewArgs(period, provider, range, configSource, scope)
if (overviewWarmed) return { ok: true, value: await deps.spawnCli(args, priority ? { priority } : undefined) }
const value = await deps.spawnCli(args, {
args = buildOverviewArgs(period, provider, range, configSource, scope)
if (overviewWarmed) return { ok: true, value: trackProviderIssues(await deps.spawnCli(args, priority ? { priority } : undefined)) }
const value = trackProviderIssues(await deps.spawnCli(args, {
timeoutMs: WARMUP_TIMEOUT_MS,
extraEnv: { CODEBURN_PROGRESS: '1' },
onStderr: makeProgressReader(emitProgress),
...(priority ? { priority } : {}),
})
}))
overviewWarmed = true
emitProgress({ kind: 'done' })
emitColdStart(false)
@ -651,7 +678,7 @@ export function createBridgeHandlers(deps: Deps): Record<string, Handler> {
} catch (err) {
const error = coldError(err)
if (!overviewWarmed) emitColdStart(error.kind === 'timeout')
telemetry?.track('cli_error', cliErrorProps(err, 'status'))
telemetry?.track('cli_error', cliErrorProps(err, args ?? ['status'], startedAt))
return { ok: false, error }
}
}

View file

@ -474,7 +474,7 @@ describe('spawnCli', () => {
it('rejects with kind "nonzero" on a non-zero exit', async () => {
fakeBin('fail.js', 'process.stderr.write("boom"); process.exit(2)')
await expect(spawnCli(['status'])).rejects.toMatchObject({ kind: 'nonzero' } satisfies Partial<CliError>)
await expect(spawnCli(['status'])).rejects.toMatchObject({ kind: 'nonzero', exit: '2' } satisfies Partial<CliError>)
})
it('rejects with kind "bad-json" on non-JSON stdout', async () => {

View file

@ -52,11 +52,14 @@ export class CliError extends Error {
readonly kind: CliErrorKind
/** For `not-found` only: the non-sensitive stage enum. Undefined otherwise. */
readonly detail?: NotFoundStage
constructor(kind: CliErrorKind, message: string, detail?: NotFoundStage) {
/** A one-shot child's exit code, or the signal that ended it. Undefined on the serve path. */
readonly exit?: string
constructor(kind: CliErrorKind, message: string, detail?: NotFoundStage, exit?: string) {
super(message)
this.name = 'CliError'
this.kind = kind
this.detail = detail
this.exit = exit
}
}
@ -672,10 +675,10 @@ function runCli(spec: SpawnSpec, cmdLabel: string, timeoutMs: number, onStderr?:
finish(() => reject(new CliError('not-found', err.message, 'spawn-error')))
})
child.on('close', code => {
child.on('close', (code, signal) => {
finish(() => {
if (code !== 0) {
reject(new CliError('nonzero', withoutProgressLines(stderr) || `codeburn exited with code ${code}`))
reject(new CliError('nonzero', withoutProgressLines(stderr) || `codeburn exited with code ${code}`, undefined, signal ?? String(code)))
return
}
try {

View file

@ -779,7 +779,7 @@ describe('createBridgeHandlers (telemetry wiring)', () => {
}
const handlers = createBridgeHandlers(failing)
await handlers['codeburn:getSessions']!('week', 'all')
expect(telemetry.track).toHaveBeenCalledWith('cli_error', { cmd: 'sessions', kind: 'timeout' })
expect(telemetry.track).toHaveBeenCalledWith('cli_error', { cmd: 'sessions', kind: 'timeout', ms: '<1s' })
})
it('includes the resolution-stage detail for a not-found (self-diagnosing without a repro)', async () => {
@ -791,7 +791,7 @@ describe('createBridgeHandlers (telemetry wiring)', () => {
}
const handlers = createBridgeHandlers(failing)
await handlers['codeburn:getPlans']!('week')
expect(telemetry.track).toHaveBeenCalledWith('cli_error', { cmd: 'status', kind: 'not-found', detail: 'bundled-not-absolute' })
expect(telemetry.track).toHaveBeenCalledWith('cli_error', { cmd: 'status', kind: 'not-found', detail: 'bundled-not-absolute', ms: '<1s' })
})
it('never leaks a path or message into cli_error telemetry, even when the error carries one', async () => {
@ -806,10 +806,51 @@ describe('createBridgeHandlers (telemetry wiring)', () => {
const handlers = createBridgeHandlers(failing)
await handlers['codeburn:getSessions']!('week', 'all')
const props = telemetry.track.mock.calls.find(([name]) => name === 'cli_error')![1] as Record<string, unknown>
expect(props).toEqual({ cmd: 'sessions', kind: 'not-found', detail: 'spawn-error' })
expect(props).toEqual({ cmd: 'sessions', kind: 'not-found', detail: 'spawn-error', ms: '<1s' })
expect(JSON.stringify(props)).not.toContain('secret')
expect(JSON.stringify(props)).not.toContain('C:\\')
})
it('adds the exit code, a stderr reason label and a scoped provider, never the stderr or a project', async () => {
const telemetry = fakeTelemetry()
const handlers = createBridgeHandlers({
...deps(telemetry),
spawnCli: vi.fn(async () => {
throw new CliError('nonzero', "EACCES: permission denied, open '/Users/alice/secret/a.jsonl'", undefined, '1')
}),
})
await handlers['codeburn:getSessions']!('week', 'codex')
await handlers['codeburn:getOverview']!('week', 'all')
const calls = telemetry.track.mock.calls.filter(([name]) => name === 'cli_error').map(([, props]) => props)
expect(calls).toEqual([
{ cmd: 'sessions', kind: 'nonzero', ms: '<1s', exit: '1', reason: 'eacces', provider: 'codex' },
{ cmd: 'status', kind: 'nonzero', ms: '<1s', exit: '1', reason: 'eacces' },
])
expect(JSON.stringify(calls)).not.toContain('secret')
})
it('forwards valid providerIssues from the overview payload as provider_read_fail', async () => {
const telemetry = fakeTelemetry()
const handlers = createBridgeHandlers({
...deps(telemetry),
spawnCli: vi.fn(async () => ({
current: { cost: 1 },
providerIssues: [
{ provider: 'cursor', stage: 'locate', kind: 'eacces' },
{ provider: 'codex', stage: 'parse', kind: 'malformed' },
{ provider: '/Users/alice', stage: 'parse', kind: 'error' },
{ provider: 'kiro', stage: 'empty', kind: 'error' },
{ provider: 'zed', stage: 'parse', kind: 'EACCES: /Users/alice' },
],
})),
})
await handlers['codeburn:getOverview']!('week', 'all')
const forwarded = telemetry.track.mock.calls.filter(([name]) => name === 'provider_read_fail').map(([, props]) => props)
expect(forwarded).toEqual([
{ provider: 'cursor', stage: 'locate', kind: 'eacces' },
{ provider: 'codex', stage: 'parse', kind: 'malformed' },
])
})
})
// The renderer hands this a URL and the main process hands it to the shell, so the guard is

View file

@ -310,6 +310,7 @@ function bootstrap(): void {
getAppMetrics: () => app.getAppMetrics(),
getServeUsage: serveUsage,
})
telemetryInstance.settleUpdate(app.getVersion())
// completeOnboarding tracks the first app_open itself; only already-
// onboarded installs record subsequent opens here. app_open carries the
// Capacity Dock state (on/off/none) so dock adoption is measurable.
@ -390,7 +391,13 @@ function bootstrap(): void {
})) {
// Loaded only here: electron-updater picks a platform updater as soon as it is touched.
const { autoUpdater } = require('electron-updater') as typeof import('electron-updater')
autoUpdate = createAutoUpdateChecker({ updater: autoUpdater, currentVersion: app.getVersion(), onChange: broadcastUpdateStatus })
autoUpdate = createAutoUpdateChecker({
updater: autoUpdater,
currentVersion: app.getVersion(),
onChange: broadcastUpdateStatus,
onDownloadFail: (outcome, from, to) => telemetryInstance?.track('update_result', { from, to, outcome }),
onInstall: (from, to) => telemetryInstance?.noteUpdateInstall(from, to),
})
updateChecker = autoUpdate
} else {
updateChecker = createUpdateChecker({ currentVersion: app.getVersion(), storeManaged: windowsStore })

View file

@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cpuBucket, defaultEnabledFor, memBucket, Telemetry } from './telemetry'
import { cliErrorReason, cpuBucket, defaultEnabledFor, durationBucket, EVENT_NAMES, memBucket, Telemetry } from './telemetry'
let dir: string
@ -274,6 +274,47 @@ describe('events', () => {
expect(reloaded.status()).toMatchObject({ enabled: true, onboarded: true })
})
it('sends provider_read_fail once per provider per local day, across restarts', () => {
let instant = new Date('2026-07-17T12:00:00')
const { telemetry } = make({ now: () => instant })
telemetry.completeOnboarding(true)
const before = telemetry.queueLength
telemetry.track('provider_read_fail', { provider: 'cursor', stage: 'locate', kind: 'eacces' })
telemetry.track('provider_read_fail', { provider: 'cursor', stage: 'locate', kind: 'eacces' })
telemetry.track('provider_read_fail', { provider: 'cursor', stage: 'parse', kind: 'malformed' })
telemetry.track('provider_read_fail', { provider: 'codex', stage: 'parse', kind: 'busy' })
expect(telemetry.queueLength).toBe(before + 3)
const reloaded = new Telemetry({ stateDir: dir, country: 'US', isPackaged: true, appVersion: '1', now: () => instant })
reloaded.track('provider_read_fail', { provider: 'cursor', stage: 'locate', kind: 'eacces' })
expect(reloaded.queueLength).toBe(0)
instant = new Date('2026-07-18T12:00:00')
reloaded.track('provider_read_fail', { provider: 'cursor', stage: 'locate', kind: 'eacces' })
expect(reloaded.queueLength).toBe(1)
})
it('settles a pending update once on the next launch: ok when newer, install_fail when unchanged, nothing when older', async () => {
for (const [running, expected] of [
['0.9.27', [{ from: '0.9.26', to: '0.9.27', outcome: 'ok' }]],
['0.9.26', [{ from: '0.9.26', to: '0.9.27', outcome: 'install_fail' }]],
['0.9.28', [{ from: '0.9.26', to: '0.9.28', outcome: 'ok' }]],
['0.9.25', []],
] as const) {
const stateDir = join(dir, running)
const first = new Telemetry({ stateDir, country: 'US', isPackaged: true, appVersion: '0.9.26' })
first.completeOnboarding(true)
first.noteUpdateInstall('0.9.26', '0.9.27')
const { telemetry: next, posts } = make({ stateDir, appVersion: running })
next.settleUpdate(running)
next.settleUpdate(running)
await next.flush()
const events = (posts[0]?.body as { events: Array<{ name: string; props: unknown }> } | undefined)?.events ?? []
expect(events.filter(e => e.name === 'update_result').map(e => e.props))
.toEqual(expected)
expect(JSON.parse(readFileSync(join(stateDir, 'telemetry.v1.json'), 'utf-8')).pendingUpdate).toBeUndefined()
}
})
it('evicts the oldest event so app_close survives a full queue', async () => {
const { telemetry, posts } = make()
telemetry.completeOnboarding(true)
@ -426,3 +467,27 @@ describe('app_close resource usage', () => {
expect(props).toEqual({ sessionMinutes: 10 })
})
})
describe('cli_error enrichment', () => {
it('buckets durations', () => {
expect([0, 999, 1000, 4999, 5000, 14_999, 15_000, 29_999, 30_000, 119_999, 120_000, NaN].map(durationBucket))
.toEqual(['<1s', '<1s', '1-5s', '1-5s', '5-15s', '5-15s', '15-30s', '15-30s', '30-120s', '30-120s', '120s+', '<1s'])
})
it('classifies stderr into a fixed label set', () => {
expect(cliErrorReason('FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory')).toBe('oom')
expect(cliErrorReason('SqliteError: database is locked')).toBe('lock-busy')
expect(cliErrorReason("EACCES: permission denied, open '/Users/me/.claude/x.jsonl'")).toBe('eacces')
expect(cliErrorReason("ENOENT: no such file or directory, open '/Users/me/x'")).toBe('enoent')
expect(cliErrorReason('getaddrinfo ENOTFOUND api.example.com')).toBe('network')
expect(cliErrorReason('SyntaxError: Unexpected token } in JSON at position 4')).toBe('parse')
expect(cliErrorReason('codeburn serve exited')).toBe('serve')
expect(cliErrorReason('codeburn is shutting down')).toBe('shutdown')
expect(cliErrorReason('something else entirely at /Users/me/project')).toBe('other')
})
it('allows the new event names', () => {
expect(EVENT_NAMES.has('provider_read_fail')).toBe(true)
expect(EVENT_NAMES.has('update_result')).toBe(true)
})
})

View file

@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { writeFileAtomic } from './tray-settings'
import { compareSemver } from './updates'
// Anonymous, consent-gated product telemetry for the desktop app ONLY.
// Runs entirely in the Electron main process. Like cli.ts, this module must
@ -62,6 +63,35 @@ export function memBucket(mb: number): string {
return '3k+'
}
/// How long a failed CLI read ran before it failed.
/// Buckets: `<1s`, `1-5s`, `5-15s`, `15-30s`, `30-120s`, `120s+`.
export function durationBucket(ms: number): string {
if (!Number.isFinite(ms) || ms < 1000) return '<1s'
if (ms < 5000) return '1-5s'
if (ms < 15_000) return '5-15s'
if (ms < 30_000) return '15-30s'
if (ms < 120_000) return '30-120s'
return '120s+'
}
const CLI_ERROR_REASONS: Array<[string, RegExp]> = [
['oom', /heap out of memory|ENOMEM|allocation failed/i],
['lock-busy', /SQLITE_BUSY|SQLITE_LOCKED|database (?:is |table is )?locked|refresh lock|EBUSY/i],
['eacces', /EACCES|EPERM|permission denied|operation not permitted/i],
['enoent', /ENOENT|no such file/i],
['network', /ECONNRESET|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|fetch failed|socket hang up/i],
['parse', /SyntaxError|Unexpected token|Unexpected end of JSON|not valid JSON/i],
['shutdown', /shutting down|cancelled/i],
['serve', /serve (?:exited|not running|write failed|request failed)/i],
]
/// Sorts a failed CLI read's message (stderr, which can carry a path) into a
/// small enum here, so only the label leaves the machine.
export function cliErrorReason(message: string): string {
for (const [reason, pattern] of CLI_ERROR_REASONS) if (pattern.test(message)) return reason
return 'other'
}
export const EVENT_NAMES = new Set([
'app_open',
'app_close',
@ -76,6 +106,9 @@ export const EVENT_NAMES = new Set([
'export',
'compare_view',
'settings_change',
// Enums only: a provider id, a stage and an error kind; a version pair and an outcome.
'provider_read_fail',
'update_result',
])
/// A few seconds of wall time is too short a base for a percent: one burst of startup work
@ -117,6 +150,8 @@ type PersistedState = {
lastSnapshotDay?: string
cliErrorDay?: string
cliErrorCounts?: Record<string, number>
/** Written just before an update installs, settled on the next launch. */
pendingUpdate?: { from: string; to: string }
}
/** The slice of Electron's ProcessMetric this module reads. Injected, because
@ -250,6 +285,9 @@ export class Telemetry {
onboardedAt: typeof raw.onboardedAt === 'string' ? raw.onboardedAt : undefined,
lastSnapshotDay: typeof raw.lastSnapshotDay === 'string' ? raw.lastSnapshotDay : undefined,
...loadCliErrorBudget(raw.cliErrorDay, raw.cliErrorCounts),
...(typeof raw.pendingUpdate?.from === 'string' && typeof raw.pendingUpdate.to === 'string'
? { pendingUpdate: { from: raw.pendingUpdate.from, to: raw.pendingUpdate.to } }
: {}),
}
}
} catch { /* first run or unreadable — start fresh */ }
@ -319,21 +357,45 @@ export class Telemetry {
this.queue.shift()
}
const sanitizedProps = sanitizeProps(props)
if (name === 'cli_error') {
const budgetKey = name === 'cli_error' ? String(sanitizedProps.kind ?? '')
: name === 'provider_read_fail'
? `provider:${String(sanitizedProps.provider ?? '')}:${String(sanitizedProps.stage ?? '')}:${String(sanitizedProps.kind ?? '')}`
: name === 'update_result' ? `update:${String(sanitizedProps.outcome ?? '')}`
: null
if (budgetKey !== null) {
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
const cap = name === 'provider_read_fail' ? 1 : MAX_CLI_ERRORS_PER_KIND_PER_DAY
const count = Object.prototype.hasOwnProperty.call(counts, budgetKey) ? counts[budgetKey]! : 0
if (count >= cap) return
counts[budgetKey] = count + 1
this.save()
}
this.queue.push({ name, day, props: sanitizedProps })
}
/** Remembers the update about to install, so the next launch can tell whether it landed. */
noteUpdateInstall(from: string, to: string): void {
this.state.pendingUpdate = { from, to }
this.save()
}
/** On launch: a newer version running means the install landed, and `to` is what runs;
* the old one still running means it did not. An older one is a manual downgrade: the
* marker goes and nothing is sent. */
settleUpdate(currentVersion: string): void {
const pending = this.state.pendingUpdate
if (!pending) return
delete this.state.pendingUpdate
this.save()
const order = compareSemver(currentVersion, pending.from)
if (order > 0) this.track('update_result', { from: pending.from, to: currentVersion, outcome: 'ok' })
else if (order === 0) this.track('update_result', { from: pending.from, to: pending.to, outcome: 'install_fail' })
}
/** One cheap read of how heavy this app run is. Called on the existing flush
* beat and once at close, never on a timer of its own. */
sampleResources(): void {

View file

@ -171,6 +171,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
observeCapacityDockProviderSettingsRequests()
setupUpdateNotifications()
Telemetry.shared.start()
updateChecker.settlePendingUpdate()
Task { await updateChecker.checkIfNeeded() }
}

View file

@ -255,7 +255,7 @@ final class Telemetry {
static let eventNames: Set<String> = [
"app_open", "app_close", "popover_open", "settings_open", "update_click",
"glance_open", "dock_enabled", "dock_disabled", "dock_provider_switch",
"dock_drag_end", "usage_snapshot",
"dock_drag_end", "usage_snapshot", "update_result",
]
static let maxQueue = 200

View file

@ -11,6 +11,9 @@ private let lastCheckKey = "UpdateChecker.lastCheckDate"
private let cachedVersionKey = "UpdateChecker.latestVersion"
private let cachedCliVersionKey = "UpdateChecker.latestCliVersion"
private let lastNotifiedVersionsKey = "UpdateChecker.lastNotifiedVersions"
// Written just before the installer runs, settled on the next launch: the installer
// stops and relaunches this app, so nothing tracked in this process would survive.
private let pendingUpdateKey = "UpdateChecker.pendingUpdate"
// A global npm install on a slow link or a cold cache can run for minutes.
private let cliUpdateTimeoutSeconds: UInt64 = 600
private let menubarUpdateTimeoutSeconds: UInt64 = 300
@ -72,10 +75,54 @@ final class UpdateChecker {
@ObservationIgnored private let defaults: UserDefaults
@ObservationIgnored private let makeNotifier: () -> any UpdateNotifier
@ObservationIgnored private var notifier: (any UpdateNotifier)?
@ObservationIgnored private let track: @MainActor (String, JSONValue) -> Void
init(defaults: UserDefaults = .standard, makeNotifier: @escaping () -> any UpdateNotifier = { SystemUpdateNotifier() }) {
init(
defaults: UserDefaults = .standard,
makeNotifier: @escaping () -> any UpdateNotifier = { SystemUpdateNotifier() },
track: @escaping @MainActor (String, JSONValue) -> Void = { Telemetry.shared.track($0, $1) }
) {
self.defaults = defaults
self.makeNotifier = makeNotifier
self.track = track
}
private func trackUpdateResult(from: String, to: String, outcome: String) {
track("update_result", .object(["from": .string(from), "to": .string(to), "outcome": .string(outcome)]))
}
/// On launch: a newer version running means the last install landed, and `to` is what
/// runs; the old one still running means it did not. An older one is a manual
/// downgrade: the marker goes and nothing is sent.
func settlePendingUpdate(running: String? = nil) {
guard let pending = defaults.dictionary(forKey: pendingUpdateKey) as? [String: String],
let from = pending["from"], let to = pending["to"] else { return }
defaults.removeObject(forKey: pendingUpdateKey)
let now = AppVersion.normalize(running ?? currentVersion)
switch now.compare(from, options: .numeric) {
case .orderedDescending: trackUpdateResult(from: from, to: now, outcome: "ok")
case .orderedSame: trackUpdateResult(from: from, to: to, outcome: "install_fail")
case .orderedAscending: break
}
}
/// A failed `codeburn menubar --force`, sorted by the installer's own messages
/// (src/menubar-installer.ts) so the stderr itself never leaves the machine.
nonisolated static func updateFailureOutcome(stderr: String) -> String {
let text = stderr.lowercased()
let verify = ["checksum", "not signed", "notarized", "bundle id", "refusing"]
if verify.contains(where: text.contains) { return "verify_fail" }
if text.range(of: #"failed after \d+ attempts|http \d{3}|econn|etimedout|enotfound|network"#, options: .regularExpression) != nil {
return "download_fail"
}
return "install_fail"
}
private func failPendingUpdate(outcome: String) {
guard let pending = defaults.dictionary(forKey: pendingUpdateKey) as? [String: String],
let from = pending["from"], let to = pending["to"] else { return }
defaults.removeObject(forKey: pendingUpdateKey)
trackUpdateResult(from: from, to: to, outcome: outcome)
}
var latestVersion: String?
@ -463,6 +510,15 @@ final class UpdateChecker {
isUpdating = true
updateError = nil
updateFailureStage = nil
// `menubar --force` installs the CLI's own version, so that is the target; when it
// is not ahead of this app the run cannot move it, and there is nothing to settle.
if Self.menubarInstallAdvances(installedCli: installedCliVersion, app: currentVersion),
let installedCliVersion {
defaults.set(
["from": AppVersion.normalize(currentVersion), "to": AppVersion.normalize(installedCliVersion)],
forKey: pendingUpdateKey
)
}
let process = CodeburnCLI.makeProcess(subcommand: ["menubar", "--force"])
let errPipe = Pipe()
@ -492,6 +548,7 @@ final class UpdateChecker {
guard let self else { return }
self.isUpdating = false
if proc.terminationStatus != 0 {
self.failPendingUpdate(outcome: Self.updateFailureOutcome(stderr: stderr))
self.updateFailureStage = .menubarUpdate
self.updateError = stderr.isEmpty ? L("Update failed (exit %lld)", proc.terminationStatus) : stderr
NSLog("CodeBurn: update failed (exit \(proc.terminationStatus)): \(stderr)")
@ -504,6 +561,7 @@ final class UpdateChecker {
do {
try process.run()
} catch {
failPendingUpdate(outcome: "install_fail")
isUpdating = false
updateFailureStage = .menubarUpdate
updateError = error.localizedDescription

View file

@ -237,3 +237,44 @@ struct UnmanagedCliTests {
#expect(UpdateChecker.cliUpdateFailureMessage(stderr: "boom", status: 1, manualCommand: "x") == "boom")
}
}
@Suite("update_result telemetry")
@MainActor
struct UpdateResultTelemetryTests {
@Test("sorts installer failures by the installer's own messages")
func failureOutcome() {
#expect(UpdateChecker.updateFailureOutcome(stderr: "Checksum mismatch for /tmp/x.zip.") == "verify_fail")
#expect(UpdateChecker.updateFailureOutcome(stderr: "Refusing to install: the downloaded CodeBurn Menubar is not signed and notarized by AgentSeal") == "verify_fail")
#expect(UpdateChecker.updateFailureOutcome(stderr: "Menubar download failed after 3 attempts: ECONNRESET (https://x)") == "download_fail")
#expect(UpdateChecker.updateFailureOutcome(stderr: "ditto exited with status 1") == "install_fail")
#expect(UpdateChecker.updateFailureOutcome(stderr: "") == "install_fail")
}
@Test("settles a pending update once: ok when newer, install_fail when unchanged, nothing when older")
func settlesPendingUpdate() {
let cases: [(String, [String: String]?)] = [
("0.9.27", ["from": "0.9.26", "to": "0.9.27", "outcome": "ok"]),
("0.9.28", ["from": "0.9.26", "to": "0.9.28", "outcome": "ok"]),
("0.9.26", ["from": "0.9.26", "to": "0.9.27", "outcome": "install_fail"]),
("0.9.25", nil),
]
for (running, expected) in cases {
let (defaults, suiteName) = TestDefaults.make("codeburn.update-result.\(running)")
defer { TestDefaults.forget(suiteName) }
var tracked: [(String, JSONValue)] = []
let checker = UpdateChecker(defaults: defaults, track: { tracked.append(($0, $1)) })
defaults.set(["from": "0.9.26", "to": "0.9.27"], forKey: "UpdateChecker.pendingUpdate")
checker.settlePendingUpdate(running: running)
checker.settlePendingUpdate(running: running)
#expect(defaults.dictionary(forKey: "UpdateChecker.pendingUpdate") == nil)
guard let expected else {
#expect(tracked.isEmpty)
continue
}
#expect(tracked.count == 1)
#expect(tracked.first?.0 == "update_result")
#expect(tracked.first?.1 == .object(expected.mapValues { .string($0) }))
#expect(Telemetry.eventNames.contains("update_result"))
}
}
}

View file

@ -1404,7 +1404,13 @@ program
// carry this machine's sync status.
const { cursorSyncStatus } = await import('./cursor-sync.js')
const cursorSync = await cursorSyncStatus().catch(() => null)
console.log(JSON.stringify(cursorSync ? { ...payload, cursorSync } : payload))
const { providerIssues } = await import('./provider-issues.js')
const issues = providerIssues()
console.log(JSON.stringify({
...payload,
...(cursorSync ? { cursorSync } : {}),
...(issues.length > 0 ? { providerIssues: issues } : {}),
}))
return
}

View file

@ -14,6 +14,7 @@ import { antigravityCascadeIdFromPath, flushAntigravityCache, preloadAntigravity
import { getClaudeConfigDirs, getDesktopSessionsDirs } from './providers/claude.js'
import { kimicodeLineageForSource } from './providers/kimicode.js'
import { isSqliteBusyError } from './sqlite.js'
import { clearProviderIssue, recordProviderIssue } from './provider-issues.js'
import { getCodeburnCacheDir } from './cache-dir.js'
import {
isHermesLedgerPublicationError,
@ -3402,6 +3403,7 @@ const parseFailureCounts = new Map<string, number>()
const PARSE_FAILURE_WARN_CAP = 5
function warnProviderParseFailure(providerName: string, sourcePath: string, err: unknown): void {
recordProviderIssue(providerName, 'parse', err)
const n = (parseFailureCounts.get(providerName) ?? 0) + 1
parseFailureCounts.set(providerName, n)
if (n > PARSE_FAILURE_WARN_CAP) return
@ -6578,9 +6580,11 @@ async function runParseInner(
claudeProjects.push(...deduplicateClaudeDesktopLedger(ledgerProjects, claudeProjects))
}
if (claudeSources.length > 0) emitScanProgress({ kind: 'provider', provider: 'claude', state: 'done', files: claudeSources.length })
clearProviderIssue('claude', 'parse', 'eacces')
} catch (err) {
if (!isPermissionError(err)) throw err
permissionSkippedProviders.add('claude')
recordProviderIssue('claude', 'parse', err)
process.stderr.write(`codeburn: skipped claude data (permission denied; grant Full Disk Access to include it)\n`)
emitScanProgress({ kind: 'provider', provider: 'claude', state: 'skipped' })
}
@ -6593,12 +6597,14 @@ async function runParseInner(
try {
const projects = await parseProviderSources(providerName, sources, seenKeys, diskCache, dateRange, saveProgress, readOnly)
emitScanProgress({ kind: 'provider', provider: providerName, state: 'done', files: sources.length })
clearProviderIssue(providerName, 'parse', 'eacces')
otherProjects.push(...projects)
} catch (err) {
// A permission-locked provider skips-and-continues; any other error is a
// real bug and still aborts (per-file/DB-lock cases are handled deeper).
if (!isPermissionError(err)) throw err
permissionSkippedProviders.add(providerName)
recordProviderIssue(providerName, 'parse', err)
process.stderr.write(`codeburn: skipped ${providerName} data (permission denied; grant Full Disk Access to include it)\n`)
emitScanProgress({ kind: 'provider', provider: providerName, state: 'skipped' })
}

38
src/provider-issues.ts Normal file
View file

@ -0,0 +1,38 @@
import { isBlockedDatabaseError, isSqliteBusyError } from './sqlite.js'
/// A provider whose data could not be read, as enums only: never the error
/// message, which can carry a path. Emitted on the menubar-json payload so the
/// desktop app can report `provider_read_fail`; the CLI itself sends nothing.
export type ProviderIssue = {
provider: string
stage: 'locate' | 'parse'
kind: 'eacces' | 'busy' | 'enoent' | 'malformed' | 'error'
}
// Process-wide on purpose: a file that failed to parse is cached as failed and
// not read again, so a per-run list would lose it on the next poll.
const issues = new Map<string, ProviderIssue>()
export function providerIssueKind(err: unknown): ProviderIssue['kind'] {
const code = (err as NodeJS.ErrnoException | undefined)?.code
if (code === 'EACCES' || code === 'EPERM' || isBlockedDatabaseError(err)) return 'eacces'
if (isSqliteBusyError(err)) return 'busy'
if (code === 'ENOENT') return 'enoent'
if (err instanceof SyntaxError) return 'malformed'
return 'error'
}
export function recordProviderIssue(provider: string, stage: ProviderIssue['stage'], err: unknown): void {
issues.set(`${provider}:${stage}`, { provider, stage, kind: providerIssueKind(err) })
}
/** With `kind`, clears only an entry of that kind: a permission error that Full Disk
* Access has since fixed, but not a malformed file that is still cached as failed. */
export function clearProviderIssue(provider: string, stage: ProviderIssue['stage'], kind?: ProviderIssue['kind']): void {
const key = `${provider}:${stage}`
if (kind === undefined || issues.get(key)?.kind === kind) issues.delete(key)
}
export function providerIssues(): ProviderIssue[] {
return [...issues.values()]
}

View file

@ -30,6 +30,7 @@ import { zerostack } from './zerostack.js'
import { grok } from './grok.js'
import { grokbot } from './grokbot.js'
import { isBlockedDatabaseError } from '../sqlite.js'
import { clearProviderIssue, recordProviderIssue } from '../provider-issues.js'
import type { Provider, SessionSource } from './types.js'
let antigravityProvider: Provider | null = null
@ -266,8 +267,11 @@ export const providers = coreProviders
const warnedDiscoveryFailures = new Set<string>()
async function discoverOne(provider: Provider): Promise<{ sources: SessionSource[]; failed: boolean }> {
try {
return { sources: await provider.discoverSessions(), failed: false }
const sources = await provider.discoverSessions()
clearProviderIssue(provider.name, 'locate')
return { sources, failed: false }
} catch (err) {
recordProviderIssue(provider.name, 'locate', err)
// An error that already explained itself on stderr does not need a second,
// vaguer line.
if (!warnedDiscoveryFailures.has(provider.name) && !isBlockedDatabaseError(err)) {

View file

@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import { discoverAllSessionsWithFailures } from '../src/providers/index.js'
import type { Provider } from '../src/providers/types.js'
import { clearProviderIssue, providerIssueKind, providerIssues, recordProviderIssue } from '../src/provider-issues.js'
function provider(name: string, discover: () => Promise<never[]>): Provider {
return { name, discoverSessions: discover } as unknown as Provider
}
function errno(code: string): Error {
return Object.assign(new Error(`${code}: /Users/someone/secret/path`), { code })
}
describe('provider issues', () => {
it('classifies by code and type, never by message', () => {
expect(providerIssueKind(errno('EACCES'))).toBe('eacces')
expect(providerIssueKind(errno('EPERM'))).toBe('eacces')
expect(providerIssueKind(errno('ENOENT'))).toBe('enoent')
expect(providerIssueKind(errno('SQLITE_BUSY'))).toBe('busy')
expect(providerIssueKind(new SyntaxError('Unexpected token'))).toBe('malformed')
expect(providerIssueKind(new Error('permission denied'))).toBe('error')
})
it('records a failed discovery as locate and clears it once discovery works again', async () => {
let fail = true
const flaky = provider('flaky-test', async () => {
if (fail) throw errno('EACCES')
return []
})
await discoverAllSessionsWithFailures(undefined, [flaky])
expect(providerIssues()).toContainEqual({ provider: 'flaky-test', stage: 'locate', kind: 'eacces' })
expect(JSON.stringify(providerIssues())).not.toContain('/Users')
fail = false
await discoverAllSessionsWithFailures(undefined, [flaky])
expect(providerIssues().some(issue => issue.provider === 'flaky-test')).toBe(false)
})
it('clears a parse entry by kind only when the kind matches', () => {
recordProviderIssue('fda-test', 'parse', errno('EPERM'))
recordProviderIssue('bad-file-test', 'parse', new SyntaxError('x'))
clearProviderIssue('fda-test', 'parse', 'eacces')
clearProviderIssue('bad-file-test', 'parse', 'eacces')
expect(providerIssues().some(issue => issue.provider === 'fda-test')).toBe(false)
expect(providerIssues()).toContainEqual({ provider: 'bad-file-test', stage: 'parse', kind: 'malformed' })
})
it('keeps one entry per provider and stage', () => {
recordProviderIssue('dup-test', 'parse', new SyntaxError('x'))
recordProviderIssue('dup-test', 'parse', errno('ENOENT'))
expect(providerIssues().filter(issue => issue.provider === 'dup-test')).toEqual([
{ provider: 'dup-test', stage: 'parse', kind: 'enoent' },
])
})
})

View file

@ -238,9 +238,12 @@ module doc before changing anything here: its invariants are the contract.
Switching the toggle off mints a fresh install id and empties the queue, as the desktop does.
- **Events.** `app_open` and `app_close` (`sessionMinutes`), `popover_open`, `settings_open`
(`pane`), `update_click` (`action`), `glance_open`, `dock_enabled` / `dock_disabled` (`edge`,
`scaleBucket`), `dock_provider_switch` (`provider`), `dock_drag_end` (`edge`) and
`usage_snapshot`, which forwards the `telemetrySnapshot` object out of the CLI's menubar
payload at most once a calendar day, and only when the desktop app is not the consent source
`scaleBucket`), `dock_provider_switch` (`provider`), `dock_drag_end` (`edge`),
`update_result` (`from`, `to`, `outcome`: `ok`, `download_fail`, `verify_fail` or
`install_fail`; `ok` and a failed install are settled on the next launch from the
`pendingUpdate` key `update.rs` writes before the installer runs) and `usage_snapshot`,
which forwards the `telemetrySnapshot` object out of the CLI's menubar payload at most once
a calendar day, and only when the desktop app is not the consent source
(that app sends the same aggregate from the same payload). An unknown name is dropped, and
every prop goes through the same whitelist sanitizer the desktop uses: every leaf is a
short string, a finite number or a boolean, and the nesting (5), key count (16), array

View file

@ -172,6 +172,7 @@ pub fn run() {
// `track` is silent until this has run, so it comes before anything that reports.
telemetry::init(app.package_info().version.to_string());
telemetry::track("app_open", serde_json::Value::Null);
update::settle_pending_update(&app.package_info().version.to_string());
tauri::async_runtime::spawn(async {
// The queue is offered on the beat rather than at launch, so starting up
// never costs a request on its own. After a failed send the wait is the

View file

@ -45,7 +45,7 @@ const DEFAULT_OFF_COUNTRIES: [&str; 32] = [
/// Every event this app may send. An unknown name is dropped rather than forwarded, so a
/// frontend typo cannot invent a metric.
const EVENT_NAMES: [&str; 11] = [
const EVENT_NAMES: [&str; 12] = [
"app_open",
"app_close",
"popover_open",
@ -57,6 +57,7 @@ const EVENT_NAMES: [&str; 11] = [
"dock_provider_switch",
"dock_drag_end",
"usage_snapshot",
"update_result",
];
const MAX_QUEUE: usize = 200;
@ -1887,6 +1888,16 @@ mod tests {
assert_eq!(queued.props.get("edge").unwrap(), "left");
}
#[test]
fn update_result_is_allowed_and_an_unknown_name_is_not() {
let telemetry = client(consented(ConsentSource::App), None);
telemetry.track_on("update_results", &Value::Null, "2026-09-03");
assert_eq!(telemetry.queue_len(), 0);
let props = serde_json::json!({ "from": "0.9.26", "to": "0.9.27", "outcome": "ok" });
telemetry.track_on("update_result", &props, "2026-09-03");
assert_eq!(telemetry.queue_len(), 1);
}
#[test]
fn a_debug_build_queues_but_does_not_send() {
let telemetry = client(consented(ConsentSource::App), None);

View file

@ -457,11 +457,27 @@ fn pending() -> std::sync::MutexGuard<'static, Option<(Update, Vec<u8>)>> {
/// Fetches the MSI the feed names. The plugin verifies its minisign signature against the
/// compiled-in public key before this returns, so only a verified installer is kept.
pub async fn download(app: &AppHandle) -> Result<()> {
let mut to = String::new();
let result = download_into_pending(app, &mut to).await;
// An empty `to` is a feed with no newer release or an updater that never started: no
// download was attempted, so there is no outcome to report.
if let (Err(err), false) = (&result, to.is_empty()) {
track_update_result(
&app.package_info().version.to_string(),
&to,
download_outcome(err),
);
}
result
}
async fn download_into_pending(app: &AppHandle, to: &mut String) -> Result<()> {
let update = app
.updater()?
.check()
.await?
.context("The update feed has no newer release yet.")?;
to.clone_from(&update.version);
let bytes = update.download(|_, _| {}, || {}).await?;
*pending() = Some((update, bytes));
Ok(())
@ -469,14 +485,85 @@ pub async fn download(app: &AppHandle) -> Result<()> {
/// Runs the verified installer. On Windows the plugin hands it to msiexec with
/// AUTOLAUNCHAPP and exits, so the new version starts itself; elsewhere this restarts.
/// Whether it landed is only known on the next launch, so the versions are written first.
pub fn install(app: &AppHandle) -> Result<()> {
let (update, bytes) = pending()
.take()
.context("No downloaded update to install.")?;
update.install(bytes)?;
set_pending_update(Some((&update.current_version, &update.version)));
if let Err(err) = update.install(bytes) {
set_pending_update(None);
track_update_result(&update.current_version, &update.version, "install_fail");
return Err(err.into());
}
app.restart()
}
const KEY_PENDING_UPDATE: &str = "pendingUpdate";
fn set_pending_update(versions: Option<(&str, &str)>) {
let value = versions
.map(|(from, to)| serde_json::json!({ "from": from, "to": to }))
.unwrap_or(serde_json::Value::Null);
let mut patch = serde_json::Map::new();
patch.insert(KEY_PENDING_UPDATE.into(), value);
if let Err(err) = crate::settings::patch(patch) {
crate::log_line!("codeburn: failed to record the pending update: {err}");
}
}
/// On launch, after telemetry is up: a newer version running means the last install landed,
/// and `to` is what runs; the old one still running means it did not. An older one is a
/// manual downgrade: the marker goes and nothing is sent.
pub fn settle_pending_update(running: &str) {
let settings = crate::settings::read();
let Some(pending) = settings.get(KEY_PENDING_UPDATE) else {
return;
};
set_pending_update(None);
if let Some((from, to, outcome)) = pending_update_outcome(pending, running) {
track_update_result(&from, &to, outcome);
}
}
fn pending_update_outcome(
pending: &serde_json::Value,
running: &str,
) -> Option<(String, String, &'static str)> {
let from = pending.get("from")?.as_str()?;
let to = pending.get("to")?.as_str()?;
if running == from {
Some((from.to_owned(), to.to_owned(), "install_fail"))
} else if is_newer(running, from) {
Some((from.to_owned(), running.to_owned(), "ok"))
} else {
None
}
}
fn track_update_result(from: &str, to: &str, outcome: &str) {
crate::telemetry::track(
"update_result",
serde_json::json!({ "from": from, "to": to, "outcome": outcome }),
);
}
/// A download the plugin refused on its signature is a verify failure; anything else on the
/// way (the feed, the network, the disk) is a download failure.
fn download_outcome(err: &anyhow::Error) -> &'static str {
use tauri_plugin_updater::Error as E;
match err.downcast_ref::<E>() {
Some(
E::Minisign(_)
| E::Base64(_)
| E::SignatureUtf8(_)
| E::SignedVersionMismatch { .. }
| E::MissingSignedVersion,
) => "verify_fail",
_ => "download_fail",
}
}
// Scrubbing -----------------------------------------------------------------------------------
fn token_len(text: &str) -> usize {
@ -781,6 +868,42 @@ mod tests {
);
}
#[test]
fn a_pending_update_settles_to_ok_only_when_newer() {
let pending = serde_json::json!({ "from": "0.9.26", "to": "0.9.27" });
assert_eq!(
pending_update_outcome(&pending, "0.9.27"),
Some(("0.9.26".into(), "0.9.27".into(), "ok"))
);
assert_eq!(
pending_update_outcome(&pending, "0.9.26"),
Some(("0.9.26".into(), "0.9.27".into(), "install_fail"))
);
assert_eq!(
pending_update_outcome(&pending, "0.9.28"),
Some(("0.9.26".into(), "0.9.28".into(), "ok"))
);
assert_eq!(pending_update_outcome(&pending, "0.9.25"), None);
assert_eq!(
pending_update_outcome(&serde_json::json!("junk"), "0.9.27"),
None
);
}
#[test]
fn a_refused_signature_is_a_verify_failure() {
let signature = anyhow::Error::from(tauri_plugin_updater::Error::SignatureUtf8("x".into()));
assert_eq!(download_outcome(&signature), "verify_fail");
let missing = anyhow::Error::from(tauri_plugin_updater::Error::MissingSignedVersion);
assert_eq!(download_outcome(&missing), "verify_fail");
let feed = anyhow::Error::from(tauri_plugin_updater::Error::ReleaseNotFound);
assert_eq!(download_outcome(&feed), "download_fail");
assert_eq!(
download_outcome(&anyhow::anyhow!("no newer release")),
"download_fail"
);
}
#[test]
fn scrubbing_also_strips_nuls_and_caps_the_length() {
assert_eq!(scrub(" ok\u{0000}ay \n"), "okay");