Skip to content

Commit ea78a27

Browse files
committed
fix(telemetry): add circuit breaker and shutdown draining to TelemetryService
1 parent cd29243 commit ea78a27

4 files changed

Lines changed: 445 additions & 6 deletions

File tree

packages/telemetry/src/TelemetryService.ts

Lines changed: 117 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
1542
export 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
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// pnpm --filter @roo-code/telemetry test src/__tests__/TelemetryService.circuit-breaker.test.ts
2+
3+
import { TelemetryEventName, type TelemetryClient } from "@roo-code/types"
4+
5+
import { TelemetryService } from "../TelemetryService"
6+
7+
describe("TelemetryService circuit breaker", () => {
8+
let mockClient: TelemetryClient
9+
10+
beforeEach(() => {
11+
vi.useFakeTimers()
12+
vi.setSystemTime(0)
13+
14+
mockClient = {
15+
setProvider: vi.fn(),
16+
capture: vi.fn().mockResolvedValue(undefined),
17+
captureException: vi.fn().mockResolvedValue(undefined),
18+
updateTelemetryState: vi.fn(),
19+
isTelemetryEnabled: vi.fn().mockReturnValue(true),
20+
shutdown: vi.fn().mockResolvedValue(undefined),
21+
}
22+
})
23+
24+
afterEach(() => {
25+
vi.useRealTimers()
26+
})
27+
28+
it("passes through captures under the trip threshold", () => {
29+
const service = new TelemetryService([mockClient])
30+
31+
for (let i = 0; i < 49; i++) {
32+
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i })
33+
}
34+
35+
expect(mockClient.capture).toHaveBeenCalledTimes(49)
36+
})
37+
38+
it("trips after 50 CODE_INDEX_ERROR captures within the window and drops further ones", () => {
39+
const service = new TelemetryService([mockClient])
40+
41+
for (let i = 0; i < 50; i++) {
42+
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i })
43+
}
44+
expect(mockClient.capture).toHaveBeenCalledTimes(50)
45+
46+
// 51st capture should be dropped - breaker has tripped.
47+
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i: 50 })
48+
expect(mockClient.capture).toHaveBeenCalledTimes(50)
49+
50+
// Keeps dropping while tripped.
51+
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i: 51 })
52+
expect(mockClient.capture).toHaveBeenCalledTimes(50)
53+
})
54+
55+
it("re-allows captures after the cooldown window elapses", () => {
56+
const service = new TelemetryService([mockClient])
57+
58+
for (let i = 0; i < 50; i++) {
59+
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i })
60+
}
61+
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i: 50 })
62+
expect(mockClient.capture).toHaveBeenCalledTimes(50)
63+
64+
// Just under 10 minutes - still tripped.
65+
vi.setSystemTime(10 * 60 * 1000 - 1)
66+
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i: 51 })
67+
expect(mockClient.capture).toHaveBeenCalledTimes(50)
68+
69+
// Cooldown elapsed - one more error gets through.
70+
vi.setSystemTime(10 * 60 * 1000)
71+
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i: 52 })
72+
expect(mockClient.capture).toHaveBeenCalledTimes(51)
73+
})
74+
75+
it("does not reset the guarded count when unrelated events are interleaved", () => {
76+
// A real broken install still does normal things (creates/completes other tasks)
77+
// while a subsystem like code-index is stuck in a retry loop. Unrelated telemetry
78+
// must not mask the CODE_INDEX_ERROR burst by resetting its count.
79+
const service = new TelemetryService([mockClient])
80+
81+
for (let i = 0; i < 25; i++) {
82+
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i })
83+
service.captureEvent(TelemetryEventName.TASK_CREATED, { taskId: `task-${i}` })
84+
}
85+
// 25 CODE_INDEX_ERROR so far - still under the threshold of 50.
86+
expect(mockClient.capture).toHaveBeenCalledTimes(25 + 25)
87+
88+
for (let i = 25; i < 50; i++) {
89+
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i })
90+
service.captureEvent(TelemetryEventName.TASK_CREATED, { taskId: `task-${i}` })
91+
}
92+
// 50th CODE_INDEX_ERROR trips the breaker; TASK_CREATED events are never guarded.
93+
expect(mockClient.capture).toHaveBeenCalledTimes(50 + 50)
94+
95+
// Further CODE_INDEX_ERROR captures are dropped even though unrelated events keep flowing.
96+
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i: 50 })
97+
service.captureEvent(TelemetryEventName.TASK_CREATED, { taskId: "task-50" })
98+
expect(mockClient.capture).toHaveBeenCalledTimes(50 + 51)
99+
})
100+
101+
it("expires old occurrences outside the counting window instead of trapping the breaker open forever", () => {
102+
// A slow trickle of CODE_INDEX_ERROR (below the burst rate) should never trip the
103+
// breaker, since old occurrences age out of the window rather than accumulating forever.
104+
const service = new TelemetryService([mockClient])
105+
106+
for (let i = 0; i < 60; i++) {
107+
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i })
108+
// Advance well past the counting window between each one.
109+
vi.setSystemTime(Date.now() + 60 * 1000)
110+
}
111+
112+
expect(mockClient.capture).toHaveBeenCalledTimes(60)
113+
})
114+
115+
it("does not guard other event names", () => {
116+
const service = new TelemetryService([mockClient])
117+
118+
for (let i = 0; i < 200; i++) {
119+
service.captureEvent(TelemetryEventName.TOOL_USED, { tool: "read_file" })
120+
}
121+
122+
expect(mockClient.capture).toHaveBeenCalledTimes(200)
123+
})
124+
})

0 commit comments

Comments
 (0)