Skip to content

Commit 4a59361

Browse files
committed
feat: add FilesystemSink for telemetry audit mode
1 parent 13b34a3 commit 4a59361

4 files changed

Lines changed: 161 additions & 1 deletion

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
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+
describe('FilesystemSink', () => {
17+
beforeEach(() => tmp.setup());
18+
afterAll(() => tmp.cleanup());
19+
20+
it('flush writes buffered entries as a JSON array', async () => {
21+
const sink = createSink();
22+
sink.record(42, { command_group: 'deploy', command: 'deploy', exit_reason: 'success' });
23+
24+
await sink.flush();
25+
26+
const written = JSON.parse(await readFile(join(outputDir, 'test-session.json'), 'utf-8'));
27+
expect(written).toHaveLength(1);
28+
expect(written[0]).toMatchObject({
29+
value: 42,
30+
attrs: { command_group: 'deploy', command: 'deploy', exit_reason: 'success' },
31+
});
32+
});
33+
34+
it('accumulates multiple records into a single file', async () => {
35+
const sink = createSink();
36+
sink.record(10, { command_group: 'add', command: 'add.agent' });
37+
sink.record(20, { command_group: 'add', command: 'add.memory' });
38+
39+
await sink.flush();
40+
41+
const written = JSON.parse(await readFile(join(outputDir, 'test-session.json'), 'utf-8'));
42+
expect(written).toHaveLength(2);
43+
expect(written[0].value).toBe(10);
44+
expect(written[1].value).toBe(20);
45+
});
46+
47+
it('creates output directory if it does not exist', async () => {
48+
const nested = join(tmp.testDir, 'deep', 'nested', 'telemetry');
49+
const filePath = join(nested, 'test.json');
50+
const sink = new FilesystemSink({ filePath });
51+
sink.record(1, { command_group: 'status', command: 'status' });
52+
53+
await sink.flush();
54+
55+
const written = JSON.parse(await readFile(filePath, 'utf-8'));
56+
expect(written).toHaveLength(1);
57+
});
58+
59+
it('flush is a no-op when no records exist', async () => {
60+
const sink = createSink();
61+
await expect(sink.flush()).resolves.toBeUndefined();
62+
});
63+
64+
it('shutdown writes entries and logs audit message', async () => {
65+
const logged: string[] = [];
66+
const sink = createSink({ log: msg => logged.push(msg) });
67+
sink.record(99, { command_group: 'invoke', command: 'invoke' });
68+
69+
await sink.shutdown();
70+
71+
const written = JSON.parse(await readFile(join(outputDir, 'test-session.json'), 'utf-8'));
72+
expect(written).toHaveLength(1);
73+
expect(logged).toHaveLength(1);
74+
expect(logged[0]).toContain('[audit mode]');
75+
expect(logged[0]).toContain('test-session.json');
76+
});
77+
78+
it('shutdown does not log when no records were written', async () => {
79+
const logged: string[] = [];
80+
const sink = createSink({ log: msg => logged.push(msg) });
81+
82+
await sink.shutdown();
83+
84+
expect(logged).toHaveLength(0);
85+
});
86+
87+
it('subsequent flushes include all records', async () => {
88+
const sink = createSink();
89+
sink.record(1, { command_group: 'deploy', command: 'deploy' });
90+
await sink.flush();
91+
92+
sink.record(2, { command_group: 'deploy', command: 'deploy' });
93+
await sink.flush();
94+
95+
const written = JSON.parse(await readFile(join(outputDir, 'test-session.json'), 'utf-8'));
96+
expect(written).toHaveLength(2);
97+
});
98+
});
99+
100+
describe('resolveAuditFilePath', () => {
101+
it('joins outputDir, entrypoint, and sessionId into a JSON file path', () => {
102+
const path = resolveAuditFilePath('/home/user/.agentcore/telemetry', 'deploy', 'abc-123');
103+
expect(path).toBe('/home/user/.agentcore/telemetry/deploy-abc-123.json');
104+
});
105+
});

src/cli/telemetry/config.ts

Lines changed: 9 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,11 @@ export async function resolveResourceAttributes(mode: 'cli' | 'tui'): Promise<Re
5960
'node.version': process.version,
6061
});
6162
}
63+
64+
// ---------------------------------------------------------------------------
65+
// Audit file path
66+
// ---------------------------------------------------------------------------
67+
68+
export function resolveAuditFilePath(outputDir: string, entrypoint: string, sessionId: string): string {
69+
return join(outputDir, `${entrypoint}-${sessionId}.json`);
70+
}

src/cli/telemetry/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
export { resolveTelemetryPreference, resolveResourceAttributes } from './config.js';
1+
export { resolveTelemetryPreference, resolveResourceAttributes, resolveAuditFilePath } from './config.js';
22
export type { TelemetryPreference } from './config.js';
33
export { TelemetryClient, CANCELLED } from './client.js';
44
export { type MetricSink, CompositeSink } from './sinks/metric-sink.js';
55
export { OtelMetricSink, type OtelMetricSinkConfig } from './sinks/otel-metric-sink.js';
6+
export { FilesystemSink, type FilesystemSinkConfig } from './sinks/filesystem-sink.js';
67
export { classifyError, isUserError } from './error-classification.js';
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { MetricSink } from './metric-sink.js';
2+
import { mkdir, writeFile } from 'fs/promises';
3+
import { dirname } from 'path';
4+
5+
export interface FilesystemSinkConfig {
6+
filePath: string;
7+
log?: (message: string) => void;
8+
}
9+
10+
interface RecordedEntry {
11+
value: number;
12+
attrs: Record<string, string | number>;
13+
}
14+
15+
export class FilesystemSink implements MetricSink {
16+
private readonly entries: RecordedEntry[] = [];
17+
private readonly filePath: string;
18+
private readonly log: (message: string) => void;
19+
20+
constructor(config: FilesystemSinkConfig) {
21+
this.filePath = config.filePath;
22+
this.log = config.log ?? (msg => console.error(msg));
23+
}
24+
25+
record(value: number, attrs: Record<string, string | number>): void {
26+
this.entries.push({ value, attrs });
27+
}
28+
29+
async flush(): Promise<void> {
30+
await this.writeEntries();
31+
}
32+
33+
async shutdown(): Promise<void> {
34+
await this.writeEntries();
35+
if (this.entries.length > 0) {
36+
this.log(`[audit mode] Telemetry written to ${this.filePath}`);
37+
}
38+
}
39+
40+
private async writeEntries(): Promise<void> {
41+
if (this.entries.length === 0) return;
42+
await mkdir(dirname(this.filePath), { recursive: true });
43+
await writeFile(this.filePath, JSON.stringify(this.entries, null, 2));
44+
}
45+
}

0 commit comments

Comments
 (0)