diff --git a/packages/cli/src/analytics.test.ts b/packages/cli/src/analytics.test.ts index 91bf3543..8cd8bd5b 100644 --- a/packages/cli/src/analytics.test.ts +++ b/packages/cli/src/analytics.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { analyticsUserId, @@ -8,6 +8,10 @@ import { telemetryCredential, } from "./analytics.js"; +afterEach(() => { + vi.unstubAllEnvs(); +}); + describe("analyticsUserId", () => { it("reports a run nothing identified as anonymous, not as an empty identity", () => { expect(analyticsUserId("")).toBe("anonymous"); @@ -124,10 +128,12 @@ describe("getErrorAnalyticsEventProperties", () => { { version: "2.33.2", ci: true, + agent: "codex", }, ); expect(properties).toMatchObject({ + agent: "codex", ci: true, errCode: "API_ERROR", message: "branch already exists", @@ -168,4 +174,17 @@ describe("getAnalyticsEventProperties", () => { }).flags.output, ).toBe("secret-value"); }); + + it("attributes commands run by a coding agent", () => { + vi.stubEnv("CODEX_CI", undefined); + vi.stubEnv("CODEX_THREAD_ID", undefined); + vi.stubEnv("CODEX_SESSION_ID", undefined); + vi.stubEnv("CLAUDE_CODE_CHILD_SESSION", "1"); + + expect( + getAnalyticsEventProperties({ + _: ["branches", "list"], + }).agent, + ).toBe("claude-code"); + }); }); diff --git a/packages/cli/src/analytics.ts b/packages/cli/src/analytics.ts index 6a2c201d..20ab66c0 100644 --- a/packages/cli/src/analytics.ts +++ b/packages/cli/src/analytics.ts @@ -5,7 +5,7 @@ import { type AuthContext, getAuthContext } from "./auth_context.js"; import { credentialsPath } from "./config.js"; import { isCurrentBranchProbe } from "./context.js"; import { storeFor } from "./credential_io.js"; -import { getGithubEnvVars, isCi } from "./env.js"; +import { getCliAgent, getGithubEnvVars, isCi } from "./env.js"; import type { ErrorCode } from "./errors.js"; import { log } from "./log.js"; import pkg from "./pkg.js"; @@ -124,12 +124,14 @@ type AnalyticsEventProperties = { output: string | undefined; }; ci: boolean; + agent: ReturnType; githubEnvVars: ReturnType; }; type ErrorEventContext = { version: string; ci: boolean; + agent: ReturnType; }; /** @@ -331,6 +333,7 @@ const getErrorAnalyticsEventContext = ( ): ErrorEventContext => ({ version: pkg.version, ci: isCi(), + agent: getCliAgent(process.env), }); export const getAnalyticsEventProperties = ( @@ -342,5 +345,6 @@ export const getAnalyticsEventProperties = ( output: args.output, }, ci: isCi(), + agent: getCliAgent(process.env), githubEnvVars: getGithubEnvVars(process.env), }); diff --git a/packages/cli/src/env.test.ts b/packages/cli/src/env.test.ts index 8860decc..ca607766 100644 --- a/packages/cli/src/env.test.ts +++ b/packages/cli/src/env.test.ts @@ -1,5 +1,74 @@ import { describe, expect, it } from "vitest"; -import { getGithubEnvVars } from "./env"; +import { getCliAgent, getGithubEnvVars } from "./env"; + +describe("getCliAgent", () => { + it("attributes a Claude Code child session", () => { + expect(getCliAgent({ CLAUDE_CODE_CHILD_SESSION: "1" })).toBe( + "claude-code", + ); + }); + + it("attributes a Codex thread", () => { + expect( + getCliAgent({ + CODEX_CI: "1", + CODEX_THREAD_ID: "thread-123", + }), + ).toBe("codex"); + }); + + it("attributes a Codex session", () => { + expect( + getCliAgent({ + CODEX_CI: "1", + CODEX_SESSION_ID: "session-123", + }), + ).toBe("codex"); + }); + + it("does not attribute a user-initiated Codex shell command", () => { + expect( + getCliAgent({ + CODEX_THREAD_ID: "thread-123", + CODEX_SESSION_ID: "session-123", + }), + ).toBeUndefined(); + }); + + it("does not attribute disabled or empty direct markers", () => { + expect( + getCliAgent({ + CLAUDE_CODE_CHILD_SESSION: "false", + CODEX_THREAD_ID: "", + CODEX_SESSION_ID: " ", + }), + ).toBeUndefined(); + }); + + it("does not attribute ambient or sandbox utility markers", () => { + expect( + getCliAgent({ + CLAUDECODE: "1", + CLAUDE_CODE: "1", + CLAUDE_CLI: "1", + CODEX: "1", + CODEX_CI: "1", + CODEX_SANDBOX: "seatbelt", + CODEX_SANDBOX_NETWORK_DISABLED: "1", + }), + ).toBeUndefined(); + }); + + it("omits attribution when nested agent markers conflict", () => { + expect( + getCliAgent({ + CLAUDE_CODE_CHILD_SESSION: "1", + CODEX_CI: "1", + CODEX_THREAD_ID: "thread-123", + }), + ).toBeUndefined(); + }); +}); describe("getGithubEnvVars", () => { it("success all keys", () => { diff --git a/packages/cli/src/env.ts b/packages/cli/src/env.ts index 6b5132e2..fd0f1dd2 100644 --- a/packages/cli/src/env.ts +++ b/packages/cli/src/env.ts @@ -6,6 +6,19 @@ export const isDebug = () => { return Boolean(process.env.DEBUG); }; +export type CliAgent = "claude-code" | "codex"; + +export const getCliAgent = (env: NodeJS.Dict): CliAgent | undefined => { + const claudeCode = env.CLAUDE_CODE_CHILD_SESSION === "1"; + const codex = + env.CODEX_CI === "1" && + (Boolean(env.CODEX_THREAD_ID?.trim()) || + Boolean(env.CODEX_SESSION_ID?.trim())); + + if (claudeCode === codex) return undefined; + return claudeCode ? "claude-code" : "codex"; +}; + export const getGithubEnvVars = (env: NodeJS.Dict) => { const vars = [ // github action info diff --git a/packages/cli/src/init/agent_snapshot.test.ts b/packages/cli/src/init/agent_snapshot.test.ts index f816583f..d6cd1d55 100644 --- a/packages/cli/src/init/agent_snapshot.test.ts +++ b/packages/cli/src/init/agent_snapshot.test.ts @@ -344,7 +344,8 @@ async function runInit( }); } - const child = spawn(process.execPath, [CLI, "init", ...args], { + const cliArgs = [CLI, "--no-analytics", "init", ...args]; + const child = spawn(process.execPath, cliArgs, { cwd, stdio: ["ignore", "pipe", "pipe"], env: { @@ -355,7 +356,6 @@ async function runInit( NEON_CONFIG_DIR: configDir, NEON_STUB_LOG: stubLog, NEON_BOOTSTRAP_MANIFEST_URL: manifestUrl, - NEON_NO_ANALYTICS: "1", CI: "true", NO_COLOR: "1", FORCE_COLOR: "0", diff --git a/packages/cli/src/init/auth.test.ts b/packages/cli/src/init/auth.test.ts index 98626225..5335d799 100644 --- a/packages/cli/src/init/auth.test.ts +++ b/packages/cli/src/init/auth.test.ts @@ -204,7 +204,8 @@ describe("`neon init` failure output", { timeout: 20_000 }, () => { writeFileSync(resolve(configDir, "credentials.json"), credentials, { mode: 0o600, }); - const result = spawnSync(process.execPath, [CLI, "init", ...args], { + const cliArgs = [CLI, "--no-analytics", "init", ...args]; + const result = spawnSync(process.execPath, cliArgs, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], @@ -212,7 +213,6 @@ describe("`neon init` failure output", { timeout: 20_000 }, () => { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", NEON_CONFIG_DIR: configDir, - NEON_NO_ANALYTICS: "1", }, }); return { ...result, configDir };