From 47e2074791e37e4af972a3f6808a4423eb231fa2 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 16 Apr 2026 14:18:24 -0400 Subject: [PATCH 1/3] feat(replay): Add replayStart/replayEnd client lifecycle hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose replay lifecycle events via the client so consumers can observe recording state changes, including internal stops (session expiry, send errors, mutation limit, event buffer overflow). Fixes #20281. Usage: getClient()?.on('replayStart', ({ sessionId, recordingMode }) => { ... }); getClient()?.on('replayEnd', ({ sessionId, reason }) => { ... }); The `reason` on `replayEnd` is a typed union — `manual`, `sessionExpired`, `sendError`, `mutationLimit`, `eventBufferError`, or `eventBufferOverflow` — letting consumers distinguish user-initiated from internal stops. Internal-only `reason` strings previously used for debug logs have been renamed to match the public union (e.g. `refresh session` → `sessionExpired`). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/core/src/client.ts | 24 ++++ packages/core/src/index.ts | 9 +- packages/core/src/types-hoist/replay.ts | 34 ++++++ packages/replay-internal/src/integration.ts | 2 +- packages/replay-internal/src/replay.ts | 23 +++- packages/replay-internal/src/types/replay.ts | 11 +- packages/replay-internal/src/util/addEvent.ts | 2 +- .../test/integration/lifecycleHooks.test.ts | 109 ++++++++++++++++++ 8 files changed, 204 insertions(+), 10 deletions(-) create mode 100644 packages/replay-internal/test/integration/lifecycleHooks.test.ts diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 6c3ca949f38e..00c12db06855 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -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'; @@ -726,6 +727,19 @@ export abstract class Client { */ 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. @@ -1001,6 +1015,16 @@ export abstract class Client { */ 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. */ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f4039244e550..38a1c2d4a5ab 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/core/src/types-hoist/replay.ts b/packages/core/src/types-hoist/replay.ts index 65641ce011bd..a23f548aa357 100644 --- a/packages/core/src/types-hoist/replay.ts +++ b/packages/core/src/types-hoist/replay.ts @@ -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'; + +/** + * 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; + reason: ReplayStopReason; +} diff --git a/packages/replay-internal/src/integration.ts b/packages/replay-internal/src/integration.ts index a940ef746979..ec762eacd8dd 100644 --- a/packages/replay-internal/src/integration.ts +++ b/packages/replay-internal/src/integration.ts @@ -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' }); } /** diff --git a/packages/replay-internal/src/replay.ts b/packages/replay-internal/src/replay.ts index cab408ca9d5d..d80f47a6704b 100644 --- a/packages/replay-internal/src/replay.ts +++ b/packages/replay-internal/src/replay.ts @@ -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 { @@ -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 { + public async stop({ + forceFlush = false, + reason, + }: { forceFlush?: boolean; reason?: ReplayStopReason } = {}): Promise { if (!this._isEnabled) { return; } @@ -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 }); + try { - DEBUG_BUILD && debug.log(`Stopping Replay${reason ? ` triggered by ${reason}` : ''}`); + DEBUG_BUILD && debug.log(`Stopping Replay triggered by ${stopReason}`); resetReplayIdOnDynamicSamplingContext(); @@ -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, + recordingMode: this.recordingMode, + }); + } + this.startRecording(); } @@ -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); } @@ -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' }); const client = getClient(); diff --git a/packages/replay-internal/src/types/replay.ts b/packages/replay-internal/src/types/replay.ts index 6f8d836611bb..95cfbdd849bf 100644 --- a/packages/replay-internal/src/types/replay.ts +++ b/packages/replay-internal/src/types/replay.ts @@ -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'; @@ -507,7 +514,7 @@ export interface ReplayContainer { getContext(): InternalEventContext; initializeSampling(): void; start(): void; - stop(options?: { reason?: string; forceflush?: boolean }): Promise; + stop(options?: { reason?: ReplayStopReason; forceFlush?: boolean }): Promise; pause(): void; resume(): void; startRecording(): void; diff --git a/packages/replay-internal/src/util/addEvent.ts b/packages/replay-internal/src/util/addEvent.ts index 0cd76227379c..d7c11f5f0ab6 100644 --- a/packages/replay-internal/src/util/addEvent.ts +++ b/packages/replay-internal/src/util/addEvent.ts @@ -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) { diff --git a/packages/replay-internal/test/integration/lifecycleHooks.test.ts b/packages/replay-internal/test/integration/lifecycleHooks.test.ts new file mode 100644 index 000000000000..814e50491bfb --- /dev/null +++ b/packages/replay-internal/test/integration/lifecycleHooks.test.ts @@ -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); + }); +}); From e5b525f98cc56de818e9ebc0df1781d137b5c154 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 16 Apr 2026 14:55:10 -0400 Subject: [PATCH 2/3] chore(size-limit): Bump CDN bundle (tracing+replay) uncompressed limit to 252 KB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The replayStart/replayEnd hook overloads and payload types push the uncompressed tracing+replay CDN bundle to 251,080 bytes — 80 bytes over the previous 251 KB cap. Co-Authored-By: Claude Opus 4.7 (1M context) --- .size-limit.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.size-limit.js b/.size-limit.js index 86f3ef5ed87d..abad31ed1f38 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -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', From 19406408c41deea2252ab8db41d22812d1ce0912 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 16 Apr 2026 15:52:52 -0400 Subject: [PATCH 3/3] chore(size-limit): Bump CDN bundle (tracing+replay+feedback) uncompressed limit to 265 KB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The replay lifecycle hook overloads push this bundle to 264,109 bytes on the CI (Linux) runner — 109 bytes over the 264 KB cap. Local (macOS) builds measured the tracing+replay bundle at the edge but kept this one under; Linux produced slightly larger artifacts for both. Co-Authored-By: Claude Opus 4.7 (1M context) --- .size-limit.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.size-limit.js b/.size-limit.js index abad31ed1f38..de5abf6f1008 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -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',