From 34bb40317bc0af261205fdddca81542748afcd56 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 19 Jun 2023 13:36:09 +0200 Subject: [PATCH] feat(replay): Stop replay when event buffer exceeds max. size (#8315) When the buffer exceeds ~20MB, stop the replay. Closes https://github.com/getsentry/sentry-javascript/issues/7657 Closes https://github.com/getsentry/team-replay/issues/94 --- packages/replay/src/constants.ts | 3 + .../src/eventBuffer/EventBufferArray.ts | 12 +++- .../EventBufferCompressionWorker.ts | 18 ++++- packages/replay/src/eventBuffer/index.ts | 8 +++ packages/replay/src/util/addEvent.ts | 5 +- .../unit/eventBuffer/EventBufferArray.test.ts | 55 ++++++++++++++- .../EventBufferCompressionWorker.test.ts | 70 ++++++++++++++++++- .../replay/test/unit/util/addEvent.test.ts | 28 ++++++++ 8 files changed, 192 insertions(+), 7 deletions(-) diff --git a/packages/replay/src/constants.ts b/packages/replay/src/constants.ts index 120079ebb857..699cefe8b784 100644 --- a/packages/replay/src/constants.ts +++ b/packages/replay/src/constants.ts @@ -44,3 +44,6 @@ export const SLOW_CLICK_THRESHOLD = 3_000; export const SLOW_CLICK_SCROLL_TIMEOUT = 300; /* Clicks in this time period are considered e.g. double/triple clicks. */ export const MULTI_CLICK_TIMEOUT = 1_000; + +/** When encountering a total segment size exceeding this size, stop the replay (as we cannot properly ingest it). */ +export const REPLAY_MAX_EVENT_BUFFER_SIZE = 20_000_000; // ~20MB diff --git a/packages/replay/src/eventBuffer/EventBufferArray.ts b/packages/replay/src/eventBuffer/EventBufferArray.ts index eaebd1b174e7..a7b363891026 100644 --- a/packages/replay/src/eventBuffer/EventBufferArray.ts +++ b/packages/replay/src/eventBuffer/EventBufferArray.ts @@ -1,5 +1,7 @@ +import { REPLAY_MAX_EVENT_BUFFER_SIZE } from '../constants'; import type { AddEventResult, EventBuffer, EventBufferType, RecordingEvent } from '../types'; import { timestampToMs } from '../util/timestampToMs'; +import { EventBufferSizeExceededError } from '.'; /** * A basic event buffer that does not do any compression. @@ -8,6 +10,7 @@ import { timestampToMs } from '../util/timestampToMs'; export class EventBufferArray implements EventBuffer { /** All the events that are buffered to be sent. */ public events: RecordingEvent[]; + private _totalSize = 0; public constructor() { this.events = []; @@ -30,6 +33,12 @@ export class EventBufferArray implements EventBuffer { /** @inheritdoc */ public async addEvent(event: RecordingEvent): Promise { + const eventSize = JSON.stringify(event).length; + this._totalSize += eventSize; + if (this._totalSize > REPLAY_MAX_EVENT_BUFFER_SIZE) { + throw new EventBufferSizeExceededError(); + } + this.events.push(event); } @@ -40,7 +49,7 @@ export class EventBufferArray implements EventBuffer { // events member so that we do not lose new events while uploading // attachment. const eventsRet = this.events; - this.events = []; + this.clear(); resolve(JSON.stringify(eventsRet)); }); } @@ -48,6 +57,7 @@ export class EventBufferArray implements EventBuffer { /** @inheritdoc */ public clear(): void { this.events = []; + this._totalSize = 0; } /** @inheritdoc */ diff --git a/packages/replay/src/eventBuffer/EventBufferCompressionWorker.ts b/packages/replay/src/eventBuffer/EventBufferCompressionWorker.ts index 45696ea46bc9..5b4c0eb4487a 100644 --- a/packages/replay/src/eventBuffer/EventBufferCompressionWorker.ts +++ b/packages/replay/src/eventBuffer/EventBufferCompressionWorker.ts @@ -1,7 +1,9 @@ import type { ReplayRecordingData } from '@sentry/types'; +import { REPLAY_MAX_EVENT_BUFFER_SIZE } from '../constants'; import type { AddEventResult, EventBuffer, EventBufferType, RecordingEvent } from '../types'; import { timestampToMs } from '../util/timestampToMs'; +import { EventBufferSizeExceededError } from '.'; import { WorkerHandler } from './WorkerHandler'; /** @@ -11,6 +13,7 @@ import { WorkerHandler } from './WorkerHandler'; export class EventBufferCompressionWorker implements EventBuffer { private _worker: WorkerHandler; private _earliestTimestamp: number | null; + private _totalSize = 0; public constructor(worker: Worker) { this._worker = new WorkerHandler(worker); @@ -53,7 +56,14 @@ export class EventBufferCompressionWorker implements EventBuffer { this._earliestTimestamp = timestamp; } - return this._sendEventToWorker(event); + const data = JSON.stringify(event); + this._totalSize += data.length; + + if (this._totalSize > REPLAY_MAX_EVENT_BUFFER_SIZE) { + return Promise.reject(new EventBufferSizeExceededError()); + } + + return this._sendEventToWorker(data); } /** @@ -66,6 +76,7 @@ export class EventBufferCompressionWorker implements EventBuffer { /** @inheritdoc */ public clear(): void { this._earliestTimestamp = null; + this._totalSize = 0; // We do not wait on this, as we assume the order of messages is consistent for the worker void this._worker.postMessage('clear'); } @@ -78,8 +89,8 @@ export class EventBufferCompressionWorker implements EventBuffer { /** * Send the event to the worker. */ - private _sendEventToWorker(event: RecordingEvent): Promise { - return this._worker.postMessage('addEvent', JSON.stringify(event)); + private _sendEventToWorker(data: string): Promise { + return this._worker.postMessage('addEvent', data); } /** @@ -89,6 +100,7 @@ export class EventBufferCompressionWorker implements EventBuffer { const response = await this._worker.postMessage('finish'); this._earliestTimestamp = null; + this._totalSize = 0; return response; } diff --git a/packages/replay/src/eventBuffer/index.ts b/packages/replay/src/eventBuffer/index.ts index f0eb83c68243..fe58b76f3c7b 100644 --- a/packages/replay/src/eventBuffer/index.ts +++ b/packages/replay/src/eventBuffer/index.ts @@ -1,6 +1,7 @@ import { getWorkerURL } from '@sentry-internal/replay-worker'; import { logger } from '@sentry/utils'; +import { REPLAY_MAX_EVENT_BUFFER_SIZE } from '../constants'; import type { EventBuffer } from '../types'; import { EventBufferArray } from './EventBufferArray'; import { EventBufferProxy } from './EventBufferProxy'; @@ -30,3 +31,10 @@ export function createEventBuffer({ useCompression }: CreateEventBufferParams): __DEBUG_BUILD__ && logger.log('[Replay] Using simple buffer'); return new EventBufferArray(); } + +/** This error indicates that the event buffer size exceeded the limit.. */ +export class EventBufferSizeExceededError extends Error { + public constructor() { + super(`Event buffer exceeded maximum size of ${REPLAY_MAX_EVENT_BUFFER_SIZE}.`); + } +} diff --git a/packages/replay/src/util/addEvent.ts b/packages/replay/src/util/addEvent.ts index 16f653e9fc5d..79bf4ff2f362 100644 --- a/packages/replay/src/util/addEvent.ts +++ b/packages/replay/src/util/addEvent.ts @@ -2,6 +2,7 @@ import { EventType } from '@sentry-internal/rrweb'; import { getCurrentHub } from '@sentry/core'; import { logger } from '@sentry/utils'; +import { EventBufferSizeExceededError } from '../eventBuffer'; import type { AddEventResult, RecordingEvent, ReplayContainer, ReplayFrameEvent } from '../types'; import { timestampToMs } from './timestampToMs'; @@ -56,8 +57,10 @@ export async function addEvent( return await replay.eventBuffer.addEvent(eventAfterPossibleCallback); } catch (error) { + const reason = error && error instanceof EventBufferSizeExceededError ? 'addEventSizeExceeded' : 'addEvent'; + __DEBUG_BUILD__ && logger.error(error); - await replay.stop('addEvent'); + await replay.stop(reason); const client = getCurrentHub().getClient(); diff --git a/packages/replay/test/unit/eventBuffer/EventBufferArray.test.ts b/packages/replay/test/unit/eventBuffer/EventBufferArray.test.ts index 37827610e4cc..c7b0a4bd7e90 100644 --- a/packages/replay/test/unit/eventBuffer/EventBufferArray.test.ts +++ b/packages/replay/test/unit/eventBuffer/EventBufferArray.test.ts @@ -1,4 +1,5 @@ -import { createEventBuffer } from './../../../src/eventBuffer'; +import { REPLAY_MAX_EVENT_BUFFER_SIZE } from '../../../src/constants'; +import { createEventBuffer, EventBufferSizeExceededError } from './../../../src/eventBuffer'; import { BASE_TIMESTAMP } from './../../index'; const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; @@ -44,4 +45,56 @@ describe('Unit | eventBuffer | EventBufferArray', () => { expect(result1).toEqual(JSON.stringify([TEST_EVENT])); expect(result2).toEqual(JSON.stringify([])); }); + + describe('size limit', () => { + it('rejects if size exceeds limit', async function () { + const buffer = createEventBuffer({ useCompression: false }); + + const largeEvent = { + data: { a: 'a'.repeat(REPLAY_MAX_EVENT_BUFFER_SIZE / 3) }, + timestamp: BASE_TIMESTAMP, + type: 3, + }; + + await buffer.addEvent(largeEvent); + await buffer.addEvent(largeEvent); + + // Now it should error + await expect(() => buffer.addEvent(largeEvent)).rejects.toThrowError(EventBufferSizeExceededError); + }); + + it('resets size limit on clear', async function () { + const buffer = createEventBuffer({ useCompression: false }); + + const largeEvent = { + data: { a: 'a'.repeat(REPLAY_MAX_EVENT_BUFFER_SIZE / 3) }, + timestamp: BASE_TIMESTAMP, + type: 3, + }; + + await buffer.addEvent(largeEvent); + await buffer.addEvent(largeEvent); + + await buffer.clear(); + + await buffer.addEvent(largeEvent); + }); + + it('resets size limit on finish', async function () { + const buffer = createEventBuffer({ useCompression: false }); + + const largeEvent = { + data: { a: 'a'.repeat(REPLAY_MAX_EVENT_BUFFER_SIZE / 3) }, + timestamp: BASE_TIMESTAMP, + type: 3, + }; + + await buffer.addEvent(largeEvent); + await buffer.addEvent(largeEvent); + + await buffer.finish(); + + await buffer.addEvent(largeEvent); + }); + }); }); diff --git a/packages/replay/test/unit/eventBuffer/EventBufferCompressionWorker.test.ts b/packages/replay/test/unit/eventBuffer/EventBufferCompressionWorker.test.ts index 74819a2cdf75..6c3e5948fac1 100644 --- a/packages/replay/test/unit/eventBuffer/EventBufferCompressionWorker.test.ts +++ b/packages/replay/test/unit/eventBuffer/EventBufferCompressionWorker.test.ts @@ -3,8 +3,9 @@ import 'jsdom-worker'; import pako from 'pako'; import { BASE_TIMESTAMP } from '../..'; +import { REPLAY_MAX_EVENT_BUFFER_SIZE } from '../../../src/constants'; import { EventBufferProxy } from '../../../src/eventBuffer/EventBufferProxy'; -import { createEventBuffer } from './../../../src/eventBuffer'; +import { createEventBuffer, EventBufferSizeExceededError } from './../../../src/eventBuffer'; const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; @@ -146,4 +147,71 @@ describe('Unit | eventBuffer | EventBufferCompressionWorker', () => { await expect(() => buffer.addEvent({ data: { o: 3 }, timestamp: BASE_TIMESTAMP, type: 3 })).rejects.toBeDefined(); }); + + describe('size limit', () => { + it('rejects if size exceeds limit', async function () { + const buffer = createEventBuffer({ + useCompression: true, + }) as EventBufferProxy; + + expect(buffer).toBeInstanceOf(EventBufferProxy); + await buffer.ensureWorkerIsLoaded(); + + const largeEvent = { + data: { a: 'a'.repeat(REPLAY_MAX_EVENT_BUFFER_SIZE / 3) }, + timestamp: BASE_TIMESTAMP, + type: 3, + }; + + await buffer.addEvent(largeEvent); + await buffer.addEvent(largeEvent); + + // Now it should error + await expect(() => buffer.addEvent(largeEvent)).rejects.toThrowError(EventBufferSizeExceededError); + }); + + it('resets size limit on clear', async function () { + const buffer = createEventBuffer({ + useCompression: true, + }) as EventBufferProxy; + + expect(buffer).toBeInstanceOf(EventBufferProxy); + await buffer.ensureWorkerIsLoaded(); + + const largeEvent = { + data: { a: 'a'.repeat(REPLAY_MAX_EVENT_BUFFER_SIZE / 3) }, + timestamp: BASE_TIMESTAMP, + type: 3, + }; + + await buffer.addEvent(largeEvent); + await buffer.addEvent(largeEvent); + + await buffer.clear(); + + await buffer.addEvent(largeEvent); + }); + + it('resets size limit on finish', async function () { + const buffer = createEventBuffer({ + useCompression: true, + }) as EventBufferProxy; + + expect(buffer).toBeInstanceOf(EventBufferProxy); + await buffer.ensureWorkerIsLoaded(); + + const largeEvent = { + data: { a: 'a'.repeat(REPLAY_MAX_EVENT_BUFFER_SIZE / 3) }, + timestamp: BASE_TIMESTAMP, + type: 3, + }; + + await buffer.addEvent(largeEvent); + await buffer.addEvent(largeEvent); + + await buffer.finish(); + + await buffer.addEvent(largeEvent); + }); + }); }); diff --git a/packages/replay/test/unit/util/addEvent.test.ts b/packages/replay/test/unit/util/addEvent.test.ts index 6cc2e6b6ffdf..f00dc82d338f 100644 --- a/packages/replay/test/unit/util/addEvent.test.ts +++ b/packages/replay/test/unit/util/addEvent.test.ts @@ -1,6 +1,7 @@ import 'jsdom-worker'; import { BASE_TIMESTAMP } from '../..'; +import { REPLAY_MAX_EVENT_BUFFER_SIZE } from '../../../src/constants'; import type { EventBufferProxy } from '../../../src/eventBuffer/EventBufferProxy'; import { addEvent } from '../../../src/util/addEvent'; import { setupReplayContainer } from '../../utils/setupReplayContainer'; @@ -29,4 +30,31 @@ describe('Unit | util | addEvent', () => { expect(replay.isEnabled()).toEqual(false); }); + + it('stops when exceeding buffer size limit', async function () { + jest.setSystemTime(BASE_TIMESTAMP); + + const replay = setupReplayContainer({ + options: { + useCompression: true, + }, + }); + + const largeEvent = { + data: { a: 'a'.repeat(REPLAY_MAX_EVENT_BUFFER_SIZE / 3) }, + timestamp: BASE_TIMESTAMP, + type: 3, + }; + + await (replay.eventBuffer as EventBufferProxy).ensureWorkerIsLoaded(); + + await addEvent(replay, largeEvent); + await addEvent(replay, largeEvent); + + expect(replay.isEnabled()).toEqual(true); + + await addEvent(replay, largeEvent); + + expect(replay.isEnabled()).toEqual(false); + }); });