diff --git a/packages/replay/jest.setup.ts b/packages/replay/jest.setup.ts index 24e2e2088aa4..481221831f73 100644 --- a/packages/replay/jest.setup.ts +++ b/packages/replay/jest.setup.ts @@ -2,8 +2,7 @@ import { getCurrentHub } from '@sentry/core'; import { Transport } from '@sentry/types'; -import { ReplayContainer } from './src/replay'; -import { Session } from './src/session/Session'; +import type { ReplayContainer, Session } from './src/types'; // @ts-ignore TS error, this is replaced in prod builds bc of rollup global.__SENTRY_REPLAY_VERSION__ = 'version:Test'; diff --git a/packages/replay/src/constants.ts b/packages/replay/src/constants.ts index b7cbfe52873d..47da014c0e78 100644 --- a/packages/replay/src/constants.ts +++ b/packages/replay/src/constants.ts @@ -9,6 +9,7 @@ export const WINDOW = GLOBAL_OBJ as typeof GLOBAL_OBJ & Window; export const REPLAY_SESSION_KEY = 'sentryReplaySession'; export const REPLAY_EVENT_NAME = 'replay_event'; export const RECORDING_EVENT_NAME = 'replay_recording'; +export const UNABLE_TO_SEND_REPLAY = 'Unable to send Replay'; // The idle limit for a session export const SESSION_IDLE_DURATION = 300_000; // 5 minutes in ms diff --git a/packages/replay/src/coreHandlers/breadcrumbHandler.ts b/packages/replay/src/coreHandlers/breadcrumbHandler.ts index 8f3dd1e78983..fe0504b0230f 100644 --- a/packages/replay/src/coreHandlers/breadcrumbHandler.ts +++ b/packages/replay/src/coreHandlers/breadcrumbHandler.ts @@ -1,6 +1,6 @@ import { Breadcrumb, Scope } from '@sentry/types'; -import { InstrumentationTypeBreadcrumb } from '../types'; +import type { InstrumentationTypeBreadcrumb } from '../types'; import { DomHandlerData, handleDom } from './handleDom'; import { handleScope } from './handleScope'; diff --git a/packages/replay/src/coreHandlers/handleFetch.ts b/packages/replay/src/coreHandlers/handleFetch.ts index e68e2d911134..1b6430d52413 100644 --- a/packages/replay/src/coreHandlers/handleFetch.ts +++ b/packages/replay/src/coreHandlers/handleFetch.ts @@ -1,7 +1,9 @@ -import { ReplayPerformanceEntry } from '../createPerformanceEntry'; +import type { ReplayPerformanceEntry } from '../createPerformanceEntry'; +import type { ReplayContainer } from '../types'; +import { createPerformanceSpans } from '../util/createPerformanceSpans'; import { isIngestHost } from '../util/isIngestHost'; -export interface FetchHandlerData { +interface FetchHandlerData { args: Parameters; fetchData: { method: string; @@ -18,6 +20,7 @@ export interface FetchHandlerData { endTimestamp?: number; } +/** only exported for tests */ export function handleFetch(handlerData: FetchHandlerData): null | ReplayPerformanceEntry { if (!handlerData.endTimestamp) { return null; @@ -41,3 +44,28 @@ export function handleFetch(handlerData: FetchHandlerData): null | ReplayPerform }, }; } + +/** + * Returns a listener to be added to `addInstrumentationHandler('fetch', listener)`. + */ +export function handleFetchSpanListener(replay: ReplayContainer): (handlerData: FetchHandlerData) => void { + return (handlerData: FetchHandlerData) => { + if (!replay.isEnabled()) { + return; + } + + const result = handleFetch(handlerData); + + if (result === null) { + return; + } + + replay.addUpdate(() => { + createPerformanceSpans(replay, [result]); + // 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 true; + }); + }; +} diff --git a/packages/replay/src/coreHandlers/handleGlobalEvent.ts b/packages/replay/src/coreHandlers/handleGlobalEvent.ts new file mode 100644 index 000000000000..1fa843a86cfd --- /dev/null +++ b/packages/replay/src/coreHandlers/handleGlobalEvent.ts @@ -0,0 +1,74 @@ +import { Event } from '@sentry/types'; + +import { REPLAY_EVENT_NAME, UNABLE_TO_SEND_REPLAY } from '../constants'; +import type { ReplayContainer } from '../types'; +import { addInternalBreadcrumb } from '../util/addInternalBreadcrumb'; + +/** + * Returns a listener to be added to `addGlobalEventProcessor(listener)`. + */ +export function handleGlobalEventListener(replay: ReplayContainer): (event: Event) => Event { + return (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 + // @ts-ignore private + if (event.type !== 'transaction' || !replay._waitForError) { + event.tags = { ...event.tags, replayId: replay.session?.id }; + } + + // Collect traceIds in _context regardless of `_waitForError` - if it's true, + // _context gets cleared on every checkout + if (event.type === 'transaction' && event.contexts && event.contexts.trace && event.contexts.trace.trace_id) { + replay.getContext().traceIds.add(event.contexts.trace.trace_id as string); + return event; + } + + // no event type means error + if (!event.type) { + replay.getContext().errorIds.add(event.event_id as string); + } + + 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 ( + // @ts-ignore private + replay._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 replay.flushImmediate(); + + if (replay.stopRecording()) { + // Reset all "capture on error" configuration before + // starting a new recording + // @ts-ignore private + replay._waitForError = false; + replay.startRecording(); + } + }); + } + + return event; + }; +} diff --git a/packages/replay/src/coreHandlers/handleHistory.ts b/packages/replay/src/coreHandlers/handleHistory.ts index a94d19455192..f806d2d3c75b 100644 --- a/packages/replay/src/coreHandlers/handleHistory.ts +++ b/packages/replay/src/coreHandlers/handleHistory.ts @@ -1,11 +1,13 @@ import { ReplayPerformanceEntry } from '../createPerformanceEntry'; +import type { ReplayContainer } from '../types'; +import { createPerformanceSpans } from '../util/createPerformanceSpans'; -export interface HistoryHandlerData { +interface HistoryHandlerData { from: string; to: string; } -export function handleHistory(handlerData: HistoryHandlerData): ReplayPerformanceEntry { +function handleHistory(handlerData: HistoryHandlerData): ReplayPerformanceEntry { const { from, to } = handlerData; const now = new Date().getTime() / 1000; @@ -20,3 +22,30 @@ export function handleHistory(handlerData: HistoryHandlerData): ReplayPerformanc }, }; } + +/** + * Returns a listener to be added to `addInstrumentationHandler('history', listener)`. + */ +export function handleHistorySpanListener(replay: ReplayContainer): (handlerData: HistoryHandlerData) => void { + return (handlerData: HistoryHandlerData) => { + if (!replay.isEnabled()) { + return; + } + + const result = handleHistory(handlerData); + + if (result === null) { + return; + } + + // Need to collect visited URLs + replay.getContext().urls.push(result.name); + replay.triggerUserActivity(); + + replay.addUpdate(() => { + createPerformanceSpans(replay, [result]); + // Returning false to flush + return false; + }); + }; +} diff --git a/packages/replay/src/coreHandlers/handleXhr.ts b/packages/replay/src/coreHandlers/handleXhr.ts index a37e7374ab82..6577e91f3b92 100644 --- a/packages/replay/src/coreHandlers/handleXhr.ts +++ b/packages/replay/src/coreHandlers/handleXhr.ts @@ -1,4 +1,6 @@ import { ReplayPerformanceEntry } from '../createPerformanceEntry'; +import type { ReplayContainer } from '../types'; +import { createPerformanceSpans } from '../util/createPerformanceSpans'; import { isIngestHost } from '../util/isIngestHost'; // From sentry-javascript @@ -19,14 +21,14 @@ interface SentryWrappedXMLHttpRequest extends XMLHttpRequest { __sentry_own_request__?: boolean; } -export interface XhrHandlerData { +interface XhrHandlerData { args: [string, string]; xhr: SentryWrappedXMLHttpRequest; startTimestamp: number; endTimestamp?: number; } -export function handleXhr(handlerData: XhrHandlerData): ReplayPerformanceEntry | null { +function handleXhr(handlerData: XhrHandlerData): ReplayPerformanceEntry | null { if (handlerData.xhr.__sentry_own_request__) { // Taken from sentry-javascript // Only capture non-sentry requests @@ -61,3 +63,28 @@ export function handleXhr(handlerData: XhrHandlerData): ReplayPerformanceEntry | }, }; } + +/** + * Returns a listener to be added to `addInstrumentationHandler('xhr', listener)`. + */ +export function handleXhrSpanListener(replay: ReplayContainer): (handlerData: XhrHandlerData) => void { + return (handlerData: XhrHandlerData) => { + if (!replay.isEnabled()) { + return; + } + + const result = handleXhr(handlerData); + + if (result === null) { + return; + } + + replay.addUpdate(() => { + createPerformanceSpans(replay, [result]); + // 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 true; + }); + }; +} diff --git a/packages/replay/src/coreHandlers/performanceObserver.ts b/packages/replay/src/coreHandlers/performanceObserver.ts new file mode 100644 index 000000000000..a7dc1de13244 --- /dev/null +++ b/packages/replay/src/coreHandlers/performanceObserver.ts @@ -0,0 +1,43 @@ +import type { AllPerformanceEntry, ReplayContainer } from '../types'; +import { dedupePerformanceEntries } from '../util/dedupePerformanceEntries'; + +/** + * Sets up a PerformanceObserver to listen to all performance entry types. + */ +export function setupPerformanceObserver(replay: ReplayContainer): PerformanceObserver { + const performanceObserverHandler = (list: PerformanceObserverEntryList): void => { + // For whatever reason the observer was returning duplicate navigation + // entries (the other entry types were not duplicated). + const newPerformanceEntries = dedupePerformanceEntries( + replay.performanceEvents, + list.getEntries() as AllPerformanceEntry[], + ); + replay.performanceEvents = newPerformanceEntries; + }; + + const performanceObserver = new PerformanceObserver(performanceObserverHandler); + + [ + 'element', + 'event', + 'first-input', + 'largest-contentful-paint', + 'layout-shift', + 'longtask', + 'navigation', + 'paint', + 'resource', + ].forEach(type => { + try { + performanceObserver.observe({ + type, + buffered: true, + }); + } catch { + // This can throw if an entry type is not supported in the browser. + // Ignore these errors. + } + }); + + return performanceObserver; +} diff --git a/packages/replay/src/coreHandlers/spanHandler.ts b/packages/replay/src/coreHandlers/spanHandler.ts deleted file mode 100644 index c69b908b5137..000000000000 --- a/packages/replay/src/coreHandlers/spanHandler.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { ReplayPerformanceEntry } from '../createPerformanceEntry'; -import { InstrumentationTypeSpan } from '../types'; -import { FetchHandlerData, handleFetch } from './handleFetch'; -import { handleHistory, HistoryHandlerData } from './handleHistory'; -import { handleXhr, XhrHandlerData } from './handleXhr'; - -export function spanHandler(type: InstrumentationTypeSpan, handlerData: unknown): null | ReplayPerformanceEntry { - if (type === 'fetch') { - return handleFetch(handlerData as FetchHandlerData); - } - - if (type === 'xhr') { - return handleXhr(handlerData as XhrHandlerData); - } - - return handleHistory(handlerData as HistoryHandlerData); -} diff --git a/packages/replay/src/createPerformanceEntry.ts b/packages/replay/src/createPerformanceEntry.ts index 329366a8a04f..33f6ae1a6a4d 100644 --- a/packages/replay/src/createPerformanceEntry.ts +++ b/packages/replay/src/createPerformanceEntry.ts @@ -2,7 +2,7 @@ import { browserPerformanceTimeOrigin } from '@sentry/utils'; import { record } from 'rrweb'; import { WINDOW } from './constants'; -import { AllPerformanceEntry, PerformanceNavigationTiming, PerformancePaintTiming } from './types'; +import type { AllPerformanceEntry, PerformanceNavigationTiming, PerformancePaintTiming } from './types'; import { isIngestHost } from './util/isIngestHost'; export interface ReplayPerformanceEntry { diff --git a/packages/replay/src/eventBuffer.ts b/packages/replay/src/eventBuffer.ts index 3ebf87229668..b83942be4f67 100644 --- a/packages/replay/src/eventBuffer.ts +++ b/packages/replay/src/eventBuffer.ts @@ -4,7 +4,7 @@ import { captureException } from '@sentry/core'; import { logger } from '@sentry/utils'; -import { RecordingEvent, WorkerRequest, WorkerResponse } from './types'; +import type { EventBuffer, RecordingEvent, WorkerRequest, WorkerResponse } from './types'; import workerString from './worker/worker.js'; interface CreateEventBufferParams { @@ -35,13 +35,6 @@ export function createEventBuffer({ useCompression }: CreateEventBufferParams): return new EventBufferArray(); } -export interface EventBuffer { - readonly length: number; - destroy(): void; - addEvent(event: RecordingEvent, isCheckout?: boolean): void; - finish(): Promise; -} - class EventBufferArray implements EventBuffer { private _events: RecordingEvent[]; diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts index e5c0007752bf..ef5c4636f1fa 100644 --- a/packages/replay/src/replay.ts +++ b/packages/replay/src/replay.ts @@ -1,55 +1,61 @@ /* 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 { Breadcrumb, Client, Event } 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, + UNABLE_TO_SEND_REPLAY, 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 { handleFetchSpanListener } from './coreHandlers/handleFetch'; +import { handleGlobalEventListener } from './coreHandlers/handleGlobalEvent'; +import { handleHistorySpanListener } from './coreHandlers/handleHistory'; +import { handleXhrSpanListener } from './coreHandlers/handleXhr'; +import { setupPerformanceObserver } from './coreHandlers/performanceObserver'; +import { createPerformanceEntries } from './createPerformanceEntry'; +import { createEventBuffer } from './eventBuffer'; import { deleteSession } from './session/deleteSession'; import { getSession } from './session/getSession'; import { saveSession } from './session/saveSession'; -import { Session } from './session/Session'; import type { + AddUpdateCallback, AllPerformanceEntry, + EventBuffer, InstrumentationTypeBreadcrumb, - InstrumentationTypeSpan, InternalEventContext, PopEventContext, RecordingEvent, RecordingOptions, + ReplayContainer as ReplayContainerInterface, ReplayPluginOptions, SendReplay, + Session, } from './types'; -import { addInternalBreadcrumb } from './util/addInternalBreadcrumb'; +import { addEvent } from './util/addEvent'; +import { addMemoryEntry } from './util/addMemoryEntry'; import { captureInternalException } from './util/captureInternalException'; import { createBreadcrumb } from './util/createBreadcrumb'; import { createPayload } from './util/createPayload'; -import { dedupePerformanceEntries } from './util/dedupePerformanceEntries'; +import { createPerformanceSpans } from './util/createPerformanceSpans'; import { isExpired } from './util/isExpired'; import { isSessionExpired } from './util/isSessionExpired'; +import { overwriteRecordDroppedEvent, restoreRecordDroppedEvent } from './util/monkeyPatchRecordDroppedEvent'; /** * 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 { +export class ReplayContainer implements ReplayContainerInterface { public eventBuffer: EventBuffer | null = null; /** @@ -108,11 +114,6 @@ export class ReplayContainer { */ 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(), @@ -131,6 +132,21 @@ export class ReplayContainer { }); } + /** Get the event context. */ + public getContext(): InternalEventContext { + return this._context; + } + + /** If recording is currently enabled. */ + public isEnabled(): boolean { + return this._isEnabled; + } + + /** If recording is currently paused. */ + public isPaused(): boolean { + return this._isPaused; + } + /** * Initializes the plugin. * @@ -197,6 +213,19 @@ export class ReplayContainer { } } + /** + * Stops the recording, if it was running. + * Returns true if it was stopped, else false. + */ + public stopRecording(): boolean { + if (this._stopRecording) { + this._stopRecording(); + return true; + } + + return false; + } + /** * Currently, this needs to be manually called (e.g. for tests). Sentry SDK * does not support a teardown @@ -310,7 +339,7 @@ export class ReplayContainer { WINDOW.addEventListener('focus', this.handleWindowFocus); // We need to filter out dropped events captured by `addGlobalEventProcessor(this.handleGlobalEvent)` below - this._overwriteRecordDroppedEvent(); + overwriteRecordDroppedEvent(this._context.errorIds); // There is no way to remove these listeners, so ensure they are only added once if (!this._hasInitializedCoreListeners) { @@ -318,13 +347,13 @@ export class ReplayContainer { 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')); + addInstrumentationHandler('fetch', handleFetchSpanListener(this)); + addInstrumentationHandler('xhr', handleXhrSpanListener(this)); + addInstrumentationHandler('history', handleHistorySpanListener(this)); // 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); + addGlobalEventProcessor(handleGlobalEventListener(this)); this._hasInitializedCoreListeners = true; } @@ -338,30 +367,7 @@ export class ReplayContainer { return; } - this._performanceObserver = new PerformanceObserver(this.handle_performanceObserver); - - // 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. - } - }); + this._performanceObserver = setupPerformanceObserver(this); } /** @@ -374,7 +380,7 @@ export class ReplayContainer { WINDOW.removeEventListener('blur', this.handleWindowBlur); WINDOW.removeEventListener('focus', this.handleWindowFocus); - this._restoreRecordDroppedEvent(); + restoreRecordDroppedEvent(); if (this._performanceObserver) { this._performanceObserver.disconnect(); @@ -394,7 +400,7 @@ export class ReplayContainer { * Accepts a callback to perform side-effects and returns true to stop batch * processing and hand back control to caller. */ - addUpdate(cb?: AddUpdateCallback): void { + addUpdate(cb: AddUpdateCallback): void { // We need to always run `cb` (e.g. in the case of `this._waitForError == true`) const cbResult = cb?.(); @@ -415,72 +421,6 @@ export class ReplayContainer { 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 - this._waitForError = false; - this.startRecording(); - } - }); - } - - return event; - }; - /** * Handler for recording events. * @@ -509,7 +449,7 @@ export class ReplayContainer { // We need to clear existing events on a checkout, otherwise they are // incremental event updates and should be appended - this.addEvent(event, isCheckout); + addEvent(this, 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 @@ -594,39 +534,6 @@ export class ReplayContainer { 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. * @@ -656,7 +563,7 @@ export class ReplayContainer { } this.addUpdate(() => { - this.addEvent({ + addEvent(this, { type: EventType.Custom, // TODO: We were converting from ms to seconds for breadcrumbs, spans, // but maybe we should just keep them as milliseconds @@ -672,19 +579,6 @@ export class ReplayContainer { }); }; - /** - * Keep a list of performance entries that will be sent with a replay - */ - 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( - 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) */ @@ -739,45 +633,6 @@ export class ReplayContainer { 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) */ @@ -826,7 +681,7 @@ export class ReplayContainer { */ createCustomBreadcrumb(breadcrumb: Breadcrumb): void { this.addUpdate(() => { - this.addEvent({ + addEvent(this, { type: EventType.Custom, timestamp: breadcrumb.timestamp || 0, data: { @@ -837,57 +692,16 @@ export class ReplayContainer { }); } - /** - * 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 { + addPerformanceEntries(): void { // 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), - ]); + createPerformanceSpans(this, createPerformanceEntries(entries)); } /** @@ -991,7 +805,7 @@ export class ReplayContainer { } // Only attach memory event if eventBuffer is not empty - await this.addMemoryEntry(); + await addMemoryEntry(this); try { // Note this empties the event buffer regardless of outcome of sending replay @@ -1249,39 +1063,4 @@ export class ReplayContainer { 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/src/session/Session.ts b/packages/replay/src/session/Session.ts index 2936a8c19dda..d20fa0daf33c 100644 --- a/packages/replay/src/session/Session.ts +++ b/packages/replay/src/session/Session.ts @@ -1,39 +1,8 @@ import { uuid4 } from '@sentry/utils'; +import type { Sampled, Session } from '../types'; import { isSampled } from '../util/isSampled'; -type Sampled = false | 'session' | 'error'; - -export interface Session { - id: string; - - /** - * Start time of current session - */ - started: number; - - /** - * Last known activity of the session - */ - lastActivity: number; - - /** - * Segment ID for replay events - */ - segmentId: number; - - /** - * The ID of the previous session. - * If this is empty, there was no previous session. - */ - previousSessionId?: string; - - /** - * Is the session sampled? `false` if not sampled, otherwise, `session` or `error` - */ - sampled: Sampled; -} - /** * Get a session with defaults & applied sampling. */ diff --git a/packages/replay/src/session/createSession.ts b/packages/replay/src/session/createSession.ts index 5dcb015fe615..bd6d18ad33e8 100644 --- a/packages/replay/src/session/createSession.ts +++ b/packages/replay/src/session/createSession.ts @@ -1,8 +1,8 @@ import { logger } from '@sentry/utils'; -import { SessionOptions } from '../types'; +import type { Session, SessionOptions } from '../types'; import { saveSession } from './saveSession'; -import { getSessionSampleType, makeSession, Session } from './Session'; +import { getSessionSampleType, makeSession } from './Session'; /** * Create a new session, which in its current implementation is a Sentry event diff --git a/packages/replay/src/session/fetchSession.ts b/packages/replay/src/session/fetchSession.ts index c2dc95c9454c..4b4b1eccf530 100644 --- a/packages/replay/src/session/fetchSession.ts +++ b/packages/replay/src/session/fetchSession.ts @@ -1,5 +1,6 @@ import { REPLAY_SESSION_KEY, WINDOW } from '../constants'; -import { makeSession, Session } from './Session'; +import type { Session } from '../types'; +import { makeSession } from './Session'; /** * Fetches a session from storage diff --git a/packages/replay/src/session/getSession.ts b/packages/replay/src/session/getSession.ts index 103008061ad0..254fd33866c8 100644 --- a/packages/replay/src/session/getSession.ts +++ b/packages/replay/src/session/getSession.ts @@ -1,10 +1,9 @@ import { logger } from '@sentry/utils'; -import { SessionOptions } from '../types'; +import type { Session, SessionOptions } from '../types'; import { isSessionExpired } from '../util/isSessionExpired'; import { createSession } from './createSession'; import { fetchSession } from './fetchSession'; -import { Session } from './Session'; interface GetSessionParams extends SessionOptions { /** diff --git a/packages/replay/src/session/saveSession.ts b/packages/replay/src/session/saveSession.ts index 4f3e1804a25e..a506625436f8 100644 --- a/packages/replay/src/session/saveSession.ts +++ b/packages/replay/src/session/saveSession.ts @@ -1,5 +1,5 @@ import { REPLAY_SESSION_KEY, WINDOW } from '../constants'; -import { Session } from './Session'; +import type { Session } from '../types'; export function saveSession(session: Session): void { const hasSessionStorage = 'sessionStorage' in WINDOW; diff --git a/packages/replay/src/types.ts b/packages/replay/src/types.ts index a3d9cafde299..18d792d27eac 100644 --- a/packages/replay/src/types.ts +++ b/packages/replay/src/types.ts @@ -16,13 +16,6 @@ export interface SendReplay { } export type InstrumentationTypeBreadcrumb = 'dom' | 'scope'; -export type InstrumentationTypeSpan = 'fetch' | 'xhr' | 'history'; -export type InstrumentationType = - | InstrumentationTypeBreadcrumb - | InstrumentationTypeSpan - | 'console' - | 'error' - | 'unhandledrejection'; /** * The request payload to worker @@ -167,3 +160,62 @@ export interface InternalEventContext extends CommonEventContext { */ earliestEvent: number | null; } + +export type Sampled = false | 'session' | 'error'; + +export interface Session { + id: string; + + /** + * Start time of current session + */ + started: number; + + /** + * Last known activity of the session + */ + lastActivity: number; + + /** + * Segment ID for replay events + */ + segmentId: number; + + /** + * The ID of the previous session. + * If this is empty, there was no previous session. + */ + previousSessionId?: string; + + /** + * Is the session sampled? `false` if not sampled, otherwise, `session` or `error` + */ + sampled: Sampled; +} + +export interface EventBuffer { + readonly length: number; + destroy(): void; + addEvent(event: RecordingEvent, isCheckout?: boolean): void; + finish(): Promise; +} + +export type AddUpdateCallback = () => boolean | void; + +export interface ReplayContainer { + eventBuffer: EventBuffer | null; + performanceEvents: AllPerformanceEntry[]; + session: Session | undefined; + isEnabled(): boolean; + isPaused(): boolean; + getContext(): InternalEventContext; + start(): void; + stop(): void; + pause(): void; + resume(): void; + startRecording(): void; + stopRecording(): boolean; + flushImmediate(): void; + triggerUserActivity(): void; + addUpdate(cb: AddUpdateCallback): void; +} diff --git a/packages/replay/src/util/addEvent.ts b/packages/replay/src/util/addEvent.ts new file mode 100644 index 000000000000..34086a4725c5 --- /dev/null +++ b/packages/replay/src/util/addEvent.ts @@ -0,0 +1,39 @@ +import { SESSION_IDLE_DURATION } from '../constants'; +import type { RecordingEvent, ReplayContainer } from '../types'; + +/** + * Add an event to the event buffer + */ +export function addEvent(replay: ReplayContainer, event: RecordingEvent, isCheckout?: boolean): void { + if (!replay.eventBuffer) { + // This implies that `_isEnabled` is false + return; + } + + if (replay.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 + const earliestEvent = replay.getContext().earliestEvent; + if (replay.session?.segmentId === 0 && (!earliestEvent || timestampInMs < earliestEvent)) { + replay.getContext().earliestEvent = timestampInMs; + } + + replay.eventBuffer.addEvent(event, isCheckout); +} diff --git a/packages/replay/src/util/addMemoryEntry.ts b/packages/replay/src/util/addMemoryEntry.ts new file mode 100644 index 000000000000..bac07200cd6c --- /dev/null +++ b/packages/replay/src/util/addMemoryEntry.ts @@ -0,0 +1,19 @@ +import { WINDOW } from '../constants'; +import type { ReplayContainer } from '../types'; +import { createPerformanceSpans } from './createPerformanceSpans'; + +/** + * Create a "span" for the total amount of memory being used by JS objects + * (including v8 internal objects). + */ +export function addMemoryEntry(replay: ReplayContainer): void { + // window.performance.memory is a non-standard API and doesn't work on all browsers, so we try-catch this + try { + createPerformanceSpans(replay, [ + // @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), + ]); + } catch (error) { + // Do nothing + } +} diff --git a/packages/replay/src/util/createPayload.ts b/packages/replay/src/util/createPayload.ts index 222f52cfd4d7..6567703022ef 100644 --- a/packages/replay/src/util/createPayload.ts +++ b/packages/replay/src/util/createPayload.ts @@ -1,4 +1,4 @@ -import { RecordedEvents } from '../types'; +import type { RecordedEvents } from '../types'; export function createPayload({ events, diff --git a/packages/replay/src/util/createPerformanceSpans.ts b/packages/replay/src/util/createPerformanceSpans.ts new file mode 100644 index 000000000000..9bb999a0faa3 --- /dev/null +++ b/packages/replay/src/util/createPerformanceSpans.ts @@ -0,0 +1,27 @@ +import { EventType } from 'rrweb'; + +import { ReplayPerformanceEntry } from '../createPerformanceEntry'; +import type { ReplayContainer } from '../types'; +import { addEvent } from './addEvent'; + +/** + * Create a "span" for each performance entry. The parent transaction is `this.replayEvent`. + */ +export function createPerformanceSpans(replay: ReplayContainer, entries: ReplayPerformanceEntry[]): void { + entries.map(({ type, start, end, name, data }) => + addEvent(replay, { + type: EventType.Custom, + timestamp: start, + data: { + tag: 'performanceSpan', + payload: { + op: type, + description: name, + startTimestamp: start, + endTimestamp: end, + data, + }, + }, + }), + ); +} diff --git a/packages/replay/src/util/isSessionExpired.ts b/packages/replay/src/util/isSessionExpired.ts index 78e12ecf87fd..05ce3d21f69a 100644 --- a/packages/replay/src/util/isSessionExpired.ts +++ b/packages/replay/src/util/isSessionExpired.ts @@ -1,5 +1,5 @@ import { MAX_SESSION_LIFE } from '../constants'; -import { Session } from '../session/Session'; +import type { Session } from '../types'; import { isExpired } from './isExpired'; /** diff --git a/packages/replay/src/util/monkeyPatchRecordDroppedEvent.ts b/packages/replay/src/util/monkeyPatchRecordDroppedEvent.ts new file mode 100644 index 000000000000..70f629301dc8 --- /dev/null +++ b/packages/replay/src/util/monkeyPatchRecordDroppedEvent.ts @@ -0,0 +1,39 @@ +import { getCurrentHub } from '@sentry/core'; +import { Client, DataCategory, Event, EventDropReason } from '@sentry/types'; + +let _originalRecordDroppedEvent: Client['recordDroppedEvent'] | undefined; + +export function overwriteRecordDroppedEvent(errorIds: Set): 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) { + errorIds.delete(event.event_id); + } + + return _originalCallback(reason, category, event); + }; + + client.recordDroppedEvent = recordDroppedEvent; + _originalRecordDroppedEvent = _originalCallback; +} + +export function restoreRecordDroppedEvent(): void { + const client = getCurrentHub().getClient(); + + if (!client || !_originalRecordDroppedEvent) { + return; + } + + client.recordDroppedEvent = _originalRecordDroppedEvent; +} diff --git a/packages/replay/test/fixtures/performanceEntry/lcp.ts b/packages/replay/test/fixtures/performanceEntry/lcp.ts index 761e75d56e8f..7ad0bf7b4637 100644 --- a/packages/replay/test/fixtures/performanceEntry/lcp.ts +++ b/packages/replay/test/fixtures/performanceEntry/lcp.ts @@ -1,4 +1,4 @@ -import { PerformancePaintTiming } from '../../../src/types'; +import type { PerformancePaintTiming } from '../../../src/types'; export function PerformanceEntryLcp(obj?: Partial): PerformancePaintTiming { const entry = { diff --git a/packages/replay/test/mocks/mockRrweb.ts b/packages/replay/test/mocks/mockRrweb.ts index 6e4b49aad8fb..92a93e00e885 100644 --- a/packages/replay/test/mocks/mockRrweb.ts +++ b/packages/replay/test/mocks/mockRrweb.ts @@ -1,6 +1,6 @@ import type { record as rrwebRecord } from 'rrweb'; -import { RecordingEvent } from '../../src/types'; +import type { RecordingEvent } from '../../src/types'; type RecordAdditionalProperties = { takeFullSnapshot: jest.Mock; diff --git a/packages/replay/test/mocks/mockSdk.ts b/packages/replay/test/mocks/mockSdk.ts index d9d8f2f4a495..93134081adef 100644 --- a/packages/replay/test/mocks/mockSdk.ts +++ b/packages/replay/test/mocks/mockSdk.ts @@ -3,7 +3,7 @@ import { Envelope, Transport } from '@sentry/types'; import { Replay as ReplayIntegration } from '../../src'; import { ReplayContainer } from '../../src/replay'; -import { ReplayConfiguration } from '../../src/types'; +import type { ReplayConfiguration } from '../../src/types'; export interface MockSdkParams { replayOptions?: ReplayConfiguration; diff --git a/packages/replay/test/mocks/resetSdkMock.ts b/packages/replay/test/mocks/resetSdkMock.ts index fb3d9772be66..bc6b83ab7a08 100644 --- a/packages/replay/test/mocks/resetSdkMock.ts +++ b/packages/replay/test/mocks/resetSdkMock.ts @@ -1,8 +1,8 @@ import { getCurrentHub } from '@sentry/core'; -import { ReplayContainer } from '../../src/replay'; +import type { ReplayContainer } from '../../src/replay'; import { BASE_TIMESTAMP, RecordMock } from './../index'; -import { DomHandler, MockTransportSend } from './../types'; +import type { DomHandler, MockTransportSend } from './../types'; import { mockSdk, MockSdkParams } from './mockSdk'; export async function resetSdkMock({ replayOptions, sentryOptions }: MockSdkParams): Promise<{ diff --git a/packages/replay/test/unit/flush.test.ts b/packages/replay/test/unit/flush.test.ts index 3d4149041466..39f1931c73cb 100644 --- a/packages/replay/test/unit/flush.test.ts +++ b/packages/replay/test/unit/flush.test.ts @@ -1,6 +1,8 @@ import * as SentryUtils from '@sentry/utils'; import { SESSION_IDLE_DURATION, WINDOW } from '../../src/constants'; +import * as AddMemoryEntry from '../../src/util/addMemoryEntry'; +import { createPerformanceSpans } from '../../src/util/createPerformanceSpans'; import { createPerformanceEntries } from './../../src/createPerformanceEntry'; import { ReplayContainer } from './../../src/replay'; import { useFakeTimers } from './../../test/utils/use-fake-timers'; @@ -15,7 +17,7 @@ async function advanceTimers(time: number) { type MockSendReplay = jest.MockedFunction; type MockAddPerformanceEntries = jest.MockedFunction; -type MockAddMemoryEntry = jest.MockedFunction; +type MockAddMemoryEntry = jest.SpyInstance; type MockEventBufferFinish = jest.MockedFunction['finish']>; type MockFlush = jest.MockedFunction; type MockRunFlush = jest.MockedFunction; @@ -62,8 +64,7 @@ beforeAll(async () => { return []; }); - jest.spyOn(replay, 'addMemoryEntry'); - mockAddMemoryEntry = replay.addMemoryEntry as MockAddMemoryEntry; + mockAddMemoryEntry = jest.spyOn(AddMemoryEntry, 'addMemoryEntry'); }); beforeEach(() => { @@ -178,8 +179,9 @@ it('long first flush enqueues following events', async () => { }); // Add this to test that segment ID increases - mockAddPerformanceEntries.mockImplementationOnce(async () => { - return replay.createPerformanceSpans( + mockAddPerformanceEntries.mockImplementationOnce(() => { + createPerformanceSpans( + replay, createPerformanceEntries([ { name: 'https://sentry.io/foo.js', diff --git a/packages/replay/test/unit/index-errorSampleRate.test.ts b/packages/replay/test/unit/index-errorSampleRate.test.ts index dc4240546d02..12eea275bdfe 100644 --- a/packages/replay/test/unit/index-errorSampleRate.test.ts +++ b/packages/replay/test/unit/index-errorSampleRate.test.ts @@ -3,6 +3,7 @@ jest.unmock('@sentry/browser'); import { captureException } from '@sentry/browser'; import { REPLAY_SESSION_KEY, VISIBILITY_CHANGE_TIMEOUT, WINDOW } from '../../src/constants'; +import { addEvent } from '../../src/util/addEvent'; import { ReplayContainer } from './../../src/replay'; import { PerformanceEntryResource } from './../fixtures/performanceEntry/resource'; import { BASE_TIMESTAMP, RecordMock } from './../index'; @@ -195,7 +196,7 @@ describe('Replay (errorSampleRate)', () => { jest.advanceTimersByTime(ELAPSED); const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 2 }; - replay.addEvent(TEST_EVENT); + addEvent(replay, TEST_EVENT); document.dispatchEvent(new Event('visibilitychange')); diff --git a/packages/replay/test/unit/index-handleGlobalEvent.test.ts b/packages/replay/test/unit/index-handleGlobalEvent.test.ts index aaae032da14b..cb005faf7256 100644 --- a/packages/replay/test/unit/index-handleGlobalEvent.test.ts +++ b/packages/replay/test/unit/index-handleGlobalEvent.test.ts @@ -1,6 +1,8 @@ import { getCurrentHub } from '@sentry/core'; import { REPLAY_EVENT_NAME } from '../../src/constants'; +import { handleGlobalEventListener } from '../../src/coreHandlers/handleGlobalEvent'; +import { overwriteRecordDroppedEvent, restoreRecordDroppedEvent } from '../../src/util/monkeyPatchRecordDroppedEvent'; import { ReplayContainer } from './../../src/replay'; import { Error } from './../fixtures/error'; import { Transaction } from './../fixtures/transaction'; @@ -33,14 +35,14 @@ it('deletes breadcrumbs from replay events', () => { }; // @ts-ignore replay event type - expect(replay.handleGlobalEvent(replayEvent)).toEqual({ + expect(handleGlobalEventListener(replay)(replayEvent)).toEqual({ type: REPLAY_EVENT_NAME, }); }); it('does not delete breadcrumbs from error and transaction events', () => { expect( - replay.handleGlobalEvent({ + handleGlobalEventListener(replay)({ breadcrumbs: [{ type: 'fakecrumb' }], }), ).toEqual( @@ -49,7 +51,7 @@ it('does not delete breadcrumbs from error and transaction events', () => { }), ); expect( - replay.handleGlobalEvent({ + handleGlobalEventListener(replay)({ type: 'transaction', breadcrumbs: [{ type: 'fakecrumb' }], }), @@ -64,21 +66,19 @@ it('only tags errors with replay id, adds trace and error id to context for erro const transaction = Transaction(); const error = Error(); // @ts-ignore idc - expect(replay.handleGlobalEvent(transaction)).toEqual( + expect(handleGlobalEventListener(replay)(transaction)).toEqual( expect.objectContaining({ tags: expect.not.objectContaining({ replayId: expect.anything() }), }), ); - expect(replay.handleGlobalEvent(error)).toEqual( + expect(handleGlobalEventListener(replay)(error)).toEqual( expect.objectContaining({ tags: expect.objectContaining({ replayId: expect.any(String) }), }), ); - // @ts-ignore private - expect(replay._context.traceIds).toContain('trace_id'); - // @ts-ignore private - expect(replay._context.errorIds).toContain('event_id'); + expect(replay.getContext().traceIds).toContain('trace_id'); + expect(replay.getContext().errorIds).toContain('event_id'); jest.runAllTimers(); await new Promise(process.nextTick); // wait for flush @@ -93,20 +93,21 @@ it('strips out dropped events from errorIds', async () => { const error2 = Error({ event_id: 'err2' }); const error3 = Error({ event_id: 'err3' }); - replay['_overwriteRecordDroppedEvent'](); + // @ts-ignore private + overwriteRecordDroppedEvent(replay.getContext().errorIds); const client = getCurrentHub().getClient()!; - replay.handleGlobalEvent(error1); - replay.handleGlobalEvent(error2); - replay.handleGlobalEvent(error3); + handleGlobalEventListener(replay)(error1); + handleGlobalEventListener(replay)(error2); + handleGlobalEventListener(replay)(error3); client.recordDroppedEvent('before_send', 'error', { event_id: 'err2' }); // @ts-ignore private - expect(Array.from(replay._context.errorIds)).toEqual(['err1', 'err3']); + expect(Array.from(replay.getContext().errorIds)).toEqual(['err1', 'err3']); - replay['_restoreRecordDroppedEvent'](); + restoreRecordDroppedEvent(); }); it('tags errors and transactions with replay id for session samples', async () => { @@ -115,12 +116,12 @@ it('tags errors and transactions with replay id for session samples', async () = const transaction = Transaction(); const error = Error(); // @ts-ignore idc - expect(replay.handleGlobalEvent(transaction)).toEqual( + expect(handleGlobalEventListener(replay)(transaction)).toEqual( expect.objectContaining({ tags: expect.objectContaining({ replayId: expect.any(String) }), }), ); - expect(replay.handleGlobalEvent(error)).toEqual( + expect(handleGlobalEventListener(replay)(error)).toEqual( expect.objectContaining({ tags: expect.objectContaining({ replayId: expect.any(String) }), }), diff --git a/packages/replay/test/unit/index-noSticky.test.ts b/packages/replay/test/unit/index-noSticky.test.ts index e1c42d63870a..fa69f34b8e02 100644 --- a/packages/replay/test/unit/index-noSticky.test.ts +++ b/packages/replay/test/unit/index-noSticky.test.ts @@ -3,6 +3,7 @@ import { Transport } from '@sentry/types'; import * as SentryUtils from '@sentry/utils'; import { SESSION_IDLE_DURATION, VISIBILITY_CHANGE_TIMEOUT } from '../../src/constants'; +import { addEvent } from '../../src/util/addEvent'; import { ReplayContainer } from './../../src/replay'; import { BASE_TIMESTAMP, mockRrweb, mockSdk } from './../index'; import { useFakeTimers } from './../utils/use-fake-timers'; @@ -119,7 +120,7 @@ describe('Replay (no sticky)', () => { jest.advanceTimersByTime(ELAPSED); const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 2 }; - replay.addEvent(TEST_EVENT); + addEvent(replay, TEST_EVENT); document.dispatchEvent(new Event('visibilitychange')); diff --git a/packages/replay/test/unit/index.test.ts b/packages/replay/test/unit/index.test.ts index c2032287559c..8464766734db 100644 --- a/packages/replay/test/unit/index.test.ts +++ b/packages/replay/test/unit/index.test.ts @@ -6,7 +6,9 @@ import { EventType } from 'rrweb'; 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 type { RecordingEvent } from '../../src/types'; +import { addEvent } from '../../src/util/addEvent'; +import { createPerformanceSpans } from '../../src/util/createPerformanceSpans'; import { useFakeTimers } from '../utils/use-fake-timers'; import { PerformanceEntryResource } from './../fixtures/performanceEntry/resource'; import { BASE_TIMESTAMP, RecordMock } from './../index'; @@ -82,7 +84,7 @@ describe('Replay with custom mock', () => { timestamp: new Date().valueOf(), } as RecordingEvent; - replay.addEvent(event); + addEvent(replay, event); await replay.runFlush(); @@ -250,7 +252,7 @@ describe('Replay', () => { }, }; - replay.addEvent(TEST_EVENT); + addEvent(replay, TEST_EVENT); WINDOW.dispatchEvent(new Event('blur')); await new Promise(process.nextTick); expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); @@ -276,7 +278,7 @@ describe('Replay', () => { const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 2 }; - replay.addEvent(TEST_EVENT); + addEvent(replay, TEST_EVENT); document.dispatchEvent(new Event('visibilitychange')); jest.runAllTimers(); await new Promise(process.nextTick); @@ -349,8 +351,7 @@ describe('Replay', () => { const initialSession = replay.session; expect(initialSession?.id).toBeDefined(); - // @ts-ignore private member - expect(replay._context).toEqual( + expect(replay.getContext()).toEqual( expect.objectContaining({ initialUrl: 'http://localhost/', initialTimestamp: BASE_TIMESTAMP, @@ -423,8 +424,7 @@ describe('Replay', () => { }); // `_context` should be reset when a new session is created - // @ts-ignore private member - expect(replay._context).toEqual( + expect(replay.getContext()).toEqual( expect.objectContaining({ initialUrl: 'http://dummy/', initialTimestamp: newTimestamp, @@ -437,8 +437,7 @@ describe('Replay', () => { const initialSession = replay.session; expect(initialSession?.id).toBeDefined(); - // @ts-ignore private member - expect(replay._context).toEqual( + expect(replay.getContext()).toEqual( expect.objectContaining({ initialUrl: 'http://localhost/', initialTimestamp: BASE_TIMESTAMP, @@ -463,7 +462,7 @@ describe('Replay', () => { // performance events can still be collected while recording is stopped // TODO: we may want to prevent `addEvent` from adding to buffer when user is inactive replay.addUpdate(() => { - replay.createPerformanceSpans([ + createPerformanceSpans(replay, [ { type: 'navigation.navigate', name: 'foo', @@ -536,8 +535,7 @@ describe('Replay', () => { }); // `_context` should be reset when a new session is created - // @ts-ignore private member - expect(replay._context).toEqual( + expect(replay.getContext()).toEqual( expect.objectContaining({ initialUrl: 'http://dummy/', initialTimestamp: newTimestamp, @@ -691,7 +689,7 @@ describe('Replay', () => { const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 2 }; - replay.addEvent(TEST_EVENT); + addEvent(replay, TEST_EVENT); WINDOW.dispatchEvent(new Event('blur')); await new Promise(process.nextTick); expect(replay).toHaveSentReplay({ @@ -699,7 +697,7 @@ describe('Replay', () => { }); expect(replay.session?.segmentId).toBe(1); - replay.addEvent(TEST_EVENT); + addEvent(replay, TEST_EVENT); WINDOW.dispatchEvent(new Event('blur')); jest.runAllTimers(); await new Promise(process.nextTick); @@ -731,7 +729,7 @@ describe('Replay', () => { type: 2, }; - replay.addEvent(TEST_EVENT); + addEvent(replay, TEST_EVENT); WINDOW.dispatchEvent(new Event('blur')); await new Promise(process.nextTick); @@ -816,10 +814,10 @@ describe('Replay', () => { type: 2, }; - replay.addEvent(TEST_EVENT); + addEvent(replay, TEST_EVENT); // Add a fake event that started BEFORE - replay.addEvent({ + addEvent(replay, { data: {}, timestamp: (BASE_TIMESTAMP - 10000) / 1000, type: 5, @@ -855,8 +853,7 @@ describe('Replay', () => { ); // This should be null because `addEvent` has not been called yet - // @ts-ignore private member - expect(replay._context.earliestEvent).toBe(null); + expect(replay.getContext().earliestEvent).toBe(null); expect(mockTransportSend).toHaveBeenCalledTimes(0); // A new checkout occurs (i.e. a new session was started) @@ -866,7 +863,7 @@ describe('Replay', () => { type: 2, }; - replay.addEvent(TEST_EVENT); + addEvent(replay, TEST_EVENT); // This event will trigger a flush WINDOW.dispatchEvent(new Event('blur')); jest.runAllTimers(); @@ -896,8 +893,7 @@ describe('Replay', () => { }); // This gets reset after sending replay - // @ts-ignore private member - expect(replay._context.earliestEvent).toBe(null); + expect(replay.getContext().earliestEvent).toBe(null); }); it('has single flush when checkout flush and debounce flush happen near simultaneously', async () => { diff --git a/packages/replay/test/unit/stop.test.ts b/packages/replay/test/unit/stop.test.ts index 767149e724df..543f7e512a3f 100644 --- a/packages/replay/test/unit/stop.test.ts +++ b/packages/replay/test/unit/stop.test.ts @@ -2,6 +2,7 @@ import * as SentryUtils from '@sentry/utils'; import { SESSION_IDLE_DURATION, WINDOW } from '../../src/constants'; import { ReplayContainer } from '../../src/replay'; +import { addEvent } from '../../src/util/addEvent'; import { Replay } from './../../src'; // mock functions need to be imported first import { BASE_TIMESTAMP, mockRrweb, mockSdk } from './../index'; @@ -73,7 +74,7 @@ describe('Replay - stop', () => { // Pretend 5 seconds have passed jest.advanceTimersByTime(ELAPSED); - replay.addEvent(TEST_EVENT); + addEvent(replay, TEST_EVENT); WINDOW.dispatchEvent(new Event('blur')); await new Promise(process.nextTick); expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); @@ -103,7 +104,7 @@ describe('Replay - stop', () => { }, }; - replay.addEvent(TEST_EVENT); + addEvent(replay, TEST_EVENT); WINDOW.dispatchEvent(new Event('blur')); jest.runAllTimers(); await new Promise(process.nextTick);