From f8bc99847a48848ef15d6a7466f57e10bc827ada Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Mon, 9 Jan 2023 14:49:37 -0500 Subject: [PATCH 1/3] update worker --- packages/replay/src/constants.ts | 2 + .../src/coreHandlers/handleGlobalEvent.ts | 3 - packages/replay/src/integration.ts | 2 +- packages/replay/src/replay.ts | 77 ++++++++++++++----- packages/replay/src/types.ts | 11 +++ .../replay/src/util/clearPendingReplay.ts | 9 +++ packages/replay/src/util/getPendingReplay.ts | 41 ++++++++++ packages/replay/src/util/sendReplayRequest.ts | 4 + packages/replay/src/util/setFlushState.ts | 20 +++++ 9 files changed, 147 insertions(+), 22 deletions(-) create mode 100644 packages/replay/src/util/clearPendingReplay.ts create mode 100644 packages/replay/src/util/getPendingReplay.ts create mode 100644 packages/replay/src/util/setFlushState.ts diff --git a/packages/replay/src/constants.ts b/packages/replay/src/constants.ts index 187d729cb6f5..2801e5c4240b 100644 --- a/packages/replay/src/constants.ts +++ b/packages/replay/src/constants.ts @@ -7,6 +7,8 @@ import { GLOBAL_OBJ } from '@sentry/utils'; export const WINDOW = GLOBAL_OBJ as typeof GLOBAL_OBJ & Window; export const REPLAY_SESSION_KEY = 'sentryReplaySession'; +export const PENDING_REPLAY_STATUS_KEY = 'sentryReplayFlushStatus'; +export const PENDING_REPLAY_DATA_KEY = 'sentryReplayFlushData'; export const REPLAY_EVENT_NAME = 'replay_event'; export const RECORDING_EVENT_NAME = 'replay_recording'; export const UNABLE_TO_SEND_REPLAY = 'Unable to send Replay'; diff --git a/packages/replay/src/coreHandlers/handleGlobalEvent.ts b/packages/replay/src/coreHandlers/handleGlobalEvent.ts index 4bdb3e5c1a54..11e3fee5581e 100644 --- a/packages/replay/src/coreHandlers/handleGlobalEvent.ts +++ b/packages/replay/src/coreHandlers/handleGlobalEvent.ts @@ -13,9 +13,6 @@ export function handleGlobalEventListener(replay: ReplayContainer): (event: Even return (event: Event) => { // Do not apply replayId to the root event if (event.type === REPLAY_EVENT_NAME) { - // Replays have separate set of breadcrumbs, do not include breadcrumbs - // from core SDK - delete event.breadcrumbs; return event; } diff --git a/packages/replay/src/integration.ts b/packages/replay/src/integration.ts index 274ec6147e34..2be5cfdd40a5 100644 --- a/packages/replay/src/integration.ts +++ b/packages/replay/src/integration.ts @@ -170,7 +170,7 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`, return; } - this._replay.start(); + void this._replay.start(); } /** diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts index ffba33c5631f..41847a39c110 100644 --- a/packages/replay/src/replay.ts +++ b/packages/replay/src/replay.ts @@ -29,17 +29,21 @@ import type { ReplayPluginOptions, Session, } from './types'; +import { FlushState } from './types'; import { addEvent } from './util/addEvent'; import { addMemoryEntry } from './util/addMemoryEntry'; +import { clearPendingReplay } from './util/clearPendingReplay'; import { createBreadcrumb } from './util/createBreadcrumb'; import { createPerformanceEntries } from './util/createPerformanceEntries'; import { createPerformanceSpans } from './util/createPerformanceSpans'; import { debounce } from './util/debounce'; +import { getPendingReplay } from './util/getPendingReplay'; import { isExpired } from './util/isExpired'; import { isSessionExpired } from './util/isSessionExpired'; import { overwriteRecordDroppedEvent, restoreRecordDroppedEvent } from './util/monkeyPatchRecordDroppedEvent'; import { sendReplay } from './util/sendReplay'; -import { RateLimitError } from './util/sendReplayRequest'; +import { RateLimitError,sendReplayRequest } from './util/sendReplayRequest'; +import { setFlushState } from './util/setFlushState'; /** * The main replay container class, which holds all the state and methods for recording and sending replays. @@ -151,7 +155,7 @@ export class ReplayContainer implements ReplayContainerInterface { * Creates or loads a session, attaches listeners to varying events (DOM, * _performanceObserver, Recording, Sentry SDK, etc) */ - public start(): void { + public async start(): Promise { this._setInitialState(); this._loadSession({ expiry: SESSION_IDLE_DURATION }); @@ -162,6 +166,23 @@ export class ReplayContainer implements ReplayContainerInterface { return; } + const useCompression = Boolean(this._options.useCompression); + + // Flush any pending events that were previously unable to be sent + try { + const pendingEvent = await getPendingReplay({ useCompression }); + if (pendingEvent) { + await sendReplayRequest({ + ...pendingEvent, + session: this.session, + options: this._options, + }); + clearPendingReplay(); + } + } catch { + // ignore + } + if (!this.session.sampled) { // If session was not sampled, then we do not initialize the integration at all. return; @@ -178,7 +199,7 @@ export class ReplayContainer implements ReplayContainerInterface { this._updateSessionActivity(); this.eventBuffer = createEventBuffer({ - useCompression: Boolean(this._options.useCompression), + useCompression, }); this._addListeners(); @@ -366,6 +387,7 @@ export class ReplayContainer implements ReplayContainerInterface { // enable flag to create the root replay if (type === 'new') { this._setInitialState(); + clearPendingReplay(); } const currentSessionId = this.getSessionId(); @@ -801,24 +823,21 @@ export class ReplayContainer implements ReplayContainerInterface { return; } - await this._addPerformanceEntries(); + try { + const promises: Promise[] = []; - // Check eventBuffer again, as it could have been stopped in the meanwhile - if (!this.eventBuffer || !this.eventBuffer.pendingLength) { - return; - } + promises.push(this._addPerformanceEntries()); - // Only attach memory event if eventBuffer is not empty - await addMemoryEntry(this); + // Do not continue if there are no pending events in buffer + if (!this.eventBuffer?.pendingLength) { + return; + } - // Check eventBuffer again, as it could have been stopped in the meanwhile - if (!this.eventBuffer) { - return; - } + // Only attach memory entry if eventBuffer is not empty + promises.push(addMemoryEntry(this)); - try { - // Note this empties the event buffer regardless of outcome of sending replay - const recordingData = await this.eventBuffer.finish(); + // This empties the event buffer regardless of outcome of sending replay + promises.push(this.eventBuffer.finish()); // NOTE: Copy values from instance members, as it's possible they could // change before the flush finishes. @@ -826,9 +845,23 @@ export class ReplayContainer implements ReplayContainerInterface { const eventContext = this._popEventContext(); // Always increment segmentId regardless of outcome of sending replay const segmentId = this.session.segmentId++; + + // Write to local storage before flushing, in case flush request never starts + setFlushState(FlushState.START, { + recordingData: this.eventBuffer.pendingEvents, + replayId, + eventContext, + segmentId, + includeReplayStartTimestamp: segmentId === 0, + timestamp: new Date().getTime(), + }); + + // Save session (new segment id) after we save flush data assuming this._maybeSaveSession(); - await sendReplay({ + const [, , recordingData] = await Promise.all(promises); + + const sendReplayPromise = sendReplay({ replayId, recordingData, segmentId, @@ -838,8 +871,16 @@ export class ReplayContainer implements ReplayContainerInterface { options: this.getOptions(), timestamp: new Date().getTime(), }); + + // If replay request starts, optimistically update some states + setFlushState(FlushState.SENT_REQUEST); + + await sendReplayPromise; + + setFlushState(FlushState.SENT_REQUEST); } catch (err) { this._handleException(err); + setFlushState(FlushState.ERROR); if (err instanceof RateLimitError) { this._handleRateLimit(err.rateLimits); diff --git a/packages/replay/src/types.ts b/packages/replay/src/types.ts index 356a3c1179fc..2eef27fd9c9e 100644 --- a/packages/replay/src/types.ts +++ b/packages/replay/src/types.ts @@ -18,6 +18,17 @@ export interface SendReplayData { options: ReplayPluginOptions; } +export enum FlushState { + START = 'start', + SENT_REQUEST = 'sent-request', + COMPLETE = 'complete', + ERROR = 'error', +} + +export type PendingReplayData = Omit & { + recordingData: RecordingEvent[]; +}; + export type InstrumentationTypeBreadcrumb = 'dom' | 'scope'; /** diff --git a/packages/replay/src/util/clearPendingReplay.ts b/packages/replay/src/util/clearPendingReplay.ts new file mode 100644 index 000000000000..fb36bddd4870 --- /dev/null +++ b/packages/replay/src/util/clearPendingReplay.ts @@ -0,0 +1,9 @@ +import { PENDING_REPLAY_DATA_KEY, PENDING_REPLAY_STATUS_KEY, WINDOW } from '../constants'; + +/** + * Clears pending segment that was previously unable to be sent (e.g. due to page reload). + */ +export function clearPendingReplay(): void { + WINDOW.sessionStorage.removeItem(PENDING_REPLAY_STATUS_KEY); + WINDOW.sessionStorage.removeItem(PENDING_REPLAY_DATA_KEY); +} diff --git a/packages/replay/src/util/getPendingReplay.ts b/packages/replay/src/util/getPendingReplay.ts new file mode 100644 index 000000000000..8d2963c53b7d --- /dev/null +++ b/packages/replay/src/util/getPendingReplay.ts @@ -0,0 +1,41 @@ +import { PENDING_REPLAY_DATA_KEY, WINDOW } from '../constants'; +import { createEventBuffer } from '../eventBuffer'; +import type { PendingReplayData, SendReplayData } from '../types'; + +/** + * Attempts to flush pending segment that was previously unable to be sent + * (e.g. due to page reload). + */ +export async function getPendingReplay({ useCompression }: { useCompression: boolean }): Promise | null> { + try { + const leftoverData = WINDOW.sessionStorage.getItem(PENDING_REPLAY_DATA_KEY); + + // Potential data that was not flushed + const parsedData = leftoverData && (JSON.parse(leftoverData) as Partial | null); + const { recordingData, replayId, segmentId, includeReplayStartTimestamp, eventContext, timestamp } = parsedData || {}; + + // Ensure data integrity -- also *skip* includeReplayStartTimestamp as it + // implies a checkout which we do not store due to potential size + if (!recordingData || !replayId || !segmentId || !eventContext || !timestamp || includeReplayStartTimestamp) { + return null; + } + + // start a local eventBuffer + const eventBuffer = createEventBuffer({ + useCompression, + }); + + await Promise.all(recordingData.map(event => eventBuffer.addEvent(event))); + + return { + recordingData: await eventBuffer.finish(), + replayId, + segmentId, + includeReplayStartTimestamp: false, + eventContext, + timestamp, + }; + } catch { + return null; + } +} diff --git a/packages/replay/src/util/sendReplayRequest.ts b/packages/replay/src/util/sendReplayRequest.ts index 37d6ef2c99f0..349e3bb0323c 100644 --- a/packages/replay/src/util/sendReplayRequest.ts +++ b/packages/replay/src/util/sendReplayRequest.ts @@ -69,6 +69,10 @@ export async function sendReplayRequest({ errorSampleRate: options.errorSampleRate, }; + // Replays have separate set of breadcrumbs, do not include breadcrumbs + // from core SDK + delete replayEvent.breadcrumbs; + /* For reference, the fully built event looks something like this: { diff --git a/packages/replay/src/util/setFlushState.ts b/packages/replay/src/util/setFlushState.ts new file mode 100644 index 000000000000..786bf8d8b10a --- /dev/null +++ b/packages/replay/src/util/setFlushState.ts @@ -0,0 +1,20 @@ +import { PENDING_REPLAY_DATA_KEY, PENDING_REPLAY_STATUS_KEY, WINDOW } from '../constants'; +import type { PendingReplayData } from '../types'; +import { FlushState } from '../types'; + +/** + * + */ +export function setFlushState(state: FlushState, data?: PendingReplayData): void { + if (data) { + WINDOW.sessionStorage.setItem(PENDING_REPLAY_DATA_KEY, JSON.stringify(data)); + } + + if (state === FlushState.SENT_REQUEST) { + WINDOW.sessionStorage.removeItem(PENDING_REPLAY_DATA_KEY); + } else if (state === FlushState.COMPLETE) { + WINDOW.sessionStorage.removeItem(PENDING_REPLAY_STATUS_KEY); + } else { + WINDOW.sessionStorage.setItem(PENDING_REPLAY_STATUS_KEY, state); + } +} From 4f7537dc15c4cc7ad8e2195186f9f7fdfddb4ff2 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Mon, 9 Jan 2023 20:10:03 -0500 Subject: [PATCH 2/3] add comments --- packages/replay/src/replay.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts index 41847a39c110..e4d7af40bab7 100644 --- a/packages/replay/src/replay.ts +++ b/packages/replay/src/replay.ts @@ -846,7 +846,9 @@ export class ReplayContainer implements ReplayContainerInterface { // Always increment segmentId regardless of outcome of sending replay const segmentId = this.session.segmentId++; - // Write to local storage before flushing, in case flush request never starts + // Write to local storage before flushing, in case flush request never starts. + // Ensure that this happens before *any* `await` happens, otherwise we + // will lose data. setFlushState(FlushState.START, { recordingData: this.eventBuffer.pendingEvents, replayId, @@ -856,9 +858,13 @@ export class ReplayContainer implements ReplayContainerInterface { timestamp: new Date().getTime(), }); - // Save session (new segment id) after we save flush data assuming + // Save session (new segment id) after we save flush data assuming either + // 1) request succeeds or 2) it fails or never happens, in which case we + // need to retry this segment. this._maybeSaveSession(); + // NOTE: Be mindful that nothing after this point (the first `await`) + // will run after when the page is unloaded. const [, , recordingData] = await Promise.all(promises); const sendReplayPromise = sendReplay({ From 7f3fa0b67ef936090b62b2d1d13b86018e10ecbb Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 17 Jan 2023 12:14:03 -0500 Subject: [PATCH 3/3] change promise order --- packages/replay/src/replay.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts index e4d7af40bab7..eac36cce6c14 100644 --- a/packages/replay/src/replay.ts +++ b/packages/replay/src/replay.ts @@ -836,9 +836,6 @@ export class ReplayContainer implements ReplayContainerInterface { // Only attach memory entry if eventBuffer is not empty promises.push(addMemoryEntry(this)); - // This empties the event buffer regardless of outcome of sending replay - promises.push(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; @@ -865,7 +862,10 @@ export class ReplayContainer implements ReplayContainerInterface { // NOTE: Be mindful that nothing after this point (the first `await`) // will run after when the page is unloaded. - const [, , recordingData] = await Promise.all(promises); + await Promise.all(promises); + + // This empties the event buffer regardless of outcome of sending replay + const recordingData = await this.eventBuffer.finish(); const sendReplayPromise = sendReplay({ replayId,