diff --git a/packages/replay/jest.setup.ts b/packages/replay/jest.setup.ts index db17db23f761..26f9ea346a0d 100644 --- a/packages/replay/jest.setup.ts +++ b/packages/replay/jest.setup.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { getCurrentHub } from '@sentry/core'; -import type { ReplayRecordingData,Transport } from '@sentry/types'; +import type { ReplayRecordingData, Transport } from '@sentry/types'; import type { ReplayContainer, Session } from './src/types'; @@ -34,7 +34,7 @@ type SentReplayExpected = { replayEventPayload?: ReplayEventPayload; recordingHeader?: RecordingHeader; recordingPayloadHeader?: RecordingPayloadHeader; - events?: ReplayRecordingData; + recordingData?: ReplayRecordingData; }; // eslint-disable-next-line @typescript-eslint/explicit-function-return-type @@ -79,7 +79,7 @@ function checkCallForSentReplay( const [[replayEventHeader, replayEventPayload], [recordingHeader, recordingPayload] = []] = envelopeItems; // @ts-ignore recordingPayload is always a string in our tests - const [recordingPayloadHeader, events] = recordingPayload?.split('\n') || []; + const [recordingPayloadHeader, recordingData] = recordingPayload?.split('\n') || []; const actualObj: Required = { // @ts-ignore Custom envelope @@ -91,7 +91,7 @@ function checkCallForSentReplay( // @ts-ignore Custom envelope recordingHeader: recordingHeader, recordingPayloadHeader: recordingPayloadHeader && JSON.parse(recordingPayloadHeader), - events, + recordingData, }; const isObjectContaining = expected && 'sample' in expected && 'inverse' in expected; diff --git a/packages/replay/src/constants.ts b/packages/replay/src/constants.ts index b0176293e13c..187d729cb6f5 100644 --- a/packages/replay/src/constants.ts +++ b/packages/replay/src/constants.ts @@ -33,3 +33,6 @@ export const MASK_ALL_TEXT_SELECTOR = 'body *:not(style), body *:not(script)'; export const DEFAULT_FLUSH_MIN_DELAY = 5_000; export const DEFAULT_FLUSH_MAX_DELAY = 15_000; export const INITIAL_FLUSH_DELAY = 5_000; + +export const RETRY_BASE_INTERVAL = 5000; +export const RETRY_MAX_COUNT = 3; diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts index 06a8ae58f64a..241d330dd412 100644 --- a/packages/replay/src/replay.ts +++ b/packages/replay/src/replay.ts @@ -1,18 +1,11 @@ /* eslint-disable max-lines */ // TODO: We might want to split this file up -import { addGlobalEventProcessor, captureException, getCurrentHub, setContext } from '@sentry/core'; -import type { Breadcrumb, ReplayEvent, ReplayRecordingMode, TransportMakeRequestResponse } from '@sentry/types'; +import { addGlobalEventProcessor, captureException, getCurrentHub } from '@sentry/core'; +import type { Breadcrumb, ReplayRecordingMode } from '@sentry/types'; import type { RateLimits } from '@sentry/utils'; -import { addInstrumentationHandler, disabledUntil, isRateLimited, logger, updateRateLimits } from '@sentry/utils'; +import { addInstrumentationHandler, disabledUntil, logger } from '@sentry/utils'; import { EventType, record } from 'rrweb'; -import { - MAX_SESSION_LIFE, - REPLAY_EVENT_NAME, - SESSION_IDLE_DURATION, - UNABLE_TO_SEND_REPLAY, - VISIBILITY_CHANGE_TIMEOUT, - WINDOW, -} from './constants'; +import { MAX_SESSION_LIFE, SESSION_IDLE_DURATION, VISIBILITY_CHANGE_TIMEOUT, WINDOW } from './constants'; import { breadcrumbHandler } from './coreHandlers/breadcrumbHandler'; import { handleFetchSpanListener } from './coreHandlers/handleFetch'; import { handleGlobalEventListener } from './coreHandlers/handleGlobalEvent'; @@ -34,7 +27,6 @@ import type { RecordingOptions, ReplayContainer as ReplayContainerInterface, ReplayPluginOptions, - SendReplay, Session, } from './types'; import { addEvent } from './util/addEvent'; @@ -42,20 +34,12 @@ import { addMemoryEntry } from './util/addMemoryEntry'; import { createBreadcrumb } from './util/createBreadcrumb'; import { createPerformanceEntries } from './util/createPerformanceEntries'; import { createPerformanceSpans } from './util/createPerformanceSpans'; -import { createRecordingData } from './util/createRecordingData'; -import { createReplayEnvelope } from './util/createReplayEnvelope'; import { debounce } from './util/debounce'; import { isExpired } from './util/isExpired'; import { isSessionExpired } from './util/isSessionExpired'; import { overwriteRecordDroppedEvent, restoreRecordDroppedEvent } from './util/monkeyPatchRecordDroppedEvent'; -import { prepareReplayEvent } from './util/prepareReplayEvent'; - -/** - * Returns true to return control to calling function, otherwise continue with normal batching - */ - -const BASE_RETRY_INTERVAL = 5000; -const MAX_RETRY_COUNT = 3; +import { sendReplay } from './util/sendReplay'; +import { RateLimitError } from './util/sendReplayRequest'; /** * The main replay container class, which holds all the state and methods for recording and sending replays. @@ -86,9 +70,6 @@ export class ReplayContainer implements ReplayContainerInterface { private _performanceObserver: PerformanceObserver | null = null; - private _retryCount: number = 0; - private _retryInterval: number = BASE_RETRY_INTERVAL; - private _debouncedFlush: ReturnType; private _flushLock: Promise | null = null; @@ -129,11 +110,6 @@ export class ReplayContainer implements ReplayContainerInterface { initialUrl: '', }; - /** - * A RateLimits object holding the rate-limit durations in case a sent replay event was rate-limited. - */ - private _rateLimits: RateLimits = {}; - public constructor({ options, recordingOptions, @@ -837,14 +813,20 @@ export class ReplayContainer implements ReplayContainerInterface { const segmentId = this.session.segmentId++; this._maybeSaveSession(); - await this._sendReplay({ + await sendReplay({ replayId, - events: recordingData, + recordingData, segmentId, includeReplayStartTimestamp: segmentId === 0, eventContext, + session: this.session, + options: this.getOptions(), + timestamp: new Date().getTime(), }); } catch (err) { + if (err instanceof RateLimitError) { + this._handleRateLimit(err.rateLimits); + } this._handleException(err); } } @@ -897,185 +879,6 @@ export class ReplayContainer implements ReplayContainerInterface { } }; - /** - * Send replay attachment using `fetch()` - */ - private async _sendReplayRequest({ - events, - replayId, - segmentId: segment_id, - includeReplayStartTimestamp, - eventContext, - timestamp = new Date().getTime(), - }: SendReplay): Promise { - const recordingData = createRecordingData({ - events, - headers: { - segment_id, - }, - }); - - const { urls, errorIds, traceIds, initialTimestamp } = eventContext; - - const hub = getCurrentHub(); - const client = hub.getClient(); - const scope = hub.getScope(); - const transport = client && client.getTransport(); - const dsn = client?.getDsn(); - - if (!client || !scope || !transport || !dsn || !this.session || !this.session.sampled) { - return; - } - - const baseEvent: ReplayEvent = { - // @ts-ignore private api - type: REPLAY_EVENT_NAME, - ...(includeReplayStartTimestamp ? { replay_start_timestamp: initialTimestamp / 1000 } : {}), - timestamp: timestamp / 1000, - error_ids: errorIds, - trace_ids: traceIds, - urls, - replay_id: replayId, - segment_id, - replay_type: this.session.sampled, - }; - - const replayEvent = await prepareReplayEvent({ scope, client, replayId, event: baseEvent }); - - if (!replayEvent) { - // Taken from baseclient's `_processEvent` method, where this is handled for errors/transactions - client.recordDroppedEvent('event_processor', 'replay_event', baseEvent); - __DEBUG_BUILD__ && logger.log('An event processor returned `null`, will not send event.'); - return; - } - - replayEvent.tags = { - ...replayEvent.tags, - sessionSampleRate: this._options.sessionSampleRate, - errorSampleRate: this._options.errorSampleRate, - }; - - /* - For reference, the fully built event looks something like this: - { - "type": "replay_event", - "timestamp": 1670837008.634, - "error_ids": [ - "errorId" - ], - "trace_ids": [ - "traceId" - ], - "urls": [ - "https://example.com" - ], - "replay_id": "eventId", - "segment_id": 3, - "replay_type": "error", - "platform": "javascript", - "event_id": "eventId", - "environment": "production", - "sdk": { - "integrations": [ - "BrowserTracing", - "Replay" - ], - "name": "sentry.javascript.browser", - "version": "7.25.0" - }, - "sdkProcessingMetadata": {}, - "tags": { - "sessionSampleRate": 1, - "errorSampleRate": 0, - } - } - */ - - const envelope = createReplayEnvelope(replayEvent, recordingData, dsn, client.getOptions().tunnel); - - try { - const response = await transport.send(envelope); - // TODO (v8): we can remove this guard once transport.send's type signature doesn't include void anymore - if (response) { - this._rateLimits = updateRateLimits(this._rateLimits, response); - if (isRateLimited(this._rateLimits, 'replay')) { - this._handleRateLimit(); - } - } - return response; - } catch { - throw new Error(UNABLE_TO_SEND_REPLAY); - } - } - - /** - * Reset the counter of retries for sending replays. - */ - private _resetRetries(): void { - this._retryCount = 0; - this._retryInterval = BASE_RETRY_INTERVAL; - } - - /** - * Finalize and send the current replay event to Sentry - */ - private async _sendReplay({ - replayId, - events, - segmentId, - includeReplayStartTimestamp, - eventContext, - }: SendReplay): Promise { - // short circuit if there's no events to upload (this shouldn't happen as _runFlush makes this check) - if (!events.length) { - return; - } - - try { - await this._sendReplayRequest({ - events, - replayId, - segmentId, - includeReplayStartTimestamp, - eventContext, - }); - this._resetRetries(); - return true; - } catch (err) { - // Capture error for every failed replay - setContext('Replays', { - _retryCount: this._retryCount, - }); - this._handleException(err); - - // If an error happened here, it's likely that uploading the attachment - // failed, we'll can retry with the same events payload - if (this._retryCount >= MAX_RETRY_COUNT) { - throw new Error(`${UNABLE_TO_SEND_REPLAY} - max retries exceeded`); - } - - // will retry in intervals of 5, 10, 30 - this._retryInterval = ++this._retryCount * this._retryInterval; - - return await new Promise((resolve, reject) => { - setTimeout(async () => { - try { - await this._sendReplay({ - replayId, - events, - segmentId, - includeReplayStartTimestamp, - eventContext, - }); - resolve(true); - } catch (err) { - reject(err); - } - }, this._retryInterval); - }); - } - } - /** Save the session, if it is sticky */ private _maybeSaveSession(): void { if (this.session && this._options.stickySession) { @@ -1086,14 +889,14 @@ export class ReplayContainer implements ReplayContainerInterface { /** * Pauses the replay and resumes it after the rate-limit duration is over. */ - private _handleRateLimit(): void { + private _handleRateLimit(rateLimits: RateLimits): void { // in case recording is already paused, we don't need to do anything, as we might have already paused because of a // rate limit if (this.isPaused()) { return; } - const rateLimitEnd = disabledUntil(this._rateLimits, 'replay'); + const rateLimitEnd = disabledUntil(rateLimits, 'replay'); const rateLimitDuration = rateLimitEnd - Date.now(); if (rateLimitDuration > 0) { diff --git a/packages/replay/src/types.ts b/packages/replay/src/types.ts index 21a528575380..5d97766d6d00 100644 --- a/packages/replay/src/types.ts +++ b/packages/replay/src/types.ts @@ -5,17 +5,17 @@ import type { eventWithTime, recordOptions } from './types/rrweb'; export type RecordingEvent = eventWithTime; export type RecordingOptions = recordOptions; -export type RecordedEvents = Uint8Array | string; - export type AllPerformanceEntry = PerformancePaintTiming | PerformanceResourceTiming | PerformanceNavigationTiming; -export interface SendReplay { - events: RecordedEvents; +export interface SendReplayData { + recordingData: ReplayRecordingData; replayId: string; segmentId: number; includeReplayStartTimestamp: boolean; eventContext: PopEventContext; - timestamp?: number; + timestamp: number; + session: Session; + options: ReplayPluginOptions; } export type InstrumentationTypeBreadcrumb = 'dom' | 'scope'; diff --git a/packages/replay/src/util/createRecordingData.ts b/packages/replay/src/util/prepareRecordingData.ts similarity index 59% rename from packages/replay/src/util/createRecordingData.ts rename to packages/replay/src/util/prepareRecordingData.ts index a0d9835c1094..2ee3391d6f42 100644 --- a/packages/replay/src/util/createRecordingData.ts +++ b/packages/replay/src/util/prepareRecordingData.ts @@ -1,15 +1,13 @@ import type { ReplayRecordingData } from '@sentry/types'; -import type { RecordedEvents } from '../types'; - /** - * Create the recording data ready to be sent. + * Prepare the recording data ready to be sent. */ -export function createRecordingData({ - events, +export function prepareRecordingData({ + recordingData, headers, }: { - events: RecordedEvents; + recordingData: ReplayRecordingData; headers: Record; }): ReplayRecordingData { let payloadWithSequence; @@ -18,16 +16,16 @@ export function createRecordingData({ const replayHeaders = `${JSON.stringify(headers)} `; - if (typeof events === 'string') { - payloadWithSequence = `${replayHeaders}${events}`; + if (typeof recordingData === 'string') { + payloadWithSequence = `${replayHeaders}${recordingData}`; } else { const enc = new TextEncoder(); // XXX: newline is needed to separate sequence id from events const sequence = enc.encode(replayHeaders); // Merge the two Uint8Arrays - payloadWithSequence = new Uint8Array(sequence.length + events.length); + payloadWithSequence = new Uint8Array(sequence.length + recordingData.length); payloadWithSequence.set(sequence); - payloadWithSequence.set(events, sequence.length); + payloadWithSequence.set(recordingData, sequence.length); } return payloadWithSequence; diff --git a/packages/replay/src/util/sendReplay.ts b/packages/replay/src/util/sendReplay.ts new file mode 100644 index 000000000000..aa2e4649dda7 --- /dev/null +++ b/packages/replay/src/util/sendReplay.ts @@ -0,0 +1,61 @@ +import { captureException, setContext } from '@sentry/core'; + +import { RETRY_BASE_INTERVAL, RETRY_MAX_COUNT, UNABLE_TO_SEND_REPLAY } from '../constants'; +import type { SendReplayData } from '../types'; +import { RateLimitError, sendReplayRequest } from './sendReplayRequest'; + +/** + * Finalize and send the current replay event to Sentry + */ +export async function sendReplay( + replayData: SendReplayData, + retryConfig = { + count: 0, + interval: RETRY_BASE_INTERVAL, + }, +): Promise { + const { recordingData, options } = replayData; + + // short circuit if there's no events to upload (this shouldn't happen as _runFlush makes this check) + if (!recordingData.length) { + return; + } + + try { + await sendReplayRequest(replayData); + return true; + } catch (err) { + if (err instanceof RateLimitError) { + throw err; + } + + // Capture error for every failed replay + setContext('Replays', { + _retryCount: retryConfig.count, + }); + + if (__DEBUG_BUILD__ && options._experiments && options._experiments.captureExceptions) { + captureException(err); + } + + // If an error happened here, it's likely that uploading the attachment + // failed, we'll can retry with the same events payload + if (retryConfig.count >= RETRY_MAX_COUNT) { + throw new Error(`${UNABLE_TO_SEND_REPLAY} - max retries exceeded`); + } + + // will retry in intervals of 5, 10, 30 + retryConfig.interval *= ++retryConfig.count; + + return await new Promise((resolve, reject) => { + setTimeout(async () => { + try { + await sendReplay(replayData, retryConfig); + resolve(true); + } catch (err) { + reject(err); + } + }, retryConfig.interval); + }); + } +} diff --git a/packages/replay/src/util/sendReplayRequest.ts b/packages/replay/src/util/sendReplayRequest.ts new file mode 100644 index 000000000000..23df14ffcdda --- /dev/null +++ b/packages/replay/src/util/sendReplayRequest.ts @@ -0,0 +1,138 @@ +import { getCurrentHub } from '@sentry/core'; +import type { ReplayEvent, TransportMakeRequestResponse } from '@sentry/types'; +import type { RateLimits } from '@sentry/utils'; +import { isRateLimited, logger, updateRateLimits } from '@sentry/utils'; + +import { REPLAY_EVENT_NAME, UNABLE_TO_SEND_REPLAY } from '../constants'; +import type { SendReplayData } from '../types'; +import { createReplayEnvelope } from './createReplayEnvelope'; +import { prepareRecordingData } from './prepareRecordingData'; +import { prepareReplayEvent } from './prepareReplayEvent'; + +/** + * Send replay attachment using `fetch()` + */ +export async function sendReplayRequest({ + recordingData, + replayId, + segmentId: segment_id, + includeReplayStartTimestamp, + eventContext, + timestamp, + session, + options, +}: SendReplayData): Promise { + const preparedRecordingData = prepareRecordingData({ + recordingData, + headers: { + segment_id, + }, + }); + + const { urls, errorIds, traceIds, initialTimestamp } = eventContext; + + const hub = getCurrentHub(); + const client = hub.getClient(); + const scope = hub.getScope(); + const transport = client && client.getTransport(); + const dsn = client?.getDsn(); + + if (!client || !scope || !transport || !dsn || !session.sampled) { + return; + } + + const baseEvent: ReplayEvent = { + // @ts-ignore private api + type: REPLAY_EVENT_NAME, + ...(includeReplayStartTimestamp ? { replay_start_timestamp: initialTimestamp / 1000 } : {}), + timestamp: timestamp / 1000, + error_ids: errorIds, + trace_ids: traceIds, + urls, + replay_id: replayId, + segment_id, + replay_type: session.sampled, + }; + + const replayEvent = await prepareReplayEvent({ scope, client, replayId, event: baseEvent }); + + if (!replayEvent) { + // Taken from baseclient's `_processEvent` method, where this is handled for errors/transactions + client.recordDroppedEvent('event_processor', 'replay_event', baseEvent); + __DEBUG_BUILD__ && logger.log('An event processor returned `null`, will not send event.'); + return; + } + + replayEvent.tags = { + ...replayEvent.tags, + sessionSampleRate: options.sessionSampleRate, + errorSampleRate: options.errorSampleRate, + }; + + /* + For reference, the fully built event looks something like this: + { + "type": "replay_event", + "timestamp": 1670837008.634, + "error_ids": [ + "errorId" + ], + "trace_ids": [ + "traceId" + ], + "urls": [ + "https://example.com" + ], + "replay_id": "eventId", + "segment_id": 3, + "replay_type": "error", + "platform": "javascript", + "event_id": "eventId", + "environment": "production", + "sdk": { + "integrations": [ + "BrowserTracing", + "Replay" + ], + "name": "sentry.javascript.browser", + "version": "7.25.0" + }, + "sdkProcessingMetadata": {}, + "tags": { + "sessionSampleRate": 1, + "errorSampleRate": 0, + } + } + */ + + const envelope = createReplayEnvelope(replayEvent, preparedRecordingData, dsn, client.getOptions().tunnel); + + let response: void | TransportMakeRequestResponse; + + try { + response = await transport.send(envelope); + } catch { + throw new Error(UNABLE_TO_SEND_REPLAY); + } + + // TODO (v8): we can remove this guard once transport.send's type signature doesn't include void anymore + if (response) { + const rateLimits = updateRateLimits({}, response); + if (isRateLimited(rateLimits, 'replay')) { + throw new RateLimitError(rateLimits); + } + } + return response; +} + +/** + * This error indicates that we hit a rate limit API error. + */ +export class RateLimitError extends Error { + public rateLimits: RateLimits; + + public constructor(rateLimits: RateLimits) { + super('Rate limit hit'); + this.rateLimits = rateLimits; + } +} diff --git a/packages/replay/test/integration/errorSampleRate.test.ts b/packages/replay/test/integration/errorSampleRate.test.ts index 2730560093a4..2b52e6c7d96c 100644 --- a/packages/replay/test/integration/errorSampleRate.test.ts +++ b/packages/replay/test/integration/errorSampleRate.test.ts @@ -68,7 +68,7 @@ describe('Integration | errorSampleRate', () => { sessionSampleRate: 0, }), }), - events: JSON.stringify([ + recordingData: JSON.stringify([ { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, TEST_EVENT, { @@ -98,7 +98,7 @@ describe('Integration | errorSampleRate', () => { sessionSampleRate: 0, }), }), - events: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + 5020, type: 2 }]), + recordingData: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + 5020, type: 2 }]), }); jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); @@ -106,7 +106,7 @@ describe('Integration | errorSampleRate', () => { // New checkout when we call `startRecording` again after uploading segment // after an error occurs expect(replay).toHaveLastSentReplay({ - events: JSON.stringify([ + recordingData: JSON.stringify([ { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY + 20, @@ -124,7 +124,7 @@ describe('Integration | errorSampleRate', () => { await new Promise(process.nextTick); expect(replay).toHaveLastSentReplay({ - events: JSON.stringify([ + recordingData: JSON.stringify([ { type: 5, timestamp: BASE_TIMESTAMP + 10000 + 40, @@ -304,7 +304,7 @@ describe('Integration | errorSampleRate', () => { await new Promise(process.nextTick); expect(replay).toHaveSentReplay({ - events: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, TEST_EVENT]), + recordingData: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, TEST_EVENT]), replayEventPayload: expect.objectContaining({ replay_start_timestamp: BASE_TIMESTAMP / 1000, // the exception happens roughly 10 seconds after BASE_TIMESTAMP @@ -360,7 +360,7 @@ describe('Integration | errorSampleRate', () => { // Make sure the old performance event is thrown out replay_start_timestamp: (BASE_TIMESTAMP + ELAPSED + 20) / 1000, }), - events: JSON.stringify([ + recordingData: JSON.stringify([ { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + ELAPSED + 20, @@ -406,12 +406,12 @@ it('sends a replay after loading the session multiple times', async () => { await new Promise(process.nextTick); expect(replay).toHaveSentReplay({ - events: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, TEST_EVENT]), + recordingData: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, TEST_EVENT]), }); // Latest checkout when we call `startRecording` again after uploading segment // after an error occurs (e.g. when we switch to session replay recording) expect(replay).toHaveLastSentReplay({ - events: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + 5020, type: 2 }]), + recordingData: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + 5020, type: 2 }]), }); }); diff --git a/packages/replay/test/integration/events.test.ts b/packages/replay/test/integration/events.test.ts index 9bfe34484ce6..5168426dfd79 100644 --- a/packages/replay/test/integration/events.test.ts +++ b/packages/replay/test/integration/events.test.ts @@ -23,9 +23,6 @@ describe('Integration | events', () => { let mockTransportSend: jest.SpyInstance; const prevLocation = WINDOW.location; - type MockSendReplayRequest = jest.MockedFunction; - let mockSendReplayRequest: MockSendReplayRequest; - beforeAll(async () => { jest.setSystemTime(new Date(BASE_TIMESTAMP)); jest.runAllTimers(); @@ -40,15 +37,11 @@ describe('Integration | events', () => { mockTransportSend = jest.spyOn(getCurrentHub().getClient()!.getTransport()!, 'send'); - // @ts-ignore private API - mockSendReplayRequest = jest.spyOn(replay, '_sendReplayRequest'); - // Create a new session and clear mocks because a segment (from initial // checkout) will have already been uploaded by the time the tests run clearSession(replay); replay['_loadSession']({ expiry: 0 }); mockTransportSend.mockClear(); - mockSendReplayRequest.mockClear(); }); afterEach(async () => { @@ -60,7 +53,6 @@ describe('Integration | events', () => { }); clearSession(replay); jest.clearAllMocks(); - mockSendReplayRequest.mockRestore(); mockRecord.takeFullSnapshot.mockClear(); replay.stop(); }); @@ -184,7 +176,7 @@ describe('Integration | events', () => { // Make sure the old performance event is thrown out replay_start_timestamp: BASE_TIMESTAMP / 1000, }), - events: JSON.stringify([ + recordingData: JSON.stringify([ TEST_EVENT, { type: 5, diff --git a/packages/replay/test/integration/flush.test.ts b/packages/replay/test/integration/flush.test.ts index f2760ef43371..c0c143b255e4 100644 --- a/packages/replay/test/integration/flush.test.ts +++ b/packages/replay/test/integration/flush.test.ts @@ -6,6 +6,7 @@ import type { EventBuffer } from '../../src/types'; import * as AddMemoryEntry from '../../src/util/addMemoryEntry'; import { createPerformanceEntries } from '../../src/util/createPerformanceEntries'; import { createPerformanceSpans } from '../../src/util/createPerformanceSpans'; +import * as SendReplay from '../../src/util/sendReplay'; import { BASE_TIMESTAMP, mockRrweb, mockSdk } from '../index'; import { clearSession } from '../utils/clearSession'; import { useFakeTimers } from '../utils/use-fake-timers'; @@ -17,7 +18,7 @@ async function advanceTimers(time: number) { await new Promise(process.nextTick); } -type MockSendReplay = jest.MockedFunction; +type MockSendReplay = jest.MockedFunction; type MockAddPerformanceEntries = jest.MockedFunction; type MockAddMemoryEntry = jest.SpyInstance; type MockEventBufferFinish = jest.MockedFunction; @@ -48,8 +49,7 @@ describe('Integration | flush', () => { ({ replay } = await mockSdk()); - // @ts-ignore private API - mockSendReplay = jest.spyOn(replay, '_sendReplay'); + mockSendReplay = jest.spyOn(SendReplay, 'sendReplay'); mockSendReplay.mockImplementation( jest.fn(async () => { return; @@ -175,12 +175,16 @@ describe('Integration | flush', () => { // debouncedFlush, which will call `flush` in 1 second expect(mockFlush).toHaveBeenCalledTimes(4); // sendReplay is called with replayId, events, segment + expect(mockSendReplay).toHaveBeenLastCalledWith({ - events: expect.any(String), + recordingData: expect.any(String), replayId: expect.any(String), includeReplayStartTimestamp: true, segmentId: 0, eventContext: expect.anything(), + session: expect.any(Object), + options: expect.any(Object), + timestamp: expect.any(Number), }); // Add this to test that segment ID increases @@ -223,11 +227,14 @@ describe('Integration | flush', () => { expect(mockFlush).toHaveBeenCalledTimes(5); expect(mockRunFlush).toHaveBeenCalledTimes(2); expect(mockSendReplay).toHaveBeenLastCalledWith({ - events: expect.any(String), + recordingData: expect.any(String), replayId: expect.any(String), includeReplayStartTimestamp: false, segmentId: 1, eventContext: expect.anything(), + session: expect.any(Object), + options: expect.any(Object), + timestamp: expect.any(Number), }); // Make sure there's no other calls diff --git a/packages/replay/test/integration/rateLimiting.test.ts b/packages/replay/test/integration/rateLimiting.test.ts index 1270d7c9d780..31e15638c314 100644 --- a/packages/replay/test/integration/rateLimiting.test.ts +++ b/packages/replay/test/integration/rateLimiting.test.ts @@ -3,6 +3,7 @@ import type { Transport, TransportMakeRequestResponse } from '@sentry/types'; import { DEFAULT_FLUSH_MIN_DELAY, SESSION_IDLE_DURATION } from '../../src/constants'; import type { ReplayContainer } from '../../src/replay'; +import * as SendReplayRequest from '../../src/util/sendReplayRequest'; import { BASE_TIMESTAMP, mockSdk } from '../index'; import { mockRrweb } from '../mocks/mockRrweb'; import { clearSession } from '../utils/clearSession'; @@ -16,12 +17,11 @@ async function advanceTimers(time: number) { } type MockTransportSend = jest.MockedFunction; -type MockSendReplayRequest = jest.MockedFunction; describe('Integration | rate-limiting behaviour', () => { let replay: ReplayContainer; let mockTransportSend: MockTransportSend; - let mockSendReplayRequest: MockSendReplayRequest; + let mockSendReplayRequest: jest.MockedFunction; const { record: mockRecord } = mockRrweb(); beforeAll(async () => { @@ -33,12 +33,9 @@ describe('Integration | rate-limiting behaviour', () => { }, })); - // @ts-ignore private API - jest.spyOn(replay, '_sendReplayRequest'); - jest.runAllTimers(); mockTransportSend = getCurrentHub()?.getClient()?.getTransport()?.send as MockTransportSend; - mockSendReplayRequest = replay['_sendReplayRequest'] as MockSendReplayRequest; + mockSendReplayRequest = jest.spyOn(SendReplayRequest, 'sendReplayRequest'); }); beforeEach(() => { @@ -52,8 +49,6 @@ describe('Integration | rate-limiting behaviour', () => { replay['_loadSession']({ expiry: 0 }); mockSendReplayRequest.mockClear(); - - replay['_rateLimits'] = {}; }); afterEach(async () => { @@ -92,15 +87,13 @@ describe('Integration | rate-limiting behaviour', () => { }, }, ] as TransportMakeRequestResponse[])( - 'pauses recording and flushing a rate limit is hit and resumes both after the rate limit duration is over', + 'pauses recording and flushing a rate limit is hit and resumes both after the rate limit duration is over %j', async rateLimitResponse => { expect(replay.session?.segmentId).toBe(0); jest.spyOn(replay, 'pause'); jest.spyOn(replay, 'resume'); // @ts-ignore private API jest.spyOn(replay, '_handleRateLimit'); - // @ts-ignore private API - jest.spyOn(replay, '_sendReplay'); const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 2 }; @@ -115,7 +108,7 @@ describe('Integration | rate-limiting behaviour', () => { expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); expect(mockTransportSend).toHaveBeenCalledTimes(1); - expect(replay).toHaveLastSentReplay({ events: JSON.stringify([TEST_EVENT]) }); + expect(replay).toHaveLastSentReplay({ recordingData: JSON.stringify([TEST_EVENT]) }); expect(replay['_handleRateLimit']).toHaveBeenCalledTimes(1); // resume() was called once before we even started @@ -136,7 +129,7 @@ describe('Integration | rate-limiting behaviour', () => { mockRecord._emitter(ev); await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); expect(replay.isPaused()).toBe(true); - expect(replay['_sendReplay']).toHaveBeenCalledTimes(1); + expect(mockSendReplayRequest).toHaveBeenCalledTimes(1); expect(mockTransportSend).toHaveBeenCalledTimes(1); } @@ -148,9 +141,9 @@ describe('Integration | rate-limiting behaviour', () => { expect(replay.resume).toHaveBeenCalledTimes(1); expect(replay.isPaused()).toBe(false); - expect(replay['_sendReplay']).toHaveBeenCalledTimes(2); + expect(mockSendReplayRequest).toHaveBeenCalledTimes(2); expect(replay).toHaveLastSentReplay({ - events: JSON.stringify([ + recordingData: JSON.stringify([ { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY * 7, type: 2 }, ]), }); @@ -165,14 +158,14 @@ describe('Integration | rate-limiting behaviour', () => { // T = base + 40 await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); - expect(replay['_sendReplay']).toHaveBeenCalledTimes(3); - expect(replay).toHaveLastSentReplay({ events: JSON.stringify([TEST_EVENT3]) }); + expect(mockSendReplayRequest).toHaveBeenCalledTimes(3); + expect(replay).toHaveLastSentReplay({ recordingData: JSON.stringify([TEST_EVENT3]) }); // nothing should happen afterwards // T = base + 60 await advanceTimers(20_000); - expect(replay['_sendReplay']).toHaveBeenCalledTimes(3); - expect(replay).toHaveLastSentReplay({ events: JSON.stringify([TEST_EVENT3]) }); + expect(mockSendReplayRequest).toHaveBeenCalledTimes(3); + expect(replay).toHaveLastSentReplay({ recordingData: JSON.stringify([TEST_EVENT3]) }); // events array should be empty expect(replay.eventBuffer?.pendingLength).toBe(0); @@ -185,8 +178,6 @@ describe('Integration | rate-limiting behaviour', () => { jest.spyOn(replay, 'resume'); // @ts-ignore private API jest.spyOn(replay, '_handleRateLimit'); - // @ts-ignore private API - jest.spyOn(replay, '_sendReplay'); const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 2 }; @@ -201,7 +192,7 @@ describe('Integration | rate-limiting behaviour', () => { expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); expect(mockTransportSend).toHaveBeenCalledTimes(1); - expect(replay).toHaveLastSentReplay({ events: JSON.stringify([TEST_EVENT]) }); + expect(replay).toHaveLastSentReplay({ recordingData: JSON.stringify([TEST_EVENT]) }); expect(replay['_handleRateLimit']).toHaveBeenCalledTimes(1); // resume() was called once before we even started @@ -223,7 +214,7 @@ describe('Integration | rate-limiting behaviour', () => { mockRecord._emitter(ev); await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); expect(replay.isPaused()).toBe(true); - expect(replay['_sendReplay']).toHaveBeenCalledTimes(1); + expect(mockSendReplayRequest).toHaveBeenCalledTimes(1); expect(mockTransportSend).toHaveBeenCalledTimes(1); } @@ -235,9 +226,9 @@ describe('Integration | rate-limiting behaviour', () => { expect(replay.resume).toHaveBeenCalledTimes(1); expect(replay.isPaused()).toBe(false); - expect(replay['_sendReplay']).toHaveBeenCalledTimes(2); + expect(mockSendReplayRequest).toHaveBeenCalledTimes(2); expect(replay).toHaveLastSentReplay({ - events: JSON.stringify([ + recordingData: JSON.stringify([ { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY * 13, type: 2 }, ]), }); @@ -252,14 +243,14 @@ describe('Integration | rate-limiting behaviour', () => { // T = base + 65 await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); - expect(replay['_sendReplay']).toHaveBeenCalledTimes(3); - expect(replay).toHaveLastSentReplay({ events: JSON.stringify([TEST_EVENT3]) }); + expect(mockSendReplayRequest).toHaveBeenCalledTimes(3); + expect(replay).toHaveLastSentReplay({ recordingData: JSON.stringify([TEST_EVENT3]) }); // nothing should happen afterwards // T = base + 85 await advanceTimers(20_000); - expect(replay['_sendReplay']).toHaveBeenCalledTimes(3); - expect(replay).toHaveLastSentReplay({ events: JSON.stringify([TEST_EVENT3]) }); + expect(mockSendReplayRequest).toHaveBeenCalledTimes(3); + expect(replay).toHaveLastSentReplay({ recordingData: JSON.stringify([TEST_EVENT3]) }); // events array should be empty expect(replay.eventBuffer?.pendingLength).toBe(0); @@ -291,7 +282,7 @@ describe('Integration | rate-limiting behaviour', () => { expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); expect(mockTransportSend).toHaveBeenCalledTimes(1); - expect(replay).toHaveLastSentReplay({ events: JSON.stringify([TEST_EVENT]) }); + expect(replay).toHaveLastSentReplay({ recordingData: JSON.stringify([TEST_EVENT]) }); expect(replay['_handleRateLimit']).toHaveBeenCalledTimes(1); expect(replay.resume).not.toHaveBeenCalled(); diff --git a/packages/replay/test/integration/sendReplayEvent.test.ts b/packages/replay/test/integration/sendReplayEvent.test.ts index ade380a1605a..9a4c62fa8ede 100644 --- a/packages/replay/test/integration/sendReplayEvent.test.ts +++ b/packages/replay/test/integration/sendReplayEvent.test.ts @@ -1,10 +1,11 @@ -import { getCurrentHub } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; import type { Transport } from '@sentry/types'; import * as SentryUtils from '@sentry/utils'; import { DEFAULT_FLUSH_MIN_DELAY, SESSION_IDLE_DURATION, WINDOW } from '../../src/constants'; import type { ReplayContainer } from '../../src/replay'; import { addEvent } from '../../src/util/addEvent'; +import * as SendReplayRequest from '../../src/util/sendReplayRequest'; import { BASE_TIMESTAMP, mockRrweb, mockSdk } from '../index'; import { clearSession } from '../utils/clearSession'; import { useFakeTimers } from '../utils/use-fake-timers'; @@ -17,12 +18,11 @@ async function advanceTimers(time: number) { } type MockTransportSend = jest.MockedFunction; -type MockSendReplayRequest = jest.MockedFunction; describe('Integration | sendReplayEvent', () => { let replay: ReplayContainer; let mockTransportSend: MockTransportSend; - let mockSendReplayRequest: MockSendReplayRequest; + let mockSendReplayRequest: jest.SpyInstance; let domHandler: (args: any) => any; const { record: mockRecord } = mockRrweb(); @@ -37,14 +37,16 @@ describe('Integration | sendReplayEvent', () => { ({ replay } = await mockSdk({ replayOptions: { stickySession: false, + _experiments: { + captureExceptions: true, + }, }, })); - // @ts-ignore private API - mockSendReplayRequest = jest.spyOn(replay, '_sendReplayRequest'); + mockSendReplayRequest = jest.spyOn(SendReplayRequest, 'sendReplayRequest'); jest.runAllTimers(); - mockTransportSend = getCurrentHub()?.getClient()?.getTransport()?.send as MockTransportSend; + mockTransportSend = SentryCore.getCurrentHub()?.getClient()?.getTransport()?.send as MockTransportSend; }); beforeEach(() => { @@ -94,7 +96,7 @@ describe('Integration | sendReplayEvent', () => { expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).toHaveLastSentReplay({ events: JSON.stringify([TEST_EVENT]) }); + expect(replay).toHaveLastSentReplay({ recordingData: JSON.stringify([TEST_EVENT]) }); // Session's last activity is not updated because we do not consider // visibilitystate as user being active @@ -134,7 +136,7 @@ describe('Integration | sendReplayEvent', () => { expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).toHaveLastSentReplay({ events: JSON.stringify([TEST_EVENT]) }); + expect(replay).toHaveLastSentReplay({ recordingData: JSON.stringify([TEST_EVENT]) }); // No user activity to trigger an update expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP); @@ -158,7 +160,7 @@ describe('Integration | sendReplayEvent', () => { await new Promise(process.nextTick); expect(replay).toHaveLastSentReplay({ - events: JSON.stringify([...Array(5)].map(() => TEST_EVENT)), + recordingData: JSON.stringify([...Array(5)].map(() => TEST_EVENT)), }); // There should also not be another attempt at an upload 5 seconds after the last replay event @@ -175,7 +177,7 @@ describe('Integration | sendReplayEvent', () => { mockTransportSend.mockClear(); mockRecord._emitter(TEST_EVENT); await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); - expect(replay).toHaveLastSentReplay({ events: JSON.stringify([TEST_EVENT]) }); + expect(replay).toHaveLastSentReplay({ recordingData: JSON.stringify([TEST_EVENT]) }); }); it('uploads a replay event when WINDOW is blurred', async () => { @@ -209,7 +211,7 @@ describe('Integration | sendReplayEvent', () => { await new Promise(process.nextTick); expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); expect(replay).toHaveLastSentReplay({ - events: JSON.stringify([TEST_EVENT, hiddenBreadcrumb]), + recordingData: JSON.stringify([TEST_EVENT, hiddenBreadcrumb]), }); // Session's last activity should not be updated expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP); @@ -236,7 +238,7 @@ describe('Integration | sendReplayEvent', () => { await new Promise(process.nextTick); expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).toHaveLastSentReplay({ events: JSON.stringify([TEST_EVENT]) }); + expect(replay).toHaveLastSentReplay({ recordingData: JSON.stringify([TEST_EVENT]) }); // Session's last activity is not updated because we do not consider // visibilitystate as user being active @@ -254,7 +256,7 @@ describe('Integration | sendReplayEvent', () => { expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); expect(mockTransportSend).toHaveBeenCalledTimes(1); - expect(replay).toHaveLastSentReplay({ events: JSON.stringify([TEST_EVENT]) }); + expect(replay).toHaveLastSentReplay({ recordingData: JSON.stringify([TEST_EVENT]) }); // No user activity to trigger an update expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP); @@ -278,7 +280,7 @@ describe('Integration | sendReplayEvent', () => { await new Promise(process.nextTick); expect(replay).toHaveLastSentReplay({ - events: JSON.stringify([...Array(5)].map(() => TEST_EVENT)), + recordingData: JSON.stringify([...Array(5)].map(() => TEST_EVENT)), }); // There should also not be another attempt at an upload 5 seconds after the last replay event @@ -296,7 +298,7 @@ describe('Integration | sendReplayEvent', () => { mockTransportSend.mockClear(); mockRecord._emitter(TEST_EVENT); await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); - expect(replay).toHaveLastSentReplay({ events: JSON.stringify([TEST_EVENT]) }); + expect(replay).toHaveLastSentReplay({ recordingData: JSON.stringify([TEST_EVENT]) }); }); it('uploads a dom breadcrumb 5 seconds after listener receives an event', async () => { @@ -309,7 +311,7 @@ describe('Integration | sendReplayEvent', () => { await advanceTimers(ELAPSED); expect(replay).toHaveLastSentReplay({ - events: JSON.stringify([ + recordingData: JSON.stringify([ { type: 5, timestamp: BASE_TIMESTAMP, @@ -361,13 +363,13 @@ describe('Integration | sendReplayEvent', () => { error_ids: [], replay_id: expect.any(String), replay_start_timestamp: BASE_TIMESTAMP / 1000, - // 20seconds = Add up all of the previous `advanceTimers()` - timestamp: (BASE_TIMESTAMP + 20000) / 1000 + 0.02, + // timestamp is set on first try, after 5s flush + timestamp: (BASE_TIMESTAMP + 5000) / 1000, trace_ids: [], urls: ['http://localhost/'], }), recordingPayloadHeader: { segment_id: 0 }, - events: JSON.stringify([TEST_EVENT]), + recordingData: JSON.stringify([TEST_EVENT]), }); mockTransportSend.mockClear(); @@ -383,20 +385,15 @@ describe('Integration | sendReplayEvent', () => { it('fails to upload data and hits retry max and stops', async () => { const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - // @ts-ignore private API - const spySendReplay = jest.spyOn(replay, '_sendReplay'); + const spyHandleException = jest.spyOn(SentryCore, 'captureException'); // Suppress console.errors const mockConsole = jest.spyOn(console, 'error').mockImplementation(jest.fn()); - // @ts-ignore privaye api - Check errors - const spyHandleException = jest.spyOn(replay, '_handleException'); - expect(replay.session?.segmentId).toBe(0); - // fail the first and second requests and pass the third one - mockSendReplayRequest.mockReset(); - mockSendReplayRequest.mockImplementation(() => { + // fail all requests + mockSendReplayRequest.mockImplementation(async () => { throw new Error('Something bad happened'); }); mockRecord._emitter(TEST_EVENT); @@ -414,14 +411,12 @@ describe('Integration | sendReplayEvent', () => { await advanceTimers(30000); expect(mockSendReplayRequest).toHaveBeenCalledTimes(4); - expect(spySendReplay).toHaveBeenCalledTimes(4); mockConsole.mockReset(); // Make sure it doesn't retry again jest.runAllTimers(); expect(mockSendReplayRequest).toHaveBeenCalledTimes(4); - expect(spySendReplay).toHaveBeenCalledTimes(4); // Retries = 3 (total tries = 4 including initial attempt) // + last exception is max retries exceeded diff --git a/packages/replay/test/integration/session.test.ts b/packages/replay/test/integration/session.test.ts index c07bac150751..0a8a79dc5467 100644 --- a/packages/replay/test/integration/session.test.ts +++ b/packages/replay/test/integration/session.test.ts @@ -149,7 +149,7 @@ describe('Integration | session', () => { const breadcrumbTimestamp = newTimestamp + 20; // I don't know where this 20ms comes from expect(replay).toHaveLastSentReplay({ - events: JSON.stringify([ + recordingData: JSON.stringify([ { data: { isCheckout: true }, timestamp: newTimestamp, type: 2 }, { type: 5, @@ -309,7 +309,7 @@ describe('Integration | session', () => { expect(replay).toHaveLastSentReplay({ recordingPayloadHeader: { segment_id: 0 }, - events: JSON.stringify([ + recordingData: JSON.stringify([ { data: { isCheckout: true }, timestamp: newTimestamp, type: 2 }, { type: 5, @@ -420,7 +420,7 @@ describe('Integration | session', () => { expect(replay).toHaveLastSentReplay({ recordingPayloadHeader: { segment_id: 0 }, - events: JSON.stringify([ + recordingData: JSON.stringify([ { data: { isCheckout: true }, timestamp: newTimestamp, type: 2 }, { type: 5, diff --git a/packages/replay/test/integration/stop.test.ts b/packages/replay/test/integration/stop.test.ts index 42ccbe09ca68..55f8dafd9289 100644 --- a/packages/replay/test/integration/stop.test.ts +++ b/packages/replay/test/integration/stop.test.ts @@ -110,7 +110,7 @@ describe('Integration | stop', () => { jest.runAllTimers(); await new Promise(process.nextTick); expect(replay).toHaveLastSentReplay({ - events: JSON.stringify([ + recordingData: JSON.stringify([ // This event happens when we call `replay.start` { data: { isCheckout: true },