Skip to content

Commit 6284aff

Browse files
logaretmclaude
andauthored
feat(replay): Add replayStart/replayEnd client lifecycle hooks (#20369)
Expose replay lifecycle events on the client so external consumers can observe when recording starts and stops. This includes internal stops (session expiry, send errors, mutation limit, event buffer overflow) that today are invisible to wrapper libraries. Closes #20281. ## API ```ts getClient()?.on('replayStart', ({ sessionId, recordingMode }) => { // recordingMode: 'session' | 'buffer' }); getClient()?.on('replayEnd', ({ sessionId, reason }) => { // reason: 'manual' | 'sessionExpired' | 'sendError' | 'mutationLimit' // | 'eventBufferError' | 'eventBufferOverflow' }); ``` The typed `reason` union lets consumers distinguish a user-initiated `replay.stop()` from an internally triggered stop, so wrapper state can stay in sync with actual replay state. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent be13537 commit 6284aff

File tree

8 files changed

+204
-10
lines changed

8 files changed

+204
-10
lines changed

packages/core/src/client.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import type { Metric } from './types-hoist/metric';
2828
import type { Primitive } from './types-hoist/misc';
2929
import type { ClientOptions } from './types-hoist/options';
3030
import type { ParameterizedString } from './types-hoist/parameterize';
31+
import type { ReplayEndEvent, ReplayStartEvent } from './types-hoist/replay';
3132
import type { RequestEventData } from './types-hoist/request';
3233
import type { SdkMetadata } from './types-hoist/sdkmetadata';
3334
import type { Session, SessionAggregates } from './types-hoist/session';
@@ -726,6 +727,19 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
726727
*/
727728
public on(hook: 'openFeedbackWidget', callback: () => void): () => void;
728729

730+
/**
731+
* A hook that is called when a replay session starts recording (either session or buffer mode).
732+
* @returns {() => void} A function that, when executed, removes the registered callback.
733+
*/
734+
public on(hook: 'replayStart', callback: (event: ReplayStartEvent) => void): () => void;
735+
736+
/**
737+
* A hook that is called when a replay session stops recording, either manually or due to an
738+
* internal condition such as `maxReplayDuration` expiry, send failure, or mutation limit.
739+
* @returns {() => void} A function that, when executed, removes the registered callback.
740+
*/
741+
public on(hook: 'replayEnd', callback: (event: ReplayEndEvent) => void): () => void;
742+
729743
/**
730744
* A hook for the browser tracing integrations to trigger a span start for a page load.
731745
* @returns {() => void} A function that, when executed, removes the registered callback.
@@ -1001,6 +1015,16 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
10011015
*/
10021016
public emit(hook: 'openFeedbackWidget'): void;
10031017

1018+
/**
1019+
* Fire a hook event when a replay session starts recording.
1020+
*/
1021+
public emit(hook: 'replayStart', event: ReplayStartEvent): void;
1022+
1023+
/**
1024+
* Fire a hook event when a replay session stops recording.
1025+
*/
1026+
public emit(hook: 'replayEnd', event: ReplayEndEvent): void;
1027+
10041028
/**
10051029
* Emit a hook event for browser tracing integrations to trigger a span start for a page load.
10061030
*/

packages/core/src/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -441,7 +441,14 @@ export type {
441441
Profile,
442442
ProfileChunk,
443443
} from './types-hoist/profiling';
444-
export type { ReplayEvent, ReplayRecordingData, ReplayRecordingMode } from './types-hoist/replay';
444+
export type {
445+
ReplayEndEvent,
446+
ReplayEvent,
447+
ReplayRecordingData,
448+
ReplayRecordingMode,
449+
ReplayStartEvent,
450+
ReplayStopReason,
451+
} from './types-hoist/replay';
445452
export type {
446453
FeedbackEvent,
447454
FeedbackFormData,

packages/core/src/types-hoist/replay.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,37 @@ export type ReplayRecordingData = string | Uint8Array;
2525
* @hidden
2626
*/
2727
export type ReplayRecordingMode = 'session' | 'buffer';
28+
29+
/**
30+
* Reason a replay recording stopped, passed to the `replayEnd` client hook.
31+
*
32+
* - `manual`: user called `replay.stop()`.
33+
* - `sessionExpired`: session hit `maxReplayDuration` or the idle-expiry threshold.
34+
* - `sendError`: a replay segment failed to send after retries.
35+
* - `mutationLimit`: DOM mutation budget for the session was exhausted.
36+
* - `eventBufferError`: the event buffer threw an unexpected error.
37+
* - `eventBufferOverflow`: the event buffer ran out of space.
38+
*/
39+
export type ReplayStopReason =
40+
| 'manual'
41+
| 'sessionExpired'
42+
| 'sendError'
43+
| 'mutationLimit'
44+
| 'eventBufferError'
45+
| 'eventBufferOverflow';
46+
47+
/**
48+
* Payload emitted on the `replayStart` client hook when a replay begins recording.
49+
*/
50+
export interface ReplayStartEvent {
51+
sessionId: string;
52+
recordingMode: ReplayRecordingMode;
53+
}
54+
55+
/**
56+
* Payload emitted on the `replayEnd` client hook when a replay stops recording.
57+
*/
58+
export interface ReplayEndEvent {
59+
sessionId?: string;
60+
reason: ReplayStopReason;
61+
}

packages/replay-internal/src/integration.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ export class Replay implements Integration {
297297
return Promise.resolve();
298298
}
299299

300-
return this._replay.stop({ forceFlush: this._replay.recordingMode === 'session' });
300+
return this._replay.stop({ forceFlush: this._replay.recordingMode === 'session', reason: 'manual' });
301301
}
302302

303303
/**

packages/replay-internal/src/replay.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* eslint-disable max-lines */ // TODO: We might want to split this file up
2-
import type { ReplayRecordingMode, Span } from '@sentry/core';
2+
import type { ReplayRecordingMode, ReplayStopReason, Span } from '@sentry/core';
33
import { getActiveSpan, getClient, getRootSpan, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, spanToJSON } from '@sentry/core';
44
import { EventType, record } from '@sentry-internal/rrweb';
55
import {
@@ -495,7 +495,10 @@ export class ReplayContainer implements ReplayContainerInterface {
495495
* Currently, this needs to be manually called (e.g. for tests). Sentry SDK
496496
* does not support a teardown
497497
*/
498-
public async stop({ forceFlush = false, reason }: { forceFlush?: boolean; reason?: string } = {}): Promise<void> {
498+
public async stop({
499+
forceFlush = false,
500+
reason,
501+
}: { forceFlush?: boolean; reason?: ReplayStopReason } = {}): Promise<void> {
499502
if (!this._isEnabled) {
500503
return;
501504
}
@@ -508,8 +511,11 @@ export class ReplayContainer implements ReplayContainerInterface {
508511
// breadcrumbs to trigger a flush (e.g. in `addUpdate()`)
509512
this.recordingMode = 'buffer';
510513

514+
const stopReason: ReplayStopReason = reason ?? 'manual';
515+
getClient()?.emit('replayEnd', { sessionId: this.session?.id, reason: stopReason });
516+
511517
try {
512-
DEBUG_BUILD && debug.log(`Stopping Replay${reason ? ` triggered by ${reason}` : ''}`);
518+
DEBUG_BUILD && debug.log(`Stopping Replay triggered by ${stopReason}`);
513519

514520
resetReplayIdOnDynamicSamplingContext();
515521

@@ -862,6 +868,13 @@ export class ReplayContainer implements ReplayContainerInterface {
862868
this._isEnabled = true;
863869
this._isPaused = false;
864870

871+
if (this.session) {
872+
getClient()?.emit('replayStart', {
873+
sessionId: this.session.id,
874+
recordingMode: this.recordingMode,
875+
});
876+
}
877+
865878
this.startRecording();
866879
}
867880

@@ -926,7 +939,7 @@ export class ReplayContainer implements ReplayContainerInterface {
926939
if (!this._isEnabled) {
927940
return;
928941
}
929-
await this.stop({ reason: 'refresh session' });
942+
await this.stop({ reason: 'sessionExpired' });
930943
this.initializeSampling(session.id);
931944
}
932945

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

12171230
const client = getClient();
12181231

packages/replay-internal/src/types/replay.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import type { Breadcrumb, ErrorEvent, ReplayRecordingData, ReplayRecordingMode, Span } from '@sentry/core';
1+
import type {
2+
Breadcrumb,
3+
ErrorEvent,
4+
ReplayRecordingData,
5+
ReplayRecordingMode,
6+
ReplayStopReason,
7+
Span,
8+
} from '@sentry/core';
29
import type { SKIPPED, THROTTLED } from '../util/throttle';
310
import type { AllPerformanceEntry, AllPerformanceEntryData, ReplayPerformanceEntry } from './performance';
411
import type { ReplayFrameEvent } from './replayFrame';
@@ -507,7 +514,7 @@ export interface ReplayContainer {
507514
getContext(): InternalEventContext;
508515
initializeSampling(): void;
509516
start(): void;
510-
stop(options?: { reason?: string; forceflush?: boolean }): Promise<void>;
517+
stop(options?: { reason?: ReplayStopReason; forceFlush?: boolean }): Promise<void>;
511518
pause(): void;
512519
resume(): void;
513520
startRecording(): void;

packages/replay-internal/src/util/addEvent.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ async function _addEvent(
8282
return await eventBuffer.addEvent(eventAfterPossibleCallback);
8383
} catch (error) {
8484
const isExceeded = error && error instanceof EventBufferSizeExceededError;
85-
const reason = isExceeded ? 'addEventSizeExceeded' : 'addEvent';
85+
const reason = isExceeded ? 'eventBufferOverflow' : 'eventBufferError';
8686
const client = getClient();
8787

8888
if (client) {
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
5+
import '../utils/mock-internal-setTimeout';
6+
import type { ReplayEndEvent, ReplayStartEvent } from '@sentry/core';
7+
import { getClient } from '@sentry/core';
8+
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
9+
import type { Replay } from '../../src/integration';
10+
import type { ReplayContainer } from '../../src/replay';
11+
import { BASE_TIMESTAMP } from '../index';
12+
import { resetSdkMock } from '../mocks/resetSdkMock';
13+
14+
describe('Integration | lifecycle hooks', () => {
15+
let replay: ReplayContainer;
16+
let integration: Replay;
17+
let startEvents: ReplayStartEvent[];
18+
let endEvents: ReplayEndEvent[];
19+
let unsubscribes: Array<() => void>;
20+
21+
beforeAll(() => {
22+
vi.useFakeTimers();
23+
});
24+
25+
beforeEach(async () => {
26+
({ replay, integration } = await resetSdkMock({
27+
replayOptions: { stickySession: false },
28+
sentryOptions: { replaysSessionSampleRate: 0.0 },
29+
autoStart: false,
30+
}));
31+
32+
startEvents = [];
33+
endEvents = [];
34+
const client = getClient()!;
35+
unsubscribes = [
36+
client.on('replayStart', event => startEvents.push(event)),
37+
client.on('replayEnd', event => endEvents.push(event)),
38+
];
39+
40+
await vi.runAllTimersAsync();
41+
});
42+
43+
afterEach(async () => {
44+
unsubscribes.forEach(off => off());
45+
await integration.stop();
46+
await vi.runAllTimersAsync();
47+
vi.setSystemTime(new Date(BASE_TIMESTAMP));
48+
});
49+
50+
it('fires replayStart with session mode when start() is called', () => {
51+
integration.start();
52+
53+
expect(startEvents).toHaveLength(1);
54+
expect(startEvents[0]).toEqual({
55+
sessionId: expect.any(String),
56+
recordingMode: 'session',
57+
});
58+
expect(startEvents[0]!.sessionId).toBe(replay.session!.id);
59+
});
60+
61+
it('fires replayStart with buffer mode when startBuffering() is called', () => {
62+
integration.startBuffering();
63+
64+
expect(startEvents).toHaveLength(1);
65+
expect(startEvents[0]).toEqual({
66+
sessionId: expect.any(String),
67+
recordingMode: 'buffer',
68+
});
69+
});
70+
71+
it('fires replayEnd with reason "manual" when integration.stop() is called', async () => {
72+
integration.start();
73+
const sessionId = replay.session!.id;
74+
75+
await integration.stop();
76+
77+
expect(endEvents).toHaveLength(1);
78+
expect(endEvents[0]).toEqual({ sessionId, reason: 'manual' });
79+
});
80+
81+
it('forwards the internal stop reason to replayEnd subscribers', async () => {
82+
integration.start();
83+
const sessionId = replay.session!.id;
84+
85+
await replay.stop({ reason: 'mutationLimit' });
86+
87+
expect(endEvents).toHaveLength(1);
88+
expect(endEvents[0]).toEqual({ sessionId, reason: 'mutationLimit' });
89+
});
90+
91+
it('does not fire replayEnd twice when stop() is called while already stopped', async () => {
92+
integration.start();
93+
94+
await replay.stop({ reason: 'sendError' });
95+
await replay.stop({ reason: 'sendError' });
96+
97+
expect(endEvents).toHaveLength(1);
98+
expect(endEvents[0]!.reason).toBe('sendError');
99+
});
100+
101+
it('stops invoking callbacks after the returned unsubscribe is called', () => {
102+
const [offStart] = unsubscribes;
103+
offStart!();
104+
105+
integration.start();
106+
107+
expect(startEvents).toHaveLength(0);
108+
});
109+
});

0 commit comments

Comments
 (0)