Skip to content
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
52 changes: 51 additions & 1 deletion 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 @@ -40,14 +41,22 @@ export function getHandleRecordingEmit(replay: ReplayContainer): RecordingEmitCa

// We need to clear existing events on a checkout, otherwise they are
// incremental event updates and should be appended
void addEvent(replay, event, isCheckout);
addEvent(replay, event, isCheckout);

// Different behavior for full snapshots (type=2), ignore other event types
// See https://github.com/rrweb-io/rrweb/blob/d8f9290ca496712aa1e7d472549480c4e7876594/packages/rrweb/src/types.ts#L16
if (!isCheckout) {
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