feat(highlight.run): stop session replay when uploads cannot succeed - #700
feat(highlight.run): stop session replay when uploads cannot succeed#700abelonogov-ld wants to merge 7 commits into
Conversation
…sion data Every public graph failure was retried three times with backoff, permanent ones included, and recording only stopped when an upload timed out. A refused environment therefore cost four doomed requests per page load and left rrweb buffering events that nothing would ever upload. Failures are now classified the way the mobile SDKs classify them, by status code and by the server's `extensions.retryable` flag, so an unrecoverable one skips the retries and ends recording for this page load. Co-authored-by: Cursor <cursoragent@cursor.com>
The worker already asked the client to stop when uploads timed out, but that stop left rrweb attached while `_save` no longer ran, so events piled up for the rest of the page load, which is what the stop was meant to prevent. Five failed sends in a row were worse: the worker went quiet without stopping anything and queued every later payload for good. Both cases now end recording the same way. The worker drops what it buffered and stops accepting work until the client initializes again, and the client detaches rrweb and clears its buffer for any stop the worker asks for. Co-authored-by: Cursor <cursoragent@cursor.com>
Initialize resets the rest of the failure state so a fresh recording attempt can upload again, but the timeout count survived it. A session that initialized without a reset after a timeout stop therefore started at the limit, and its first slow upload stopped recording on the spot. Co-authored-by: Cursor <cursoragent@cursor.com>
Requests already in flight when recording stops keep failing and arrive after the stop, and a slow upload can time out and then be rejected as well, so the worker could ask the client to tear down several times over. Only the first request is served now. Each stop also emitted its reason as a timeline event, which the client received after it had already stopped recording. That event could never be captured, so it sat in `addCustomEvent`'s waiting path and polled every 500ms for the rest of the page load. The reason already travels on the stop message and both clients log it, so the event is gone. Co-authored-by: Cursor <cursoragent@cursor.com>
| if (manual && this._recordStop) { | ||
| this._recordStop() | ||
| this._recordStop = undefined | ||
| if (manual) { |
There was a problem hiding this comment.
there is probably a lot that can be cleaned up in this implementation... but we don't have to do it now
The legacy client stopped recording by way of `stopRecording`, which also shuts OTel down, so a worker stop took tracing and metrics with it. That was already wrong for upload timeouts and this branch made it reachable whenever the backend refuses replay data, disabling browser telemetry for the rest of the page load over a failure that says nothing about it. Worker stops now end capture through `_stopCapture`, which leaves the providers alone. `stopRecording` still shuts them down for a manual stop or a reset, where the whole SDK is going away or about to reinitialize. Co-authored-by: Cursor <cursoragent@cursor.com>
The page visibility listener is attached for the page lifetime on purpose, so after the worker gave up it still ran and, with disableBackgroundRecording, started a session the worker had already refused. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 485eea4. Configure here.
| ) | ||
| stopRecording(StopReason.RetriesExhausted) | ||
| } | ||
| } |
There was a problem hiding this comment.
Stale failures stop new sessions
Medium Severity
handleFailedMessage still stops recording for failures from requests that were already in flight when a prior stop happened. Initialize and Reset clear hasStoppedRecording without invalidating those older requests, so a late 403 or exhausted retry from the previous session can post a new Stop and tear down a session that just resumed.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 485eea4. Configure here.


Brings the browser SDK in line with the iOS and Android work on unrecoverable Session Replay errors, and closes the memory paths that surfaced along the way.
Why
Every public graph failure was retried three times with backoff, permanent ones included, and the only thing that ever stopped recording was an upload timeout. So:
_saveno longer ran, so events accumulated for the rest of the page load, which is the opposite of what that stop was for.What changed
Error classification (
src/client/utils/error-recoverability.ts) mirrors the SwiftErrorRecoverability: 400, 408, 429 and anything outside the 4xx range are recoverable, the rest of the 4xx range is not, an explicitextensions.retryableoverrides the status either way, and a200carrying GraphQL errors is treated like a generic 4xx.BillingQuotaExceededstays permanent regardless, which is the one case the old message-matching handled. Anything that is not aClientError(offline, DNS, aborted fetch) stays recoverable.The retry wrapper asks the classifier instead of comparing error message strings, so a refusal throws on the first attempt. This covers
initializeSessionon the main thread and the worker's uploads, since both share the wrapper.The worker turns an unrecoverable failure, and an exhausted retry budget, into the stop signal that previously only fired on timeouts.
stopRecording()now owns stopping: it drops the pending queue and batched metrics, posts the stop with aStopReason, records the reason as a track property, and stops accepting work until the client initializes again.The client detaches rrweb and clears its event buffer for any stop the worker asks for, in both
RecordSDKand the legacyHighlightclass.stop()keeps its existing behavior for manual stops; the teardown moved into a shared_stopRecorder().Notes
identifyand friends called after a stop are now dropped instead of queued forever.initializeSessionalready runs before capture starts.Test plan
yarn turbo run test --filter highlight.run(lint, build, enforce-size, tests) greentsc --noEmitcleanInitializeresumes), andRecordSDKreleasing rrweb plus clearing its buffer for all three stop reasonsMade with Cursor