Skip to content

ref(replay): Extract sticky session handling #6315

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 1 commit into from
Nov 30, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/replay/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
}
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
}
}
95 changes: 15 additions & 80 deletions packages/replay/src/session/Session.ts
Original file line number Diff line number Diff line change
@@ -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<Pick<SessionOptions, 'stickySession'>>;

type Sampled = false | 'session' | 'error';

Expand Down Expand Up @@ -33,115 +30,53 @@ 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<SessionObject> = {},
{ stickySession, sessionSampleRate, errorSampleRate }: StickyOption & SampleRates,
) {
public constructor(session: Partial<SessionObject> = {}, { 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 {
return {
id: this.id,
started: this.started,
lastActivity: this.lastActivity,
segmentId: this._segmentId,
sampled: this._sampled,
segmentId: this.segmentId,
sampled: this.sampled,
} as SessionObject;
}
}
1 change: 0 additions & 1 deletion packages/replay/src/session/createSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
7 changes: 1 addition & 6 deletions packages/replay/src/session/fetchSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/replay/test/mocks/mockSdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class MockTransport implements Transport {

export async function mockSdk({
replayOptions = {
stickySession: true,
stickySession: false,
sessionSampleRate: 1.0,
errorSampleRate: 0.0,
},
Expand Down
92 changes: 74 additions & 18 deletions packages/replay/test/unit/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
Expand All @@ -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');
Expand Down Expand Up @@ -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,
Expand Down
Loading