diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e060eef42..2c83e047c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,9 +20,9 @@ via the option `swizzleClassNameExclude`. - Finish TTID correctly when viewWillAppear is skipped (#4417) - Swizzling RootUIViewController if ignored by `swizzleClassNameExclude` (#4407) - Data race in SentrySwizzleInfo.originalCalled (#4434) +- Delete old session replay files (#4446) - Thread running at user-initiated quality-of-service for session replay (#4439) - ### Improvements - Serializing profile on a BG Thread (#4377) to avoid potentially slightly blocking the main thread. diff --git a/Sources/Sentry/SentryFileManager.m b/Sources/Sentry/SentryFileManager.m index 366f23b421..69a63c7582 100644 --- a/Sources/Sentry/SentryFileManager.m +++ b/Sources/Sentry/SentryFileManager.m @@ -270,6 +270,12 @@ - (void)deleteOldEnvelopesFromPath:(NSString *)envelopesPath } } +- (BOOL)isDirectory:(NSString *)path +{ + BOOL isDir = NO; + return [NSFileManager.defaultManager fileExistsAtPath:path isDirectory:&isDir] && isDir; +} + - (void)deleteAllEnvelopes { [self removeFileAtPath:self.envelopesPath]; diff --git a/Sources/Sentry/SentrySessionReplayIntegration.m b/Sources/Sentry/SentrySessionReplayIntegration.m index 3ecd0e1972..f24a3abf27 100644 --- a/Sources/Sentry/SentrySessionReplayIntegration.m +++ b/Sources/Sentry/SentrySessionReplayIntegration.m @@ -89,6 +89,8 @@ - (void)setupWith:(SentryReplayOptions *)replayOptions enableTouchTracker:(BOOL) _notificationCenter = SentryDependencyContainer.sharedInstance.notificationCenterWrapper; [self moveCurrentReplay]; + [self cleanUp]; + [SentrySDK.currentHub registerSessionListener:self]; [SentryGlobalEventProcessor.shared addEventProcessor:^SentryEvent *_Nullable(SentryEvent *_Nonnull event) { @@ -103,6 +105,19 @@ - (void)setupWith:(SentryReplayOptions *)replayOptions enableTouchTracker:(BOOL) [SentryDependencyContainer.sharedInstance.reachability addObserver:self]; } +- (nullable NSDictionary *)lastReplayInfo +{ + NSURL *dir = [self replayDirectory]; + NSURL *lastReplayUrl = [dir URLByAppendingPathComponent:SENTRY_LAST_REPLAY]; + NSData *lastReplay = [NSData dataWithContentsOfURL:lastReplayUrl]; + + if (lastReplay == nil) { + return nil; + } + + return [SentrySerialization deserializeDictionaryFromJsonData:lastReplay]; +} + /** * Send the cached frames from a previous session that eventually crashed. * This function is called when processing an event created by SentryCrashIntegration, @@ -112,15 +127,8 @@ - (void)setupWith:(SentryReplayOptions *)replayOptions enableTouchTracker:(BOOL) - (void)resumePreviousSessionReplay:(SentryEvent *)event { NSURL *dir = [self replayDirectory]; - NSURL *lastReplayUrl = [dir URLByAppendingPathComponent:SENTRY_LAST_REPLAY]; - NSData *lastReplay = [NSData dataWithContentsOfURL:lastReplayUrl]; + NSDictionary *jsonObject = [self lastReplayInfo]; - if (lastReplay == nil) { - return; - } - - NSDictionary *jsonObject = - [SentrySerialization deserializeDictionaryFromJsonData:lastReplay]; if (jsonObject == nil) { return; } @@ -365,6 +373,37 @@ - (void)moveCurrentReplay } } +- (void)cleanUp +{ + NSURL *replayDir = [self replayDirectory]; + NSDictionary *lastReplayInfo = [self lastReplayInfo]; + NSString *lastReplayFolder = lastReplayInfo[@"path"]; + + SentryFileManager *fileManager = SentryDependencyContainer.sharedInstance.fileManager; + // Mapping replay folder here and not in dispatched queue to prevent a race condition between + // listing files and creating a new replay session. + NSArray *replayFiles = [fileManager allFilesInFolder:replayDir.path]; + if (replayFiles.count == 0) { + return; + } + + [SentryDependencyContainer.sharedInstance.dispatchQueueWrapper dispatchAsyncWithBlock:^{ + for (NSString *file in replayFiles) { + // Skip the last replay folder. + if ([file isEqualToString:lastReplayFolder]) { + continue; + } + + NSString *filePath = [replayDir.path stringByAppendingPathComponent:file]; + + // Check if the file is a directory before deleting it. + if ([fileManager isDirectory:filePath]) { + [fileManager removeFileAtPath:filePath]; + } + } + }]; +} + - (void)pause { [self.sessionReplay pause]; diff --git a/Sources/Sentry/include/SentryFileManager.h b/Sources/Sentry/include/SentryFileManager.h index 8ababf2f18..b66a8f81e5 100644 --- a/Sources/Sentry/include/SentryFileManager.h +++ b/Sources/Sentry/include/SentryFileManager.h @@ -89,7 +89,8 @@ SENTRY_NO_INIT - (NSNumber *_Nullable)readTimezoneOffset; - (void)storeTimezoneOffset:(NSInteger)offset; - (void)deleteTimezoneOffset; - +- (NSArray *)allFilesInFolder:(NSString *)path; +- (BOOL)isDirectory:(NSString *)path; BOOL createDirectoryIfNotExists(NSString *path, NSError **error); /** diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift index 156ea6eb7d..10f4b740fa 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift @@ -358,12 +358,47 @@ class SentrySessionReplayIntegrationTests: XCTestCase { XCTAssertTrue(sessionReplay.isFullSession) } - func createLastSessionReplay(writeSessionInfo: Bool = true, errorSampleRate: Double = 1) throws { + func testCleanUp() throws { + // Create 3 old Sessions + try createLastSessionReplay() + try createLastSessionReplay() + try createLastSessionReplay() + SentryDependencyContainer.sharedInstance().dispatchQueueWrapper = TestSentryDispatchQueueWrapper() + + // Start the integration with a configuration that will enable it + startSDK(sessionSampleRate: 0, errorSampleRate: 1) + + // Check whether there is only one old session directory and the current session directory + let content = try FileManager.default.contentsOfDirectory(atPath: replayFolder()).filter { name in + !name.hasPrefix("replay") && !name.hasPrefix(".") //remove replay info files and system directories + } + + XCTAssertEqual(content.count, 2) + } + + func testCleanUpWithNoFiles() throws { let options = Options() options.dsn = "https://user@test.com/test" options.cacheDirectoryPath = FileManager.default.temporaryDirectory.path - let replayFolder = options.cacheDirectoryPath + "/io.sentry/\(options.parsedDsn?.getHash() ?? "")/replay" + let dispatchQueue = TestSentryDispatchQueueWrapper() + SentryDependencyContainer.sharedInstance().dispatchQueueWrapper = dispatchQueue + SentryDependencyContainer.sharedInstance().fileManager = try SentryFileManager(options: options) + + if FileManager.default.fileExists(atPath: replayFolder()) { + try FileManager.default.removeItem(atPath: replayFolder()) + } + + // We can't use SentrySDK.start because the dependency container dispatch queue is used for other tasks. + // Manually starting the integration and initializing it makes the test more controlled. + let integration = SentrySessionReplayIntegration() + integration.install(with: options) + + XCTAssertEqual(dispatchQueue.dispatchAsyncCalled, 0) + } + + func createLastSessionReplay(writeSessionInfo: Bool = true, errorSampleRate: Double = 1) throws { + let replayFolder = replayFolder() let jsonPath = replayFolder + "/replay.current" var sessionFolder = UUID().uuidString let info: [String: Any] = ["replayId": SentryId().sentryIdString, @@ -389,6 +424,13 @@ class SentrySessionReplayIntegrationTests: XCTestCase { sentrySessionReplaySync_writeInfo() } } + + func replayFolder() -> String { + let options = Options() + options.dsn = "https://user@test.com/test" + options.cacheDirectoryPath = FileManager.default.temporaryDirectory.path + return options.cacheDirectoryPath + "/io.sentry/\(options.parsedDsn?.getHash() ?? "")/replay" + } } #endif