From f1b31e610e3fc281c5c370bf6ade70a6173ed5b5 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Tue, 6 Dec 2022 11:03:02 +0100 Subject: [PATCH 1/3] ref: Split replay integration & container class --- packages/replay/jest.setup.ts | 6 +- packages/replay/src/index.ts | 1276 +--------------- packages/replay/src/replay.ts | 1283 +++++++++++++++++ packages/replay/test/mocks/index.ts | 4 +- packages/replay/test/mocks/mockSdk.ts | 28 +- packages/replay/test/unit/flush.test.ts | 16 +- .../test/unit/index-errorSampleRate.test.ts | 4 +- .../test/unit/index-handleGlobalEvent.test.ts | 4 +- .../replay/test/unit/index-noSticky.test.ts | 4 +- packages/replay/test/unit/index.test.ts | 5 +- packages/replay/test/unit/stop.test.ts | 22 +- 11 files changed, 1357 insertions(+), 1295 deletions(-) create mode 100644 packages/replay/src/replay.ts diff --git a/packages/replay/jest.setup.ts b/packages/replay/jest.setup.ts index 494bc2a339b4..24e2e2088aa4 100644 --- a/packages/replay/jest.setup.ts +++ b/packages/replay/jest.setup.ts @@ -2,7 +2,7 @@ import { getCurrentHub } from '@sentry/core'; import { Transport } from '@sentry/types'; -import { Replay } from './src'; +import { ReplayContainer } from './src/replay'; import { Session } from './src/session/Session'; // @ts-ignore TS error, this is replaced in prod builds bc of rollup @@ -54,7 +54,7 @@ type SentReplayExpected = { }; // eslint-disable-next-line @typescript-eslint/explicit-function-return-type -const toHaveSameSession = function (received: jest.Mocked, expected: undefined | Session) { +const toHaveSameSession = function (received: jest.Mocked, expected: undefined | Session) { const pass = this.equals(received.session?.id, expected?.id) as boolean; const options = { @@ -77,7 +77,7 @@ const toHaveSameSession = function (received: jest.Mocked, expected: und */ // eslint-disable-next-line @typescript-eslint/explicit-function-return-type const toHaveSentReplay = function ( - _received: jest.Mocked, + _received: jest.Mocked, expected?: SentReplayExpected | { sample: SentReplayExpected; inverse: boolean }, ) { const { calls } = (getCurrentHub().getClient()?.getTransport()?.send as MockTransport).mock; diff --git a/packages/replay/src/index.ts b/packages/replay/src/index.ts index 86668adc9c57..e433aadee9b7 100644 --- a/packages/replay/src/index.ts +++ b/packages/replay/src/index.ts @@ -1,58 +1,13 @@ -/* eslint-disable max-lines */ // TODO: We might want to split this file up import type { BrowserClient, BrowserOptions } from '@sentry/browser'; -import { addGlobalEventProcessor, getCurrentHub, Scope, setContext } from '@sentry/core'; -import { Breadcrumb, Client, DataCategory, Event, EventDropReason, Integration } from '@sentry/types'; -import { addInstrumentationHandler, createEnvelope, logger } from '@sentry/utils'; -import debounce from 'lodash.debounce'; -import { PerformanceObserverEntryList } from 'perf_hooks'; -import { EventType, record } from 'rrweb'; +import { getCurrentHub } from '@sentry/core'; +import { Integration } from '@sentry/types'; -import { - DEFAULT_ERROR_SAMPLE_RATE, - DEFAULT_SESSION_SAMPLE_RATE, - MAX_SESSION_LIFE, - REPLAY_EVENT_NAME, - SESSION_IDLE_DURATION, - VISIBILITY_CHANGE_TIMEOUT, - WINDOW, -} from './constants'; -import { breadcrumbHandler } from './coreHandlers/breadcrumbHandler'; -import { spanHandler } from './coreHandlers/spanHandler'; -import { createMemoryEntry, createPerformanceEntries, ReplayPerformanceEntry } from './createPerformanceEntry'; -import { createEventBuffer, EventBuffer } from './eventBuffer'; -import { deleteSession } from './session/deleteSession'; -import { getSession } from './session/getSession'; -import { saveSession } from './session/saveSession'; -import { Session } from './session/Session'; -import type { - AllPerformanceEntry, - InstrumentationTypeBreadcrumb, - InstrumentationTypeSpan, - InternalEventContext, - PopEventContext, - RecordingEvent, - RecordingOptions, - ReplayConfiguration, - ReplayPluginOptions, - SendReplay, -} from './types'; -import { addInternalBreadcrumb } from './util/addInternalBreadcrumb'; +import { DEFAULT_ERROR_SAMPLE_RATE, DEFAULT_SESSION_SAMPLE_RATE } from './constants'; +import { ReplayContainer } from './replay'; +import type { RecordingOptions, ReplayConfiguration, ReplayPluginOptions } from './types'; import { captureInternalException } from './util/captureInternalException'; -import { createBreadcrumb } from './util/createBreadcrumb'; -import { createPayload } from './util/createPayload'; -import { dedupePerformanceEntries } from './util/dedupePerformanceEntries'; import { isBrowser } from './util/isBrowser'; -import { isExpired } from './util/isExpired'; -import { isSessionExpired } from './util/isSessionExpired'; -/** - * Returns true to return control to calling function, otherwise continue with normal batching - */ -type AddUpdateCallback = () => boolean | void; - -const BASE_RETRY_INTERVAL = 5000; -const MAX_RETRY_COUNT = 3; -const UNABLE_TO_SEND_REPLAY = 'Unable to send Replay'; const MEDIA_SELECTORS = 'img,image,svg,path,rect,area,video,object,picture,embed,map,audio'; let _initialized = false; @@ -68,15 +23,6 @@ export class Replay implements Integration { */ public name: string = Replay.id; - public eventBuffer: EventBuffer | null = null; - - /** - * List of PerformanceEntry from PerformanceObserver - */ - public performanceEvents: AllPerformanceEntry[] = []; - - public session: Session | undefined; - /** * Options to pass to `rrweb.record()` */ @@ -84,61 +30,10 @@ export class Replay implements Integration { readonly options: ReplayPluginOptions; - private _performanceObserver: PerformanceObserver | null = null; - - private _retryCount: number = 0; - private _retryInterval: number = BASE_RETRY_INTERVAL; - - private _debouncedFlush: ReturnType; - private _flushLock: Promise | null = null; - - /** - * Timestamp of the last user activity. This lives across sessions. - */ - private _lastActivity: number = new Date().getTime(); - - /** - * Is the integration currently active? - */ - private _isEnabled: boolean = false; - - /** - * Paused is a state where: - * - DOM Recording is not listening at all - * - Nothing will be added to event buffer (e.g. core SDK events) - */ - private _isPaused: boolean = false; - - /** - * Integration will wait until an error occurs before creating and sending a - * replay. - */ - private _waitForError: boolean = false; - - /** - * Have we attached listeners to the core SDK? - * Note we have to track this as there is no way to remove instrumentation handlers. - */ - private _hasInitializedCoreListeners: boolean = false; - - /** - * Function to stop recording - */ - private _stopRecording: ReturnType | null = null; - - /** - * We overwrite `client.recordDroppedEvent`, but store the original method here so we can restore it. - */ - private _originalRecordDroppedEvent?: Client['recordDroppedEvent']; + /** In tests, this is only called the first time */ + protected _hasCalledSetupOnce: boolean = false; - private _context: InternalEventContext = { - errorIds: new Set(), - traceIds: new Set(), - urls: [], - earliestEvent: null, - initialTimestamp: new Date().getTime(), - initialUrl: '', - }; + private replay?: ReplayContainer; constructor({ flushMinDelay = 5000, @@ -217,15 +112,12 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`, : `${this.recordingOptions.blockSelector},${MEDIA_SELECTORS}`; } - this._debouncedFlush = debounce(() => this.flush(), this.options.flushMinDelay, { - maxWait: this.options.flushMaxDelay, - }); - if (isBrowser() && _initialized) { const error = new Error('Multiple Sentry Session Replay instances are not supported'); captureInternalException(error); throw error; } + _initialized = true; } @@ -235,7 +127,7 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`, * and applied their own global event processor. An example is: * https://github.com/getsentry/sentry-javascript/blob/b47ceafbdac7f8b99093ce6023726ad4687edc48/packages/browser/src/integrations/useragent.ts * - * So we call `this.setup` in next event loop as a workaround to wait for other + * So we call `replay.setup` in next event loop as a workaround to wait for other * global event processors to finish. This is no longer needed, but keeping it * here to avoid any future issues. */ @@ -244,8 +136,8 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`, return; } - // Client is not available in constructor, so we need to wait until setupOnce - this._loadReplayOptionsFromClient(); + this._setup(); + this._hasCalledSetupOnce = true; // XXX: See method comments above setTimeout(() => this.start()); @@ -258,65 +150,11 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`, * PerformanceObserver, Recording, Sentry SDK, etc) */ start(): void { - if (!isBrowser()) { + if (!this.replay) { return; } - this.setInitialState(); - - this.loadSession({ expiry: SESSION_IDLE_DURATION }); - - // If there is no session, then something bad has happened - can't continue - if (!this.session) { - captureInternalException(new Error('Invalid session')); - return; - } - - if (!this.session.sampled) { - // If session was not sampled, then we do not initialize the integration at all. - return; - } - - // Modify recording options to checkoutEveryNthSecond if - // sampling for error replay. This is because we don't know - // when an error will occur, so we need to keep a buffer of - // replay events. - if (this.session.sampled === 'error') { - // Checkout every minute, meaning we only get up-to one minute of events before the error happens - this.recordingOptions.checkoutEveryNms = 60000; - this._waitForError = true; - } - - // setup() is generally called on page load or manually - in both cases we - // should treat it as an activity - this.updateSessionActivity(); - - this.eventBuffer = createEventBuffer({ - useCompression: Boolean(this.options.useCompression), - }); - - this.addListeners(); - - this.startRecording(); - - this._isEnabled = true; - } - - /** - * Start recording. - * - * Note that this will cause a new DOM checkout - */ - startRecording(): void { - try { - this._stopRecording = record({ - ...this.recordingOptions, - emit: this.handleRecordingEmit, - }); - } catch (err) { - __DEBUG_BUILD__ && logger.error('[Replay]', err); - captureInternalException(err); - } + this.replay.start(); } /** @@ -324,1056 +162,21 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`, * does not support a teardown */ stop(): void { - if (!isBrowser()) { - return; - } - - try { - __DEBUG_BUILD__ && logger.log('[Replay] Stopping Replays'); - this._isEnabled = false; - this.removeListeners(); - this._stopRecording?.(); - this.eventBuffer?.destroy(); - this.eventBuffer = null; - } catch (err) { - __DEBUG_BUILD__ && logger.error('[Replay]', err); - captureInternalException(err); - } - } - - /** - * Pause some replay functionality. See comments for `isPaused`. - * This differs from stop as this only stops DOM recording, it is - * not as thorough of a shutdown as `stop()`. - */ - pause(): void { - this._isPaused = true; - try { - if (this._stopRecording) { - this._stopRecording(); - this._stopRecording = undefined; - } - } catch (err) { - __DEBUG_BUILD__ && logger.error('[Replay]', err); - captureInternalException(err); - } - } - - /** - * Resumes recording, see notes for `pause(). - * - * Note that calling `startRecording()` here will cause a - * new DOM checkout.` - */ - resume(): void { - this._isPaused = false; - this.startRecording(); - } - - clearSession(): void { - try { - deleteSession(); - this.session = undefined; - } catch (err) { - __DEBUG_BUILD__ && logger.error('[Replay]', err); - captureInternalException(err); - } - } - - /** - * Loads a session from storage, or creates a new one if it does not exist or - * is expired. - */ - loadSession({ expiry }: { expiry: number }): void { - const { type, session } = getSession({ - expiry, - stickySession: Boolean(this.options.stickySession), - currentSession: this.session, - sessionSampleRate: this.options.sessionSampleRate, - errorSampleRate: this.options.errorSampleRate, - }); - - // If session was newly created (i.e. was not loaded from storage), then - // enable flag to create the root replay - if (type === 'new') { - this.setInitialState(); - } - - if (session.id !== this.session?.id) { - session.previousSessionId = this.session?.id; - } - - this.session = session; - } - - /** - * Capture some initial state that can change throughout the lifespan of the - * replay. This is required because otherwise they would be captured at the - * first flush. - */ - setInitialState(): void { - const urlPath = `${WINDOW.location.pathname}${WINDOW.location.hash}${WINDOW.location.search}`; - const url = `${WINDOW.location.origin}${urlPath}`; - - this.performanceEvents = []; - - // Reset context as well - this.clearContext(); - - this._context.initialUrl = url; - this._context.initialTimestamp = new Date().getTime(); - this._context.urls.push(url); - } - - /** - * Adds listeners to record events for the replay - */ - addListeners(): void { - try { - WINDOW.document.addEventListener('visibilitychange', this.handleVisibilityChange); - WINDOW.addEventListener('blur', this.handleWindowBlur); - WINDOW.addEventListener('focus', this.handleWindowFocus); - - // We need to filter out dropped events captured by `addGlobalEventProcessor(this.handleGlobalEvent)` below - this._overwriteRecordDroppedEvent(); - - // There is no way to remove these listeners, so ensure they are only added once - if (!this._hasInitializedCoreListeners) { - // Listeners from core SDK // - const scope = getCurrentHub().getScope(); - scope?.addScopeListener(this.handleCoreBreadcrumbListener('scope')); - addInstrumentationHandler('dom', this.handleCoreBreadcrumbListener('dom')); - addInstrumentationHandler('fetch', this.handleCoreSpanListener('fetch')); - addInstrumentationHandler('xhr', this.handleCoreSpanListener('xhr')); - addInstrumentationHandler('history', this.handleCoreSpanListener('history')); - - // Tag all (non replay) events that get sent to Sentry with the current - // replay ID so that we can reference them later in the UI - addGlobalEventProcessor(this.handleGlobalEvent); - - this._hasInitializedCoreListeners = true; - } - } catch (err) { - __DEBUG_BUILD__ && logger.error('[Replay]', err); - captureInternalException(err); - } - - // PerformanceObserver // - if (!('PerformanceObserver' in WINDOW)) { - return; - } - - this._performanceObserver = new PerformanceObserver(this.handlePerformanceObserver); - - // Observe almost everything for now (no mark/measure) - [ - 'element', - 'event', - 'first-input', - 'largest-contentful-paint', - 'layout-shift', - 'longtask', - 'navigation', - 'paint', - 'resource', - ].forEach(type => { - try { - this._performanceObserver?.observe({ - type, - buffered: true, - }); - } catch { - // This can throw if an entry type is not supported in the browser. - // Ignore these errors. - } - }); - } - - /** - * Cleans up listeners that were created in `addListeners` - */ - removeListeners(): void { - try { - WINDOW.document.removeEventListener('visibilitychange', this.handleVisibilityChange); - - WINDOW.removeEventListener('blur', this.handleWindowBlur); - WINDOW.removeEventListener('focus', this.handleWindowFocus); - - this._restoreRecordDroppedEvent(); - - if (this._performanceObserver) { - this._performanceObserver.disconnect(); - this._performanceObserver = null; - } - } catch (err) { - __DEBUG_BUILD__ && logger.error('[Replay]', err); - captureInternalException(err); - } - } - - /** - * We want to batch uploads of replay events. Save events only if - * `` milliseconds have elapsed since the last event - * *OR* if `` milliseconds have elapsed. - * - * Accepts a callback to perform side-effects and returns true to stop batch - * processing and hand back control to caller. - */ - addUpdate(cb?: AddUpdateCallback): void { - // We need to always run `cb` (e.g. in the case of `this.waitForError == true`) - const cbResult = cb?.(); - - // If this option is turned on then we will only want to call `flush` - // explicitly - if (this._waitForError) { - return; - } - - // If callback is true, we do not want to continue with flushing -- the - // caller will need to handle it. - if (cbResult === true) { - return; - } - - // addUpdate is called quite frequently - use debouncedFlush so that it - // respects the flush delays and does not flush immediately - this._debouncedFlush(); - } - - /** - * Core Sentry SDK global event handler. Attaches `replayId` to all [non-replay] - * events as a tag. Also handles the case where we only want to capture a reply - * when an error occurs. - **/ - handleGlobalEvent: (event: Event) => Event = (event: Event) => { - // Do not apply replayId to the root event - if ( - // @ts-ignore new event type - event.type === REPLAY_EVENT_NAME - ) { - // Replays have separate set of breadcrumbs, do not include breadcrumbs - // from core SDK - delete event.breadcrumbs; - return event; - } - - // Only tag transactions with replayId if not waiting for an error - if (event.type !== 'transaction' || !this._waitForError) { - event.tags = { ...event.tags, replayId: this.session?.id }; - } - - // Collect traceIds in context regardless of `waitForError` - if it's true, - // context gets cleared on every checkout - if (event.type === 'transaction') { - this._context.traceIds.add(String(event.contexts?.trace?.trace_id || '')); - return event; - } - - // XXX: Is it safe to assume that all other events are error events? - // @ts-ignore: Type 'undefined' is not assignable to type 'string'.ts(2345) - this._context.errorIds.add(event.event_id); - - const exc = event.exception?.values?.[0]; - addInternalBreadcrumb({ - message: `Tagging event (${event.event_id}) - ${event.message} - ${exc?.type || 'Unknown'}: ${ - exc?.value || 'n/a' - }`, - }); - - // Need to be very careful that this does not cause an infinite loop - if ( - this._waitForError && - event.exception && - event.message !== UNABLE_TO_SEND_REPLAY // ignore this error because otherwise we could loop indefinitely with trying to capture replay and failing - ) { - setTimeout(async () => { - // Allow flush to complete before resuming as a session recording, otherwise - // the checkout from `startRecording` may be included in the payload. - // Prefer to keep the error replay as a separate (and smaller) segment - // than the session replay. - await this.flushImmediate(); - - if (this._stopRecording) { - this._stopRecording(); - // Reset all "capture on error" configuration before - // starting a new recording - delete this.recordingOptions.checkoutEveryNms; - this._waitForError = false; - this.startRecording(); - } - }); - } - - return event; - }; - - /** - * Handler for recording events. - * - * Adds to event buffer, and has varying flushing behaviors if the event was a checkout. - */ - handleRecordingEmit: (event: RecordingEvent, isCheckout?: boolean) => void = ( - event: RecordingEvent, - isCheckout?: boolean, - ) => { - // If this is false, it means session is expired, create and a new session and wait for checkout - if (!this.checkAndHandleExpiredSession()) { - __DEBUG_BUILD__ && logger.error('[Replay] Received replay event after session expired.'); - - return; - } - - this.addUpdate(() => { - // The session is always started immediately on pageload/init, but for - // error-only replays, it should reflect the most recent checkout - // when an error occurs. Clear any state that happens before this current - // checkout. This needs to happen before `addEvent()` which updates state - // dependent on this reset. - if (this._waitForError && event.type === 2) { - this.setInitialState(); - } - - // We need to clear existing events on a checkout, otherwise they are - // incremental event updates and should be appended - this.addEvent(event, isCheckout); - - // Different behavior for full snapshots (type=2), ignore other event types - // See https://github.com/rrweb-io/rrweb/blob/d8f9290ca496712aa1e7d472549480c4e7876594/packages/rrweb/src/types.ts#L16 - if (event.type !== 2) { - return false; - } - - // If there is a previousSessionId after a full snapshot occurs, then - // the replay session was started due to session expiration. The new session - // is started before triggering a new checkout and contains the id - // of the previous session. Do not immediately flush in this case - // to avoid capturing only the checkout and instead the replay will - // be captured if they perform any follow-up actions. - if (this.session?.previousSessionId) { - return true; - } - - // See note above re: session start needs to reflect the most recent - // checkout. - if (this._waitForError && this.session && this._context.earliestEvent) { - this.session.started = this._context.earliestEvent; - this._maybeSaveSession(); - } - - // If the full snapshot is due to an initial load, we will not have - // a previous session ID. In this case, we want to buffer events - // for a set amount of time before flushing. This can help avoid - // capturing replays of users that immediately close the window. - setTimeout(() => this.conditionalFlush(), this.options.initialFlushDelay); - - // Cancel any previously debounced flushes to ensure there are no [near] - // simultaneous flushes happening. The latter request should be - // insignificant in this case, so wait for additional user interaction to - // trigger a new flush. - // - // This can happen because there's no guarantee that a recording event - // happens first. e.g. a mouse click can happen and trigger a debounced - // flush before the checkout. - this._debouncedFlush?.cancel(); - - return true; - }); - }; - - /** - * Handle when visibility of the page content changes. Opening a new tab will - * cause the state to change to hidden because of content of current page will - * be hidden. Likewise, moving a different window to cover the contents of the - * page will also trigger a change to a hidden state. - */ - handleVisibilityChange: () => void = () => { - if (WINDOW.document.visibilityState === 'visible') { - this.doChangeToForegroundTasks(); - } else { - this.doChangeToBackgroundTasks(); - } - }; - - /** - * Handle when page is blurred - */ - handleWindowBlur: () => void = () => { - const breadcrumb = createBreadcrumb({ - category: 'ui.blur', - }); - - // Do not count blur as a user action -- it's part of the process of them - // leaving the page - this.doChangeToBackgroundTasks(breadcrumb); - }; - - /** - * Handle when page is focused - */ - handleWindowFocus: () => void = () => { - const breadcrumb = createBreadcrumb({ - category: 'ui.focus', - }); - - // Do not count focus as a user action -- instead wait until they focus and - // interactive with page - this.doChangeToForegroundTasks(breadcrumb); - }; - - /** - * Handler for Sentry Core SDK events. - * - * These specific events will create span-like objects in the recording. - */ - handleCoreSpanListener: (type: InstrumentationTypeSpan) => (handlerData: unknown) => void = - (type: InstrumentationTypeSpan) => - (handlerData: unknown): void => { - if (!this._isEnabled) { - return; - } - - const result = spanHandler(type, handlerData); - - if (result === null) { - return; - } - - if (type === 'history') { - // Need to collect visited URLs - this._context.urls.push(result.name); - this.triggerUserActivity(); - } - - this.addUpdate(() => { - void this.createPerformanceSpans([result as ReplayPerformanceEntry]); - // Returning true will cause `addUpdate` to not flush - // We do not want network requests to cause a flush. This will prevent - // recurring/polling requests from keeping the replay session alive. - return ['xhr', 'fetch'].includes(type); - }); - }; - - /** - * Handler for Sentry Core SDK events. - * - * These events will create breadcrumb-like objects in the recording. - */ - handleCoreBreadcrumbListener: (type: InstrumentationTypeBreadcrumb) => (handlerData: unknown) => void = - (type: InstrumentationTypeBreadcrumb) => - (handlerData: unknown): void => { - if (!this._isEnabled) { - return; - } - - const result = breadcrumbHandler(type, handlerData); - - if (result === null) { - return; - } - - if (result.category === 'sentry.transaction') { - return; - } - - if (result.category === 'ui.click') { - this.triggerUserActivity(); - } else { - this.checkAndHandleExpiredSession(); - } - - this.addUpdate(() => { - this.addEvent({ - type: EventType.Custom, - // TODO: We were converting from ms to seconds for breadcrumbs, spans, - // but maybe we should just keep them as milliseconds - timestamp: (result.timestamp || 0) * 1000, - data: { - tag: 'breadcrumb', - payload: result, - }, - }); - - // Do not flush after console log messages - return result.category === 'console'; - }); - }; - - /** - * Keep a list of performance entries that will be sent with a replay - */ - handlePerformanceObserver: (list: PerformanceObserverEntryList) => void = (list: PerformanceObserverEntryList) => { - // For whatever reason the observer was returning duplicate navigation - // entries (the other entry types were not duplicated). - const newPerformanceEntries = dedupePerformanceEntries( - this.performanceEvents, - list.getEntries() as AllPerformanceEntry[], - ); - this.performanceEvents = newPerformanceEntries; - }; - - /** - * Tasks to run when we consider a page to be hidden (via blurring and/or visibility) - */ - doChangeToBackgroundTasks(breadcrumb?: Breadcrumb): void { - if (!this.session) { + if (!this.replay) { return; } - const expired = isSessionExpired(this.session, VISIBILITY_CHANGE_TIMEOUT); - - if (breadcrumb && !expired) { - this.createCustomBreadcrumb(breadcrumb); - } - - // Send replay when the page/tab becomes hidden. There is no reason to send - // replay if it becomes visible, since no actions we care about were done - // while it was hidden - this.conditionalFlush(); - } - - /** - * Tasks to run when we consider a page to be visible (via focus and/or visibility) - */ - doChangeToForegroundTasks(breadcrumb?: Breadcrumb): void { - if (!this.session) { - return; - } - - const isSessionActive = this.checkAndHandleExpiredSession({ - expiry: VISIBILITY_CHANGE_TIMEOUT, - }); - - if (!isSessionActive) { - // If the user has come back to the page within VISIBILITY_CHANGE_TIMEOUT - // ms, we will re-use the existing session, otherwise create a new - // session - __DEBUG_BUILD__ && logger.log('[Replay] Document has become active, but session has expired'); - return; - } - - if (breadcrumb) { - this.createCustomBreadcrumb(breadcrumb); - } - } - - /** - * Trigger rrweb to take a full snapshot which will cause this plugin to - * create a new Replay event. - */ - triggerFullSnapshot(): void { - __DEBUG_BUILD__ && logger.log('[Replay] Taking full rrweb snapshot'); - record.takeFullSnapshot(true); - } - - /** - * Add an event to the event buffer - */ - addEvent(event: RecordingEvent, isCheckout?: boolean): void { - if (!this.eventBuffer) { - // This implies that `isEnabled` is false - return; - } - - if (this._isPaused) { - // Do not add to event buffer when recording is paused - return; - } - - // TODO: sadness -- we will want to normalize timestamps to be in ms - - // requires coordination with frontend - const isMs = event.timestamp > 9999999999; - const timestampInMs = isMs ? event.timestamp : event.timestamp * 1000; - - // Throw out events that happen more than 5 minutes ago. This can happen if - // page has been left open and idle for a long period of time and user - // comes back to trigger a new session. The performance entries rely on - // `performance.timeOrigin`, which is when the page first opened. - if (timestampInMs + SESSION_IDLE_DURATION < new Date().getTime()) { - return; - } - - // Only record earliest event if a new session was created, otherwise it - // shouldn't be relevant - if ( - this.session?.segmentId === 0 && - (!this._context.earliestEvent || timestampInMs < this._context.earliestEvent) - ) { - this._context.earliestEvent = timestampInMs; - } - - this.eventBuffer.addEvent(event, isCheckout); + this.replay.stop(); } - /** - * Update user activity (across session lifespans) - */ - updateUserActivity(lastActivity: number = new Date().getTime()): void { - this._lastActivity = lastActivity; - } - - /** - * Updates the session's last activity timestamp - */ - updateSessionActivity(lastActivity: number = new Date().getTime()): void { - if (this.session) { - this.session.lastActivity = lastActivity; - this._maybeSaveSession(); - } - } - - /** - * Updates the user activity timestamp and resumes recording. This should be - * called in an event handler for a user action that we consider as the user - * being "active" (e.g. a mouse click). - */ - triggerUserActivity(): void { - this.updateUserActivity(); - - // This case means that recording was once stopped due to inactivity. - // Ensure that recording is resumed. - if (!this._stopRecording) { - // Create a new session, otherwise when the user action is flushed, it - // will get rejected due to an expired session. - this.loadSession({ expiry: SESSION_IDLE_DURATION }); - - // Note: This will cause a new DOM checkout - this.resume(); - return; - } - - // Otherwise... recording was never suspended, continue as normalish - this.checkAndHandleExpiredSession(); - - this.updateSessionActivity(); - } - - /** - * Helper to create (and buffer) a replay breadcrumb from a core SDK breadcrumb - */ - createCustomBreadcrumb(breadcrumb: Breadcrumb): void { - this.addUpdate(() => { - this.addEvent({ - type: EventType.Custom, - timestamp: breadcrumb.timestamp || 0, - data: { - tag: 'breadcrumb', - payload: breadcrumb, - }, - }); - }); - } - - /** - * Create a "span" for each performance entry. The parent transaction is `this.replayEvent`. - */ - createPerformanceSpans(entries: ReplayPerformanceEntry[]): Promise { - return Promise.all( - entries.map(({ type, start, end, name, data }) => - this.addEvent({ - type: EventType.Custom, - timestamp: start, - data: { - tag: 'performanceSpan', - payload: { - op: type, - description: name, - startTimestamp: start, - endTimestamp: end, - data, - }, - }, - }), - ), - ); - } - - /** - * Observed performance events are added to `this.performanceEvents`. These - * are included in the replay event before it is finished and sent to Sentry. - */ - addPerformanceEntries(): Promise { - // Copy and reset entries before processing - const entries = [...this.performanceEvents]; - this.performanceEvents = []; - - return this.createPerformanceSpans(createPerformanceEntries(entries)); - } - - /** - * Create a "span" for the total amount of memory being used by JS objects - * (including v8 internal objects). - */ - addMemoryEntry(): Promise | undefined { - // window.performance.memory is a non-standard API and doesn't work on all browsers - // so we check before creating the event. - if (!('memory' in WINDOW.performance)) { - return; - } - - return this.createPerformanceSpans([ - // @ts-ignore memory doesn't exist on type Performance as the API is non-standard (we check that it exists above) - createMemoryEntry(WINDOW.performance.memory), - ]); - } - - /** - * Checks if recording should be stopped due to user inactivity. Otherwise - * check if session is expired and create a new session if so. Triggers a new - * full snapshot on new session. - * - * Returns true if session is not expired, false otherwise. - */ - checkAndHandleExpiredSession({ expiry = SESSION_IDLE_DURATION }: { expiry?: number } = {}): boolean | void { - const oldSessionId = this.session?.id; - - // Prevent starting a new session if the last user activity is older than - // MAX_SESSION_LIFE. Otherwise non-user activity can trigger a new - // session+recording. This creates noisy replays that do not have much - // content in them. - if (this._lastActivity && isExpired(this._lastActivity, MAX_SESSION_LIFE)) { - // Pause recording - this.pause(); - return; - } - - // --- There is recent user activity --- // - // This will create a new session if expired, based on expiry length - this.loadSession({ expiry }); - - // Session was expired if session ids do not match - const expired = oldSessionId !== this.session?.id; - - if (!expired) { - return true; - } - - // Session is expired, trigger a full snapshot (which will create a new session) - this.triggerFullSnapshot(); - - return false; - } - - /** - * Only flush if `this.waitForError` is false. - */ - conditionalFlush(): void { - if (this._waitForError) { - return; - } - - void this.flushImmediate(); - } - - /** - * Clear context - */ - clearContext(): void { - // XXX: `initialTimestamp` and `initialUrl` do not get cleared - this._context.errorIds.clear(); - this._context.traceIds.clear(); - this._context.urls = []; - this._context.earliestEvent = null; - } - - /** - * Return and clear context - */ - popEventContext(): PopEventContext { - if (this._context.earliestEvent && this._context.earliestEvent < this._context.initialTimestamp) { - this._context.initialTimestamp = this._context.earliestEvent; - } - - const context = { - initialTimestamp: this._context.initialTimestamp, - initialUrl: this._context.initialUrl, - errorIds: Array.from(this._context.errorIds).filter(Boolean), - traceIds: Array.from(this._context.traceIds).filter(Boolean), - urls: this._context.urls, - }; - - this.clearContext(); - - return context; - } - - /** - * Flushes replay event buffer to Sentry. - * - * Performance events are only added right before flushing - this is - * due to the buffered performance observer events. - * - * Should never be called directly, only by `flush` - */ - async runFlush(): Promise { - if (!this.session) { - __DEBUG_BUILD__ && logger.error('[Replay] No session found to flush.'); - return; - } - - await this.addPerformanceEntries(); - - if (!this.eventBuffer?.length) { - return; - } - - // Only attach memory event if eventBuffer is not empty - await this.addMemoryEntry(); - - try { - // Note this empties the event buffer regardless of outcome of sending replay - const recordingData = await this.eventBuffer.finish(); - - // NOTE: Copy values from instance members, as it's possible they could - // change before the flush finishes. - const replayId = this.session.id; - const eventContext = this.popEventContext(); - // Always increment segmentId regardless of outcome of sending replay - const segmentId = this.session.segmentId++; - this._maybeSaveSession(); - - await this.sendReplay({ - replayId, - events: recordingData, - segmentId, - includeReplayStartTimestamp: segmentId === 0, - eventContext, - }); - } catch (err) { - __DEBUG_BUILD__ && logger.error(err); - captureInternalException(err); - } - } - - /** - * Flush recording data to Sentry. Creates a lock so that only a single flush - * can be active at a time. Do not call this directly. - */ - flush: () => Promise = async () => { - if (!this._isEnabled) { - // This is just a precaution, there should be no listeners that would - // cause a flush. - return; - } - - if (!this.checkAndHandleExpiredSession()) { - __DEBUG_BUILD__ && logger.error('[Replay] Attempting to finish replay event after session expired.'); - return; - } - - if (!this.session?.id) { - __DEBUG_BUILD__ && logger.error('[Replay] No session found to flush.'); - return; - } - - // A flush is about to happen, cancel any queued flushes - this._debouncedFlush?.cancel(); - - // No existing flush in progress, proceed with flushing. - // this.flushLock acts as a lock so that future calls to `flush()` - // will be blocked until this promise resolves - if (!this._flushLock) { - this._flushLock = this.runFlush(); - await this._flushLock; - this._flushLock = null; - return; - } - - // Wait for previous flush to finish, then call the debounced `flush()`. - // It's possible there are other flush requests queued and waiting for it - // to resolve. We want to reduce all outstanding requests (as well as any - // new flush requests that occur within a second of the locked flush - // completing) into a single flush. - - try { - await this._flushLock; - } catch (err) { - __DEBUG_BUILD__ && logger.error(err); - } finally { - this._debouncedFlush(); - } - }; - - /** - * - * Always flush via `debouncedFlush` so that we do not have flushes triggered - * from calling both `flush` and `debouncedFlush`. Otherwise, there could be - * cases of mulitple flushes happening closely together. - */ - flushImmediate(): Promise { - this._debouncedFlush(); - // `.flush` is provided by lodash.debounce - return this._debouncedFlush.flush(); - } - - /** - * Send replay attachment using `fetch()` - */ - async sendReplayRequest({ - events, - replayId: event_id, - segmentId: segment_id, - includeReplayStartTimestamp, - eventContext, - }: SendReplay): Promise { - const payloadWithSequence = createPayload({ - events, - headers: { - segment_id, - }, - }); - - const { urls, errorIds, traceIds, initialTimestamp } = eventContext; - - const currentTimestamp = new Date().getTime(); - - const sdkInfo = { - name: 'sentry.javascript.integration.replay', - version: __SENTRY_REPLAY_VERSION__, - }; - - const replayEvent = await new Promise(resolve => { - getCurrentHub() - // @ts-ignore private api - ?._withClient(async (client: Client, scope: Scope) => { - // XXX: This event does not trigger `beforeSend` in SDK - // @ts-ignore private api - const preparedEvent: Event = await client._prepareEvent( - { - type: REPLAY_EVENT_NAME, - ...(includeReplayStartTimestamp ? { replay_start_timestamp: initialTimestamp / 1000 } : {}), - timestamp: currentTimestamp / 1000, - error_ids: errorIds, - trace_ids: traceIds, - urls, - replay_id: event_id, - segment_id, - }, - { event_id }, - scope, - ); - const session = scope && scope.getSession(); - if (session) { - // @ts-ignore private api - client._updateSessionFromEvent(session, preparedEvent); - } - - preparedEvent.sdk = { - ...preparedEvent.sdk, - ...sdkInfo, - }; - - preparedEvent.tags = { - ...preparedEvent.tags, - sessionSampleRate: this.options.sessionSampleRate, - errorSampleRate: this.options.errorSampleRate, - replayType: this.session?.sampled, - }; + private _setup(): void { + // Client is not available in constructor, so we need to wait until setupOnce + this._loadReplayOptionsFromClient(); - resolve(preparedEvent); - }); + this.replay = new ReplayContainer({ + options: this.options, + recordingOptions: this.recordingOptions, }); - - const envelope = createEnvelope( - { - event_id, - sent_at: new Date().toISOString(), - sdk: sdkInfo, - }, - [ - // @ts-ignore New types - [{ type: 'replay_event' }, replayEvent], - [ - { - // @ts-ignore setting envelope - type: 'replay_recording', - length: payloadWithSequence.length, - }, - // @ts-ignore: Type 'string' is not assignable to type 'ClientReport'.ts(2322) - payloadWithSequence, - ], - ], - ); - - const client = getCurrentHub().getClient(); - try { - return client?.getTransport()?.send(envelope); - } catch { - throw new Error(UNABLE_TO_SEND_REPLAY); - } - } - - resetRetries(): void { - this._retryCount = 0; - this._retryInterval = BASE_RETRY_INTERVAL; - } - - /** - * Finalize and send the current replay event to Sentry - */ - 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) { - __DEBUG_BUILD__ && logger.error(err); - // Capture error for every failed replay - setContext('Replays', { - retryCount: this._retryCount, - }); - captureInternalException(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`); - } - - this._retryCount = this._retryCount + 1; - // 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) { - saveSession(this.session); - } } /** Parse Replay-related options from SDK options */ @@ -1389,39 +192,4 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`, this.options.errorSampleRate = opt.replaysOnErrorSampleRate; } } - - private _overwriteRecordDroppedEvent(): void { - const client = getCurrentHub().getClient(); - - if (!client) { - return; - } - - const _originalCallback = client.recordDroppedEvent.bind(client); - - const recordDroppedEvent: Client['recordDroppedEvent'] = ( - reason: EventDropReason, - category: DataCategory, - event?: Event, - ): void => { - if (event && event.event_id) { - this._context.errorIds.delete(event.event_id); - } - - return _originalCallback(reason, category, event); - }; - - client.recordDroppedEvent = recordDroppedEvent; - this._originalRecordDroppedEvent = _originalCallback; - } - - private _restoreRecordDroppedEvent(): void { - const client = getCurrentHub().getClient(); - - if (!client || !this._originalRecordDroppedEvent) { - return; - } - - client.recordDroppedEvent = this._originalRecordDroppedEvent; - } } diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts new file mode 100644 index 000000000000..8e38c553f979 --- /dev/null +++ b/packages/replay/src/replay.ts @@ -0,0 +1,1283 @@ +/* eslint-disable max-lines */ // TODO: We might want to split this file up +import { addGlobalEventProcessor, getCurrentHub, Scope, setContext } from '@sentry/core'; +import { Breadcrumb, Client, DataCategory, Event, EventDropReason } from '@sentry/types'; +import { addInstrumentationHandler, createEnvelope, logger } from '@sentry/utils'; +import debounce from 'lodash.debounce'; +import { PerformanceObserverEntryList } from 'perf_hooks'; +import { EventType, record } from 'rrweb'; + +import { + MAX_SESSION_LIFE, + REPLAY_EVENT_NAME, + SESSION_IDLE_DURATION, + VISIBILITY_CHANGE_TIMEOUT, + WINDOW, +} from './constants'; +import { breadcrumbHandler } from './coreHandlers/breadcrumbHandler'; +import { spanHandler } from './coreHandlers/spanHandler'; +import { createMemoryEntry, createPerformanceEntries, ReplayPerformanceEntry } from './createPerformanceEntry'; +import { createEventBuffer, EventBuffer } from './eventBuffer'; +import { deleteSession } from './session/deleteSession'; +import { getSession } from './session/getSession'; +import { saveSession } from './session/saveSession'; +import { Session } from './session/Session'; +import type { + AllPerformanceEntry, + InstrumentationTypeBreadcrumb, + InstrumentationTypeSpan, + InternalEventContext, + PopEventContext, + RecordingEvent, + RecordingOptions, + ReplayPluginOptions, + SendReplay, +} from './types'; +import { addInternalBreadcrumb } from './util/addInternalBreadcrumb'; +import { captureInternalException } from './util/captureInternalException'; +import { createBreadcrumb } from './util/createBreadcrumb'; +import { createPayload } from './util/createPayload'; +import { dedupePerformanceEntries } from './util/dedupePerformanceEntries'; +import { isExpired } from './util/isExpired'; +import { isSessionExpired } from './util/isSessionExpired'; + +/** + * Returns true to return control to calling function, otherwise continue with normal batching + */ +type AddUpdateCallback = () => boolean | void; + +const BASE_RETRY_INTERVAL = 5000; +const MAX_RETRY_COUNT = 3; +const UNABLE_TO_SEND_REPLAY = 'Unable to send Replay'; + +export class ReplayContainer { + public eventBuffer: EventBuffer | null = null; + + /** + * List of PerformanceEntry from PerformanceObserver + */ + public performanceEvents: AllPerformanceEntry[] = []; + + public session: Session | undefined; + + /** + * Options to pass to `rrweb.record()` + */ + readonly recordingOptions: RecordingOptions; + + readonly options: ReplayPluginOptions; + + private performanceObserver: PerformanceObserver | null = null; + + private retryCount: number = 0; + private retryInterval: number = BASE_RETRY_INTERVAL; + + private debouncedFlush: ReturnType; + private flushLock: Promise | null = null; + + /** + * Timestamp of the last user activity. This lives across sessions. + */ + private lastActivity: number = new Date().getTime(); + + /** + * Is the integration currently active? + */ + private isEnabled: boolean = false; + + /** + * Paused is a state where: + * - DOM Recording is not listening at all + * - Nothing will be added to event buffer (e.g. core SDK events) + */ + private isPaused: boolean = false; + + /** + * Integration will wait until an error occurs before creating and sending a + * replay. + */ + private waitForError: boolean = false; + + /** + * Have we attached listeners to the core SDK? + * Note we have to track this as there is no way to remove instrumentation handlers. + */ + private hasInitializedCoreListeners: boolean = false; + + /** + * Function to stop recording + */ + private stopRecording: ReturnType | null = null; + + /** + * We overwrite `client.recordDroppedEvent`, but store the original method here so we can restore it. + */ + private _originalRecordDroppedEvent?: Client['recordDroppedEvent']; + + private context: InternalEventContext = { + errorIds: new Set(), + traceIds: new Set(), + urls: [], + earliestEvent: null, + initialTimestamp: new Date().getTime(), + initialUrl: '', + }; + + constructor({ options, recordingOptions }: { options: ReplayPluginOptions; recordingOptions: RecordingOptions }) { + this.recordingOptions = recordingOptions; + this.options = options; + + this.debouncedFlush = debounce(() => this.flush(), this.options.flushMinDelay, { + maxWait: this.options.flushMaxDelay, + }); + } + + /** + * Initializes the plugin. + * + * Creates or loads a session, attaches listeners to varying events (DOM, + * PerformanceObserver, Recording, Sentry SDK, etc) + */ + start(): void { + this.setInitialState(); + + this.loadSession({ expiry: SESSION_IDLE_DURATION }); + + // If there is no session, then something bad has happened - can't continue + if (!this.session) { + captureInternalException(new Error('Invalid session')); + return; + } + + if (!this.session.sampled) { + // If session was not sampled, then we do not initialize the integration at all. + return; + } + + // Modify recording options to checkoutEveryNthSecond if + // sampling for error replay. This is because we don't know + // when an error will occur, so we need to keep a buffer of + // replay events. + if (this.session.sampled === 'error') { + // Checkout every minute, meaning we only get up-to one minute of events before the error happens + this.recordingOptions.checkoutEveryNms = 60000; + this.waitForError = true; + } + + // setup() is generally called on page load or manually - in both cases we + // should treat it as an activity + this.updateSessionActivity(); + + this.eventBuffer = createEventBuffer({ + useCompression: Boolean(this.options.useCompression), + }); + + this.addListeners(); + + this.startRecording(); + + this.isEnabled = true; + } + + /** + * Start recording. + * + * Note that this will cause a new DOM checkout + */ + startRecording(): void { + try { + this.stopRecording = record({ + ...this.recordingOptions, + emit: this.handleRecordingEmit, + }); + } catch (err) { + __DEBUG_BUILD__ && logger.error('[Replay]', err); + captureInternalException(err); + } + } + + /** + * Currently, this needs to be manually called (e.g. for tests). Sentry SDK + * does not support a teardown + */ + stop(): void { + try { + __DEBUG_BUILD__ && logger.log('[Replay] Stopping Replays'); + this.isEnabled = false; + this.removeListeners(); + this.stopRecording?.(); + this.eventBuffer?.destroy(); + this.eventBuffer = null; + } catch (err) { + __DEBUG_BUILD__ && logger.error('[Replay]', err); + captureInternalException(err); + } + } + + /** + * Pause some replay functionality. See comments for `isPaused`. + * This differs from stop as this only stops DOM recording, it is + * not as thorough of a shutdown as `stop()`. + */ + pause(): void { + this.isPaused = true; + try { + if (this.stopRecording) { + this.stopRecording(); + this.stopRecording = undefined; + } + } catch (err) { + __DEBUG_BUILD__ && logger.error('[Replay]', err); + captureInternalException(err); + } + } + + /** + * Resumes recording, see notes for `pause(). + * + * Note that calling `startRecording()` here will cause a + * new DOM checkout.` + */ + resume(): void { + this.isPaused = false; + this.startRecording(); + } + + /** for tests only */ + clearSession(): void { + try { + deleteSession(); + this.session = undefined; + } catch (err) { + __DEBUG_BUILD__ && logger.error('[Replay]', err); + captureInternalException(err); + } + } + + /** + * Loads a session from storage, or creates a new one if it does not exist or + * is expired. + */ + loadSession({ expiry }: { expiry: number }): void { + const { type, session } = getSession({ + expiry, + stickySession: Boolean(this.options.stickySession), + currentSession: this.session, + sessionSampleRate: this.options.sessionSampleRate, + errorSampleRate: this.options.errorSampleRate, + }); + + // If session was newly created (i.e. was not loaded from storage), then + // enable flag to create the root replay + if (type === 'new') { + this.setInitialState(); + } + + if (session.id !== this.session?.id) { + session.previousSessionId = this.session?.id; + } + + this.session = session; + } + + /** + * Capture some initial state that can change throughout the lifespan of the + * replay. This is required because otherwise they would be captured at the + * first flush. + */ + setInitialState(): void { + const urlPath = `${WINDOW.location.pathname}${WINDOW.location.hash}${WINDOW.location.search}`; + const url = `${WINDOW.location.origin}${urlPath}`; + + this.performanceEvents = []; + + // Reset context as well + this.clearContext(); + + this.context.initialUrl = url; + this.context.initialTimestamp = new Date().getTime(); + this.context.urls.push(url); + } + + /** + * Adds listeners to record events for the replay + */ + addListeners(): void { + try { + WINDOW.document.addEventListener('visibilitychange', this.handleVisibilityChange); + WINDOW.addEventListener('blur', this.handleWindowBlur); + WINDOW.addEventListener('focus', this.handleWindowFocus); + + // We need to filter out dropped events captured by `addGlobalEventProcessor(this.handleGlobalEvent)` below + this._overwriteRecordDroppedEvent(); + + // There is no way to remove these listeners, so ensure they are only added once + if (!this.hasInitializedCoreListeners) { + // Listeners from core SDK // + const scope = getCurrentHub().getScope(); + scope?.addScopeListener(this.handleCoreBreadcrumbListener('scope')); + addInstrumentationHandler('dom', this.handleCoreBreadcrumbListener('dom')); + addInstrumentationHandler('fetch', this.handleCoreSpanListener('fetch')); + addInstrumentationHandler('xhr', this.handleCoreSpanListener('xhr')); + addInstrumentationHandler('history', this.handleCoreSpanListener('history')); + + // Tag all (non replay) events that get sent to Sentry with the current + // replay ID so that we can reference them later in the UI + addGlobalEventProcessor(this.handleGlobalEvent); + + this.hasInitializedCoreListeners = true; + } + } catch (err) { + __DEBUG_BUILD__ && logger.error('[Replay]', err); + captureInternalException(err); + } + + // PerformanceObserver // + if (!('PerformanceObserver' in WINDOW)) { + return; + } + + this.performanceObserver = new PerformanceObserver(this.handlePerformanceObserver); + + // Observe almost everything for now (no mark/measure) + [ + 'element', + 'event', + 'first-input', + 'largest-contentful-paint', + 'layout-shift', + 'longtask', + 'navigation', + 'paint', + 'resource', + ].forEach(type => { + try { + this.performanceObserver?.observe({ + type, + buffered: true, + }); + } catch { + // This can throw if an entry type is not supported in the browser. + // Ignore these errors. + } + }); + } + + /** + * Cleans up listeners that were created in `addListeners` + */ + removeListeners(): void { + try { + WINDOW.document.removeEventListener('visibilitychange', this.handleVisibilityChange); + + WINDOW.removeEventListener('blur', this.handleWindowBlur); + WINDOW.removeEventListener('focus', this.handleWindowFocus); + + this._restoreRecordDroppedEvent(); + + if (this.performanceObserver) { + this.performanceObserver.disconnect(); + this.performanceObserver = null; + } + } catch (err) { + __DEBUG_BUILD__ && logger.error('[Replay]', err); + captureInternalException(err); + } + } + + /** + * We want to batch uploads of replay events. Save events only if + * `` milliseconds have elapsed since the last event + * *OR* if `` milliseconds have elapsed. + * + * Accepts a callback to perform side-effects and returns true to stop batch + * processing and hand back control to caller. + */ + addUpdate(cb?: AddUpdateCallback): void { + // We need to always run `cb` (e.g. in the case of `this.waitForError == true`) + const cbResult = cb?.(); + + // If this option is turned on then we will only want to call `flush` + // explicitly + if (this.waitForError) { + return; + } + + // If callback is true, we do not want to continue with flushing -- the + // caller will need to handle it. + if (cbResult === true) { + return; + } + + // addUpdate is called quite frequently - use debouncedFlush so that it + // respects the flush delays and does not flush immediately + this.debouncedFlush(); + } + + /** + * Core Sentry SDK global event handler. Attaches `replayId` to all [non-replay] + * events as a tag. Also handles the case where we only want to capture a reply + * when an error occurs. + **/ + handleGlobalEvent: (event: Event) => Event = (event: Event) => { + // Do not apply replayId to the root event + if ( + // @ts-ignore new event type + event.type === REPLAY_EVENT_NAME + ) { + // Replays have separate set of breadcrumbs, do not include breadcrumbs + // from core SDK + delete event.breadcrumbs; + return event; + } + + // Only tag transactions with replayId if not waiting for an error + if (event.type !== 'transaction' || !this.waitForError) { + event.tags = { ...event.tags, replayId: this.session?.id }; + } + + // Collect traceIds in context regardless of `waitForError` - if it's true, + // context gets cleared on every checkout + if (event.type === 'transaction') { + this.context.traceIds.add(String(event.contexts?.trace?.trace_id || '')); + return event; + } + + // XXX: Is it safe to assume that all other events are error events? + // @ts-ignore: Type 'undefined' is not assignable to type 'string'.ts(2345) + this.context.errorIds.add(event.event_id); + + const exc = event.exception?.values?.[0]; + addInternalBreadcrumb({ + message: `Tagging event (${event.event_id}) - ${event.message} - ${exc?.type || 'Unknown'}: ${ + exc?.value || 'n/a' + }`, + }); + + // Need to be very careful that this does not cause an infinite loop + if ( + this.waitForError && + event.exception && + event.message !== UNABLE_TO_SEND_REPLAY // ignore this error because otherwise we could loop indefinitely with trying to capture replay and failing + ) { + setTimeout(async () => { + // Allow flush to complete before resuming as a session recording, otherwise + // the checkout from `startRecording` may be included in the payload. + // Prefer to keep the error replay as a separate (and smaller) segment + // than the session replay. + await this.flushImmediate(); + + if (this.stopRecording) { + this.stopRecording(); + // Reset all "capture on error" configuration before + // starting a new recording + delete this.recordingOptions.checkoutEveryNms; + this.waitForError = false; + this.startRecording(); + } + }); + } + + return event; + }; + + /** + * Handler for recording events. + * + * Adds to event buffer, and has varying flushing behaviors if the event was a checkout. + */ + handleRecordingEmit: (event: RecordingEvent, isCheckout?: boolean) => void = ( + event: RecordingEvent, + isCheckout?: boolean, + ) => { + // If this is false, it means session is expired, create and a new session and wait for checkout + if (!this.checkAndHandleExpiredSession()) { + __DEBUG_BUILD__ && logger.error('[Replay] Received replay event after session expired.'); + + return; + } + + this.addUpdate(() => { + // The session is always started immediately on pageload/init, but for + // error-only replays, it should reflect the most recent checkout + // when an error occurs. Clear any state that happens before this current + // checkout. This needs to happen before `addEvent()` which updates state + // dependent on this reset. + if (this.waitForError && event.type === 2) { + this.setInitialState(); + } + + // We need to clear existing events on a checkout, otherwise they are + // incremental event updates and should be appended + this.addEvent(event, isCheckout); + + // Different behavior for full snapshots (type=2), ignore other event types + // See https://github.com/rrweb-io/rrweb/blob/d8f9290ca496712aa1e7d472549480c4e7876594/packages/rrweb/src/types.ts#L16 + if (event.type !== 2) { + return false; + } + + // If there is a previousSessionId after a full snapshot occurs, then + // the replay session was started due to session expiration. The new session + // is started before triggering a new checkout and contains the id + // of the previous session. Do not immediately flush in this case + // to avoid capturing only the checkout and instead the replay will + // be captured if they perform any follow-up actions. + if (this.session?.previousSessionId) { + return true; + } + + // See note above re: session start needs to reflect the most recent + // checkout. + if (this.waitForError && this.session && this.context.earliestEvent) { + this.session.started = this.context.earliestEvent; + this._maybeSaveSession(); + } + + // If the full snapshot is due to an initial load, we will not have + // a previous session ID. In this case, we want to buffer events + // for a set amount of time before flushing. This can help avoid + // capturing replays of users that immediately close the window. + setTimeout(() => this.conditionalFlush(), this.options.initialFlushDelay); + + // Cancel any previously debounced flushes to ensure there are no [near] + // simultaneous flushes happening. The latter request should be + // insignificant in this case, so wait for additional user interaction to + // trigger a new flush. + // + // This can happen because there's no guarantee that a recording event + // happens first. e.g. a mouse click can happen and trigger a debounced + // flush before the checkout. + this.debouncedFlush?.cancel(); + + return true; + }); + }; + + /** + * Handle when visibility of the page content changes. Opening a new tab will + * cause the state to change to hidden because of content of current page will + * be hidden. Likewise, moving a different window to cover the contents of the + * page will also trigger a change to a hidden state. + */ + handleVisibilityChange: () => void = () => { + if (WINDOW.document.visibilityState === 'visible') { + this.doChangeToForegroundTasks(); + } else { + this.doChangeToBackgroundTasks(); + } + }; + + /** + * Handle when page is blurred + */ + handleWindowBlur: () => void = () => { + const breadcrumb = createBreadcrumb({ + category: 'ui.blur', + }); + + // Do not count blur as a user action -- it's part of the process of them + // leaving the page + this.doChangeToBackgroundTasks(breadcrumb); + }; + + /** + * Handle when page is focused + */ + handleWindowFocus: () => void = () => { + const breadcrumb = createBreadcrumb({ + category: 'ui.focus', + }); + + // Do not count focus as a user action -- instead wait until they focus and + // interactive with page + this.doChangeToForegroundTasks(breadcrumb); + }; + + /** + * Handler for Sentry Core SDK events. + * + * These specific events will create span-like objects in the recording. + */ + handleCoreSpanListener: (type: InstrumentationTypeSpan) => (handlerData: unknown) => void = + (type: InstrumentationTypeSpan) => + (handlerData: unknown): void => { + if (!this.isEnabled) { + return; + } + + const result = spanHandler(type, handlerData); + + if (result === null) { + return; + } + + if (type === 'history') { + // Need to collect visited URLs + this.context.urls.push(result.name); + this.triggerUserActivity(); + } + + this.addUpdate(() => { + void this.createPerformanceSpans([result as ReplayPerformanceEntry]); + // Returning true will cause `addUpdate` to not flush + // We do not want network requests to cause a flush. This will prevent + // recurring/polling requests from keeping the replay session alive. + return ['xhr', 'fetch'].includes(type); + }); + }; + + /** + * Handler for Sentry Core SDK events. + * + * These events will create breadcrumb-like objects in the recording. + */ + handleCoreBreadcrumbListener: (type: InstrumentationTypeBreadcrumb) => (handlerData: unknown) => void = + (type: InstrumentationTypeBreadcrumb) => + (handlerData: unknown): void => { + if (!this.isEnabled) { + return; + } + + const result = breadcrumbHandler(type, handlerData); + + if (result === null) { + return; + } + + if (result.category === 'sentry.transaction') { + return; + } + + if (result.category === 'ui.click') { + this.triggerUserActivity(); + } else { + this.checkAndHandleExpiredSession(); + } + + this.addUpdate(() => { + this.addEvent({ + type: EventType.Custom, + // TODO: We were converting from ms to seconds for breadcrumbs, spans, + // but maybe we should just keep them as milliseconds + timestamp: (result.timestamp || 0) * 1000, + data: { + tag: 'breadcrumb', + payload: result, + }, + }); + + // Do not flush after console log messages + return result.category === 'console'; + }); + }; + + /** + * Keep a list of performance entries that will be sent with a replay + */ + handlePerformanceObserver: (list: PerformanceObserverEntryList) => void = (list: PerformanceObserverEntryList) => { + // For whatever reason the observer was returning duplicate navigation + // entries (the other entry types were not duplicated). + const newPerformanceEntries = dedupePerformanceEntries( + this.performanceEvents, + list.getEntries() as AllPerformanceEntry[], + ); + this.performanceEvents = newPerformanceEntries; + }; + + /** + * Tasks to run when we consider a page to be hidden (via blurring and/or visibility) + */ + doChangeToBackgroundTasks(breadcrumb?: Breadcrumb): void { + if (!this.session) { + return; + } + + const expired = isSessionExpired(this.session, VISIBILITY_CHANGE_TIMEOUT); + + if (breadcrumb && !expired) { + this.createCustomBreadcrumb(breadcrumb); + } + + // Send replay when the page/tab becomes hidden. There is no reason to send + // replay if it becomes visible, since no actions we care about were done + // while it was hidden + this.conditionalFlush(); + } + + /** + * Tasks to run when we consider a page to be visible (via focus and/or visibility) + */ + doChangeToForegroundTasks(breadcrumb?: Breadcrumb): void { + if (!this.session) { + return; + } + + const isSessionActive = this.checkAndHandleExpiredSession({ + expiry: VISIBILITY_CHANGE_TIMEOUT, + }); + + if (!isSessionActive) { + // If the user has come back to the page within VISIBILITY_CHANGE_TIMEOUT + // ms, we will re-use the existing session, otherwise create a new + // session + __DEBUG_BUILD__ && logger.log('[Replay] Document has become active, but session has expired'); + return; + } + + if (breadcrumb) { + this.createCustomBreadcrumb(breadcrumb); + } + } + + /** + * Trigger rrweb to take a full snapshot which will cause this plugin to + * create a new Replay event. + */ + triggerFullSnapshot(): void { + __DEBUG_BUILD__ && logger.log('[Replay] Taking full rrweb snapshot'); + record.takeFullSnapshot(true); + } + + /** + * Add an event to the event buffer + */ + addEvent(event: RecordingEvent, isCheckout?: boolean): void { + if (!this.eventBuffer) { + // This implies that `isEnabled` is false + return; + } + + if (this.isPaused) { + // Do not add to event buffer when recording is paused + return; + } + + // TODO: sadness -- we will want to normalize timestamps to be in ms - + // requires coordination with frontend + const isMs = event.timestamp > 9999999999; + const timestampInMs = isMs ? event.timestamp : event.timestamp * 1000; + + // Throw out events that happen more than 5 minutes ago. This can happen if + // page has been left open and idle for a long period of time and user + // comes back to trigger a new session. The performance entries rely on + // `performance.timeOrigin`, which is when the page first opened. + if (timestampInMs + SESSION_IDLE_DURATION < new Date().getTime()) { + return; + } + + // Only record earliest event if a new session was created, otherwise it + // shouldn't be relevant + if (this.session?.segmentId === 0 && (!this.context.earliestEvent || timestampInMs < this.context.earliestEvent)) { + this.context.earliestEvent = timestampInMs; + } + + this.eventBuffer.addEvent(event, isCheckout); + } + + /** + * Update user activity (across session lifespans) + */ + updateUserActivity(lastActivity: number = new Date().getTime()): void { + this.lastActivity = lastActivity; + } + + /** + * Updates the session's last activity timestamp + */ + updateSessionActivity(lastActivity: number = new Date().getTime()): void { + if (this.session) { + this.session.lastActivity = lastActivity; + this._maybeSaveSession(); + } + } + + /** + * Updates the user activity timestamp and resumes recording. This should be + * called in an event handler for a user action that we consider as the user + * being "active" (e.g. a mouse click). + */ + triggerUserActivity(): void { + this.updateUserActivity(); + + // This case means that recording was once stopped due to inactivity. + // Ensure that recording is resumed. + if (!this.stopRecording) { + // Create a new session, otherwise when the user action is flushed, it + // will get rejected due to an expired session. + this.loadSession({ expiry: SESSION_IDLE_DURATION }); + + // Note: This will cause a new DOM checkout + this.resume(); + return; + } + + // Otherwise... recording was never suspended, continue as normalish + this.checkAndHandleExpiredSession(); + + this.updateSessionActivity(); + } + + /** + * Helper to create (and buffer) a replay breadcrumb from a core SDK breadcrumb + */ + createCustomBreadcrumb(breadcrumb: Breadcrumb): void { + this.addUpdate(() => { + this.addEvent({ + type: EventType.Custom, + timestamp: breadcrumb.timestamp || 0, + data: { + tag: 'breadcrumb', + payload: breadcrumb, + }, + }); + }); + } + + /** + * Create a "span" for each performance entry. The parent transaction is `this.replayEvent`. + */ + createPerformanceSpans(entries: ReplayPerformanceEntry[]): Promise { + return Promise.all( + entries.map(({ type, start, end, name, data }) => + this.addEvent({ + type: EventType.Custom, + timestamp: start, + data: { + tag: 'performanceSpan', + payload: { + op: type, + description: name, + startTimestamp: start, + endTimestamp: end, + data, + }, + }, + }), + ), + ); + } + + /** + * Observed performance events are added to `this.performanceEvents`. These + * are included in the replay event before it is finished and sent to Sentry. + */ + addPerformanceEntries(): Promise { + // Copy and reset entries before processing + const entries = [...this.performanceEvents]; + this.performanceEvents = []; + + return this.createPerformanceSpans(createPerformanceEntries(entries)); + } + + /** + * Create a "span" for the total amount of memory being used by JS objects + * (including v8 internal objects). + */ + addMemoryEntry(): Promise | undefined { + // window.performance.memory is a non-standard API and doesn't work on all browsers + // so we check before creating the event. + if (!('memory' in WINDOW.performance)) { + return; + } + + return this.createPerformanceSpans([ + // @ts-ignore memory doesn't exist on type Performance as the API is non-standard (we check that it exists above) + createMemoryEntry(WINDOW.performance.memory), + ]); + } + + /** + * Checks if recording should be stopped due to user inactivity. Otherwise + * check if session is expired and create a new session if so. Triggers a new + * full snapshot on new session. + * + * Returns true if session is not expired, false otherwise. + */ + checkAndHandleExpiredSession({ expiry = SESSION_IDLE_DURATION }: { expiry?: number } = {}): boolean | void { + const oldSessionId = this.session?.id; + + // Prevent starting a new session if the last user activity is older than + // MAX_SESSION_LIFE. Otherwise non-user activity can trigger a new + // session+recording. This creates noisy replays that do not have much + // content in them. + if (this.lastActivity && isExpired(this.lastActivity, MAX_SESSION_LIFE)) { + // Pause recording + this.pause(); + return; + } + + // --- There is recent user activity --- // + // This will create a new session if expired, based on expiry length + this.loadSession({ expiry }); + + // Session was expired if session ids do not match + const expired = oldSessionId !== this.session?.id; + + if (!expired) { + return true; + } + + // Session is expired, trigger a full snapshot (which will create a new session) + this.triggerFullSnapshot(); + + return false; + } + + /** + * Only flush if `this.waitForError` is false. + */ + conditionalFlush(): void { + if (this.waitForError) { + return; + } + + void this.flushImmediate(); + } + + /** + * Clear context + */ + clearContext(): void { + // XXX: `initialTimestamp` and `initialUrl` do not get cleared + this.context.errorIds.clear(); + this.context.traceIds.clear(); + this.context.urls = []; + this.context.earliestEvent = null; + } + + /** + * Return and clear context + */ + popEventContext(): PopEventContext { + if (this.context.earliestEvent && this.context.earliestEvent < this.context.initialTimestamp) { + this.context.initialTimestamp = this.context.earliestEvent; + } + + const context = { + initialTimestamp: this.context.initialTimestamp, + initialUrl: this.context.initialUrl, + errorIds: Array.from(this.context.errorIds).filter(Boolean), + traceIds: Array.from(this.context.traceIds).filter(Boolean), + urls: this.context.urls, + }; + + this.clearContext(); + + return context; + } + + /** + * Flushes replay event buffer to Sentry. + * + * Performance events are only added right before flushing - this is + * due to the buffered performance observer events. + * + * Should never be called directly, only by `flush` + */ + async runFlush(): Promise { + if (!this.session) { + __DEBUG_BUILD__ && logger.error('[Replay] No session found to flush.'); + return; + } + + await this.addPerformanceEntries(); + + if (!this.eventBuffer?.length) { + return; + } + + // Only attach memory event if eventBuffer is not empty + await this.addMemoryEntry(); + + try { + // Note this empties the event buffer regardless of outcome of sending replay + const recordingData = await this.eventBuffer.finish(); + + // NOTE: Copy values from instance members, as it's possible they could + // change before the flush finishes. + const replayId = this.session.id; + const eventContext = this.popEventContext(); + // Always increment segmentId regardless of outcome of sending replay + const segmentId = this.session.segmentId++; + this._maybeSaveSession(); + + await this.sendReplay({ + replayId, + events: recordingData, + segmentId, + includeReplayStartTimestamp: segmentId === 0, + eventContext, + }); + } catch (err) { + __DEBUG_BUILD__ && logger.error(err); + captureInternalException(err); + } + } + + /** + * Flush recording data to Sentry. Creates a lock so that only a single flush + * can be active at a time. Do not call this directly. + */ + flush: () => Promise = async () => { + if (!this.isEnabled) { + // This is just a precaution, there should be no listeners that would + // cause a flush. + return; + } + + if (!this.checkAndHandleExpiredSession()) { + __DEBUG_BUILD__ && logger.error('[Replay] Attempting to finish replay event after session expired.'); + return; + } + + if (!this.session?.id) { + __DEBUG_BUILD__ && logger.error('[Replay] No session found to flush.'); + return; + } + + // A flush is about to happen, cancel any queued flushes + this.debouncedFlush?.cancel(); + + // No existing flush in progress, proceed with flushing. + // this.flushLock acts as a lock so that future calls to `flush()` + // will be blocked until this promise resolves + if (!this.flushLock) { + this.flushLock = this.runFlush(); + await this.flushLock; + this.flushLock = null; + return; + } + + // Wait for previous flush to finish, then call the debounced `flush()`. + // It's possible there are other flush requests queued and waiting for it + // to resolve. We want to reduce all outstanding requests (as well as any + // new flush requests that occur within a second of the locked flush + // completing) into a single flush. + + try { + await this.flushLock; + } catch (err) { + __DEBUG_BUILD__ && logger.error(err); + } finally { + this.debouncedFlush(); + } + }; + + /** + * + * Always flush via `debouncedFlush` so that we do not have flushes triggered + * from calling both `flush` and `debouncedFlush`. Otherwise, there could be + * cases of mulitple flushes happening closely together. + */ + flushImmediate(): Promise { + this.debouncedFlush(); + // `.flush` is provided by lodash.debounce + return this.debouncedFlush.flush(); + } + + /** + * Send replay attachment using `fetch()` + */ + async sendReplayRequest({ + events, + replayId: event_id, + segmentId: segment_id, + includeReplayStartTimestamp, + eventContext, + }: SendReplay): Promise { + const payloadWithSequence = createPayload({ + events, + headers: { + segment_id, + }, + }); + + const { urls, errorIds, traceIds, initialTimestamp } = eventContext; + + const currentTimestamp = new Date().getTime(); + + const sdkInfo = { + name: 'sentry.javascript.integration.replay', + version: __SENTRY_REPLAY_VERSION__, + }; + + const replayEvent = await new Promise(resolve => { + getCurrentHub() + // @ts-ignore private api + ?._withClient(async (client: Client, scope: Scope) => { + // XXX: This event does not trigger `beforeSend` in SDK + // @ts-ignore private api + const preparedEvent: Event = await client._prepareEvent( + { + type: REPLAY_EVENT_NAME, + ...(includeReplayStartTimestamp ? { replay_start_timestamp: initialTimestamp / 1000 } : {}), + timestamp: currentTimestamp / 1000, + error_ids: errorIds, + trace_ids: traceIds, + urls, + replay_id: event_id, + segment_id, + }, + { event_id }, + scope, + ); + const session = scope && scope.getSession(); + if (session) { + // @ts-ignore private api + client._updateSessionFromEvent(session, preparedEvent); + } + + preparedEvent.sdk = { + ...preparedEvent.sdk, + ...sdkInfo, + }; + + preparedEvent.tags = { + ...preparedEvent.tags, + sessionSampleRate: this.options.sessionSampleRate, + errorSampleRate: this.options.errorSampleRate, + replayType: this.session?.sampled, + }; + + resolve(preparedEvent); + }); + }); + + const envelope = createEnvelope( + { + event_id, + sent_at: new Date().toISOString(), + sdk: sdkInfo, + }, + [ + // @ts-ignore New types + [{ type: 'replay_event' }, replayEvent], + [ + { + // @ts-ignore setting envelope + type: 'replay_recording', + length: payloadWithSequence.length, + }, + // @ts-ignore: Type 'string' is not assignable to type 'ClientReport'.ts(2322) + payloadWithSequence, + ], + ], + ); + + const client = getCurrentHub().getClient(); + try { + return client?.getTransport()?.send(envelope); + } catch { + throw new Error(UNABLE_TO_SEND_REPLAY); + } + } + + resetRetries(): void { + this.retryCount = 0; + this.retryInterval = BASE_RETRY_INTERVAL; + } + + /** + * Finalize and send the current replay event to Sentry + */ + 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) { + __DEBUG_BUILD__ && logger.error(err); + // Capture error for every failed replay + setContext('Replays', { + retryCount: this.retryCount, + }); + captureInternalException(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`); + } + + this.retryCount = this.retryCount + 1; + // 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) { + saveSession(this.session); + } + } + + private _overwriteRecordDroppedEvent(): void { + const client = getCurrentHub().getClient(); + + if (!client) { + return; + } + + const _originalCallback = client.recordDroppedEvent.bind(client); + + const recordDroppedEvent: Client['recordDroppedEvent'] = ( + reason: EventDropReason, + category: DataCategory, + event?: Event, + ): void => { + if (event && event.event_id) { + this.context.errorIds.delete(event.event_id); + } + + return _originalCallback(reason, category, event); + }; + + client.recordDroppedEvent = recordDroppedEvent; + this._originalRecordDroppedEvent = _originalCallback; + } + + private _restoreRecordDroppedEvent(): void { + const client = getCurrentHub().getClient(); + + if (!client || !this._originalRecordDroppedEvent) { + return; + } + + client.recordDroppedEvent = this._originalRecordDroppedEvent; + } +} diff --git a/packages/replay/test/mocks/index.ts b/packages/replay/test/mocks/index.ts index 17a4f1e652ca..9ef3845d03e5 100644 --- a/packages/replay/test/mocks/index.ts +++ b/packages/replay/test/mocks/index.ts @@ -1,6 +1,6 @@ import { getCurrentHub } from '@sentry/core'; -import { Replay } from './../../src'; +import { ReplayContainer } from '../../src/replay'; import { BASE_TIMESTAMP, RecordMock } from './../index'; import { DomHandler, MockTransportSend } from './../types'; import { MockSdkParams } from './mockSdk'; @@ -9,7 +9,7 @@ export async function resetSdkMock({ replayOptions, sentryOptions }: MockSdkPara domHandler: DomHandler; mockRecord: RecordMock; mockTransportSend: MockTransportSend; - replay: Replay; + replay: ReplayContainer; spyCaptureException: jest.SpyInstance; }> { let domHandler: DomHandler; diff --git a/packages/replay/test/mocks/mockSdk.ts b/packages/replay/test/mocks/mockSdk.ts index d9c2f51c0762..24c156728296 100644 --- a/packages/replay/test/mocks/mockSdk.ts +++ b/packages/replay/test/mocks/mockSdk.ts @@ -1,7 +1,8 @@ import type { BrowserOptions } from '@sentry/browser'; import { Envelope, Transport } from '@sentry/types'; -import { Replay as ReplayClass } from '../../src'; +import { Replay as ReplayIntegration } from '../../src'; +import { ReplayContainer } from '../../src/replay'; import { ReplayConfiguration } from '../../src/types'; export interface MockSdkParams { @@ -34,11 +35,14 @@ class MockTransport implements Transport { } } -export async function mockSdk({ replayOptions, sentryOptions }: MockSdkParams = {}): Promise<{ replay: ReplayClass }> { +export async function mockSdk({ replayOptions, sentryOptions }: MockSdkParams = {}): Promise<{ + replay: ReplayContainer; + integration: ReplayIntegration; +}> { const { init } = jest.requireActual('@sentry/browser'); const { Replay } = await import('../../src'); - const replay = new Replay({ + const replayIntegration = new Replay({ stickySession: false, ...replayOptions, }); @@ -51,15 +55,19 @@ export async function mockSdk({ replayOptions, sentryOptions }: MockSdkParams = replaysSessionSampleRate: 1.0, replaysOnErrorSampleRate: 0.0, ...sentryOptions, - integrations: [replay], + integrations: [replayIntegration], }); - // setupOnce is only called the first time, so we ensure to re-parse the options every time - replay['_loadReplayOptionsFromClient'](); + // setupOnce is only called the first time, so we ensure to manually run setup in subsequent calls + if (replayIntegration['_hasCalledSetupOnce']) { + // The first time the integration is used, `start()` is called (in setupOnce) + // For consistency, we want to stop that + replayIntegration.stop(); + } else { + replayIntegration['_setup'](); + } - // The first time the integration is used, `start()` is called (in setupOnce) - // For consistency, we want to stop that - replay.stop(); + const replay = replayIntegration['replay']!; - return { replay }; + return { replay, integration: replayIntegration }; } diff --git a/packages/replay/test/unit/flush.test.ts b/packages/replay/test/unit/flush.test.ts index bfb527557bfd..3d4149041466 100644 --- a/packages/replay/test/unit/flush.test.ts +++ b/packages/replay/test/unit/flush.test.ts @@ -1,8 +1,8 @@ import * as SentryUtils from '@sentry/utils'; import { SESSION_IDLE_DURATION, WINDOW } from '../../src/constants'; -import { Replay } from './../../src'; import { createPerformanceEntries } from './../../src/createPerformanceEntry'; +import { ReplayContainer } from './../../src/replay'; import { useFakeTimers } from './../../test/utils/use-fake-timers'; import { BASE_TIMESTAMP, mockRrweb, mockSdk } from './../index'; @@ -13,19 +13,19 @@ async function advanceTimers(time: number) { await new Promise(process.nextTick); } -type MockSendReplay = jest.MockedFunction; -type MockAddPerformanceEntries = jest.MockedFunction; -type MockAddMemoryEntry = jest.MockedFunction; -type MockEventBufferFinish = jest.MockedFunction['finish']>; -type MockFlush = jest.MockedFunction; -type MockRunFlush = jest.MockedFunction; +type MockSendReplay = jest.MockedFunction; +type MockAddPerformanceEntries = jest.MockedFunction; +type MockAddMemoryEntry = jest.MockedFunction; +type MockEventBufferFinish = jest.MockedFunction['finish']>; +type MockFlush = jest.MockedFunction; +type MockRunFlush = jest.MockedFunction; const prevLocation = WINDOW.location; let domHandler: (args: any) => any; const { record: mockRecord } = mockRrweb(); -let replay: Replay; +let replay: ReplayContainer; let mockSendReplay: MockSendReplay; let mockFlush: MockFlush; let mockRunFlush: MockRunFlush; diff --git a/packages/replay/test/unit/index-errorSampleRate.test.ts b/packages/replay/test/unit/index-errorSampleRate.test.ts index c97125ea450d..b5d28029bf2a 100644 --- a/packages/replay/test/unit/index-errorSampleRate.test.ts +++ b/packages/replay/test/unit/index-errorSampleRate.test.ts @@ -3,7 +3,7 @@ jest.unmock('@sentry/browser'); import { captureException } from '@sentry/browser'; import { REPLAY_SESSION_KEY, VISIBILITY_CHANGE_TIMEOUT, WINDOW } from '../../src/constants'; -import { Replay } from './../../src'; +import { ReplayContainer } from './../../src/replay'; import { PerformanceEntryResource } from './../fixtures/performanceEntry/resource'; import { BASE_TIMESTAMP, RecordMock } from './../index'; import { resetSdkMock } from './../mocks'; @@ -18,7 +18,7 @@ async function advanceTimers(time: number) { } describe('Replay (errorSampleRate)', () => { - let replay: Replay; + let replay: ReplayContainer; let mockRecord: RecordMock; let mockTransportSend: MockTransportSend; let domHandler: DomHandler; diff --git a/packages/replay/test/unit/index-handleGlobalEvent.test.ts b/packages/replay/test/unit/index-handleGlobalEvent.test.ts index ae0d70b36cfa..78b1bd55234b 100644 --- a/packages/replay/test/unit/index-handleGlobalEvent.test.ts +++ b/packages/replay/test/unit/index-handleGlobalEvent.test.ts @@ -1,14 +1,14 @@ import { getCurrentHub } from '@sentry/core'; import { REPLAY_EVENT_NAME } from '../../src/constants'; -import { Replay } from './../../src'; +import { ReplayContainer } from './../../src/replay'; import { Error } from './../fixtures/error'; import { Transaction } from './../fixtures/transaction'; import { resetSdkMock } from './../mocks'; import { useFakeTimers } from './../utils/use-fake-timers'; useFakeTimers(); -let replay: Replay; +let replay: ReplayContainer; beforeEach(async () => { ({ replay } = await resetSdkMock({ diff --git a/packages/replay/test/unit/index-noSticky.test.ts b/packages/replay/test/unit/index-noSticky.test.ts index 3dc271637aaa..e1c42d63870a 100644 --- a/packages/replay/test/unit/index-noSticky.test.ts +++ b/packages/replay/test/unit/index-noSticky.test.ts @@ -3,7 +3,7 @@ import { Transport } from '@sentry/types'; import * as SentryUtils from '@sentry/utils'; import { SESSION_IDLE_DURATION, VISIBILITY_CHANGE_TIMEOUT } from '../../src/constants'; -import { Replay } from './../../src'; +import { ReplayContainer } from './../../src/replay'; import { BASE_TIMESTAMP, mockRrweb, mockSdk } from './../index'; import { useFakeTimers } from './../utils/use-fake-timers'; @@ -17,7 +17,7 @@ async function advanceTimers(time: number) { type MockTransport = jest.MockedFunction; describe('Replay (no sticky)', () => { - let replay: Replay; + let replay: ReplayContainer; let mockTransport: MockTransport; let domHandler: (args: any) => any; const { record: mockRecord } = mockRrweb(); diff --git a/packages/replay/test/unit/index.test.ts b/packages/replay/test/unit/index.test.ts index ffcd9e4ffcc4..ffcb4c1c3522 100644 --- a/packages/replay/test/unit/index.test.ts +++ b/packages/replay/test/unit/index.test.ts @@ -1,10 +1,11 @@ jest.mock('./../../src/util/isInternal', () => ({ isInternal: jest.fn(() => true), })); + import { EventType } from 'rrweb'; -import { Replay } from '../../src'; import { MAX_SESSION_LIFE, REPLAY_SESSION_KEY, VISIBILITY_CHANGE_TIMEOUT, WINDOW } from '../../src/constants'; +import { ReplayContainer } from '../../src/replay'; import { RecordingEvent } from '../../src/types'; import { useFakeTimers } from '../utils/use-fake-timers'; import { PerformanceEntryResource } from './../fixtures/performanceEntry/resource'; @@ -91,7 +92,7 @@ describe('Replay with custom mock', () => { }); describe('Replay', () => { - let replay: Replay; + let replay: ReplayContainer; let mockRecord: RecordMock; let mockTransportSend: MockTransportSend; let domHandler: DomHandler; diff --git a/packages/replay/test/unit/stop.test.ts b/packages/replay/test/unit/stop.test.ts index 13f4a737a670..767149e724df 100644 --- a/packages/replay/test/unit/stop.test.ts +++ b/packages/replay/test/unit/stop.test.ts @@ -1,6 +1,7 @@ import * as SentryUtils from '@sentry/utils'; import { SESSION_IDLE_DURATION, WINDOW } from '../../src/constants'; +import { ReplayContainer } from '../../src/replay'; import { Replay } from './../../src'; // mock functions need to be imported first import { BASE_TIMESTAMP, mockRrweb, mockSdk } from './../index'; @@ -9,7 +10,8 @@ import { useFakeTimers } from './../utils/use-fake-timers'; useFakeTimers(); describe('Replay - stop', () => { - let replay: Replay; + let replay: ReplayContainer; + let integration: Replay; const prevLocation = WINDOW.location; type MockAddInstrumentationHandler = jest.MockedFunction; @@ -24,7 +26,7 @@ describe('Replay - stop', () => { 'addInstrumentationHandler', ) as MockAddInstrumentationHandler; - ({ replay } = await mockSdk()); + ({ replay, integration } = await mockSdk()); jest.runAllTimers(); }); @@ -50,7 +52,7 @@ describe('Replay - stop', () => { }); afterAll(() => { - replay && replay.stop(); + integration && integration.stop(); }); it('does not upload replay if it was stopped and can resume replays afterwards', async () => { @@ -66,7 +68,7 @@ describe('Replay - stop', () => { const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; // stop replays - replay.stop(); + integration.stop(); // Pretend 5 seconds have passed jest.advanceTimersByTime(ELAPSED); @@ -82,7 +84,7 @@ describe('Replay - stop', () => { expect(replay.eventBuffer).toBe(null); // re-enable replay - replay.start(); + integration.start(); jest.advanceTimersByTime(ELAPSED); @@ -127,7 +129,7 @@ describe('Replay - stop', () => { expect(replay.eventBuffer?.length).toBe(1); // stop replays - replay.stop(); + integration.stop(); expect(replay.eventBuffer?.length).toBe(undefined); @@ -140,10 +142,10 @@ describe('Replay - stop', () => { it('does not call core SDK `addInstrumentationHandler` after initial setup', async function () { // NOTE: We clear addInstrumentationHandler mock after every test - replay.stop(); - replay.start(); - replay.stop(); - replay.start(); + integration.stop(); + integration.start(); + integration.stop(); + integration.start(); expect(mockAddInstrumentationHandler).not.toHaveBeenCalled(); }); From d80328794c39e74f04c0cdd255aea0c9801c2a0f Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 5 Dec 2022 12:52:26 +0100 Subject: [PATCH 2/3] doc(replay): Add to migration docs --- packages/replay/MIGRATION.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/replay/MIGRATION.md b/packages/replay/MIGRATION.md index 8bceac5d669e..6a7e2d25e98c 100644 --- a/packages/replay/MIGRATION.md +++ b/packages/replay/MIGRATION.md @@ -62,3 +62,14 @@ Unless you manually imported this and used it somewhere in your codebase, this w The `Session` object exported from Replay is now a plain object, instead of a class. This should not affect you unless you specifically accessed this class & did custom things with it. + +## Reduce public API of Replay integration (https://github.com/getsentry/sentry-javascript/pull/6407) + +The result of `new Replay()` now has a much more limited public API. Only the following methods are exposed: + +```js +const replay = new Replay(); + +replay.start(); +replay.stop(); +``` From e64301589017b8e658989ebad128abc883429838 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Tue, 6 Dec 2022 11:11:41 +0100 Subject: [PATCH 3/3] ref(replay): Prefix private methods/properties with `_` in ReplayContainer --- packages/replay/src/index.ts | 12 +- packages/replay/src/replay.ts | 217 +++++++++--------- packages/replay/test/mocks/mockSdk.ts | 2 +- .../test/unit/index-handleGlobalEvent.test.ts | 2 +- packages/replay/test/unit/index.test.ts | 6 +- 5 files changed, 121 insertions(+), 118 deletions(-) diff --git a/packages/replay/src/index.ts b/packages/replay/src/index.ts index e433aadee9b7..01ed1adb522d 100644 --- a/packages/replay/src/index.ts +++ b/packages/replay/src/index.ts @@ -33,7 +33,7 @@ export class Replay implements Integration { /** In tests, this is only called the first time */ protected _hasCalledSetupOnce: boolean = false; - private replay?: ReplayContainer; + private _replay?: ReplayContainer; constructor({ flushMinDelay = 5000, @@ -150,11 +150,11 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`, * PerformanceObserver, Recording, Sentry SDK, etc) */ start(): void { - if (!this.replay) { + if (!this._replay) { return; } - this.replay.start(); + this._replay.start(); } /** @@ -162,18 +162,18 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`, * does not support a teardown */ stop(): void { - if (!this.replay) { + if (!this._replay) { return; } - this.replay.stop(); + this._replay.stop(); } private _setup(): void { // Client is not available in constructor, so we need to wait until setupOnce this._loadReplayOptionsFromClient(); - this.replay = new ReplayContainer({ + this._replay = new ReplayContainer({ options: this.options, recordingOptions: this.recordingOptions, }); diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts index 8e38c553f979..87e6cfb2186d 100644 --- a/packages/replay/src/replay.ts +++ b/packages/replay/src/replay.ts @@ -66,54 +66,54 @@ export class ReplayContainer { readonly options: ReplayPluginOptions; - private performanceObserver: PerformanceObserver | null = null; + private _performanceObserver: PerformanceObserver | null = null; - private retryCount: number = 0; - private retryInterval: number = BASE_RETRY_INTERVAL; + private _retryCount: number = 0; + private _retryInterval: number = BASE_RETRY_INTERVAL; - private debouncedFlush: ReturnType; - private flushLock: Promise | null = null; + private _debouncedFlush: ReturnType; + private _flushLock: Promise | null = null; /** * Timestamp of the last user activity. This lives across sessions. */ - private lastActivity: number = new Date().getTime(); + private _lastActivity: number = new Date().getTime(); /** * Is the integration currently active? */ - private isEnabled: boolean = false; + private _isEnabled: boolean = false; /** * Paused is a state where: * - DOM Recording is not listening at all * - Nothing will be added to event buffer (e.g. core SDK events) */ - private isPaused: boolean = false; + private _isPaused: boolean = false; /** * Integration will wait until an error occurs before creating and sending a * replay. */ - private waitForError: boolean = false; + private _waitForError: boolean = false; /** * Have we attached listeners to the core SDK? * Note we have to track this as there is no way to remove instrumentation handlers. */ - private hasInitializedCoreListeners: boolean = false; + private _hasInitializedCoreListeners: boolean = false; /** * Function to stop recording */ - private stopRecording: ReturnType | null = null; + private _stopRecording: ReturnType | null = null; /** * We overwrite `client.recordDroppedEvent`, but store the original method here so we can restore it. */ private _originalRecordDroppedEvent?: Client['recordDroppedEvent']; - private context: InternalEventContext = { + private _context: InternalEventContext = { errorIds: new Set(), traceIds: new Set(), urls: [], @@ -126,7 +126,7 @@ export class ReplayContainer { this.recordingOptions = recordingOptions; this.options = options; - this.debouncedFlush = debounce(() => this.flush(), this.options.flushMinDelay, { + this._debouncedFlush = debounce(() => this.flush(), this.options.flushMinDelay, { maxWait: this.options.flushMaxDelay, }); } @@ -135,7 +135,7 @@ export class ReplayContainer { * Initializes the plugin. * * Creates or loads a session, attaches listeners to varying events (DOM, - * PerformanceObserver, Recording, Sentry SDK, etc) + * _performanceObserver, Recording, Sentry SDK, etc) */ start(): void { this.setInitialState(); @@ -160,7 +160,7 @@ export class ReplayContainer { if (this.session.sampled === 'error') { // Checkout every minute, meaning we only get up-to one minute of events before the error happens this.recordingOptions.checkoutEveryNms = 60000; - this.waitForError = true; + this._waitForError = true; } // setup() is generally called on page load or manually - in both cases we @@ -175,7 +175,7 @@ export class ReplayContainer { this.startRecording(); - this.isEnabled = true; + this._isEnabled = true; } /** @@ -185,7 +185,7 @@ export class ReplayContainer { */ startRecording(): void { try { - this.stopRecording = record({ + this._stopRecording = record({ ...this.recordingOptions, emit: this.handleRecordingEmit, }); @@ -202,9 +202,9 @@ export class ReplayContainer { stop(): void { try { __DEBUG_BUILD__ && logger.log('[Replay] Stopping Replays'); - this.isEnabled = false; + this._isEnabled = false; this.removeListeners(); - this.stopRecording?.(); + this._stopRecording?.(); this.eventBuffer?.destroy(); this.eventBuffer = null; } catch (err) { @@ -214,16 +214,16 @@ export class ReplayContainer { } /** - * Pause some replay functionality. See comments for `isPaused`. + * Pause some replay functionality. See comments for `_isPaused`. * This differs from stop as this only stops DOM recording, it is * not as thorough of a shutdown as `stop()`. */ pause(): void { - this.isPaused = true; + this._isPaused = true; try { - if (this.stopRecording) { - this.stopRecording(); - this.stopRecording = undefined; + if (this._stopRecording) { + this._stopRecording(); + this._stopRecording = undefined; } } catch (err) { __DEBUG_BUILD__ && logger.error('[Replay]', err); @@ -238,7 +238,7 @@ export class ReplayContainer { * new DOM checkout.` */ resume(): void { - this.isPaused = false; + this._isPaused = false; this.startRecording(); } @@ -290,12 +290,12 @@ export class ReplayContainer { this.performanceEvents = []; - // Reset context as well + // Reset _context as well this.clearContext(); - this.context.initialUrl = url; - this.context.initialTimestamp = new Date().getTime(); - this.context.urls.push(url); + this._context.initialUrl = url; + this._context.initialTimestamp = new Date().getTime(); + this._context.urls.push(url); } /** @@ -311,7 +311,7 @@ export class ReplayContainer { this._overwriteRecordDroppedEvent(); // There is no way to remove these listeners, so ensure they are only added once - if (!this.hasInitializedCoreListeners) { + if (!this._hasInitializedCoreListeners) { // Listeners from core SDK // const scope = getCurrentHub().getScope(); scope?.addScopeListener(this.handleCoreBreadcrumbListener('scope')); @@ -324,19 +324,19 @@ export class ReplayContainer { // replay ID so that we can reference them later in the UI addGlobalEventProcessor(this.handleGlobalEvent); - this.hasInitializedCoreListeners = true; + this._hasInitializedCoreListeners = true; } } catch (err) { __DEBUG_BUILD__ && logger.error('[Replay]', err); captureInternalException(err); } - // PerformanceObserver // - if (!('PerformanceObserver' in WINDOW)) { + // _performanceObserver // + if (!('_performanceObserver' in WINDOW)) { return; } - this.performanceObserver = new PerformanceObserver(this.handlePerformanceObserver); + this._performanceObserver = new PerformanceObserver(this.handle_performanceObserver); // Observe almost everything for now (no mark/measure) [ @@ -351,7 +351,7 @@ export class ReplayContainer { 'resource', ].forEach(type => { try { - this.performanceObserver?.observe({ + this._performanceObserver?.observe({ type, buffered: true, }); @@ -374,9 +374,9 @@ export class ReplayContainer { this._restoreRecordDroppedEvent(); - if (this.performanceObserver) { - this.performanceObserver.disconnect(); - this.performanceObserver = null; + if (this._performanceObserver) { + this._performanceObserver.disconnect(); + this._performanceObserver = null; } } catch (err) { __DEBUG_BUILD__ && logger.error('[Replay]', err); @@ -393,12 +393,12 @@ export class ReplayContainer { * processing and hand back control to caller. */ addUpdate(cb?: AddUpdateCallback): void { - // We need to always run `cb` (e.g. in the case of `this.waitForError == true`) + // We need to always run `cb` (e.g. in the case of `this._waitForError == true`) const cbResult = cb?.(); // If this option is turned on then we will only want to call `flush` // explicitly - if (this.waitForError) { + if (this._waitForError) { return; } @@ -408,9 +408,9 @@ export class ReplayContainer { return; } - // addUpdate is called quite frequently - use debouncedFlush so that it + // addUpdate is called quite frequently - use _debouncedFlush so that it // respects the flush delays and does not flush immediately - this.debouncedFlush(); + this._debouncedFlush(); } /** @@ -431,20 +431,20 @@ export class ReplayContainer { } // Only tag transactions with replayId if not waiting for an error - if (event.type !== 'transaction' || !this.waitForError) { + if (event.type !== 'transaction' || !this._waitForError) { event.tags = { ...event.tags, replayId: this.session?.id }; } - // Collect traceIds in context regardless of `waitForError` - if it's true, - // context gets cleared on every checkout + // Collect traceIds in _context regardless of `_waitForError` - if it's true, + // _context gets cleared on every checkout if (event.type === 'transaction') { - this.context.traceIds.add(String(event.contexts?.trace?.trace_id || '')); + this._context.traceIds.add(String(event.contexts?.trace?.trace_id || '')); return event; } // XXX: Is it safe to assume that all other events are error events? // @ts-ignore: Type 'undefined' is not assignable to type 'string'.ts(2345) - this.context.errorIds.add(event.event_id); + this._context.errorIds.add(event.event_id); const exc = event.exception?.values?.[0]; addInternalBreadcrumb({ @@ -455,7 +455,7 @@ export class ReplayContainer { // Need to be very careful that this does not cause an infinite loop if ( - this.waitForError && + this._waitForError && event.exception && event.message !== UNABLE_TO_SEND_REPLAY // ignore this error because otherwise we could loop indefinitely with trying to capture replay and failing ) { @@ -466,12 +466,12 @@ export class ReplayContainer { // than the session replay. await this.flushImmediate(); - if (this.stopRecording) { - this.stopRecording(); + if (this._stopRecording) { + this._stopRecording(); // Reset all "capture on error" configuration before // starting a new recording delete this.recordingOptions.checkoutEveryNms; - this.waitForError = false; + this._waitForError = false; this.startRecording(); } }); @@ -502,7 +502,7 @@ export class ReplayContainer { // when an error occurs. Clear any state that happens before this current // checkout. This needs to happen before `addEvent()` which updates state // dependent on this reset. - if (this.waitForError && event.type === 2) { + if (this._waitForError && event.type === 2) { this.setInitialState(); } @@ -528,8 +528,8 @@ export class ReplayContainer { // See note above re: session start needs to reflect the most recent // checkout. - if (this.waitForError && this.session && this.context.earliestEvent) { - this.session.started = this.context.earliestEvent; + if (this._waitForError && this.session && this._context.earliestEvent) { + this.session.started = this._context.earliestEvent; this._maybeSaveSession(); } @@ -547,7 +547,7 @@ export class ReplayContainer { // This can happen because there's no guarantee that a recording event // happens first. e.g. a mouse click can happen and trigger a debounced // flush before the checkout. - this.debouncedFlush?.cancel(); + this._debouncedFlush?.cancel(); return true; }); @@ -601,7 +601,7 @@ export class ReplayContainer { handleCoreSpanListener: (type: InstrumentationTypeSpan) => (handlerData: unknown) => void = (type: InstrumentationTypeSpan) => (handlerData: unknown): void => { - if (!this.isEnabled) { + if (!this._isEnabled) { return; } @@ -613,7 +613,7 @@ export class ReplayContainer { if (type === 'history') { // Need to collect visited URLs - this.context.urls.push(result.name); + this._context.urls.push(result.name); this.triggerUserActivity(); } @@ -634,7 +634,7 @@ export class ReplayContainer { handleCoreBreadcrumbListener: (type: InstrumentationTypeBreadcrumb) => (handlerData: unknown) => void = (type: InstrumentationTypeBreadcrumb) => (handlerData: unknown): void => { - if (!this.isEnabled) { + if (!this._isEnabled) { return; } @@ -674,7 +674,7 @@ export class ReplayContainer { /** * Keep a list of performance entries that will be sent with a replay */ - handlePerformanceObserver: (list: PerformanceObserverEntryList) => void = (list: PerformanceObserverEntryList) => { + handle_performanceObserver: (list: PerformanceObserverEntryList) => void = (list: PerformanceObserverEntryList) => { // For whatever reason the observer was returning duplicate navigation // entries (the other entry types were not duplicated). const newPerformanceEntries = dedupePerformanceEntries( @@ -743,11 +743,11 @@ export class ReplayContainer { */ addEvent(event: RecordingEvent, isCheckout?: boolean): void { if (!this.eventBuffer) { - // This implies that `isEnabled` is false + // This implies that `_isEnabled` is false return; } - if (this.isPaused) { + if (this._isPaused) { // Do not add to event buffer when recording is paused return; } @@ -767,8 +767,11 @@ export class ReplayContainer { // Only record earliest event if a new session was created, otherwise it // shouldn't be relevant - if (this.session?.segmentId === 0 && (!this.context.earliestEvent || timestampInMs < this.context.earliestEvent)) { - this.context.earliestEvent = timestampInMs; + if ( + this.session?.segmentId === 0 && + (!this._context.earliestEvent || timestampInMs < this._context.earliestEvent) + ) { + this._context.earliestEvent = timestampInMs; } this.eventBuffer.addEvent(event, isCheckout); @@ -777,16 +780,16 @@ export class ReplayContainer { /** * Update user activity (across session lifespans) */ - updateUserActivity(lastActivity: number = new Date().getTime()): void { - this.lastActivity = lastActivity; + updateUserActivity(_lastActivity: number = new Date().getTime()): void { + this._lastActivity = _lastActivity; } /** * Updates the session's last activity timestamp */ - updateSessionActivity(lastActivity: number = new Date().getTime()): void { + updateSessionActivity(_lastActivity: number = new Date().getTime()): void { if (this.session) { - this.session.lastActivity = lastActivity; + this.session.lastActivity = _lastActivity; this._maybeSaveSession(); } } @@ -801,7 +804,7 @@ export class ReplayContainer { // This case means that recording was once stopped due to inactivity. // Ensure that recording is resumed. - if (!this.stopRecording) { + if (!this._stopRecording) { // Create a new session, otherwise when the user action is flushed, it // will get rejected due to an expired session. this.loadSession({ expiry: SESSION_IDLE_DURATION }); @@ -900,7 +903,7 @@ export class ReplayContainer { // MAX_SESSION_LIFE. Otherwise non-user activity can trigger a new // session+recording. This creates noisy replays that do not have much // content in them. - if (this.lastActivity && isExpired(this.lastActivity, MAX_SESSION_LIFE)) { + if (this._lastActivity && isExpired(this._lastActivity, MAX_SESSION_LIFE)) { // Pause recording this.pause(); return; @@ -924,10 +927,10 @@ export class ReplayContainer { } /** - * Only flush if `this.waitForError` is false. + * Only flush if `this._waitForError` is false. */ conditionalFlush(): void { - if (this.waitForError) { + if (this._waitForError) { return; } @@ -935,35 +938,35 @@ export class ReplayContainer { } /** - * Clear context + * Clear _context */ clearContext(): void { // XXX: `initialTimestamp` and `initialUrl` do not get cleared - this.context.errorIds.clear(); - this.context.traceIds.clear(); - this.context.urls = []; - this.context.earliestEvent = null; + this._context.errorIds.clear(); + this._context.traceIds.clear(); + this._context.urls = []; + this._context.earliestEvent = null; } /** - * Return and clear context + * Return and clear _context */ popEventContext(): PopEventContext { - if (this.context.earliestEvent && this.context.earliestEvent < this.context.initialTimestamp) { - this.context.initialTimestamp = this.context.earliestEvent; + if (this._context.earliestEvent && this._context.earliestEvent < this._context.initialTimestamp) { + this._context.initialTimestamp = this._context.earliestEvent; } - const context = { - initialTimestamp: this.context.initialTimestamp, - initialUrl: this.context.initialUrl, - errorIds: Array.from(this.context.errorIds).filter(Boolean), - traceIds: Array.from(this.context.traceIds).filter(Boolean), - urls: this.context.urls, + const _context = { + initialTimestamp: this._context.initialTimestamp, + initialUrl: this._context.initialUrl, + errorIds: Array.from(this._context.errorIds).filter(Boolean), + traceIds: Array.from(this._context.traceIds).filter(Boolean), + urls: this._context.urls, }; this.clearContext(); - return context; + return _context; } /** @@ -1019,7 +1022,7 @@ export class ReplayContainer { * can be active at a time. Do not call this directly. */ flush: () => Promise = async () => { - if (!this.isEnabled) { + if (!this._isEnabled) { // This is just a precaution, there should be no listeners that would // cause a flush. return; @@ -1036,15 +1039,15 @@ export class ReplayContainer { } // A flush is about to happen, cancel any queued flushes - this.debouncedFlush?.cancel(); + this._debouncedFlush?.cancel(); // No existing flush in progress, proceed with flushing. - // this.flushLock acts as a lock so that future calls to `flush()` + // this._flushLock acts as a lock so that future calls to `flush()` // will be blocked until this promise resolves - if (!this.flushLock) { - this.flushLock = this.runFlush(); - await this.flushLock; - this.flushLock = null; + if (!this._flushLock) { + this._flushLock = this.runFlush(); + await this._flushLock; + this._flushLock = null; return; } @@ -1055,24 +1058,24 @@ export class ReplayContainer { // completing) into a single flush. try { - await this.flushLock; + await this._flushLock; } catch (err) { __DEBUG_BUILD__ && logger.error(err); } finally { - this.debouncedFlush(); + this._debouncedFlush(); } }; /** * - * Always flush via `debouncedFlush` so that we do not have flushes triggered - * from calling both `flush` and `debouncedFlush`. Otherwise, there could be + * Always flush via `_debouncedFlush` so that we do not have flushes triggered + * from calling both `flush` and `_debouncedFlush`. Otherwise, there could be * cases of mulitple flushes happening closely together. */ flushImmediate(): Promise { - this.debouncedFlush(); + this._debouncedFlush(); // `.flush` is provided by lodash.debounce - return this.debouncedFlush.flush(); + return this._debouncedFlush.flush(); } /** @@ -1173,8 +1176,8 @@ export class ReplayContainer { } resetRetries(): void { - this.retryCount = 0; - this.retryInterval = BASE_RETRY_INTERVAL; + this._retryCount = 0; + this._retryInterval = BASE_RETRY_INTERVAL; } /** @@ -1206,19 +1209,19 @@ export class ReplayContainer { __DEBUG_BUILD__ && logger.error(err); // Capture error for every failed replay setContext('Replays', { - retryCount: this.retryCount, + _retryCount: this._retryCount, }); captureInternalException(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) { + if (this._retryCount >= MAX_RETRY_COUNT) { throw new Error(`${UNABLE_TO_SEND_REPLAY} - max retries exceeded`); } - this.retryCount = this.retryCount + 1; + this._retryCount = this._retryCount + 1; // will retry in intervals of 5, 10, 30 - this.retryInterval = this.retryCount * this.retryInterval; + this._retryInterval = this._retryCount * this._retryInterval; return await new Promise((resolve, reject) => { setTimeout(async () => { @@ -1234,7 +1237,7 @@ export class ReplayContainer { } catch (err) { reject(err); } - }, this.retryInterval); + }, this._retryInterval); }); } } @@ -1261,7 +1264,7 @@ export class ReplayContainer { event?: Event, ): void => { if (event && event.event_id) { - this.context.errorIds.delete(event.event_id); + this._context.errorIds.delete(event.event_id); } return _originalCallback(reason, category, event); diff --git a/packages/replay/test/mocks/mockSdk.ts b/packages/replay/test/mocks/mockSdk.ts index 24c156728296..c6e2df60f4b8 100644 --- a/packages/replay/test/mocks/mockSdk.ts +++ b/packages/replay/test/mocks/mockSdk.ts @@ -67,7 +67,7 @@ export async function mockSdk({ replayOptions, sentryOptions }: MockSdkParams = replayIntegration['_setup'](); } - const replay = replayIntegration['replay']!; + const replay = replayIntegration['_replay']!; return { replay, integration: replayIntegration }; } diff --git a/packages/replay/test/unit/index-handleGlobalEvent.test.ts b/packages/replay/test/unit/index-handleGlobalEvent.test.ts index 78b1bd55234b..005df4f93637 100644 --- a/packages/replay/test/unit/index-handleGlobalEvent.test.ts +++ b/packages/replay/test/unit/index-handleGlobalEvent.test.ts @@ -83,7 +83,7 @@ it('only tags errors with replay id, adds trace and error id to context for erro jest.runAllTimers(); await new Promise(process.nextTick); // wait for flush - // Turns off `waitForError` mode + // Turns off `_waitForError` mode // @ts-ignore private expect(replay._waitForError).toBe(false); }); diff --git a/packages/replay/test/unit/index.test.ts b/packages/replay/test/unit/index.test.ts index ffcb4c1c3522..dc38bf33c4d1 100644 --- a/packages/replay/test/unit/index.test.ts +++ b/packages/replay/test/unit/index.test.ts @@ -422,7 +422,7 @@ describe('Replay', () => { ]), }); - // `context` should be reset when a new session is created + // `_context` should be reset when a new session is created // @ts-ignore private member expect(replay._context).toEqual( expect.objectContaining({ @@ -483,7 +483,7 @@ describe('Replay', () => { expect(replay).toHaveSameSession(initialSession); // @ts-ignore private - expect(replay.stopRecording).toBeUndefined(); + expect(replay._stopRecording).toBeUndefined(); // Now do a click domHandler({ @@ -535,7 +535,7 @@ describe('Replay', () => { ]), }); - // `context` should be reset when a new session is created + // `_context` should be reset when a new session is created // @ts-ignore private member expect(replay._context).toEqual( expect.objectContaining({