Skip to content

feat(highlight.run): stop session replay when uploads cannot succeed - #700

Open
abelonogov-ld wants to merge 7 commits into
mainfrom
feat/web-session-replay-unrecoverable-errors
Open

feat(highlight.run): stop session replay when uploads cannot succeed#700
abelonogov-ld wants to merge 7 commits into
mainfrom
feat/web-session-replay-unrecoverable-errors

Conversation

@abelonogov-ld

Copy link
Copy Markdown
Contributor

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:

  • A refused environment (bad SDK key, replay disabled, quota) cost four doomed requests and roughly 6.5s of backoff on every page load.
  • The timeout stop left rrweb attached while _save no longer ran, so events accumulated for the rest of the page load, which is the opposite of what that stop was for.
  • Five failed sends in a row closed the worker's send gate without stopping anything, and every later payload was queued for good.

What changed

Error classification (src/client/utils/error-recoverability.ts) mirrors the Swift ErrorRecoverability: 400, 408, 429 and anything outside the 4xx range are recoverable, the rest of the 4xx range is not, an explicit extensions.retryable overrides the status either way, and a 200 carrying GraphQL errors is treated like a generic 4xx. BillingQuotaExceeded stays permanent regardless, which is the one case the old message-matching handled. Anything that is not a ClientError (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 initializeSession on 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 a StopReason, 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 RecordSDK and the legacy Highlight class. stop() keeps its existing behavior for manual stops; the teardown moved into a shared _stopRecorder().

Notes

  • An unrecoverable failure on any worker operation (identify, session properties, feedback) stops recording, not just payload pushes. The realistic causes are environment-wide, so they get the same verdict.
  • identify and friends called after a stop are now dropped instead of queued forever.
  • Unlike mobile, there is no disk-cached refusal and no eager probe: a page load is the natural fresh launch, and initializeSession already runs before capture starts.

Test plan

  • yarn turbo run test --filter highlight.run (lint, build, enforce-size, tests) green
  • 461 tests across 26 files, tsc --noEmit clean
  • New coverage: the classifier, the retry wrapper (no retry when unrecoverable, full budget when not), the worker end to end against a mocked graph SDK (403 stops once and drops later messages, 503 does not stop, exhausted budget stops, a new Initialize resumes), and RecordSDK releasing rrweb plus clearing its buffer for all three stop reasons

Made with Cursor

abelonogov-ld and others added 2 commits July 30, 2026 14:06
…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>
@abelonogov-ld
abelonogov-ld requested a review from a team as a code owner July 30, 2026 21:24
Comment thread sdk/highlight-run/src/client/workers/highlight-client-worker.ts
Comment thread sdk/highlight-run/src/client/workers/highlight-client-worker.ts Outdated
Comment thread sdk/highlight-run/src/client/workers/highlight-client-worker.ts
abelonogov-ld and others added 2 commits July 30, 2026 15:08
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>
Comment thread sdk/highlight-run/src/client/index.tsx Outdated
if (manual && this._recordStop) {
this._recordStop()
this._recordStop = undefined
if (manual) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is probably a lot that can be cleaned up in this implementation... but we don't have to do it now

Comment thread sdk/highlight-run/src/sdk/record.ts
Comment thread sdk/highlight-run/src/client/index.tsx
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>
Comment thread sdk/highlight-run/src/client/index.tsx
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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ 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)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 485eea4. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants