Skip to content

feat(replay): Save replay in sessionStorage and attempt to re-request if necessary #6698

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/replay/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
3 changes: 0 additions & 3 deletions packages/replay/src/coreHandlers/handleGlobalEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines -16 to -18
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved this to the SDK where we send the replay request (right after we prepare event), otherwise the restoration request happens before this handler is attached.

return event;
}

Expand Down
2 changes: 1 addition & 1 deletion packages/replay/src/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`,
return;
}

this._replay.start();
void this._replay.start();
}

/**
Expand Down
85 changes: 66 additions & 19 deletions packages/replay/src/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<void> {
this._setInitialState();

this._loadSession({ expiry: SESSION_IDLE_DURATION });
Expand All @@ -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;
Expand All @@ -178,7 +199,7 @@ export class ReplayContainer implements ReplayContainerInterface {
this._updateSessionActivity();

this.eventBuffer = createEventBuffer({
useCompression: Boolean(this._options.useCompression),
useCompression,
});

this._addListeners();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -801,34 +823,51 @@ export class ReplayContainer implements ReplayContainerInterface {
return;
}

await this._addPerformanceEntries();

// Check eventBuffer again, as it could have been stopped in the meanwhile
if (!this.eventBuffer || !this.eventBuffer.pendingLength) {
return;
}
try {
const promises: Promise<any>[] = [];

// Only attach memory event if eventBuffer is not empty
await addMemoryEntry(this);
promises.push(this._addPerformanceEntries());

// Check eventBuffer again, as it could have been stopped in the meanwhile
if (!this.eventBuffer) {
return;
}
// Do not continue if there are no pending events in buffer
if (!this.eventBuffer?.pendingLength) {
return;
}

try {
// Note this empties the event buffer regardless of outcome of sending replay
const recordingData = await this.eventBuffer.finish();
// Only attach memory entry if eventBuffer is not empty
promises.push(addMemoryEntry(this));

// 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++;

// 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,
eventContext,
segmentId,
includeReplayStartTimestamp: segmentId === 0,
timestamp: new Date().getTime(),
});

// 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();

await sendReplay({
// NOTE: Be mindful that nothing after this point (the first `await`)
// will run after when the page is unloaded.
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,
recordingData,
segmentId,
Expand All @@ -838,8 +877,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);
Expand Down
11 changes: 11 additions & 0 deletions packages/replay/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ export interface SendReplayData {
options: ReplayPluginOptions;
}

export enum FlushState {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should generally avoid enums as they have a pretty hefty bundle size impact (more details)

We could use string constants instead, something along these lines:

export type FlushState = 'start' | 'sent-request' | 'complete' | 'error'

WDYT?

START = 'start',
SENT_REQUEST = 'sent-request',
COMPLETE = 'complete',
ERROR = 'error',
}

export type PendingReplayData = Omit<SendReplayData, 'recordingData'|'session'|'options'> & {
recordingData: RecordingEvent[];
};

export type InstrumentationTypeBreadcrumb = 'dom' | 'scope';

/**
Expand Down
9 changes: 9 additions & 0 deletions packages/replay/src/util/clearPendingReplay.ts
Original file line number Diff line number Diff line change
@@ -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);
}
41 changes: 41 additions & 0 deletions packages/replay/src/util/getPendingReplay.ts
Original file line number Diff line number Diff line change
@@ -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<Omit<SendReplayData, 'session'|'options'> | 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<PendingReplayData> | 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;
}
}
4 changes: 4 additions & 0 deletions packages/replay/src/util/sendReplayRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
{
Expand Down
20 changes: 20 additions & 0 deletions packages/replay/src/util/setFlushState.ts
Original file line number Diff line number Diff line change
@@ -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);
Copy link
Member Author

@billyvg billyvg Jan 10, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After reading this... this probably isn't necessary. It would let us know at which stage the user dropped off.

}
}