Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions app/electron/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ const CHANNELS = [
'codeburn:resetPlan',
'codeburn:exportData',
'codeburn:cliStatus',
'codeburn:telemetryStatus',
'codeburn:telemetrySetEnabled',
'codeburn:telemetryOnboarded',
'codeburn:telemetryTrack',
] as const

const ARGV_CASES: Array<{ channel: string; args: unknown[]; argv: string[] }> = [
Expand Down Expand Up @@ -320,3 +324,61 @@ describe('createBridgeHandlers (cold-start warmup)', () => {
expect(emitProgress).toHaveBeenCalledWith({ kind: 'tick', provider: 'claude', done: 5, total: 10 })
})
})

describe('createBridgeHandlers (telemetry wiring)', () => {
const fakeTelemetry = () => ({
status: vi.fn(() => ({ installId: 'id-1', country: 'US', enabled: true, defaultEnabled: true, onboarded: false })),
setEnabled: vi.fn((enabled: boolean) => ({ installId: 'id-2', country: 'US', enabled, defaultEnabled: true, onboarded: false })),
completeOnboarding: vi.fn((enabled: boolean) => ({ installId: 'id-1', country: 'US', enabled, defaultEnabled: true, onboarded: true })),
track: vi.fn(),
})
const deps = (telemetry: ReturnType<typeof fakeTelemetry> | null) => ({
spawnCli: vi.fn(async () => ({ current: { cost: 1 } })),
spawnCliAction: vi.fn(),
resolveCodeburnPath: () => '/bin/codeburn',
getQuota: vi.fn(async () => []),
emitProgress: vi.fn(),
telemetry,
})

it('exposes status/consent/track channels and forwards to the telemetry service', async () => {
const telemetry = fakeTelemetry()
const handlers = createBridgeHandlers(deps(telemetry))

expect(await handlers['codeburn:telemetryStatus']!()).toMatchObject({ ok: true, value: { installId: 'id-1', onboarded: false } })
expect(await handlers['codeburn:telemetrySetEnabled']!(false)).toMatchObject({ ok: true, value: { enabled: false } })
expect(telemetry.setEnabled).toHaveBeenCalledWith(false)
expect(await handlers['codeburn:telemetryOnboarded']!(true)).toMatchObject({ ok: true, value: { onboarded: true } })
expect(telemetry.completeOnboarding).toHaveBeenCalledWith(true)
await handlers['codeburn:telemetryTrack']!('section_view', { section: 'spend' })
expect(telemetry.track).toHaveBeenCalledWith('section_view', { section: 'spend' })
})

it('returns null (not an error) when telemetry is unavailable', async () => {
const handlers = createBridgeHandlers(deps(null))
expect(await handlers['codeburn:telemetryStatus']!()).toEqual({ ok: true, value: null })
expect(await handlers['codeburn:telemetryTrack']!('section_view', {})).toEqual({ ok: true, value: true })
})

it('tracks cold_start once on the first overview success, with duration', async () => {
const telemetry = fakeTelemetry()
const handlers = createBridgeHandlers(deps(telemetry))
await handlers['codeburn:getOverview']!('30days', 'all')
await handlers['codeburn:getOverview']!('30days', 'all')
const coldStarts = telemetry.track.mock.calls.filter(([name]) => name === 'cold_start')
expect(coldStarts.length).toBe(1)
expect(coldStarts[0]![1]).toMatchObject({ timedOut: false })
expect(typeof (coldStarts[0]![1] as { ms: number }).ms).toBe('number')
})

it('tracks cli_error kinds from failing handlers', async () => {
const telemetry = fakeTelemetry()
const failing = {
...deps(telemetry),
spawnCli: vi.fn(async () => { throw new CliError('timeout', 'timed out') }),
}
const handlers = createBridgeHandlers(failing)
await handlers['codeburn:getSessions']!('week', 'all')
expect(telemetry.track).toHaveBeenCalledWith('cli_error', { kind: 'timeout' })
})
})
56 changes: 52 additions & 4 deletions app/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ import path from 'node:path'

import { CliError, killAll, resolveCodeburnPath, spawnCli, spawnCliAction, type ActionResult } from './cli'
import { getQuota, sanitizeError } from './quota'
import { Telemetry } from './telemetry'

