Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 2 additions & 2 deletions packages/replay/src/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,12 +207,12 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`,
* Currently, this needs to be manually called (e.g. for tests). Sentry SDK
* does not support a teardown
*/
public stop(): void {
public async stop(): Promise<void> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to make this async, as this will just create a new promise!

if (!this._replay) {
return;
}

this._replay.stop();
return this._replay.stop();
}

/**
Expand Down
32 changes: 27 additions & 5 deletions packages/replay/src/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { logger } from '@sentry/utils';
import { ERROR_CHECKOUT_TIME, MAX_SESSION_LIFE, SESSION_IDLE_DURATION, WINDOW } from './constants';
import { setupPerformanceObserver } from './coreHandlers/performanceObserver';
import { createEventBuffer } from './eventBuffer';
import { clearSession } from './session/clearSession';
import { getSession } from './session/getSession';
import { saveSession } from './session/saveSession';
import type {
Expand Down Expand Up @@ -85,6 +86,11 @@ export class ReplayContainer implements ReplayContainerInterface {
*/
private _isEnabled: boolean = false;

/**
* If true, will flush regardless of `_isEnabled` property
*/
private _shouldFinalFlush: boolean = false;

/**
* Paused is a state where:
* - DOM Recording is not listening at all
Expand Down Expand Up @@ -237,7 +243,7 @@ export class ReplayContainer implements ReplayContainerInterface {
* Currently, this needs to be manually called (e.g. for tests). Sentry SDK
* does not support a teardown
*/
public stop(reason?: string): void {
public async stop(reason?: string): Promise<void> {
if (!this._isEnabled) {
return;
}
Expand All @@ -253,14 +259,30 @@ export class ReplayContainer implements ReplayContainerInterface {
log(msg);
}

// Set this property so that it ignores `_isEnabled` = false
// We can't move `_isEnabled` after awaiting a flush, otherwise we can
// enter into an infinite loop when `stop()` is called while flushing.
this._shouldFinalFlush = true;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I don't really like this flag - I would prefer we find a way to solve this by e.g. passing an argument to flushImmediate() or making a separate function for this type of flushing to ensure this. Seems prone to run out of sync, be accidentally true/false when we don't mean it to, etc.

One option I could see is to just do:

this._isEnabled = false;
this._removeListeners();
this._flushAfterStop();

function _flushAfterStop() {
  this._debouncedFlush().cancel();
  this._flush({ force: true });
}

Or something along these lines? IMHO as we are stopping here anyhow, we don't necessarily need to run the debounced flush here at all, allowing us to separate this a bit better?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated, instead of creating a private method, I just kept the flush calls inline as it isn't used elsewhere.

this._isEnabled = false;
this._removeListeners();
this.stopRecording();

// Flush event buffer before stopping
await this.flushImmediate();

this._shouldFinalFlush = false;

// After flush, destroy event buffer
this.eventBuffer && this.eventBuffer.destroy();
this.eventBuffer = null;
this._debouncedFlush.cancel();

// Clear session from session storage, note this means if a new session
// is started after, it will not have `previousSessionId`
clearSession(this);
} catch (err) {
this._handleException(err);
} finally {
this._shouldFinalFlush = false;
}
}

Expand Down Expand Up @@ -466,7 +488,7 @@ export class ReplayContainer implements ReplayContainerInterface {
this.session = session;

if (!this.session.sampled) {
this.stop('session unsampled');
void this.stop('session unsampled');
return false;
}

Expand Down Expand Up @@ -760,7 +782,7 @@ export class ReplayContainer implements ReplayContainerInterface {
// This means we retried 3 times and all of them failed,
// or we ran into a problem we don't want to retry, like rate limiting.
// In this case, we want to completely stop the replay - otherwise, we may get inconsistent segments
this.stop('sendReplay');
void this.stop('sendReplay');
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just noticed this @mydea -- I think this is ok since stop() will attempt the flush, fail, and when it retries, this._isEnabled is false, so will fail.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it should be fine since this._isEnabled = false should be set "synchronously" in the async stop function. So I would expect this to be false already in the next code line, basically.


const client = getCurrentHub().getClient();

Expand All @@ -775,7 +797,7 @@ export class ReplayContainer implements ReplayContainerInterface {
* can be active at a time. Do not call this directly.
*/
private _flush: () => Promise<void> = async () => {
if (!this._isEnabled) {
if (!this._isEnabled && !this._shouldFinalFlush) {
// This can happen if e.g. the replay was stopped because of exceeding the retry limit
return;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { REPLAY_SESSION_KEY, WINDOW } from '../../src/constants';
import type { ReplayContainer } from '../../src/types';

export function clearSession(replay: ReplayContainer) {
/**
*
*/
export function clearSession(replay: ReplayContainer): void {
deleteSession();
replay.session = undefined;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/replay/src/util/addEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export async function addEvent(
return await replay.eventBuffer.addEvent(event, isCheckout);
} catch (error) {
__DEBUG_BUILD__ && logger.error(error);
replay.stop('addEvent');
await replay.stop('addEvent');

const client = getCurrentHub().getClient();

Expand Down
2 changes: 1 addition & 1 deletion packages/replay/test/integration/errorSampleRate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ import {
WINDOW,
} from '../../src/constants';
import type { ReplayContainer } from '../../src/replay';
import { clearSession } from '../../src/session/clearSession';
import { addEvent } from '../../src/util/addEvent';
import { PerformanceEntryResource } from '../fixtures/performanceEntry/resource';
import type { RecordMock } from '../index';
import { BASE_TIMESTAMP } from '../index';
import { resetSdkMock } from '../mocks/resetSdkMock';
import type { DomHandler } from '../types';
import { clearSession } from '../utils/clearSession';
import { useFakeTimers } from '../utils/use-fake-timers';

useFakeTimers();
Expand Down
2 changes: 1 addition & 1 deletion packages/replay/test/integration/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import { getCurrentHub } from '@sentry/core';

import { WINDOW } from '../../src/constants';
import type { ReplayContainer } from '../../src/replay';
import { clearSession } from '../../src/session/clearSession';
import { addEvent } from '../../src/util/addEvent';
import { PerformanceEntryResource } from '../fixtures/performanceEntry/resource';
import type { RecordMock } from '../index';
import { BASE_TIMESTAMP } from '../index';
import { resetSdkMock } from '../mocks/resetSdkMock';
import { clearSession } from '../utils/clearSession';
import { useFakeTimers } from '../utils/use-fake-timers';

useFakeTimers();
Expand Down
2 changes: 1 addition & 1 deletion packages/replay/test/integration/flush.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ import * as SentryUtils from '@sentry/utils';

import { DEFAULT_FLUSH_MIN_DELAY, WINDOW } from '../../src/constants';
import type { ReplayContainer } from '../../src/replay';
import { clearSession } from '../../src/session/clearSession';
import type { EventBuffer } from '../../src/types';
import * as AddMemoryEntry from '../../src/util/addMemoryEntry';
import { createPerformanceEntries } from '../../src/util/createPerformanceEntries';
import { createPerformanceSpans } from '../../src/util/createPerformanceSpans';
import * as SendReplay from '../../src/util/sendReplay';
import { BASE_TIMESTAMP, mockRrweb, mockSdk } from '../index';
import { clearSession } from '../utils/clearSession';
import { useFakeTimers } from '../utils/use-fake-timers';

useFakeTimers();
Expand Down
5 changes: 2 additions & 3 deletions packages/replay/test/integration/rateLimiting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import type { Transport } from '@sentry/types';

import { DEFAULT_FLUSH_MIN_DELAY } from '../../src/constants';
import type { ReplayContainer } from '../../src/replay';
import { clearSession } from '../../src/session/clearSession';
import * as SendReplayRequest from '../../src/util/sendReplayRequest';
import { BASE_TIMESTAMP, mockSdk } from '../index';
import { mockRrweb } from '../mocks/mockRrweb';
import { clearSession } from '../utils/clearSession';
import { useFakeTimers } from '../utils/use-fake-timers';

useFakeTimers();
Expand Down Expand Up @@ -86,8 +86,7 @@ describe('Integration | rate-limiting behaviour', () => {
expect(replay.stop).toHaveBeenCalledTimes(1);

// No user activity to trigger an update
expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP);
expect(replay.session?.segmentId).toBe(1);
expect(replay.session).toBe(undefined);

// let's simulate the default rate-limit time of inactivity (60secs) and check that we
// don't do anything in the meantime or after the time has passed
Expand Down
11 changes: 3 additions & 8 deletions packages/replay/test/integration/sendReplayEvent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import * as SentryUtils from '@sentry/utils';

import { DEFAULT_FLUSH_MIN_DELAY, WINDOW } from '../../src/constants';
import type { ReplayContainer } from '../../src/replay';
import { clearSession } from '../../src/session/clearSession';
import { addEvent } from '../../src/util/addEvent';
import * as SendReplayRequest from '../../src/util/sendReplayRequest';
import { BASE_TIMESTAMP, mockRrweb, mockSdk } from '../index';
import { clearSession } from '../utils/clearSession';
import { useFakeTimers } from '../utils/use-fake-timers';

useFakeTimers();
Expand Down Expand Up @@ -396,13 +396,8 @@ describe('Integration | sendReplayEvent', () => {
'Something bad happened',
);

// No activity has occurred, session's last activity should remain the same
expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP);

// segmentId increases despite error
expect(replay.session?.segmentId).toBe(1);

// Replay should be completely stopped now
// Replay has stopped, no session should exist
expect(replay.session).toBe(undefined);
expect(replay.isEnabled()).toBe(false);

// Events are ignored now, because we stopped
Expand Down
2 changes: 1 addition & 1 deletion packages/replay/test/integration/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ import {
WINDOW,
} from '../../src/constants';
import type { ReplayContainer } from '../../src/replay';
import { clearSession } from '../../src/session/clearSession';
import type { Session } from '../../src/types';
import { addEvent } from '../../src/util/addEvent';
import { createPerformanceSpans } from '../../src/util/createPerformanceSpans';
import { BASE_TIMESTAMP } from '../index';
import type { RecordMock } from '../mocks/mockRrweb';
import { resetSdkMock } from '../mocks/resetSdkMock';
import { clearSession } from '../utils/clearSession';
import { useFakeTimers } from '../utils/use-fake-timers';

useFakeTimers();
Expand Down
37 changes: 27 additions & 10 deletions packages/replay/test/integration/stop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@ import * as SentryUtils from '@sentry/utils';
import type { Replay } from '../../src';
import { WINDOW } from '../../src/constants';
import type { ReplayContainer } from '../../src/replay';
import { clearSession } from '../../src/session/clearSession';
import { addEvent } from '../../src/util/addEvent';
// mock functions need to be imported first
import { BASE_TIMESTAMP, mockRrweb, mockSdk } from '../index';
import { clearSession } from '../utils/clearSession';
import { useFakeTimers } from '../utils/use-fake-timers';

useFakeTimers();

type MockRunFlush = jest.MockedFunction<ReplayContainer['_runFlush']>;

describe('Integration | stop', () => {
let replay: ReplayContainer;
let integration: Replay;
Expand All @@ -20,6 +22,7 @@ describe('Integration | stop', () => {
const { record: mockRecord } = mockRrweb();

let mockAddInstrumentationHandler: MockAddInstrumentationHandler;
let mockRunFlush: MockRunFlush;

beforeAll(async () => {
jest.setSystemTime(new Date(BASE_TIMESTAMP));
Expand All @@ -29,6 +32,10 @@ describe('Integration | stop', () => {
) as MockAddInstrumentationHandler;

({ replay, integration } = await mockSdk());

// @ts-ignore private API
mockRunFlush = jest.spyOn(replay, '_runFlush');

jest.runAllTimers();
});

Expand Down Expand Up @@ -68,9 +75,10 @@ describe('Integration | stop', () => {
// Not sure where the 20ms comes from tbh
const EXTRA_TICKS = 20;
const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };
const previousSessionId = replay.session?.id;

// stop replays
integration.stop();
await integration.stop();

// Pretend 5 seconds have passed
jest.advanceTimersByTime(ELAPSED);
Expand All @@ -80,14 +88,17 @@ describe('Integration | stop', () => {
await new Promise(process.nextTick);
expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
expect(replay).not.toHaveLastSentReplay();
// Session's last activity should not be updated
expect(replay.session?.lastActivity).toEqual(BASE_TIMESTAMP);
// Session's does not exist
expect(replay.session).toEqual(undefined);
// eventBuffer is destroyed
expect(replay.eventBuffer).toBe(null);

// re-enable replay
integration.start();

// will be different session
expect(replay.session?.id).not.toEqual(previousSessionId);

jest.advanceTimersByTime(ELAPSED);

const timestamp = +new Date(BASE_TIMESTAMP + ELAPSED + ELAPSED + EXTRA_TICKS) / 1000;
Expand Down Expand Up @@ -126,27 +137,33 @@ describe('Integration | stop', () => {
expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP + ELAPSED + 20);
});

it('does not buffer events when stopped', async function () {
WINDOW.dispatchEvent(new Event('blur'));
it('does not buffer new events after being stopped', async function () {
const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };
addEvent(replay, TEST_EVENT);
expect(replay.eventBuffer?.hasEvents).toBe(true);
expect(mockRunFlush).toHaveBeenCalledTimes(0);

// stop replays
integration.stop();
await integration.stop();

expect(mockRunFlush).toHaveBeenCalledTimes(1);

expect(replay.eventBuffer).toBe(null);

WINDOW.dispatchEvent(new Event('blur'));
await new Promise(process.nextTick);

expect(replay.eventBuffer).toBe(null);
expect(replay).not.toHaveLastSentReplay();
expect(replay).toHaveLastSentReplay({
recordingData: JSON.stringify([TEST_EVENT]),
});
});

it('does not call core SDK `addInstrumentationHandler` after initial setup', async function () {
// NOTE: We clear addInstrumentationHandler mock after every test
integration.stop();
await integration.stop();
integration.start();
integration.stop();
await integration.stop();
integration.start();

expect(mockAddInstrumentationHandler).not.toHaveBeenCalled();
Expand Down
2 changes: 1 addition & 1 deletion packages/replay/test/utils/setupReplayContainer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createEventBuffer } from '../../src/eventBuffer';
import { ReplayContainer } from '../../src/replay';
import { clearSession } from '../../src/session/clearSession';
import type { RecordingOptions, ReplayPluginOptions } from '../../src/types';
import { clearSession } from './clearSession';

export function setupReplayContainer({
options,
Expand Down