mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Refine Agent Dashboard lifecycle handling (#32172)
* Track Agent Dashboard adoption and daily visible use - Record enablement, board opens, and per-surface daily visibility - Persist daily limits across restarts and respect consent and window visibility * Track dashboard telemetry at surface roots
This commit is contained in:
@@ -19,7 +19,8 @@ Alongside each event we include basic build and platform information: the Orca v
|
||||
|
||||
The categories of behavior we observe:
|
||||
|
||||
- **Lifecycle** — when the app opens. Used to estimate daily, weekly, and monthly active users.
|
||||
- **Lifecycle** — when the app opens, including whether Agent Dashboard is currently enabled. Used to estimate daily, weekly, and monthly active users and feature adoption, including users who enabled the dashboard before measurement began.
|
||||
- **Agent Dashboard** — when the board opens and whether it is visible during a UTC day, labeled as in-window or pop-out. Visible-use observations are limited to one per day per surface across app restarts, so a board left visible across days is counted each day. Hidden or minimized windows pause these observations. This measures visible-use days, not time spent or human attention; no card or agent contents are included.
|
||||
- **Repos and workspaces** — when you add a repo or create a workspace. We record _how_ you did it (e.g. folder picker vs. clone URL; command palette vs. drag-and-drop), never the repo name, URL, path, branch name, or any free-form text. For a workspace create we also record how long it took and how that time split across its steps, whether a checkout Orca had prepared in advance was reused (and if so, what reset it needed, what prepared it, how long it took to prepare and how long it waited unused; if not, a fixed reason code), for a failed create which step it stopped in, plus coarse context: whether it came from the Orca window or from the CLI, phone or agent interface, whether Git ran locally, in WSL or over SSH, a bucketed worktree count, a bucketed count of the files the repo tracks, how many other creates and prepared-checkout jobs were running, and whether the repo has a post-checkout hook (never its contents).
|
||||
- **Agents** — which agents you use and their token counts. Never prompts or agent output.
|
||||
- **Agent errors** — a coarse error category and which agent kind was involved. We never see raw error messages or stack traces; per-incident detail stays in a local diagnostic trace file on your machine and only reaches Orca if you explicitly share a diagnostic bundle.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
handleMock,
|
||||
setMainUiLanguageMock,
|
||||
rebuildSystemTrayMenuMock,
|
||||
applyElectronProxySettingsMock,
|
||||
applyAgentStatusHooksEnabledMock,
|
||||
trackMock
|
||||
} = vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
setMainUiLanguageMock: vi.fn(),
|
||||
rebuildSystemTrayMenuMock: vi.fn(),
|
||||
applyElectronProxySettingsMock: vi.fn(),
|
||||
applyAgentStatusHooksEnabledMock: vi.fn(),
|
||||
trackMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: vi.fn(() => '/test/user-data') },
|
||||
BrowserWindow: { getAllWindows: vi.fn(() => []) },
|
||||
ipcMain: { handle: handleMock, on: vi.fn() },
|
||||
nativeTheme: { themeSource: 'system' }
|
||||
}))
|
||||
vi.mock('../telemetry/client', () => ({ track: trackMock }))
|
||||
vi.mock('../telemetry/cohort-classifier', () => ({
|
||||
getCohortAtEmit: () => ({ nth_repo_added: 2 })
|
||||
}))
|
||||
vi.mock('../ssh/config/ssh-config-parser', () => ({
|
||||
loadUserSshConfig: () => ({ hosts: [] }),
|
||||
sshConfigHostsToTargets: () => []
|
||||
}))
|
||||
vi.mock('../ghostty/index', () => ({ previewGhosttyImport: vi.fn() }))
|
||||
vi.mock('../warp-themes', () => ({ previewWarpThemeImport: vi.fn() }))
|
||||
vi.mock('../network/proxy-settings', () => ({
|
||||
applyElectronProxySettings: applyElectronProxySettingsMock
|
||||
}))
|
||||
vi.mock('../browser/browser-session-proxy', () => ({
|
||||
applyBrowserSessionProxies: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
vi.mock('../browser/browser-session-registry', () => ({
|
||||
browserSessionRegistry: { listProfiles: vi.fn(() => []) }
|
||||
}))
|
||||
vi.mock('../app-icon', () => ({ applyAppIcon: vi.fn() }))
|
||||
vi.mock('../ai-vault-search/session-search-enablement', () => ({
|
||||
applySessionSearchSettingsChange: vi.fn()
|
||||
}))
|
||||
vi.mock('../agent-hooks/managed-agent-hook-controls', () => ({
|
||||
applyAgentStatusHooksEnabled: applyAgentStatusHooksEnabledMock
|
||||
}))
|
||||
vi.mock('../worktree-root-preparation', () => ({
|
||||
prepareLocalWorktreeRootsForRepos: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
vi.mock('../menu/register-app-menu', () => ({ rebuildAppMenu: vi.fn() }))
|
||||
vi.mock('../tray/system-tray', () => ({ rebuildSystemTrayMenu: rebuildSystemTrayMenuMock }))
|
||||
vi.mock('../i18n/main-i18n', () => ({ setMainUiLanguage: setMainUiLanguageMock }))
|
||||
|
||||
import { registerSettingsHandlers } from './settings'
|
||||
|
||||
it('records Agent Dashboard enablement only for a real setting transition', async () => {
|
||||
const store = {
|
||||
getSettings: vi.fn(() => ({ experimentalAgentDashboardPopout: false })),
|
||||
updateSettings: vi.fn(() => ({ experimentalAgentDashboardPopout: true })),
|
||||
onSettingsChanged: vi.fn(() => () => {})
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This handler only uses the settings methods supplied by the fixture.
|
||||
registerSettingsHandlers(store as never)
|
||||
const handler = handleMock.mock.calls.find(([channel]) => channel === 'settings:set')?.[1]
|
||||
if (typeof handler !== 'function') {
|
||||
throw new Error('Missing settings:set handler')
|
||||
}
|
||||
|
||||
await handler({ sender: { id: 1 } }, { experimentalAgentDashboardPopout: true })
|
||||
|
||||
expect(trackMock).toHaveBeenCalledWith('agent_dashboard_enabled', { enabled: true })
|
||||
|
||||
trackMock.mockClear()
|
||||
store.getSettings.mockReturnValue({ experimentalAgentDashboardPopout: true })
|
||||
await handler({ sender: { id: 1 } }, { experimentalAgentDashboardPopout: true })
|
||||
expect(trackMock).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -70,6 +70,8 @@ vi.mock('../ai-vault-search/session-search-enablement', () => ({
|
||||
applySessionSearchSettingsChange: applySessionSearchSettingsChangeMock
|
||||
}))
|
||||
|
||||
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
|
||||
|
||||
vi.mock('../agent-hooks/managed-agent-hook-controls', () => ({
|
||||
applyAgentStatusHooksEnabled: applyAgentStatusHooksEnabledMock
|
||||
}))
|
||||
|
||||
@@ -203,6 +203,14 @@ export function registerSettingsHandlers(
|
||||
'alwaysForceDeletePreservedBranches' in sanitizedArgs
|
||||
? await store.updateSettingsAndFlush(sanitizedArgs, updateOptions)
|
||||
: store.updateSettings(sanitizedArgs, updateOptions)
|
||||
if (
|
||||
'experimentalAgentDashboardPopout' in sanitizedArgs &&
|
||||
before.experimentalAgentDashboardPopout !== result.experimentalAgentDashboardPopout
|
||||
) {
|
||||
track('agent_dashboard_enabled', {
|
||||
enabled: result.experimentalAgentDashboardPopout === true
|
||||
})
|
||||
}
|
||||
const proxySettingsChanged =
|
||||
('httpProxyUrl' in sanitizedArgs && before.httpProxyUrl !== result.httpProxyUrl) ||
|
||||
('httpProxyBypassRules' in sanitizedArgs &&
|
||||
|
||||
@@ -12,7 +12,9 @@ const {
|
||||
persistBannerAcknowledgeMock,
|
||||
consumeConsentMutationTokenMock,
|
||||
getCohortAtEmitMock,
|
||||
getOnboardingCohortAtEmitMock
|
||||
getOnboardingCohortAtEmitMock,
|
||||
hasTrackedAgentDashboardActiveTodayMock,
|
||||
fromWebContentsMock
|
||||
} = vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
trackMock: vi.fn(),
|
||||
@@ -20,12 +22,18 @@ const {
|
||||
persistBannerAcknowledgeMock: vi.fn(),
|
||||
consumeConsentMutationTokenMock: vi.fn(),
|
||||
getCohortAtEmitMock: vi.fn(),
|
||||
getOnboardingCohortAtEmitMock: vi.fn()
|
||||
getOnboardingCohortAtEmitMock: vi.fn(),
|
||||
hasTrackedAgentDashboardActiveTodayMock: vi.fn(),
|
||||
fromWebContentsMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({ ipcMain: { handle: handleMock } }))
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: { handle: handleMock },
|
||||
BrowserWindow: { fromWebContents: fromWebContentsMock }
|
||||
}))
|
||||
vi.mock('../telemetry/client', () => ({
|
||||
track: trackMock,
|
||||
hasTrackedAgentDashboardActiveToday: hasTrackedAgentDashboardActiveTodayMock,
|
||||
setOptIn: setOptInMock,
|
||||
persistBannerAcknowledgeWithoutEmitting: persistBannerAcknowledgeMock
|
||||
}))
|
||||
@@ -88,6 +96,12 @@ describe('telemetry IPC handlers', () => {
|
||||
getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 0 })
|
||||
getOnboardingCohortAtEmitMock.mockReset()
|
||||
getOnboardingCohortAtEmitMock.mockReturnValue({ cohort: undefined })
|
||||
hasTrackedAgentDashboardActiveTodayMock.mockReset().mockReturnValue(false)
|
||||
fromWebContentsMock.mockReset().mockReturnValue({
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
isMinimized: () => false
|
||||
})
|
||||
_resetStoreForTests()
|
||||
})
|
||||
afterEach(() => {
|
||||
@@ -130,6 +144,40 @@ describe('telemetry IPC handlers', () => {
|
||||
expect(getCohortAtEmitMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['missing', 'destroyed', 'hidden', 'minimized'])(
|
||||
'drops dashboard observations from a %s native window before spending the event budget',
|
||||
(state) => {
|
||||
registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true })
|
||||
fromWebContentsMock.mockReturnValue(
|
||||
state === 'missing'
|
||||
? null
|
||||
: {
|
||||
isDestroyed: () => state === 'destroyed',
|
||||
isVisible: () => state !== 'hidden',
|
||||
isMinimized: () => state === 'minimized'
|
||||
}
|
||||
)
|
||||
|
||||
const handler = handlers.get('telemetry:track')!
|
||||
expect(handler({ sender: {} }, 'agent_dashboard_active', { surface: 'popout' })).toBe(false)
|
||||
expect(trackMock).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('confirms dashboard observations that were captured or already recorded that day', () => {
|
||||
registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true })
|
||||
const handler = handlers.get('telemetry:track')!
|
||||
trackMock.mockReturnValue(true)
|
||||
expect(handler({ sender: {} }, 'agent_dashboard_active', { surface: 'popout' })).toBe(true)
|
||||
|
||||
trackMock.mockReturnValue(false)
|
||||
hasTrackedAgentDashboardActiveTodayMock.mockReturnValue(true)
|
||||
expect(handler({ sender: {} }, 'agent_dashboard_active', { surface: 'popout' })).toBe(true)
|
||||
|
||||
hasTrackedAgentDashboardActiveTodayMock.mockReturnValue(false)
|
||||
expect(handler({ sender: {} }, 'agent_dashboard_active', { surface: 'popout' })).toBe(false)
|
||||
})
|
||||
|
||||
// Renderer-only Setup-step events depend on the handler injecting cohort so call sites stay synchronous.
|
||||
it('injects cohort for add_repo_setup_step_action (renderer-only event)', () => {
|
||||
registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true })
|
||||
@@ -145,6 +193,7 @@ describe('telemetry IPC handlers', () => {
|
||||
it('drops main-owned events from renderer telemetry IPC', () => {
|
||||
registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true })
|
||||
const handler = handlers.get('telemetry:track')!
|
||||
handler({}, 'agent_dashboard_enabled', { enabled: true })
|
||||
handler({}, 'app_starred_orca', { source: 'settings' })
|
||||
handler({}, 'star_nag_outcome', {
|
||||
outcome: 'shown',
|
||||
|
||||
@@ -9,9 +9,14 @@
|
||||
// too), and `via` derived from main-owned state — never passed over the wire, so a
|
||||
// compromised renderer can't misreport it.
|
||||
|
||||
import { ipcMain } from 'electron'
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
import { consumeConsentMutationToken } from '../telemetry/burst-cap'
|
||||
import { persistBannerAcknowledgeWithoutEmitting, setOptIn, track } from '../telemetry/client'
|
||||
import {
|
||||
hasTrackedAgentDashboardActiveToday,
|
||||
persistBannerAcknowledgeWithoutEmitting,
|
||||
setOptIn,
|
||||
track
|
||||
} from '../telemetry/client'
|
||||
import { getCohortAtEmit } from '../telemetry/cohort-classifier'
|
||||
import { getOnboardingCohortAtEmit } from '../telemetry/onboarding-cohort-classifier'
|
||||
import { resolveConsent, type ConsentState } from '../telemetry/consent'
|
||||
@@ -23,6 +28,7 @@ import type { EventName, EventProps, OptInVia } from '../../shared/telemetry-eve
|
||||
let storeRef: Store | null = null
|
||||
|
||||
const MAIN_OWNED_TELEMETRY_EVENTS = new Set<EventName>([
|
||||
'agent_dashboard_enabled',
|
||||
'agent_token_usage',
|
||||
'app_starred_orca',
|
||||
'daemon_adopted',
|
||||
@@ -63,19 +69,25 @@ function deriveOptInVia(store: Store, incomingOptedIn: boolean): OptInVia {
|
||||
export function registerTelemetryHandlers(store: Store): void {
|
||||
storeRef = store
|
||||
|
||||
ipcMain.handle('telemetry:track', (_event, name: unknown, props: unknown): void => {
|
||||
ipcMain.handle('telemetry:track', (event, name: unknown, props: unknown): boolean => {
|
||||
// Drop non-string names at the boundary so a flood of bogus payloads never reaches the Zod validator.
|
||||
if (typeof name !== 'string') {
|
||||
return
|
||||
return false
|
||||
}
|
||||
// `props` is optional (undefined/null → {} below); reject any other non-object at the boundary.
|
||||
if (props !== null && props !== undefined && typeof props !== 'object') {
|
||||
return
|
||||
return false
|
||||
}
|
||||
const eventName = name as EventName
|
||||
// Why: these events are main-owned; renderer IPC emitting them would let compromised content spoof product outcomes.
|
||||
if (MAIN_OWNED_TELEMETRY_EVENTS.has(eventName)) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
if (eventName === 'agent_dashboard_active') {
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!window || window.isDestroyed() || !window.isVisible() || window.isMinimized()) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Inject cohort props only for schemas that declare them: schemas are `.strict()`, so an extra prop on any other event fails Zod and drops it.
|
||||
const baseProps = (props ?? {}) as Record<string, unknown>
|
||||
@@ -86,7 +98,11 @@ export function registerTelemetryHandlers(store: Store): void {
|
||||
? { ...withRepoCohort, ...getOnboardingCohortAtEmit() }
|
||||
: withRepoCohort
|
||||
// Casts are pass-through only; `track()`'s validator is the single runtime enforcement point, not these casts.
|
||||
track(eventName, finalProps as EventProps<EventName>)
|
||||
return (
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: track validates this IPC payload against its event schema before capture.
|
||||
track(eventName, finalProps as EventProps<EventName>) ||
|
||||
(eventName === 'agent_dashboard_active' && hasTrackedAgentDashboardActiveToday(finalProps))
|
||||
)
|
||||
})
|
||||
|
||||
ipcMain.handle('telemetry:setOptIn', (_event, optedIn: unknown): Promise<void> | void => {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { initTelemetry, track } from './client'
|
||||
import {
|
||||
cleanupTelemetryClientTest,
|
||||
setupTelemetryClientTest,
|
||||
type TelemetryClientTestState
|
||||
} from './client-test-harness'
|
||||
|
||||
describe('Agent Dashboard daily visible use', () => {
|
||||
let state: TelemetryClientTestState
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-10-22T12:00:00Z'))
|
||||
state = setupTelemetryClientTest()
|
||||
state.settings.experimentalAgentDashboardPopout = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanupTelemetryClientTest(state.envStash)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('caps repeated mounts and observations across windows to one event per surface per UTC day', () => {
|
||||
expect(track('agent_dashboard_active', { surface: 'popout' })).toBe(true)
|
||||
expect(track('agent_dashboard_active', { surface: 'in_window' })).toBe(true)
|
||||
const settingsReads = vi.mocked(state.store.getSettings).mock.calls.length
|
||||
for (let index = 0; index < 1500; index++) {
|
||||
track('agent_dashboard_active', { surface: 'popout' })
|
||||
track('agent_dashboard_active', { surface: 'in_window' })
|
||||
}
|
||||
|
||||
expect(state.mock.capture).toHaveBeenCalledTimes(2)
|
||||
expect(state.store.updateSettings).toHaveBeenCalledTimes(2)
|
||||
expect(state.store.getSettings).toHaveBeenCalledTimes(settingsReads)
|
||||
expect(state.settings.telemetry?.agentDashboardActiveDays).toEqual({
|
||||
popout: Math.floor(Date.now() / 86_400_000),
|
||||
in_window: Math.floor(Date.now() / 86_400_000)
|
||||
})
|
||||
expect(track('app_opened', {})).toBe(true)
|
||||
expect(state.mock.capture.mock.calls[0]?.[0].properties).toMatchObject({
|
||||
surface: 'popout',
|
||||
$process_person_profile: false
|
||||
})
|
||||
expect(state.mock.capture.mock.calls[0]?.[0].properties).not.toHaveProperty('day')
|
||||
})
|
||||
|
||||
it('counts the next UTC day without a new app session or opening', () => {
|
||||
vi.setSystemTime(new Date('2026-10-22T23:59:59Z'))
|
||||
track('agent_dashboard_active', { surface: 'popout' })
|
||||
vi.setSystemTime(new Date('2026-10-23T00:00:00Z'))
|
||||
|
||||
expect(track('agent_dashboard_active', { surface: 'popout' })).toBe(true)
|
||||
expect(track('agent_dashboard_active', { surface: 'popout' })).toBe(false)
|
||||
expect(state.mock.capture).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('preserves the daily limit across restarts without consuming the event budget on repeats', () => {
|
||||
track('agent_dashboard_active', { surface: 'popout' })
|
||||
initTelemetry(state.store)
|
||||
for (let index = 0; index < 1500; index++) {
|
||||
expect(track('agent_dashboard_active', { surface: 'popout' })).toBe(false)
|
||||
}
|
||||
|
||||
expect(state.mock.capture).toHaveBeenCalledTimes(1)
|
||||
expect(state.store.updateSettings).toHaveBeenCalledTimes(1)
|
||||
expect(track('agent_dashboard_active', { surface: 'in_window' })).toBe(true)
|
||||
expect(track('app_opened', {})).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['opt_out', 'pending_banner', 'do_not_track', 'orca_disabled', 'ci', 'dashboard_off'])(
|
||||
'does not send or consume the daily allowance while blocked by %s',
|
||||
(block) => {
|
||||
if (block === 'opt_out') {
|
||||
state.settings.telemetry!.optedIn = false
|
||||
} else if (block === 'pending_banner') {
|
||||
state.settings.telemetry!.optedIn = null
|
||||
} else if (block === 'dashboard_off') {
|
||||
state.settings.experimentalAgentDashboardPopout = false
|
||||
} else if (block === 'do_not_track') {
|
||||
process.env.DO_NOT_TRACK = '1'
|
||||
} else if (block === 'orca_disabled') {
|
||||
process.env.ORCA_TELEMETRY_DISABLED = '1'
|
||||
} else {
|
||||
process.env.CI = '1'
|
||||
}
|
||||
|
||||
expect(track('agent_dashboard_active', { surface: 'popout' })).toBe(false)
|
||||
expect(state.mock.capture).not.toHaveBeenCalled()
|
||||
expect(state.store.updateSettings).not.toHaveBeenCalled()
|
||||
|
||||
state.settings.telemetry!.optedIn = true
|
||||
state.settings.experimentalAgentDashboardPopout = true
|
||||
delete process.env.DO_NOT_TRACK
|
||||
delete process.env.ORCA_TELEMETRY_DISABLED
|
||||
delete process.env.CI
|
||||
expect(track('agent_dashboard_active', { surface: 'popout' })).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('does not consume a daily allowance for invalid payloads', () => {
|
||||
const invalid = { surface: 'popout', card: 'private' } as const
|
||||
expect(track('agent_dashboard_active', invalid)).toBe(false)
|
||||
expect(state.store.updateSettings).not.toHaveBeenCalled()
|
||||
expect(track('agent_dashboard_active', { surface: 'popout' })).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the in-memory limit if saving the cursor fails', () => {
|
||||
vi.mocked(state.store.updateSettings).mockImplementation(() => {
|
||||
throw new Error('disk unavailable')
|
||||
})
|
||||
|
||||
expect(track('agent_dashboard_active', { surface: 'popout' })).toBe(true)
|
||||
expect(track('agent_dashboard_active', { surface: 'popout' })).toBe(false)
|
||||
expect(state.mock.capture).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not consume a daily allowance before the SDK has finished enabling', () => {
|
||||
Object.assign(state.mock, { optedOut: true })
|
||||
expect(track('agent_dashboard_active', { surface: 'popout' })).toBe(false)
|
||||
expect(state.store.updateSettings).not.toHaveBeenCalled()
|
||||
expect(state.mock.capture).not.toHaveBeenCalled()
|
||||
|
||||
Object.assign(state.mock, { optedOut: false })
|
||||
expect(track('agent_dashboard_active', { surface: 'popout' })).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { eventSchemas } from '../../shared/telemetry-events'
|
||||
import { persistBannerAcknowledgeWithoutEmitting, track, trackAppOpenedOnce } from './client'
|
||||
import {
|
||||
cleanupTelemetryClientTest,
|
||||
setupTelemetryClientTest,
|
||||
type TelemetryClientTestState
|
||||
} from './client-test-harness'
|
||||
|
||||
describe('Agent Dashboard app-open snapshot', () => {
|
||||
let state: TelemetryClientTestState
|
||||
|
||||
beforeEach(() => {
|
||||
state = setupTelemetryClientTest()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanupTelemetryClientTest(state.envStash)
|
||||
})
|
||||
|
||||
it.each([true, false, undefined])(
|
||||
'records the saved setting %s once without requiring a toggle or adding an event',
|
||||
(enabled) => {
|
||||
state.settings.experimentalAgentDashboardPopout = enabled
|
||||
|
||||
trackAppOpenedOnce()
|
||||
trackAppOpenedOnce()
|
||||
|
||||
expect(state.mock.capture).toHaveBeenCalledTimes(1)
|
||||
expect(state.mock.capture.mock.calls[0]?.[0]).toMatchObject({
|
||||
event: 'app_opened',
|
||||
properties: {
|
||||
agent_dashboard_enabled: enabled === true,
|
||||
$process_person_profile: false
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('overrides a caller-provided snapshot with main-owned settings', () => {
|
||||
state.settings.experimentalAgentDashboardPopout = true
|
||||
|
||||
track('app_opened', { agent_dashboard_enabled: false })
|
||||
|
||||
expect(state.mock.capture.mock.calls[0]?.[0].properties.agent_dashboard_enabled).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['opt_out', 'pending_banner', 'do_not_track', 'orca_disabled', 'ci'])(
|
||||
'sends no snapshot when consent is blocked by %s',
|
||||
(block) => {
|
||||
state.settings.experimentalAgentDashboardPopout = true
|
||||
if (block === 'opt_out') {
|
||||
state.settings.telemetry!.optedIn = false
|
||||
} else if (block === 'pending_banner') {
|
||||
state.settings.telemetry!.optedIn = null
|
||||
} else if (block === 'do_not_track') {
|
||||
process.env.DO_NOT_TRACK = '1'
|
||||
} else if (block === 'orca_disabled') {
|
||||
process.env.ORCA_TELEMETRY_DISABLED = '1'
|
||||
} else {
|
||||
process.env.CI = '1'
|
||||
}
|
||||
|
||||
trackAppOpenedOnce()
|
||||
|
||||
expect(state.mock.capture).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('reads the latest setting when the pending banner is resolved', async () => {
|
||||
state.settings.telemetry!.optedIn = null
|
||||
state.settings.telemetry!.existedBeforeTelemetryRelease = true
|
||||
state.settings.experimentalAgentDashboardPopout = false
|
||||
trackAppOpenedOnce()
|
||||
expect(state.mock.capture).not.toHaveBeenCalled()
|
||||
|
||||
state.settings.experimentalAgentDashboardPopout = true
|
||||
await persistBannerAcknowledgeWithoutEmitting()
|
||||
trackAppOpenedOnce()
|
||||
|
||||
expect(state.mock.capture).toHaveBeenCalledTimes(1)
|
||||
expect(state.mock.capture.mock.calls[0]?.[0]).toMatchObject({
|
||||
event: 'app_opened',
|
||||
properties: { agent_dashboard_enabled: true }
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the app-open schema compatible with older payloads and rejects non-boolean state', () => {
|
||||
expect(eventSchemas.app_opened.safeParse({}).success).toBe(true)
|
||||
expect(eventSchemas.app_opened.safeParse({ agent_dashboard_enabled: true }).success).toBe(true)
|
||||
expect(eventSchemas.app_opened.safeParse({ agent_dashboard_enabled: 'true' }).success).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -62,6 +62,10 @@ describe('setOptIn()', () => {
|
||||
})
|
||||
await setOptIn('settings', true)
|
||||
expect(order).toEqual(['optIn', 'capture:app_opened', 'capture:telemetry_opted_in'])
|
||||
expect(state.store.updateSettings).toHaveBeenCalledWith(
|
||||
{ telemetry: expect.objectContaining({ optedIn: true }) },
|
||||
{ notifyListeners: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('drops telemetry_opted_in silently in non-official builds', async () => {
|
||||
@@ -121,6 +125,10 @@ describe('persistBannerAcknowledgeWithoutEmitting()', () => {
|
||||
|
||||
expect(order).toEqual(['optIn', 'capture:app_opened'])
|
||||
expect(state.settings.telemetry?.optedIn).toBe(true)
|
||||
expect(state.store.updateSettings).toHaveBeenCalledWith(
|
||||
{ telemetry: expect.objectContaining({ optedIn: true }) },
|
||||
{ notifyListeners: true }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ describe('track()', () => {
|
||||
expect(call.distinctId).toBe(BASE_COMMON.install_id)
|
||||
expect(call.properties).toEqual({
|
||||
...BASE_COMMON,
|
||||
agent_dashboard_enabled: false,
|
||||
$process_person_profile: false
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,16 +6,18 @@
|
||||
// equivalent of posthog-js's `person_profiles: 'identified_only'` (no PostHog person per install_id).
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { arch as osArch, platform as osPlatform, release as osRelease } from 'node:os'
|
||||
import { getAppEnvironment } from '../../shared/app-environment'
|
||||
import { PostHog } from 'posthog-node'
|
||||
import type { PostHog } from 'posthog-node'
|
||||
import type { CommonProps, EventName, EventProps, OptInVia } from '../../shared/telemetry-events'
|
||||
import type { AgentDashboardSurface } from '../../shared/telemetry-agent-dashboard-event-schemas'
|
||||
import type { Store } from '../persistence'
|
||||
import { consumeBurstToken, resetBurstCapsForSession } from './burst-cap'
|
||||
import { getCohortAtEmit } from './cohort-classifier'
|
||||
import { resolveConsent, type ConsentState } from './consent'
|
||||
import { resolveConsent } from './consent'
|
||||
import { buildCommonProps, createPostHogClient, waitForCaptureEnqueue } from './posthog-transport'
|
||||
import { commonPropsSchema, validate } from './validator'
|
||||
|
||||
export { shouldOptOutSdkAtInit } from './posthog-transport'
|
||||
|
||||
// Compile-time feature flag, independent of the build-identity gate — both must be satisfied to transmit.
|
||||
// NOTE: config/scripts/verify-telemetry-constants.mjs greps `const TELEMETRY_ENABLED = true|false`; keep that shape or update its regex.
|
||||
const TELEMETRY_ENABLED = true
|
||||
@@ -42,26 +44,12 @@ let commonProps: CommonProps | null = null
|
||||
let shuttingDown = false
|
||||
let storeRef: Store | null = null
|
||||
|
||||
const OPT_OUT_CAPTURE_ENQUEUE_TIMEOUT_MS = 1_000
|
||||
|
||||
// Test-only transport-gate override (`_enableTransportForTests`) so tests exercise the full pipeline without a real CI build.
|
||||
let testTransportEnabled = false
|
||||
|
||||
// First-launch `app_opened` gate: no events transmit until the banner resolves; keep mark+emit atomic.
|
||||
let appOpenedTrackedThisSession = false
|
||||
|
||||
function buildCommonProps(installId: string, sid: string, channel: 'stable' | 'rc'): CommonProps {
|
||||
// Don't truncate here; the validator's `.max(64)` is authoritative, so an over-long string drops rather than being silently masked.
|
||||
return {
|
||||
app_version: getAppEnvironment().getVersion(),
|
||||
platform: osPlatform(),
|
||||
arch: osArch(),
|
||||
os_release: osRelease(),
|
||||
install_id: installId,
|
||||
session_id: sid,
|
||||
orca_channel: channel
|
||||
}
|
||||
}
|
||||
const trackedDashboardActiveDays = new Map<AgentDashboardSurface, number>()
|
||||
|
||||
export function initTelemetry(store: Store): void {
|
||||
// Set unconditionally so `setOptIn` can persist opt-out to disk even on contributor / non-official builds.
|
||||
@@ -70,8 +58,9 @@ export function initTelemetry(store: Store): void {
|
||||
shuttingDown = false
|
||||
// Reset per session: the "no app_opened until banner resolution" invariant is per-launch, not per-install.
|
||||
appOpenedTrackedThisSession = false
|
||||
trackedDashboardActiveDays.clear()
|
||||
|
||||
if (!TELEMETRY_ENABLED || !IS_OFFICIAL_BUILD) {
|
||||
if (!TELEMETRY_ENABLED || !IS_OFFICIAL_BUILD || BUILD_IDENTITY === null || WRITE_KEY === null) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -84,12 +73,7 @@ export function initTelemetry(store: Store): void {
|
||||
}
|
||||
|
||||
sessionId = randomUUID()
|
||||
commonProps = buildCommonProps(
|
||||
installId,
|
||||
sessionId,
|
||||
// Non-null here: `IS_OFFICIAL_BUILD` gated this branch to the `'stable' | 'rc'` arm.
|
||||
BUILD_IDENTITY as 'stable' | 'rc'
|
||||
)
|
||||
commonProps = buildCommonProps(installId, sessionId, BUILD_IDENTITY)
|
||||
|
||||
const languageCohort = settings.telemetry?.onboardingLanguageCohort
|
||||
if (languageCohort === 'control' || languageCohort === 'treatment') {
|
||||
@@ -105,64 +89,7 @@ export function initTelemetry(store: Store): void {
|
||||
return
|
||||
}
|
||||
|
||||
posthog = new PostHog(WRITE_KEY as string, {
|
||||
host: 'https://us.i.posthog.com',
|
||||
flushAt: 20,
|
||||
flushInterval: 10_000,
|
||||
// Strip SDK-auto GeoIP / client-IP enrichment; our wire is exactly CommonProps ∪ EventProps ∪ a small allow-list.
|
||||
disableGeoip: true,
|
||||
// Bumped from the default 1000 (drops oldest-first past cap) to 5000 to tolerate long-offline sessions.
|
||||
maxQueueSize: 5000
|
||||
})
|
||||
|
||||
if (shouldOptOutSdkAtInit(resolveConsent(settings))) {
|
||||
posthog.optOut()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to flip the PostHog SDK's in-memory `optedOut` flag at boot.
|
||||
*
|
||||
* True for DISABLED cohorts only, re-applied every boot (the flag doesn't persist) so any direct
|
||||
* `posthog.capture()` bypassing `track()` still drops for an opted-out user. Deliberately excludes
|
||||
* `pending_banner`: the direct `telemetry_opted_out` capture in `setOptIn(_, false)` must not drop,
|
||||
* or we'd lose the one signal that the opt-out flow works.
|
||||
*/
|
||||
export function shouldOptOutSdkAtInit(consent: ConsentState): boolean {
|
||||
return consent.effective === 'disabled'
|
||||
}
|
||||
|
||||
function waitForCaptureEnqueue(client: PostHog, event: EventName, uuid: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
let stopListening: (() => void) | null = null
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const settle = (enqueued: boolean): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
stopListening?.()
|
||||
resolve(enqueued)
|
||||
}
|
||||
|
||||
// Why: posthog-node capture() enqueues async; this SDK event is the durable boundary before optOut().
|
||||
stopListening = client.on('capture', (payload: unknown) => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return
|
||||
}
|
||||
const message = payload as { event?: unknown; uuid?: unknown }
|
||||
if (message.event === event && message.uuid === uuid) {
|
||||
settle(true)
|
||||
}
|
||||
})
|
||||
|
||||
timeout = setTimeout(() => settle(false), OPT_OUT_CAPTURE_ENQUEUE_TIMEOUT_MS)
|
||||
})
|
||||
posthog = createPostHogClient(WRITE_KEY, resolveConsent(settings))
|
||||
}
|
||||
|
||||
/** Lets producers avoid preparing usage payloads when transmission is disabled. */
|
||||
@@ -177,6 +104,17 @@ export function isTelemetryEnabled(): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
export function hasTrackedAgentDashboardActiveToday(props: unknown): boolean {
|
||||
if (!props || typeof props !== 'object' || !('surface' in props)) {
|
||||
return false
|
||||
}
|
||||
const surface = props.surface
|
||||
return (
|
||||
(surface === 'in_window' || surface === 'popout') &&
|
||||
(trackedDashboardActiveDays.get(surface) ?? -1) >= Math.floor(Date.now() / 86_400_000)
|
||||
)
|
||||
}
|
||||
|
||||
// No-op in contributor / non-official builds; only official stable/rc builds (CI-injected `ORCA_BUILD_IDENTITY` + `ORCA_POSTHOG_WRITE_KEY`) transmit.
|
||||
export function track<N extends EventName>(name: N, props: EventProps<N>): boolean {
|
||||
if (!testTransportEnabled && (!IS_OFFICIAL_BUILD || !TELEMETRY_ENABLED)) {
|
||||
@@ -187,7 +125,24 @@ export function track<N extends EventName>(name: N, props: EventProps<N>): boole
|
||||
if (shuttingDown) {
|
||||
return false
|
||||
}
|
||||
if (!posthog || !commonProps || !storeRef) {
|
||||
if (!posthog || !commonProps || !storeRef || posthog.optedOut) {
|
||||
return false
|
||||
}
|
||||
|
||||
const dashboardActiveSurface =
|
||||
name === 'agent_dashboard_active' &&
|
||||
props !== null &&
|
||||
typeof props === 'object' &&
|
||||
'surface' in props &&
|
||||
(props.surface === 'in_window' || props.surface === 'popout')
|
||||
? props.surface
|
||||
: null
|
||||
const dashboardActiveDay = dashboardActiveSurface ? Math.floor(Date.now() / 86_400_000) : 0
|
||||
// Drop repeat observations before they can exhaust the session-wide event budget.
|
||||
if (
|
||||
dashboardActiveSurface &&
|
||||
(trackedDashboardActiveDays.get(dashboardActiveSurface) ?? -1) >= dashboardActiveDay
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -197,7 +152,8 @@ export function track<N extends EventName>(name: N, props: EventProps<N>): boole
|
||||
}
|
||||
|
||||
// (3) Consent resolve — reads live settings every call so it can't drift from persisted state / env-var precedence.
|
||||
const consent = resolveConsent(storeRef.getSettings())
|
||||
const settings = storeRef.getSettings()
|
||||
const consent = resolveConsent(settings)
|
||||
if (consent.effective !== 'enabled') {
|
||||
return false
|
||||
}
|
||||
@@ -207,6 +163,23 @@ export function track<N extends EventName>(name: N, props: EventProps<N>): boole
|
||||
if (!result.ok) {
|
||||
return false
|
||||
}
|
||||
if (dashboardActiveSurface) {
|
||||
if (settings.experimentalAgentDashboardPopout !== true) {
|
||||
return false
|
||||
}
|
||||
const savedDay = settings.telemetry?.agentDashboardActiveDays?.[dashboardActiveSurface] ?? -1
|
||||
if (savedDay >= dashboardActiveDay) {
|
||||
trackedDashboardActiveDays.set(dashboardActiveSurface, savedDay)
|
||||
return false
|
||||
}
|
||||
}
|
||||
const mainOwnedProps =
|
||||
name === 'app_opened'
|
||||
? {
|
||||
agent_dashboard_enabled: settings.experimentalAgentDashboardPopout === true
|
||||
}
|
||||
: {}
|
||||
|
||||
// (5) Capture. `$process_person_profile: false` stops posthog-node creating a person per install_id (no init-time equivalent).
|
||||
posthog.capture({
|
||||
distinctId: commonProps.install_id,
|
||||
@@ -214,9 +187,26 @@ export function track<N extends EventName>(name: N, props: EventProps<N>): boole
|
||||
properties: {
|
||||
...commonProps,
|
||||
...result.props,
|
||||
...mainOwnedProps,
|
||||
$process_person_profile: false
|
||||
}
|
||||
})
|
||||
if (dashboardActiveSurface && settings.telemetry) {
|
||||
trackedDashboardActiveDays.set(dashboardActiveSurface, dashboardActiveDay)
|
||||
try {
|
||||
storeRef.updateSettings({
|
||||
telemetry: {
|
||||
...settings.telemetry,
|
||||
agentDashboardActiveDays: {
|
||||
...settings.telemetry.agentDashboardActiveDays,
|
||||
[dashboardActiveSurface]: dashboardActiveDay
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('[telemetry] failed to persist dashboard daily usage limit', err)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -226,12 +216,15 @@ export async function setOptIn(via: OptInVia, optedIn: boolean): Promise<void> {
|
||||
}
|
||||
const settings = storeRef.getSettings()
|
||||
// Deep-merge (persistence.ts:552) so flipping `optedIn` won't clobber `installId` / `existedBeforeTelemetryRelease`.
|
||||
storeRef.updateSettings({
|
||||
telemetry: {
|
||||
...(settings.telemetry ?? { installId: '', existedBeforeTelemetryRelease: true }),
|
||||
optedIn
|
||||
}
|
||||
})
|
||||
storeRef.updateSettings(
|
||||
{
|
||||
telemetry: {
|
||||
...(settings.telemetry ?? { installId: '', existedBeforeTelemetryRelease: true }),
|
||||
optedIn
|
||||
}
|
||||
},
|
||||
{ notifyListeners: true }
|
||||
)
|
||||
|
||||
const client = posthog
|
||||
if (optedIn) {
|
||||
@@ -285,12 +278,15 @@ export async function persistBannerAcknowledgeWithoutEmitting(): Promise<void> {
|
||||
}
|
||||
const settings = storeRef.getSettings()
|
||||
// Fallback only used if the `telemetry` block is absent (migration invariant broken); updateSettings deep-merges it (persistence.ts:560).
|
||||
storeRef.updateSettings({
|
||||
telemetry: {
|
||||
...(settings.telemetry ?? { installId: '', existedBeforeTelemetryRelease: true }),
|
||||
optedIn: true
|
||||
}
|
||||
})
|
||||
storeRef.updateSettings(
|
||||
{
|
||||
telemetry: {
|
||||
...(settings.telemetry ?? { installId: '', existedBeforeTelemetryRelease: true }),
|
||||
optedIn: true
|
||||
}
|
||||
},
|
||||
{ notifyListeners: true }
|
||||
)
|
||||
if (posthog) {
|
||||
await posthog.optIn()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { arch as osArch, platform as osPlatform, release as osRelease } from 'node:os'
|
||||
import { PostHog } from 'posthog-node'
|
||||
import { getAppEnvironment } from '../../shared/app-environment'
|
||||
import type { CommonProps, EventName } from '../../shared/telemetry-events'
|
||||
import type { ConsentState } from './consent'
|
||||
|
||||
const OPT_OUT_CAPTURE_ENQUEUE_TIMEOUT_MS = 1_000
|
||||
|
||||
export function buildCommonProps(
|
||||
installId: string,
|
||||
sid: string,
|
||||
channel: 'stable' | 'rc'
|
||||
): CommonProps {
|
||||
// Don't truncate here; the validator's `.max(64)` is authoritative, so an over-long string drops rather than being silently masked.
|
||||
return {
|
||||
app_version: getAppEnvironment().getVersion(),
|
||||
platform: osPlatform(),
|
||||
arch: osArch(),
|
||||
os_release: osRelease(),
|
||||
install_id: installId,
|
||||
session_id: sid,
|
||||
orca_channel: channel
|
||||
}
|
||||
}
|
||||
|
||||
export function waitForCaptureEnqueue(
|
||||
client: PostHog,
|
||||
event: EventName,
|
||||
uuid: string
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
let stopListening: (() => void) | null = null
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const settle = (enqueued: boolean): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
stopListening?.()
|
||||
resolve(enqueued)
|
||||
}
|
||||
|
||||
// Why: posthog-node capture() enqueues async; this SDK event is the durable boundary before optOut().
|
||||
stopListening = client.on('capture', (payload: unknown) => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return
|
||||
}
|
||||
if (
|
||||
'event' in payload &&
|
||||
payload.event === event &&
|
||||
'uuid' in payload &&
|
||||
payload.uuid === uuid
|
||||
) {
|
||||
settle(true)
|
||||
}
|
||||
})
|
||||
|
||||
timeout = setTimeout(() => settle(false), OPT_OUT_CAPTURE_ENQUEUE_TIMEOUT_MS)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to flip the PostHog SDK's in-memory `optedOut` flag at boot.
|
||||
*
|
||||
* True for DISABLED cohorts only, re-applied every boot (the flag doesn't persist) so any direct
|
||||
* `posthog.capture()` bypassing `track()` still drops for an opted-out user. Deliberately excludes
|
||||
* `pending_banner`: the direct `telemetry_opted_out` capture in `setOptIn(_, false)` must not drop,
|
||||
* or we'd lose the one signal that the opt-out flow works.
|
||||
*/
|
||||
export function shouldOptOutSdkAtInit(consent: ConsentState): boolean {
|
||||
return consent.effective === 'disabled'
|
||||
}
|
||||
|
||||
export function createPostHogClient(writeKey: string, consent: ConsentState): PostHog {
|
||||
const client = new PostHog(writeKey, {
|
||||
host: 'https://us.i.posthog.com',
|
||||
flushAt: 20,
|
||||
flushInterval: 10_000,
|
||||
// Strip SDK-auto GeoIP / client-IP enrichment; our wire is exactly CommonProps ∪ EventProps ∪ a small allow-list.
|
||||
disableGeoip: true,
|
||||
// Bumped from the default 1000 (drops oldest-first past cap) to 5000 to tolerate long-offline sessions.
|
||||
maxQueueSize: 5000
|
||||
})
|
||||
|
||||
if (shouldOptOutSdkAtInit(consent)) {
|
||||
client.optOut()
|
||||
}
|
||||
return client
|
||||
}
|
||||
@@ -47,9 +47,9 @@ export type DiagnosticsApi = {
|
||||
}
|
||||
|
||||
export type TelemetryApi = {
|
||||
/** Fire-and-forget track. Loose IPC typing on purpose — the main-side validator enforces;
|
||||
* renderer sites should import `track<N>()` from lib/telemetry.ts, not reach here. */
|
||||
telemetryTrack: (name: string, props: Record<string, unknown>) => Promise<void>
|
||||
/** True means captured or already recorded today; older bridges return void.
|
||||
* Use lib/telemetry.ts wrappers; main validates the deliberately loose IPC payload. */
|
||||
telemetryTrack: (name: string, props: Record<string, unknown>) => Promise<boolean | void>
|
||||
/** Flip the persisted opt-in preference. Subject to a per-session
|
||||
* consent-mutation rate limit on the main side (≤5/session). */
|
||||
telemetrySetOptIn: (optedIn: boolean) => Promise<void>
|
||||
|
||||
@@ -22,11 +22,7 @@ import { FirstLaunchBanner } from './FirstLaunchBanner'
|
||||
|
||||
export function TelemetryFirstLaunchSurface(): React.JSX.Element | null {
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
// Main's `telemetry:setOptIn` / `telemetry:acknowledgeBanner` handlers
|
||||
// intentionally do NOT broadcast `settings:changed`, so the notice
|
||||
// must call `fetchSettings()` itself after its IPC writes to keep the
|
||||
// renderer store (and PrivacyPane) from rendering stale telemetry
|
||||
// state. See PrivacyPane.tsx for the identical pattern.
|
||||
// Rebase to main's saved consent after the IPC write, including when a mutation was rejected.
|
||||
const fetchSettings = useAppStore((s) => s.fetchSettings)
|
||||
const [dismissedThisSession, setDismissedThisSession] = useState(false)
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { APP_MENU_PASTE_EVENT } from '@/lib/app-menu-paste'
|
||||
import { APP_MENU_SELECTION_ACTION_EVENT } from '@/lib/app-menu-selection-actions'
|
||||
|
||||
const useAgentDashboardTelemetry = vi.hoisted(() => vi.fn())
|
||||
vi.mock('./use-agent-dashboard-telemetry', () => ({ useAgentDashboardTelemetry }))
|
||||
vi.mock('./AgentKanbanBoard', () => ({ AgentKanbanBoard: () => null }))
|
||||
vi.mock('./useDashboardSnapshot', () => ({ useDashboardSnapshot: () => null }))
|
||||
|
||||
@@ -52,6 +54,7 @@ describe('DashboardPopoutRoot', () => {
|
||||
window.addEventListener(APP_MENU_PASTE_EVENT, onPaste)
|
||||
window.addEventListener(APP_MENU_SELECTION_ACTION_EVENT, onSelectionAction)
|
||||
render(<DashboardPopoutRoot />)
|
||||
expect(useAgentDashboardTelemetry).toHaveBeenCalledWith('popout')
|
||||
expect(emitAppMenuPaste).not.toBeNull()
|
||||
expect(emitAppMenuSelectionAction).not.toBeNull()
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useAppMenuPaste } from '@/hooks/useAppMenuPaste'
|
||||
import { useAppMenuSelectionActions } from '@/hooks/useAppMenuSelectionActions'
|
||||
import { AgentKanbanBoard } from './AgentKanbanBoard'
|
||||
import { useDashboardSnapshot } from './useDashboardSnapshot'
|
||||
import { useAgentDashboardTelemetry } from './use-agent-dashboard-telemetry'
|
||||
|
||||
/**
|
||||
* Root of the pop-out dashboard window. Subscribes to the live snapshot relayed
|
||||
@@ -12,6 +13,7 @@ export function DashboardPopoutRoot(): React.JSX.Element {
|
||||
// Edit-menu IPC into the ownership events the terminal preview claims.
|
||||
useAppMenuPaste()
|
||||
useAppMenuSelectionActions()
|
||||
useAgentDashboardTelemetry('popout')
|
||||
const snapshot = useDashboardSnapshot()
|
||||
return <AgentKanbanBoard snapshot={snapshot} />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
import type { TelemetryConsentState } from '../../../../shared/telemetry-consent-types'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
const { track, getConsentState } = vi.hoisted(() => ({
|
||||
track: vi.fn(),
|
||||
getConsentState: vi.fn()
|
||||
}))
|
||||
vi.mock('@/lib/telemetry', () => ({ track, trackWithResult: track, getConsentState }))
|
||||
|
||||
import { useAgentDashboardTelemetry } from './use-agent-dashboard-telemetry'
|
||||
|
||||
const initialState = useAppStore.getInitialState()
|
||||
|
||||
async function flushConsent(): Promise<void> {
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
function publishSavedConsent(optedIn: boolean | null): void {
|
||||
useAppStore.setState((state) => ({
|
||||
settings: {
|
||||
...(state.settings ?? getDefaultSettings(process.cwd())),
|
||||
telemetry: { optedIn, installId: 'test-install', existedBeforeTelemetryRelease: false }
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
describe('dashboard visible-use telemetry', () => {
|
||||
let visibility: DocumentVisibilityState
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
track.mockReset().mockResolvedValue(true)
|
||||
getConsentState.mockReset().mockResolvedValue({ effective: 'enabled' })
|
||||
useAppStore.setState({
|
||||
settings: {
|
||||
...getDefaultSettings(process.cwd()),
|
||||
telemetry: {
|
||||
optedIn: true,
|
||||
installId: 'test-install',
|
||||
existedBeforeTelemetryRelease: false
|
||||
}
|
||||
}
|
||||
})
|
||||
visibility = 'visible'
|
||||
vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibility)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
useAppStore.setState(initialState, true)
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it.each(['popout', 'in_window'] as const)(
|
||||
'observes %s across days without recording another opening',
|
||||
async (surface) => {
|
||||
vi.setSystemTime(new Date('2026-10-22T12:00:00Z'))
|
||||
renderHook(() => useAgentDashboardTelemetry(surface))
|
||||
await flushConsent()
|
||||
expect(track.mock.calls).toEqual([
|
||||
['agent_dashboard_opened', { surface }],
|
||||
['agent_dashboard_active', { surface }]
|
||||
])
|
||||
|
||||
vi.setSystemTime(new Date('2026-10-23T12:00:00Z'))
|
||||
act(() => vi.advanceTimersByTime(60_000))
|
||||
|
||||
expect(track.mock.calls.filter(([event]) => event === 'agent_dashboard_opened')).toHaveLength(
|
||||
1
|
||||
)
|
||||
expect(track.mock.calls.filter(([event]) => event === 'agent_dashboard_active')).toHaveLength(
|
||||
2
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('waits for a hidden window to become visible and pauses again when hidden', async () => {
|
||||
visibility = 'hidden'
|
||||
const { unmount } = renderHook(() => useAgentDashboardTelemetry('popout'))
|
||||
await flushConsent()
|
||||
act(() => vi.advanceTimersByTime(60_000))
|
||||
expect(track.mock.calls).toEqual([['agent_dashboard_opened', { surface: 'popout' }]])
|
||||
|
||||
visibility = 'visible'
|
||||
act(() => document.dispatchEvent(new Event('visibilitychange')))
|
||||
expect(track).toHaveBeenLastCalledWith('agent_dashboard_active', { surface: 'popout' })
|
||||
expect(track).toHaveBeenCalledTimes(2)
|
||||
|
||||
visibility = 'hidden'
|
||||
act(() => document.dispatchEvent(new Event('visibilitychange')))
|
||||
act(() => vi.advanceTimersByTime(120_000))
|
||||
expect(track).toHaveBeenCalledTimes(2)
|
||||
|
||||
unmount()
|
||||
visibility = 'visible'
|
||||
act(() => document.dispatchEvent(new Event('visibilitychange')))
|
||||
act(() => vi.advanceTimersByTime(120_000))
|
||||
expect(track).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('stops observations when the board closes while visible', async () => {
|
||||
const { unmount } = renderHook(() => useAgentDashboardTelemetry('in_window'))
|
||||
await flushConsent()
|
||||
expect(track).toHaveBeenCalledTimes(2)
|
||||
|
||||
unmount()
|
||||
act(() => vi.advanceTimersByTime(120_000))
|
||||
|
||||
expect(track).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('retains a confirmed observation across visibility changes and observes the next visible day', async () => {
|
||||
vi.setSystemTime(new Date('2026-10-22T12:00:00Z'))
|
||||
renderHook(() => useAgentDashboardTelemetry('popout'))
|
||||
await flushConsent()
|
||||
|
||||
for (let index = 0; index < 3; index++) {
|
||||
visibility = 'hidden'
|
||||
act(() => document.dispatchEvent(new Event('visibilitychange')))
|
||||
visibility = 'visible'
|
||||
act(() => document.dispatchEvent(new Event('visibilitychange')))
|
||||
await flushConsent()
|
||||
}
|
||||
expect(track.mock.calls.filter(([event]) => event === 'agent_dashboard_active')).toHaveLength(1)
|
||||
|
||||
visibility = 'hidden'
|
||||
act(() => document.dispatchEvent(new Event('visibilitychange')))
|
||||
vi.setSystemTime(new Date('2026-10-24T12:00:00Z'))
|
||||
visibility = 'visible'
|
||||
act(() => document.dispatchEvent(new Event('visibilitychange')))
|
||||
await flushConsent()
|
||||
|
||||
expect(track.mock.calls.filter(([event]) => event === 'agent_dashboard_active')).toHaveLength(2)
|
||||
expect(track.mock.calls.filter(([event]) => event === 'agent_dashboard_opened')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('sends only two visible-use observations across 24 hours without rerendering the board', async () => {
|
||||
vi.setSystemTime(new Date('2026-10-22T12:00:00Z'))
|
||||
let renders = 0
|
||||
renderHook(() => {
|
||||
renders++
|
||||
useAgentDashboardTelemetry('popout')
|
||||
})
|
||||
await flushConsent()
|
||||
|
||||
act(() => vi.advanceTimersByTime(24 * 60 * 60_000))
|
||||
|
||||
expect(track.mock.calls.filter(([event]) => event === 'agent_dashboard_active')).toHaveLength(2)
|
||||
expect(getConsentState).toHaveBeenCalledTimes(1)
|
||||
expect(renders).toBe(1)
|
||||
})
|
||||
|
||||
it.each(['pending_banner', 'user_opt_out', 'do_not_track', 'orca_disabled', 'ci'] as const)(
|
||||
'creates no timer or daily-use attempts while blocked by %s',
|
||||
async (reason) => {
|
||||
getConsentState.mockResolvedValue(
|
||||
reason === 'pending_banner'
|
||||
? { effective: 'pending_banner' }
|
||||
: { effective: 'disabled', reason }
|
||||
)
|
||||
renderHook(() => useAgentDashboardTelemetry('popout'))
|
||||
await flushConsent()
|
||||
|
||||
act(() => vi.advanceTimersByTime(24 * 60 * 60_000))
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(track.mock.calls).toEqual([['agent_dashboard_opened', { surface: 'popout' }]])
|
||||
}
|
||||
)
|
||||
|
||||
it('starts and stops observing on consent broadcasts without counting a new opening', async () => {
|
||||
publishSavedConsent(null)
|
||||
getConsentState.mockResolvedValue({ effective: 'pending_banner' })
|
||||
renderHook(() => useAgentDashboardTelemetry('popout'))
|
||||
await flushConsent()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
getConsentState.mockResolvedValue({ effective: 'enabled' })
|
||||
act(() => {
|
||||
publishSavedConsent(true)
|
||||
})
|
||||
await flushConsent()
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
expect(track.mock.calls.filter(([event]) => event === 'agent_dashboard_active')).toHaveLength(1)
|
||||
|
||||
getConsentState.mockResolvedValue({ effective: 'disabled', reason: 'user_opt_out' })
|
||||
act(() => {
|
||||
publishSavedConsent(false)
|
||||
})
|
||||
await flushConsent()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(track.mock.calls.filter(([event]) => event === 'agent_dashboard_opened')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not install an observer after closing before the consent read finishes', async () => {
|
||||
let resolveConsent: ((value: TelemetryConsentState) => void) | undefined
|
||||
getConsentState.mockReturnValue(
|
||||
new Promise<TelemetryConsentState>((resolve) => {
|
||||
resolveConsent = resolve
|
||||
})
|
||||
)
|
||||
const { unmount } = renderHook(() => useAgentDashboardTelemetry('popout'))
|
||||
unmount()
|
||||
|
||||
resolveConsent?.({ effective: 'enabled' })
|
||||
await flushConsent()
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(track.mock.calls).toEqual([['agent_dashboard_opened', { surface: 'popout' }]])
|
||||
})
|
||||
|
||||
it('retries an unrecorded observation and suppresses same-day ticks after confirmation', async () => {
|
||||
let recorded = false
|
||||
track.mockImplementation(() => Promise.resolve(recorded))
|
||||
renderHook(() => useAgentDashboardTelemetry('popout'))
|
||||
await flushConsent()
|
||||
|
||||
recorded = true
|
||||
await act(async () => vi.advanceTimersByTimeAsync(60_000))
|
||||
await act(async () => vi.advanceTimersByTimeAsync(5 * 60_000))
|
||||
|
||||
expect(track.mock.calls.filter(([event]) => event === 'agent_dashboard_active')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('does not stack IPC requests while an observation is pending', async () => {
|
||||
track.mockReturnValue(new Promise(() => {}))
|
||||
renderHook(() => useAgentDashboardTelemetry('popout'))
|
||||
await flushConsent()
|
||||
|
||||
await act(async () => vi.advanceTimersByTimeAsync(10 * 60_000))
|
||||
|
||||
expect(track.mock.calls.filter(([event]) => event === 'agent_dashboard_active')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('releases timers and visibility listeners after repeated open/close cycles', async () => {
|
||||
const added = vi.spyOn(document, 'addEventListener')
|
||||
const removed = vi.spyOn(document, 'removeEventListener')
|
||||
for (let index = 0; index < 25; index++) {
|
||||
const { unmount } = renderHook(() => useAgentDashboardTelemetry('popout'))
|
||||
await flushConsent()
|
||||
unmount()
|
||||
}
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(added.mock.calls.filter(([event]) => event === 'visibilitychange')).toHaveLength(25)
|
||||
expect(removed.mock.calls.filter(([event]) => event === 'visibilitychange')).toHaveLength(25)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect } from 'react'
|
||||
import type { AgentDashboardSurface } from '../../../../shared/telemetry-agent-dashboard-event-schemas'
|
||||
import { getConsentState, track, trackWithResult } from '@/lib/telemetry'
|
||||
import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
export function useAgentDashboardTelemetry(surface: AgentDashboardSurface): void {
|
||||
const optedIn = useAppStore((state) => state.settings?.telemetry?.optedIn)
|
||||
useEffect(() => {
|
||||
track('agent_dashboard_opened', { surface })
|
||||
}, [surface])
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
let stopObserving: (() => void) | undefined
|
||||
let lastObservedDay = -1
|
||||
let inFlight = false
|
||||
const observe = (): void => {
|
||||
const day = Math.floor(Date.now() / 86_400_000)
|
||||
if (day === lastObservedDay || inFlight) {
|
||||
return
|
||||
}
|
||||
inFlight = true
|
||||
void trackWithResult('agent_dashboard_active', { surface }).then((recorded) => {
|
||||
inFlight = false
|
||||
if (!disposed && recorded !== false) {
|
||||
lastObservedDay = day
|
||||
}
|
||||
})
|
||||
}
|
||||
void getConsentState().then((consent) => {
|
||||
if (disposed || consent.effective !== 'enabled') {
|
||||
return
|
||||
}
|
||||
stopObserving = installWindowVisibilityInterval({
|
||||
run: observe,
|
||||
intervalMs: 60_000
|
||||
})
|
||||
})
|
||||
return () => {
|
||||
disposed = true
|
||||
stopObserving?.()
|
||||
}
|
||||
}, [surface, optedIn])
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useAppStore } from '@/store'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
useLiveDashboardSnapshot: vi.fn(() => ({ generatedAt: 1, cards: [] })),
|
||||
useAgentDashboardTelemetry: vi.fn(),
|
||||
blockingOverlay: false,
|
||||
boardProps: null as Record<string, unknown> | null,
|
||||
activateTabAndFocusPane: vi.fn(),
|
||||
@@ -24,6 +25,10 @@ vi.mock('./useLiveDashboardSnapshot', () => ({
|
||||
useLiveDashboardSnapshot: mocks.useLiveDashboardSnapshot
|
||||
}))
|
||||
|
||||
vi.mock('../dashboard-popout/use-agent-dashboard-telemetry', () => ({
|
||||
useAgentDashboardTelemetry: mocks.useAgentDashboardTelemetry
|
||||
}))
|
||||
|
||||
vi.mock('../dashboard-popout/AgentKanbanBoard', () => ({
|
||||
AgentKanbanBoard: (props: Record<string, unknown>) => {
|
||||
mocks.boardProps = props
|
||||
@@ -54,6 +59,7 @@ beforeEach(() => {
|
||||
false
|
||||
)
|
||||
mocks.useLiveDashboardSnapshot.mockClear()
|
||||
mocks.useAgentDashboardTelemetry.mockClear()
|
||||
mocks.activateTabAndFocusPane.mockClear()
|
||||
mocks.activateAndRevealWorkspace.mockClear()
|
||||
mocks.activateAndRevealWorkspace.mockReturnValue({ primaryTabId: null })
|
||||
@@ -74,9 +80,11 @@ describe('AgentDashboardDrawer', () => {
|
||||
render(<AgentDashboardDrawer statusBarVisible />)
|
||||
|
||||
expect(mocks.useLiveDashboardSnapshot).not.toHaveBeenCalled()
|
||||
expect(mocks.useAgentDashboardTelemetry).not.toHaveBeenCalled()
|
||||
|
||||
act(() => useAppStore.setState({ agentDashboardDrawerOpen: true }))
|
||||
expect(mocks.useLiveDashboardSnapshot).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.useAgentDashboardTelemetry).toHaveBeenCalledWith('in_window')
|
||||
})
|
||||
|
||||
it('leaves Escape to an open terminal panel before dismissing the drawer', () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '../sidebar/workspace-chrome-metrics'
|
||||
import { AgentDashboardSettingsMenu } from './AgentDashboardSettingsMenu'
|
||||
import { useLiveDashboardSnapshot } from './useLiveDashboardSnapshot'
|
||||
import { useAgentDashboardTelemetry } from '../dashboard-popout/use-agent-dashboard-telemetry'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
// Why: Escape should dismiss interactive nested overlays (e.g. the terminal
|
||||
@@ -40,6 +41,7 @@ function AgentDashboardDrawerBody({
|
||||
onMenuOpenChange: (open: boolean) => void
|
||||
}): React.JSX.Element {
|
||||
const snapshot = useLiveDashboardSnapshot()
|
||||
useAgentDashboardTelemetry('in_window')
|
||||
|
||||
// In-window ack/reveal act on the local store directly — the pop-out's IPC
|
||||
// relay is gated to the pop-out renderer and would reject calls from here.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { track, trackWithResult } from './telemetry'
|
||||
|
||||
describe('telemetry observation result', () => {
|
||||
const bridgeTrack = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
bridgeTrack.mockReset()
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
vi.stubGlobal('window', { api: { telemetryTrack: bridgeTrack } })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it.each([true, false, undefined])(
|
||||
'preserves the bridge result %s, including older bridges',
|
||||
async (result) => {
|
||||
bridgeTrack.mockResolvedValue(result)
|
||||
|
||||
expect(await trackWithResult('agent_dashboard_active', { surface: 'popout' })).toBe(result)
|
||||
expect(bridgeTrack).toHaveBeenCalledWith('agent_dashboard_active', { surface: 'popout' })
|
||||
}
|
||||
)
|
||||
|
||||
it('lets IPC failures retry and keeps fire-and-forget calls from rejecting', async () => {
|
||||
bridgeTrack.mockRejectedValue(new Error('IPC unavailable'))
|
||||
|
||||
expect(await trackWithResult('agent_dashboard_active', { surface: 'popout' })).toBe(false)
|
||||
expect(track('agent_dashboard_opened', { surface: 'popout' })).toBeUndefined()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
it('returns false when the bridge is missing or throws synchronously', async () => {
|
||||
vi.stubGlobal('window', {})
|
||||
expect(await trackWithResult('agent_dashboard_active', { surface: 'popout' })).toBe(false)
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
telemetryTrack: () => {
|
||||
throw new Error('preload mismatch')
|
||||
}
|
||||
}
|
||||
})
|
||||
expect(await trackWithResult('agent_dashboard_active', { surface: 'popout' })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -27,13 +27,25 @@ function isTelemetryConsentState(x: unknown): x is TelemetryConsentState {
|
||||
}
|
||||
|
||||
export function track<N extends EventName>(name: N, props: EventProps<N>): void {
|
||||
void trackWithResult(name, props)
|
||||
}
|
||||
|
||||
// Undefined supports older bridges; false lets visible-use observations retry a rejected attempt.
|
||||
export function trackWithResult<N extends EventName>(
|
||||
name: N,
|
||||
props: EventProps<N>
|
||||
): Promise<boolean | void> {
|
||||
// Why: telemetry is fire-and-forget and must never throw into the renderer; log (don't rethrow/silently swallow) so IPC failures leave a breadcrumb.
|
||||
try {
|
||||
void window.api?.telemetryTrack?.(name, props as Record<string, unknown>)?.catch((err) => {
|
||||
console.warn('[telemetry] IPC track failed', err)
|
||||
})
|
||||
return (
|
||||
window.api?.telemetryTrack?.(name, { ...props })?.catch((err) => {
|
||||
console.warn('[telemetry] IPC track failed', err)
|
||||
return false
|
||||
}) ?? Promise.resolve(false)
|
||||
)
|
||||
} catch (err) {
|
||||
console.warn('[telemetry] IPC track threw synchronously', err)
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -525,8 +525,7 @@ export type GlobalSettings = GlobalSettingsBase & {
|
||||
sourceControlAi?: SourceControlAiSettings
|
||||
/** GitLab project preferences (pinned + recent paths). Optional for pre-GitLab profiles; persistence merge fills the default. */
|
||||
gitlabProjects?: GitLabProjectSettings
|
||||
/** Anonymous product-telemetry state; optional until the one-shot Store.load() migration populates it.
|
||||
* Holds consent, identity, and frozen experiments, not volatile counters — those would amplify the debounced settings write. */
|
||||
/** Anonymous telemetry consent, identity, frozen experiments, and daily usage limits; optional before migration. */
|
||||
telemetry?: TelemetrySettings
|
||||
/** One-shot cohort marker for the tab-switch keybinding swap. 'pending' =
|
||||
* pre-existing install (seed pins old chords, then flips to 'done'); 'done' = fresh install. */
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { eventSchemas } from './telemetry-events'
|
||||
|
||||
describe('Agent Dashboard telemetry schemas', () => {
|
||||
it('accepts only the adoption and rendered-surface payloads', () => {
|
||||
expect(eventSchemas.agent_dashboard_enabled.safeParse({ enabled: true }).success).toBe(true)
|
||||
expect(eventSchemas.agent_dashboard_opened.safeParse({ surface: 'popout' }).success).toBe(true)
|
||||
expect(eventSchemas.agent_dashboard_opened.safeParse({ surface: 'in_window' }).success).toBe(
|
||||
true
|
||||
)
|
||||
expect(eventSchemas.agent_dashboard_active.safeParse({ surface: 'popout' }).success).toBe(true)
|
||||
expect(eventSchemas.agent_dashboard_active.safeParse({ surface: 'in_window' }).success).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects unbounded or ambiguous dashboard telemetry', () => {
|
||||
expect(eventSchemas.agent_dashboard_enabled.safeParse({}).success).toBe(false)
|
||||
expect(eventSchemas.agent_dashboard_opened.safeParse({ surface: 'sidebar' }).success).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
eventSchemas.agent_dashboard_opened.safeParse({ surface: 'popout', card: 'task' }).success
|
||||
).toBe(false)
|
||||
expect(eventSchemas.agent_dashboard_active.safeParse({ surface: 'sidebar' }).success).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
eventSchemas.agent_dashboard_active.safeParse({ surface: 'popout', day: 123 }).success
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const agentDashboardSurfaceSchema = z.enum(['in_window', 'popout'])
|
||||
export type AgentDashboardSurface = z.infer<typeof agentDashboardSurfaceSchema>
|
||||
|
||||
// No agent, workspace, or card data: adoption only needs the setting transition.
|
||||
export const agentDashboardEnabledSchema = z.object({ enabled: z.boolean() }).strict()
|
||||
|
||||
// Emitted only after the board React tree mounts, not when an open request is made.
|
||||
export const agentDashboardOpenedSchema = z
|
||||
.object({ surface: agentDashboardSurfaceSchema })
|
||||
.strict()
|
||||
|
||||
// One visible-use observation per UTC day and surface, capped in main across restarts.
|
||||
export const agentDashboardActiveSchema = z
|
||||
.object({ surface: agentDashboardSurfaceSchema })
|
||||
.strict()
|
||||
@@ -30,6 +30,7 @@ export const nthRepoAddedSchema = z.number().int().nonnegative().optional()
|
||||
export const appOpenedSchema = z
|
||||
.object({
|
||||
nth_repo_added: nthRepoAddedSchema,
|
||||
agent_dashboard_enabled: z.boolean().optional(),
|
||||
// Accept retired sampling fields from older callers; current builds no longer emit them.
|
||||
tab_close_sampled: z.boolean().optional(),
|
||||
tab_close_sample_rate: z.literal(0.1).optional()
|
||||
|
||||
@@ -4,6 +4,11 @@ import {
|
||||
onboardingLanguageRequestedSchema
|
||||
} from './telemetry-onboarding-language-schemas'
|
||||
import { agentTokenUsageSchema } from './telemetry-agent-token-usage-schema'
|
||||
import {
|
||||
agentDashboardActiveSchema,
|
||||
agentDashboardEnabledSchema,
|
||||
agentDashboardOpenedSchema
|
||||
} from './telemetry-agent-dashboard-event-schemas'
|
||||
import {
|
||||
agentErrorSchema,
|
||||
agentPromptSentSchema,
|
||||
@@ -110,6 +115,9 @@ import {
|
||||
// ── Event registry: the one record the validator consumes ───────────────
|
||||
// Versioning: breaking changes (rename/re-mean/remove a key) need a new event name; in-place edits blend pre/post rows unmixably. Additive-optional fields are safe.
|
||||
export const eventSchemas = {
|
||||
agent_dashboard_active: agentDashboardActiveSchema,
|
||||
agent_dashboard_enabled: agentDashboardEnabledSchema,
|
||||
agent_dashboard_opened: agentDashboardOpenedSchema,
|
||||
app_opened: appOpenedSchema,
|
||||
app_starred_orca: appStarredOrcaSchema,
|
||||
star_nag_outcome: starNagOutcomeEventSchema,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { AgentDashboardSurface } from './telemetry-agent-dashboard-event-schemas'
|
||||
|
||||
export type TelemetrySettings = {
|
||||
/** New users: true at install. Existing users: null until they resolve the first-launch banner. */
|
||||
optedIn: boolean | null
|
||||
@@ -7,4 +9,6 @@ export type TelemetrySettings = {
|
||||
existedBeforeTelemetryRelease: boolean
|
||||
/** Frozen 50/50 assignment for fresh installs; absent for profiles predating the experiment. */
|
||||
onboardingLanguageCohort?: 'control' | 'treatment'
|
||||
/** Local UTC-day cursors cap dashboard observations across restarts; at most two writes a day. */
|
||||
agentDashboardActiveDays?: Partial<Record<AgentDashboardSurface, number>>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user