Skip to content

Commit 034ad39

Browse files
authored
feat(telemetry): enable audit mode for telemetry (#1858)
* feat(tel): implement audit sink on the fs * test(tel): add explicit test for the case where audit is disabled * refactor(telemetry): remove logging sink in favor of audit sink * test(telemetry): add a test case for invalid audit path * refactor(telemetry): simplify the caching of metric sinks and attributes
1 parent 186f66b commit 034ad39

4 files changed

Lines changed: 217 additions & 68 deletions

File tree

src/telemetry/client.test.tsx

Lines changed: 124 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { test, describe, beforeEach, afterEach, expect } from "bun:test";
22
import { join } from "node:path";
3-
import { mkdtemp, rm } from "node:fs/promises";
4-
import { tmpdir } from "node:os";
3+
import { mkdtemp, readFile, rm } from "node:fs/promises";
4+
import os, { tmpdir } from "node:os";
55
import { DefaultTelemetryClient } from "./client";
66
import { TelemetryAttributesRecorder } from "./recorder";
77
import { createFileLogger, type Logger } from "../logging";
88
import { LOG_LEVEL } from "../logging";
99
import { assertLogsMatch, TestGlobalConfigAccessor } from "../testing";
1010
import type { MetricSink } from "./types";
11-
import { LoggingSink } from "./loggingSink";
11+
import { FileSystemSink } from "./fileSystemSink";
1212

1313
describe("DefaultTelemetryClient", () => {
1414
let tempDir: string;
@@ -26,42 +26,120 @@ describe("DefaultTelemetryClient", () => {
2626
await rm(tempDir, { recursive: true, force: true });
2727
});
2828

29-
test("emits metrics with resource and command attributes to configured sinks", async () => {
30-
const sink = new LoggingSink({ logger: logger.child({ module: "loggingSink" }) });
29+
test("emits complete metrics to configured JSONL filesystem sinks", async () => {
30+
const auditFilePath = join(tempDir, "telemetry", "audit.jsonl");
31+
const fileSystemSink = new FileSystemSink({
32+
logger: logger.child({ module: "fileSystemSink" }),
33+
filePath: auditFilePath,
34+
});
3135
const sessionId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
3236
const globalConfigAccessor = new TestGlobalConfigAccessor();
3337
const client = new DefaultTelemetryClient({
3438
logger,
3539
sessionId,
3640
globalConfigAccessor,
37-
metricSinks: [sink],
41+
metricSinks: [fileSystemSink],
3842
});
3943

4044
const recorder = new TelemetryAttributesRecorder("cli.command_run", { exit_reason: "success" });
4145

42-
recorder.record({ exit_reason: "failure" });
43-
4446
await client.emit("cli.command_run", 123, recorder.getAttributes());
4547

46-
expect(sink.getName()).toBe("LoggingSink");
47-
await sink.shutdown();
48+
recorder.record({ exit_reason: "failure" });
49+
await client.emit("cli.command_run", 456, recorder.getAttributes());
4850
await client.shutdown();
4951

52+
expect(fileSystemSink.getName()).toBe("FileSystemSink");
53+
5054
const { installationId } = await globalConfigAccessor.get();
51-
await assertLogsMatch(tempDir, [
55+
56+
const auditContents = await readFile(auditFilePath, "utf8");
57+
58+
const entries = auditContents
59+
.trimEnd()
60+
.split("\n")
61+
.map((line) => JSON.parse(line));
62+
63+
const resourceAttributes = {
64+
"service.name": "agentcore-cli",
65+
"service.version": "0.0.0",
66+
"agentcore-cli.installation_id": installationId,
67+
"agentcore-cli.session_id": sessionId,
68+
"os.type": os.type(),
69+
"os.version": os.release(),
70+
"host.arch": os.arch(),
71+
"node.version": process.version,
72+
};
73+
74+
expect(entries).toEqual([
5275
{
53-
filter: (log: any) =>
54-
log.metricName === "cli.command_run" &&
55-
log.metricValue === 123 &&
56-
log.metricAttributes?.["exit_reason"] === "failure" &&
57-
log.metricAttributes?.["service.name"] === "agentcore-cli" &&
58-
log.metricAttributes?.["agentcore-cli.session_id"] === sessionId &&
59-
log.metricAttributes?.["agentcore-cli.installation_id"] === installationId,
60-
expectedCount: 1,
76+
metricName: "cli.command_run",
77+
value: 123,
78+
attrs: { ...resourceAttributes, exit_reason: "success" },
79+
},
80+
{
81+
metricName: "cli.command_run",
82+
value: 456,
83+
attrs: { ...resourceAttributes, exit_reason: "failure" },
6184
},
6285
]);
6386
});
6487

88+
test("enables the default audit sink only when global config audit is enabled", async () => {
89+
const auditFilePath = join(tempDir, "telemetry", "config-audit.jsonl");
90+
const enabledSessionId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
91+
const disabledSessionId = "ffffffff-1111-2222-3333-444444444444";
92+
93+
const enabledConfigAccessor = new TestGlobalConfigAccessor();
94+
const enabledConfig = await enabledConfigAccessor.get();
95+
await enabledConfigAccessor.set({
96+
...enabledConfig,
97+
telemetry: { ...enabledConfig.telemetry, audit: true },
98+
});
99+
100+
const disabledConfigAccessor = new TestGlobalConfigAccessor();
101+
const disabledConfig = await disabledConfigAccessor.get();
102+
await disabledConfigAccessor.set({
103+
...disabledConfig,
104+
telemetry: { ...disabledConfig.telemetry, audit: false },
105+
});
106+
107+
const enabledClient = new DefaultTelemetryClient({
108+
logger,
109+
sessionId: enabledSessionId,
110+
globalConfigAccessor: enabledConfigAccessor,
111+
auditFilePath,
112+
});
113+
const disabledClient = new DefaultTelemetryClient({
114+
logger,
115+
sessionId: disabledSessionId,
116+
globalConfigAccessor: disabledConfigAccessor,
117+
auditFilePath,
118+
});
119+
120+
await enabledClient.emit("cli.command_run", 123, { exit_reason: "success" });
121+
await disabledClient.emit("cli.command_run", 456, { exit_reason: "failure" });
122+
await Promise.all([enabledClient.shutdown(), disabledClient.shutdown()]);
123+
124+
const auditLines = (await readFile(auditFilePath, "utf8")).trimEnd().split("\n");
125+
expect(auditLines).toHaveLength(1);
126+
expect(JSON.parse(auditLines[0]!)).toEqual({
127+
metricName: "cli.command_run",
128+
value: 123,
129+
attrs: {
130+
"service.name": "agentcore-cli",
131+
"service.version": "0.0.0",
132+
"agentcore-cli.installation_id": enabledConfig.installationId,
133+
"agentcore-cli.session_id": enabledSessionId,
134+
"os.type": os.type(),
135+
"os.version": os.release(),
136+
"host.arch": os.arch(),
137+
"node.version": process.version,
138+
exit_reason: "success",
139+
},
140+
});
141+
});
142+
65143
test("throws when recorder has incomplete attributes", async () => {
66144
const client = new DefaultTelemetryClient({
67145
logger,
@@ -76,6 +154,33 @@ describe("DefaultTelemetryClient", () => {
76154
await client.shutdown();
77155
});
78156

157+
test("FileSystemSink logs a warning when the file is not writable", async () => {
158+
// Point the sink at a directory to trigger EISDIR
159+
const sink = new FileSystemSink({
160+
logger: logger.child({ module: "fileSystemSink" }),
161+
filePath: tempDir,
162+
});
163+
164+
const client = new DefaultTelemetryClient({
165+
logger,
166+
sessionId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
167+
globalConfigAccessor: new TestGlobalConfigAccessor(),
168+
metricSinks: [sink],
169+
});
170+
171+
await client.emit("cli.command_run", 1, { exit_reason: "success" });
172+
await client.shutdown();
173+
174+
await assertLogsMatch(tempDir, [
175+
{
176+
filter: (log: any) =>
177+
log.msg === "failed to append metric data to file" &&
178+
log.errorMessage?.includes("EISDIR"),
179+
expectedCount: 1,
180+
},
181+
]);
182+
});
183+
79184
test("handles sink errors gracefully without throwing", async () => {
80185
const recordedMetrics: string[] = [];
81186
const goodSink: MetricSink = {

src/telemetry/client.tsx

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { Logger } from "../logging";
2-
import { LoggingSink } from "./loggingSink";
32
import { resourceAttributesSchema, type ResourceAttributes } from "./shapes";
43
import os from "os";
54
import {
@@ -11,12 +10,15 @@ import {
1110
type MetricName,
1211
} from "./types";
1312
import type { GlobalConfigAccessor } from "../globalConfig";
13+
import { FileSystemSink } from "./fileSystemSink";
14+
import path from "path";
1415

1516
export type DefaultTelemetryClientConfig = {
1617
logger: Logger;
1718
globalConfigAccessor: GlobalConfigAccessor;
1819
sessionId: string;
1920
metricSinks?: MetricSink[];
21+
auditFilePath?: string;
2022
};
2123

2224
/**
@@ -25,16 +27,17 @@ export type DefaultTelemetryClientConfig = {
2527
export class DefaultTelemetryClient implements TelemetryClient {
2628
private logger: Logger;
2729
private readonly sessionId: string;
30+
private readonly auditFilePath: string;
2831
private globalConfigAccessor: GlobalConfigAccessor;
29-
private resourceAttributes: ResourceAttributes | undefined;
30-
private metricSinks: MetricSink[] | undefined;
31-
32+
private readonly metricSinksOverride: MetricSink[] | undefined;
3233
constructor(config: DefaultTelemetryClientConfig) {
3334
this.logger = config.logger;
3435
this.sessionId = config.sessionId;
3536
this.globalConfigAccessor = config.globalConfigAccessor;
36-
this.resourceAttributes = undefined;
37-
this.metricSinks = config.metricSinks;
37+
this.metricSinksOverride = config.metricSinks;
38+
this.auditFilePath =
39+
config.auditFilePath ??
40+
path.join(os.homedir(), ".agentcore", "telemetry", `${this.sessionId}.jsonl`);
3841
}
3942

4043
async emit<TMetricName extends MetricName>(
@@ -43,7 +46,7 @@ export class DefaultTelemetryClient implements TelemetryClient {
4346
metricAttributes: AttributesOf<TMetricName>,
4447
): Promise<void> {
4548
try {
46-
const metricSinks = this.getMetricSinks();
49+
const metricSinks = await this.getMetricSinks();
4750
const resourceAttributes = await this.getResourceAttributes();
4851
// merge in resource attributes with metric attributes before sending to sink.
4952
const attributes = {
@@ -74,7 +77,7 @@ export class DefaultTelemetryClient implements TelemetryClient {
7477
}
7578

7679
async shutdown(): Promise<void> {
77-
const metricSinks = this.getMetricSinks();
80+
const metricSinks = await this.getMetricSinks();
7881

7982
const promises = metricSinks.map(async (sink) => {
8083
return sink.shutdown().catch((e) => {
@@ -87,19 +90,27 @@ export class DefaultTelemetryClient implements TelemetryClient {
8790
await Promise.all(promises);
8891
}
8992

90-
private getMetricSinks(): MetricSink[] {
91-
if (this.metricSinks !== undefined) return this.metricSinks;
93+
private getMetricSinks: () => Promise<MetricSink[]> = once(async () => {
94+
if (this.metricSinksOverride) return this.metricSinksOverride;
9295

93-
this.metricSinks = [new LoggingSink({ logger: this.logger.child({ module: "loggingSink" }) })];
96+
const metricSinks = [];
9497

95-
return this.metricSinks;
96-
}
98+
const globalConfig = await this.globalConfigAccessor.get();
99+
100+
if (globalConfig.telemetry.audit)
101+
metricSinks.push(
102+
new FileSystemSink({
103+
logger: this.logger.child({ module: "fileSystemSink" }),
104+
filePath: this.auditFilePath,
105+
}),
106+
);
97107

98-
private async getResourceAttributes(): Promise<ResourceAttributes> {
99-
if (this.resourceAttributes !== undefined) return this.resourceAttributes;
108+
return metricSinks;
109+
});
100110

111+
private getResourceAttributes: () => Promise<ResourceAttributes> = once(async () => {
101112
const globalConfig = await this.globalConfigAccessor.get();
102-
this.resourceAttributes = resourceAttributesSchema.parse({
113+
return resourceAttributesSchema.parse({
103114
"service.name": "agentcore-cli",
104115
// TODO: wire up real package version.
105116
"service.version": "0.0.0",
@@ -110,6 +121,11 @@ export class DefaultTelemetryClient implements TelemetryClient {
110121
"host.arch": os.arch(),
111122
"node.version": process.version,
112123
});
113-
return this.resourceAttributes;
114-
}
124+
});
125+
}
126+
127+
/** wraps an async function such that it only executes once **/
128+
function once<T>(fn: () => Promise<T>): () => Promise<T> {
129+
let cachedPromise: Promise<T> | undefined;
130+
return () => (cachedPromise ??= fn());
115131
}

src/telemetry/fileSystemSink.tsx

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import type { Logger } from "../logging";
2+
import type { MetricSink } from "./types";
3+
import { mkdir, appendFile } from "fs/promises";
4+
import { dirname } from "path";
5+
6+
export type FileSystemSinkConfig = {
7+
logger: Logger;
8+
filePath: string;
9+
};
10+
11+
/** An implementation of {@link MetricSink} that sends all data to the specified file in JSONL format **/
12+
export class FileSystemSink implements MetricSink {
13+
private readonly name: string;
14+
15+
private readonly filePath: string;
16+
private logger: Logger;
17+
18+
/* a chain of promises describing the pending writes to the audit file */
19+
private pendingWrite: Promise<void>;
20+
21+
constructor(config: FileSystemSinkConfig) {
22+
this.filePath = config.filePath;
23+
this.logger = config.logger.child({ fsSinkFilePath: this.filePath });
24+
this.name = new.target.name;
25+
26+
this.pendingWrite = Promise.resolve();
27+
}
28+
29+
send(metricName: string, value: number, attributes: Record<string, string | number>): void {
30+
this.pendingWrite = this.pendingWrite.then(() =>
31+
this.appendEntry({ metricName, value, attrs: attributes }),
32+
);
33+
}
34+
35+
private async appendEntry(entry: {
36+
metricName: string;
37+
value: number;
38+
attrs: Record<string, string | number>;
39+
}): Promise<void> {
40+
await mkdir(dirname(this.filePath), { recursive: true });
41+
await appendFile(this.filePath, JSON.stringify(entry) + "\n");
42+
}
43+
44+
async shutdown(): Promise<void> {
45+
try {
46+
await this.pendingWrite;
47+
this.logger.info(`audit file written to '${this.filePath}'`);
48+
} catch (e) {
49+
const error = e instanceof Error ? e : new Error(String(e));
50+
this.logger
51+
.child({ errorName: error.name, errorMessage: error.message })
52+
.warn(`failed to append metric data to file`);
53+
}
54+
}
55+
56+
getName(): string {
57+
return this.name;
58+
}
59+
}

src/telemetry/loggingSink.tsx

Lines changed: 0 additions & 31 deletions
This file was deleted.

0 commit comments

Comments
 (0)