// Initialized in bootstrap() once Electron paths exist; stays null under tests.
let telemetryInstance: Telemetry | null = null

/** The slice of Telemetry the bridge handlers use — injectable for tests. */
export type TelemetryBridge = Pick<Telemetry, 'status' | 'setEnabled' | 'completeOnboarding' | 'track'>

// Result envelope: handlers never throw across IPC so the structured error
// `kind` survives contextBridge serialization. preload.ts unwraps it.
Expand Down Expand Up @@ -127,6 +134,8 @@ type Deps = {
getQuota: typeof getQuota
/** Forward cold-start scan-progress events to the renderer splash. */
emitProgress?: (event: unknown) => void
/** Consent-gated anonymous telemetry; absent under tests unless injected. */
telemetry?: TelemetryBridge | null
}

type Handler = (...args: any[]) => Promise<Envelope>
Expand All @@ -136,8 +145,9 @@ type Handler = (...args: any[]) => Promise<Envelope>
* shell) and returns a result envelope. Pure + injectable so the wiring is
* unit-testable without launching Electron.
*/
export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, resolveCodeburnPath, getQuota, emitProgress: broadcastProgress }): Record<string, Handler> {
export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, resolveCodeburnPath, getQuota, emitProgress: broadcastProgress, telemetry: telemetryInstance }): Record<string, Handler> {
const emitProgress = deps.emitProgress ?? (() => {})
const telemetry = deps.telemetry ?? null
// 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.
Expand All @@ -147,7 +157,9 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re
try {
return { ok: true, value: await deps.spawnCli(build(...args)) }
} catch (err) {
return { ok: false, error: toEnvelopeError(err) }
const error = toEnvelopeError(err)
telemetry?.track('cli_error', { kind: error.kind })
return { ok: false, error }
}
}

Expand All @@ -160,6 +172,7 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re
]

const getOverview: Handler = async (period: string, provider: string, range?: DateRange, configSource?: string | null) => {
const startedAt = Date.now()
try {
const args = buildOverviewArgs(period, provider, range, configSource)
if (overviewWarmed) return { ok: true, value: await deps.spawnCli(args) }
Expand All @@ -170,9 +183,13 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re
})
overviewWarmed = true
emitProgress({ kind: 'done' })
telemetry?.track('cold_start', { ms: Date.now() - startedAt, timedOut: false })
return { ok: true, value }
} catch (err) {
return { ok: false, error: toEnvelopeError(err) }
const error = toEnvelopeError(err)
if (!overviewWarmed) telemetry?.track('cold_start', { ms: Date.now() - startedAt, timedOut: error.kind === 'timeout' })
telemetry?.track('cli_error', { kind: error.kind })
return { ok: false, error }
}
}
const runAction = (build: (...args: any[]) => string[]): Handler => async (...args: any[]) => {
Expand Down Expand Up @@ -239,6 +256,15 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re
const p = deps.resolveCodeburnPath()
return { ok: true, value: { found: p !== null, path: p } }
},
// Telemetry consent + events. Value is null when telemetry is unavailable
// (tests, or init failure) — the renderer treats null as "no onboarding".
'codeburn:telemetryStatus': async () => ({ ok: true, value: telemetry ? telemetry.status() : null }),
'codeburn:telemetrySetEnabled': async (enabled?: boolean) => ({ ok: true, value: telemetry ? telemetry.setEnabled(Boolean(enabled)) : null }),
'codeburn:telemetryOnboarded': async (enabled?: boolean) => ({ ok: true, value: telemetry ? telemetry.completeOnboarding(Boolean(enabled)) : null }),
'codeburn:telemetryTrack': async (name?: string, props?: unknown) => {
telemetry?.track(String(name ?? ''), props)
return { ok: true, value: true }
},
}
}

Expand Down Expand Up @@ -400,9 +426,31 @@ function bootstrap(): void {
win.focus()
})

app.on('before-quit', () => killAll())
app.on('before-quit', () => {
killAll()
// Best-effort final beat: session duration + whatever is still queued.
telemetryInstance?.trackClose()
void telemetryInstance?.flush()
})

