Skip to content

feat(replay): Add event to capture options on checkouts #8011

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

Merged
merged 11 commits into from
May 5, 2023
Merged
7 changes: 6 additions & 1 deletion packages/replay/src/eventBuffer/EventBufferArray.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AddEventResult, EventBuffer, RecordingEvent } from '../types';
import type { AddEventResult, EventBuffer, EventBufferType, RecordingEvent } from '../types';
import { timestampToMs } from '../util/timestampToMs';

/**
Expand All @@ -18,6 +18,11 @@ export class EventBufferArray implements EventBuffer {
return this.events.length > 0;
}

/** @inheritdoc */
public get type(): EventBufferType {
return 'sync';
}

/** @inheritdoc */
public destroy(): void {
this.events = [];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ReplayRecordingData } from '@sentry/types';

import type { AddEventResult, EventBuffer, RecordingEvent } from '../types';
import type { AddEventResult, EventBuffer, EventBufferType, RecordingEvent } from '../types';
import { timestampToMs } from '../util/timestampToMs';
import { WorkerHandler } from './WorkerHandler';

Expand All @@ -22,6 +22,11 @@ export class EventBufferCompressionWorker implements EventBuffer {
return !!this._earliestTimestamp;
}

/** @inheritdoc */
public get type(): EventBufferType {
return 'worker';
}

/**
* Ensure the worker is ready (or not).
* This will either resolve when the worker is ready, or reject if an error occured.
Expand Down
7 changes: 6 additions & 1 deletion packages/replay/src/eventBuffer/EventBufferProxy.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { ReplayRecordingData } from '@sentry/types';
import { logger } from '@sentry/utils';

import type { AddEventResult, EventBuffer, RecordingEvent } from '../types';
import type { AddEventResult, EventBuffer, EventBufferType, RecordingEvent } from '../types';
import { EventBufferArray } from './EventBufferArray';
import { EventBufferCompressionWorker } from './EventBufferCompressionWorker';

Expand All @@ -24,6 +24,11 @@ export class EventBufferProxy implements EventBuffer {
this._ensureWorkerIsLoadedPromise = this._ensureWorkerIsLoaded();
}

/** @inheritdoc */
public get type(): EventBufferType {
return this._used.type;
}

