Skip to content
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
6 changes: 6 additions & 0 deletions packages/replay/src/eventBuffer/EventBufferArray.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,16 @@ import { EventBufferSizeExceededError } from './error';
export class EventBufferArray implements EventBuffer {
/** All the events that are buffered to be sent. */
public events: RecordingEvent[];

/** @inheritdoc */
public hasCheckout: boolean;

private _totalSize: number;

public constructor() {
this.events = [];
this._totalSize = 0;
this.hasCheckout = false;
}

/** @inheritdoc */
Expand Down Expand Up @@ -59,6 +64,7 @@ export class EventBufferArray implements EventBuffer {
public clear(): void {
this.events = [];
this._totalSize = 0;
this.hasCheckout = false;
}

/** @inheritdoc */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import { WorkerHandler } from './WorkerHandler';
* Exported only for testing.
*/
export class EventBufferCompressionWorker implements EventBuffer {
/** @inheritdoc */
public hasCheckout: boolean;

private _worker: WorkerHandler;
private _earliestTimestamp: number | null;
private _totalSize;
Expand All @@ -19,6 +22,7 @@ export class EventBufferCompressionWorker implements EventBuffer {
this._worker = new WorkerHandler(worker);
this._earliestTimestamp = null;
this._totalSize = 0;
this.hasCheckout = false;
}

/** @inheritdoc */
Expand Down Expand Up @@ -78,6 +82,8 @@ export class EventBufferCompressionWorker implements EventBuffer {
public clear(): void {
this._earliestTimestamp = null;
this._totalSize = 0;
this.hasCheckout = false;

// We do not wait on this, as we assume the order of messages is consistent for the worker
void this._worker.postMessage('clear');
}
Expand Down
9 changes: 9 additions & 0 deletions packages/replay/src/eventBuffer/EventBufferProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ export class EventBufferProxy implements EventBuffer {
return this._used.hasEvents;
}

/** @inheritdoc */
public get hasCheckout(): boolean {
return this._used.hasCheckout;
}
/** @inheritdoc */
public set hasCheckout(value: boolean) {
this._used.hasCheckout = value;
}

/** @inheritDoc */
public destroy(): void {
this._fallback.destroy();
Expand Down
17 changes: 13 additions & 4 deletions packages/replay/src/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import { debounce } from './util/debounce';
import { getHandleRecordingEmit } from './util/handleRecordingEmit';
import { isExpired } from './util/isExpired';
import { isSessionExpired } from './util/isSessionExpired';
import { logInfo } from './util/log';
import { logInfo, logInfoNextTick } from './util/log';
import { sendReplay } from './util/sendReplay';
import type { SKIPPED } from './util/throttle';
import { throttle, THROTTLED } from './util/throttle';
Expand Down Expand Up @@ -250,7 +250,10 @@ export class ReplayContainer implements ReplayContainerInterface {
this.recordingMode = 'buffer';
}

logInfo(`[Replay] Starting replay in ${this.recordingMode} mode`, this._options._experiments.traceInternals);
logInfoNextTick(
`[Replay] Starting replay in ${this.recordingMode} mode`,
this._options._experiments.traceInternals,
);

this._initializeRecording();
}
Expand All @@ -271,7 +274,7 @@ export class ReplayContainer implements ReplayContainerInterface {
throw new Error('Replay buffering is in progress, call `flush()` to save the replay');
}

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

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

Expand Down Expand Up @@ -300,7 +303,7 @@ export class ReplayContainer implements ReplayContainerInterface {
throw new Error('Replay recording is already in progress');
}

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

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

Expand Down Expand Up @@ -1151,6 +1154,12 @@ export class ReplayContainer implements ReplayContainerInterface {
return;
}

const eventBuffer = this.eventBuffer;
if (eventBuffer && this.session.segmentId === 0 && !eventBuffer.hasCheckout) {
Copy link
Member

Choose a reason for hiding this comment

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

Hmm won't this log for session based replays?

Copy link
Member Author

Choose a reason for hiding this comment

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

there should still always be a checkout in the initial segment, right?

logInfo('[Replay] Flushing initial segment without checkout.', this._options._experiments.traceInternals);
// TODO FN: Evaluate if we want to stop here, or remove this again?
}

// this._flushLock acts as a lock so that future calls to `_flush()`
// will be blocked until this promise resolves
if (!this._flushLock) {
Expand Down
4 changes: 2 additions & 2 deletions packages/replay/src/session/fetchSession.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { REPLAY_SESSION_KEY, WINDOW } from '../constants';
import type { Session } from '../types';
import { hasSessionStorage } from '../util/hasSessionStorage';
import { logInfo } from '../util/log';
import { logInfoNextTick } from '../util/log';
import { makeSession } from './Session';

/**
Expand All @@ -22,7 +22,7 @@ export function fetchSession(traceInternals?: boolean): Session | null {

const sessionObj = JSON.parse(sessionStringFromStorage) as Session;

logInfo('[Replay] Loading existing session', traceInternals);
logInfoNextTick('[Replay] Loading existing session', traceInternals);

return makeSession(sessionObj);
} catch {
Expand Down
8 changes: 4 additions & 4 deletions packages/replay/src/session/getSession.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Session, SessionOptions, Timeouts } from '../types';
import { isSessionExpired } from '../util/isSessionExpired';
import { logInfo } from '../util/log';
import { logInfoNextTick } from '../util/log';
import { createSession } from './createSession';
import { fetchSession } from './fetchSession';
import { makeSession } from './Session';
Expand Down Expand Up @@ -44,10 +44,10 @@ export function getSession({
// and when this session is expired, it will not be renewed until user
// reloads.
const discardedSession = makeSession({ sampled: false });
logInfo('[Replay] Session should not be refreshed', traceInternals);
logInfoNextTick('[Replay] Session should not be refreshed', traceInternals);
return { type: 'new', session: discardedSession };
} else {
logInfo('[Replay] Session has expired', traceInternals);
logInfoNextTick('[Replay] Session has expired', traceInternals);
}
// Otherwise continue to create a new session
}
Expand All @@ -57,7 +57,7 @@ export function getSession({
sessionSampleRate,
allowBuffering,
});
logInfo('[Replay] Created new session', traceInternals);
logInfoNextTick('[Replay] Created new session', traceInternals);

return { type: 'new', session: newSession };
}
5 changes: 5 additions & 0 deletions packages/replay/src/types/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,11 @@ export interface EventBuffer {
*/
readonly type: EventBufferType;

/**
* If the event buffer contains a checkout event.
*/
hasCheckout: boolean;

/**
* Destroy the event buffer.
*/
Expand Down
1 change: 1 addition & 0 deletions packages/replay/src/util/addEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export async function addEvent(
try {
if (isCheckout && replay.recordingMode === 'buffer') {
replay.eventBuffer.clear();
replay.eventBuffer.hasCheckout = true;
}

const replayOptions = replay.getOptions();
Expand Down
44 changes: 32 additions & 12 deletions packages/replay/src/util/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,42 @@ export function logInfo(message: string, shouldAddBreadcrumb?: boolean): void {

logger.info(message);

if (shouldAddBreadcrumb) {
addBreadcrumb(message);
}
}

/**
* Log a message, and add a breadcrumb in the next tick.
* This is necessary when the breadcrumb may be added before the replay is initialized.
*/
export function logInfoNextTick(message: string, shouldAddBreadcrumb?: boolean): void {
Copy link
Member Author

Choose a reason for hiding this comment

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

I ended up splitting this into two methods logInfo and logInfoNextTick, as while the log in next tick change fixed some cases, but "broke" others (when logging right before a flush). So now we only explicitly log in the next tick in cases that could be logged before the replay is started.

if (!__DEBUG_BUILD__) {
return;
}

logger.info(message);

if (shouldAddBreadcrumb) {
// Wait a tick here to avoid race conditions for some initial logs
// which may be added before replay is initialized
setTimeout(() => {
const hub = getCurrentHub();
hub.addBreadcrumb(
{
category: 'console',
data: {
logger: 'replay',
},
level: 'info',
message,
},
{ level: 'info' },
);
addBreadcrumb(message);
}, 0);
}
}

function addBreadcrumb(message: string): void {
const hub = getCurrentHub();
hub.addBreadcrumb(
{
category: 'console',
data: {
logger: 'replay',
},
level: 'info',
message,
},
{ level: 'info' },
);
}
75 changes: 73 additions & 2 deletions packages/replay/test/integration/flush.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as SentryUtils from '@sentry/utils';

import { DEFAULT_FLUSH_MIN_DELAY, WINDOW } from '../../src/constants';
import { DEFAULT_FLUSH_MIN_DELAY, MAX_SESSION_LIFE, WINDOW } from '../../src/constants';
import type { ReplayContainer } from '../../src/replay';
import { clearSession } from '../../src/session/clearSession';
import type { EventBuffer } from '../../src/types';
Expand Down Expand Up @@ -286,15 +286,22 @@ describe('Integration | flush', () => {

expect(mockFlush).toHaveBeenCalledTimes(20);
expect(mockSendReplay).toHaveBeenCalledTimes(1);

replay.getOptions().minReplayDuration = 0;
});

it('does not flush if session is too long', async () => {
replay.timeouts.maxSessionLife = 100_000;
jest.setSystemTime(new Date(BASE_TIMESTAMP));
jest.setSystemTime(BASE_TIMESTAMP);

sessionStorage.clear();
clearSession(replay);
replay['_loadAndCheckSession']();
// No-op _loadAndCheckSession to avoid us resetting the session for this test
Copy link
Member Author

Choose a reason for hiding this comment

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

actually noticed this test was incorrect before, it just worked because we never reset minReplayDuration above 😬 oops...

const _tmp = replay['_loadAndCheckSession'];
replay['_loadAndCheckSession'] = () => {
return true;
};

await advanceTimers(120_000);

Expand All @@ -308,7 +315,71 @@ describe('Integration | flush', () => {
mockRecord._emitter(TEST_EVENT);

await advanceTimers(DEFAULT_FLUSH_MIN_DELAY);

expect(mockFlush).toHaveBeenCalledTimes(1);
expect(mockSendReplay).toHaveBeenCalledTimes(0);

replay.timeouts.maxSessionLife = MAX_SESSION_LIFE;
replay['_loadAndCheckSession'] = _tmp;
});

it('logs warning if flushing initial segment without checkout', async () => {
replay.getOptions()._experiments.traceInternals = true;

sessionStorage.clear();
clearSession(replay);
replay['_loadAndCheckSession']();
await new Promise(process.nextTick);
jest.setSystemTime(BASE_TIMESTAMP);

// Clear the event buffer to simulate no checkout happened
replay.eventBuffer!.clear();

// click happens first
domHandler({
name: 'click',
});

// no checkout!
await advanceTimers(DEFAULT_FLUSH_MIN_DELAY);

expect(mockFlush).toHaveBeenCalledTimes(1);
expect(mockSendReplay).toHaveBeenCalledTimes(1);

const replayData = mockSendReplay.mock.calls[0][0];

expect(JSON.parse(replayData.recordingData)).toEqual([
{
type: 5,
timestamp: BASE_TIMESTAMP,
data: {
tag: 'breadcrumb',
payload: {
timestamp: BASE_TIMESTAMP / 1000,
type: 'default',
category: 'ui.click',
message: '<unknown>',
data: {},
},
},
},
{
type: 5,
timestamp: BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY,
data: {
tag: 'breadcrumb',
payload: {
timestamp: (BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY) / 1000,
type: 'default',
category: 'console',
data: { logger: 'replay' },
level: 'info',
message: '[Replay] Flushing initial segment without checkout.',
},
},
},
]);

replay.getOptions()._experiments.traceInternals = false;
});
});