diff --git a/packages/replay-internal/src/replay.ts b/packages/replay-internal/src/replay.ts index 49e8ce092edd..0538da17da45 100644 --- a/packages/replay-internal/src/replay.ts +++ b/packages/replay-internal/src/replay.ts @@ -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'; @@ -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(); @@ -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'); } } diff --git a/packages/replay-internal/src/util/sendReplayRequest.ts b/packages/replay-internal/src/util/sendReplayRequest.ts index 4f40934f37d3..64be990c1f37 100644 --- a/packages/replay-internal/src/util/sendReplayRequest.ts +++ b/packages/replay-internal/src/util/sendReplayRequest.ts @@ -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'); + } +} diff --git a/packages/replay-internal/test/integration/flush.test.ts b/packages/replay-internal/test/integration/flush.test.ts index d9c45278855b..409f3a64bece 100644 --- a/packages/replay-internal/test/integration/flush.test.ts +++ b/packages/replay-internal/test/integration/flush.test.ts @@ -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( () => diff --git a/packages/replay-internal/test/integration/rateLimiting.test.ts b/packages/replay-internal/test/integration/rateLimiting.test.ts index 688c9469fc40..745c4378a91f 100644 --- a/packages/replay-internal/test/integration/rateLimiting.test.ts +++ b/packages/replay-internal/test/integration/rateLimiting.test.ts @@ -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(); + }); }); diff --git a/packages/replay-internal/test/unit/util/sendReplayRequest.test.ts b/packages/replay-internal/test/unit/util/sendReplayRequest.test.ts new file mode 100644 index 000000000000..f5ea1787571a --- /dev/null +++ b/packages/replay-internal/test/unit/util/sendReplayRequest.test.ts @@ -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); + }); + }); +});