Skip to content

Commit 7517d84

Browse files
committed
feat: add telemetry notice and preference management
1 parent 4a3d91d commit 7517d84

File tree

14 files changed

+554
-14
lines changed

14 files changed

+554
-14
lines changed

integ-tests/telemetry.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { spawnAndCollect } from '../src/test-utils/cli-runner.js';
2+
import { mkdtempSync } from 'node:fs';
3+
import { rm } from 'node:fs/promises';
4+
import { tmpdir } from 'node:os';
5+
import { join } from 'node:path';
6+
import { afterAll, describe, expect, it } from 'vitest';
7+
8+
const testConfigDir = mkdtempSync(join(tmpdir(), 'agentcore-integ-'));
9+
const cliPath = join(__dirname, '..', 'dist', 'cli', 'index.mjs');
10+
11+
function run(args: string[]) {
12+
return spawnAndCollect('node', [cliPath, ...args], tmpdir(), {
13+
AGENTCORE_SKIP_INSTALL: '1',
14+
AGENTCORE_CONFIG_DIR: testConfigDir,
15+
});
16+
}
17+
18+
describe('telemetry e2e', () => {
19+
afterAll(() => rm(testConfigDir, { recursive: true, force: true }));
20+
21+
it('disable → status shows Disabled, enable → status shows Enabled', async () => {
22+
await run(['telemetry', 'disable']);
23+
let status = await run(['telemetry', 'status']);
24+
expect(status.stdout).toContain('Disabled');
25+
expect(status.stdout).toContain('global config');
26+
27+
await run(['telemetry', 'enable']);
28+
status = await run(['telemetry', 'status']);
29+
expect(status.stdout).toContain('Enabled');
30+
expect(status.stdout).toContain('global config');
31+
});
32+
});
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { getOrCreateInstallationId, readGlobalConfig, updateGlobalConfig } from '../global-config';
2+
import { createTempConfig } from './helpers/temp-config';
3+
import { readFile, writeFile } from 'fs/promises';
4+
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
5+
6+
const tmp = createTempConfig('gc');
7+
8+
describe('global-config', () => {
9+
beforeEach(() => tmp.setup());
10+
afterAll(() => tmp.cleanup());
11+
12+
describe('readGlobalConfig', () => {
13+
it('returns parsed config when file exists', async () => {
14+
await writeFile(tmp.configFile, JSON.stringify({ telemetry: { enabled: false } }));
15+
16+
const config = await readGlobalConfig(tmp.configFile);
17+
18+
expect(config).toEqual({ telemetry: { enabled: false } });
19+
});
20+
21+
it('returns empty object when file is missing or invalid', async () => {
22+
expect(await readGlobalConfig(tmp.testDir + '/nonexistent.json')).toEqual({});
23+
24+
await writeFile(tmp.configFile, JSON.stringify({ telemetry: { enabled: 'false' } }));
25+
expect(await readGlobalConfig(tmp.configFile)).toEqual({});
26+
});
27+
28+
it('preserves unknown fields via passthrough', async () => {
29+
const full = {
30+
installationId: 'abc-123',
31+
telemetry: { enabled: true, endpoint: 'https://example.com', audit: false },
32+
futureField: 'hello',
33+
};
34+
await writeFile(tmp.configFile, JSON.stringify(full));
35+
36+
const config = await readGlobalConfig(tmp.configFile);
37+
38+
expect(config).toEqual(full);
39+
});
40+
});
41+
42+
describe('updateGlobalConfig', () => {
43+
it('creates directory and writes config when none exists', async () => {
44+
const fresh = createTempConfig('gc-fresh');
45+
46+
const ok = await updateGlobalConfig({ telemetry: { enabled: false } }, fresh.configDir, fresh.configFile);
47+
48+
expect(ok).toBe(true);
49+
const written = JSON.parse(await readFile(fresh.configFile, 'utf-8'));
50+
expect(written).toEqual({ telemetry: { enabled: false } });
51+
52+
await fresh.cleanup();
53+
});
54+
55+
it('deep-merges telemetry sub-object with existing config', async () => {
56+
await writeFile(
57+
tmp.configFile,
58+
JSON.stringify({ installationId: 'keep-me', telemetry: { enabled: true, endpoint: 'https://x.com' } })
59+
);
60+
61+
await updateGlobalConfig({ telemetry: { enabled: false } }, tmp.configDir, tmp.configFile);
62+
63+
const written = JSON.parse(await readFile(tmp.configFile, 'utf-8'));
64+
expect(written).toEqual({
65+
installationId: 'keep-me',
66+
telemetry: { enabled: false, endpoint: 'https://x.com' },
67+
});
68+
});
69+
70+
it('returns false on write failures', async () => {
71+
const ok = await updateGlobalConfig(
72+
{ telemetry: { enabled: true } },
73+
tmp.testDir + '/\0invalid',
74+
tmp.testDir + '/\0invalid/config.json'
75+
);
76+
77+
expect(ok).toBe(false);
78+
});
79+
});
80+
81+
describe('getOrCreateInstallationId', () => {
82+
it('generates installationId on first run and returns created: true', async () => {
83+
const result = await getOrCreateInstallationId(tmp.configDir, tmp.configFile);
84+
85+
expect(result.created).toBe(true);
86+
expect(result.id).toMatch(/^[0-9a-f-]{36}$/);
87+
const config = await readGlobalConfig(tmp.configFile);
88+
expect(config.installationId).toBe(result.id);
89+
});
90+
91+
it('returns existing id with created: false', async () => {
92+
await writeFile(tmp.configFile, JSON.stringify({ installationId: 'existing-id' }));
93+
94+
const result = await getOrCreateInstallationId(tmp.configDir, tmp.configFile);
95+
96+
expect(result).toEqual({ id: 'existing-id', created: false });
97+
});
98+
});
99+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { mkdir, rm } from 'fs/promises';
2+
import { randomUUID } from 'node:crypto';
3+
import { tmpdir } from 'node:os';
4+
import { join } from 'node:path';
5+
6+
export interface TempConfigPaths {
7+
testDir: string;
8+
configDir: string;
9+
configFile: string;
10+
setup: () => Promise<void>;
11+
cleanup: () => Promise<void>;
12+
}
13+
14+
export function createTempConfig(label: string): TempConfigPaths {
15+
const testDir = join(tmpdir(), `agentcore-${label}-${randomUUID()}`);
16+
const configDir = join(testDir, '.agentcore');
17+
const configFile = join(configDir, 'config.json');
18+
return {
19+
testDir,
20+
configDir,
21+
configFile,
22+
setup: async () => {
23+
await rm(testDir, { recursive: true, force: true });
24+
await mkdir(configDir, { recursive: true });
25+
},
26+
cleanup: () => rm(testDir, { recursive: true, force: true }),
27+
};
28+
}

src/cli/cli.ts

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@ import { registerRemove } from './commands/remove';
1414
import { registerResume } from './commands/resume';
1515
import { registerRun } from './commands/run';
1616
import { registerStatus } from './commands/status';
17+
import { registerTelemetry } from './commands/telemetry';
1718
import { registerTraces } from './commands/traces';
1819
import { registerUpdate } from './commands/update';
1920
import { registerValidate } from './commands/validate';
2021
import { PACKAGE_VERSION } from './constants';
22+
import { getOrCreateInstallationId } from './global-config';
2123
import { ALL_PRIMITIVES } from './primitives';
2224
import { App } from './tui/App';
2325
import { LayoutProvider } from './tui/context';
@@ -61,10 +63,37 @@ function setupGlobalCleanup() {
6163
});
6264
}
6365

