Skip to content

Commit 9288ff8

Browse files
feat: add execution context to telemetry events (#1074)
Track exit code, duration, CI/AI agent environment, interactivity, and command retry detection in telemetry data for better CLI usage insights.
1 parent 3d899db commit 9288ff8

8 files changed

Lines changed: 394 additions & 4 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
"archiver": "~7.0.1",
8686
"axios": "^1.11.0",
8787
"chalk": "~5.6.0",
88+
"ci-info": "~4.4.0",
8889
"cli-table3": "^0.6.5",
8990
"computer-name": "~0.1.0",
9091
"configparser": "~0.3.10",

src/lib/command-framework/apify-command.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ import widestLine from 'widest-line';
99
import wrapAnsi from 'wrap-ansi';
1010

1111
import { cachedStdinInput } from '../../entrypoints/_shared.js';
12+
import { detectAiAgent, detectCi, detectIsInteractive } from '../hooks/telemetry/detectEnvironment.js';
1213
import type { TrackEventMap } from '../hooks/telemetry/trackEvent.js';
1314
import { trackEvent } from '../hooks/telemetry/trackEvent.js';
15+
import { checkAndUpdateLastCommand } from '../hooks/telemetry/useTelemetryState.js';
1416
import { useCLIMetadata } from '../hooks/useCLIMetadata.js';
1517
import { ProjectLanguage, useCwdProject } from '../hooks/useCwdProject.js';
1618
import { error } from '../outputs.js';
@@ -220,6 +222,12 @@ export abstract class ApifyCommand<T extends typeof BuiltApifyCommand = typeof B
220222

221223
this.telemetryData.commandString = commandString;
222224
this.telemetryData.entrypoint = entrypoint;
225+
226+
const ci = detectCi();
227+
this.telemetryData.aiAgent = detectAiAgent();
228+
this.telemetryData.isCi = ci.isCi;
229+
this.telemetryData.ciProvider = ci.ciProvider;
230+
this.telemetryData.isInteractive = detectIsInteractive();
223231
}
224232

225233
abstract run(): Awaitable<void>;
@@ -243,6 +251,7 @@ export abstract class ApifyCommand<T extends typeof BuiltApifyCommand = typeof B
243251
}
244252

245253
private async _run(parseResult: ParseResult) {
254+
const startTime = Date.now();
246255
const { values: rawFlags, positionals: rawArgs, tokens: rawTokens } = parseResult;
247256

248257
if (rawFlags.help) {
@@ -329,9 +338,13 @@ export abstract class ApifyCommand<T extends typeof BuiltApifyCommand = typeof B
329338
});
330339
}
331340

