Skip to content
Open
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
4 changes: 2 additions & 2 deletions .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ module.exports = [
path: createCDNPath('bundle.tracing.replay.min.js'),
gzip: false,
brotli: false,
limit: '251 KB',
limit: '252 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed',
Expand All @@ -290,7 +290,7 @@ module.exports = [
path: createCDNPath('bundle.tracing.replay.feedback.min.js'),
gzip: false,
brotli: false,
limit: '264 KB',
limit: '265 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed',
Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type { Metric } from './types-hoist/metric';
import type { Primitive } from './types-hoist/misc';
import type { ClientOptions } from './types-hoist/options';
import type { ParameterizedString } from './types-hoist/parameterize';
import type { ReplayEndEvent, ReplayStartEvent } from './types-hoist/replay';
import type { RequestEventData } from './types-hoist/request';
import type { SdkMetadata } from './types-hoist/sdkmetadata';
import type { Session, SessionAggregates } from './types-hoist/session';
Expand Down Expand Up @@ -726,6 +727,19 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
*/
public on(hook: 'openFeedbackWidget', callback: () => void): () => void;

/**
* A hook that is called when a replay session starts recording (either session or buffer mode).
* @returns {() => void} A function that, when executed, removes the registered callback.
*/
public on(hook: 'replayStart', callback: (event: ReplayStartEvent) => void): () => void;

/**
* A hook that is called when a replay session stops recording, either manually or due to an
* internal condition such as `maxReplayDuration` expiry, send failure, or mutation limit.
* @returns {() => void} A function that, when executed, removes the registered callback.
*/
public on(hook: 'replayEnd', callback: (event: ReplayEndEvent) => void): () => void;

/**
* A hook for the browser tracing integrations to trigger a span start for a page load.
* @returns {() => void} A function that, when executed, removes the registered callback.
Expand Down Expand Up @@ -1001,6 +1015,16 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
*/
public emit(hook: 'openFeedbackWidget'): void;

/**
* Fire a hook event when a replay session starts recording.
*/
public emit(hook: 'replayStart', event: ReplayStartEvent): void;

/**
* Fire a hook event when a replay session stops recording.
*/
public emit(hook: 'replayEnd', event: ReplayEndEvent): void;

/**
* Emit a hook event for browser tracing integrations to trigger a span start for a page load.
*/
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,14 @@ export type {
Profile,
ProfileChunk,
} from './types-hoist/profiling';
export type { ReplayEvent, ReplayRecordingData, ReplayRecordingMode } from './types-hoist/replay';
export type {
ReplayEndEvent,
ReplayEvent,
ReplayRecordingData,
ReplayRecordingMode,
ReplayStartEvent,
ReplayStopReason,
} from './types-hoist/replay';
export type {
FeedbackEvent,
FeedbackFormData,
Expand Down
34 changes: 34 additions & 0 deletions packages/core/src/types-hoist/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,37 @@ export type ReplayRecordingData = string | Uint8Array;
* @hidden
*/
export type ReplayRecordingMode = 'session' | 'buffer';

/**
* Reason a replay recording stopped, passed to the `replayEnd` client hook.
*
* - `manual`: user called `replay.stop()`.
* - `sessionExpired`: session hit `maxReplayDuration` or the idle-expiry threshold.
* - `sendError`: a replay segment failed to send after retries.
* - `mutationLimit`: DOM mutation budget for the session was exhausted.
* - `eventBufferError`: the event buffer threw an unexpected error.
* - `eventBufferOverflow`: the event buffer ran out of space.
*/
export type ReplayStopReason =
| 'manual'
| 'sessionExpired'
| 'sendError'
| 'mutationLimit'
| 'eventBufferError'
| 'eventBufferOverflow';
Comment on lines +30 to +45
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

The docstring for sendError says it only covers failures after retries, but the replay code also stops with reason: 'sendError' for non-retryable conditions like rate limiting and ReplayDurationLimitError (duration too long). Either broaden the sendError description to match actual behavior, or introduce more granular stop reasons (e.g. rate-limited / invalid / durationLimit) so consumers can distinguish why replay ended.

Copilot uses AI. Check for mistakes.

/**
* Payload emitted on the `replayStart` client hook when a replay begins recording.
*/
export interface ReplayStartEvent {
sessionId: string;
recordingMode: ReplayRecordingMode;
}

/**
* Payload emitted on the `replayEnd` client hook when a replay stops recording.
*/
export interface ReplayEndEvent {
sessionId?: string;
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

ReplayEndEvent.sessionId is optional, but ReplayContainer.stop() emits replayEnd only while _isEnabled is true and a Session is always created before _initializeRecording() sets _isEnabled (so a session id should always be available). Making sessionId required would simplify consumer code and matches the documented example; alternatively, if there are real cases where it can be missing, document those and avoid emitting the hook when sessionId is undefined.

Suggested change
sessionId?: string;
sessionId: string;

Copilot uses AI. Check for mistakes.
reason: ReplayStopReason;
}
2 changes: 1 addition & 1 deletion packages/replay-internal/src/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ export class Replay implements Integration {
return Promise.resolve();
}

return this._replay.stop({ forceFlush: this._replay.recordingMode === 'session' });
return this._replay.stop({ forceFlush: this._replay.recordingMode === 'session', reason: 'manual' });
}

/**
Expand Down
23 changes: 18 additions & 5 deletions packages/replay-internal/src/replay.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable max-lines */ // TODO: We might want to split this file up
import type { ReplayRecordingMode, Span } from '@sentry/core';
import type { ReplayRecordingMode, ReplayStopReason, Span } from '@sentry/core';
import { getActiveSpan, getClient, getRootSpan, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, spanToJSON } from '@sentry/core';
import { EventType, record } from '@sentry-internal/rrweb';
import {
Expand Down Expand Up @@ -495,7 +495,10 @@ export class ReplayContainer implements ReplayContainerInterface {
* Currently, this needs to be manually called (e.g. for tests). Sentry SDK
* does not support a teardown
*/
public async stop({ forceFlush = false, reason }: { forceFlush?: boolean; reason?: string } = {}): Promise<void> {
public async stop({
forceFlush = false,
reason,
}: { forceFlush?: boolean; reason?: ReplayStopReason } = {}): Promise<void> {
if (!this._isEnabled) {
return;
}
Expand All @@ -508,8 +511,11 @@ export class ReplayContainer implements ReplayContainerInterface {
// breadcrumbs to trigger a flush (e.g. in `addUpdate()`)
this.recordingMode = 'buffer';

const stopReason: ReplayStopReason = reason ?? 'manual';
getClient()?.emit('replayEnd', { sessionId: this.session?.id, reason: stopReason });
Comment thread
logaretm marked this conversation as resolved.
Dismissed

try {
DEBUG_BUILD && debug.log(`Stopping Replay${reason ? ` triggered by ${reason}` : ''}`);
DEBUG_BUILD && debug.log(`Stopping Replay triggered by ${stopReason}`);

resetReplayIdOnDynamicSamplingContext();

Expand Down Expand Up @@ -862,6 +868,13 @@ export class ReplayContainer implements ReplayContainerInterface {
this._isEnabled = true;
this._isPaused = false;

if (this.session) {
getClient()?.emit('replayStart', {
sessionId: this.session.id,
Comment thread
logaretm marked this conversation as resolved.
Dismissed
recordingMode: this.recordingMode,
});
}

this.startRecording();
Comment on lines +871 to 878
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

replayStart is emitted before startRecording() runs. Since startRecording() is wrapped in a try/catch (and can fail without throwing to callers), this hook may fire even when rrweb recording fails to start. Consider emitting replayStart only after recording successfully starts (e.g. after record() returns and _stopRecording is set), or emitting a corresponding replayEnd on startup failure so consumers don't observe a stuck “started” state.

Suggested change
if (this.session) {
getClient()?.emit('replayStart', {
sessionId: this.session.id,
recordingMode: this.recordingMode,
});
}
this.startRecording();
this.startRecording();
if (this.session && this._stopRecording) {
getClient()?.emit('replayStart', {
sessionId: this.session.id,
recordingMode: this.recordingMode,
});
}

Copilot uses AI. Check for mistakes.
}

Expand Down Expand Up @@ -926,7 +939,7 @@ export class ReplayContainer implements ReplayContainerInterface {
if (!this._isEnabled) {
return;
}
await this.stop({ reason: 'refresh session' });
await this.stop({ reason: 'sessionExpired' });
this.initializeSampling(session.id);
}

Expand Down Expand Up @@ -1212,7 +1225,7 @@ export class ReplayContainer implements ReplayContainerInterface {
// In this case, we want to completely stop the replay - otherwise, we may get inconsistent segments
// This should never reject
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.stop({ reason: 'sendReplay' });
this.stop({ reason: 'sendError' });
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Wrong stop reason emitted for ReplayDurationLimitError

Medium Severity

The catch block in _sendReplay calls this.stop({ reason: 'sendError' }) for all errors, including ReplayDurationLimitError. That error is thrown when the session exceeds maxReplayDuration (line 1199–1200), which the ReplayStopReason documentation explicitly maps to 'sessionExpired' ("session hit maxReplayDuration or the idle-expiry threshold"). Consumers listening on replayEnd will receive reason: 'sendError' when the actual cause is duration expiry, making it impossible to distinguish network failures from session timeouts — which is the stated purpose of the typed reason union.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 47e2074. Configure here.


const client = getClient();

Expand Down
11 changes: 9 additions & 2 deletions packages/replay-internal/src/types/replay.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import type { Breadcrumb, ErrorEvent, ReplayRecordingData, ReplayRecordingMode, Span } from '@sentry/core';
import type {
Breadcrumb,
ErrorEvent,
ReplayRecordingData,
ReplayRecordingMode,
ReplayStopReason,
Span,
} from '@sentry/core';
import type { SKIPPED, THROTTLED } from '../util/throttle';
import type { AllPerformanceEntry, AllPerformanceEntryData, ReplayPerformanceEntry } from './performance';
import type { ReplayFrameEvent } from './replayFrame';
Expand Down Expand Up @@ -507,7 +514,7 @@ export interface ReplayContainer {
getContext(): InternalEventContext;
initializeSampling(): void;
start(): void;
stop(options?: { reason?: string; forceflush?: boolean }): Promise<void>;
stop(options?: { reason?: ReplayStopReason; forceFlush?: boolean }): Promise<void>;
pause(): void;
resume(): void;
startRecording(): void;
Expand Down
2 changes: 1 addition & 1 deletion packages/replay-internal/src/util/addEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ async function _addEvent(
return await eventBuffer.addEvent(eventAfterPossibleCallback);
} catch (error) {
const isExceeded = error && error instanceof EventBufferSizeExceededError;
const reason = isExceeded ? 'addEventSizeExceeded' : 'addEvent';
const reason = isExceeded ? 'eventBufferOverflow' : 'eventBufferError';
const client = getClient();

if (client) {
Expand Down
109 changes: 109 additions & 0 deletions packages/replay-internal/test/integration/lifecycleHooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* @vitest-environment jsdom
*/

import '../utils/mock-internal-setTimeout';
import type { ReplayEndEvent, ReplayStartEvent } from '@sentry/core';
import { getClient } from '@sentry/core';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Replay } from '../../src/integration';
import type { ReplayContainer } from '../../src/replay';
import { BASE_TIMESTAMP } from '../index';
import { resetSdkMock } from '../mocks/resetSdkMock';

describe('Integration | lifecycle hooks', () => {
let replay: ReplayContainer;
let integration: Replay;
let startEvents: ReplayStartEvent[];
let endEvents: ReplayEndEvent[];
let unsubscribes: Array<() => void>;

beforeAll(() => {
vi.useFakeTimers();
});

beforeEach(async () => {
({ replay, integration } = await resetSdkMock({
replayOptions: { stickySession: false },
sentryOptions: { replaysSessionSampleRate: 0.0 },
autoStart: false,
}));

startEvents = [];
endEvents = [];
const client = getClient()!;
unsubscribes = [
client.on('replayStart', event => startEvents.push(event)),
client.on('replayEnd', event => endEvents.push(event)),
];

await vi.runAllTimersAsync();
});

afterEach(async () => {
unsubscribes.forEach(off => off());
await integration.stop();
await vi.runAllTimersAsync();
vi.setSystemTime(new Date(BASE_TIMESTAMP));
});

it('fires replayStart with session mode when start() is called', () => {
integration.start();

expect(startEvents).toHaveLength(1);
expect(startEvents[0]).toEqual({
sessionId: expect.any(String),
recordingMode: 'session',
});
expect(startEvents[0]!.sessionId).toBe(replay.session!.id);
});

it('fires replayStart with buffer mode when startBuffering() is called', () => {
integration.startBuffering();

expect(startEvents).toHaveLength(1);
expect(startEvents[0]).toEqual({
sessionId: expect.any(String),
recordingMode: 'buffer',
});
});

it('fires replayEnd with reason "manual" when integration.stop() is called', async () => {
integration.start();
const sessionId = replay.session!.id;

await integration.stop();

expect(endEvents).toHaveLength(1);
expect(endEvents[0]).toEqual({ sessionId, reason: 'manual' });
});

it('forwards the internal stop reason to replayEnd subscribers', async () => {
integration.start();
const sessionId = replay.session!.id;

await replay.stop({ reason: 'mutationLimit' });

expect(endEvents).toHaveLength(1);
expect(endEvents[0]).toEqual({ sessionId, reason: 'mutationLimit' });
});

it('does not fire replayEnd twice when stop() is called while already stopped', async () => {
integration.start();

await replay.stop({ reason: 'sendError' });
await replay.stop({ reason: 'sendError' });

expect(endEvents).toHaveLength(1);
expect(endEvents[0]!.reason).toBe('sendError');
});

it('stops invoking callbacks after the returned unsubscribe is called', () => {
const [offStart] = unsubscribes;
offStart!();

integration.start();

expect(startEvents).toHaveLength(0);
});
});
Loading