Skip to content

Commit 26b1c4c

Browse files
authored
Merge pull request #1053 from aws/sync-preview/merge-main-20260430
sync-preview: merge main into preview
2 parents 370ee84 + 9f2702a commit 26b1c4c

10 files changed

Lines changed: 285 additions & 8 deletions

File tree

integ-tests/help.test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
import { spawnAndCollect } from '../src/test-utils/cli-runner.js';
12
import { runCLI } from '../src/test-utils/index.js';
2-
import { describe, expect, it } from 'vitest';
3+
import { readdirSync } from 'node:fs';
4+
import { mkdir, readFile, rm } from 'node:fs/promises';
5+
import { tmpdir } from 'node:os';
6+
import { join } from 'node:path';
7+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
38

49
const COMMANDS = [
510
'create',
@@ -38,3 +43,59 @@ describe('CLI help', () => {
3843
}
3944
});
4045
});
46+
47+
describe('help modes telemetry', () => {
48+
let testConfigDir: string;
49+
const cliPath = join(__dirname, '..', 'dist', 'cli', 'index.mjs');
50+
51+
beforeAll(async () => {
52+
testConfigDir = join(tmpdir(), `agentcore-help-telemetry-${Date.now()}`);
53+
await mkdir(testConfigDir, { recursive: true });
54+
});
55+
afterAll(() => rm(testConfigDir, { recursive: true, force: true }));
56+
57+
function run(args: string[], extraEnv: Record<string, string> = {}) {
58+
return spawnAndCollect('node', [cliPath, ...args], tmpdir(), {
59+
AGENTCORE_SKIP_INSTALL: '1',
60+
AGENTCORE_CONFIG_DIR: testConfigDir,
61+
...extraEnv,
62+
});
63+
}
64+
65+
it('writes JSONL audit file when audit is enabled via env var', async () => {
66+
const result = await run(['help', 'modes'], { AGENTCORE_TELEMETRY_AUDIT: '1' });
67+
expect(result.exitCode).toBe(0);
68+
69+
const telemetryDir = join(testConfigDir, 'telemetry');
70+
const files = readdirSync(telemetryDir).filter(f => f.startsWith('help-'));
71+
expect(files).toHaveLength(1);
72+
73+
const content = await readFile(join(telemetryDir, files[0]!), 'utf-8');
74+
const entry = JSON.parse(content.trim());
75+
expect(entry.attrs).toMatchObject({
76+
'service.name': 'agentcore-cli',
77+
'agentcore-cli.mode': 'cli',
78+
command_group: 'help',
79+
command: 'help.modes',
80+
exit_reason: 'success',
81+
});
82+
expect(entry.attrs['agentcore-cli.session_id']).toBeDefined();
83+
expect(entry.attrs['os.type']).toBeDefined();
84+
expect(entry.value).toBeGreaterThanOrEqual(0);
85+
});
86+
87+
it('does not write audit file when audit is not enabled', async () => {
88+
const telemetryDir = join(testConfigDir, 'telemetry');
89+
await rm(telemetryDir, { recursive: true, force: true });
90+
91+
const result = await run(['help', 'modes']);
92+
expect(result.exitCode).toBe(0);
93+
94+
try {
95+
const files = readdirSync(telemetryDir);
96+
expect(files).toHaveLength(0);
97+
} catch {
98+
// telemetry dir doesn't exist — correct
99+
}
100+
});
101+
});

src/cli/cli.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { registerValidate } from './commands/validate';
2323
import { PACKAGE_VERSION } from './constants';
2424
import { getOrCreateInstallationId } from './global-config';
2525
import { ALL_PRIMITIVES } from './primitives';
26+
import { TelemetryClientAccessor } from './telemetry';
2627
import { App } from './tui/App';
2728
import { LayoutProvider } from './tui/context';
2829
import { COMMAND_DESCRIPTIONS } from './tui/copy';
@@ -228,7 +229,12 @@ export const main = async (argv: string[]) => {
228229
printTelemetryNotice();
229230
}
230231

