Skip to content

Commit 402275b

Browse files
committed
feat: add telemetry preference controls and preinstall notice
Add user-facing telemetry controls (opt-out command, env var support, config file, preinstall notice) without collecting any data. This establishes the user-facing messaging before launch. - Add `agentcore telemetry` command with enable/disable/status subcommands - Add telemetry notice as a warning in preinstall script (shown on install/upgrade) - Support AGENTCORE_TELEMETRY_DISABLED and DO_NOT_TRACK env vars - Add global config module (~/.agentcore/config.json) for persisting preferences - Refactor update-notifier to reuse shared GLOBAL_CONFIG_DIR constant - Add unit tests for global config, preference resolution, and command actions
1 parent 5f2fbda commit 402275b

14 files changed

Lines changed: 486 additions & 3 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
"scripts"
4444
],
4545
"scripts": {
46-
"preinstall": "node scripts/check-old-cli.mjs",
46+
"preinstall": "node scripts/preinstall-warnings.mjs",
4747
"build": "npm run build:lib && npm run build:cli && npm run build:assets",
4848
"build:lib": "tsc -p tsconfig.build.json",
4949
"build:cli": "node esbuild.config.mjs",
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,19 @@ try {
2424
} catch {
2525
// No agentcore binary found or unexpected error — nothing to do
2626
}
27+
28+
// Telemetry notice — shown on every install/upgrade
29+
try {
30+
console.warn(
31+
[
32+
'',
33+
'\x1b[33m⚠ NOTICE: The AgentCore CLI collects aggregated, anonymous usage\x1b[0m',
34+
'\x1b[33manalytics to help improve the tool. To opt out, run:\x1b[0m',
35+
'\x1b[33m agentcore telemetry disable\x1b[0m',
36+
'\x1b[33mOr set: AGENTCORE_TELEMETRY_DISABLED=true\x1b[0m',
37+
'',
38+
].join('\n')
39+
);
40+
} catch {
41+
// Never fail the install
42+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { GLOBAL_CONFIG_DIR, GLOBAL_CONFIG_FILE, readGlobalConfig, updateGlobalConfig } from '../global-config';
2+
import { mkdir, readFile, writeFile } from 'fs/promises';
3+
import { beforeEach, describe, expect, it, vi } from 'vitest';
4+
5+
vi.mock('fs/promises');
6+
7+
const mockMkdir = vi.mocked(mkdir);
8+
const mockReadFile = vi.mocked(readFile);
9+
const mockWriteFile = vi.mocked(writeFile);
10+
11+
describe('global-config', () => {
12+
beforeEach(() => {
13+
vi.clearAllMocks();
14+
});
15+
16+
describe('readGlobalConfig', () => {
17+
it('returns parsed config when file exists', async () => {
18+
mockReadFile.mockResolvedValue(JSON.stringify({ telemetry: { enabled: false } }));
19+
20+
const config = await readGlobalConfig();
21+
22+
expect(config).toEqual({ telemetry: { enabled: false } });
23+
expect(mockReadFile).toHaveBeenCalledWith(GLOBAL_CONFIG_FILE, 'utf-8');
24+
});
25+
26+
it('returns empty object when file does not exist', async () => {
27+
mockReadFile.mockRejectedValue(new Error('ENOENT'));
28+
29+
const config = await readGlobalConfig();
30+
31+
expect(config).toEqual({});
32+
});
33+
34+
it('returns empty object when file contains invalid JSON', async () => {
35+
mockReadFile.mockResolvedValue('not json');
36+
37+
const config = await readGlobalConfig();
38+
39+
expect(config).toEqual({});
40+
});
41+
});
42+
43+
describe('updateGlobalConfig', () => {
44+
it('creates directory and writes merged config', async () => {
45+
mockReadFile.mockResolvedValue(JSON.stringify({ telemetry: { enabled: true } }));
46+
mockMkdir.mockResolvedValue(undefined);
47+
mockWriteFile.mockResolvedValue(undefined);
48+
49+
await updateGlobalConfig({ telemetry: { enabled: false } });
50+
51+
expect(mockMkdir).toHaveBeenCalledWith(GLOBAL_CONFIG_DIR, { recursive: true });
52+
const written = JSON.parse(mockWriteFile.mock.calls[0]![1] as string);
53+
expect(written).toEqual({ telemetry: { enabled: false } });
54+
});
55+
56+
it('merges telemetry sub-object without overwriting other keys', async () => {
57+
mockReadFile.mockResolvedValue(JSON.stringify({ telemetry: { enabled: true } }));
58+
mockMkdir.mockResolvedValue(undefined);
59+
mockWriteFile.mockResolvedValue(undefined);
60+
61+
await updateGlobalConfig({ telemetry: { enabled: false } });
62+
63+
const written = JSON.parse(mockWriteFile.mock.calls[0]![1] as string);
64+
expect(written).toEqual({ telemetry: { enabled: false } });
65+
});
66+
67+
it('silently ignores write failures', async () => {
68+
mockReadFile.mockResolvedValue('{}');
69+
mockMkdir.mockResolvedValue(undefined);
70+
mockWriteFile.mockRejectedValue(new Error('EACCES'));
71+
72+
// Should not throw
73+
await updateGlobalConfig({ telemetry: { enabled: true } });
74+
});
75+
76+
it('handles missing existing config gracefully', async () => {
77+
mockReadFile.mockRejectedValue(new Error('ENOENT'));
78+
mockMkdir.mockResolvedValue(undefined);
79+
mockWriteFile.mockResolvedValue(undefined);
80+
81+
await updateGlobalConfig({ telemetry: { enabled: true } });
82+
83+
const written = JSON.parse(mockWriteFile.mock.calls[0]![1] as string);
84+
expect(written).toEqual({ telemetry: { enabled: true } });
85+
});
86+
});
87+
});

src/cli/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { registerInvoke } from './commands/invoke';
77
import { registerPackage } from './commands/package';
88
import { registerRemove } from './commands/remove';
99
import { registerStatus } from './commands/status';
10+
import { registerTelemetry } from './commands/telemetry';
1011
import { registerUpdate } from './commands/update';
1112
import { registerValidate } from './commands/validate';
1213
import { PACKAGE_VERSION } from './constants';
@@ -132,6 +133,7 @@ export function registerCommands(program: Command) {
132133
registerPackage(program);
133134
registerRemove(program);
134135
registerStatus(program);
136+
registerTelemetry(program);
135137
registerUpdate(program);
136138
registerValidate(program);
137139
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import * as globalConfig from '../../../global-config';
2+
import * as resolve from '../../../telemetry/resolve';
3+
import { handleTelemetryDisable, handleTelemetryEnable, handleTelemetryStatus } from '../actions';
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5+
6+
vi.mock('../../../global-config');
7+
vi.mock('../../../telemetry/resolve');
8+
9+
const mockUpdateGlobalConfig = vi.mocked(globalConfig.updateGlobalConfig);
10+
const mockResolveTelemetryPreference = vi.mocked(resolve.resolveTelemetryPreference);
11+
12+
describe('telemetry actions', () => {
13+
let consoleSpy: ReturnType<typeof vi.spyOn>;
14+
15+
beforeEach(() => {
16+
vi.clearAllMocks();
17+
// eslint-disable-next-line @typescript-eslint/no-empty-function
18+
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
19+
mockUpdateGlobalConfig.mockResolvedValue(undefined);
20+
});
21+
22+
afterEach(() => {
23+
consoleSpy.mockRestore();
24+
});
25+
26+
describe('handleTelemetryDisable', () => {
27+
it('writes disabled config and prints confirmation', async () => {
28+
await handleTelemetryDisable();
29+
30+
expect(mockUpdateGlobalConfig).toHaveBeenCalledWith({ telemetry: { enabled: false } });
31+
expect(consoleSpy).toHaveBeenCalledWith('Telemetry has been disabled.');
32+
});
33+
});
34+
35+
describe('handleTelemetryEnable', () => {
36+
it('writes enabled config and prints confirmation', async () => {
37+
await handleTelemetryEnable();
38+
39+
expect(mockUpdateGlobalConfig).toHaveBeenCalledWith({ telemetry: { enabled: true } });
40+
expect(consoleSpy).toHaveBeenCalledWith('Telemetry has been enabled.');
41+
});
42+
});
43+
44+
describe('handleTelemetryStatus', () => {
45+
it('shows enabled status with default source', async () => {
46+
mockResolveTelemetryPreference.mockResolvedValue({ enabled: true, source: 'default' });
47+
48+
await handleTelemetryStatus();
49+
50+
expect(consoleSpy).toHaveBeenCalledWith('Telemetry: Enabled');
51+
expect(consoleSpy).toHaveBeenCalledWith('Source: default');
52+
});
53+
54+
it('shows disabled status with global-config source', async () => {
55+
mockResolveTelemetryPreference.mockResolvedValue({ enabled: false, source: 'global-config' });
56+
57+
await handleTelemetryStatus();
58+
59+
expect(consoleSpy).toHaveBeenCalledWith('Telemetry: Disabled');
60+
expect(consoleSpy).toHaveBeenCalledWith('Source: global config (~/.agentcore/config.json)');
61+
});
62+
63+
it('shows env var note when source is environment (AGENTCORE_TELEMETRY_DISABLED)', async () => {
64+
const originalEnv = process.env;
65+
process.env = { ...originalEnv, AGENTCORE_TELEMETRY_DISABLED: 'true' };
66+
67+
mockResolveTelemetryPreference.mockResolvedValue({ enabled: false, source: 'environment' });
68+
69+
await handleTelemetryStatus();
70+
71+
expect(consoleSpy).toHaveBeenCalledWith('Telemetry: Disabled');
72+
expect(consoleSpy).toHaveBeenCalledWith('Source: environment variable');
73+
expect(consoleSpy).toHaveBeenCalledWith('\nNote: AGENTCORE_TELEMETRY_DISABLED=true is set in your environment.');
74+
75+
process.env = originalEnv;
76+
});
77+
78+
it('shows env var note when source is environment (DO_NOT_TRACK)', async () => {
79+
const originalEnv = process.env;
80+
process.env = { ...originalEnv, DO_NOT_TRACK: '1' };
81+
delete process.env.AGENTCORE_TELEMETRY_DISABLED;
82+
83+
mockResolveTelemetryPreference.mockResolvedValue({ enabled: false, source: 'environment' });
84+
85+
await handleTelemetryStatus();
86+
87+
expect(consoleSpy).toHaveBeenCalledWith('\nNote: DO_NOT_TRACK=1 is set in your environment.');
88+
89+
process.env = originalEnv;
90+
});
91+
});
92+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { updateGlobalConfig } from '../../global-config.js';
2+
import { resolveTelemetryPreference } from '../../telemetry/resolve.js';
3+
4+
export async function handleTelemetryDisable(): Promise<void> {
5+
await updateGlobalConfig({ telemetry: { enabled: false } });
6+
console.log('Telemetry has been disabled.');
7+
}
8+
9+
export async function handleTelemetryEnable(): Promise<void> {
10+
await updateGlobalConfig({ telemetry: { enabled: true } });
11+
console.log('Telemetry has been enabled.');
12+
}
13+
14+
export async function handleTelemetryStatus(): Promise<void> {
15+
const pref = await resolveTelemetryPreference();
16+
17+
const status = pref.enabled ? 'Enabled' : 'Disabled';
18+
19+
const sourceLabel =
20+
pref.source === 'environment'
21+
? 'environment variable'
22+
: pref.source === 'global-config'
23+
? 'global config (~/.agentcore/config.json)'
24+
: 'default';
25+
26+
console.log(`Telemetry: ${status}`);
27+
console.log(`Source: ${sourceLabel}`);
28+
29+
if (pref.source === 'environment') {
30+
// eslint-disable-next-line @typescript-eslint/dot-notation
31+
const agentcoreEnv = process.env['AGENTCORE_TELEMETRY_DISABLED'];
32+
// eslint-disable-next-line @typescript-eslint/dot-notation
33+
const doNotTrack = process.env['DO_NOT_TRACK'];
34+
if (agentcoreEnv !== undefined) {
35+
console.log(`\nNote: AGENTCORE_TELEMETRY_DISABLED=${agentcoreEnv} is set in your environment.`);
36+
} else if (doNotTrack !== undefined) {
37+
console.log(`\nNote: DO_NOT_TRACK=${doNotTrack} is set in your environment.`);
38+
}
39+
}
40+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { COMMAND_DESCRIPTIONS } from '../../tui/copy.js';
2+
import { handleTelemetryDisable, handleTelemetryEnable, handleTelemetryStatus } from './actions.js';
3+
import type { Command } from '@commander-js/extra-typings';
4+
5+
export function registerTelemetry(program: Command) {
6+
const telemetry = program
7+
.command('telemetry')
8+
.description(COMMAND_DESCRIPTIONS.telemetry)
9+
.argument('[subcommand]', 'Subcommand to run (enable, disable, status)')
10+
.action(() => {
11+
telemetry.outputHelp();
12+
});
13+
14+
telemetry
15+
.command('disable')
16+
.description('Disable anonymous usage analytics')
17+
.action(async () => {
18+
await handleTelemetryDisable();
19+
});
20+
21+
telemetry
22+
.command('enable')
23+
.description('Enable anonymous usage analytics')
24+
.action(async () => {
25+
await handleTelemetryEnable();
26+
});
27+
28+
telemetry
29+
.command('status')
30+
.description('Show current telemetry preference and source')
31+
.action(async () => {
32+
await handleTelemetryStatus();
33+
});
34+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { registerTelemetry } from './command.js';

src/cli/global-config.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { mkdir, readFile, writeFile } from 'fs/promises';
2+
import { homedir } from 'os';
3+
import { join } from 'path';
4+
5+
export const GLOBAL_CONFIG_DIR = join(homedir(), '.agentcore');
6+
export const GLOBAL_CONFIG_FILE = join(GLOBAL_CONFIG_DIR, 'config.json');
7+
8+
export interface GlobalConfig {
9+
telemetry?: {
10+
enabled?: boolean;
11+
};
12+
}
13+
14+
export async function readGlobalConfig(): Promise<GlobalConfig> {
15+
try {
16+
const data = await readFile(GLOBAL_CONFIG_FILE, 'utf-8');
17+
return JSON.parse(data) as GlobalConfig;
18+
} catch {
19+
return {};
20+
}
21+
}
22+
23+
export async function updateGlobalConfig(partial: GlobalConfig): Promise<void> {
24+
try {
25+
const existing = await readGlobalConfig();
26+
27+
// Shallow merge with one level of nesting for telemetry sub-object
28+
const merged: GlobalConfig = { ...existing };
29+
30+
if (partial.telemetry !== undefined) {
31+
merged.telemetry = { ...existing.telemetry, ...partial.telemetry };
32+
}
33+
34+
await mkdir(GLOBAL_CONFIG_DIR, { recursive: true });
35+
await writeFile(GLOBAL_CONFIG_FILE, JSON.stringify(merged, null, 2), 'utf-8');
36+
} catch {
37+
// Silently ignore write failures
38+
}
39+
}

0 commit comments

Comments
 (0)