diff --git a/package.json b/package.json index 5c259df7a..1760fb984 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "archiver": "~7.0.1", "axios": "^1.11.0", "chalk": "~5.6.0", + "ci-info": "~4.4.0", "cli-table3": "^0.6.5", "computer-name": "~0.1.0", "configparser": "~0.3.10", diff --git a/src/lib/command-framework/apify-command.ts b/src/lib/command-framework/apify-command.ts index 049359bd7..fc32080b5 100644 --- a/src/lib/command-framework/apify-command.ts +++ b/src/lib/command-framework/apify-command.ts @@ -9,8 +9,10 @@ import widestLine from 'widest-line'; import wrapAnsi from 'wrap-ansi'; import { cachedStdinInput } from '../../entrypoints/_shared.js'; +import { detectAiAgent, detectCi, detectIsInteractive } from '../hooks/telemetry/detectEnvironment.js'; import type { TrackEventMap } from '../hooks/telemetry/trackEvent.js'; import { trackEvent } from '../hooks/telemetry/trackEvent.js'; +import { checkAndUpdateLastCommand } from '../hooks/telemetry/useTelemetryState.js'; import { useCLIMetadata } from '../hooks/useCLIMetadata.js'; import { ProjectLanguage, useCwdProject } from '../hooks/useCwdProject.js'; import { error } from '../outputs.js'; @@ -220,6 +222,12 @@ export abstract class ApifyCommand; @@ -243,6 +251,7 @@ export abstract class ApifyCommand { }); // First time we are tracking telemetry, so we want to notify user about it. - info({ message: telemetryWarningText }); + // Skip the notice if telemetry is disabled via env var — the user already opted out. + if ( + !process.env.APIFY_CLI_DISABLE_TELEMETRY || + ['false', '0'].includes(process.env.APIFY_CLI_DISABLE_TELEMETRY) + ) { + info({ message: telemetryWarningText }); + } return useTelemetryState(); } @@ -110,6 +118,33 @@ export async function updateUserId(userId: string | null) { }); } +/** Max time (ms) between identical commands to consider the second one a retry (e.g. user re-running after a failure). */ +const RETRY_WINDOW_MS = 10_000; + +/** + * Checks whether the same command was executed within {@link RETRY_WINDOW_MS} and updates the + * last-command state for future calls. Detection is best-effort — concurrent invocations may + * both read stale state, which is acceptable for an analytics heuristic. + */ +export async function checkAndUpdateLastCommand(commandString: string): Promise { + try { + const state = await useTelemetryState(); + const now = Date.now(); + + const wasRetried = + state.lastCommand === commandString && now - (state.lastCommandTimestamp ?? 0) < RETRY_WINDOW_MS; + + updateTelemetryState(state, (stateToUpdate) => { + stateToUpdate.lastCommand = commandString; + stateToUpdate.lastCommandTimestamp = now; + }); + + return wasRetried; + } catch { + return false; + } +} + export async function updateTelemetryEnabled(enabled: boolean) { const state = await useTelemetryState(); diff --git a/test/lib/hooks/telemetry/checkAndUpdateLastCommand.test.ts b/test/lib/hooks/telemetry/checkAndUpdateLastCommand.test.ts new file mode 100644 index 000000000..845c7a6c5 --- /dev/null +++ b/test/lib/hooks/telemetry/checkAndUpdateLastCommand.test.ts @@ -0,0 +1,222 @@ +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +let telemetryFilePath: string; + +vi.mock('../../../../src/lib/consts.js', async (importOriginal) => { + const original = await importOriginal(); + + return { + ...original, + TELEMETRY_FILE_PATH: () => telemetryFilePath, + }; +}); + +vi.mock('../../../../src/lib/utils.js', () => ({ + getLocalUserInfo: async () => ({}), +})); + +vi.mock('../../../../src/lib/outputs.js', () => ({ + info: () => { + /* noop */ + }, +})); + +function writeTelemetryState(state: Record) { + const dir = dirname(telemetryFilePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(telemetryFilePath, JSON.stringify(state, null, '\t')); +} + +function readTelemetryState() { + return JSON.parse(readFileSync(telemetryFilePath, 'utf-8')); +} + +describe('checkAndUpdateLastCommand', () => { + let testDir: string; + let counter = 0; + + beforeEach(() => { + counter++; + testDir = join(tmpdir(), `apify-cli-test-telemetry-${process.pid}-${counter}-${Date.now()}`); + telemetryFilePath = join(testDir, 'telemetry.json'); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + // Clean up temp files + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + test('returns false on first invocation (no prior command)', async () => { + vi.setSystemTime(1000); + + const { checkAndUpdateLastCommand } = await import('../../../../src/lib/hooks/telemetry/useTelemetryState.js'); + + const result = await checkAndUpdateLastCommand('apify run'); + + expect(result).toBe(false); + }); + + test('stores the command and timestamp in telemetry state', async () => { + vi.setSystemTime(50_000); + + const { checkAndUpdateLastCommand } = await import('../../../../src/lib/hooks/telemetry/useTelemetryState.js'); + + await checkAndUpdateLastCommand('apify push'); + + const state = readTelemetryState(); + expect(state.lastCommand).toBe('apify push'); + expect(state.lastCommandTimestamp).toBe(50_000); + }); + + test('returns true when the same command is repeated within the retry window', async () => { + vi.setSystemTime(100_000); + + // Seed state with a recent identical command + writeTelemetryState({ + version: 1, + enabled: true, + anonymousId: 'CLI:test', + lastCommand: 'apify run', + lastCommandTimestamp: 95_000, // 5 seconds ago — within the 10s window + }); + + const { checkAndUpdateLastCommand } = await import('../../../../src/lib/hooks/telemetry/useTelemetryState.js'); + + const result = await checkAndUpdateLastCommand('apify run'); + + expect(result).toBe(true); + }); + + test('returns false when the same command is repeated outside the retry window', async () => { + vi.setSystemTime(100_000); + + writeTelemetryState({ + version: 1, + enabled: true, + anonymousId: 'CLI:test', + lastCommand: 'apify run', + lastCommandTimestamp: 80_000, // 20 seconds ago — outside the 10s window + }); + + const { checkAndUpdateLastCommand } = await import('../../../../src/lib/hooks/telemetry/useTelemetryState.js'); + + const result = await checkAndUpdateLastCommand('apify run'); + + expect(result).toBe(false); + }); + + test('returns false when a different command is run within the retry window', async () => { + vi.setSystemTime(100_000); + + writeTelemetryState({ + version: 1, + enabled: true, + anonymousId: 'CLI:test', + lastCommand: 'apify run', + lastCommandTimestamp: 95_000, + }); + + const { checkAndUpdateLastCommand } = await import('../../../../src/lib/hooks/telemetry/useTelemetryState.js'); + + const result = await checkAndUpdateLastCommand('apify push'); + + expect(result).toBe(false); + }); + + test('updates state after checking so the next call sees the new command', async () => { + vi.setSystemTime(100_000); + + writeTelemetryState({ + version: 1, + enabled: true, + anonymousId: 'CLI:test', + lastCommand: 'apify run', + lastCommandTimestamp: 90_000, + }); + + const { checkAndUpdateLastCommand } = await import('../../../../src/lib/hooks/telemetry/useTelemetryState.js'); + + await checkAndUpdateLastCommand('apify push'); + + const state = readTelemetryState(); + expect(state.lastCommand).toBe('apify push'); + expect(state.lastCommandTimestamp).toBe(100_000); + }); + + test('returns false when lastCommandTimestamp is missing', async () => { + vi.setSystemTime(100_000); + + writeTelemetryState({ + version: 1, + enabled: true, + anonymousId: 'CLI:test', + lastCommand: 'apify run', + // no lastCommandTimestamp + }); + + const { checkAndUpdateLastCommand } = await import('../../../../src/lib/hooks/telemetry/useTelemetryState.js'); + + const result = await checkAndUpdateLastCommand('apify run'); + + expect(result).toBe(false); + }); + + test('returns false when telemetry state file is corrupted', async () => { + // Write invalid JSON + const dir = dirname(telemetryFilePath); + mkdirSync(dir, { recursive: true }); + writeFileSync(telemetryFilePath, '{{{invalid json'); + + const { checkAndUpdateLastCommand } = await import('../../../../src/lib/hooks/telemetry/useTelemetryState.js'); + + const result = await checkAndUpdateLastCommand('apify run'); + + expect(result).toBe(false); + }); + + test('returns true at exactly the retry window boundary', async () => { + // Command was run exactly 9999ms ago (just inside the 10_000ms window) + vi.setSystemTime(109_999); + + writeTelemetryState({ + version: 1, + enabled: true, + anonymousId: 'CLI:test', + lastCommand: 'apify run', + lastCommandTimestamp: 100_000, + }); + + const { checkAndUpdateLastCommand } = await import('../../../../src/lib/hooks/telemetry/useTelemetryState.js'); + + const result = await checkAndUpdateLastCommand('apify run'); + + expect(result).toBe(true); + }); + + test('returns false at exactly the retry window boundary (equal to window)', async () => { + // Command was run exactly 10_000ms ago (at the boundary, not strictly less than) + vi.setSystemTime(110_000); + + writeTelemetryState({ + version: 1, + enabled: true, + anonymousId: 'CLI:test', + lastCommand: 'apify run', + lastCommandTimestamp: 100_000, + }); + + const { checkAndUpdateLastCommand } = await import('../../../../src/lib/hooks/telemetry/useTelemetryState.js'); + + const result = await checkAndUpdateLastCommand('apify run'); + + expect(result).toBe(false); + }); +}); diff --git a/test/lib/hooks/telemetry/detectEnvironment.test.ts b/test/lib/hooks/telemetry/detectEnvironment.test.ts new file mode 100644 index 000000000..ac0ddb408 --- /dev/null +++ b/test/lib/hooks/telemetry/detectEnvironment.test.ts @@ -0,0 +1,74 @@ +import { detectAiAgent, detectCi, detectIsInteractive } from '../../../../src/lib/hooks/telemetry/detectEnvironment.js'; + +// `ci-info` evaluates process.env at import time, +// so we mock it to control the return value per-test. +vi.mock('ci-info', () => ({ default: { isCI: false, id: null } })); + +describe('detectAiAgent', () => { + const agentEnvVars = [ + 'CLAUDECODE', + 'CLAUDE_CODE_ENTRYPOINT', + 'CURSOR_AGENT', + 'CLINE_ACTIVE', + 'CODEX_SANDBOX', + 'CODEX_THREAD_ID', + 'GEMINI_CLI', + 'OPENCODE', + 'OPENCLAW_SHELL', + ]; + + afterEach(() => { + for (const key of agentEnvVars) { + delete process.env[key]; + } + }); + + test('returns undefined when no agent env vars are set', () => { + expect(detectAiAgent()).toBeUndefined(); + }); + + test('returns correct agent for a known env var', () => { + process.env.GEMINI_CLI = '1'; + expect(detectAiAgent()).toBe('gemini_cli'); + }); + + test('returns first match when multiple agent env vars are set', () => { + process.env.CURSOR_AGENT = '1'; + process.env.GEMINI_CLI = '1'; + + // CURSOR_AGENT appears before GEMINI_CLI in the lookup table + expect(detectAiAgent()).toBe('cursor'); + }); +}); + +describe('detectCi', () => { + test('returns isCi false when not in CI', () => { + const result = detectCi(); + expect(result).toEqual({ isCi: false, ciProvider: undefined }); + }); + + test('returns provider id from ci-info when in CI', async () => { + vi.resetModules(); + vi.doMock('ci-info', () => ({ default: { isCI: true, id: 'GITHUB_ACTIONS' } })); + + const { detectCi: detectCiFresh } = await import('../../../../src/lib/hooks/telemetry/detectEnvironment.js'); + + expect(detectCiFresh()).toEqual({ isCi: true, ciProvider: 'github_actions' }); + }); + + test('returns unknown provider when in CI but ci-info has no id', async () => { + vi.resetModules(); + vi.doMock('ci-info', () => ({ default: { isCI: true, id: null } })); + + const { detectCi: detectCiFresh } = await import('../../../../src/lib/hooks/telemetry/detectEnvironment.js'); + + expect(detectCiFresh()).toEqual({ isCi: true, ciProvider: 'unknown' }); + }); +}); + +describe('detectIsInteractive', () => { + test('returns false when stdin or stdout is not a TTY', () => { + // In test runners, stdio is typically not a TTY + expect(detectIsInteractive()).toBe(false); + }); +}); diff --git a/yarn.lock b/yarn.lock index 8c5652016..71ee4234e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2433,6 +2433,7 @@ __metadata: archiver: "npm:~7.0.1" axios: "npm:^1.11.0" chalk: "npm:~5.6.0" + ci-info: "npm:~4.4.0" cli-table3: "npm:^0.6.5" computer-name: "npm:~0.1.0" configparser: "npm:~0.3.10" @@ -3215,7 +3216,7 @@ __metadata: languageName: node linkType: hard -"ci-info@npm:^4.0.0, ci-info@npm:^4.1.0": +"ci-info@npm:^4.0.0, ci-info@npm:^4.1.0, ci-info@npm:~4.4.0": version: 4.4.0 resolution: "ci-info@npm:4.4.0" checksum: 10c0/44156201545b8dde01aa8a09ee2fe9fc7a73b1bef9adbd4606c9f61c8caeeb73fb7a575c88b0443f7b4edb5ee45debaa59ed54ba5f99698339393ca01349eb3a