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
13 changes: 10 additions & 3 deletions packages/replay-internal/src/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ import { debug } from './util/logger';
import { resetReplayIdOnDynamicSamplingContext } from './util/resetReplayIdOnDynamicSamplingContext';
import { closestElementOfNode } from './util/rrweb';
import { sendReplay } from './util/sendReplay';
import { RateLimitError } from './util/sendReplayRequest';
import { RateLimitError, ReplayDurationLimitError } from './util/sendReplayRequest';
import type { SKIPPED } from './util/throttle';
import { throttle, THROTTLED } from './util/throttle';

Expand Down Expand Up @@ -1185,7 +1185,7 @@ export class ReplayContainer implements ReplayContainerInterface {
// We leave 30s wiggle room to accommodate late flushing etc.
// This _could_ happen when the browser is suspended during flushing, in which case we just want to stop
if (timestamp - this._context.initialTimestamp > this._options.maxReplayDuration + 30_000) {
throw new Error('Session is too long, not sending replay');
throw new ReplayDurationLimitError();
}

const eventContext = this._popEventContext();
Expand Down Expand Up @@ -1218,7 +1218,14 @@ export class ReplayContainer implements ReplayContainerInterface {
const client = getClient();

if (client) {
const dropReason = err instanceof RateLimitError ? 'ratelimit_backoff' : 'send_error';
let dropReason: 'ratelimit_backoff' | 'send_error' | 'sample_rate';
if (err instanceof RateLimitError) {
dropReason = 'ratelimit_backoff';
} else if (err instanceof ReplayDurationLimitError) {
dropReason = 'sample_rate';
Copy link
Member

Choose a reason for hiding this comment

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

'sample_rate' would not be the correct drop reason here (this is only used in case an event was dropped because of the configured sample rate).

Copy link
Author

Choose a reason for hiding this comment

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

got it then if im not wrong then is network_error the appropriate drop reason ?

Copy link
Member

@chargome chargome Jan 19, 2026

Choose a reason for hiding this comment

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

No, let's work with ignored for now (We might add a new drop reason in the future!) – please also update the browser integration tests for this

Copy link
Author

Choose a reason for hiding this comment

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

Make sense, I'll update it

Copy link
Member

Choose a reason for hiding this comment

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

@harshit078 actually we'll introduce a new reason right away so this stays consistant. Mind if I take this PR over? The rest looks good already

Copy link

Choose a reason for hiding this comment

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

Incorrect drop reason for duration limit exceeded

Medium Severity

The dropReason for ReplayDurationLimitError is set to 'sample_rate', but this is semantically incorrect. As noted in the PR discussion, 'sample_rate' is specifically meant for events dropped due to the configured sample rate (e.g., sessionSampleRate), not for replays that exceed the maximum duration limit. This will cause misleading analytics where duration-exceeded drops appear as sample-rate drops, making it harder to diagnose why replays are being discarded.

Fix in Cursor Fix in Web

} else {
dropReason = 'send_error';
}
client.recordDroppedEvent(dropReason, 'replay');
}
}
Expand Down
10 changes: 10 additions & 0 deletions packages/replay-internal/src/util/sendReplayRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,13 @@ export class RateLimitError extends Error {
this.rateLimits = rateLimits;
}
}

/**
* This error indicates that the replay duration limit was exceeded and the session is too long.
*
*/
export class ReplayDurationLimitError extends Error {
public constructor() {
super('Session is too long, not sending replay');
}
}
43 changes: 43 additions & 0 deletions packages/replay-internal/test/integration/flush.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,49 @@ describe('Integration | flush', () => {
await replay.start();
});

