-
Notifications
You must be signed in to change notification settings - Fork 3
feat(highlight.run): stop session replay when uploads cannot succeed #700
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
abelonogov-ld
wants to merge
7
commits into
main
Choose a base branch
from
feat/web-session-replay-unrecoverable-errors
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
63b0433
feat(highlight.run): stop session replay when the backend refuses ses…
abelonogov-ld 653ff38
fix(highlight.run): release the recorder whenever uploads stop
abelonogov-ld 512d816
fix(highlight.run): clear the upload timeout count on initialize
abelonogov-ld 60e3d5c
fix(highlight.run): stop recording once per session, and quietly
abelonogov-ld 1672f2a
Merge branch 'main' into feat/web-session-replay-unrecoverable-errors
abelonogov-ld a2d6f7b
fix(highlight.run): keep telemetry running when replay stops
abelonogov-ld 485eea4
fix(highlight.run): keep tab visibility from undoing a worker stop
abelonogov-ld File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
50 changes: 50 additions & 0 deletions
50
sdk/highlight-run/src/__tests__/client-worker-stop.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' | ||
| import { Highlight } from '../client' | ||
| import { MessageType, StopReason } from '../client/workers/types' | ||
|
|
||
| const shutdown = vi.fn() | ||
| vi.mock('../client/otel', async (importOriginal) => ({ | ||
| ...(await importOriginal<typeof import('../client/otel')>()), | ||
| shutdown: () => shutdown(), | ||
| })) | ||
|
|
||
| describe('Highlight worker stop handling', () => { | ||
| let highlight: Highlight | ||
|
|
||
| beforeEach(() => { | ||
| vi.useFakeTimers() | ||
| shutdown.mockClear() | ||
| highlight = new Highlight({ | ||
| organizationID: '1', | ||
| sessionSecureID: 'seed', | ||
| backendUrl: 'https://pub.observability.app.launchdarkly.com', | ||
| }) | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| vi.useRealTimers() | ||
| }) | ||
|
|
||
| it('keeps telemetry running when the worker stops replay', () => { | ||
| highlight._worker.onmessage({ | ||
| data: { | ||
| response: { | ||
| type: MessageType.Stop, | ||
| reason: StopReason.UnrecoverableError, | ||
| }, | ||
| }, | ||
| } as MessageEvent<any>) | ||
|
|
||
| expect(shutdown).not.toHaveBeenCalled() | ||
| expect(highlight.state).toBe('NotRecording') | ||
| // Keeps the page visibility listener, which outlives a stop, from restarting us. | ||
| expect(highlight.manualStopped).toBe(true) | ||
| }) | ||
|
|
||
| it('shuts telemetry down when the SDK itself stops', () => { | ||
| highlight.stopRecording(true) | ||
|
|
||
| expect(shutdown).toHaveBeenCalledTimes(1) | ||
| expect(highlight.state).toBe('NotRecording') | ||
| }) | ||
| }) |
108 changes: 108 additions & 0 deletions
108
sdk/highlight-run/src/__tests__/record-worker-stop.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' | ||
| import { RecordSDK } from '../sdk/record' | ||
| import { MessageType, StopReason } from '../client/workers/types' | ||
|
|
||
| const recordStop = vi.fn() | ||
| const recordSpy = vi.fn(() => recordStop) | ||
| vi.mock('@highlight-run/rrweb', () => ({ | ||
| addCustomEvent: vi.fn(), | ||
| record: () => recordSpy(), | ||
| })) | ||
|
|
||
| vi.mock('../client/graph/generated/operations', () => ({ | ||
| getSdk: () => ({ | ||
| initializeSession: vi.fn().mockResolvedValue({ | ||
| initializeSession: { | ||
| secure_id: 'test-session', | ||
| project_id: '1', | ||
| }, | ||
| }), | ||
| }), | ||
| })) | ||
|
|
||
| vi.mock('../client/workers/highlight-client-worker?worker&inline', () => ({ | ||
| default: class MockWorker { | ||
| onmessage: any | ||
| postMessage() {} | ||
| }, | ||
| })) | ||
|
|
||
| async function startedSDK( | ||
| options: Partial<ConstructorParameters<typeof RecordSDK>[0]> = {}, | ||
| ): Promise<RecordSDK> { | ||
| const sdk = new RecordSDK({ | ||
| organizationID: '1', | ||
| sessionSecureID: 'seed', | ||
| ...options, | ||
| }) | ||
| await sdk.start() | ||
| return sdk | ||
| } | ||
|
|
||
| /** Delivers a worker response to the SDK the way the real worker would. */ | ||
| function postToSDK(sdk: RecordSDK, response: unknown) { | ||
| sdk._worker.onmessage({ data: { response } } as MessageEvent<any>) | ||
| } | ||
|
|
||
| describe('RecordSDK worker stop handling', () => { | ||
| beforeEach(() => { | ||
| vi.useFakeTimers() | ||
| recordStop.mockClear() | ||
| recordSpy.mockClear() | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| vi.useRealTimers() | ||
| }) | ||
|
|
||
| it.each([ | ||
| StopReason.PushPayloadTimeout, | ||
| StopReason.UnrecoverableError, | ||
| StopReason.RetriesExhausted, | ||
| ])( | ||
| 'releases the recorder when the worker stops us (%s)', | ||
| async (reason) => { | ||
| const sdk = await startedSDK() | ||
| sdk.events.push({ type: 3, data: {}, timestamp: 1 } as any) | ||
|
|
||
| postToSDK(sdk, { type: MessageType.Stop, reason }) | ||
|
|
||
| expect(recordStop).toHaveBeenCalledTimes(1) | ||
| expect(sdk.events).toEqual([]) | ||
| expect(sdk.getRecordingState()).toBe('NotRecording') | ||
| }, | ||
| ) | ||
|
|
||
| // The page visibility listener stays attached once recording has ever started, so it is the | ||
| // one thing that can restart us after the worker has given up. | ||
| it('stays stopped when the tab becomes visible again', async () => { | ||
| const sdk = await startedSDK({ disableBackgroundRecording: true }) | ||
|
|
||
| postToSDK(sdk, { | ||
| type: MessageType.Stop, | ||
| reason: StopReason.UnrecoverableError, | ||
| }) | ||
| recordSpy.mockClear() | ||
| sdk._lastVisibilityChangeTime = 0 // clear the debounce frozen fake timers impose | ||
| await sdk._visibilityHandler(false) | ||
|
|
||
| expect(recordSpy).not.toHaveBeenCalled() | ||
| expect(sdk.getRecordingState()).toBe('NotRecording') | ||
| }) | ||
|
|
||
| it('keeps recording while the worker only reports uploads', async () => { | ||
| const sdk = await startedSDK() | ||
| sdk.events.push({ type: 3, data: {}, timestamp: 1 } as any) | ||
|
|
||
| postToSDK(sdk, { | ||
| type: MessageType.AsyncEvents, | ||
| id: 1, | ||
| eventsSize: 100, | ||
| compressedSize: 50, | ||
| }) | ||
|
|
||
| expect(recordStop).not.toHaveBeenCalled() | ||
| expect(sdk.events).toHaveLength(1) | ||
| expect(sdk.getRecordingState()).toBe('Recording') | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
113 changes: 113 additions & 0 deletions
113
sdk/highlight-run/src/client/utils/error-recoverability.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| import { describe, expect, it } from 'vitest' | ||
| import { ClientError } from 'graphql-request' | ||
| import { PublicGraphError } from '../graph/generated/schemas' | ||
| import { | ||
| isErrorRecoverable, | ||
| isHttpErrorRecoverable, | ||
| } from './error-recoverability' | ||
|
|
||
| type TestGraphQLError = { | ||
| message: string | ||
| extensions?: { retryable?: boolean } | ||
| } | ||
|
|
||
| function clientError(status: number, errors?: TestGraphQLError[]): ClientError { | ||
| return new ClientError( | ||
| { status, errors } as unknown as ClientError['response'], | ||
| { query: 'mutation pushPayload { pushPayload }' }, | ||
| ) | ||
| } | ||
|
|
||
| describe('isHttpErrorRecoverable', () => { | ||
| it('treats transient 4xx statuses as recoverable', () => { | ||
| expect(isHttpErrorRecoverable(400)).toBe(true) | ||
| expect(isHttpErrorRecoverable(408)).toBe(true) | ||
| expect(isHttpErrorRecoverable(429)).toBe(true) | ||
| }) | ||
|
|
||
| it('treats every other 4xx status as unrecoverable', () => { | ||
| expect(isHttpErrorRecoverable(401)).toBe(false) | ||
| expect(isHttpErrorRecoverable(402)).toBe(false) | ||
| expect(isHttpErrorRecoverable(403)).toBe(false) | ||
| expect(isHttpErrorRecoverable(404)).toBe(false) | ||
| expect(isHttpErrorRecoverable(422)).toBe(false) | ||
| }) | ||
|
|
||
| it('treats server errors and non-error statuses as recoverable', () => { | ||
| expect(isHttpErrorRecoverable(500)).toBe(true) | ||
| expect(isHttpErrorRecoverable(502)).toBe(true) | ||
| expect(isHttpErrorRecoverable(503)).toBe(true) | ||
| expect(isHttpErrorRecoverable(504)).toBe(true) | ||
| expect(isHttpErrorRecoverable(200)).toBe(true) | ||
| expect(isHttpErrorRecoverable(0)).toBe(true) | ||
| }) | ||
| }) | ||
|
|
||
| describe('isErrorRecoverable', () => { | ||
| it('treats errors of unknown origin as recoverable', () => { | ||
| expect(isErrorRecoverable(new Error('Failed to fetch'))).toBe(true) | ||
| expect(isErrorRecoverable(new TypeError('NetworkError'))).toBe(true) | ||
| expect(isErrorRecoverable(undefined)).toBe(true) | ||
| }) | ||
|
|
||
| it('classifies a rejected request by its status', () => { | ||
| expect(isErrorRecoverable(clientError(403))).toBe(false) | ||
| expect(isErrorRecoverable(clientError(404))).toBe(false) | ||
| expect(isErrorRecoverable(clientError(429))).toBe(true) | ||
| expect(isErrorRecoverable(clientError(503))).toBe(true) | ||
| }) | ||
|
|
||
| it('treats a 200 carrying GraphQL errors as unrecoverable', () => { | ||
| expect( | ||
| isErrorRecoverable(clientError(200, [{ message: 'not allowed' }])), | ||
| ).toBe(false) | ||
| }) | ||
|
|
||
| it('honors an explicit retryable flag over the status', () => { | ||
| expect( | ||
| isErrorRecoverable( | ||
| clientError(403, [ | ||
| { message: 'try again', extensions: { retryable: true } }, | ||
| ]), | ||
| ), | ||
| ).toBe(true) | ||
| expect( | ||
| isErrorRecoverable( | ||
| clientError(429, [ | ||
| { message: 'give up', extensions: { retryable: false } }, | ||
| ]), | ||
| ), | ||
| ).toBe(false) | ||
| expect( | ||
| isErrorRecoverable( | ||
| clientError(200, [ | ||
| { message: 'try again', extensions: { retryable: true } }, | ||
| ]), | ||
| ), | ||
| ).toBe(true) | ||
| }) | ||
|
|
||
| it('takes the most pessimistic retryable flag when errors disagree', () => { | ||
| expect( | ||
| isErrorRecoverable( | ||
| clientError(200, [ | ||
| { message: 'try again', extensions: { retryable: true } }, | ||
| { message: 'give up', extensions: { retryable: false } }, | ||
| ]), | ||
| ), | ||
| ).toBe(false) | ||
| }) | ||
|
|
||
| it('ignores a retryable flag on a permanent public graph error', () => { | ||
| expect( | ||
| isErrorRecoverable( | ||
| clientError(200, [ | ||
| { | ||
| message: PublicGraphError.BillingQuotaExceeded, | ||
| extensions: { retryable: true }, | ||
| }, | ||
| ]), | ||
| ), | ||
| ).toBe(false) | ||
| }) | ||
| }) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.