66+
function printTelemetryNotice(): void {
67+
const yellow = '\x1b[33m';
68+
const reset = '\x1b[0m';
69+
process.stderr.write(
70+
[
71+
'',
72+
`${yellow}The AgentCore CLI will soon begin collecting aggregated, anonymous usage`,
73+
'analytics to help improve the tool.',
74+
'To opt out: agentcore telemetry disable',
75+
`To learn more: agentcore telemetry --help${reset}`,
76+
'',
77+
'',
78+
].join('\n')
79+
);
80+
}
81+
82+
function printPostCommandNotices(isFirstRun: boolean, updateCheck: Promise<UpdateCheckResult | null>): Promise<void> {
83+
if (isFirstRun) {
84+
printTelemetryNotice();
85+
}
86+
return updateCheck.then(result => {
87+
if (result?.updateAvailable) {
88+
printUpdateNotification(result);
89+
}
90+
});
91+
}
92+
6493
/**
6594
* Render the TUI in alternate screen buffer mode.
6695
*/
67-
function renderTUI(updateCheck: Promise<UpdateCheckResult | null>) {
96+
function renderTUI(updateCheck: Promise<UpdateCheckResult | null>, isFirstRun: boolean) {
6897
inAltScreen = true;
6998
process.stdout.write(ENTER_ALT_SCREEN);
7099

@@ -82,11 +111,7 @@ function renderTUI(updateCheck: Promise<UpdateCheckResult | null>) {
82111
clearExitMessage();
83112
}
84113

85-
// Print update notification after TUI exits
86-
const result = await updateCheck;
87-
if (result?.updateAvailable) {
88-
printUpdateNotification(result);
89-
}
114+
await printPostCommandNotices(isFirstRun, updateCheck);
90115
});
91116
}
92117

