@@ -7,14 +7,63 @@ import {
77 type TelemetrySetting ,
88} from "@roo-code/types"
99
10+ /**
11+ * Events prone to retry-storm-style repetition (e.g. a broken embedder config
12+ * re-triggering on every file-system event). Guarded by a circuit breaker in
13+ * `captureEvent` so a single broken install can't flood the Product Analytics
14+ * quota. Tracked per-event via a sliding time window, independent of any
15+ * other telemetry the same install may also be sending.
16+ */
17+ const CIRCUIT_BREAKER_GUARDED_EVENTS = new Set < TelemetryEventName > ( [ TelemetryEventName . CODE_INDEX_ERROR ] )
18+
19+ /** Captures of a guarded event within the counting window allowed before the breaker trips. */
20+ const CIRCUIT_BREAKER_MAX_IN_WINDOW = 50
21+
22+ /** Rolling window over which guarded-event occurrences are counted. */
23+ const CIRCUIT_BREAKER_WINDOW_MS = 10 * 60 * 1000
24+
25+ /** How long a tripped breaker stays tripped before allowing captures again. */
26+ const CIRCUIT_BREAKER_COOLDOWN_MS = 10 * 60 * 1000
27+
28+ /**
29+ * Upper bound on how long shutdown() will wait for in-flight capture calls to drain.
30+ * deactivate() awaits shutdown() before terminal cleanup, so an unbounded wait here
31+ * (e.g. a capture stuck on network I/O that never resolves/rejects) would block the
32+ * extension host from ever finishing deactivation. Losing an in-flight capture on
33+ * timeout is an acceptable tradeoff against blocking shutdown indefinitely.
34+ */
35+ const SHUTDOWN_DRAIN_TIMEOUT_MS = 3000
36+
1037/**
1138 * TelemetryService wrapper class that defers initialization.
1239 * This ensures that we only create the various clients after environment
1340 * variables are loaded.
1441 */
1542export class TelemetryService {
43+ // Timestamps of recent guarded-event occurrences, per event name, oldest first.
44+ private guardedEventOccurrences = new Map < TelemetryEventName , number [ ] > ( )
45+ private trippedUntil = new Map < TelemetryEventName , number > ( )
46+
47+ // In-flight client.capture()/captureException() promises. captureEvent/captureException are
48+ // synchronous (void-returning) for callers, but the underlying client calls are async (e.g.
49+ // PostHogTelemetryClient awaits property enrichment before enqueueing). Tracked here so
50+ // shutdown() can drain them before flushing/closing the clients -- otherwise a capture that's
51+ // still mid-flight when shutdown() runs could be lost entirely.
52+ private pendingClientCalls = new Set < Promise < unknown > > ( )
53+
54+ // Set at the start of shutdown() so new captureEvent/captureException calls stop being
55+ // tracked (and, once clients are closing, stop being sent) instead of racing the drain.
56+ private isShuttingDown = false
57+
1658 constructor ( private clients : TelemetryClient [ ] ) { }
1759
60+ private trackPendingClientCall ( promise : Promise < unknown > ) : void {
61+ // Never let a rejected client call surface as an unhandled rejection or block shutdown.
62+ const tracked = promise . catch ( ( ) => undefined )
63+ this . pendingClientCalls . add ( tracked )
64+ void tracked . finally ( ( ) => this . pendingClientCalls . delete ( tracked ) )
65+ }
66+
1867 public register ( client : TelemetryClient ) : void {
1968 this . clients . push ( client )
2069 }
@@ -51,18 +100,60 @@ export class TelemetryService {
51100 this . clients . forEach ( ( client ) => client . updateTelemetryState ( isOptedIn ) )
52101 }
53102
103+ /**
104+ * Checks whether a guarded event should be dropped by the circuit breaker,
105+ * updating the breaker's internal state as a side effect. Tracked entirely
106+ * independently of other event names -- unrelated telemetry from the same
107+ * install must never mask (or count towards) a guarded-event burst.
108+ */
109+ private shouldDropForCircuitBreaker ( eventName : TelemetryEventName ) : boolean {
110+ if ( ! CIRCUIT_BREAKER_GUARDED_EVENTS . has ( eventName ) ) {
111+ return false
112+ }
113+
114+ const now = Date . now ( )
115+
116+ const trippedUntil = this . trippedUntil . get ( eventName )
117+ if ( trippedUntil !== undefined ) {
118+ if ( now < trippedUntil ) {
119+ return true
120+ }
121+
122+ // Cooldown elapsed - reset and allow this capture through.
123+ this . trippedUntil . delete ( eventName )
124+ this . guardedEventOccurrences . delete ( eventName )
125+ }
126+
127+ const windowStart = now - CIRCUIT_BREAKER_WINDOW_MS
128+ const occurrences = ( this . guardedEventOccurrences . get ( eventName ) ?? [ ] ) . filter ( ( ts ) => ts > windowStart )
129+ occurrences . push ( now )
130+ this . guardedEventOccurrences . set ( eventName , occurrences )
131+
132+ if ( occurrences . length > CIRCUIT_BREAKER_MAX_IN_WINDOW ) {
133+ this . trippedUntil . set ( eventName , now + CIRCUIT_BREAKER_COOLDOWN_MS )
134+ this . guardedEventOccurrences . delete ( eventName )
135+ return true
136+ }
137+
138+ return false
139+ }
140+
54141 /**
55142 * Generic method to capture any type of event with specified properties
56143 * @param eventName The event name to capture
57144 * @param properties The event properties
58145 */
59146 // eslint-disable-next-line @typescript-eslint/no-explicit-any
60147 public captureEvent ( eventName : TelemetryEventName , properties ?: Record < string , any > ) : void {
61- if ( ! this . isReady ) {
148+ if ( ! this . isReady || this . isShuttingDown ) {
149+ return
150+ }
151+
152+ if ( this . shouldDropForCircuitBreaker ( eventName ) ) {
62153 return
63154 }
64155
65- this . clients . forEach ( ( client ) => client . capture ( { event : eventName , properties } ) )
156+ this . clients . forEach ( ( client ) => this . trackPendingClientCall ( client . capture ( { event : eventName , properties } ) ) )
66157 }
67158
68159 /**
@@ -71,11 +162,13 @@ export class TelemetryService {
71162 * @param additionalProperties Additional properties to include with the exception
72163 */
73164 public captureException ( error : Error , additionalProperties ?: Record < string , unknown > ) : void {
74- if ( ! this . isReady ) {
165+ if ( ! this . isReady || this . isShuttingDown ) {
75166 return
76167 }
77168
78- this . clients . forEach ( ( client ) => client . captureException ( error , additionalProperties ) )
169+ this . clients . forEach ( ( client ) =>
170+ this . trackPendingClientCall ( client . captureException ( error , additionalProperties ) ) ,
171+ )
79172 }
80173
81174 public captureTaskCreated ( taskId : string ) : void {
@@ -263,7 +356,26 @@ export class TelemetryService {
263356 return
264357 }
265358
266- this . clients . forEach ( ( client ) => client . shutdown ( ) )
359+ // Stop accepting new captures immediately, before draining -- otherwise a steady trickle
360+ // of new calls (e.g. from a teardown-time error handler) could keep pendingClientCalls
361+ // non-empty indefinitely and the drain loop below would never terminate on its own.
362+ this . isShuttingDown = true
363+
364+ // Drain any in-flight capture/captureException calls first, so a client's shutdown()
365+ // (which flushes its queue) can't run ahead of a capture that hasn't been enqueued yet.
366+ // Loop rather than a single snapshot: a call already in flight when draining started may
367+ // itself still be tracked by the time we check again. Bounded by a timeout so a capture
368+ // stuck on network I/O that never resolves/rejects can't block deactivate() forever --
369+ // losing that one capture is an acceptable tradeoff against hanging terminal cleanup.
370+ const drainStart = Date . now ( )
371+ while ( this . pendingClientCalls . size > 0 && Date . now ( ) - drainStart < SHUTDOWN_DRAIN_TIMEOUT_MS ) {
372+ await Promise . race ( [
373+ Promise . all ( this . pendingClientCalls ) ,
374+ new Promise ( ( resolve ) => setTimeout ( resolve , SHUTDOWN_DRAIN_TIMEOUT_MS - ( Date . now ( ) - drainStart ) ) ) ,
375+ ] )
376+ }
377+
378+ await Promise . all ( this . clients . map ( ( client ) => client . shutdown ( ) ) )
267379 }
268380
269381 private static _instance : TelemetryService | null = null
0 commit comments