11import { test , describe , beforeEach , afterEach , expect } from "bun:test" ;
22import { 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" ;
55import { DefaultTelemetryClient } from "./client" ;
66import { TelemetryAttributesRecorder } from "./recorder" ;
77import { createFileLogger , type Logger } from "../logging" ;
88import { LOG_LEVEL } from "../logging" ;
99import { assertLogsMatch , TestGlobalConfigAccessor } from "../testing" ;
1010import type { MetricSink } from "./types" ;
11- import { LoggingSink } from "./loggingSink " ;
11+ import { FileSystemSink } from "./fileSystemSink " ;
1212
1313describe ( "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 = {
0 commit comments