diff --git a/packages/replay/src/index.ts b/packages/replay/src/index.ts index 8c818140b22f..eec0563c58c8 100644 --- a/packages/replay/src/index.ts +++ b/packages/replay/src/index.ts @@ -21,6 +21,7 @@ import { } from './session/constants'; import { deleteSession } from './session/deleteSession'; import { getSession } from './session/getSession'; +import { saveSession } from './session/saveSession'; import { Session } from './session/Session'; import type { AllPerformanceEntry, @@ -637,6 +638,7 @@ export class Replay implements Integration { // checkout. if (this.waitForError && this.session && this.context.earliestEvent) { this.session.started = this.context.earliestEvent; + this._maybeSaveSession(); } // If the full snapshot is due to an initial load, we will not have @@ -893,6 +895,7 @@ export class Replay implements Integration { updateSessionActivity(lastActivity: number = new Date().getTime()): void { if (this.session) { this.session.lastActivity = lastActivity; + this._maybeSaveSession(); } } @@ -1104,6 +1107,7 @@ export class Replay implements Integration { const eventContext = this.popEventContext(); // Always increment segmentId regardless of outcome of sending replay const segmentId = this.session.segmentId++; + this._maybeSaveSession(); await this.sendReplay({ replayId, @@ -1342,4 +1346,11 @@ export class Replay implements Integration { }); } } + + /** Save the session, if it is sticky */ + private _maybeSaveSession(): void { + if (this.session && this.options.stickySession) { + saveSession(this.session); + } + } } diff --git a/packages/replay/src/session/Session.ts b/packages/replay/src/session/Session.ts index 509b322a4eef..4eb20c057519 100644 --- a/packages/replay/src/session/Session.ts +++ b/packages/replay/src/session/Session.ts @@ -1,10 +1,7 @@ import { uuid4 } from '@sentry/utils'; -import { SampleRates, SessionOptions } from '../types'; +import { SampleRates } from '../types'; import { isSampled } from '../util/isSampled'; -import { saveSession } from './saveSession'; - -type StickyOption = Required>; type Sampled = false | 'session' | 'error'; @@ -33,106 +30,44 @@ interface SessionObject { } export class Session { - public readonly options: StickyOption; - /** * Session ID */ - private _id: string; + public readonly id: string; /** * Start time of current session */ - private _started: number; + public started: number; /** * Last known activity of the session */ - private _lastActivity: number; + public lastActivity: number; /** * Sequence ID specific to replay updates */ - private _segmentId: number; + public segmentId: number; /** * Previous session ID */ - private _previousSessionId: string | undefined; + public previousSessionId: string | undefined; /** * Is the Session sampled? */ - private _sampled: Sampled; + public readonly sampled: Sampled; - public constructor( - session: Partial = {}, - { stickySession, sessionSampleRate, errorSampleRate }: StickyOption & SampleRates, - ) { + public constructor(session: Partial = {}, { sessionSampleRate, errorSampleRate }: SampleRates) { const now = new Date().getTime(); - this._id = session.id || uuid4(); - this._started = session.started ?? now; - this._lastActivity = session.lastActivity ?? now; - this._segmentId = session.segmentId ?? 0; - this._sampled = + this.id = session.id || uuid4(); + this.started = session.started ?? now; + this.lastActivity = session.lastActivity ?? now; + this.segmentId = session.segmentId ?? 0; + this.sampled = session.sampled ?? (isSampled(sessionSampleRate) ? 'session' : isSampled(errorSampleRate) ? 'error' : false); - - this.options = { - stickySession, - }; - } - - get id(): string { - return this._id; - } - - get started(): number { - return this._started; - } - - set started(newDate: number) { - this._started = newDate; - if (this.options.stickySession) { - saveSession(this); - } - } - - get lastActivity(): number { - return this._lastActivity; - } - - set lastActivity(newDate: number) { - this._lastActivity = newDate; - if (this.options.stickySession) { - saveSession(this); - } - } - - get segmentId(): number { - return this._segmentId; - } - - set segmentId(id: number) { - this._segmentId = id; - if (this.options.stickySession) { - saveSession(this); - } - } - - get previousSessionId(): string | undefined { - return this._previousSessionId; - } - - set previousSessionId(id: string | undefined) { - this._previousSessionId = id; - } - - get sampled(): Sampled { - return this._sampled; - } - - set sampled(_isSampled: Sampled) { - throw new Error('Unable to change sampled value'); } toJSON(): SessionObject { @@ -140,8 +75,8 @@ export class Session { id: this.id, started: this.started, lastActivity: this.lastActivity, - segmentId: this._segmentId, - sampled: this._sampled, + segmentId: this.segmentId, + sampled: this.sampled, } as SessionObject; } } diff --git a/packages/replay/src/session/createSession.ts b/packages/replay/src/session/createSession.ts index 16212b4a8aef..d5d2e1ee98e6 100644 --- a/packages/replay/src/session/createSession.ts +++ b/packages/replay/src/session/createSession.ts @@ -11,7 +11,6 @@ import { Session } from './Session'; */ export function createSession({ sessionSampleRate, errorSampleRate, stickySession = false }: SessionOptions): Session { const session = new Session(undefined, { - stickySession, errorSampleRate, sessionSampleRate, }); diff --git a/packages/replay/src/session/fetchSession.ts b/packages/replay/src/session/fetchSession.ts index 201218a0f38d..136bfb9f27b4 100644 --- a/packages/replay/src/session/fetchSession.ts +++ b/packages/replay/src/session/fetchSession.ts @@ -24,12 +24,7 @@ export function fetchSession({ sessionSampleRate, errorSampleRate }: SampleRates const sessionObj = JSON.parse(sessionStringFromStorage); - return new Session( - sessionObj, - // We are assuming that if there is a saved item, then the session is sticky, - // however this could break down if we used a different storage mechanism (e.g. localstorage) - { stickySession: true, sessionSampleRate, errorSampleRate }, - ); + return new Session(sessionObj, { sessionSampleRate, errorSampleRate }); } catch { return null; } diff --git a/packages/replay/test/mocks/mockSdk.ts b/packages/replay/test/mocks/mockSdk.ts index cb92d1d47a50..142cfc26c08a 100644 --- a/packages/replay/test/mocks/mockSdk.ts +++ b/packages/replay/test/mocks/mockSdk.ts @@ -36,7 +36,7 @@ class MockTransport implements Transport { export async function mockSdk({ replayOptions = { - stickySession: true, + stickySession: false, sessionSampleRate: 1.0, errorSampleRate: 0.0, }, diff --git a/packages/replay/test/unit/index.test.ts b/packages/replay/test/unit/index.test.ts index dcec60a95b63..bfd08e4c843a 100644 --- a/packages/replay/test/unit/index.test.ts +++ b/packages/replay/test/unit/index.test.ts @@ -6,9 +6,11 @@ import { BASE_TIMESTAMP, RecordMock } from '@test'; import { PerformanceEntryResource } from '@test/fixtures/performanceEntry/resource'; import { resetSdkMock } from '@test/mocks'; import { DomHandler, MockTransportSend } from '@test/types'; +import { EventType } from 'rrweb'; import { Replay } from '../../src'; import { MAX_SESSION_LIFE, REPLAY_SESSION_KEY, VISIBILITY_CHANGE_TIMEOUT } from '../../src/session/constants'; +import { RecordingEvent } from '../../src/types'; import { useFakeTimers } from '../utils/use-fake-timers'; useFakeTimers(); @@ -18,6 +20,77 @@ async function advanceTimers(time: number) { await new Promise(process.nextTick); } +describe('Replay with custom mock', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('calls rrweb.record with custom options', async () => { + const { mockRecord } = await resetSdkMock({ + ignoreClass: 'sentry-test-ignore', + stickySession: false, + sessionSampleRate: 1.0, + errorSampleRate: 0.0, + }); + expect(mockRecord.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "blockClass": "sentry-block", + "blockSelector": "[data-sentry-block],img,image,svg,path,rect,area,video,object,picture,embed,map,audio", + "emit": [Function], + "ignoreClass": "sentry-test-ignore", + "maskAllInputs": true, + "maskTextClass": "sentry-mask", + "maskTextSelector": "*", + } + `); + }); + + describe('auto save session', () => { + test.each([ + ['with stickySession=true', true, 1], + ['with stickySession=false', false, 0], + ])('%s', async (_: string, stickySession: boolean, addSummand: number) => { + let saveSessionSpy; + + jest.mock('../../src/session/saveSession', () => { + saveSessionSpy = jest.fn(); + + return { + saveSession: saveSessionSpy, + }; + }); + + const { replay } = await resetSdkMock({ + stickySession, + sessionSampleRate: 1.0, + errorSampleRate: 0.0, + }); + + // Initially called up to three times: once for start, then once for replay.updateSessionActivity & once for segmentId increase + expect(saveSessionSpy).toHaveBeenCalledTimes(addSummand * 3); + + replay.updateSessionActivity(); + + expect(saveSessionSpy).toHaveBeenCalledTimes(addSummand * 4); + + // In order for runFlush to actually do something, we need to add an event + const event = { + type: EventType.Custom, + data: { + tag: 'test custom', + }, + timestamp: new Date().valueOf(), + } as RecordingEvent; + + replay.addEvent(event); + + await replay.runFlush(); + + expect(saveSessionSpy).toHaveBeenCalledTimes(addSummand * 5); + }); + }); +}); + describe('Replay', () => { let replay: Replay; let mockRecord: RecordMock; @@ -38,7 +111,7 @@ describe('Replay', () => { ({ mockRecord, mockTransportSend, domHandler, replay, spyCaptureException } = await resetSdkMock({ sessionSampleRate: 1.0, errorSampleRate: 0.0, - stickySession: true, + stickySession: false, })); jest.spyOn(replay, 'flush'); @@ -68,23 +141,6 @@ describe('Replay', () => { replay.stop(); }); - it('calls rrweb.record with custom options', async () => { - ({ mockRecord, mockTransportSend, domHandler, replay } = await resetSdkMock({ - ignoreClass: 'sentry-test-ignore', - })); - expect(mockRecord.mock.calls[0][0]).toMatchInlineSnapshot(` - Object { - "blockClass": "sentry-block", - "blockSelector": "[data-sentry-block],img,image,svg,path,rect,area,video,object,picture,embed,map,audio", - "emit": [Function], - "ignoreClass": "sentry-test-ignore", - "maskAllInputs": true, - "maskTextClass": "sentry-mask", - "maskTextSelector": "*", - } - `); - }); - it('should have a session after setup', () => { expect(replay.session).toMatchObject({ lastActivity: BASE_TIMESTAMP, diff --git a/packages/replay/test/unit/session/Session.test.ts b/packages/replay/test/unit/session/Session.test.ts index 27e4a67d92e3..194d7095e6ba 100644 --- a/packages/replay/test/unit/session/Session.test.ts +++ b/packages/replay/test/unit/session/Session.test.ts @@ -26,7 +26,6 @@ jest.mock('@sentry/utils', () => { import * as Sentry from '@sentry/browser'; import { WINDOW } from '@sentry/browser'; -import { saveSession } from '../../../src/session/saveSession'; import { Session } from '../../../src/session/Session'; type CaptureEventMockType = jest.MockedFunction; @@ -39,48 +38,8 @@ afterEach(() => { (Sentry.getCurrentHub().captureEvent as CaptureEventMockType).mockReset(); }); -it('non-sticky Session does not save to local storage', function () { - const newSession = new Session(undefined, { - stickySession: false, - sessionSampleRate: 1.0, - errorSampleRate: 0, - }); - - expect(saveSession).not.toHaveBeenCalled(); - expect(newSession.id).toBe('test_session_id'); - expect(newSession.segmentId).toBe(0); - - newSession.segmentId++; - expect(saveSession).not.toHaveBeenCalled(); - expect(newSession.segmentId).toBe(1); -}); - -it('sticky Session saves to local storage', function () { - const newSession = new Session(undefined, { - stickySession: true, - sessionSampleRate: 1.0, - errorSampleRate: 0, - }); - - expect(saveSession).toHaveBeenCalledTimes(0); - expect(newSession.id).toBe('test_session_id'); - expect(newSession.segmentId).toBe(0); - - (saveSession as jest.Mock).mockClear(); - - newSession.segmentId++; - expect(saveSession).toHaveBeenCalledTimes(1); - expect(saveSession).toHaveBeenCalledWith( - expect.objectContaining({ - segmentId: 1, - }), - ); - expect(newSession.segmentId).toBe(1); -}); - it('does not sample', function () { const newSession = new Session(undefined, { - stickySession: true, sessionSampleRate: 0.0, errorSampleRate: 0.0, }); @@ -90,7 +49,6 @@ it('does not sample', function () { it('samples using `sessionSampleRate`', function () { const newSession = new Session(undefined, { - stickySession: true, sessionSampleRate: 1.0, errorSampleRate: 0.0, }); @@ -100,7 +58,6 @@ it('samples using `sessionSampleRate`', function () { it('samples using `errorSampleRate`', function () { const newSession = new Session(undefined, { - stickySession: true, sessionSampleRate: 0, errorSampleRate: 1.0, }); @@ -114,7 +71,6 @@ it('does not run sampling function if existing session was sampled', function () sampled: 'session', }, { - stickySession: true, sessionSampleRate: 0, errorSampleRate: 0, }, diff --git a/packages/replay/test/unit/session/getSession.test.ts b/packages/replay/test/unit/session/getSession.test.ts index 752c19030c0f..ebb30d82dab0 100644 --- a/packages/replay/test/unit/session/getSession.test.ts +++ b/packages/replay/test/unit/session/getSession.test.ts @@ -27,7 +27,7 @@ function createMockSession(when: number = new Date().getTime()) { started: when, sampled: 'session', }, - { stickySession: false, ...SAMPLE_RATES }, + { ...SAMPLE_RATES }, ); } @@ -92,7 +92,7 @@ it('creates a non-sticky session, when one is expired', function () { started: new Date().getTime() - 1001, segmentId: 0, }, - { stickySession: false, ...SAMPLE_RATES }, + { ...SAMPLE_RATES }, ), }); @@ -188,7 +188,7 @@ it('fetches a non-expired non-sticky session', function () { started: +new Date() - 500, segmentId: 0, }, - { stickySession: false, ...SAMPLE_RATES }, + { ...SAMPLE_RATES }, ), }); diff --git a/packages/replay/test/unit/session/saveSession.test.ts b/packages/replay/test/unit/session/saveSession.test.ts index 5001261a9fcd..34b06afef8a7 100644 --- a/packages/replay/test/unit/session/saveSession.test.ts +++ b/packages/replay/test/unit/session/saveSession.test.ts @@ -21,7 +21,7 @@ it('saves a valid session', function () { lastActivity: 1648827162658, sampled: 'session', }, - { stickySession: true, sessionSampleRate: 1.0, errorSampleRate: 0 }, + { sessionSampleRate: 1.0, errorSampleRate: 0 }, ); saveSession(session); diff --git a/packages/replay/test/unit/util/isSessionExpired.test.ts b/packages/replay/test/unit/util/isSessionExpired.test.ts index ff39ae7f9496..6e8c01c939b8 100644 --- a/packages/replay/test/unit/util/isSessionExpired.test.ts +++ b/packages/replay/test/unit/util/isSessionExpired.test.ts @@ -9,7 +9,7 @@ function createSession(extra?: Record) { segmentId: 0, ...extra, }, - { stickySession: false, sessionSampleRate: 1.0, errorSampleRate: 0 }, + { sessionSampleRate: 1.0, errorSampleRate: 0 }, ); }