Skip to content

fix(replay): Streamline session creation/refresh #8813

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 6 commits into from
Aug 18, 2023
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;
window.Replay = new Sentry.Replay({
flushMinDelay: 200,
flushMaxDelay: 200,
minReplayDuration: 0,
});

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
sampleRate: 1,
replaysSessionSampleRate: 0.0,
replaysOnErrorSampleRate: 1.0,

integrations: [window.Replay],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<button onclick="console.log('Test log 1')" id="button1">Click me</button>
<button onclick="Sentry.captureException('test error')" id="buttonError">Click me</button>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { expect } from '@playwright/test';

import { sentryTest } from '../../../utils/fixtures';
import {
getReplaySnapshot,
shouldSkipReplayTest,
waitForReplayRequest,
waitForReplayRunning,
} from '../../../utils/replayHelpers';

sentryTest('continues buffer session in session mode after error & reload', async ({ getLocalTestPath, page }) => {
if (shouldSkipReplayTest()) {
sentryTest.skip();
}

const reqPromise1 = waitForReplayRequest(page, 0);

await page.route('https://dsn.ingest.sentry.io/**/*', route => {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 'test-id' }),
});
});

const url = await getLocalTestPath({ testDir: __dirname });

await page.goto(url);

// buffer session captures an error & switches to session mode
await page.click('#buttonError');
await new Promise(resolve => setTimeout(resolve, 300));
await reqPromise1;

await waitForReplayRunning(page);
const replay1 = await getReplaySnapshot(page);

expect(replay1.recordingMode).toEqual('session');
expect(replay1.session?.sampled).toEqual('buffer');
expect(replay1.session?.segmentId).toBeGreaterThan(0);

// Reload to ensure the session is correctly recovered from sessionStorage
await page.reload();

await waitForReplayRunning(page);
const replay2 = await getReplaySnapshot(page);