@@ -148,6 +173,7 @@ export function registerCommands(program: Command) {
148173
registerResume(program);
149174
registerRun(program);
150175
registerStatus(program);
176+
registerTelemetry(program);
151177
registerTraces(program);
152178
registerUpdate(program);
153179
registerValidate(program);
@@ -162,6 +188,9 @@ export const main = async (argv: string[]) => {
162188
// Register global cleanup handlers once at startup
163189
setupGlobalCleanup();
164190

191+
// Generate installationId on first run and show telemetry notice
192+
const { created: isFirstRun } = await getOrCreateInstallationId();
193+
165194
const program = createProgram();
166195

167196
const args = argv.slice(2);
@@ -172,15 +201,16 @@ export const main = async (argv: string[]) => {
172201

173202
// Show TUI for no arguments, commander handles --help via configureHelp()
174203
if (args.length === 0) {
175-
renderTUI(updateCheck);
204+
renderTUI(updateCheck, isFirstRun);
176205
return;
177206
}
178207

208+
if (isFirstRun) {
209+
printTelemetryNotice();
210+
}
211+
179212
await program.parseAsync(argv);
180213

181-
// Print notification after command finishes
182-
const result = await updateCheck;
183-
if (result?.updateAvailable) {
184-
printUpdateNotification(result);
185-
}
214+
// Telemetry notice already printed above; only run update check here.
215+
await printPostCommandNotices(false, updateCheck);
186216
};
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { createTempConfig } from '../../../__tests__/helpers/temp-config';
2+
import { readGlobalConfig } from '../../../global-config';
3+
import { handleTelemetryDisable, handleTelemetryEnable, handleTelemetryStatus } from '../actions';
4+
import { chmod, mkdir, rm, writeFile } from 'fs/promises';
5+
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6+
7+
const tmp = createTempConfig('actions');
8+
9+
describe('telemetry actions', () => {
10+
const originalEnv = process.env;
11+
12+
beforeEach(() => tmp.setup());
13+
14+
afterEach(() => {
15+
process.env = originalEnv;
16+
});
17+
18+
afterAll(() => tmp.cleanup());
19+
20+
describe('handleTelemetryDisable', () => {
21+
it('writes disabled to config and returns true', async () => {
22+
const ok = await handleTelemetryDisable(tmp.configDir, tmp.configFile);
23+
24+
expect(ok).toBe(true);
25+
const config = await readGlobalConfig(tmp.configFile);
26+
expect(config.telemetry?.enabled).toBe(false);
27+
});
28+
29+
it('returns false when config write fails', async () => {
30+
await rm(tmp.testDir, { recursive: true, force: true });
31+
await mkdir(tmp.testDir, { recursive: true });
32+
await chmod(tmp.testDir, 0o444);
33+
34+
const ok = await handleTelemetryDisable(tmp.configDir, tmp.configFile);
35+
36+
expect(ok).toBe(false);
37+
38+
await chmod(tmp.testDir, 0o755);
39+
});
40+
});
41+
42+
describe('handleTelemetryEnable', () => {
43+
it('writes enabled to config and returns true', async () => {
44+
const ok = await handleTelemetryEnable(tmp.configDir, tmp.configFile);
45+
46+
expect(ok).toBe(true);
47+
const config = await readGlobalConfig(tmp.configFile);
48+
expect(config.telemetry?.enabled).toBe(true);
49+
});
50+
});
51+
52+
describe('handleTelemetryStatus', () => {
53+
it('reports default source when no config exists', async () => {
54+
const spy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
55+
56+
await handleTelemetryStatus(tmp.configFile);
57+
58+
const output = spy.mock.calls.map(c => c[0]).join('\n');
59+
expect(output).toContain('Enabled');
60+
expect(output).toContain('default');
61+
spy.mockRestore();
62+
});
63+
64+
it('reports global-config source when config exists', async () => {
65+
await writeFile(tmp.configFile, JSON.stringify({ telemetry: { enabled: false } }));
66+
const spy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
67+
68+
await handleTelemetryStatus(tmp.configFile);
69+
70+
const output = spy.mock.calls.map(c => c[0]).join('\n');
71+
expect(output).toContain('Disabled');
72+
expect(output).toContain('global config');
73+
spy.mockRestore();
74+
});
75+
76+
it('reports environment source with env var note', async () => {
77+
process.env = { ...originalEnv, AGENTCORE_TELEMETRY_DISABLED: 'true' };
78+
const spy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
79+
80+
await handleTelemetryStatus(tmp.configFile);
81+
82+
const output = spy.mock.calls.map(c => c[0]).join('\n');
83+
expect(output).toContain('Disabled');
84+
expect(output).toContain('environment');
85+
expect(output).toContain('AGENTCORE_TELEMETRY_DISABLED');
86+
spy.mockRestore();
87+
});
88+
});
89+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { GLOBAL_CONFIG_DIR, GLOBAL_CONFIG_FILE, updateGlobalConfig } from '../../global-config.js';
2+
import { resolveTelemetryPreference } from '../../telemetry/resolve.js';
3+
4+
export async function handleTelemetryDisable(
5+
configDir = GLOBAL_CONFIG_DIR,
6+
configFile = GLOBAL_CONFIG_FILE
7+
): Promise<boolean> {
8+
const ok = await updateGlobalConfig({ telemetry: { enabled: false } }, configDir, configFile);
9+
console.log(ok ? 'Telemetry has been disabled.' : `Warning: could not write config to ${configFile}`);
10+
return ok;
11+
}
12+
13+
export async function handleTelemetryEnable(
14+
configDir = GLOBAL_CONFIG_DIR,
15+
configFile = GLOBAL_CONFIG_FILE
16+
): Promise<boolean> {
17+
const ok = await updateGlobalConfig({ telemetry: { enabled: true } }, configDir, configFile);
18+
console.log(ok ? 'Telemetry has been enabled.' : `Warning: could not write config to ${configFile}`);
19+
return ok;
20+
}
21+
22+
export async function handleTelemetryStatus(configFile = GLOBAL_CONFIG_FILE): Promise<void> {
23+
const pref = await resolveTelemetryPreference(configFile);
24+
25+
const status = pref.enabled ? 'Enabled' : 'Disabled';
26+
const sourceLabel =
27+
pref.source === 'environment'
28+
? 'environment variable'
29+
: pref.source === 'global-config'
30+
? `global config (${configFile})`
31+
: 'default';
32+
33+
console.log(`Telemetry: ${status}`);
34+
console.log(`Source: ${sourceLabel}`);
35+
36+
if (pref.envVar) {
37+
console.log(`\nNote: ${pref.envVar.name}=${pref.envVar.value} is set in your environment.`);
38+
}
39+
}

0 commit comments

Comments
 (0)