231-
await program.parseAsync(argv);
232+
TelemetryClientAccessor.init(args[0] ?? 'unknown');
233+
try {
234+
await program.parseAsync(argv);
235+
} finally {
236+
await TelemetryClientAccessor.shutdown();
237+
}
232238

233239
// Telemetry notice already printed above; only run update check here.
234240
await printPostCommandNotices(false, updateCheck);

src/cli/commands/help/command.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { TelemetryClientAccessor } from '../../telemetry/client-accessor.js';
12
import type { Command } from '@commander-js/extra-typings';
23

34
const MODES_HELP = `
@@ -41,15 +42,23 @@ export const registerHelp = (program: Command) => {
4142
const helpCmd = program
4243
.command('help')
4344
.description('Display help topics')
44-
.action(() => {
45-
console.log('Available help topics: modes');
46-
console.log('Run `agentcore help <topic>` for details.');
45+
.action(async () => {
46+
const client = await TelemetryClientAccessor.get();
47+
await client.withCommandRun('help', () => {
48+
console.log('Available help topics: modes');
49+
console.log('Run `agentcore help <topic>` for details.');
50+
return {};
51+
});
4752
});
4853

4954
helpCmd
5055
.command('modes')
5156
.description('Explain interactive vs non-interactive modes')
52-
.action(() => {
53-
console.log(MODES_HELP);
57+
.action(async () => {
58+
const client = await TelemetryClientAccessor.get();
59+
await client.withCommandRun('help.modes', () => {
60+
console.log(MODES_HELP);
61+
return {};
62+
});
5463
});
5564
};

src/cli/operations/dev/web-ui/handlers/invocations.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export async function handleInvocations(
7676
return new Promise<void>((resolve, reject) => {
7777
const headers: Record<string, string> = {
7878
'Content-Type': 'application/json',
79+
Accept: 'text/event-stream, */*',
7980
'x-amzn-bedrock-agentcore-runtime-session-id': sessionId ?? randomUUID(),
8081
};
8182
if (userId) {
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { createTempConfig } from '../../__tests__/helpers/temp-config';
2+
import { resolveAuditFilePath } from '../config';
3+
import { FileSystemSink } from '../sinks/filesystem-sink';
4+
import { readFile } from 'fs/promises';
5+
import { join } from 'node:path';
6+
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
7+
8+
const tmp = createTempConfig('fs-sink');
9+
const outputDir = join(tmp.configDir, 'telemetry');
10+
11+
function createSink(opts: { dir?: string; log?: (msg: string) => void } = {}) {
12+
const filePath = join(opts.dir ?? outputDir, 'test-session.json');
13+
return new FileSystemSink({ filePath, log: opts.log });
14+
}
15+
16+
function readJsonl(path: string): Promise<unknown[]> {
17+
return readFile(path, 'utf-8').then(data =>
18+
data
19+
.trim()
20+
.split('\n')
21+
.map(line => JSON.parse(line))
22+
);
23+
}
24+
25+
describe('FileSystemSink', () => {
26+
beforeEach(() => tmp.setup());
27+
afterAll(() => tmp.cleanup());
28+
29+
it('writes each record as a JSONL line on disk', async () => {
30+
const sink = createSink();
31+
sink.record(42, { command_group: 'deploy', command: 'deploy', exit_reason: 'success' });
32+
await sink.flush();
33+
34+
const entries = await readJsonl(join(outputDir, 'test-session.json'));
35+
expect(entries).toHaveLength(1);
36+
expect(entries[0]).toMatchObject({
37+
value: 42,
38+
attrs: { command_group: 'deploy', command: 'deploy', exit_reason: 'success' },
39+
});
40+
});
41+
42+
it('appends multiple records as separate lines', async () => {
43+
const sink = createSink();
44+
sink.record(10, { command_group: 'add', command: 'add.agent' });
45+
sink.record(20, { command_group: 'add', command: 'add.memory' });
46+
await sink.flush();
47+
48+
const entries = await readJsonl(join(outputDir, 'test-session.json'));
49+
expect(entries).toHaveLength(2);
50+
expect(entries[0]).toMatchObject({ value: 10 });
51+
expect(entries[1]).toMatchObject({ value: 20 });
52+
});
53+
54+
it('creates output directory if it does not exist', async () => {
55+
const nested = join(tmp.testDir, 'deep', 'nested', 'telemetry');
56+
const filePath = join(nested, 'test.json');
57+
const sink = new FileSystemSink({ filePath });
58+
sink.record(1, { command_group: 'status', command: 'status' });
59+
await sink.flush();
60+
61+
const entries = await readJsonl(filePath);
62+
expect(entries).toHaveLength(1);
63+
});
64+
65+
it('flush is a no-op when no records exist', async () => {
66+
const sink = createSink();
67+
await expect(sink.flush()).resolves.toBeUndefined();
68+
});
69+
70+
it('shutdown logs audit message when records were written', async () => {
71+
const logged: string[] = [];
72+
const sink = createSink({ log: msg => logged.push(msg) });
73+
sink.record(99, { command_group: 'invoke', command: 'invoke' });
74+
await sink.shutdown();
75+
76+
expect(logged).toHaveLength(1);
77+
expect(logged[0]).toContain('[audit mode]');
78+
expect(logged[0]).toContain('test-session.json');
79+
});
80+
81+
it('shutdown does not log when no records were written', async () => {
82+
const logged: string[] = [];
83+
const sink = createSink({ log: msg => logged.push(msg) });
84+
await sink.shutdown();
85+
86+
expect(logged).toHaveLength(0);
87+
});
88+
});
89+
90+
describe('resolveAuditFilePath', () => {
91+
it('joins outputDir, entrypoint, and sessionId into a JSON file path', () => {
92+
const path = resolveAuditFilePath('/home/user/.agentcore/telemetry', 'deploy', 'abc-123');
93+
expect(path).toBe('/home/user/.agentcore/telemetry/deploy-abc-123.json');
94+
});
95+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { GLOBAL_CONFIG_DIR, readGlobalConfig } from '../global-config.js';
2+
import { TelemetryClient } from './client.js';
3+
import { resolveAuditFilePath, resolveResourceAttributes } from './config.js';
4+
import { FileSystemSink } from './sinks/filesystem-sink.js';
5+
import { CompositeSink } from './sinks/metric-sink.js';
6+
import { join } from 'path';
7+
8+
/**
9+
* Manages a singleton TelemetryClient. Call init() at startup to configure,
10+
* get() from command handlers to obtain the client, and shutdown() on exit.
11+
* get() lazily initializes if init() was never called.
12+
*/
13+
export class TelemetryClientAccessor {
14+
private static clientPromise: Promise<TelemetryClient> | undefined;
15+
16+
static init(entrypoint: string, mode: 'cli' | 'tui' = 'cli'): void {
17+
this.clientPromise = createClient(entrypoint, mode);
18+
}
19+
20+
static get(): Promise<TelemetryClient> {
21+
this.clientPromise ??= createClient('unknown');
22+
return this.clientPromise;
23+
}
24+
25+
static async shutdown(): Promise<void> {
26+
if (this.clientPromise) {
27+
const client = await this.clientPromise;
28+
await client.shutdown();
29+
}
30+
}
31+
}
32+
33+
async function createClient(entrypoint: string, mode: 'cli' | 'tui' = 'cli'): Promise<TelemetryClient> {
34+
const [resource, config] = await Promise.all([resolveResourceAttributes(mode), readGlobalConfig()]);
35+
36+
const sinks = [];
37+
const audit = process.env.AGENTCORE_TELEMETRY_AUDIT === '1' || config.telemetry?.audit === true;
38+
39+
if (audit) {
40+
const filePath = resolveAuditFilePath(
41+
join(GLOBAL_CONFIG_DIR, 'telemetry'),
42+
entrypoint,
43+
resource['agentcore-cli.session_id']
44+
);
45+
sinks.push(new FileSystemSink({ filePath, resource }));
46+
}
47+
48+
return new TelemetryClient(new CompositeSink(sinks));
49+
}

src/cli/telemetry/config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getOrCreateInstallationId, readGlobalConfig } from '../global-config.js
33
import { type ResourceAttributes, ResourceAttributesSchema } from './schemas/common-attributes.js';
44
import { randomUUID } from 'crypto';
55
import os from 'os';
6+
import { join } from 'path';
67

78
// ---------------------------------------------------------------------------
89
// Telemetry preference (opt-in / opt-out)
@@ -59,3 +60,7 @@ export async function resolveResourceAttributes(mode: 'cli' | 'tui'): Promise<Re
5960
'node.version': process.version,
6061
});
6162
}
63+
64+
export function resolveAuditFilePath(outputDir: string, entrypoint: string, sessionId: string): string {
65+
return join(outputDir, `${entrypoint}-${sessionId}.json`);
66+
}

src/cli/telemetry/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
export { resolveTelemetryPreference, resolveResourceAttributes } from './config.js';
1+
export { resolveTelemetryPreference, resolveResourceAttributes, resolveAuditFilePath } from './config.js';
22
export type { TelemetryPreference } from './config.js';
3+
export { TelemetryClientAccessor } from './client-accessor.js';
34
export { TelemetryClient, CANCELLED } from './client.js';
45
export { type MetricSink, CompositeSink } from './sinks/metric-sink.js';
56
export { OtelMetricSink, type OtelMetricSinkConfig } from './sinks/otel-metric-sink.js';
7+
export { FileSystemSink, type FileSystemSinkConfig } from './sinks/filesystem-sink.js';
68
export { classifyError, isUserError } from './error-classification.js';

src/cli/telemetry/schemas/command-run.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ export const COMMAND_SCHEMAS = {
193193
package: NoAttrs,
194194
validate: NoAttrs,
195195
'help.modes': NoAttrs,
196+
help: NoAttrs,
196197
'remove.agent': NoAttrs,
197198
'remove.memory': NoAttrs,
198199
'remove.credential': NoAttrs,
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { MetricSink } from './metric-sink.js';
2+
import { appendFile, mkdir } from 'fs/promises';
3+
import { dirname } from 'path';
4+
5+
export interface FileSystemSinkConfig {
6+
filePath: string;
7+
resource?: Record<string, string | number>;
8+
log?: (message: string) => void;
9+
}
10+
11+
export class FileSystemSink implements MetricSink {
12+
private readonly filePath: string;
13+
private readonly resource: Record<string, string | number>;
14+
private readonly log: (message: string) => void;
15+
private hasRecords = false;
16+
17+
constructor(config: FileSystemSinkConfig) {
18+
this.filePath = config.filePath;
19+
this.resource = config.resource ?? {};
20+
this.log = config.log ?? (msg => console.log(msg));
21+
}
22+
23+
record(value: number, attrs: Record<string, string | number>): void {
24+
this.hasRecords = true;
25+
this.pendingWrite = this.pendingWrite.then(() =>
26+
this.appendEntry({ value, attrs: { ...this.resource, ...attrs } })
27+
);
28+
}
29+
30+
async flush(): Promise<void> {
31+
await this.pendingWrite;
32+
}
33+
34+
async shutdown(): Promise<void> {
35+
await this.pendingWrite;
36+
if (this.hasRecords) {
37+
this.log(`[audit mode] Telemetry written to ${this.filePath}`);
38+
}
39+
}
40+
41+
// Promise chain that serializes async writes so record() can stay synchronous.
42+
private pendingWrite: Promise<void> = Promise.resolve();
43+
44+
private async appendEntry(entry: { value: number; attrs: Record<string, string | number> }): Promise<void> {
45+
await mkdir(dirname(this.filePath), { recursive: true });
46+
await appendFile(this.filePath, JSON.stringify(entry) + '\n');
47+
}
48+
}

0 commit comments

Comments
 (0)