Skip to content

Commit 67f27fa

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

13 files changed

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

0 commit comments

Comments
 (0)