diff --git a/src/__tests__/extensions/replay/sessionrecording.test.ts b/src/__tests__/extensions/replay/sessionrecording.test.ts index 4a2058993..07f1a61e3 100644 --- a/src/__tests__/extensions/replay/sessionrecording.test.ts +++ b/src/__tests__/extensions/replay/sessionrecording.test.ts @@ -351,7 +351,7 @@ describe('SessionRecording', () => { sessionRecording.afterDecideResponse(makeDecideResponse({ sessionRecording: undefined })) expect(sessionRecording['status']).toBe('disabled') - expect(sessionRecording['buffer']?.data.length).toEqual(undefined) + expect(sessionRecording['buffer'].data.length).toEqual(0) expect(posthog.capture).not.toHaveBeenCalled() }) @@ -624,7 +624,7 @@ describe('SessionRecording', () => { // access private method 🤯so we don't need to wait for the timer sessionRecording['_flushBuffer']() - expect(sessionRecording['buffer']?.data.length).toEqual(undefined) + expect(sessionRecording['buffer'].data.length).toEqual(0) expect(posthog.capture).toHaveBeenCalledTimes(1) expect(posthog.capture).toHaveBeenCalledWith( @@ -700,7 +700,7 @@ describe('SessionRecording', () => { // Another big event means the old data will be flushed _emit(createIncrementalSnapshot({ data: { source: 1, payload: bigData } })) expect(posthog.capture).toHaveBeenCalled() - expect(sessionRecording['buffer']?.data.length).toEqual(1) // The new event + expect(sessionRecording['buffer'].data.length).toEqual(1) // The new event expect(sessionRecording['buffer']).toMatchObject({ size: 755017 }) }) @@ -712,7 +712,7 @@ describe('SessionRecording', () => { _emit(createIncrementalSnapshot({ data: { source: 1, payload: bigData } })) expect(sessionRecording['buffer']).toMatchObject({ size: 755037 }) // the size of the big data event - expect(sessionRecording['buffer']?.data.length).toEqual(2) // full snapshot and a big event + expect(sessionRecording['buffer'].data.length).toEqual(2) // full snapshot and a big event _emit(createIncrementalSnapshot({ data: { source: 1, payload: 1 } })) _emit(createIncrementalSnapshot({ data: { source: 1, payload: 2 } })) @@ -725,7 +725,7 @@ describe('SessionRecording', () => { // but the recording is still buffering expect(sessionRecording['status']).toBe('buffering') expect(posthog.capture).not.toHaveBeenCalled() - expect(sessionRecording['buffer']?.data.length).toEqual(5) // + the new event + expect(sessionRecording['buffer'].data.length).toEqual(5) // + the new event expect(sessionRecording['buffer']).toMatchObject({ size: 755037 + 755101 }) // the size of the big data event }) @@ -733,13 +733,13 @@ describe('SessionRecording', () => { sessionRecording.afterDecideResponse(makeDecideResponse({ sessionRecording: { endpoint: '/s/' } })) sessionRecording.startIfEnabledOrStop() - expect(sessionRecording['buffer']?.sessionId).toEqual(sessionId) + expect(sessionRecording['buffer'].sessionId).toEqual(sessionId) _emit(createIncrementalSnapshot({ emit: 1 })) expect(posthog.capture).not.toHaveBeenCalled() - expect(sessionRecording['buffer']?.sessionId).not.toEqual(null) - expect(sessionRecording['buffer']?.data).toEqual([ + expect(sessionRecording['buffer'].sessionId).not.toEqual(null) + expect(sessionRecording['buffer'].data).toEqual([ createFullSnapshot(), { data: { source: 1 }, emit: 1, type: 3 }, ]) @@ -1524,7 +1524,7 @@ describe('SessionRecording', () => { expect(sessionRecording['sessionDuration']).toBe(100) expect(sessionRecording['minimumDuration']).toBe(1500) - expect(sessionRecording['buffer']?.data.length).toBe(2) // full snapshot and the emitted incremental event + expect(sessionRecording['buffer'].data.length).toBe(2) // full snapshot and the emitted incremental event // call the private method to avoid waiting for the timer sessionRecording['_flushBuffer']() @@ -1549,7 +1549,7 @@ describe('SessionRecording', () => { expect(sessionRecording['sessionDuration']).toBe(-1000) expect(sessionRecording['minimumDuration']).toBe(1500) - expect(sessionRecording['buffer']?.data.length).toBe(2) // full snapshot and the emitted incremental event + expect(sessionRecording['buffer'].data.length).toBe(2) // full snapshot and the emitted incremental event // call the private method to avoid waiting for the timer sessionRecording['_flushBuffer']() @@ -1569,7 +1569,7 @@ describe('SessionRecording', () => { expect(sessionRecording['sessionDuration']).toBe(100) expect(sessionRecording['minimumDuration']).toBe(1500) - expect(sessionRecording['buffer']?.data.length).toBe(2) // full snapshot and the emitted incremental event + expect(sessionRecording['buffer'].data.length).toBe(2) // full snapshot and the emitted incremental event // call the private method to avoid waiting for the timer sessionRecording['_flushBuffer']() @@ -1577,21 +1577,21 @@ describe('SessionRecording', () => { _emit(createIncrementalSnapshot({ data: { source: 1 }, timestamp: sessionStartTimestamp + 1501 })) - expect(sessionRecording['buffer']?.data.length).toBe(3) // full snapshot and two emitted incremental events + expect(sessionRecording['buffer'].data.length).toBe(3) // full snapshot and two emitted incremental events // call the private method to avoid waiting for the timer sessionRecording['_flushBuffer']() expect(posthog.capture).toHaveBeenCalled() - expect(sessionRecording['buffer']?.data.length).toBe(undefined) + expect(sessionRecording['buffer'].data.length).toBe(0) expect(sessionRecording['sessionDuration']).toBe(null) _emit(createIncrementalSnapshot({ data: { source: 1 }, timestamp: sessionStartTimestamp + 1502 })) - expect(sessionRecording['buffer']?.data.length).toBe(1) + expect(sessionRecording['buffer'].data.length).toBe(1) expect(sessionRecording['sessionDuration']).toBe(1502) // call the private method to avoid waiting for the timer sessionRecording['_flushBuffer']() expect(posthog.capture).toHaveBeenCalled() - expect(sessionRecording['buffer']?.data.length).toBe(undefined) + expect(sessionRecording['buffer'].data.length).toBe(0) }) }) diff --git a/src/extensions/replay/sessionrecording.ts b/src/extensions/replay/sessionrecording.ts index b467d6ef9..2a93f9a54 100644 --- a/src/extensions/replay/sessionrecording.ts +++ b/src/extensions/replay/sessionrecording.ts @@ -92,8 +92,34 @@ type SessionRecordingStatus = 'disabled' | 'sampled' | 'active' | 'buffering' interface SnapshotBuffer { size: number data: any[] - sessionId: string | null - windowId: string | null + sessionId: string + windowId: string + + readonly mostRecentSnapshotTimestamp: number | null + add(properties: Properties): void +} + +class InMemoryBuffer implements SnapshotBuffer { + size: number + data: any[] + sessionId: string + windowId: string + + get mostRecentSnapshotTimestamp(): number | null { + return this.data.length ? this.data[this.data.length - 1].timestamp : null + } + + constructor(sessionId: string, windowId: string) { + this.size = 0 + this.data = [] + this.sessionId = sessionId + this.windowId = windowId + } + + add(properties: Properties) { + this.size += properties.$snapshot_bytes + this.data.push(properties.$snapshot_data) + } } interface QueuedRRWebEvent { @@ -112,12 +138,11 @@ const newQueuedEvent = (rrwebMethod: () => void): QueuedRRWebEvent => ({ const LOGGER_PREFIX = '[SessionRecording]' export class SessionRecording { - private instance: PostHog private _endpoint: string private flushBufferTimer?: any // we have a buffer - that contains PostHog snapshot events ready to be sent to the server - private buffer?: SnapshotBuffer + private buffer: SnapshotBuffer // and a queue - that contains rrweb events that we want to send to rrweb, but rrweb wasn't able to accept them yet private queuedRRWebEvents: QueuedRRWebEvent[] = [] @@ -153,7 +178,6 @@ export class SessionRecording { private get sessionManager() { if (!this.instance.sessionManager) { - logger.error(LOGGER_PREFIX + ' started without valid sessionManager') throw new Error(LOGGER_PREFIX + ' started without valid sessionManager. This is a bug.') } @@ -166,9 +190,9 @@ export class SessionRecording { } private get sessionDuration(): number | null { - const mostRecentSnapshot = this.buffer?.data[this.buffer?.data.length - 1] + const mostRecentSnapshotTimestamp = this.buffer.mostRecentSnapshotTimestamp const { sessionStartTimestamp } = this.sessionManager.checkAndGetSessionAndWindowId(true) - return mostRecentSnapshot ? mostRecentSnapshot.timestamp - sessionStartTimestamp : null + return mostRecentSnapshotTimestamp ? mostRecentSnapshotTimestamp - sessionStartTimestamp : null } private get isRecordingEnabled() { @@ -250,8 +274,7 @@ export class SessionRecording { } } - constructor(instance: PostHog) { - this.instance = instance + constructor(private readonly instance: PostHog) { this._captureStarted = false this._endpoint = BASE_ENDPOINT this.stopRrweb = undefined @@ -286,7 +309,7 @@ export class SessionRecording { this.sessionId = sessionId this.windowId = windowId - this.buffer = this.clearBuffer() + this.buffer = new InMemoryBuffer(this.sessionId, this.windowId) // on reload there might be an already sampled session that should be continued before decide response, // so we call this here _and_ in the decide response @@ -846,24 +869,11 @@ export class SessionRecording { return url } - private clearBuffer(): SnapshotBuffer { - this.buffer = undefined - - return { - size: 0, - data: [], - sessionId: this.sessionId, - windowId: this.windowId, - } + private clearBuffer(): void { + this.buffer = new InMemoryBuffer(this.sessionId, this.windowId) } - // the intention is a buffer that (currently) is used only after a decide response enables session recording - // it is called ever X seconds using the flushBufferTimer so that we don't have to wait for the buffer to fill up - // when it is called on a timer it assumes that it can definitely flush - // it is flushed when the session id changes or the size of the buffered data gets too great (1mb by default) - // first change: if the recording is in buffering mode, - // flush buffer simply resets the timer and returns the existing flush buffer - private _flushBuffer() { + private _flushBuffer(): void { if (this.flushBufferTimer) { clearTimeout(this.flushBufferTimer) this.flushBufferTimer = undefined @@ -881,42 +891,31 @@ export class SessionRecording { this.flushBufferTimer = setTimeout(() => { this._flushBuffer() }, RECORDING_BUFFER_TIMEOUT) - return this.buffer || this.clearBuffer() + + return } - if (this.buffer && this.buffer.data.length !== 0) { + if (this.buffer.data.length > 0) { this._captureSnapshot({ $snapshot_bytes: this.buffer.size, $snapshot_data: this.buffer.data, $session_id: this.buffer.sessionId, $window_id: this.buffer.windowId, }) - - return this.clearBuffer() - } else { - return this.buffer || this.clearBuffer() } + this.clearBuffer() } private _captureSnapshotBuffered(properties: Properties) { const additionalBytes = 2 + (this.buffer?.data.length || 0) // 2 bytes for the array brackets and 1 byte for each comma if ( - !this.buffer || this.buffer.size + properties.$snapshot_bytes + additionalBytes > RECORDING_MAX_EVENT_SIZE || - (!!this.buffer.sessionId && this.buffer.sessionId !== this.sessionId) + this.buffer.sessionId !== this.sessionId ) { - this.buffer = this._flushBuffer() - } - - if (isNull(this.buffer.sessionId) && !isNull(this.sessionId)) { - // session id starts null but has now been assigned, update the buffer - this.buffer.sessionId = this.sessionId - this.buffer.windowId = this.windowId + this._flushBuffer() } - this.buffer.size += properties.$snapshot_bytes - this.buffer.data.push(properties.$snapshot_data) - + this.buffer.add(properties) if (!this.flushBufferTimer) { this.flushBufferTimer = setTimeout(() => { this._flushBuffer()