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';
} 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);
});
});
});