diff --git a/CHANGELOG.md b/CHANGELOG.md index aa0de50f17..ae622e613f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - Improve frames tracker performance (#4469) - Log a warning when dropping envelopes due to rate-limiting (#4463) - Expose `SentrySessionReplayIntegration-Hybrid.h` as `private` (#4486) +- Stops session replay if rate limiting is activated (#4496) - Add `maskedViewClasses` and `unmaskedViewClasses` to SentryReplayOptions init via dict (#4492) ## 8.39.0 diff --git a/Sources/Sentry/SentryClient.m b/Sources/Sentry/SentryClient.m index a31ac3abff..b3d95d32ac 100644 --- a/Sources/Sentry/SentryClient.m +++ b/Sources/Sentry/SentryClient.m @@ -99,10 +99,10 @@ - (instancetype)initWithOptions:(SentryOptions *)options fileManager:(SentryFileManager *)fileManager deleteOldEnvelopeItems:(BOOL)deleteOldEnvelopeItems { - NSArray> *transports = [SentryTransportFactory - initTransports:options - sentryFileManager:fileManager - currentDateProvider:SentryDependencyContainer.sharedInstance.dateProvider]; + NSArray> *transports = + [SentryTransportFactory initTransports:options + sentryFileManager:fileManager + rateLimits:SentryDependencyContainer.sharedInstance.rateLimits]; SentryTransportAdapter *transportAdapter = [[SentryTransportAdapter alloc] initWithTransports:transports options:options]; diff --git a/Sources/Sentry/SentryDependencyContainer.m b/Sources/Sentry/SentryDependencyContainer.m index c53086900d..710470a6ad 100644 --- a/Sources/Sentry/SentryDependencyContainer.m +++ b/Sources/Sentry/SentryDependencyContainer.m @@ -22,8 +22,12 @@ #import #import #import +#import #import +#import #import +#import +#import #import #import #import @@ -215,6 +219,26 @@ - (SentryNSNotificationCenterWrapper *)notificationCenterWrapper } } +- (id)rateLimits +{ + @synchronized(sentryDependencyContainerLock) { + if (_rateLimits == nil) { + SentryRetryAfterHeaderParser *retryAfterHeaderParser = + [[SentryRetryAfterHeaderParser alloc] + initWithHttpDateParser:[[SentryHttpDateParser alloc] init] + currentDateProvider:self.dateProvider]; + SentryRateLimitParser *rateLimitParser = + [[SentryRateLimitParser alloc] initWithCurrentDateProvider:self.dateProvider]; + + _rateLimits = [[SentryDefaultRateLimits alloc] + initWithRetryAfterHeaderParser:retryAfterHeaderParser + andRateLimitParser:rateLimitParser + currentDateProvider:self.dateProvider]; + } + return _rateLimits; + } +} + #if SENTRY_HAS_UIKIT - (SentryUIDeviceWrapper *)uiDeviceWrapper SENTRY_DISABLE_THREAD_SANITIZER( "double-checked lock produce false alarms") diff --git a/Sources/Sentry/SentrySessionReplayIntegration.m b/Sources/Sentry/SentrySessionReplayIntegration.m index f24a3abf27..3c5245dac7 100644 --- a/Sources/Sentry/SentrySessionReplayIntegration.m +++ b/Sources/Sentry/SentrySessionReplayIntegration.m @@ -14,6 +14,7 @@ # import "SentryNSNotificationCenterWrapper.h" # import "SentryOptions.h" # import "SentryRandom.h" +# import "SentryRateLimits.h" # import "SentryReachability.h" # import "SentrySDK+Private.h" # import "SentryScope+Private.h" @@ -46,6 +47,10 @@ @implementation SentrySessionReplayIntegration { SentryReplayOptions *_replayOptions; SentryNSNotificationCenterWrapper *_notificationCenter; SentryOnDemandReplay *_resumeReplayMaker; + id _rateLimits; + // We need to use this variable to identify whether rate limiting was ever activated for session replay in this session, instead of always looking for the rate status in `SentryRateLimits` + // This is the easiest way to ensure segment 0 will always reach the server, because session replay absolutely needs segment 0 to make replay work. + BOOL _rateLimited; } - (instancetype)init @@ -78,6 +83,7 @@ - (void)setupWith:(SentryReplayOptions *)replayOptions enableTouchTracker:(BOOL) { _replayOptions = replayOptions; _viewPhotographer = [[SentryViewPhotographer alloc] initWithRedactOptions:replayOptions]; + _rateLimits = SentryDependencyContainer.sharedInstance.rateLimits; if (touchTracker) { _touchTracker = [[SentryTouchTracker alloc] @@ -416,6 +422,12 @@ - (void)resume - (void)start { + if (_rateLimited) { + SENTRY_LOG_WARN( + @"This session was rate limited. Not starting session replay until next app session"); + return; + } + if (self.sessionReplay != nil) { if (self.sessionReplay.isFullSession == NO) { [self.sessionReplay captureReplay]; @@ -447,6 +459,7 @@ - (void)sentrySessionEnded:(SentrySession *)session - (void)sentrySessionStarted:(SentrySession *)session { + _rateLimited = NO; [self startSession]; } @@ -553,6 +566,15 @@ - (void)sessionReplayNewSegmentWithReplayEvent:(SentryReplayEvent *)replayEvent replayRecording:(SentryReplayRecording *)replayRecording videoUrl:(NSURL *)videoUrl { + if ([_rateLimits isRateLimitActive:kSentryDataCategoryReplay] || + [_rateLimits isRateLimitActive:kSentryDataCategoryAll]) { + SENTRY_LOG_DEBUG( + @"Rate limiting is active for replays. Stopping session replay until next session."); + _rateLimited = YES; + [self stop]; + return; + } + [SentrySDK.currentHub captureReplayEvent:replayEvent replayRecording:replayRecording video:videoUrl]; diff --git a/Sources/Sentry/SentryTransportFactory.m b/Sources/Sentry/SentryTransportFactory.m index cbdd10e599..e689706917 100644 --- a/Sources/Sentry/SentryTransportFactory.m +++ b/Sources/Sentry/SentryTransportFactory.m @@ -25,7 +25,7 @@ @implementation SentryTransportFactory + (NSArray> *)initTransports:(SentryOptions *)options sentryFileManager:(SentryFileManager *)sentryFileManager - currentDateProvider:(id)currentDateProvider + rateLimits:(id)rateLimits { NSURLSession *session; @@ -42,17 +42,6 @@ @implementation SentryTransportFactory id requestManager = [[SentryQueueableRequestManager alloc] initWithSession:session]; - SentryHttpDateParser *httpDateParser = [[SentryHttpDateParser alloc] init]; - SentryRetryAfterHeaderParser *retryAfterHeaderParser = - [[SentryRetryAfterHeaderParser alloc] initWithHttpDateParser:httpDateParser - currentDateProvider:currentDateProvider]; - SentryRateLimitParser *rateLimitParser = - [[SentryRateLimitParser alloc] initWithCurrentDateProvider:currentDateProvider]; - id rateLimits = - [[SentryDefaultRateLimits alloc] initWithRetryAfterHeaderParser:retryAfterHeaderParser - andRateLimitParser:rateLimitParser - currentDateProvider:currentDateProvider]; - SentryEnvelopeRateLimit *envelopeRateLimit = [[SentryEnvelopeRateLimit alloc] initWithRateLimits:rateLimits]; diff --git a/Sources/Sentry/include/HybridPublic/SentryDependencyContainer.h b/Sources/Sentry/include/HybridPublic/SentryDependencyContainer.h index a506cc707a..a0b6791773 100644 --- a/Sources/Sentry/include/HybridPublic/SentryDependencyContainer.h +++ b/Sources/Sentry/include/HybridPublic/SentryDependencyContainer.h @@ -20,6 +20,7 @@ @class SentryThreadInspector; @protocol SentryRandom; @protocol SentryCurrentDateProvider; +@protocol SentryRateLimits; #if SENTRY_HAS_METRIC_KIT @class SentryMXManager; @@ -71,6 +72,7 @@ SENTRY_NO_INIT @property (nonatomic, strong) SentryExtraContextProvider *extraContextProvider; @property (nonatomic, strong) SentrySysctl *sysctlWrapper; @property (nonatomic, strong) SentryThreadInspector *threadInspector; +@property (nonatomic, strong) id rateLimits; #if SENTRY_UIKIT_AVAILABLE @property (nonatomic, strong) SentryFramesTracker *framesTracker; diff --git a/Sources/Sentry/include/SentryTransportFactory.h b/Sources/Sentry/include/SentryTransportFactory.h index da079af079..7568630803 100644 --- a/Sources/Sentry/include/SentryTransportFactory.h +++ b/Sources/Sentry/include/SentryTransportFactory.h @@ -4,6 +4,7 @@ @class SentryOptions, SentryFileManager; @protocol SentryCurrentDateProvider; +@protocol SentryRateLimits; NS_ASSUME_NONNULL_BEGIN @@ -12,7 +13,7 @@ NS_SWIFT_NAME(TransportInitializer) + (NSArray> *)initTransports:(SentryOptions *)options sentryFileManager:(SentryFileManager *)sentryFileManager - currentDateProvider:(id)currentDateProvider; + rateLimits:(id)rateLimits; @end diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift index 10f4b740fa..ed9a6010c5 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift @@ -348,6 +348,110 @@ class SentrySessionReplayIntegrationTests: XCTestCase { XCTAssertEqual(sessionReplay.sessionReplayId, replayId) } + func testStopBecauseOfReplayRateLimit() throws { + let rateLimiter = TestRateLimits() + SentryDependencyContainer.sharedInstance().rateLimits = rateLimiter + rateLimiter.rateLimits.append(.replay) + + startSDK(sessionSampleRate: 1, errorSampleRate: 1) + let sut = try getSut() + let sessionReplay = sut.sessionReplay + + XCTAssertTrue(sessionReplay?.isRunning ?? false) + + let videoUrl = URL(fileURLWithPath: "video.mp4") + let videoInfo = SentryVideoInfo(path: videoUrl, height: 1_024, width: 480, duration: 5, frameCount: 5, frameRate: 1, start: Date(), end: Date(), fileSize: 10, screens: []) + let replayEvent = SentryReplayEvent(eventId: SentryId(), replayStartTimestamp: Date(), replayType: .session, segmentId: 0) + + (sut as SentrySessionReplayDelegate).sessionReplayNewSegment(replayEvent: replayEvent, + replayRecording: SentryReplayRecording(segmentId: 0, video: videoInfo, extraEvents: []), + videoUrl: videoUrl) + + XCTAssertFalse(sessionReplay?.isRunning ?? true) + XCTAssertNil(sut.sessionReplay) + } + + func testStopBecauseOfAllRateLimit() throws { + let rateLimiter = TestRateLimits() + SentryDependencyContainer.sharedInstance().rateLimits = rateLimiter + rateLimiter.rateLimits.append(.all) + + startSDK(sessionSampleRate: 1, errorSampleRate: 1) + let sut = try getSut() + let sessionReplay = sut.sessionReplay + + XCTAssertTrue(sessionReplay?.isRunning ?? false) + + let videoUrl = URL(fileURLWithPath: "video.mp4") + let videoInfo = SentryVideoInfo(path: videoUrl, height: 1_024, width: 480, duration: 5, frameCount: 5, frameRate: 1, start: Date(), end: Date(), fileSize: 10, screens: []) + let replayEvent = SentryReplayEvent(eventId: SentryId(), replayStartTimestamp: Date(), replayType: .session, segmentId: 0) + + (sut as SentrySessionReplayDelegate).sessionReplayNewSegment(replayEvent: replayEvent, + replayRecording: SentryReplayRecording(segmentId: 0, video: videoInfo, extraEvents: []), + videoUrl: videoUrl) + + XCTAssertFalse(sessionReplay?.isRunning ?? true) + XCTAssertNil(sut.sessionReplay) + } + + func testDontRestartAfterRateLimit() throws { + let rateLimiter = TestRateLimits() + SentryDependencyContainer.sharedInstance().rateLimits = rateLimiter + rateLimiter.rateLimits.append(.all) + + startSDK(sessionSampleRate: 1, errorSampleRate: 1) + let sut = try getSut() + let sessionReplay = sut.sessionReplay + + XCTAssertTrue(sessionReplay?.isRunning ?? false) + + let videoUrl = URL(fileURLWithPath: "video.mp4") + let videoInfo = SentryVideoInfo(path: videoUrl, height: 1_024, width: 480, duration: 5, frameCount: 5, frameRate: 1, start: Date(), end: Date(), fileSize: 10, screens: []) + let replayEvent = SentryReplayEvent(eventId: SentryId(), replayStartTimestamp: Date(), replayType: .session, segmentId: 0) + + (sut as SentrySessionReplayDelegate).sessionReplayNewSegment(replayEvent: replayEvent, + replayRecording: SentryReplayRecording(segmentId: 0, video: videoInfo, extraEvents: []), + videoUrl: videoUrl) + + XCTAssertFalse(sessionReplay?.isRunning ?? true) + XCTAssertNil(sut.sessionReplay) + + sut.start() + + XCTAssertFalse(sessionReplay?.isRunning ?? true) + XCTAssertNil(sut.sessionReplay) + } + + func testAlowStartForNewSessionAfterRateLimit() throws { + let rateLimiter = TestRateLimits() + SentryDependencyContainer.sharedInstance().rateLimits = rateLimiter + rateLimiter.rateLimits.append(.all) + + startSDK(sessionSampleRate: 0, errorSampleRate: 1) + let sut = try getSut() + let sessionReplay = sut.sessionReplay + sut.start() + + XCTAssertTrue(sessionReplay?.isRunning ?? false) + + let videoUrl = URL(fileURLWithPath: "video.mp4") + let videoInfo = SentryVideoInfo(path: videoUrl, height: 1_024, width: 480, duration: 5, frameCount: 5, frameRate: 1, start: Date(), end: Date(), fileSize: 10, screens: []) + let replayEvent = SentryReplayEvent(eventId: SentryId(), replayStartTimestamp: Date(), replayType: .session, segmentId: 0) + + (sut as SentrySessionReplayDelegate).sessionReplayNewSegment(replayEvent: replayEvent, + replayRecording: SentryReplayRecording(segmentId: 0, video: videoInfo, extraEvents: []), + videoUrl: videoUrl) + XCTAssertNil(sut.sessionReplay) + + sut.start() + XCTAssertNil(sut.sessionReplay) + + (sut as SentrySessionListener).sentrySessionStarted(SentrySession(releaseName: "", distinctId: "")) + + sut.start() + XCTAssertTrue(sut.sessionReplay?.isRunning ?? false) + } + func testStartWithBufferSessionReplay() throws { startSDK(sessionSampleRate: 0, errorSampleRate: 1) let sut = try getSut() diff --git a/Tests/SentryTests/Networking/SentryTransportFactoryTests.swift b/Tests/SentryTests/Networking/SentryTransportFactoryTests.swift index e98560a01c..96659a7ac6 100644 --- a/Tests/SentryTests/Networking/SentryTransportFactoryTests.swift +++ b/Tests/SentryTests/Networking/SentryTransportFactoryTests.swift @@ -5,7 +5,7 @@ import XCTest class SentryTransportFactoryTests: XCTestCase { private static let dsnAsString = TestConstants.dsnAsString(username: "SentryTransportFactoryTests") - + func testIntegration_UrlSessionDelegate_PassedToRequestManager() throws { let urlSessionDelegateSpy = UrlSessionDelegateSpy() @@ -19,7 +19,7 @@ class SentryTransportFactoryTests: XCTestCase { options.urlSessionDelegate = urlSessionDelegateSpy let fileManager = try! SentryFileManager(options: options, dispatchQueueWrapper: TestSentryDispatchQueueWrapper()) - let transports = TransportInitializer.initTransports(options, sentryFileManager: fileManager, currentDateProvider: TestCurrentDateProvider()) + let transports = TransportInitializer.initTransports(options, sentryFileManager: fileManager, rateLimits: rateLimiting()) let httpTransport = transports.first let requestManager = try XCTUnwrap(Dynamic(httpTransport).requestManager.asObject as? SentryQueueableRequestManager) @@ -44,7 +44,7 @@ class SentryTransportFactoryTests: XCTestCase { options.urlSession = sessionConfiguration let fileManager = try! SentryFileManager(options: options, dispatchQueueWrapper: TestSentryDispatchQueueWrapper()) - let transports = TransportInitializer.initTransports(options, sentryFileManager: fileManager, currentDateProvider: TestCurrentDateProvider()) + let transports = TransportInitializer.initTransports(options, sentryFileManager: fileManager, rateLimits: rateLimiting()) let httpTransport = transports.first let requestManager = try XCTUnwrap(Dynamic(httpTransport).requestManager.asObject as? SentryQueueableRequestManager) @@ -60,7 +60,7 @@ class SentryTransportFactoryTests: XCTestCase { func testShouldReturnTwoTransports_WhenSpotlightEnabled() throws { let options = Options() options.enableSpotlight = true - let transports = TransportInitializer.initTransports(options, sentryFileManager: try SentryFileManager(options: options), currentDateProvider: TestCurrentDateProvider()) + let transports = TransportInitializer.initTransports(options, sentryFileManager: try SentryFileManager(options: options), rateLimits: rateLimiting()) XCTAssert(transports.contains { $0.isKind(of: SentrySpotlightTransport.self) @@ -70,5 +70,13 @@ class SentryTransportFactoryTests: XCTestCase { $0.isKind(of: SentryHttpTransport.self) }) } + + func rateLimiting() -> RateLimits { + let dateProvider = TestCurrentDateProvider() + let retryAfterHeaderParser = RetryAfterHeaderParser(httpDateParser: HttpDateParser(), currentDateProvider: dateProvider) + let rateLimitParser = RateLimitParser(currentDateProvider: dateProvider) + + return DefaultRateLimits(retryAfterHeaderParser: retryAfterHeaderParser, andRateLimitParser: rateLimitParser, currentDateProvider: dateProvider) + } } diff --git a/Tests/SentryTests/Networking/SentryTransportInitializerTests.swift b/Tests/SentryTests/Networking/SentryTransportInitializerTests.swift index bd629b80be..6175b39d90 100644 --- a/Tests/SentryTests/Networking/SentryTransportInitializerTests.swift +++ b/Tests/SentryTests/Networking/SentryTransportInitializerTests.swift @@ -18,7 +18,7 @@ class SentryTransportInitializerTests: XCTestCase { func testDefault() throws { let options = try Options(dict: ["dsn": SentryTransportInitializerTests.dsnAsString]) - let result = TransportInitializer.initTransports(options, sentryFileManager: fileManager, currentDateProvider: TestCurrentDateProvider()) + let result = TransportInitializer.initTransports(options, sentryFileManager: fileManager, rateLimits: SentryDependencyContainer.sharedInstance().rateLimits) XCTAssertEqual(result.count, 1) let firstTransport = result.first