Skip to content

Commit 965f0c8

Browse files
authored
Extract leading capture logic from replay manager (#1360)
1 parent 1e47372 commit 965f0c8

File tree

4 files changed

+236
-172
lines changed

4 files changed

+236
-172
lines changed
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import logger from '../../logger.js';
2+
3+
/** @typedef {import('./recorder.js').BufferCursor} BufferCursor */
4+
5+
/**
6+
* Manages delayed "leading" (post-trigger) replay captures.
7+
*
8+
* Coordinates timing, buffer cursor stability, and payload preparation
9+
* for events that occur AFTER the trigger event.
10+
*/
11+
export default class LeadingCapture {
12+
_recorder;
13+
_tracing;
14+
_telemeter;
15+
_pending = new Map();
16+
_shouldSend;
17+
_onComplete;
18+
19+
/**
20+
* Creates a new LeadingCapture instance
21+
*
22+
* @param {Object} props - Configuration object
23+
* @param {Object} props.recorder - Recorder instance for capturing events
24+
* @param {Object} props.tracing - Tracing instance for span management
25+
* @param {Object} props.telemeter - Optional telemeter for telemetry spans
26+
* @param {Function} props.shouldSend - Function to check if replay can be sent
27+
* @param {Function} props.onComplete - Function to call when leading capture completes
28+
*/
29+
constructor({ recorder, tracing, telemeter, shouldSend, onComplete }) {
30+
this._recorder = recorder;
31+
this._tracing = tracing;
32+
this._telemeter = telemeter;
33+
this._shouldSend = shouldSend;
34+
this._onComplete = onComplete;
35+
}
36+
37+
/**
38+
* Schedules the capture of leading replay events after a delay.
39+
*
40+
* Captures a buffer cursor at the time of scheduling, which remains stable
41+
* even as the buffer continues to receive events. When the timer fires,
42+
* events after this cursor position are exported as the leading replay.
43+
*
44+
* @param {string} replayId - The replay ID
45+
* @param {string} occurrenceUuid - The occurrence UUID
46+
* @param {number} seconds - Number of seconds to wait before capturing
47+
*/
48+
schedule(replayId, occurrenceUuid, seconds) {
49+
const bufferCursor = this._recorder.bufferCursor();
50+
51+
const timerId = setTimeout(async () => {
52+
try {
53+
await this._export(replayId, occurrenceUuid, bufferCursor);
54+
await this.sendIfReady(replayId);
55+
} catch (error) {
56+
logger.error('Error during leading replay processing:', error);
57+
}
58+
}, seconds * 1000);
59+
60+
this._pending.set(replayId, {
61+
timerId,
62+
occurrenceUuid,
63+
bufferCursor,
64+
ready: false,
65+
});
66+
}
67+
68+
/**
69+
* Exports replay spans and adds the payload to pending context.
70+
*
71+
* Uses the captured buffer cursor to collect events that occurred after
72+
* the trigger. Exports both recording and telemetry spans, then generates
73+
* the payload and stores it for later sending.
74+
*
75+
* @param {string} replayId - The replay ID
76+
* @param {string} occurrenceUuid - The occurrence UUID
77+
* @param {BufferCursor} bufferCursor - Buffer cursor position
78+
* @private
79+
*/
80+
async _export(replayId, occurrenceUuid, bufferCursor) {
81+
const pendingContext = this._pending.get(replayId);
82+
83+
if (!pendingContext) {
84+
// Already cleaned up, possibly due to discard
85+
return;
86+
}
87+
88+
try {
89+
this._recorder.exportRecordingSpan(
90+
this._tracing,
91+
{
92+
'rollbar.replay.id': replayId,
93+
'rollbar.occurrence.uuid': occurrenceUuid,
94+
},
95+
bufferCursor,
96+
);
97+
} catch (error) {
98+
logger.error('Error exporting leading recording span:', error);
99+
this.discard(replayId);
100+
return;
101+
}
102+
103+
this._telemeter?.exportTelemetrySpan({
104+
'rollbar.replay.id': replayId,
105+
});
106+
107+
const payload = this._tracing.exporter.toPayload();
108+
this._pending.set(replayId, { ready: true, payload });
109+
}
110+
111+
/**
112+
* Sends the payload if it's ready and coordination allows it.
113+
*
114+
* Checks if the replay can be sent via the delegate function and only
115+
* sends if coordination requirements are met.
116+
*
117+
* @param {string} replayId - The replay ID
118+
* @returns {Promise<void>}
119+
*/
120+
async sendIfReady(replayId) {
121+
const pendingContext = this._pending.get(replayId);
122+
123+
if (
124+
!pendingContext?.ready ||
125+
!pendingContext?.payload ||
126+
!this._shouldSend(replayId)
127+
) {
128+
return;
129+
}
130+
131+
try {
132+
await this._tracing.exporter.post(pendingContext.payload, {
133+
'X-Rollbar-Replay-Id': replayId,
134+
});
135+
} catch (error) {
136+
logger.error('Failed to send leading replay:', error);
137+
}
138+
139+
this.discard(replayId);
140+
this._onComplete?.(replayId);
141+
}
142+
143+
/**
144+
* Cancels a scheduled capture and cleans up all state.
145+
*
146+
* Clears the timer if it hasn't fired yet, and removes all pending
147+
* context for the replay.
148+
*
149+
* @param {string} replayId - The replay ID to discard
150+
*/
151+
discard(replayId) {
152+
const pendingContext = this._pending.get(replayId);
153+
if (pendingContext?.timerId) {
154+
clearTimeout(pendingContext.timerId);
155+
}
156+
this._pending.delete(replayId);
157+
}
158+
}

0 commit comments

Comments
 (0)