Skip to content

Commit 580fae2

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

13 files changed

Lines changed: 571 additions & 6 deletions

File tree

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { getOrCreateInstallationId, readGlobalConfig, updateGlobalConfig } from '../global-config';
2+
import { mkdir, readFile, rm, writeFile } from 'fs/promises';
3+
import { randomUUID } from 'node:crypto';
4+
import { tmpdir } from 'node:os';
5+
import { join } from 'node:path';
6+
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
7+
8+
const testDir = join(tmpdir(), `agentcore-gc-${randomUUID()}`);
9+
const configDir = join(testDir, '.agentcore');
10+
const configFile = join(configDir, 'config.json');
11+
12+
describe('global-config', () => {
13+
beforeEach(async () => {
14+
await rm(testDir, { recursive: true, force: true });
15+
await mkdir(configDir, { recursive: true });
16+
});
17+
18+
afterAll(async () => {
19+
await rm(testDir, { recursive: true, force: true });
20+
});
21+
22+
describe('readGlobalConfig', () => {
23+
it('returns parsed config when file exists', async () => {
24+
await writeFile(configFile, JSON.stringify({ telemetry: { enabled: false } }));
25+
26+
const config = await readGlobalConfig(configFile);
27+
28+
expect(config).toEqual({ telemetry: { enabled: false } });
29+
});
30+
31+
it('returns empty object when file does not exist', async () => {
32+
const config = await readGlobalConfig(join(testDir, 'nonexistent.json'));
33+
34+
expect(config).toEqual({});
35+
});
36+
37+
it('returns empty object when file contains invalid JSON', async () => {
38+
await writeFile(configFile, 'not json');
39+
40+
const config = await readGlobalConfig(configFile);
41+
42+
expect(config).toEqual({});
43+
});
44+
45+
it('returns empty object when Zod validation fails (wrong types)', async () => {
46+
await writeFile(configFile, JSON.stringify({ telemetry: { enabled: 'false' } }));
47+
48+
const config = await readGlobalConfig(configFile);
49+
50+
expect(config).toEqual({});
51+
});
52+
53+
it('returns empty object for non-object JSON values', async () => {
54+
await writeFile(configFile, '[1, 2, 3]');
55+
56+
const config = await readGlobalConfig(configFile);
57+
58+
expect(config).toEqual({});
59+
});
60+
61+
it('preserves unknown fields via passthrough', async () => {
62+
await writeFile(configFile, JSON.stringify({ telemetry: { enabled: true }, futureField: 'hello' }));
63+
64+
const config = await readGlobalConfig(configFile);
65+
66+
expect(config).toMatchObject({ telemetry: { enabled: true }, futureField: 'hello' });
67+
});
68+
69+
it('parses all config fields', async () => {
70+
const full = {
71+
installationId: 'abc-123',
72+
telemetry: { enabled: true, endpoint: 'https://example.com', audit: false },
73+
};
74+
await writeFile(configFile, JSON.stringify(full));
75+
76+
const config = await readGlobalConfig(configFile);
77+
78+
expect(config).toEqual(full);
79+
});
80+
});
81+
82+
describe('updateGlobalConfig', () => {
83+
it('creates directory and writes config', async () => {
84+
const freshDir = join(testDir, 'fresh');
85+
const freshFile = join(freshDir, 'config.json');
86+
87+
const ok = await updateGlobalConfig({ telemetry: { enabled: false } }, freshDir, freshFile);
88+
89+
expect(ok).toBe(true);
90+
const written = JSON.parse(await readFile(freshFile, 'utf-8'));
91+
expect(written).toEqual({ telemetry: { enabled: false } });
92+
});
93+
94+
it('merges telemetry sub-object with existing config', async () => {
95+
await writeFile(
96+
configFile,
97+
JSON.stringify({ installationId: 'keep-me', telemetry: { enabled: true, endpoint: 'https://x.com' } })
98+
);
99+
100+
await updateGlobalConfig({ telemetry: { enabled: false } }, configDir, configFile);
101+
102+
const written = JSON.parse(await readFile(configFile, 'utf-8'));
103+
expect(written).toEqual({ installationId: 'keep-me', telemetry: { enabled: false, endpoint: 'https://x.com' } });
104+
});
105+
106+
it('returns false on write failures', async () => {
107+
const ok = await updateGlobalConfig(
108+
{ telemetry: { enabled: true } },
109+
join(testDir, '\0invalid'),
110+
join(testDir, '\0invalid', 'config.json')
111+
);
112+
113+
expect(ok).toBe(false);
114+
});
115+
116+
it('handles missing existing config gracefully', async () => {
117+
const ok = await updateGlobalConfig({ telemetry: { enabled: true } }, configDir, configFile);
118+
119+
expect(ok).toBe(true);
120+
const written = JSON.parse(await readFile(configFile, 'utf-8'));
121+
expect(written).toEqual({ telemetry: { enabled: true } });
122+
});
123+
});
124+
125+
describe('getOrCreateInstallationId', () => {
126+
it('generates installationId on first run and returns created: true', async () => {
127+
const result = await getOrCreateInstallationId(configDir, configFile);
128+
129+
expect(result.created).toBe(true);
130+
expect(result.id).toMatch(/^[0-9a-f-]{36}$/);
131+
const config = await readGlobalConfig(configFile);
132+
expect(config.installationId).toBe(result.id);
133+
});
134+
135+
it('returns existing id with created: false', async () => {
136+
await writeFile(configFile, JSON.stringify({ installationId: 'existing-id' }));
137+
138+
const result = await getOrCreateInstallationId(configDir, configFile);
139+
140+
expect(result).toEqual({ id: 'existing-id', created: false });
141+
});
142+
143+
it('preserves existing config when generating installationId', async () => {
144+
await writeFile(configFile, JSON.stringify({ telemetry: { enabled: false } }));
145+
146+
const { id } = await getOrCreateInstallationId(configDir, configFile);
147+
148+
const config = await readGlobalConfig(configFile);
149+
expect(config.telemetry?.enabled).toBe(false);
150+
expect(config.installationId).toBe(id);
151+
});
152+
});
153+
});

