Skip to content

Commit 8c773e4

Browse files
committed
🐛 Preserve in-flight debugger probe entries on removal
1 parent efdbccf commit 8c773e4

5 files changed

Lines changed: 189 additions & 90 deletions

File tree

packages/debugger/src/domain/activeEntries.ts

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,3 @@ export interface ActiveEntry {
2121
}
2222
exception?: Error
2323
}
24-
25-
export const active = new Map<string, Array<ActiveEntry | null>>()
26-
27-
export function clearActiveEntries(probeId?: string): void {
28-
if (probeId !== undefined) {
29-
active.delete(probeId)
30-
} else {
31-
active.clear()
32-
}
33-
}

packages/debugger/src/domain/api.spec.ts

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -912,6 +912,35 @@ describe('api', () => {
912912
expect(getProbes('TestClass;nonSnapshotLifetime')).toBeUndefined()
913913
})
914914

915+
it('should finish in-flight recursive entries after the lifetime budget is exhausted', () => {
916+
initTransport({ maxNonSnapshotsPerProbeLifetime: 1 })
917+
918+
const probe: Probe = {
919+
id: 'recursive-lifetime-probe',
920+
version: 0,
921+
type: 'LOG_PROBE',
922+
where: { typeName: 'TestClass', methodName: 'recursiveLifetime' },
923+
template: 'Test',
924+
captureSnapshot: false,
925+
capture: {},
926+
sampling: { snapshotsPerSecond: Infinity },
927+
evaluateAt: 'ENTRY',
928+
}
929+
addProbe(probe)
930+
931+
const probes = getProbes('TestClass;recursiveLifetime')!
932+
onEntry(probes, {}, {})
933+
onEntry(probes, {}, {})
934+
935+
onReturn(probes, null, {}, {}, {})
936+
expect(getProbes('TestClass;recursiveLifetime')).toBeUndefined()
937+
938+
// The lifetime budget gates new entries, but already accepted in-flight
939+
// entries still drain even if another frame exhausts the budget first.
940+
onReturn(probes, null, {}, {}, {})
941+
expect(mockBatchAdd).toHaveBeenCalledTimes(2)
942+
})
943+
915944
it('should reset the lifetime budget when a new probe version is delivered', () => {
916945
initTransport({ maxSnapshotsPerProbeLifetime: 1 })
917946

@@ -933,9 +962,8 @@ describe('api', () => {
933962
onReturn(probes, null, {}, {}, {})
934963
expect(mockBatchAdd).toHaveBeenCalledTimes(1)
935964

936-
// A Remote Config delivery for an existing probe id replaces the old probe with
937-
// the new version. After re-add, the new version should have a fresh budget.
938-
removeProbe(probe.id)
965+
// The old probe has reached its lifetime budget and was auto-unregistered.
966+
// After re-add, the new version should have a fresh budget.
939967
addProbe({ ...probe, version: 1 })
940968

941969
probes = getProbes('TestClass;versionedLifetime')!
@@ -1043,16 +1071,29 @@ describe('api', () => {
10431071
}
10441072
}
10451073

1046-
it('should discard in-flight entries when a probe is removed', () => {
1074+
it('should drain in-flight entries through the removed probe instance', () => {
10471075
const probe = createProbe('cleanup-probe', 'cleanupTest')
10481076
addProbe(probe)
10491077

10501078
const probes = getProbes('TestClass;cleanupTest')!
10511079
onEntry(probes, {}, {})
10521080

10531081
removeProbe('cleanup-probe')
1082+
onReturn(probes, null, {}, {}, {})
1083+
1084+
expect(mockBatchAdd).toHaveBeenCalledTimes(1)
1085+
})
1086+
1087+
it('should isolate in-flight entries from a replacement probe with the same id', () => {
1088+
const probe = createProbe('cleanup-probe', 'cleanupTest')
10541089
addProbe(probe)
10551090

1091+
const probes = getProbes('TestClass;cleanupTest')!
1092+
onEntry(probes, {}, {})
1093+
1094+
removeProbe('cleanup-probe')
1095+
addProbe(createProbe('cleanup-probe', 'cleanupTest'))
1096+
10561097
const newProbes = getProbes('TestClass;cleanupTest')!
10571098
onReturn(newProbes, null, {}, {}, {})
10581099

@@ -1067,7 +1108,7 @@ describe('api', () => {
10671108
onEntry(probes, {}, {})
10681109

10691110
clearProbes()
1070-
addProbe(probe)
1111+
addProbe(createProbe('cleanup-probe', 'clearAllTest'))
10711112

10721113
const newProbes = getProbes('TestClass;clearAllTest')!
10731114
onReturn(newProbes, null, {}, {}, {})

packages/debugger/src/domain/api.ts

Lines changed: 11 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,12 @@ import type { InitializedProbe } from './probes'
88
import {
99
checkConditionErrorBudget,
1010
checkGlobalSnapshotBudget,
11-
hasProbeLifetimeBudgetRemaining,
12-
removeProbe,
11+
enforceProbeLifetimeBudget,
12+
recordProbeEventSent,
1313
resetProbeBudgetConfiguration,
1414
setProbeBudgetConfiguration,
1515
} from './probes'
1616
import type { ActiveEntry } from './activeEntries'
17-
import { active } from './activeEntries'
1817
import { captureStackTrace, parseStackTrace } from './stacktrace'
1918
import { evaluateProbeMessage } from './template'
2019
import { evaluateProbeCondition, isConditionEvaluationError } from './condition'
@@ -40,7 +39,6 @@ export function resetDebuggerTransport(): void {
4039
debuggerBatch = undefined
4140
debuggerConfig = undefined
4241
cachedDDtags = undefined
43-
active.clear()
4442
resetProbeBudgetConfiguration()
4543
}
4644

@@ -57,22 +55,16 @@ export function onEntry(probes: InitializedProbe[], self: any, args: Record<stri
5755

5856
// TODO: A lot of repeated work performed for each probe that could be shared between probes
5957
for (const probe of probes) {
60-
if (!hasProbeLifetimeBudgetRemaining(probe)) {
58+
if (!enforceProbeLifetimeBudget(probe)) {
6159
continue
6260
}
6361

64-
let stack = active.get(probe.id) // TODO: Should we use the functionId instead?
65-
if (!stack) {
66-
stack = []
67-
active.set(probe.id, stack)
68-
}
69-
7062
// Skip if sampling budget is exceeded
7163
if (
7264
start - probe.lastCaptureMs < probe.msBetweenSampling ||
7365
!checkGlobalSnapshotBudget(start, probe.captureSnapshot)
7466
) {
75-
stack.push(null)
67+
probe.activeEntries.push(null)
7668
continue
7769
}
7870

@@ -89,7 +81,7 @@ export function onEntry(probes: InitializedProbe[], self: any, args: Record<stri
8981
// Check condition - if it fails, don't evaluate or capture anything
9082
if (!evaluateProbeCondition(probe, context)) {
9183
// Still push to stack so onReturn/onThrow can pop it, but mark as skipped
92-
stack.push(null)
84+
probe.activeEntries.push(null)
9385
continue
9486
}
9587
} catch (error) {
@@ -101,7 +93,7 @@ export function onEntry(probes: InitializedProbe[], self: any, args: Record<stri
10193
})
10294
}
10395
// Still push to stack so onReturn/onThrow can pop it, but mark as skipped
104-
stack.push(null)
96+
probe.activeEntries.push(null)
10597
continue
10698
}
10799

@@ -120,12 +112,12 @@ export function onEntry(probes: InitializedProbe[], self: any, args: Record<stri
120112
},
121113
}
122114
if (captureCtx.timedOut) {
123-
stack.push(null)
115+
probe.activeEntries.push(null)
124116
continue
125117
}
126118
}
127119

128-
stack.push({
120+
probe.activeEntries.push({
129121
start,
130122
timestamp,
131123
message,
@@ -154,23 +146,10 @@ export function onReturn(
154146
): any {
155147
const end = performance.now()
156148
const captureCtx: CaptureContext = { deadline: performance.now() + SNAPSHOT_TIMEOUT_MS, timedOut: false }
157-
let exhaustedProbeIds: string[] | undefined
158149

159150
// TODO: A lot of repeated work performed for each probe that could be shared between probes
160151
for (const probe of probes) {
161-
if (!hasProbeLifetimeBudgetRemaining(probe)) {
162-
;(exhaustedProbeIds ??= []).push(probe.id)
163-
continue
164-
}
165-
166-
const stack = active.get(probe.id) // TODO: Should we use the functionId instead?
167-
if (!stack) {
168-
continue // TODO: This shouldn't be possible, do we need it? Should we warn?
169-
}
170-
const result = stack.pop()
171-
if (stack.length === 0) {
172-
active.delete(probe.id)
173-
}
152+
const result = probe.activeEntries.pop()
174153
if (!result) {
175154
continue
176155
}
@@ -226,12 +205,6 @@ export function onReturn(
226205
queueDebuggerSnapshot(probe, result)
227206
}
228207

229-
if (exhaustedProbeIds) {
230-
for (const id of exhaustedProbeIds) {
231-
removeProbe(id)
232-
}
233-
}
234-
235208
return value
236209
}
237210

@@ -246,23 +219,10 @@ export function onReturn(
246219
export function onThrow(probes: InitializedProbe[], error: Error, self: any, args: Record<string, any> = {}): void {
247220
const end = performance.now()
248221
const captureCtx: CaptureContext = { deadline: performance.now() + SNAPSHOT_TIMEOUT_MS, timedOut: false }
249-
let exhaustedProbeIds: string[] | undefined
250222

251223
// TODO: A lot of repeated work performed for each probe that could be shared between probes
252224
for (const probe of probes) {
253-
if (!hasProbeLifetimeBudgetRemaining(probe)) {
254-
;(exhaustedProbeIds ??= []).push(probe.id)
255-
continue
256-
}
257-
258-
const stack = active.get(probe.id) // TODO: Should we use the functionId instead?
259-
if (!stack) {
260-
continue // TODO: This shouldn't be possible, do we need it? Should we warn?
261-
}
262-
const result = stack.pop()
263-
if (stack.length === 0) {
264-
active.delete(probe.id)
265-
}
225+
const result = probe.activeEntries.pop()
266226
if (!result) {
267227
continue
268228
}
@@ -320,12 +280,6 @@ export function onThrow(probes: InitializedProbe[], error: Error, self: any, arg
320280

321281
queueDebuggerSnapshot(probe, result)
322282
}
323-
324-
if (exhaustedProbeIds) {
325-
for (const id of exhaustedProbeIds) {
326-
removeProbe(id)
327-
}
328-
}
329283
}
330284

331285
/**
@@ -383,7 +337,7 @@ function queueDebuggerSnapshot(probe: InitializedProbe, result: ActiveEntry): vo
383337
}
384338

385339
debuggerBatch.add(payload)
386-
probe.eventsSentInLifetime++
340+
recordProbeEventSent(probe)
387341
}
388342

389343
function getDebuggerDDtags(debuggerVersion: string): string {

packages/debugger/src/domain/probes.spec.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,79 @@ describe('probes', () => {
126126
// Should not throw when removing probe with static template (no clearCache method)
127127
expect(() => removeProbe('test-probe-1')).not.toThrow()
128128
})
129+
130+
it('should remove the exact initialized probe instance when passed a probe', () => {
131+
const probe: Probe = {
132+
id: 'test-probe-1',
133+
version: 0,
134+
type: 'LOG_PROBE',
135+
where: { typeName: 'TestClass', methodName: 'instanceTest' },
136+
template: 'Static message',
137+
captureSnapshot: false,
138+
capture: {},
139+
sampling: {},
140+
evaluateAt: 'ENTRY',
141+
}
142+
addProbe(probe)
143+
144+
const initializedProbe = getProbes('TestClass;instanceTest')![0]
145+
removeProbe(initializedProbe)
146+
147+
expect(getProbes('TestClass;instanceTest')).toBeUndefined()
148+
})
149+
150+
it('should not remove a replacement probe when passed a stale probe instance', () => {
151+
const staleProbe: Probe = {
152+
id: 'test-probe-1',
153+
version: 0,
154+
type: 'LOG_PROBE',
155+
where: { typeName: 'TestClass', methodName: 'replacementTest' },
156+
template: 'Stale message',
157+
captureSnapshot: false,
158+
capture: {},
159+
sampling: {},
160+
evaluateAt: 'ENTRY',
161+
}
162+
addProbe(staleProbe)
163+
164+
const staleInitializedProbe = getProbes('TestClass;replacementTest')![0]
165+
removeProbe('test-probe-1')
166+
addProbe({
167+
id: 'test-probe-1',
168+
version: 1,
169+
type: 'LOG_PROBE',
170+
where: { typeName: 'TestClass', methodName: 'replacementTest' },
171+
template: 'Replacement message',
172+
captureSnapshot: false,
173+
capture: {},
174+
sampling: {},
175+
evaluateAt: 'ENTRY',
176+
})
177+
178+
expect(() => removeProbe(staleInitializedProbe)).not.toThrow()
179+
expect(getProbes('TestClass;replacementTest')).toEqual([jasmine.objectContaining({ version: 1 })])
180+
})
181+
182+
it('should not throw when passed a stale probe instance that is no longer registered', () => {
183+
const probe: Probe = {
184+
id: 'test-probe-1',
185+
version: 0,
186+
type: 'LOG_PROBE',
187+
where: { typeName: 'TestClass', methodName: 'staleTest' },
188+
template: 'Static message',
189+
captureSnapshot: false,
190+
capture: {},
191+
sampling: {},
192+
evaluateAt: 'ENTRY',
193+
}
194+
addProbe(probe)
195+
196+
const initializedProbe = getProbes('TestClass;staleTest')![0]
197+
removeProbe('test-probe-1')
198+
199+
expect(() => removeProbe(initializedProbe)).not.toThrow()
200+
expect(getProbes('TestClass;staleTest')).toBeUndefined()
201+
})
129202
})
130203

131204
describe('initializeProbe', () => {

0 commit comments

Comments
 (0)