Skip to content

Commit 2416e93

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

4 files changed

Lines changed: 149 additions & 1 deletion

File tree

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+
});

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: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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+
log?: (message: string) => void;
8+
}
9+
10+
export class FilesystemSink implements MetricSink {
11+
private readonly filePath: string;
12+
private readonly log: (message: string) => void;
13+
private hasRecords = false;
14+
15+
constructor(config: FilesystemSinkConfig) {
16+
this.filePath = config.filePath;
17+
this.log = config.log ?? (msg => console.error(msg));
18+
}
19+
20+
record(value: number, attrs: Record<string, string | number>): void {
21+
this.hasRecords = true;
22+
this.pendingWrite = this.pendingWrite.then(() => this.appendEntry({ value, attrs }));
23+
}
24+
25+
// eslint-disable-next-line @typescript-eslint/no-empty-function
26+
async flush(): Promise<void> {
27+
await this.pendingWrite;
28+
}
29+
30+
async shutdown(): Promise<void> {
31+
await this.pendingWrite;
32+
if (this.hasRecords) {
33+
this.log(`[audit mode] Telemetry written to ${this.filePath}`);
34+
}
35+
}
36+
37+
private pendingWrite: Promise<void> = Promise.resolve();
38+
39+
private async appendEntry(entry: { value: number; attrs: Record<string, string | number> }): Promise<void> {
40+
await mkdir(dirname(this.filePath), { recursive: true });
41+
await appendFile(this.filePath, JSON.stringify(entry) + '\n');
42+
}
43+
}

0 commit comments

Comments
 (0)