332-
this.telemetryData.flagsUsed = Object.keys(this.flags);
333-
334341
if (!this.skipTelemetry) {
342+
this.telemetryData.flagsUsed = Object.keys(this.flags);
343+
this.telemetryData.exitCode = typeof process.exitCode === 'number' ? process.exitCode : 0;
344+
this.telemetryData.durationMs = Date.now() - startTime;
345+
346+
this.telemetryData.wasRetried = await checkAndUpdateLastCommand(this.commandString);
347+
335348
await trackEvent(
336349
`cli_command_${this.commandString.replaceAll(' ', '_').toLowerCase()}` as const,
337350
this.telemetryData,
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import ciInfo from 'ci-info';
2+
3+
const AI_AGENT_ENV_VARS: [string, string][] = [
4+
['CLAUDECODE', 'claude_code'],
5+
['CLAUDE_CODE_ENTRYPOINT', 'claude_code'],
6+
['CURSOR_AGENT', 'cursor'],
7+
['CLINE_ACTIVE', 'cline'],
8+
['CODEX_SANDBOX', 'codex_cli'],
9+
['CODEX_THREAD_ID', 'codex_cli'],
10+
['GEMINI_CLI', 'gemini_cli'],
11+
['OPENCODE', 'open_code'],
12+
['OPENCLAW_SHELL', 'openclaw'],
13+
];
14+
15+
export function detectAiAgent(): string | undefined {
16+
for (const [envVar, agent] of AI_AGENT_ENV_VARS) {
17+
if (process.env[envVar]) {
18+
return agent;
19+
}
20+
}
21+
22+
return undefined;
23+
}
24+
25+
export function detectCi(): { isCi: boolean; ciProvider: string | undefined } {
26+
if (!ciInfo.isCI) {
27+
return { isCi: false, ciProvider: undefined };
28+
}
29+
30+
return { isCi: true, ciProvider: ciInfo.id?.toLowerCase() ?? 'unknown' };
31+
}
32+
33+
export function detectIsInteractive(): boolean {
34+
return !!process.stdin.isTTY && !!process.stdout.isTTY;
35+
}

src/lib/hooks/telemetry/trackEvent.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,15 @@ export interface TrackEventMap {
3737

3838
// init command
3939
actorWrapper?: string;
40+
41+
// execution context
42+
exitCode?: number;
43+
durationMs?: number;
44+
aiAgent?: string;
45+
isCi?: boolean;
46+
ciProvider?: string;
47+
isInteractive?: boolean;
48+
wasRetried?: boolean;
4049
};
4150
}
4251

src/lib/hooks/telemetry/useTelemetryState.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ interface TelemetryStateV1 {
2222
enabled: boolean;
2323
userId?: string | null;
2424
anonymousId: string;
25+
lastCommand?: string;
26+
lastCommandTimestamp?: number;
2527
}
2628

2729
const telemetryWarningText = [
@@ -71,7 +73,13 @@ export async function useTelemetryState(): Promise<LatestTelemetryState> {
7173
});
7274

7375
// First time we are tracking telemetry, so we want to notify user about it.
74-
info({ message: telemetryWarningText });
76+
// Skip the notice if telemetry is disabled via env var — the user already opted out.
77+
if (
78+
!process.env.APIFY_CLI_DISABLE_TELEMETRY ||
79+
['false', '0'].includes(process.env.APIFY_CLI_DISABLE_TELEMETRY)
80+
) {
81+
info({ message: telemetryWarningText });
82+
}
7583

7684
return useTelemetryState();
7785
}
@@ -110,6 +118,33 @@ export async function updateUserId(userId: string | null) {
110118
});
111119
}
112120