void app.whenReady().then(() => {
// Consent-gated anonymous telemetry (desktop only). Nothing transmits until
// the onboarding consent screen is completed and the toggle is on; EU/EEA/
// UK/CH installs default the toggle off. Dev builds never send.
try {
telemetryInstance = new Telemetry({
stateDir: app.getPath('userData'),
country: app.getLocaleCountryCode() || null,
isPackaged: app.isPackaged,
appVersion: app.getVersion(),
})
// completeOnboarding tracks the first app_open itself; only already-
// onboarded installs record subsequent opens here.
if (telemetryInstance.status().onboarded) telemetryInstance.track('app_open', {})
setInterval(() => { void telemetryInstance?.flush() }, 5 * 60_000)
} catch (err) {
console.error('telemetry init failed (continuing without):', err)
}
registerHandlers()
installApplicationMenu()
createWindow()
Expand Down
4 changes: 4 additions & 0 deletions app/electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ const bridge = {
exportData: (format: string, provider: string, outPath: string) => invoke('codeburn:exportData', format, provider, outPath),
chooseDirectory: () => invoke('codeburn:chooseDirectory'),
cliStatus: () => invoke('codeburn:cliStatus'),
telemetryStatus: () => invoke('codeburn:telemetryStatus'),
setTelemetryEnabled: (enabled: boolean) => invoke('codeburn:telemetrySetEnabled', enabled),
completeOnboarding: (enabled: boolean) => invoke('codeburn:telemetryOnboarded', enabled),
telemetryTrack: (name: string, props?: Record<string, unknown>) => invoke('codeburn:telemetryTrack', name, props),
openExternal: (url: string) => ipcRenderer.invoke('open-external', url),
// Cold-start scan progress (main → renderer). Returns an unsubscribe fn.
onProgress: (cb: (event: unknown) => void) => {
Expand Down
10 changes: 7 additions & 3 deletions app/electron/quota/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ const defaultDeps: QuotaDeps = {
readFile: readSecureFile,
writeFile: atomicWriteSecureFile,
now: Date.now,
refreshMs: 2 * 60_000,
// Politeness floor: quota stays gentle regardless of the app refresh cadence.
// A user-initiated force refresh still bypasses this (invalidate()).
refreshMs: 5 * 60_000,
}

function unavailable(provider: ProviderName, connection: QuotaProvider['connection']): QuotaProvider {
Expand Down Expand Up @@ -89,11 +91,11 @@ export class QuotaService {
// poll; keep showing the live connection rather than flapping to
// disconnected. A forced refresh re-reads the keychain and reveals truth.
if (!allowKeychain && (next.connection === 'disconnected' || next.connection === 'accessDenied')) return previous
if (next.connection === 'transientFailure') return { ...previous, connection: 'transientFailure' }
if (next.connection === 'transientFailure') return { ...previous, connection: 'transientFailure', rateLimited: next.rateLimited }
return next
}
const until = blocked[provider] ? Date.parse(blocked[provider]!) : NaN
if (Number.isFinite(until) && until > this.deps.now()) return retainOnFailure(unavailable(provider, 'transientFailure'))
if (Number.isFinite(until) && until > this.deps.now()) return retainOnFailure({ ...unavailable(provider, 'transientFailure'), rateLimited: true })
const generation = this.generations[provider]
const controller = new AbortController()
this.controllers[provider] = controller
Expand All @@ -104,6 +106,8 @@ export class QuotaService {
if (result.retryAfterSeconds !== undefined) {
blocked[provider] = new Date(this.deps.now() + result.retryAfterSeconds * 1000).toISOString()
await this.writeBlocked(blocked)
if (this.controllers[provider] === controller) this.controllers[provider] = undefined
return retainOnFailure({ ...result.quota, rateLimited: true })
} else if (blocked[provider]) {
delete blocked[provider]
await this.writeBlocked(blocked)
Expand Down
4 changes: 4 additions & 0 deletions app/electron/quota/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ export type QuotaProvider = {
details: QuotaWindow[]
planLabel: string | null
footerLines: string[]
/** Set when the provider is in a 429 backoff window (rate limited by the
* upstream quota endpoint), so the UI can say so honestly instead of the
* generic "waiting" copy. */
rateLimited?: boolean
}

export type ProviderName = QuotaProvider['provider']
Expand Down
Loading