diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7bfaaa8c3..d41d0f75b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -190,6 +190,7 @@ jobs: test-desktop: needs: install runs-on: ubuntu-latest + timeout-minutes: 15 strategy: fail-fast: false matrix: diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index c06496d34..53ff566c6 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -103,6 +103,8 @@ function createRuntime() { logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, productAnalyticsService: { capture: vi.fn(() => ({ accepted: true, reason: "accepted" })), + identifyAccount: vi.fn(() => ({ accepted: true, reason: "accepted" })), + resetAccountIdentity: vi.fn(() => true), getStatus: vi.fn(() => ({ configured: true, enabled: true, effective: true })), setEnabled: vi.fn(), flush: vi.fn(async () => true), @@ -3784,6 +3786,7 @@ describe("adeRpcServer", () => { expiresAt: "2026-07-15T10:00:00.000Z", source: "env-token", }); + expect(fixture.runtime.productAnalyticsService.identifyAccount).toHaveBeenCalledWith("user_123"); }); it("rejects product analytics capture from agent-run identities", async () => { diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 3033fe3d4..646a5d1ec 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -124,7 +124,11 @@ import type { AdeRuntime } from "./bootstrap"; import { reseedBundledAdeSkillsForCli } from "./bootstrap"; import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore"; import type { AccountMachinePublisherService } from "./services/account/accountMachinePublisherService"; -import { shouldRejectDevelopmentEnvCredential } from "./services/account/accountAuthService"; +import { + shouldRejectDevelopmentEnvCredential, + syncAccountAnalyticsIdentity, +} from "./services/account/accountAuthService"; +import { getSharedAccountAuthService } from "./services/account/sharedAccountAuthService"; import { DEFAULT_SYNC_HOST_PORT } from "./services/sync/syncProtocol"; import { runAdeCodeRemote, @@ -15651,6 +15655,15 @@ async function runServe( const port = parseOptionalPort(readValue(args, ["--port"]), "--port"); const syncEnabled = !readFlag(args, ["--no-sync"]); const projectRegistry = new ProjectRegistry(layout); + const brainAccountAuthService = getSharedAccountAuthService({ + secretsDir: layout.secretsDir, + projectRoots: () => projectRegistry.list().map((project) => project.rootPath), + logger: headlessProjectLogger, + }); + syncAccountAnalyticsIdentity( + brainAccountAuthService.getStatus(), + brainProductAnalytics, + ); const personalChatScope = createPersonalChatScope(); let preferredSyncProjectId: string | null = null; const preferredSyncProjectRoot = process.env.ADE_PROJECT_ROOT?.trim(); @@ -16049,6 +16062,8 @@ async function runServe( projectRegistry, scopeRegistry, personalChatScope, + productAnalyticsService: brainProductAnalytics, + accountAuthService: brainAccountAuthService, getAccountDirectoryHealth, getRuntimeStatus: () => { const publishHealth = getAccountDirectoryHealth(); diff --git a/apps/ade-cli/src/multiProjectRpcServer.test.ts b/apps/ade-cli/src/multiProjectRpcServer.test.ts index 63bfa856e..e5bf62f59 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.test.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.test.ts @@ -491,6 +491,10 @@ describe("multi-project RPC server", () => { it("reconciles account-owned client trust on sign-out and account switch", async () => { const { registry } = createRegistry(); const accountAuthService = makeAccountAuthServiceMock(); + const productAnalyticsService = { + identifyAccount: vi.fn(), + resetAccountIdentity: vi.fn(), + }; const reconcileAccountOwnership = vi.fn(); const previousDefaultRole = process.env.ADE_DEFAULT_ROLE; process.env.ADE_DEFAULT_ROLE = "cto"; @@ -499,6 +503,7 @@ describe("multi-project RPC server", () => { serverVersion: "test", projectRegistry: registry, accountAuthService, + productAnalyticsService, reconcileAccountOwnership, }); await handler({ @@ -515,6 +520,7 @@ describe("multi-project RPC server", () => { params: { action: "signOut", args: {} }, }); expect(reconcileAccountOwnership).toHaveBeenLastCalledWith(null); + expect(productAnalyticsService.resetAccountIdentity).toHaveBeenCalledTimes(1); (accountAuthService.pollLogin as ReturnType).mockResolvedValueOnce({ status: "signed_in", @@ -534,6 +540,7 @@ describe("multi-project RPC server", () => { params: { action: "pollLogin", args: { sessionId: "test-session" } }, }); expect(reconcileAccountOwnership).toHaveBeenLastCalledWith("account-b"); + expect(productAnalyticsService.identifyAccount).toHaveBeenCalledWith("account-b"); (accountAuthService.getStatus as ReturnType).mockReturnValue({ signedIn: true, diff --git a/apps/ade-cli/src/multiProjectRpcServer.ts b/apps/ade-cli/src/multiProjectRpcServer.ts index 277fa1cdd..7e4fc3f9b 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.ts @@ -63,6 +63,7 @@ import { } from "../../desktop/src/shared/types"; import { callAccountAction, + type AccountAnalyticsIdentity, type AccountAuthService, } from "./services/account/accountAuthService"; import { @@ -101,6 +102,7 @@ export type MultiProjectRpcHandlerOptions = { "capabilities" | "call" | "streamEvents" | "dispose" > & Partial>; accountAuthService?: AccountAuthService; + productAnalyticsService?: AccountAnalyticsIdentity; getAccountDirectoryHealth?: () => SyncAccountDirectoryHealth; getRuntimeStatus?: () => { syncPort: number | null; @@ -1205,6 +1207,7 @@ export function createMultiProjectRpcRequestHandler( } const response = await callAccountAction({ service: accountAuthService, + analytics: options.productAnalyticsService, action, actionArgs: isRecord(params.args) ? params.args : {}, }); diff --git a/apps/ade-cli/src/services/account/accountAuthService.test.ts b/apps/ade-cli/src/services/account/accountAuthService.test.ts index f274549b9..ac02e1adc 100644 --- a/apps/ade-cli/src/services/account/accountAuthService.test.ts +++ b/apps/ade-cli/src/services/account/accountAuthService.test.ts @@ -10,6 +10,7 @@ import { import type { SyncCredentialStore } from "../credentials/credentialStore"; import { ACCOUNT_SESSION_CREDENTIAL_KEY, + createAccountActionDomainService, createAccountAuthService, derivePkceChallenge, getSignedInAccountAccessToken, @@ -103,6 +104,40 @@ afterEach(() => { for (const service of activeServices.splice(0)) service.dispose(); }); +describe("account action analytics identity", () => { + it("identifies persisted signed-in status and resets identity for signed-out status", () => { + const analytics = { + identifyAccount: vi.fn(), + resetAccountIdentity: vi.fn(), + }; + const service = { + getStatus: vi.fn() + .mockReturnValueOnce({ + signedIn: true, + userId: "user_persisted", + email: null, + name: null, + expiresAt: null, + }) + .mockReturnValueOnce({ + signedIn: false, + userId: null, + email: null, + name: null, + expiresAt: null, + }), + } as unknown as AccountAuthService; + const domain = createAccountActionDomainService(service, analytics); + + expect(domain.status()).toMatchObject({ signedIn: true, userId: "user_persisted" }); + expect(analytics.identifyAccount).toHaveBeenCalledWith("user_persisted"); + expect(analytics.resetAccountIdentity).not.toHaveBeenCalled(); + + expect(domain.status()).toMatchObject({ signedIn: false, userId: null }); + expect(analytics.resetAccountIdentity).toHaveBeenCalledTimes(1); + }); +}); + describe("AccountAuthService persisted session notifications", () => { it("preserves an unreadable credential-file diagnosis when the account key is absent", () => { const store = new MemoryCredentialStore(); diff --git a/apps/ade-cli/src/services/account/accountAuthService.ts b/apps/ade-cli/src/services/account/accountAuthService.ts index 23a662c9b..ee127fbd4 100644 --- a/apps/ade-cli/src/services/account/accountAuthService.ts +++ b/apps/ade-cli/src/services/account/accountAuthService.ts @@ -83,6 +83,11 @@ export type AccountSessionRecord = { oauthConfig?: AccountOAuthConfig; }; +export type AccountAnalyticsIdentity = { + identifyAccount(userId: string | null | undefined): unknown; + resetAccountIdentity(): unknown; +}; + export type AccountAuthSource = "loopback" | "device" | "env-token" | null; export type AccountIdentityProvider = "github" | "google" | "apple" | "email"; @@ -648,19 +653,44 @@ function toStatus(record: AccountSessionRecord | null): AccountAuthStatus { }; } +export function syncAccountAnalyticsIdentity( + status: AccountAuthStatus, + analytics?: AccountAnalyticsIdentity, +): AccountAuthStatus { + if (status.signedIn) { + analytics?.identifyAccount(status.userId); + } else { + analytics?.resetAccountIdentity(); + } + return status; +} + export function createAccountActionDomainService( service: AccountAuthService, + analytics?: AccountAnalyticsIdentity, ): AccountActionDomainService { return { startLogin: () => service.startLogin(), - pollLogin: (args) => service.pollLogin(readNonEmptyString(args?.sessionId) ?? ""), + pollLogin: async (args) => { + const result = await service.pollLogin(readNonEmptyString(args?.sessionId) ?? ""); + syncAccountAnalyticsIdentity(result.authStatus, analytics); + return result; + }, startDeviceLogin: (args) => service.startDeviceLogin({ ignoreEnvCredential: args?.ignoreEnvCredential === true, }), - pollDeviceLogin: (args) => service.pollDeviceLogin(readNonEmptyString(args?.sessionId) ?? ""), - status: () => service.getStatus(), + pollDeviceLogin: async (args) => { + const result = await service.pollDeviceLogin(readNonEmptyString(args?.sessionId) ?? ""); + syncAccountAnalyticsIdentity(result.authStatus, analytics); + return result; + }, + status: () => syncAccountAnalyticsIdentity(service.getStatus(), analytics), cancelLogin: (args) => service.cancelLogin(readNonEmptyString(args?.sessionId) ?? ""), - signOut: () => service.signOut(), + signOut: () => { + const status = service.signOut(); + analytics?.resetAccountIdentity(); + return status; + }, getToken: () => service.getAccessToken(), createToken: () => service.createToken(), }; @@ -668,6 +698,7 @@ export function createAccountActionDomainService( export async function callAccountAction(args: { service: AccountAuthService; + analytics?: AccountAnalyticsIdentity; action: string; actionArgs?: Record; }): Promise<{ @@ -677,7 +708,7 @@ export async function callAccountAction(args: { statusHints: Record; }> { const action = args.action as AccountActionName; - const domain = createAccountActionDomainService(args.service); + const domain = createAccountActionDomainService(args.service, args.analytics); if (!ACCOUNT_ACTION_NAMES.includes(action)) { throw new Error(`Action 'account.${args.action}' is not callable.`); } diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 3cf455194..8db8aabd6 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -45,6 +45,7 @@ import { defaultProductAnalyticsStateFile, getSharedProductAnalyticsService, } from "./services/analytics/productAnalyticsService"; +import { detectInstallSource } from "./services/analytics/installSource"; import { captureAgentTurnSettledAnalytics } from "./services/analytics/agentTurnProductAnalytics"; import { initPerfRunFromEnv } from "./services/perf/perfLog"; import { startMetricsSampler } from "./services/perf/metricsSampler"; @@ -1421,6 +1422,18 @@ app.whenReady().then(async () => { appVersion: app.getVersion(), runtimeMode: app.isPackaged ? "desktop_packaged" : "desktop_development", })); + productAnalyticsService.captureInternal({ + event: "ade_app_installed", + surface: "desktop", + properties: { + install_source: detectInstallSource({ + isPackaged: app.isPackaged, + execPath: process.execPath, + resourcesPath: process.resourcesPath, + configuredSource: process.env.ADE_INSTALL_SOURCE, + }), + }, + }); productAnalyticsService.capture({ event: "ade_app_opened", surface: "desktop", diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index 7a11dc80a..43e4c0253 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -2345,9 +2345,9 @@ describe("runtime account actions", () => { expiresAt: "2026-07-14T12:05:00.000Z", })), pollLogin: vi.fn(async (sessionId: string) => ({ - status: "pending" as const, + status: "signed_in" as const, message: null, - authStatus: { signedIn: false, userId: null, email: null, name: null, expiresAt: null }, + authStatus: { signedIn: true, userId: "account-user", email: null, name: null, expiresAt: null }, sessionId, })), startDeviceLogin: vi.fn(async () => ({ @@ -2366,8 +2366,8 @@ describe("runtime account actions", () => { sessionId, })), getStatus: vi.fn(() => ({ - signedIn: false, - userId: null, + signedIn: true, + userId: "account-user", email: null, name: null, expiresAt: null, @@ -2388,8 +2388,13 @@ describe("runtime account actions", () => { })), dispose: vi.fn(), }; + const productAnalyticsService = { + identifyAccount: vi.fn(), + resetAccountIdentity: vi.fn(), + }; const service = getAdeActionDomainServices({ accountAuthService, + productAnalyticsService, } as never).account as { startLogin(): Promise; pollLogin(args: { sessionId: string }): Promise; @@ -2436,6 +2441,9 @@ describe("runtime account actions", () => { expect(accountAuthService.signOut).toHaveBeenCalledTimes(1); expect(accountAuthService.getAccessToken).toHaveBeenCalledTimes(1); expect(accountAuthService.createToken).toHaveBeenCalledTimes(1); + expect(productAnalyticsService.identifyAccount).toHaveBeenCalledTimes(2); + expect(productAnalyticsService.identifyAccount).toHaveBeenCalledWith("account-user"); + expect(productAnalyticsService.resetAccountIdentity).toHaveBeenCalledTimes(2); expect(isCtoOnlyAdeAction("account", "startLogin")).toBe(true); expect(isCtoOnlyAdeAction("account", "pollLogin")).toBe(true); expect(isCtoOnlyAdeAction("account", "startDeviceLogin")).toBe(true); diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 6757b42aa..bff0c6d7a 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -3853,7 +3853,10 @@ export function getAdeActionDomainServices( const automationsEnabled = areAutomationsEnabledForPackagedState(Boolean(runtime.isPackaged)); return { account: runtime.accountAuthService - ? toService(createAccountActionDomainService(runtime.accountAuthService)) + ? toService(createAccountActionDomainService( + runtime.accountAuthService, + runtime.productAnalyticsService ?? undefined, + )) : null, attention: toService(buildAttentionDomainService(runtime)), lane: toService(buildLaneDomainService(runtime)), diff --git a/apps/desktop/src/main/services/analytics/agentTurnProductAnalytics.ts b/apps/desktop/src/main/services/analytics/agentTurnProductAnalytics.ts index eb186437a..74f5563e5 100644 --- a/apps/desktop/src/main/services/analytics/agentTurnProductAnalytics.ts +++ b/apps/desktop/src/main/services/analytics/agentTurnProductAnalytics.ts @@ -31,6 +31,25 @@ export function captureAgentTurnSettledAnalytics(args: { }, }); + if (event.status === "completed") { + analytics.captureInternal({ + event: "ade_app_installed", + surface: "api", + properties: { + install_source: "unknown", + }, + }); + analytics.captureInternal({ + event: "ade_activated", + surface: "api", + projectId, + sessionId: event.sessionId, + properties: { + trigger: "work_session_completed", + }, + }); + } + if (event.status !== "failed") return; analytics.captureInternal({ event: "ade_error", diff --git a/apps/desktop/src/main/services/analytics/installSource.test.ts b/apps/desktop/src/main/services/analytics/installSource.test.ts new file mode 100644 index 000000000..a62bc9634 --- /dev/null +++ b/apps/desktop/src/main/services/analytics/installSource.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { detectInstallSource } from "./installSource"; + +describe("detectInstallSource", () => { + it("separates development, explicit distribution, Homebrew, and unknown installs", () => { + expect(detectInstallSource({ + isPackaged: false, + execPath: "/tmp/ADE", + })).toBe("development"); + expect(detectInstallSource({ + isPackaged: true, + execPath: "/private/tmp/ADE", + configuredSource: "direct_download", + })).toBe("direct_download"); + expect(detectInstallSource({ + isPackaged: true, + execPath: "/Applications/ADE.app/Contents/MacOS/ADE", + realpath: () => "/opt/homebrew/Caskroom/ade/1.2.3/ADE.app/Contents/MacOS/ADE", + })).toBe("homebrew"); + expect(detectInstallSource({ + isPackaged: true, + execPath: "/private/tmp/ADE.app/Contents/MacOS/ADE", + resourcesPath: "/private/tmp/ADE.app/Contents/Resources", + realpath: (candidate) => candidate, + })).toBe("unknown"); + expect(detectInstallSource({ + isPackaged: true, + execPath: "/Applications/ADE.app/Contents/MacOS/ADE", + resourcesPath: "/Applications/ADE.app/Contents/Resources", + realpath: (candidate) => candidate, + })).toBe("direct_download"); + }); +}); diff --git a/apps/desktop/src/main/services/analytics/installSource.ts b/apps/desktop/src/main/services/analytics/installSource.ts new file mode 100644 index 000000000..659e91124 --- /dev/null +++ b/apps/desktop/src/main/services/analytics/installSource.ts @@ -0,0 +1,50 @@ +import fs from "node:fs"; +import path from "node:path"; + +export type ProductAnalyticsInstallSource = + | "development" + | "homebrew" + | "direct_download" + | "unknown"; + +type DetectInstallSourceArgs = { + isPackaged: boolean; + execPath: string; + resourcesPath?: string | null; + configuredSource?: string | null; + realpath?: (candidate: string) => string; +}; + +function canonicalPath( + candidate: string | null | undefined, + realpath: (candidate: string) => string, +): string | null { + if (!candidate) return null; + try { + return realpath(candidate); + } catch { + return path.resolve(candidate); + } +} + +export function detectInstallSource(args: DetectInstallSourceArgs): ProductAnalyticsInstallSource { + if (!args.isPackaged) return "development"; + + const configuredSource = args.configuredSource?.trim().toLowerCase(); + if (configuredSource === "homebrew" || configuredSource === "direct_download") { + return configuredSource; + } + + const realpath = args.realpath ?? fs.realpathSync.native; + const candidates = [args.execPath, args.resourcesPath] + .flatMap((candidate) => [candidate, canonicalPath(candidate, realpath)]) + .filter((candidate): candidate is string => Boolean(candidate)); + if (candidates.some((candidate) => /(?:^|[/\\])(?:homebrew|caskroom)(?:[/\\]|$)/i.test(candidate))) { + return "homebrew"; + } + + if (candidates.some((candidate) => /(?:^|[/\\])Applications[/\\]ADE(?: Beta)?\.app(?:[/\\]|$)/i.test(candidate))) { + return "direct_download"; + } + return "unknown"; +} diff --git a/apps/desktop/src/main/services/analytics/productAnalyticsPolicy.ts b/apps/desktop/src/main/services/analytics/productAnalyticsPolicy.ts index 9873d93b6..37ce75578 100644 --- a/apps/desktop/src/main/services/analytics/productAnalyticsPolicy.ts +++ b/apps/desktop/src/main/services/analytics/productAnalyticsPolicy.ts @@ -14,6 +14,7 @@ export const PROCESS_INGRESS_LIMIT_PER_MINUTE = 120; export const MAX_LOCAL_IDENTIFIER_LENGTH = 512; export const INTERNAL_ONLY_EVENTS = new Set([ + "ade_app_installed", "ade_activated", "ade_work_session_started", "ade_work_session_completed", "ade_daily_usage_summary", "ade_analytics_budget", "ade_update_install_aborted", "ade_update_quit_escalated", "ade_update_install_did_not_land", "ade_update_auto_applied", @@ -22,7 +23,9 @@ export const INTERNAL_ONLY_EVENTS = new Set([ ]); export const EVENT_DAILY_BUDGETS: Record = { + ade_app_installed: 1, ade_app_opened: 12, + ade_activated: 1, ade_screen_viewed: 80, ade_project_opened: 20, ade_feature_used: 140, @@ -36,12 +39,15 @@ export const EVENT_DAILY_BUDGETS: Record = { ade_update_install_did_not_land: 10, ade_update_auto_applied: 10, ade_update_auto_apply_cancelled: 10, + ade_update_prompted: 10, ade_brain_recovered: 10, ade_publish_failing: 10, }; export const EVENT_MINUTE_BUDGETS: Record = { + ade_app_installed: 1, ade_app_opened: 3, + ade_activated: 1, ade_screen_viewed: 12, ade_project_opened: 6, ade_feature_used: 30, @@ -55,6 +61,7 @@ export const EVENT_MINUTE_BUDGETS: Record = { ade_update_install_did_not_land: 3, ade_update_auto_applied: 3, ade_update_auto_apply_cancelled: 3, + ade_update_prompted: 3, ade_brain_recovered: 3, ade_publish_failing: 3, }; @@ -63,7 +70,7 @@ const STRING_PROPERTIES = new Set([ "screen", "feature", "action", "outcome", "app_version", "runtime_mode", "provider", "model_family", "duration_bucket", "error_kind", "route_kind", "connection_state", "drop_reason", "source", "mode", "entry_point", "release_channel", "summary_kind", "reason", "last_command", "leg", "code", - "escalation_reason", + "escalation_reason", "install_source", "trigger", "from_version", "to_version", "user_action", ]); const NUMBER_PROPERTIES = new Set([ "sent_count", "dropped_count", "interaction_count", "session_count", "chat_session_count", @@ -72,6 +79,7 @@ const NUMBER_PROPERTIES = new Set([ "active_days", "current_streak_days", "token_count", "input_token_count", "output_token_count", "call_count", "duration_ms", "provider_count", "model_count", "error_count", "bytes_freed", "files_compressed", "blocked_ms", "failing_minutes", "attempt", + "time_since_install_seconds", ]); const BOOLEAN_PROPERTIES = new Set([ "recoverable", "paired", "cached_data", "is_packaged", "native_staging_completed", @@ -86,9 +94,11 @@ const ANALYTICS_ONLY_ACTIONS = new Set([ ]); const EVENT_PROPERTY_KEYS: Record> = { + ade_app_installed: new Set(["install_source"]), ade_app_opened: new Set([ "entry_point", "source", "release_channel", "mode", "connection_state", "paired", "cached_data", "is_packaged", ]), + ade_activated: new Set(["trigger", "time_since_install_seconds"]), ade_screen_viewed: new Set(["screen", "route_kind", "source", "mode"]), ade_project_opened: new Set(["route_kind", "source", "mode", "connection_state"]), ade_feature_used: new Set([ @@ -113,6 +123,7 @@ const EVENT_PROPERTY_KEYS: Record ade_update_install_did_not_land: new Set(["attempt"]), ade_update_auto_applied: new Set(), ade_update_auto_apply_cancelled: new Set(), + ade_update_prompted: new Set(["from_version", "to_version", "user_action"]), ade_brain_recovered: new Set(["blocked_ms", "last_command"]), ade_publish_failing: new Set(["failing_minutes", "leg", "code"]), }; @@ -157,6 +168,9 @@ const SAFE_STRING_VALUES: Partial>> = { summary_kind: new Set(["overall", "client", "provider", "model"]), reason: new Set(AUTO_UPDATE_INSTALL_ABORT_REASONS), escalation_reason: new Set(["hard_deadline", "post_staging"]), + install_source: new Set(["direct_download", "homebrew", "development", "unknown"]), + trigger: new Set(["work_session_completed"]), + user_action: new Set(["accepted", "deferred", "dismissed"]), }; export function safeProductAnalyticsString(value: ProductAnalyticsPropertyValue): string | null { diff --git a/apps/desktop/src/main/services/analytics/productAnalyticsService.test.ts b/apps/desktop/src/main/services/analytics/productAnalyticsService.test.ts index e3770da93..3198ba5af 100644 --- a/apps/desktop/src/main/services/analytics/productAnalyticsService.test.ts +++ b/apps/desktop/src/main/services/analytics/productAnalyticsService.test.ts @@ -30,6 +30,7 @@ function makeHarness(options: { root?: string; messages?: Array>; appVersion?: string; + runtimeMode?: string; captureClientMessage?: ProductAnalyticsClient["capture"]; } = {}) { const root = options.root ?? fs.mkdtempSync(path.join(os.tmpdir(), "ade-product-analytics-")); @@ -49,7 +50,7 @@ function makeHarness(options: { stateFilePath: path.join(root, "analytics.json"), logger: { debug: vi.fn(), warn: vi.fn() } as never, appVersion: options.appVersion ?? "1.2.3", - runtimeMode: "test_harness", + runtimeMode: options.runtimeMode ?? "test_harness", projectToken: options.token ?? "phc_test_project_token", dailyBudget: options.dailyBudget, now: options.now, @@ -70,6 +71,7 @@ describe("productAnalyticsService", () => { vi.stubEnv("VITEST", "false"); vi.stubEnv("NODE_ENV", "development"); vi.stubEnv("ADE_DISABLE_PRODUCT_ANALYTICS", "0"); + vi.stubEnv("ADE_ENABLE_PRODUCT_ANALYTICS_IN_DEVELOPMENT", "1"); }); afterEach(() => { @@ -404,7 +406,7 @@ describe("productAnalyticsService", () => { }); expect(configured.messages).toHaveLength(0); expect(JSON.parse(fs.readFileSync(path.join(configured.root, "analytics.json"), "utf8"))).toMatchObject({ - version: 1, + version: 2, enabled: false, }); const unconfiguredRestart = makeHarness({ root: configured.root, token: "" }); @@ -417,6 +419,33 @@ describe("productAnalyticsService", () => { fs.rmSync(configured.root, { recursive: true, force: true }); }); + it("is inert in development unless analytics is explicitly enabled", () => { + vi.stubEnv("ADE_ENABLE_PRODUCT_ANALYTICS_IN_DEVELOPMENT", "0"); + const harness = makeHarness(); + expect(harness.service.captureInternal({ + event: "ade_app_installed", + surface: "desktop", + properties: { install_source: "development" }, + })).toEqual({ accepted: false, reason: "disabled" }); + expect(harness.messages).toHaveLength(0); + expect(fs.existsSync(path.join(harness.root, "analytics.json"))).toBe(false); + fs.rmSync(harness.root, { recursive: true, force: true }); + }); + + it("recognizes the unpackaged desktop runtime as development when NODE_ENV is unset", () => { + vi.stubEnv("NODE_ENV", ""); + vi.stubEnv("ADE_ENABLE_PRODUCT_ANALYTICS_IN_DEVELOPMENT", "0"); + const harness = makeHarness({ runtimeMode: "desktop_development" }); + + expect(harness.service.capture({ event: "ade_app_opened", surface: "desktop" })).toEqual({ + accepted: false, + reason: "disabled", + }); + expect(harness.messages).toHaveLength(0); + expect(fs.existsSync(path.join(harness.root, "analytics.json"))).toBe(false); + fs.rmSync(harness.root, { recursive: true, force: true }); + }); + it("drops queued transport work immediately when the user opts out", () => { const harness = makeHarness(); expect(harness.service.capture({ event: "ade_app_opened", surface: "desktop" }).accepted).toBe(true); @@ -529,6 +558,234 @@ describe("productAnalyticsService", () => { fs.rmSync(harness.root, { recursive: true, force: true }); }); + it("captures install and activation milestones only once across restarts", () => { + let nowMs = Date.parse("2026-07-13T12:00:00.000Z"); + const first = makeHarness({ now: () => nowMs }); + + expect(first.service.captureInternal({ + event: "ade_app_installed", + surface: "desktop", + properties: { install_source: "direct_download" }, + })).toEqual({ accepted: true, reason: "accepted" }); + nowMs += 91_000; + expect(first.service.captureInternal({ + event: "ade_activated", + surface: "api", + properties: { trigger: "work_session_completed", time_since_install_seconds: 999_999 }, + })).toEqual({ accepted: true, reason: "accepted" }); + + const activation = first.messages[1] as { properties: Record }; + expect(activation.properties.time_since_install_seconds).toBe(91); + + const restarted = makeHarness({ root: first.root, messages: first.messages, now: () => nowMs }); + expect(restarted.service.captureInternal({ + event: "ade_app_installed", + surface: "desktop", + properties: { install_source: "homebrew" }, + })).toEqual({ accepted: false, reason: "duplicate" }); + expect(restarted.service.captureInternal({ + event: "ade_activated", + surface: "api", + properties: { trigger: "work_session_completed" }, + })).toEqual({ accepted: false, reason: "duplicate" }); + expect(first.messages).toHaveLength(2); + expect(restarted.service.getStatus().droppedToday).toBe(0); + fs.rmSync(first.root, { recursive: true, force: true }); + }); + + it("migrates legacy analytics state without backfilling false install or activation events", () => { + const first = makeHarness(); + first.service.capture({ event: "ade_app_opened", surface: "desktop" }); + const statePath = path.join(first.root, "analytics.json"); + const legacy = JSON.parse(fs.readFileSync(statePath, "utf8")) as Record; + legacy.version = 1; + delete legacy.anonymousId; + delete legacy.identifiedUserHash; + delete legacy.installedAtMs; + delete legacy.installCapturedAtMs; + delete legacy.activatedAtMs; + fs.writeFileSync(statePath, `${JSON.stringify(legacy)}\n`); + + const restarted = makeHarness({ root: first.root, messages: first.messages }); + expect(restarted.service.captureInternal({ + event: "ade_app_installed", + surface: "desktop", + properties: { install_source: "direct_download" }, + })).toEqual({ accepted: false, reason: "duplicate" }); + expect(restarted.service.captureInternal({ + event: "ade_activated", + surface: "api", + properties: { trigger: "work_session_completed" }, + })).toEqual({ accepted: false, reason: "duplicate" }); + fs.rmSync(first.root, { recursive: true, force: true }); + }); + + it("identifies a known account pseudonymously and rotates identity on sign-out", () => { + const harness = makeHarness(); + harness.service.capture({ event: "ade_app_opened", surface: "desktop" }); + const anonymousId = harness.messages[0]?.distinctId; + + expect(harness.service.identifyAccount("clerk_user_private_123")).toEqual({ + accepted: true, + reason: "accepted", + }); + const identify = harness.messages[1] as { + distinctId: string; + event: string; + properties: Record; + }; + expect(identify.event).toBe("$identify"); + expect(identify.distinctId).toMatch(/^ade_user_[0-9a-f]{32}$/); + expect(identify.properties).toMatchObject({ + $anon_distinct_id: anonymousId, + $set: { plan: "free", platform: process.platform, app_version: "1.2.3" }, + $geoip_disable: true, + }); + expect(JSON.stringify(identify)).not.toContain("clerk_user_private_123"); + expect(harness.service.identifyAccount("clerk_user_private_123")).toEqual({ + accepted: false, + reason: "duplicate", + }); + + harness.service.capture({ event: "ade_screen_viewed", surface: "desktop" }); + expect(harness.messages[2]?.distinctId).toBe(identify.distinctId); + expect(harness.service.resetAccountIdentity()).toBe(true); + harness.service.capture({ event: "ade_project_opened", surface: "desktop" }); + expect(harness.messages[3]?.distinctId).toMatch(/^ade_[0-9a-f]{32}$/); + expect(harness.messages[3]?.distinctId).not.toBe(anonymousId); + expect(harness.messages[3]?.distinctId).not.toBe(identify.distinctId); + fs.rmSync(harness.root, { recursive: true, force: true }); + }); + + it.each([ + { quota: "daily", expectedReason: "daily_budget" }, + { quota: "minute", expectedReason: "rate_limited" }, + ] as const)("clears the prior account when a switched identify hits the $quota quota", ({ + quota, + expectedReason, + }) => { + const nowMs = Date.parse("2026-07-13T12:00:00.000Z"); + const first = makeHarness({ now: () => nowMs }); + expect(first.service.identifyAccount("account_one").accepted).toBe(true); + const priorIdentity = first.messages[0]?.distinctId; + const statePath = path.join(first.root, "analytics.json"); + const persisted = JSON.parse(fs.readFileSync(statePath, "utf8")) as { + identifiedUserHash: string | null; + quota: { + identifyAccepted: number; + identifyMinuteWindow: number[]; + }; + }; + if (quota === "daily") { + persisted.quota.identifyAccepted = 3; + persisted.quota.identifyMinuteWindow = []; + } else { + persisted.quota.identifyAccepted = 1; + persisted.quota.identifyMinuteWindow = [nowMs - 1_000, nowMs]; + } + fs.writeFileSync(statePath, `${JSON.stringify(persisted)}\n`); + + const restarted = makeHarness({ + root: first.root, + messages: first.messages, + now: () => nowMs, + }); + expect(restarted.service.identifyAccount("account_two")).toEqual({ + accepted: false, + reason: expectedReason, + }); + expect(restarted.service.identifiedUserHashForTesting()).toBeNull(); + expect(restarted.service.capture({ + event: "ade_screen_viewed", + surface: "desktop", + properties: { screen: "work" }, + }).accepted).toBe(true); + expect(first.messages.at(-1)?.distinctId).toMatch(/^ade_[0-9a-f]{32}$/); + expect(first.messages.at(-1)?.distinctId).not.toBe(priorIdentity); + fs.rmSync(first.root, { recursive: true, force: true }); + }); + + it("attributes a rolled quota summary to the prior account before switching identities", () => { + let nowMs = Date.parse("2026-07-13T23:59:00.000Z"); + const first = makeHarness({ now: () => nowMs }); + expect(first.service.identifyAccount("account_one").accepted).toBe(true); + const priorIdentity = first.messages[0]?.distinctId; + const statePath = path.join(first.root, "analytics.json"); + const persisted = JSON.parse(fs.readFileSync(statePath, "utf8")) as { + quota: { accepted: number; dropped: number }; + }; + persisted.quota.accepted = 5; + persisted.quota.dropped = 2; + fs.writeFileSync(statePath, `${JSON.stringify(persisted)}\n`); + + nowMs = Date.parse("2026-07-14T00:01:00.000Z"); + const restarted = makeHarness({ + root: first.root, + messages: first.messages, + now: () => nowMs, + }); + expect(restarted.service.identifyAccount("account_two")).toEqual({ + accepted: true, + reason: "accepted", + }); + + expect(first.messages[1]).toMatchObject({ + event: "ade_analytics_budget", + distinctId: priorIdentity, + properties: { + sent_count: 5, + dropped_count: 2, + }, + }); + expect(first.messages[2]).toMatchObject({ + event: "$identify", + }); + expect(first.messages[2]?.distinctId).not.toBe(priorIdentity); + fs.rmSync(first.root, { recursive: true, force: true }); + }); + + it("normalizes persisted identify quotas and rejects timestamps outside the active minute", () => { + const nowMs = Date.parse("2026-07-13T12:00:00.000Z"); + const first = makeHarness({ now: () => nowMs }); + first.service.capture({ event: "ade_app_opened", surface: "desktop" }); + const statePath = path.join(first.root, "analytics.json"); + const state = JSON.parse(fs.readFileSync(statePath, "utf8")) as { + quota: { + identifyAccepted: number; + identifyMinuteWindow: number[]; + }; + }; + state.quota.identifyAccepted = 1; + state.quota.identifyMinuteWindow = [ + nowMs - 60_001, + nowMs - 30_000, + nowMs, + nowMs + 60_001, + Number.POSITIVE_INFINITY, + ]; + fs.writeFileSync(statePath, `${JSON.stringify(state)}\n`); + + const minuteLimited = makeHarness({ root: first.root, now: () => nowMs }); + expect(minuteLimited.service.identifyAccount("user_minute_limited")).toEqual({ + accepted: false, + reason: "rate_limited", + }); + const minuteState = JSON.parse(fs.readFileSync(statePath, "utf8")) as typeof state; + expect(minuteState.quota.identifyMinuteWindow).toEqual([nowMs - 30_000, nowMs]); + + minuteState.quota.identifyAccepted = 999; + minuteState.quota.identifyMinuteWindow = []; + fs.writeFileSync(statePath, `${JSON.stringify(minuteState)}\n`); + const dailyLimited = makeHarness({ root: first.root, now: () => nowMs }); + expect(dailyLimited.service.identifyAccount("user_daily_limited")).toEqual({ + accepted: false, + reason: "daily_budget", + }); + const dailyState = JSON.parse(fs.readFileSync(statePath, "utf8")) as typeof state; + expect(dailyState.quota.identifyAccepted).toBe(3); + fs.rmSync(first.root, { recursive: true, force: true }); + }); + it("rejects personal keys and invalid explicit ingestion hosts", () => { const personalKey = makeHarness({ token: "phx_personal_admin_key" }); expect(personalKey.service.capture({ event: "ade_app_opened", surface: "desktop" })).toEqual({ @@ -614,7 +871,7 @@ describe("product analytics producers", () => { event: settledEvent({ status: "failed", sessionId: "session-2" }), }); - expect(captures).toHaveLength(3); + expect(captures).toHaveLength(5); expect(captures[0]).toEqual({ event: "ade_work_session_completed", surface: "api", @@ -629,11 +886,20 @@ describe("product analytics producers", () => { source: "runtime", }, }); - expect(captures[1]).toMatchObject({ + expect(captures[1]).toEqual({ + event: "ade_app_installed", + surface: "api", + properties: { install_source: "unknown" }, + }); + expect(captures[2]).toMatchObject({ + event: "ade_activated", + properties: { trigger: "work_session_completed" }, + }); + expect(captures[3]).toMatchObject({ event: "ade_work_session_completed", properties: { feature: "chat", outcome: "failure" }, }); - expect(captures[2]).toMatchObject({ + expect(captures[4]).toMatchObject({ event: "ade_error", sessionId: "session-2", properties: { feature: "chat", error_kind: "other", recoverable: true }, diff --git a/apps/desktop/src/main/services/analytics/productAnalyticsService.ts b/apps/desktop/src/main/services/analytics/productAnalyticsService.ts index 70a0d4dff..4399e8f2b 100644 --- a/apps/desktop/src/main/services/analytics/productAnalyticsService.ts +++ b/apps/desktop/src/main/services/analytics/productAnalyticsService.ts @@ -29,6 +29,8 @@ declare const __ADE_POSTHOG_PROJECT_TOKEN__: string | undefined; declare const __ADE_POSTHOG_HOST__: string | undefined; const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com"; +const IDENTIFY_DAILY_BUDGET = 3; +const IDENTIFY_MINUTE_BUDGET = 2; const STATE_LOCK_STALE_MS = 5_000; const RANDOM_UUID_VALUE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -46,13 +48,20 @@ type ProductAnalyticsQuotaState = { minuteWindows: Partial>; dedupe: Record; droppedByReason: Record; + identifyAccepted: number; + identifyMinuteWindow: number[]; pendingBudgetSummary?: PendingBudgetSummary; }; type ProductAnalyticsState = { - version: 1; + version: 2; installationId: string; + anonymousId: string; + identifiedUserHash: string | null; hashSalt: string; + installedAtMs: number; + installCapturedAtMs: number | null; + activatedAtMs: number | null; enabled: boolean; /** Oldest usage-ledger timestamp that may be exported under current consent. */ enabledSinceMs: number | null; @@ -130,6 +139,8 @@ function freshQuotaState(nowMs: number): ProductAnalyticsQuotaState { minuteWindows: {}, dedupe: {}, droppedByReason: {}, + identifyAccepted: 0, + identifyMinuteWindow: [], }; } @@ -196,15 +207,32 @@ function normalizeQuotaState(value: unknown, nowMs: number): ProductAnalyticsQuo minuteWindows, dedupe: Object.fromEntries(dedupeEntries), droppedByReason, + identifyAccepted: finiteCount(raw.identifyAccepted, IDENTIFY_DAILY_BUDGET), + identifyMinuteWindow: Array.isArray(raw.identifyMinuteWindow) + ? raw.identifyMinuteWindow + .filter((timestamp): timestamp is number => + typeof timestamp === "number" + && Number.isFinite(timestamp) + && timestamp >= nowMs - 60_000 + && timestamp <= nowMs + 60_000) + .map((timestamp) => Math.max(0, Math.floor(timestamp))) + .slice(-IDENTIFY_MINUTE_BUDGET) + : [], ...(pendingBudgetSummary ? { pendingBudgetSummary } : {}), }; } function createInitialState(nowMs: number): ProductAnalyticsState { + const installationId = `ade_${randomBytes(16).toString("hex")}`; return { - version: 1, - installationId: `ade_${randomBytes(16).toString("hex")}`, + version: 2, + installationId, + anonymousId: installationId, + identifiedUserHash: null, hashSalt: randomBytes(32).toString("hex"), + installedAtMs: nowMs, + installCapturedAtMs: null, + activatedAtMs: null, enabled: true, enabledSinceMs: nowMs, quota: freshQuotaState(nowMs), @@ -223,19 +251,49 @@ function createFailClosedState(nowMs: number): ProductAnalyticsState { function normalizeState(value: unknown, nowMs: number): ProductAnalyticsState | null { if (!value || typeof value !== "object") return null; - const parsed = value as Partial; + const parsed = value as Omit, "version"> & { version?: number }; if ( parsed.version !== 1 - || typeof parsed.installationId !== "string" + && parsed.version !== 2 + ) return null; + if ( + typeof parsed.installationId !== "string" || !/^ade_[0-9a-f]{32}$/i.test(parsed.installationId) ) return null; const enabled = parsed.enabled !== false; + const migratedLegacyState = parsed.version === 1; + const anonymousId = !migratedLegacyState + && typeof parsed.anonymousId === "string" + && /^ade_[0-9a-f]{32}$/i.test(parsed.anonymousId) + ? parsed.anonymousId + : parsed.installationId; + const identifiedUserHash = !migratedLegacyState + && typeof parsed.identifiedUserHash === "string" + && /^ade_user_[0-9a-f]{32}$/i.test(parsed.identifiedUserHash) + ? parsed.identifiedUserHash + : null; + const installedAtMs = !migratedLegacyState + && typeof parsed.installedAtMs === "number" + && Number.isFinite(parsed.installedAtMs) + ? Math.max(0, Math.min(nowMs, Math.floor(parsed.installedAtMs))) + : nowMs; + const milestone = (candidate: unknown): number | null => + typeof candidate === "number" && Number.isFinite(candidate) + ? Math.max(installedAtMs, Math.min(nowMs, Math.floor(candidate))) + : null; return { - version: 1, + version: 2, installationId: parsed.installationId, + anonymousId, + identifiedUserHash, hashSalt: typeof parsed.hashSalt === "string" && /^[0-9a-f]{64}$/i.test(parsed.hashSalt) ? parsed.hashSalt : randomBytes(32).toString("hex"), + installedAtMs, + // Existing v1 installations must not be mislabeled as fresh installs or + // newly activated merely because they upgraded to the v2 analytics state. + installCapturedAtMs: migratedLegacyState ? nowMs : milestone(parsed.installCapturedAtMs), + activatedAtMs: migratedLegacyState ? nowMs : milestone(parsed.activatedAtMs), enabled, enabledSinceMs: enabled ? (typeof parsed.enabledSinceMs === "number" && Number.isFinite(parsed.enabledSinceMs) @@ -572,6 +630,13 @@ export function createProductAnalyticsService(args: ProductAnalyticsServiceArgs) const isRuntimeDisabled = () => process.env.ADE_DISABLE_PRODUCT_ANALYTICS === "1" + || ( + ( + process.env.NODE_ENV === "development" + || args.runtimeMode === "desktop_development" + ) + && process.env.ADE_ENABLE_PRODUCT_ANALYTICS_IN_DEVELOPMENT !== "1" + ) || process.env.NODE_ENV === "test" || process.env.VITEST === "true"; @@ -615,7 +680,7 @@ export function createProductAnalyticsService(args: ProductAnalyticsServiceArgs) currentState.quota.minuteWindows.ade_analytics_budget = [now()]; writeState(args.stateFilePath, currentState); posthog.capture({ - distinctId: currentState.installationId, + distinctId: currentState.identifiedUserHash ?? currentState.anonymousId, event: "ade_analytics_budget", properties: { surface: "api", @@ -650,6 +715,132 @@ export function createProductAnalyticsService(args: ProductAnalyticsServiceArgs) .slice(0, 24); }; + const identifyAccount = (userId: string | null | undefined): ProductAnalyticsCaptureResult => { + const normalizedUserId = typeof userId === "string" && userId.length <= MAX_LOCAL_IDENTIFIER_LENGTH + ? userId.trim() + : ""; + if (!normalizedUserId) return { accepted: false, reason: "invalid_event" }; + if (!token || !host) return { accepted: false, reason: "not_configured" }; + if (isRuntimeDisabled() || isLocallyOptedOut()) return { accepted: false, reason: "disabled" }; + + const userHash = `ade_user_${createHash("sha256") + .update(`ade-product-analytics-account-v1:${normalizedUserId}`) + .digest("hex") + .slice(0, 32)}`; + let release: (() => void) | null = null; + try { + release = tryAcquireStateLock(args.stateFilePath); + if (!release) return { accepted: false, reason: "rate_limited" }; + state = rollQuotaDay(readState(args.stateFilePath, now()), now()); + if (!state.enabled) return { accepted: false, reason: "disabled" }; + if (state.identifiedUserHash === userHash) return { accepted: false, reason: "duplicate" }; + const posthog = ensureClient(state); + if (!posthog) return { accepted: false, reason: "disabled" }; + // A rollover summary belongs to the identity that accumulated it. Emit + // it before detaching that account so an account switch cannot attribute + // the prior account's quota usage to the new anonymous or known user. + emitPendingBudgetSummary(state, posthog); + // Detach the prior account before attempting the new identify. If the + // identify is then suppressed by a quota or transport failure, later + // events remain anonymous instead of leaking into the prior account. + if (state.identifiedUserHash) { + state.anonymousId = `ade_${randomBytes(16).toString("hex")}`; + state.identifiedUserHash = null; + writeState(args.stateFilePath, state); + } + if (state.quota.accepted >= dailyBudget || state.quota.identifyAccepted >= IDENTIFY_DAILY_BUDGET) { + incrementDrop(state.quota, "daily_budget"); + writeState(args.stateFilePath, state); + return { accepted: false, reason: "daily_budget" }; + } + const nowMs = now(); + const recent = state.quota.identifyMinuteWindow.filter((timestamp) => + timestamp >= nowMs - 60_000 && timestamp <= nowMs + 60_000); + if (recent.length >= IDENTIFY_MINUTE_BUDGET) { + incrementDrop(state.quota, "rate_limited"); + writeState(args.stateFilePath, state); + return { accepted: false, reason: "rate_limited" }; + } + if (state.quota.accepted >= dailyBudget) { + incrementDrop(state.quota, "daily_budget"); + writeState(args.stateFilePath, state); + return { accepted: false, reason: "daily_budget" }; + } + + const previousAnonymousId = state.anonymousId; + const previousIdentifiedUserHash = state.identifiedUserHash; + const anonymousId = state.anonymousId; + state.identifiedUserHash = userHash; + state.quota.accepted += 1; + state.quota.identifyAccepted += 1; + recent.push(nowMs); + state.quota.identifyMinuteWindow = recent; + writeState(args.stateFilePath, state); + try { + posthog.capture({ + distinctId: userHash, + event: "$identify", + properties: { + $anon_distinct_id: anonymousId, + $set: { + plan: "free", + platform: process.platform, + app_version: appVersion, + }, + $geoip_disable: true, + }, + uuid: randomUUID(), + }); + } catch (error) { + state.anonymousId = previousAnonymousId; + state.identifiedUserHash = previousIdentifiedUserHash; + state.quota.accepted = Math.max(0, state.quota.accepted - 1); + state.quota.identifyAccepted = Math.max(0, state.quota.identifyAccepted - 1); + recent.pop(); + state.quota.identifyMinuteWindow = recent; + incrementDrop(state.quota, "transport_error"); + writeState(args.stateFilePath, state); + args.logger.debug("product_analytics.identify_failed", { + errorKind: error instanceof Error ? error.name : "unknown", + }); + return { accepted: false, reason: "transport_error" }; + } + return { accepted: true, reason: "accepted" }; + } catch (error) { + args.logger.debug("product_analytics.identify_state_failed", { + errorKind: error instanceof Error ? error.name : "unknown", + }); + return { accepted: false, reason: "transport_error" }; + } finally { + release?.(); + } + }; + + const resetAccountIdentity = (): boolean => { + if (!fs.existsSync(args.stateFilePath)) return false; + let release: (() => void) | null = null; + try { + release = tryAcquireStateLock(args.stateFilePath); + if (!release) return false; + state = rollQuotaDay(readState(args.stateFilePath, now()), now()); + if (!state.identifiedUserHash) return false; + state = { + ...state, + anonymousId: `ade_${randomBytes(16).toString("hex")}`, + identifiedUserHash: null, + }; + writeState(args.stateFilePath, state); + return true; + } catch (error) { + args.logger.debug("product_analytics.identity_reset_failed", { + errorKind: error instanceof Error ? error.name : "unknown", + }); + return false; + } finally { + release?.(); + } + }; + const captureImpl = ( input: ProductAnalyticsCapture, allowInternal: boolean, @@ -695,6 +886,12 @@ export function createProductAnalyticsService(args: ProductAnalyticsServiceArgs) if (!["desktop", "mobile", "tui", "web", "api"].includes(input.surface)) { return drop("invalid_surface"); } + if (input.event === "ade_app_installed" && state.installCapturedAtMs != null) { + return { accepted: false, reason: "duplicate" }; + } + if (input.event === "ade_activated" && state.activatedAtMs != null) { + return { accepted: false, reason: "duplicate" }; + } const posthog = ensureClient(state); if (!posthog) return { accepted: false, reason: "disabled" }; @@ -744,6 +941,12 @@ export function createProductAnalyticsService(args: ProductAnalyticsServiceArgs) $process_person_profile: false, $geoip_disable: true, }; + if (input.event === "ade_activated") { + properties.time_since_install_seconds = Math.max( + 0, + Math.floor((nowMs - state.installedAtMs) / 1_000), + ); + } const projectId = opaqueId("project", input.projectId); const sessionId = opaqueId("session", input.sessionId); if (projectId) properties.project_id = projectId; @@ -759,6 +962,10 @@ export function createProductAnalyticsService(args: ProductAnalyticsServiceArgs) const uuid = input.clientEventId?.length === 36 && RANDOM_UUID_VALUE.test(input.clientEventId) ? input.clientEventId.toLowerCase() : randomUUID(); + const previousInstallCapturedAtMs = state.installCapturedAtMs; + const previousActivatedAtMs = state.activatedAtMs; + if (input.event === "ade_app_installed") state.installCapturedAtMs = nowMs; + if (input.event === "ade_activated") state.activatedAtMs = nowMs; state.quota.accepted += 1; state.quota.acceptedByEvent[input.event] = (state.quota.acceptedByEvent[input.event] ?? 0) + 1; recent.push(nowMs); @@ -779,7 +986,7 @@ export function createProductAnalyticsService(args: ProductAnalyticsServiceArgs) writeState(args.stateFilePath, state); try { posthog.capture({ - distinctId: state.installationId, + distinctId: state.identifiedUserHash ?? state.anonymousId, event: input.event, properties, ...(timestamp ? { timestamp } : {}), @@ -797,6 +1004,8 @@ export function createProductAnalyticsService(args: ProductAnalyticsServiceArgs) recent.pop(); if (recent.length === 0) delete state.quota.minuteWindows[input.event]; else state.quota.minuteWindows[input.event] = recent; if (dedupeKey) delete state.quota.dedupe[dedupeKey]; + state.installCapturedAtMs = previousInstallCapturedAtMs; + state.activatedAtMs = previousActivatedAtMs; incrementDrop(state.quota, "transport_error"); writeState(args.stateFilePath, state); return { accepted: false, reason: "transport_error" }; @@ -968,6 +1177,8 @@ export function createProductAnalyticsService(args: ProductAnalyticsServiceArgs) return { capture, captureInternal, + identifyAccount, + resetAccountIdentity, flush, getStatus, setEnabled, @@ -979,6 +1190,7 @@ export function createProductAnalyticsService(args: ProductAnalyticsServiceArgs) }, hashProjectId: (value: string) => opaqueId("project", value), installationIdForTesting: () => state.installationId, + identifiedUserHashForTesting: () => state.identifiedUserHash, }; } diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 13f08ef84..42d6683d9 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -9239,7 +9239,9 @@ export function registerIpc({ }); ipcMain.handle(IPC.accountStatus, async (): Promise => { - return accountBridge.status(); + const status = accountBridge.status(); + if (status.signedIn) productAnalyticsService?.identifyAccount(status.userId); + return status; }); ipcMain.handle( @@ -9256,7 +9258,9 @@ export function registerIpc({ ipcMain.handle( IPC.accountPollLogin, async (_event, arg: { sessionId?: string }): Promise => { - return accountBridge.pollLogin(arg?.sessionId ?? ""); + const result = await accountBridge.pollLogin(arg?.sessionId ?? ""); + if (result.authStatus.signedIn) productAnalyticsService?.identifyAccount(result.authStatus.userId); + return result; }, ); @@ -9269,7 +9273,9 @@ export function registerIpc({ ); ipcMain.handle(IPC.accountSignOut, async (): Promise => { - return accountBridge.signOut(); + const status = accountBridge.signOut(); + productAnalyticsService?.resetAccountIdentity(); + return status; }); ipcMain.handle(IPC.accountListMachines, async (): Promise => { diff --git a/apps/desktop/src/renderer/components/analytics/ProductAnalyticsLifecycle.tsx b/apps/desktop/src/renderer/components/analytics/ProductAnalyticsLifecycle.tsx index 69ee666cc..b7c2b8e7e 100644 --- a/apps/desktop/src/renderer/components/analytics/ProductAnalyticsLifecycle.tsx +++ b/apps/desktop/src/renderer/components/analytics/ProductAnalyticsLifecycle.tsx @@ -7,6 +7,15 @@ type ProductAnalyticsLifecycleArgs = { screen: string; }; +const KEY_ANALYTICS_SCREENS = new Set([ + "project", + "lanes", + "work", + "prs", + "settings", + "onboarding", +]); + function captureHostedWebStartup(screen?: string): void { const analytics = window.ade.analytics; if (!analytics) return; @@ -19,7 +28,7 @@ function captureHostedWebStartup(screen?: string): void { dedupeKey: "web_app_opened", minimumIntervalMs: 5 * 60_000, }).catch(() => undefined); - if (screen) { + if (screen && KEY_ANALYTICS_SCREENS.has(screen)) { void analytics.capture({ event: "ade_screen_viewed", properties: { @@ -44,6 +53,7 @@ export function useProductAnalyticsLifecycle({ const [consentRequired, setConsentRequired] = useState(false); useEffect(() => { + if (!KEY_ANALYTICS_SCREENS.has(screen)) return; const analytics = window.ade.analytics; if (!analytics) return; const routeKind = isWebClientMode() ? "web" : "desktop"; diff --git a/apps/desktop/src/renderer/components/app/AppShell.aiStatus.test.tsx b/apps/desktop/src/renderer/components/app/AppShell.aiStatus.test.tsx index 98458ec16..c8fdac516 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.aiStatus.test.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.aiStatus.test.tsx @@ -11,7 +11,7 @@ import { listSessionsCached, } from "../../lib/sessionListCache"; import { useAppStore } from "../../state/appStore"; -import { AppShell } from "./AppShell"; +import { AppShell, productAnalyticsScreenForPathname } from "./AppShell"; vi.mock("./CommandPalette", () => ({ CommandPalette: () => null, @@ -218,6 +218,11 @@ describe("AppShell AI provider status", () => { vi.useRealTimers(); }); + it("maps the project picker to the sampled project analytics screen", () => { + expect(productAnalyticsScreenForPathname("/project")).toBe("project"); + expect(productAnalyticsScreenForPathname("/project/recent")).toBe("project"); + }); + it("captures a normalized route without query strings or route identifiers", async () => { window.__adeWebClient = true; render( diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index 50cac09fb..1ff4a5991 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -118,6 +118,7 @@ const PRODUCT_ANALYTICS_ROUTE_ROOTS = [ ] as const; export function productAnalyticsScreenForPathname(pathname: string): string { + if (pathname === "/project" || pathname.startsWith("/project/")) return "project"; const root = PRODUCT_ANALYTICS_ROUTE_ROOTS.find( (candidate) => pathname === candidate || pathname.startsWith(`${candidate}/`), ); diff --git a/apps/desktop/src/renderer/components/app/AutoUpdateBanner.test.tsx b/apps/desktop/src/renderer/components/app/AutoUpdateBanner.test.tsx index a194d2fd7..8647a2c2c 100644 --- a/apps/desktop/src/renderer/components/app/AutoUpdateBanner.test.tsx +++ b/apps/desktop/src/renderer/components/app/AutoUpdateBanner.test.tsx @@ -16,6 +16,7 @@ function snapshot(overrides: Partial): AutoUpdateSnapshot { function installAdeMock(initial: AutoUpdateSnapshot = snapshot({})) { const updateQuitAndInstall = vi.fn(async () => true); const updateCancelAutoApply = vi.fn(async () => true); + const capture = vi.fn(async () => ({ accepted: true, reason: "accepted" as const })); // Mutable so the initial (async) getState read can't clobber an already-emitted // state when its promise settles late. let current = initial; @@ -26,6 +27,7 @@ function installAdeMock(initial: AutoUpdateSnapshot = snapshot({})) { updateGetState: vi.fn(async () => current), updateQuitAndInstall, updateCancelAutoApply, + analytics: { capture }, onUpdateEvent: vi.fn((cb: (s: AutoUpdateSnapshot) => void) => { listener = cb; return () => { @@ -37,6 +39,7 @@ function installAdeMock(initial: AutoUpdateSnapshot = snapshot({})) { return { updateQuitAndInstall, updateCancelAutoApply, + capture, emit(next: AutoUpdateSnapshot) { current = next; act(() => listener?.(next)); @@ -101,6 +104,14 @@ describe("AutoUpdateBanner", () => { await waitFor(() => { expect(mock.updateQuitAndInstall).toHaveBeenCalledTimes(1); }); + expect(mock.capture).toHaveBeenCalledWith(expect.objectContaining({ + event: "ade_update_prompted", + properties: { + from_version: "1.2.34", + to_version: "1.2.35", + user_action: "accepted", + }, + })); }); it("shows the parked retry copy", async () => { @@ -124,6 +135,9 @@ describe("AutoUpdateBanner", () => { await waitFor(() => { expect(screen.queryByText(/1\.2\.35 is ready/)).toBeNull(); }); + expect(mock.capture).toHaveBeenCalledWith(expect.objectContaining({ + properties: expect.objectContaining({ user_action: "dismissed" }), + })); // A newer staged version has a different signature, so the banner returns. mock.emit(snapshot({ status: "ready", version: "1.2.36" })); @@ -150,6 +164,9 @@ describe("AutoUpdateBanner", () => { await waitFor(() => { expect(mock.updateCancelAutoApply).toHaveBeenCalledTimes(1); }); + expect(mock.capture).toHaveBeenCalledWith(expect.objectContaining({ + properties: expect.objectContaining({ user_action: "deferred" }), + })); // Clearing the pending state removes the toast. mock.emit(snapshot({ status: "ready", version: "1.2.35" })); diff --git a/apps/desktop/src/renderer/components/app/AutoUpdateBanner.tsx b/apps/desktop/src/renderer/components/app/AutoUpdateBanner.tsx index 50ee622a8..a3fba15b5 100644 --- a/apps/desktop/src/renderer/components/app/AutoUpdateBanner.tsx +++ b/apps/desktop/src/renderer/components/app/AutoUpdateBanner.tsx @@ -3,6 +3,7 @@ import { ArrowsClockwise, WarningCircle } from "@phosphor-icons/react"; import type { AutoUpdateSnapshot } from "../../../shared/types"; import { useAutoUpdateSnapshot } from "./useAutoUpdateSnapshot"; import { dismissToast, showToast } from "./toast/toastStore"; +import { captureUpdatePromptDecision } from "./captureUpdatePromptDecision"; const AUTO_APPLY_TOAST_ID = "ade-auto-update-auto-apply"; @@ -58,6 +59,8 @@ export function AutoUpdateBanner() { const banner = describeStalenessBanner(snapshot); const signature = banner?.signature ?? null; + const currentVersion = snapshot.currentVersion; + const updateVersion = snapshot.version; // Re-enable the Restart action whenever the banner state changes (or clears); // a stale "restarting" flag must never stick across a new staged version. @@ -66,6 +69,7 @@ export function AutoUpdateBanner() { }, [signature]); const handleRestart = useCallback(() => { + captureUpdatePromptDecision({ currentVersion, version: updateVersion }, "accepted"); setRestarting(true); void window.ade.updateQuitAndInstall() .then((started) => { @@ -74,9 +78,10 @@ export function AutoUpdateBanner() { .catch(() => { setRestarting(false); }); - }, []); + }, [currentVersion, updateVersion]); const handleCancelAutoApply = useCallback(() => { + captureUpdatePromptDecision({ currentVersion, version: updateVersion }, "deferred"); cancelRequestedRef.current = true; dismissToast(AUTO_APPLY_TOAST_ID); void window.ade.updateCancelAutoApply?.().then( @@ -89,7 +94,7 @@ export function AutoUpdateBanner() { cancelRequestedRef.current = false; }, ); - }, []); + }, [currentVersion, updateVersion]); // Drive the countdown toast off `autoApplyPending`. Re-render once a second so // the visible seconds tick down; the snapshot event clears it on apply/cancel. @@ -143,7 +148,10 @@ export function AutoUpdateBanner() {