/**
* This tests that when a replay exceeds maxReplayDuration,
* the dropped event is recorded with the 'sample_rate' reason
* to distinguish it from actual send errors.
*/
it('records dropped event with sample_rate reason when session exceeds maxReplayDuration', async () => {
const client = SentryUtils.getClient()!;
const recordDroppedEventSpy = vi.spyOn(client, 'recordDroppedEvent');

replay.getOptions().maxReplayDuration = 100_000;

sessionStorage.clear();
clearSession(replay);
replay['_initializeSessionForSampling']();
replay.setInitialState();
await new Promise(process.nextTick);
vi.setSystemTime(BASE_TIMESTAMP);

replay.eventBuffer!.clear();

replay.eventBuffer!.hasCheckout = true;

replay['_addPerformanceEntries'] = () => {
return new Promise(resolve => setTimeout(resolve, 140_000));
};

const TEST_EVENT = getTestEventCheckout({ timestamp: BASE_TIMESTAMP + 100 });
mockRecord._emitter(TEST_EVENT);

await vi.advanceTimersByTimeAsync(160_000);

expect(mockFlush).toHaveBeenCalledTimes(1);
expect(mockSendReplay).toHaveBeenCalledTimes(0);
expect(replay.isEnabled()).toBe(false);

expect(recordDroppedEventSpy).toHaveBeenCalledWith('sample_rate', 'replay');

replay.getOptions().maxReplayDuration = MAX_REPLAY_DURATION;
recordDroppedEventSpy.mockRestore();

await replay.start();
});

it('resets flush lock if runFlush rejects/throws', async () => {
mockRunFlush.mockImplementation(
() =>
Expand Down
38 changes: 38 additions & 0 deletions packages/replay-internal/test/integration/rateLimiting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,42 @@ describe('Integration | rate-limiting behaviour', () => {
expect(replay.session).toBeDefined();
expect(replay.isEnabled()).toBe(true);
});

it('records dropped event with ratelimit_backoff reason when rate limited', async () => {
const client = getClient()!;
const recordDroppedEventSpy = vi.spyOn(client, 'recordDroppedEvent');

mockTransportSend.mockImplementationOnce(() => {
return Promise.resolve({ statusCode: 429, headers: { 'retry-after': '10' } } as TransportMakeRequestResponse);
});

replay.start();
await advanceTimers(DEFAULT_FLUSH_MIN_DELAY);

expect(replay.isEnabled()).toBe(false);
expect(recordDroppedEventSpy).toHaveBeenCalledWith('ratelimit_backoff', 'replay');

recordDroppedEventSpy.mockRestore();
});

it('records dropped event with send_error reason when transport fails', async () => {
const client = getClient()!;
const recordDroppedEventSpy = vi.spyOn(client, 'recordDroppedEvent');

mockTransportSend.mockImplementation(() => {
return Promise.reject(new Error('Network error'));
});

replay.start();
await advanceTimers(DEFAULT_FLUSH_MIN_DELAY);

await advanceTimers(5000);
await advanceTimers(10000);
await advanceTimers(30000);

expect(replay.isEnabled()).toBe(false);
expect(recordDroppedEventSpy).toHaveBeenCalledWith('send_error', 'replay');

recordDroppedEventSpy.mockRestore();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import {
RateLimitError,
ReplayDurationLimitError,
TransportStatusCodeError,
} from '../../../src/util/sendReplayRequest';

describe('Unit | util | sendReplayRequest', () => {
describe('TransportStatusCodeError', () => {
it('creates error with correct message', () => {
const error = new TransportStatusCodeError(500);
expect(error.message).toBe('Transport returned status code 500');
expect(error).toBeInstanceOf(Error);
});
});

describe('RateLimitError', () => {
it('creates error with correct message and stores rate limits', () => {
const rateLimits = { all: 1234567890 };
const error = new RateLimitError(rateLimits);
expect(error.message).toBe('Rate limit hit');
expect(error.rateLimits).toBe(rateLimits);
expect(error).toBeInstanceOf(Error);
});
});

describe('ReplayDurationLimitError', () => {
it('creates error with correct message', () => {
const error = new ReplayDurationLimitError();
expect(error.message).toBe('Session is too long, not sending replay');
expect(error).toBeInstanceOf(Error);
});

it('is distinguishable from other error types', () => {
const durationError = new ReplayDurationLimitError();
const rateLimitError = new RateLimitError({ all: 123 });
const transportError = new TransportStatusCodeError(500);

expect(durationError instanceof ReplayDurationLimitError).toBe(true);
expect(durationError instanceof RateLimitError).toBe(false);
expect(durationError instanceof TransportStatusCodeError).toBe(false);

expect(rateLimitError instanceof ReplayDurationLimitError).toBe(false);
expect(rateLimitError instanceof RateLimitError).toBe(true);

expect(transportError instanceof ReplayDurationLimitError).toBe(false);
expect(transportError instanceof TransportStatusCodeError).toBe(true);
});
});
});