mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-21 05:23:19 +00:00
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:
parent
a0e66e3fcd
commit
b876a311e2
23 changed files with 647 additions and 42 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -171,6 +171,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
|
|||
observeCapacityDockProviderSettingsRequests()
|
||||
setupUpdateNotifications()
|
||||
Telemetry.shared.start()
|
||||
updateChecker.settlePendingUpdate()
|
||||
Task { await updateChecker.checkIfNeeded() }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
38
src/provider-issues.ts
Normal 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()]
|
||||
}
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
56
tests/provider-issues.test.ts
Normal file
56
tests/provider-issues.test.ts
Normal 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' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue