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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ jobs:
test-desktop:
needs: install
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
Expand Down
3 changes: 3 additions & 0 deletions apps/ade-cli/src/adeRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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 () => {
Expand Down
17 changes: 16 additions & 1 deletion apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -16049,6 +16062,8 @@ async function runServe(
projectRegistry,
scopeRegistry,
personalChatScope,
productAnalyticsService: brainProductAnalytics,
Comment thread
arul28 marked this conversation as resolved.
accountAuthService: brainAccountAuthService,
getAccountDirectoryHealth,
getRuntimeStatus: () => {
const publishHealth = getAccountDirectoryHealth();
Expand Down
7 changes: 7 additions & 0 deletions apps/ade-cli/src/multiProjectRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -499,6 +503,7 @@ describe("multi-project RPC server", () => {
serverVersion: "test",
projectRegistry: registry,
accountAuthService,
productAnalyticsService,
reconcileAccountOwnership,
});
await handler({
Expand All @@ -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<typeof vi.fn>).mockResolvedValueOnce({
status: "signed_in",
Expand All @@ -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<typeof vi.fn>).mockReturnValue({
signedIn: true,
Expand Down
3 changes: 3 additions & 0 deletions apps/ade-cli/src/multiProjectRpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import {
} from "../../desktop/src/shared/types";
import {
callAccountAction,
type AccountAnalyticsIdentity,
type AccountAuthService,
} from "./services/account/accountAuthService";
import {
Expand Down Expand Up @@ -101,6 +102,7 @@ export type MultiProjectRpcHandlerOptions = {
"capabilities" | "call" | "streamEvents" | "dispose"
> & Partial<Pick<PersonalChatScope, "activitySummary">>;
accountAuthService?: AccountAuthService;
productAnalyticsService?: AccountAnalyticsIdentity;
getAccountDirectoryHealth?: () => SyncAccountDirectoryHealth;
getRuntimeStatus?: () => {
syncPort: number | null;
Expand Down Expand Up @@ -1205,6 +1207,7 @@ export function createMultiProjectRpcRequestHandler(
}
const response = await callAccountAction({
service: accountAuthService,
analytics: options.productAnalyticsService,
action,
actionArgs: isRecord(params.args) ? params.args : {},
});
Expand Down
35 changes: 35 additions & 0 deletions apps/ade-cli/src/services/account/accountAuthService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import type { SyncCredentialStore } from "../credentials/credentialStore";
import {
ACCOUNT_SESSION_CREDENTIAL_KEY,
createAccountActionDomainService,
createAccountAuthService,
derivePkceChallenge,
getSignedInAccountAccessToken,
Expand Down Expand Up @@ -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();
Expand Down
41 changes: 36 additions & 5 deletions apps/ade-cli/src/services/account/accountAuthService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -648,26 +653,52 @@ 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(),
};
}

export async function callAccountAction(args: {
service: AccountAuthService;
analytics?: AccountAnalyticsIdentity;
action: string;
actionArgs?: Record<string, unknown>;
}): Promise<{
Expand All @@ -677,7 +708,7 @@ export async function callAccountAction(args: {
statusHints: Record<string, never>;
}> {
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.`);
}
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down
16 changes: 12 additions & 4 deletions apps/desktop/src/main/services/adeActions/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => ({
Expand All @@ -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,
Expand All @@ -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<unknown>;
pollLogin(args: { sessionId: string }): Promise<unknown>;
Expand Down Expand Up @@ -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(1);
expect(isCtoOnlyAdeAction("account", "startLogin")).toBe(true);
expect(isCtoOnlyAdeAction("account", "pollLogin")).toBe(true);
expect(isCtoOnlyAdeAction("account", "startDeviceLogin")).toBe(true);
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/main/services/adeActions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment thread
arul28 marked this conversation as resolved.
surface: "api",
projectId,
sessionId: event.sessionId,
properties: {
trigger: "work_session_completed",
},
});
}

if (event.status !== "failed") return;
analytics.captureInternal({
event: "ade_error",
Expand Down
33 changes: 33 additions & 0 deletions apps/desktop/src/main/services/analytics/installSource.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading