Skip to content

Commit 32bd50b

Browse files
committed
fix(telemetry): fix circuit breaker off-by-one, allSettled on shutdown
1 parent ea78a27 commit 32bd50b

3 files changed

Lines changed: 101 additions & 20 deletions

File tree

packages/telemetry/src/TelemetryService.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ export class TelemetryService {
129129
occurrences.push(now)
130130
this.guardedEventOccurrences.set(eventName, occurrences)
131131

132-
if (occurrences.length > CIRCUIT_BREAKER_MAX_IN_WINDOW) {
132+
if (occurrences.length >= CIRCUIT_BREAKER_MAX_IN_WINDOW) {
133133
this.trippedUntil.set(eventName, now + CIRCUIT_BREAKER_COOLDOWN_MS)
134134
this.guardedEventOccurrences.delete(eventName)
135135
return true
@@ -375,7 +375,8 @@ export class TelemetryService {
375375
])
376376
}
377377

378-
await Promise.all(this.clients.map((client) => client.shutdown()))
378+
// allSettled, not all: one client rejecting must not stop us from awaiting the others.
379+
await Promise.allSettled(this.clients.map((client) => client.shutdown()))
379380
}
380381

381382
private static _instance: TelemetryService | null = null

packages/telemetry/src/__tests__/TelemetryService.circuit-breaker.test.ts

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -35,21 +35,21 @@ describe("TelemetryService circuit breaker", () => {
3535
expect(mockClient.capture).toHaveBeenCalledTimes(49)
3636
})
3737

38-
it("trips after 50 CODE_INDEX_ERROR captures within the window and drops further ones", () => {
38+
it("trips at the 50th CODE_INDEX_ERROR capture within the window and drops further ones", () => {
3939
const service = new TelemetryService([mockClient])
4040

41-
for (let i = 0; i < 50; i++) {
41+
for (let i = 0; i < 49; i++) {
4242
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i })
4343
}
44-
expect(mockClient.capture).toHaveBeenCalledTimes(50)
44+
expect(mockClient.capture).toHaveBeenCalledTimes(49)
4545

46-
// 51st capture should be dropped - breaker has tripped.
47-
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i: 50 })
48-
expect(mockClient.capture).toHaveBeenCalledTimes(50)
46+
// 50th capture trips the breaker but is itself dropped.
47+
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i: 49 })
48+
expect(mockClient.capture).toHaveBeenCalledTimes(49)
4949

5050
// Keeps dropping while tripped.
51-
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i: 51 })
52-
expect(mockClient.capture).toHaveBeenCalledTimes(50)
51+
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i: 50 })
52+
expect(mockClient.capture).toHaveBeenCalledTimes(49)
5353
})
5454

5555
it("re-allows captures after the cooldown window elapses", () => {
@@ -58,18 +58,17 @@ describe("TelemetryService circuit breaker", () => {
5858
for (let i = 0; i < 50; i++) {
5959
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i })
6060
}
61-
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i: 50 })
62-
expect(mockClient.capture).toHaveBeenCalledTimes(50)
61+
expect(mockClient.capture).toHaveBeenCalledTimes(49)
6362

6463
// Just under 10 minutes - still tripped.
6564
vi.setSystemTime(10 * 60 * 1000 - 1)
66-
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i: 51 })
67-
expect(mockClient.capture).toHaveBeenCalledTimes(50)
65+
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i: 50 })
66+
expect(mockClient.capture).toHaveBeenCalledTimes(49)
6867

6968
// Cooldown elapsed - one more error gets through.
7069
vi.setSystemTime(10 * 60 * 1000)
71-
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i: 52 })
72-
expect(mockClient.capture).toHaveBeenCalledTimes(51)
70+
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i: 51 })
71+
expect(mockClient.capture).toHaveBeenCalledTimes(50)
7372
})
7473

7574
it("does not reset the guarded count when unrelated events are interleaved", () => {
@@ -85,17 +84,23 @@ describe("TelemetryService circuit breaker", () => {
8584
// 25 CODE_INDEX_ERROR so far - still under the threshold of 50.
8685
expect(mockClient.capture).toHaveBeenCalledTimes(25 + 25)
8786

88-
for (let i = 25; i < 50; i++) {
87+
for (let i = 25; i < 49; i++) {
8988
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i })
9089
service.captureEvent(TelemetryEventName.TASK_CREATED, { taskId: `task-${i}` })
9190
}
92-
// 50th CODE_INDEX_ERROR trips the breaker; TASK_CREATED events are never guarded.
93-
expect(mockClient.capture).toHaveBeenCalledTimes(50 + 50)
91+
// 49 CODE_INDEX_ERROR so far - still under the threshold of 50.
92+
expect(mockClient.capture).toHaveBeenCalledTimes(49 + 49)
93+
94+
// 50th CODE_INDEX_ERROR trips the breaker (and is itself dropped); TASK_CREATED
95+
// events are never guarded.
96+
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i: 49 })
97+
service.captureEvent(TelemetryEventName.TASK_CREATED, { taskId: "task-49" })
98+
expect(mockClient.capture).toHaveBeenCalledTimes(49 + 50)
9499

95100
// Further CODE_INDEX_ERROR captures are dropped even though unrelated events keep flowing.
96101
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i: 50 })
97102
service.captureEvent(TelemetryEventName.TASK_CREATED, { taskId: "task-50" })
98-
expect(mockClient.capture).toHaveBeenCalledTimes(50 + 51)
103+
expect(mockClient.capture).toHaveBeenCalledTimes(49 + 51)
99104
})
100105

101106
it("expires old occurrences outside the counting window instead of trapping the breaker open forever", () => {
@@ -121,4 +126,16 @@ describe("TelemetryService circuit breaker", () => {
121126

122127
expect(mockClient.capture).toHaveBeenCalledTimes(200)
123128
})
129+
130+
it("returns early on the not-ready (zero-client) branch without touching circuit breaker state", () => {
131+
// captureEvent's !this.isReady check runs before shouldDropForCircuitBreaker, so with
132+
// no clients registered, guarded-event bookkeeping should never be reached.
133+
const service = new TelemetryService([])
134+
135+
for (let i = 0; i < 50; i++) {
136+
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i })
137+
}
138+
139+
expect(mockClient.capture).not.toHaveBeenCalled()
140+
})
124141
})

packages/telemetry/src/__tests__/TelemetryService.shutdown.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,46 @@ describe("TelemetryService.shutdown draining", () => {
4444
expect(captureOrder).toEqual(["captured", "shutdown"])
4545
})
4646

47+
it("awaits in-flight captureException calls before shutting down clients", async () => {
48+
let resolveCapture!: () => void
49+
const capturePromise = new Promise<void>((resolve) => {
50+
resolveCapture = resolve
51+
})
52+
53+
const captureOrder: string[] = []
54+
55+
const mockClient: TelemetryClient = {
56+
setProvider: vi.fn(),
57+
capture: vi.fn(),
58+
captureException: vi.fn().mockImplementation(async () => {
59+
await capturePromise
60+
captureOrder.push("captured")
61+
}),
62+
updateTelemetryState: vi.fn(),
63+
isTelemetryEnabled: vi.fn().mockReturnValue(true),
64+
shutdown: vi.fn().mockImplementation(async () => {
65+
captureOrder.push("shutdown")
66+
}),
67+
}
68+
69+
const service = new TelemetryService([mockClient])
70+
71+
// Fire a captureException whose underlying client.captureException() promise hasn't
72+
// resolved yet -- follows the same trackPendingClientCall path as captureEvent.
73+
service.captureException(new Error("boom"))
74+
75+
const shutdownPromise = service.shutdown()
76+
77+
// Capture is still pending - shutdown must not have run yet.
78+
expect(captureOrder).toEqual([])
79+
80+
resolveCapture()
81+
await shutdownPromise
82+
83+
// The in-flight capture must complete before the client is shut down.
84+
expect(captureOrder).toEqual(["captured", "shutdown"])
85+
})
86+
4787
it("drains a call already queued before shutdown() started, even across multiple drain passes", async () => {
4888
// Regression test: shutdown() must not take a single Promise.all snapshot of
4989
// pendingClientCalls. A promise added to pendingClientCalls *after* Promise.all(set)
@@ -175,6 +215,29 @@ describe("TelemetryService.shutdown draining", () => {
175215
}
176216
})
177217

218+
it("calling shutdown() twice shuts down clients twice (no re-entrancy guard)", async () => {
219+
// Documents current behavior: shutdown() has no guard against being called more than
220+
// once. deactivate() only calls it once, so this isn't a bug to fix here, but a second
221+
// call re-running the (now-trivial, since pendingClientCalls is empty) drain loop and
222+
// calling client.shutdown() again should be an explicit, intentional outcome rather than
223+
// an untested one.
224+
const mockClient: TelemetryClient = {
225+
setProvider: vi.fn(),
226+
capture: vi.fn().mockResolvedValue(undefined),
227+
captureException: vi.fn(),
228+
updateTelemetryState: vi.fn(),
229+
isTelemetryEnabled: vi.fn().mockReturnValue(true),
230+
shutdown: vi.fn().mockResolvedValue(undefined),
231+
}
232+
233+
const service = new TelemetryService([mockClient])
234+
235+
await service.shutdown()
236+
await service.shutdown()
237+
238+
expect(mockClient.shutdown).toHaveBeenCalledTimes(2)
239+
})
240+
178241
it("does not let a rejected capture prevent shutdown from completing", async () => {
179242
const mockClient: TelemetryClient = {
180243
setProvider: vi.fn(),

0 commit comments

Comments
 (0)