/** @inheritDoc */
public get hasEvents(): boolean {
return this._used.hasEvents;
Expand Down
2 changes: 2 additions & 0 deletions packages/replay/src/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ export class Replay implements Integration {
errorSampleRate,
useCompression,
blockAllMedia,
maskAllInputs,
maskAllText,
networkDetailAllowUrls,
networkCaptureBodies,
networkRequestHeaders: _getMergedNetworkHeaders(networkRequestHeaders),
Expand Down
17 changes: 17 additions & 0 deletions packages/replay/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,16 @@ export interface ReplayPluginOptions extends ReplayNetworkOptions {
*/
blockAllMedia: boolean;

/**
* Mask all inputs in recordings
*/
maskAllInputs: boolean;

/**
* Mask all text in recordings
*/
maskAllText: boolean;

/**
* _experiments allows users to enable experimental or internal features.
* We don't consider such features as part of the public API and hence we don't guarantee semver for them.
Expand Down Expand Up @@ -435,12 +445,19 @@ export interface Session {
shouldRefresh: boolean;
}

export type EventBufferType = 'sync' | 'worker';

export interface EventBuffer {
/**
* If any events have been added to the buffer.
*/
readonly hasEvents: boolean;

/**
* The buffer type
*/
readonly type: EventBufferType;

/**
* Destroy the event buffer.
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/replay/src/types/rrweb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
type blockClass = string | RegExp;
type maskTextClass = string | RegExp;

enum EventType {
export enum EventType {
DomContentLoaded = 0,
Load = 1,
FullSnapshot = 2,
Expand Down
50 changes: 50 additions & 0 deletions packages/replay/src/util/handleRecordingEmit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { logger } from '@sentry/utils';

import { saveSession } from '../session/saveSession';
import type { RecordingEvent, ReplayContainer } from '../types';
import { EventType } from '../types/rrweb';
import { addEvent } from './addEvent';

type RecordingEmitCallback = (event: RecordingEvent, isCheckout?: boolean) => void;
Expand Down Expand Up @@ -48,6 +49,14 @@ export function getHandleRecordingEmit(replay: ReplayContainer): RecordingEmitCa
return false;
}

// Additionally, create a meta event that will capture certain SDK settings.
// In order to handle buffer mode, this needs to either be done when we
// receive checkout events or at flush time.
//
// `isCheckout` is always true, but want to be explicit that it should
// only be added for checkouts
void addSettingsEvent(replay, isCheckout);

// 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
Expand Down Expand Up @@ -84,3 +93,44 @@ export function getHandleRecordingEmit(replay: ReplayContainer): RecordingEmitCa
});
};
}


/**
* Exported for tests
*/
export function createOptionsEvent(replay: ReplayContainer): RecordingEvent {
const options = replay.getOptions();
return {
type: EventType.Custom,
timestamp: Date.now(),
data: {
tag: 'options',
payload: {
sessionSampleRate: options.sessionSampleRate,
errorSampleRate: options.errorSampleRate,
useCompressionOption: options.useCompression,
blockAllMedia: options.blockAllMedia,
maskAllText: options.maskAllText,
maskAllInputs: options.maskAllInputs,
useCompression: replay.eventBuffer ? replay.eventBuffer.type === 'worker' : false,
networkDetailHasUrls: options.networkDetailAllowUrls.length > 0,
networkCaptureBodies: options.networkCaptureBodies,
networkRequestHasHeaders: options.networkRequestHeaders.length > 0,
networkResponseHasHeaders: options.networkResponseHeaders.length > 0,
},
},
};
}

/**
* Add a "meta" event that contains a simplified view on current configuration
* options. This should only be included on the first segment of a recording.
*/
function addSettingsEvent(replay: ReplayContainer, isCheckout?: boolean): Promise<void | null> {
// Only need to add this event when sending the first segment
if (!isCheckout || !replay.session || replay.session.segmentId !== 0) {
return Promise.resolve(null);
}

return addEvent(replay, createOptionsEvent(replay), false);
}
15 changes: 1 addition & 14 deletions packages/replay/src/util/sendReplayRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ export async function sendReplayRequest({
eventContext,
timestamp,
session,
options,
}: SendReplayData): Promise<void | TransportMakeRequestResponse> {
const preparedRecordingData = prepareRecordingData({
recordingData,
Expand Down Expand Up @@ -60,15 +59,6 @@ export async function sendReplayRequest({
return;
}

replayEvent.contexts = {
...replayEvent.contexts,
replay: {
...(replayEvent.contexts && replayEvent.contexts.replay),
session_sample_rate: options.sessionSampleRate,
error_sample_rate: options.errorSampleRate,
},
};

/*
For reference, the fully built event looks something like this:
{
Expand Down Expand Up @@ -99,10 +89,7 @@ export async function sendReplayRequest({
},
"sdkProcessingMetadata": {},
"contexts": {
"replay": {
"session_sample_rate": 1,
"error_sample_rate": 0,
},
"replay": { },
},
}
*/
Expand Down
44 changes: 16 additions & 28 deletions packages/replay/test/integration/errorSampleRate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import type { ReplayContainer } from '../../src/replay';
import { clearSession } from '../../src/session/clearSession';
import { addEvent } from '../../src/util/addEvent';
import { createOptionsEvent } from '../../src/util/handleRecordingEmit';
import { PerformanceEntryResource } from '../fixtures/performanceEntry/resource';
import type { RecordMock } from '../index';
import { BASE_TIMESTAMP } from '../index';
Expand Down Expand Up @@ -50,6 +51,7 @@ describe('Integration | errorSampleRate', () => {
it('uploads a replay when `Sentry.captureException` is called and continues recording', async () => {
const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };
mockRecord._emitter(TEST_EVENT);
const optionsEvent = createOptionsEvent(replay)

expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
expect(replay).not.toHaveLastSentReplay();
Expand All @@ -72,15 +74,10 @@ describe('Integration | errorSampleRate', () => {
recordingPayloadHeader: { segment_id: 0 },
replayEventPayload: expect.objectContaining({
replay_type: 'buffer',
contexts: {
replay: {
error_sample_rate: 1,
session_sample_rate: 0,
},
},
}),
recordingData: JSON.stringify([
{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 },
optionsEvent,
TEST_EVENT,
{
type: 5,
Expand All @@ -104,12 +101,6 @@ describe('Integration | errorSampleRate', () => {
recordingPayloadHeader: { segment_id: 1 },
replayEventPayload: expect.objectContaining({
replay_type: 'buffer',
contexts: {
replay: {
error_sample_rate: 1,
session_sample_rate: 0,
},
},
}),
recordingData: JSON.stringify([
{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY + 40, type: 2 },
Expand Down Expand Up @@ -161,6 +152,7 @@ describe('Integration | errorSampleRate', () => {
it('manually flushes replay and does not continue to record', async () => {
const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };
mockRecord._emitter(TEST_EVENT);
const optionsEvent = createOptionsEvent(replay);

expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
expect(replay).not.toHaveLastSentReplay();
Expand All @@ -183,15 +175,10 @@ describe('Integration | errorSampleRate', () => {
recordingPayloadHeader: { segment_id: 0 },
replayEventPayload: expect.objectContaining({
replay_type: 'buffer',
contexts: {
replay: {
error_sample_rate: 1,
session_sample_rate: 0,
},
},
}),
recordingData: JSON.stringify([
{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 },
optionsEvent,
TEST_EVENT,
{
type: 5,
Expand Down Expand Up @@ -224,15 +211,10 @@ describe('Integration | errorSampleRate', () => {
recordingPayloadHeader: { segment_id: 0 },
replayEventPayload: expect.objectContaining({
replay_type: 'buffer',
contexts: {
replay: {
error_sample_rate: 1,
session_sample_rate: 0,
},
},
}),
recordingData: JSON.stringify([
{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 },
optionsEvent,
TEST_EVENT,
{
type: 5,
Expand Down Expand Up @@ -538,6 +520,7 @@ describe('Integration | errorSampleRate', () => {
it('has the correct timestamps with deferred root event and last replay update', async () => {
const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };
mockRecord._emitter(TEST_EVENT);
const optionsEvent = createOptionsEvent(replay);

expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
expect(replay).not.toHaveLastSentReplay();
Expand All @@ -554,7 +537,7 @@ describe('Integration | errorSampleRate', () => {
await new Promise(process.nextTick);

expect(replay).toHaveSentReplay({
recordingData: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, TEST_EVENT]),
recordingData: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, optionsEvent, TEST_EVENT]),
replayEventPayload: expect.objectContaining({
replay_start_timestamp: BASE_TIMESTAMP / 1000,
// the exception happens roughly 10 seconds after BASE_TIMESTAMP
Expand Down Expand Up @@ -590,6 +573,7 @@ describe('Integration | errorSampleRate', () => {
// in production, this happens at a time interval
// session started time should be updated to this current timestamp
mockRecord.takeFullSnapshot(true);
const optionsEvent = createOptionsEvent(replay);

jest.runAllTimers();
jest.advanceTimersByTime(20);
Expand Down Expand Up @@ -617,6 +601,7 @@ describe('Integration | errorSampleRate', () => {
timestamp: BASE_TIMESTAMP + ELAPSED + 20,
type: 2,
},
optionsEvent,
]),
});
});
Expand Down Expand Up @@ -732,8 +717,9 @@ it('sends a replay after loading the session multiple times', async () => {
},
autoStart: false,
});
// @ts-ignore this is protected, but we want to call it for this test
integration._initialize();
integration['_initialize']();

const optionsEvent = createOptionsEvent(replay)

jest.runAllTimers();

Expand All @@ -750,12 +736,14 @@ it('sends a replay after loading the session multiple times', async () => {
await new Promise(process.nextTick);

expect(replay).toHaveSentReplay({
recordingData: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, TEST_EVENT]),
recordingPayloadHeader: { segment_id: 0 },
recordingData: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, optionsEvent, TEST_EVENT]),
});

// Latest checkout when we call `startRecording` again after uploading segment
// after an error occurs (e.g. when we switch to session replay recording)
expect(replay).toHaveLastSentReplay({
recordingPayloadHeader: { segment_id: 1 },
recordingData: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + 5040, type: 2 }]),
});
});
6 changes: 0 additions & 6 deletions packages/replay/test/integration/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,12 +130,6 @@ describe('Integration | events', () => {
expect(replay).toHaveLastSentReplay({
replayEventPayload: expect.objectContaining({
replay_start_timestamp: (BASE_TIMESTAMP - 10000) / 1000,
contexts: {
replay: {
error_sample_rate: 0,
session_sample_rate: 1,
},
},
urls: ['http://localhost/'], // this doesn't truly test if we are capturing the right URL as we don't change URLs, but good enough
}),
});
Expand Down
Loading