src/cli/cli.ts

Lines changed: 30 additions & 2 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,24 @@ function setupGlobalCleanup() {
6163
});
6264
}
6365

66+
function printTelemetryNotice(): void {
67+
process.stderr.write(
68+
[
69+
'',
70+
'\x1b[33mThe AgentCore CLI will soon begin collecting aggregated, anonymous usage',
71+
'analytics to help improve the tool.',
72+
'To opt out: agentcore telemetry disable',
73+
'To learn more: agentcore telemetry --help\x1b[0m',
74+
'',
75+
'',
76+
].join('\n')
77+
);
78+
}
79+
6480
/**
6581
* Render the TUI in alternate screen buffer mode.
6682
*/
67-
function renderTUI(updateCheck: Promise<UpdateCheckResult | null>) {
83+
function renderTUI(updateCheck: Promise<UpdateCheckResult | null>, isFirstRun: boolean) {
6884
inAltScreen = true;
6985
process.stdout.write(ENTER_ALT_SCREEN);
7086

@@ -82,6 +98,10 @@ function renderTUI(updateCheck: Promise<UpdateCheckResult | null>) {
8298
clearExitMessage();
8399
}
84100

101+
if (isFirstRun) {
102+
printTelemetryNotice();
103+
}
104+
85105
// Print update notification after TUI exits
86106
const result = await updateCheck;
87107
if (result?.updateAvailable) {
@@ -148,6 +168,7 @@ export function registerCommands(program: Command) {
148168
registerResume(program);
149169
registerRun(program);
150170
registerStatus(program);
171+
registerTelemetry(program);
151172
registerTraces(program);
152173
registerUpdate(program);
153174
registerValidate(program);
@@ -162,6 +183,9 @@ export const main = async (argv: string[]) => {
162183
// Register global cleanup handlers once at startup
163184
setupGlobalCleanup();
164185

186+
// Generate installationId on first run and show telemetry notice
187+
const { created: isFirstRun } = await getOrCreateInstallationId();
188+
165189
const program = createProgram();
166190

167191
const args = argv.slice(2);
@@ -172,10 +196,14 @@ export const main = async (argv: string[]) => {
172196

173197
// Show TUI for no arguments, commander handles --help via configureHelp()
174198
if (args.length === 0) {
175-
renderTUI(updateCheck);
199+
renderTUI(updateCheck, isFirstRun);
176200
return;
177201
}
178202

203+
if (isFirstRun) {
204+
printTelemetryNotice();
205+
}
206+
179207
await program.parseAsync(argv);
180208

181209
// Print notification after command finishes
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { handleTelemetryDisable, handleTelemetryEnable, handleTelemetryStatus } from '../actions';
2+
import { chmod, mkdir, rm, writeFile } from 'fs/promises';
3+
import { randomUUID } from 'node:crypto';
4+
import { tmpdir } from 'node:os';
5+
import { join } from 'node:path';
6+
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
7+
8+
const testDir = join(tmpdir(), `agentcore-actions-${randomUUID()}`);
9+
const configDir = join(testDir, '.agentcore');
10+
const configFile = join(testDir, '.agentcore', 'config.json');
11+
12+
describe('telemetry actions', () => {
13+
let consoleSpy: ReturnType<typeof vi.spyOn>;
14+
15+
beforeEach(async () => {
16+
await rm(testDir, { recursive: true, force: true });
17+
await mkdir(configDir, { recursive: true });
18+
// eslint-disable-next-line @typescript-eslint/no-empty-function
19+
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
20+
});
21+
22+
afterEach(() => {
23+
consoleSpy.mockRestore();
24+
});
25+
26+
afterAll(async () => {
27+
await rm(testDir, { recursive: true, force: true });
28+
});
29+
30+
describe('handleTelemetryDisable', () => {
31+
it('writes disabled config and prints confirmation', async () => {
32+
await handleTelemetryDisable(configDir, configFile);
33+
34+
expect(consoleSpy).toHaveBeenCalledWith('Telemetry has been disabled.');
35+
});
36+
37+
it('prints warning when config write fails', async () => {
38+
await rm(testDir, { recursive: true, force: true });
39+
await mkdir(testDir, { recursive: true });
40+
await chmod(testDir, 0o444);
41+
42+
await handleTelemetryDisable(configDir, configFile);
43+
44+
expect(consoleSpy).toHaveBeenCalledWith(`Warning: could not write config to ${configFile}`);
45+
46+
await chmod(testDir, 0o755);
47+
});
48+
});
49+
50+
describe('handleTelemetryEnable', () => {
51+
it('writes enabled config and prints confirmation', async () => {
52+
await handleTelemetryEnable(configDir, configFile);
53+
54+
expect(consoleSpy).toHaveBeenCalledWith('Telemetry has been enabled.');
55+
});
56+
});
57+
58+
describe('handleTelemetryStatus', () => {
59+
it('shows enabled status with default source', async () => {
60+
await handleTelemetryStatus(configFile);
61+
62+
expect(consoleSpy).toHaveBeenCalledWith('Telemetry: Enabled');
63+
expect(consoleSpy).toHaveBeenCalledWith('Source: default');
64+
});
65+
66+
it('shows disabled status with global-config source', async () => {
67+
await writeFile(configFile, JSON.stringify({ telemetry: { enabled: false } }));
68+
69+
await handleTelemetryStatus(configFile);
70+
71+
expect(consoleSpy).toHaveBeenCalledWith('Telemetry: Disabled');
72+
expect(consoleSpy).toHaveBeenCalledWith(`Source: global config (${configFile})`);
73+
});
74+
75+
it('shows env var note when source is environment', async () => {
76+
const originalEnv = process.env;
77+
process.env = { ...originalEnv, AGENTCORE_TELEMETRY_DISABLED: 'true' };
78+
79+
await handleTelemetryStatus(configFile);
80+
81+
expect(consoleSpy).toHaveBeenCalledWith('Telemetry: Disabled');
82+
expect(consoleSpy).toHaveBeenCalledWith('Source: environment variable');
83+
expect(consoleSpy).toHaveBeenCalledWith('\nNote: AGENTCORE_TELEMETRY_DISABLED=true is set in your environment.');
84+
85+
process.env = originalEnv;
86+
});
87+
});
88+
});
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<void> {
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+
}
11+
12+
export async function handleTelemetryEnable(
13+
configDir = GLOBAL_CONFIG_DIR,
14+
configFile = GLOBAL_CONFIG_FILE
15+
): Promise<void> {
16+
const ok = await updateGlobalConfig({ telemetry: { enabled: true } }, configDir, configFile);
17+
console.log(ok ? 'Telemetry has been enabled.' : `Warning: could not write config to ${configFile}`);
18+
}
19+
20+
export async function handleTelemetryStatus(configFile = GLOBAL_CONFIG_FILE): Promise<void> {
21+
const pref = await resolveTelemetryPreference(configFile);
22+
23+
const status = pref.enabled ? 'Enabled' : 'Disabled';
24+
const sourceLabel =
25+
pref.source === 'environment'
26+
? 'environment variable'
27+
: pref.source === 'global-config'
28+
? `global config (${configFile})`
29+
: 'default';
30+
31+
console.log(`Telemetry: ${status}`);
32+
console.log(`Source: ${sourceLabel}`);
33+
34+
if (pref.source === 'environment') {
35+
console.log(
36+
`\nNote: AGENTCORE_TELEMETRY_DISABLED=${process.env.AGENTCORE_TELEMETRY_DISABLED} is set in your environment.`
37+
);
38+
}
39+
}

0 commit comments

Comments
 (0)