expect(replay2.recordingMode).toEqual('session');
expect(replay2.session?.sampled).toEqual('buffer');
expect(replay2.session?.segmentId).toBeGreaterThan(0);
});
145 changes: 88 additions & 57 deletions packages/replay/src/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import { handleKeyboardEvent } from './coreHandlers/handleKeyboardEvent';
import { setupPerformanceObserver } from './coreHandlers/performanceObserver';
import { createEventBuffer } from './eventBuffer';
import { clearSession } from './session/clearSession';
import { getSession } from './session/getSession';
import { loadOrCreateSession } from './session/loadOrCreateSession';
import { maybeRefreshSession } from './session/maybeRefreshSession';
import { saveSession } from './session/saveSession';
import type {
AddEventResult,
Expand Down Expand Up @@ -228,28 +229,24 @@ export class ReplayContainer implements ReplayContainerInterface {

// Otherwise if there is _any_ sample rate set, try to load an existing
// session, or create a new one.
const isSessionSampled = this._loadAndCheckSession();

if (!isSessionSampled) {
// This should only occur if `errorSampleRate` is 0 and was unsampled for
// session-based replay. In this case there is nothing to do.
return;
}
this._initializeSessionForSampling();

if (!this.session) {
// This should not happen, something wrong has occurred
this._handleException(new Error('Unable to initialize and create session'));
return;
}

if (this.session.sampled && this.session.sampled !== 'session') {
// If not sampled as session-based, then recording mode will be `buffer`
// Note that we don't explicitly check if `sampled === 'buffer'` because we
// could have sessions from Session storage that are still `error` from
// prior SDK version.
this.recordingMode = 'buffer';
if (this.session.sampled === false) {
// This should only occur if `errorSampleRate` is 0 and was unsampled for
// session-based replay. In this case there is nothing to do.
return;
}

// If segmentId > 0, it means we've previously already captured this session
// In this case, we still want to continue in `session` recording mode
this.recordingMode = this.session.sampled === 'buffer' && this.session.segmentId === 0 ? 'buffer' : 'session';

logInfoNextTick(
`[Replay] Starting replay in ${this.recordingMode} mode`,
this._options._experiments.traceInternals,
Expand All @@ -276,19 +273,20 @@ export class ReplayContainer implements ReplayContainerInterface {

logInfoNextTick('[Replay] Starting replay in session mode', this._options._experiments.traceInternals);

const previousSessionId = this.session && this.session.id;

const { session } = getSession({
timeouts: this.timeouts,
stickySession: Boolean(this._options.stickySession),
currentSession: this.session,
// This is intentional: create a new session-based replay when calling `start()`
sessionSampleRate: 1,
allowBuffering: false,
traceInternals: this._options._experiments.traceInternals,
});
const session = loadOrCreateSession(
this.session,
{
timeouts: this.timeouts,
traceInternals: this._options._experiments.traceInternals,
},
{
stickySession: this._options.stickySession,
// This is intentional: create a new session-based replay when calling `start()`
sessionSampleRate: 1,
allowBuffering: false,
},
);

session.previousSessionId = previousSessionId;
this.session = session;

this._initializeRecording();
Expand All @@ -305,18 +303,19 @@ export class ReplayContainer implements ReplayContainerInterface {

logInfoNextTick('[Replay] Starting replay in buffer mode', this._options._experiments.traceInternals);

const previousSessionId = this.session && this.session.id;

const { session } = getSession({
timeouts: this.timeouts,
stickySession: Boolean(this._options.stickySession),
currentSession: this.session,
sessionSampleRate: 0,
allowBuffering: true,
traceInternals: this._options._experiments.traceInternals,
});
const session = loadOrCreateSession(
this.session,
{
timeouts: this.timeouts,
traceInternals: this._options._experiments.traceInternals,
},
{
stickySession: this._options.stickySession,
sessionSampleRate: 0,
allowBuffering: true,
},
);

session.previousSessionId = previousSessionId;
this.session = session;

this.recordingMode = 'buffer';
Expand Down Expand Up @@ -427,7 +426,7 @@ export class ReplayContainer implements ReplayContainerInterface {
* new DOM checkout.`
*/
public resume(): void {
if (!this._isPaused || !this._loadAndCheckSession()) {
if (!this._isPaused || !this._checkSession()) {
return;
}

Expand Down Expand Up @@ -535,7 +534,7 @@ export class ReplayContainer implements ReplayContainerInterface {
if (!this._stopRecording) {
// Create a new session, otherwise when the user action is flushed, it
// will get rejected due to an expired session.
if (!this._loadAndCheckSession()) {
if (!this._checkSession()) {
return;
}

Expand Down Expand Up @@ -634,7 +633,7 @@ export class ReplayContainer implements ReplayContainerInterface {

// --- There is recent user activity --- //
// This will create a new session if expired, based on expiry length
if (!this._loadAndCheckSession()) {
if (!this._checkSession()) {
return;
}

Expand Down Expand Up @@ -751,31 +750,63 @@ export class ReplayContainer implements ReplayContainerInterface {

/**
* Loads (or refreshes) the current session.
*/
private _initializeSessionForSampling(): void {
// Whenever there is _any_ error sample rate, we always allow buffering
// Because we decide on sampling when an error occurs, we need to buffer at all times if sampling for errors
const allowBuffering = this._options.errorSampleRate > 0;

const session = loadOrCreateSession(
this.session,
{
timeouts: this.timeouts,
traceInternals: this._options._experiments.traceInternals,
},
{
stickySession: this._options.stickySession,
sessionSampleRate: this._options.sessionSampleRate,
allowBuffering,
},
);

this.session = session;
}

/**
* Checks and potentially refreshes the current session.
* Returns false if session is not recorded.
*/
private _loadAndCheckSession(): boolean {
const { type, session } = getSession({
timeouts: this.timeouts,
stickySession: Boolean(this._options.stickySession),
currentSession: this.session,
sessionSampleRate: this._options.sessionSampleRate,
allowBuffering: this._options.errorSampleRate > 0 || this.recordingMode === 'buffer',
traceInternals: this._options._experiments.traceInternals,
});
private _checkSession(): boolean {
// If there is no session yet, we do not want to refresh anything
// This should generally not happen, but to be safe....
if (!this.session) {
return false;
}

const currentSession = this.session;

const newSession = maybeRefreshSession(
currentSession,
{
timeouts: this.timeouts,
traceInternals: this._options._experiments.traceInternals,
},
{
stickySession: Boolean(this._options.stickySession),
sessionSampleRate: this._options.sessionSampleRate,
allowBuffering: this._options.errorSampleRate > 0,
},
);

const isNew = newSession.id !== currentSession.id;

// If session was newly created (i.e. was not loaded from storage), then
// enable flag to create the root replay
if (type === 'new') {
if (isNew) {
this.setInitialState();
this.session = newSession;
}

const currentSessionId = this.getSessionId();
if (session.id !== currentSessionId) {
session.previousSessionId = currentSessionId;
}

this.session = session;

if (!this.session.sampled) {
void this.stop({ reason: 'session not refreshed' });
return false;
Expand Down
2 changes: 2 additions & 0 deletions packages/replay/src/session/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export function makeSession(session: Partial<Session> & { sampled: Sampled }): S
const segmentId = session.segmentId || 0;
const sampled = session.sampled;
const shouldRefresh = typeof session.shouldRefresh === 'boolean' ? session.shouldRefresh : true;
const previousSessionId = session.previousSessionId;

return {
id,
Expand All @@ -22,5 +23,6 @@ export function makeSession(session: Partial<Session> & { sampled: Sampled }): S
segmentId,
sampled,
shouldRefresh,
previousSessionId,
};
}
6 changes: 5 additions & 1 deletion packages/replay/src/session/createSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,14 @@ export function getSessionSampleType(sessionSampleRate: number, allowBuffering:
* that all replays will be saved to as attachments. Currently, we only expect
* one of these Sentry events per "replay session".
*/
export function createSession({ sessionSampleRate, allowBuffering, stickySession = false }: SessionOptions): Session {
export function createSession(
{ sessionSampleRate, allowBuffering, stickySession = false }: SessionOptions,
{ previousSessionId }: { previousSessionId?: string } = {},
): Session {
const sampled = getSessionSampleType(sessionSampleRate, allowBuffering);
const session = makeSession({
sampled,
previousSessionId,
});

if (stickySession) {
Expand Down
63 changes: 0 additions & 63 deletions packages/replay/src/session/getSession.ts

This file was deleted.

Loading