@@ -18,6 +18,193 @@ import { getStore } from '../utils/store';
1818import { STORE_KEYS } from '@/shared/constants' ;
1919
2020const logger = createLogger ( 'Analytics' ) ;
21+ const MCP_SUCCESS_IDLE_WINDOW_MS = 60_000 ;
22+ const MAX_PROP_KEY_LENGTH = 40 ;
23+ const MAX_PROPS_PER_BATCH_EVENT = 25 ;
24+
25+ type AnalyticsPropertyValue = string | number | boolean ;
26+ type AnalyticsProperties = Record < string , AnalyticsPropertyValue > ;
27+ export type MCPToolBatchFlushReason = 'idle_timeout' | 'before_failure' | 'app_quit' ;
28+
29+ interface McpToolSuccessEvent {
30+ tool_name : string ;
31+ duration_ms ?: number ;
32+ ts : number ;
33+ }
34+
35+ class McpToolSuccessBuffer {
36+ private buffer : McpToolSuccessEvent [ ] = [ ] ;
37+ private lastSuccessAt : number | null = null ;
38+ private idleTimer : NodeJS . Timeout | null = null ;
39+
40+ recordSuccess ( toolName : string , durationMs ?: number ) : void {
41+ this . buffer . push ( {
42+ tool_name : toolName ,
43+ ...( durationMs !== undefined && { duration_ms : durationMs } ) ,
44+ ts : Date . now ( ) ,
45+ } ) ;
46+ this . lastSuccessAt = Date . now ( ) ;
47+ this . resetIdleTimer ( ) ;
48+ }
49+
50+ flush ( reason : MCPToolBatchFlushReason ) : void {
51+ if ( this . buffer . length === 0 ) {
52+ this . clearIdleTimer ( ) ;
53+ return ;
54+ }
55+
56+ this . clearIdleTimer ( ) ;
57+
58+ const durationValues = this . buffer
59+ . map ( ( event ) => event . duration_ms )
60+ . filter ( ( value ) : value is number => value !== undefined ) ;
61+ const durationTotalMs = durationValues . reduce ( ( acc , value ) => acc + value , 0 ) ;
62+ const batchId = `${ Date . now ( ) } _${ Math . random ( ) . toString ( 36 ) . slice ( 2 , 10 ) } ` ;
63+
64+ const toolDurationTotals = new Map < string , number > ( ) ;
65+ for ( const event of this . buffer ) {
66+ const toolName = event . tool_name || 'unknown_tool' ;
67+ const value = event . duration_ms ?? 0 ;
68+ toolDurationTotals . set ( toolName , ( toolDurationTotals . get ( toolName ) ?? 0 ) + value ) ;
69+ }
70+
71+ const baseParams : AnalyticsProperties = {
72+ tool_name : '__batch__' ,
73+ success : true ,
74+ batched : true ,
75+ batch_size : this . buffer . length ,
76+ idle_window_sec : MCP_SUCCESS_IDLE_WINDOW_MS / 1000 ,
77+ batch_reason : reason ,
78+ batch_id : batchId ,
79+ } ;
80+
81+ if ( this . lastSuccessAt !== null ) {
82+ baseParams . last_success_at_ts = this . lastSuccessAt ;
83+ }
84+
85+ if ( durationValues . length > 0 ) {
86+ baseParams . duration_total_ms = durationTotalMs ;
87+ }
88+
89+ const maxToolPropsPerChunk = Math . max (
90+ 1 ,
91+ MAX_PROPS_PER_BATCH_EVENT - Object . keys ( baseParams ) . length - 2 // chunk index + count
92+ ) ;
93+ const entries = this . buildUniqueToolDurationEntries ( toolDurationTotals ) ;
94+ const chunks = this . chunkEntries ( entries , maxToolPropsPerChunk ) ;
95+
96+ chunks . forEach ( ( chunk , index ) => {
97+ const params : AnalyticsProperties = {
98+ ...baseParams ,
99+ batch_chunk_index : index + 1 ,
100+ batch_chunk_count : chunks . length ,
101+ } ;
102+
103+ for ( const [ toolKey , totalMs ] of chunk ) {
104+ params [ toolKey ] = Math . round ( totalMs ) ;
105+ }
106+
107+ trackAptabaseEvent ( AnalyticsEvents . MCP_TOOL_CALL , params ) ;
108+ } ) ;
109+
110+ logger . debug (
111+ `Flushed MCP success batch (${ reason } ) with ${ this . buffer . length } events (${ chunks . length } chunk(s))`
112+ ) ;
113+
114+ this . buffer = [ ] ;
115+ this . lastSuccessAt = null ;
116+ }
117+
118+ private buildUniqueToolDurationEntries (
119+ durationTotalsByToolName : Map < string , number >
120+ ) : Array < [ string , number ] > {
121+ const collisionCountByBaseKey = new Map < string , number > ( ) ;
122+ const entries : Array < [ string , number ] > = [ ] ;
123+
124+ const sortedByToolName = Array . from ( durationTotalsByToolName . entries ( ) )
125+ . sort ( ( a , b ) => a [ 0 ] . localeCompare ( b [ 0 ] ) ) ;
126+
127+ for ( const [ toolName , totalDurationMs ] of sortedByToolName ) {
128+ const baseKey = this . toToolDurationBaseKey ( toolName ) ;
129+ const collisionIndex = ( collisionCountByBaseKey . get ( baseKey ) ?? 0 ) + 1 ;
130+ collisionCountByBaseKey . set ( baseKey , collisionIndex ) ;
131+
132+ const resolvedKey = collisionIndex === 1
133+ ? baseKey
134+ : this . withCollisionSuffix ( baseKey , collisionIndex ) ;
135+
136+ entries . push ( [ resolvedKey , totalDurationMs ] ) ;
137+ }
138+
139+ return entries ;
140+ }
141+
142+ private toToolDurationBaseKey ( toolName : string ) : string {
143+ const normalized = toolName
144+ . trim ( )
145+ . toLowerCase ( )
146+ . replace ( / [ ^ a - z 0 - 9 _ ] / g, '_' )
147+ . replace ( / _ + / g, '_' )
148+ . replace ( / ^ _ + | _ + $ / g, '' ) ;
149+
150+ const key = normalized || 'unknown_tool' ;
151+ const reservedKeys = new Set ( [
152+ 'tool_name' ,
153+ 'success' ,
154+ 'batched' ,
155+ 'batch_size' ,
156+ 'idle_window_sec' ,
157+ 'batch_reason' ,
158+ 'batch_id' ,
159+ 'last_success_at_ts' ,
160+ 'duration_total_ms' ,
161+ 'batch_chunk_index' ,
162+ 'batch_chunk_count' ,
163+ 'distribution_channel' ,
164+ ] ) ;
165+
166+ const safeKey = reservedKeys . has ( key ) ? `tool_${ key } ` : key ;
167+ return safeKey . slice ( 0 , MAX_PROP_KEY_LENGTH ) ;
168+ }
169+
170+ private withCollisionSuffix ( baseKey : string , collisionIndex : number ) : string {
171+ const suffix = `_${ collisionIndex } ` ;
172+ const maxBaseLength = Math . max ( 1 , MAX_PROP_KEY_LENGTH - suffix . length ) ;
173+ const trimmedBase = baseKey . slice ( 0 , maxBaseLength ) . replace ( / _ + $ / g, '' ) ;
174+ return `${ trimmedBase } ${ suffix } ` ;
175+ }
176+
177+ private chunkEntries (
178+ entries : Array < [ string , number ] > ,
179+ chunkSize : number
180+ ) : Array < Array < [ string , number ] > > {
181+ if ( entries . length === 0 ) {
182+ return [ [ ] ] ;
183+ }
184+
185+ const chunks : Array < Array < [ string , number ] > > = [ ] ;
186+ for ( let i = 0 ; i < entries . length ; i += chunkSize ) {
187+ chunks . push ( entries . slice ( i , i + chunkSize ) ) ;
188+ }
189+ return chunks ;
190+ }
191+
192+ private resetIdleTimer ( ) : void {
193+ this . clearIdleTimer ( ) ;
194+ this . idleTimer = setTimeout ( ( ) => {
195+ this . flush ( 'idle_timeout' ) ;
196+ } , MCP_SUCCESS_IDLE_WINDOW_MS ) ;
197+ }
198+
199+ private clearIdleTimer ( ) : void {
200+ if ( this . idleTimer ) {
201+ clearTimeout ( this . idleTimer ) ;
202+ this . idleTimer = null ;
203+ }
204+ }
205+ }
206+
207+ const mcpToolSuccessBuffer = new McpToolSuccessBuffer ( ) ;
21208
22209/**
23210 * Track event to both analytics services
@@ -102,7 +289,7 @@ export function trackMCPToolCall(
102289 errorMessage ?: string ,
103290 durationMs ?: number
104291) : void {
105- const params : Record < string , string | number | boolean > = {
292+ const params : AnalyticsProperties = {
106293 tool_name : toolName ,
107294 success : success ,
108295 } ;
@@ -115,7 +302,23 @@ export function trackMCPToolCall(
115302 params . duration_ms = durationMs ;
116303 }
117304
118- trackEvent ( AnalyticsEvents . MCP_TOOL_CALL , params ) ;
305+ if ( success ) {
306+ mcpToolSuccessBuffer . recordSuccess ( toolName , durationMs ) ;
307+ trackGA4Event ( AnalyticsEvents . MCP_TOOL_CALL , params ) ;
308+ return ;
309+ }
310+
311+ // Keep failure/timeout events immediate and flush pending successes first.
312+ mcpToolSuccessBuffer . flush ( 'before_failure' ) ;
313+ trackAptabaseEvent ( AnalyticsEvents . MCP_TOOL_CALL , params ) ;
314+ trackGA4Event ( AnalyticsEvents . MCP_TOOL_CALL , params ) ;
315+ }
316+
317+ /**
318+ * Flush buffered successful MCP tool events to Aptabase.
319+ */
320+ export function flushMCPToolSuccessBatch ( reason : MCPToolBatchFlushReason ) : void {
321+ mcpToolSuccessBuffer . flush ( reason ) ;
119322}
120323
121324/**
0 commit comments