121+
/** Max time (ms) between identical commands to consider the second one a retry (e.g. user re-running after a failure). */
122+
const RETRY_WINDOW_MS = 10_000;
123+
124+
/**
125+
* Checks whether the same command was executed within {@link RETRY_WINDOW_MS} and updates the
126+
* last-command state for future calls. Detection is best-effort — concurrent invocations may
127+
* both read stale state, which is acceptable for an analytics heuristic.
128+
*/
129+
export async function checkAndUpdateLastCommand(commandString: string): Promise<boolean> {
130+
try {
131+
const state = await useTelemetryState();
132+
const now = Date.now();
133+
134+
const wasRetried =
135+
state.lastCommand === commandString && now - (state.lastCommandTimestamp ?? 0) < RETRY_WINDOW_MS;
136+
137+
updateTelemetryState(state, (stateToUpdate) => {
138+
stateToUpdate.lastCommand = commandString;
139+
stateToUpdate.lastCommandTimestamp = now;
140+
});
141+
142+
return wasRetried;
143+
} catch {
144+
return false;
145+
}
146+
}
147+
113148
export async function updateTelemetryEnabled(enabled: boolean) {
114149
const state = await useTelemetryState();
115150

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2+
import { tmpdir } from 'node:os';
3+
import { dirname, join } from 'node:path';
4+
5+
let telemetryFilePath: string;
6+
7+
vi.mock('../../../../src/lib/consts.js', async (importOriginal) => {
8+
const original = await importOriginal<typeof import('../../../../src/lib/consts.js')>();
9+
10+
return {
11+
...original,
12+
TELEMETRY_FILE_PATH: () => telemetryFilePath,
13+
};
14+
});
15+
16+
vi.mock('../../../../src/lib/utils.js', () => ({
17+
getLocalUserInfo: async () => ({}),
18+
}));
19+
20+
vi.mock('../../../../src/lib/outputs.js', () => ({
21+
info: () => {
22+
/* noop */
23+
},
24+
}));
25+
26+
function writeTelemetryState(state: Record<string, unknown>) {
27+
const dir = dirname(telemetryFilePath);
28+
if (!existsSync(dir)) {
29+
mkdirSync(dir, { recursive: true });
30+
}
31+
writeFileSync(telemetryFilePath, JSON.stringify(state, null, '\t'));
32+
}
33+
34+
function readTelemetryState() {
35+
return JSON.parse(readFileSync(telemetryFilePath, 'utf-8'));
36+
}
37+
38+
describe('checkAndUpdateLastCommand', () => {
39+
let testDir: string;
40+
let counter = 0;
41+
42+
beforeEach(() => {
43+
counter++;
44+
testDir = join(tmpdir(), `apify-cli-test-telemetry-${process.pid}-${counter}-${Date.now()}`);
45+
telemetryFilePath = join(testDir, 'telemetry.json');
46+
vi.useFakeTimers();
47+
});
48+
49+
afterEach(() => {
50+
vi.useRealTimers();
51+
// Clean up temp files
52+
if (existsSync(testDir)) {
53+
rmSync(testDir, { recursive: true, force: true });
54+
}
55+
});
56+
57+
test('returns false on first invocation (no prior command)', async () => {
58+
vi.setSystemTime(1000);
59+
60+
const { checkAndUpdateLastCommand } = await import('../../../../src/lib/hooks/telemetry/useTelemetryState.js');
61+
62+
const result = await checkAndUpdateLastCommand('apify run');
63+
64+
expect(result).toBe(false);
65+
});
66+
67+
test('stores the command and timestamp in telemetry state', async () => {
68+
vi.setSystemTime(50_000);
69+
70+
const { checkAndUpdateLastCommand } = await import('../../../../src/lib/hooks/telemetry/useTelemetryState.js');
71+
72+
await checkAndUpdateLastCommand('apify push');
73+
74+
const state = readTelemetryState();
75+
expect(state.lastCommand).toBe('apify push');
76+
expect(state.lastCommandTimestamp).toBe(50_000);
77+
});
78+
79+
test('returns true when the same command is repeated within the retry window', async () => {
80+
vi.setSystemTime(100_000);
81+
82+
// Seed state with a recent identical command
83+
writeTelemetryState({
84+
version: 1,
85+
enabled: true,
86+
anonymousId: 'CLI:test',
87+
lastCommand: 'apify run',
88+
lastCommandTimestamp: 95_000, // 5 seconds ago — within the 10s window
89+
});
90+
91+
const { checkAndUpdateLastCommand } = await import('../../../../src/lib/hooks/telemetry/useTelemetryState.js');
92+
93+
const result = await checkAndUpdateLastCommand('apify run');
94+
95+
expect(result).toBe(true);
96+
});
97+
98+
test('returns false when the same command is repeated outside the retry window', async () => {
99+
vi.setSystemTime(100_000);
100+
101+
writeTelemetryState({
102+
version: 1,
103+
enabled: true,
104+
anonymousId: 'CLI:test',
105+
lastCommand: 'apify run',
106+
lastCommandTimestamp: 80_000, // 20 seconds ago — outside the 10s window
107+
});
108+
109+
const { checkAndUpdateLastCommand } = await import('../../../../src/lib/hooks/telemetry/useTelemetryState.js');
110+
111+
const result = await checkAndUpdateLastCommand('apify run');
112+
113+
expect(result).toBe(false);
114+
});
115+
116+
test('returns false when a different command is run within the retry window', async () => {
117+
vi.setSystemTime(100_000);
118+
119+
writeTelemetryState({
120+
version: 1,
121+
enabled: true,
122+
anonymousId: 'CLI:test',
123+
lastCommand: 'apify run',
124+
lastCommandTimestamp: 95_000,
125+
});
126+
127+
const { checkAndUpdateLastCommand } = await import('../../../../src/lib/hooks/telemetry/useTelemetryState.js');
128+
129+
const result = await checkAndUpdateLastCommand('apify push');
130+
131+
expect(result).toBe(false);
132+
});
133+
134+
test('updates state after checking so the next call sees the new command', async () => {
135+
vi.setSystemTime(100_000);
136+
137+
writeTelemetryState({
138+
version: 1,
139+
enabled: true,
140+
anonymousId: 'CLI:test',
141+
lastCommand: 'apify run',
142+
lastCommandTimestamp: 90_000,
143+
});
144+
145+
const { checkAndUpdateLastCommand } = await import('../../../../src/lib/hooks/telemetry/useTelemetryState.js');
146+
147+
await checkAndUpdateLastCommand('apify push');
148+
149+
const state = readTelemetryState();
150+
expect(state.lastCommand).toBe('apify push');
151+
expect(state.lastCommandTimestamp).toBe(100_000);
152+
});
153+
154+
test('returns false when lastCommandTimestamp is missing', async () => {
155+
vi.setSystemTime(100_000);
156+
157+
writeTelemetryState({
158+
version: 1,
159+
enabled: true,
160+
anonymousId: 'CLI:test',
161+
lastCommand: 'apify run',
162+
// no lastCommandTimestamp
163+
});
164+
165+
const { checkAndUpdateLastCommand } = await import('../../../../src/lib/hooks/telemetry/useTelemetryState.js');
166+
167+
const result = await checkAndUpdateLastCommand('apify run');
168+
169+
expect(result).toBe(false);
170+
});
171+
172+
test('returns false when telemetry state file is corrupted', async () => {
173+
// Write invalid JSON
174+
const dir = dirname(telemetryFilePath);
175+
mkdirSync(dir, { recursive: true });
176+
writeFileSync(telemetryFilePath, '{{{invalid json');
177+
178+
const { checkAndUpdateLastCommand } = await import('../../../../src/lib/hooks/telemetry/useTelemetryState.js');
179+
180+
const result = await checkAndUpdateLastCommand('apify run');
181+
182+
expect(result).toBe(false);
183+
});
184+
185+
test('returns true at exactly the retry window boundary', async () => {
186+
// Command was run exactly 9999ms ago (just inside the 10_000ms window)
187+
vi.setSystemTime(109_999);
188+
189+
writeTelemetryState({
190+
version: 1,
191+
enabled: true,
192+
anonymousId: 'CLI:test',
193+
lastCommand: 'apify run',
194+
lastCommandTimestamp: 100_000,
195+
});
196+
197+
const { checkAndUpdateLastCommand } = await import('../../../../src/lib/hooks/telemetry/useTelemetryState.js');
198+
199+
const result = await checkAndUpdateLastCommand('apify run');
200+
201+
expect(result).toBe(true);
202+
});
203+
204+
test('returns false at exactly the retry window boundary (equal to window)', async () => {
205+
// Command was run exactly 10_000ms ago (at the boundary, not strictly less than)
206+
vi.setSystemTime(110_000);
207+
208+
writeTelemetryState({
209+
version: 1,
210+
enabled: true,
211+
anonymousId: 'CLI:test',
212+
lastCommand: 'apify run',
213+
lastCommandTimestamp: 100_000,
214+
});
215+
216+
const { checkAndUpdateLastCommand } = await import('../../../../src/lib/hooks/telemetry/useTelemetryState.js');
217+
218+
const result = await checkAndUpdateLastCommand('apify run');
219+
220+
expect(result).toBe(false);
221+
});
222+
});

0 commit comments

Comments
 (0)