diff --git a/CHANGELOG.md b/CHANGELOG.md index f3559eac8a0..80635b4a9b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +- Store breadcrumbs to disk for OOM events (#2347) + ### Fixes - Too long flush duration (#2370) diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index 4cec9a30249..b653c45cf01 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -40,6 +40,7 @@ 0A1B497328E597DD00D7BFA3 /* TestLogOutput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A1B497228E597DD00D7BFA3 /* TestLogOutput.swift */; }; 0A1C3592287D7107007D01E3 /* SentryMetaTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A1C3591287D7107007D01E3 /* SentryMetaTests.swift */; }; 0A2690B72885C2E000E4432D /* TestSentryPermissionsObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AABE2EF2885C2120057ED69 /* TestSentryPermissionsObserver.swift */; }; + 0A2D7BBA29152CBF008727AF /* SentryOutOfMemoryScopeObserverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A2D7BB929152CBF008727AF /* SentryOutOfMemoryScopeObserverTests.swift */; }; 0A283E79291A67E000EF4126 /* SentryUIDeviceWrapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A283E78291A67E000EF4126 /* SentryUIDeviceWrapperTests.swift */; }; 0A2D8D5B289815C0008720F6 /* SentryBaseIntegration.m in Sources */ = {isa = PBXBuildFile; fileRef = 0A2D8D5A289815C0008720F6 /* SentryBaseIntegration.m */; }; 0A2D8D5D289815EB008720F6 /* SentryBaseIntegration.h in Headers */ = {isa = PBXBuildFile; fileRef = 0A2D8D5C289815EB008720F6 /* SentryBaseIntegration.h */; }; @@ -52,6 +53,8 @@ 0A5370A128A3EC2400B2DCDE /* SentryViewHierarchyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A5370A028A3EC2400B2DCDE /* SentryViewHierarchyTests.swift */; }; 0A56DA5F28ABA01B00C400D5 /* SentryTransactionContext+Private.h in Headers */ = {isa = PBXBuildFile; fileRef = 0A56DA5E28ABA01B00C400D5 /* SentryTransactionContext+Private.h */; }; 0A6EEADD28A657970076B469 /* UIViewRecursiveDescriptionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A6EEADC28A657970076B469 /* UIViewRecursiveDescriptionTests.swift */; }; + 0A80E433291017C300095219 /* SentryOutOfMemoryScopeObserver.m in Sources */ = {isa = PBXBuildFile; fileRef = 0A80E432291017C300095219 /* SentryOutOfMemoryScopeObserver.m */; }; + 0A80E435291017D500095219 /* SentryOutOfMemoryScopeObserver.h in Headers */ = {isa = PBXBuildFile; fileRef = 0A80E434291017D500095219 /* SentryOutOfMemoryScopeObserver.h */; }; 0A8F0A392886CC70000B15F6 /* SentryPermissionsObserver.h in Headers */ = {isa = PBXBuildFile; fileRef = 0AABE2EE288592750057ED69 /* SentryPermissionsObserver.h */; }; 0A94158228F6C4C2006A5DD1 /* SentryAppStateManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A94158128F6C4C2006A5DD1 /* SentryAppStateManagerTests.swift */; }; 0A9415BA28F96CAC006A5DD1 /* TestSentryReachability.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A9415B928F96CAC006A5DD1 /* TestSentryReachability.swift */; }; @@ -767,6 +770,7 @@ 03F9D37B2819A65C00602916 /* SentryProfilerTests.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = SentryProfilerTests.mm; sourceTree = ""; }; 0A1B497228E597DD00D7BFA3 /* TestLogOutput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestLogOutput.swift; sourceTree = ""; }; 0A1C3591287D7107007D01E3 /* SentryMetaTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryMetaTests.swift; sourceTree = ""; }; + 0A2D7BB929152CBF008727AF /* SentryOutOfMemoryScopeObserverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryOutOfMemoryScopeObserverTests.swift; sourceTree = ""; }; 0A283E78291A67E000EF4126 /* SentryUIDeviceWrapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryUIDeviceWrapperTests.swift; sourceTree = ""; }; 0A2D8D5A289815C0008720F6 /* SentryBaseIntegration.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryBaseIntegration.m; sourceTree = ""; }; 0A2D8D5C289815EB008720F6 /* SentryBaseIntegration.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryBaseIntegration.h; path = include/SentryBaseIntegration.h; sourceTree = ""; }; @@ -779,6 +783,8 @@ 0A5370A028A3EC2400B2DCDE /* SentryViewHierarchyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryViewHierarchyTests.swift; sourceTree = ""; }; 0A56DA5E28ABA01B00C400D5 /* SentryTransactionContext+Private.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "SentryTransactionContext+Private.h"; path = "include/SentryTransactionContext+Private.h"; sourceTree = ""; }; 0A6EEADC28A657970076B469 /* UIViewRecursiveDescriptionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewRecursiveDescriptionTests.swift; sourceTree = ""; }; + 0A80E432291017C300095219 /* SentryOutOfMemoryScopeObserver.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryOutOfMemoryScopeObserver.m; sourceTree = ""; }; + 0A80E434291017D500095219 /* SentryOutOfMemoryScopeObserver.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryOutOfMemoryScopeObserver.h; path = include/SentryOutOfMemoryScopeObserver.h; sourceTree = ""; }; 0A94158128F6C4C2006A5DD1 /* SentryAppStateManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryAppStateManagerTests.swift; sourceTree = ""; }; 0A9415B928F96CAC006A5DD1 /* TestSentryReachability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestSentryReachability.swift; sourceTree = ""; }; 0A9BF4E128A114940068D266 /* SentryViewHierarchyIntegration.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryViewHierarchyIntegration.m; sourceTree = ""; }; @@ -2494,6 +2500,8 @@ 7B6C5F8626034395007F7DFF /* SentryOutOfMemoryLogic.m */, 7B98D7CA25FB64EC00C5A389 /* SentryOutOfMemoryTrackingIntegration.h */, 7B98D7CE25FB650F00C5A389 /* SentryOutOfMemoryTrackingIntegration.m */, + 0A80E434291017D500095219 /* SentryOutOfMemoryScopeObserver.h */, + 0A80E432291017C300095219 /* SentryOutOfMemoryScopeObserver.m */, ); name = OutOfMemory; sourceTree = ""; @@ -2616,6 +2624,7 @@ children = ( 7B98D7DF25FB73B900C5A389 /* SentryOutOfMemoryTrackerTests.swift */, 7BFE7A0927A1B6B000D2B66E /* SentryOutOfMemoryIntegrationTests.swift */, + 0A2D7BB929152CBF008727AF /* SentryOutOfMemoryScopeObserverTests.swift */, ); path = OutOfMemory; sourceTree = ""; @@ -3185,6 +3194,7 @@ 7B42C48027E08F33009B58C2 /* SentryDependencyContainer.h in Headers */, 6334314120AD9AE40077E581 /* SentryMechanism.h in Headers */, 03F84D2827DD414C008FE43F /* SentryCPU.h in Headers */, + 0A80E435291017D500095219 /* SentryOutOfMemoryScopeObserver.h in Headers */, 7B610D642512399600B0B5D9 /* SentryHub+Private.h in Headers */, D8F6A24B2885515C00320515 /* SentryPredicateDescriptor.h in Headers */, 639FCF9C1EBC7F9500778193 /* SentryThread.h in Headers */, @@ -3405,6 +3415,7 @@ 84A8891D28DBD28900C51DFD /* SentryDevice.mm in Sources */, 7B56D73324616D9500B842DA /* SentryConcurrentRateLimitsDictionary.m in Sources */, 8ECC674825C23A20000E2BF6 /* SentryTransaction.m in Sources */, + 0A80E433291017C300095219 /* SentryOutOfMemoryScopeObserver.m in Sources */, 7BECF42826145CD900D9826E /* SentryMechanismMeta.m in Sources */, 8E7C982F2693D56000E6336C /* SentryTraceHeader.m in Sources */, 63FE715F20DA4C1100CDBAE8 /* SentryCrashID.c in Sources */, @@ -3682,6 +3693,7 @@ 7B30B68226527C55006B2752 /* TestDisplayLinkWrapper.swift in Sources */, 7BB6550D253EEB3900887E87 /* SentryUserFeedbackTests.swift in Sources */, 7BBD18B7245180FF00427C76 /* SentryDsnTests.m in Sources */, + 0A2D7BBA29152CBF008727AF /* SentryOutOfMemoryScopeObserverTests.swift in Sources */, 7BD4BD4B27EB2DC20071F4FF /* SentryDiscardedEventTests.swift in Sources */, 63FE721A20DA66EC00CDBAE8 /* SentryCrashSysCtl_Tests.m in Sources */, 7B88F30424BC8E6500ADF90A /* SentrySerializationTests.swift in Sources */, diff --git a/Sources/Sentry/SentryCrashScopeObserver.m b/Sources/Sentry/SentryCrashScopeObserver.m index 2c609667794..5395c93d438 100644 --- a/Sources/Sentry/SentryCrashScopeObserver.m +++ b/Sources/Sentry/SentryCrashScopeObserver.m @@ -9,11 +9,6 @@ #import #import -@interface -SentryCrashScopeObserver () - -@end - @implementation SentryCrashScopeObserver - (instancetype)initWithMaxBreadcrumbs:(NSInteger)maxBreadcrumbs @@ -90,10 +85,9 @@ - (void)setLevel:(enum SentryLevel)level sentrycrash_scopesync_setLevel([json bytes]); } -- (void)addBreadcrumb:(SentryBreadcrumb *)crumb +- (void)addSerializedBreadcrumb:(NSDictionary *)crumb { - NSDictionary *serialized = [crumb serialize]; - NSData *json = [self toJSONEncodedCString:serialized]; + NSData *json = [self toJSONEncodedCString:crumb]; if (json == nil) { return; } diff --git a/Sources/Sentry/SentryEvent.m b/Sources/Sentry/SentryEvent.m index 9c9416f0ff1..23154046e25 100644 --- a/Sources/Sentry/SentryEvent.m +++ b/Sources/Sentry/SentryEvent.m @@ -22,6 +22,14 @@ @property (nonatomic) BOOL isCrashEvent; +// We're storing serialized breadcrumbs to disk in JSON, and when we're reading them back (in +// the case of OOM), we end up with the serialized breadcrumbs again. Instead of turning those +// dictionaries into proper SentryBreadcrumb instances which then need to be serialized again in +// SentryEvent, we use this serializedBreadcrumbs property to set the pre-serialized +// breadcrumbs. It saves a LOT of work - especially turning an NSDictionary into a SentryBreadcrumb +// is silly when we're just going to do the opposite right after. +@property (nonatomic, strong) NSArray *serializedBreadcrumbs; + @end @implementation SentryEvent @@ -138,7 +146,13 @@ - (void)addSimpleProperties:(NSMutableDictionary *)serializedData [serializedData setValue:[self.stacktrace serialize] forKey:@"stacktrace"]; - [serializedData setValue:[self serializeBreadcrumbs] forKey:@"breadcrumbs"]; + NSMutableArray *breadcrumbs = [self serializeBreadcrumbs]; + if (self.serializedBreadcrumbs.count > 0) { + [breadcrumbs addObjectsFromArray:self.serializedBreadcrumbs]; + } + if (breadcrumbs.count > 0) { + [serializedData setValue:breadcrumbs forKey:@"breadcrumbs"]; + } [serializedData setValue:[self.context sentry_sanitize] forKey:@"contexts"]; @@ -164,15 +178,12 @@ - (void)addSimpleProperties:(NSMutableDictionary *)serializedData } } -- (NSArray *_Nullable)serializeBreadcrumbs +- (NSMutableArray *)serializeBreadcrumbs { NSMutableArray *crumbs = [NSMutableArray new]; for (SentryBreadcrumb *crumb in self.breadcrumbs) { [crumbs addObject:[crumb serialize]]; } - if (crumbs.count <= 0) { - return nil; - } return crumbs; } diff --git a/Sources/Sentry/SentryFileManager.m b/Sources/Sentry/SentryFileManager.m index a82eaa779de..1f62ff9774e 100644 --- a/Sources/Sentry/SentryFileManager.m +++ b/Sources/Sentry/SentryFileManager.m @@ -28,6 +28,10 @@ @property (nonatomic, copy) NSString *lastInForegroundFilePath; @property (nonatomic, copy) NSString *previousAppStateFilePath; @property (nonatomic, copy) NSString *appStateFilePath; +@property (nonatomic, copy) NSString *previousBreadcrumbsFilePathOne; +@property (nonatomic, copy) NSString *previousBreadcrumbsFilePathTwo; +@property (nonatomic, copy) NSString *breadcrumbsFilePathOne; +@property (nonatomic, copy) NSString *breadcrumbsFilePathTwo; @property (nonatomic, copy) NSString *timezoneOffsetFilePath; @property (nonatomic, assign) NSUInteger currentFileCounter; @property (nonatomic, assign) NSUInteger maxEnvelopes; @@ -83,6 +87,14 @@ - (nullable instancetype)initWithOptions:(SentryOptions *)options self.previousAppStateFilePath = [self.sentryPath stringByAppendingPathComponent:@"previous.app.state"]; self.appStateFilePath = [self.sentryPath stringByAppendingPathComponent:@"app.state"]; + self.previousBreadcrumbsFilePathOne = + [self.sentryPath stringByAppendingPathComponent:@"previous.breadcrumbs.1.state"]; + self.previousBreadcrumbsFilePathTwo = + [self.sentryPath stringByAppendingPathComponent:@"previous.breadcrumbs.2.state"]; + self.breadcrumbsFilePathOne = + [self.sentryPath stringByAppendingPathComponent:@"breadcrumbs.1.state"]; + self.breadcrumbsFilePathTwo = + [self.sentryPath stringByAppendingPathComponent:@"breadcrumbs.2.state"]; self.timezoneOffsetFilePath = [self.sentryPath stringByAppendingPathComponent:@"timezone.offset"]; @@ -240,8 +252,12 @@ - (BOOL)removeFileAtPath:(NSString *)path NSError *error = nil; @synchronized(self) { [fileManager removeItemAtPath:path error:&error]; + if (nil != error) { - SENTRY_LOG_ERROR(@"Couldn't delete file %@: %@", path, error); + // We don't want to log an error if the file doesn't exist. + if (error.code != NSFileNoSuchFileError) { + SENTRY_LOG_ERROR(@"Couldn't delete file %@: %@", path, error); + } return NO; } } @@ -455,26 +471,80 @@ - (void)storeAppState:(SentryAppState *)appState - (void)moveAppStateToPreviousAppState { @synchronized(self.appStateFilePath) { - NSFileManager *fileManager = [NSFileManager defaultManager]; + [self moveState:self.appStateFilePath toPreviousState:self.previousAppStateFilePath]; + } +} + +- (void)moveBreadcrumbsToPreviousBreadcrumbs +{ + @synchronized(self.breadcrumbsFilePathOne) { + [self moveState:self.breadcrumbsFilePathOne + toPreviousState:self.previousBreadcrumbsFilePathOne]; + [self moveState:self.breadcrumbsFilePathTwo + toPreviousState:self.previousBreadcrumbsFilePathTwo]; + } +} - // We first need to remove the old previous app state file, - // or we can't move the current app state file to it. - [self removeFileAtPath:self.previousAppStateFilePath]; +- (void)moveState:(NSString *)stateFilePath toPreviousState:(NSString *)previousStateFilePath +{ + NSFileManager *fileManager = [NSFileManager defaultManager]; - NSError *error = nil; - [fileManager moveItemAtPath:self.appStateFilePath - toPath:self.previousAppStateFilePath - error:&error]; + // We first need to remove the old previous state file, + // or we can't move the current state file to it. + [self removeFileAtPath:previousStateFilePath]; - // We don't want to log an error if the file doesn't exist. - if (nil != error && error.code != NSFileNoSuchFileError) { - [SentryLog - logWithMessage:[NSString - stringWithFormat: - @"Failed to move app state to previous app state: %@", error] - andLevel:kSentryLevelError]; + NSError *error = nil; + [fileManager moveItemAtPath:stateFilePath toPath:previousStateFilePath error:&error]; + + // We don't want to log an error if the file doesn't exist. + if (nil != error && error.code != NSFileNoSuchFileError) { + SENTRY_LOG_ERROR(@"Failed to move %@ to previous state file: %@", stateFilePath, error); + } +} + +- (NSArray *)readPreviousBreadcrumbs +{ + NSMutableString *combinedFilesContents = [[NSMutableString alloc] init]; + + if ([[NSFileManager defaultManager] fileExistsAtPath:self.previousBreadcrumbsFilePathOne]) { + NSString *fileContents = + [NSString stringWithContentsOfFile:self.previousBreadcrumbsFilePathOne + encoding:NSUTF8StringEncoding + error:nil]; + [combinedFilesContents appendString:fileContents]; + } + + if ([[NSFileManager defaultManager] fileExistsAtPath:self.previousBreadcrumbsFilePathTwo]) { + NSString *fileContents = + [NSString stringWithContentsOfFile:self.previousBreadcrumbsFilePathTwo + encoding:NSUTF8StringEncoding + error:nil]; + [combinedFilesContents appendString:fileContents]; + } + + NSMutableArray *breadcrumbs = [NSMutableArray array]; + + if (combinedFilesContents.length > 0) { + NSArray *lines = [combinedFilesContents + componentsSeparatedByCharactersInSet:[NSCharacterSet newlineCharacterSet]]; + + for (NSString *line in lines) { + NSData *data = [line dataUsingEncoding:NSUTF8StringEncoding]; + + NSError *error; + NSDictionary *dict = [NSJSONSerialization JSONObjectWithData:data + options:0 + error:&error]; + + if (error) { + SENTRY_LOG_ERROR(@"Error deserializing breadcrumb: %@", error); + } else { + [breadcrumbs addObject:dict]; + } } } + + return breadcrumbs; } - (SentryAppState *_Nullable)readAppState @@ -494,8 +564,7 @@ - (SentryAppState *_Nullable)readPreviousAppState - (SentryAppState *_Nullable)readAppStateFrom:(NSString *)path { NSFileManager *fileManager = [NSFileManager defaultManager]; - NSData *currentData = nil; - currentData = [fileManager contentsAtPath:path]; + NSData *currentData = [fileManager contentsAtPath:path]; if (nil == currentData) { return nil; } diff --git a/Sources/Sentry/SentryOutOfMemoryScopeObserver.m b/Sources/Sentry/SentryOutOfMemoryScopeObserver.m new file mode 100644 index 00000000000..713202fc9f5 --- /dev/null +++ b/Sources/Sentry/SentryOutOfMemoryScopeObserver.m @@ -0,0 +1,155 @@ +#import "SentryOutOfMemoryScopeObserver.h" +#import +#import +#import + +@interface +SentryOutOfMemoryScopeObserver () + +@property (strong, nonatomic) SentryFileManager *fileManager; +@property (strong, nonatomic) NSFileHandle *fileHandle; +@property (nonatomic) NSInteger maxBreadcrumbs; +@property (nonatomic) NSInteger breadcrumbCounter; +@property (strong, nonatomic) NSString *activeFilePath; + +@end + +@implementation SentryOutOfMemoryScopeObserver + +- (instancetype)initWithMaxBreadcrumbs:(NSInteger)maxBreadcrumbs + fileManager:(SentryFileManager *)fileManager +{ + if (self = [super init]) { + self.maxBreadcrumbs = maxBreadcrumbs; + self.fileManager = fileManager; + self.breadcrumbCounter = 0; + + [self switchFileHandle]; + } + + return self; +} + +- (void)dealloc +{ + [self.fileHandle closeFile]; +} + +// PRAGMA MARK: - Helper methods + +- (void)deleteFiles +{ + [self.fileHandle closeFile]; + self.fileHandle = nil; + self.activeFilePath = nil; + self.breadcrumbCounter = 0; + + [self.fileManager removeFileAtPath:self.fileManager.breadcrumbsFilePathOne]; + [self.fileManager removeFileAtPath:self.fileManager.breadcrumbsFilePathTwo]; +} + +- (void)switchFileHandle +{ + if ([self.activeFilePath isEqualToString:self.fileManager.breadcrumbsFilePathOne]) { + self.activeFilePath = self.fileManager.breadcrumbsFilePathTwo; + } else { + self.activeFilePath = self.fileManager.breadcrumbsFilePathOne; + } + + // Close the current filehandle (if any) + [self.fileHandle closeFile]; + + // Create a fresh file for the new active path + [self.fileManager removeFileAtPath:self.activeFilePath]; + [[NSFileManager defaultManager] createFileAtPath:self.activeFilePath + contents:nil + attributes:nil]; + + // Open the file for writing + self.fileHandle = [NSFileHandle fileHandleForWritingAtPath:self.activeFilePath]; + + if (!self.fileHandle) { + SENTRY_LOG_ERROR(@"Couldn't open file handle for %@", self.activeFilePath); + } +} + +- (void)store:(NSData *)data +{ + [self.fileHandle seekToEndOfFile]; + [self.fileHandle writeData:data]; + [self.fileHandle writeData:[@"\n" dataUsingEncoding:NSASCIIStringEncoding]]; + + self.breadcrumbCounter += 1; + + if (self.breadcrumbCounter >= self.maxBreadcrumbs) { + [self switchFileHandle]; + self.breadcrumbCounter = 0; + } +} + +// PRAGMA MARK: - SentryScopeObserver + +- (void)addSerializedBreadcrumb:(NSDictionary *)crumb +{ + NSError *error; + NSData *jsonData = [NSJSONSerialization dataWithJSONObject:crumb options:0 error:&error]; + + if (error) { + SENTRY_LOG_ERROR(@"Error serializing breadcrumb: %@", error); + } else { + [self store:jsonData]; + } +} + +- (void)clear +{ + [self clearBreadcrumbs]; +} + +- (void)clearBreadcrumbs +{ + [self deleteFiles]; + [self switchFileHandle]; +} + +- (void)setContext:(nullable NSDictionary *)context +{ + // Left blank on purpose +} + +- (void)setDist:(nullable NSString *)dist +{ + // Left blank on purpose +} + +- (void)setEnvironment:(nullable NSString *)environment +{ + // Left blank on purpose +} + +- (void)setExtras:(nullable NSDictionary *)extras +{ + // Left blank on purpose +} + +- (void)setFingerprint:(nullable NSArray *)fingerprint +{ + // Left blank on purpose +} + +- (void)setLevel:(enum SentryLevel)level +{ + // Left blank on purpose +} + +- (void)setTags:(nullable NSDictionary *)tags +{ + // Left blank on purpose +} + +- (void)setUser:(nullable SentryUser *)user +{ + // Left blank on purpose +} + +@end diff --git a/Sources/Sentry/SentryOutOfMemoryTracker.m b/Sources/Sentry/SentryOutOfMemoryTracker.m index 65b9b372774..a2bc7b2cbe6 100644 --- a/Sources/Sentry/SentryOutOfMemoryTracker.m +++ b/Sources/Sentry/SentryOutOfMemoryTracker.m @@ -1,10 +1,10 @@ +#import "SentryEvent+Private.h" #import "SentryFileManager.h" #import #import #import #import #import -#import #import #import #import @@ -59,6 +59,9 @@ - (void)start // Set to empty list so no breadcrumbs of the current scope are added event.breadcrumbs = @[]; + // Load the previous breascrumbs from disk, which are already serialized + event.serializedBreadcrumbs = [self.fileManager readPreviousBreadcrumbs]; + SentryException *exception = [[SentryException alloc] initWithValue:SentryOutOfMemoryExceptionValue type:SentryOutOfMemoryExceptionType]; diff --git a/Sources/Sentry/SentryOutOfMemoryTrackingIntegration.m b/Sources/Sentry/SentryOutOfMemoryTrackingIntegration.m index d83de13656d..e8854f26232 100644 --- a/Sources/Sentry/SentryOutOfMemoryTrackingIntegration.m +++ b/Sources/Sentry/SentryOutOfMemoryTrackingIntegration.m @@ -1,4 +1,5 @@ #import "SentryDefines.h" +#import "SentryScope+Private.h" #import #import #import @@ -8,6 +9,7 @@ #import #import #import +#import #import #import #import @@ -65,6 +67,7 @@ - (BOOL)installWithOptions:(SentryOptions *)options appStateManager:appStateManager dispatchQueueWrapper:dispatchQueueWrapper fileManager:fileManager]; + [self.tracker start]; self.anrTracker = @@ -73,6 +76,13 @@ - (BOOL)installWithOptions:(SentryOptions *)options self.appStateManager = appStateManager; + SentryOutOfMemoryScopeObserver *scopeObserver = [[SentryOutOfMemoryScopeObserver alloc] + initWithMaxBreadcrumbs:options.maxBreadcrumbs + fileManager:[[[SentrySDK currentHub] getClient] fileManager]]; + + [SentrySDK.currentHub configureScope:^( + SentryScope *_Nonnull outerScope) { [outerScope addObserver:scopeObserver]; }]; + return YES; } diff --git a/Sources/Sentry/SentrySDK.m b/Sources/Sentry/SentrySDK.m index ad9b925b709..e08b87dac1a 100644 --- a/Sources/Sentry/SentrySDK.m +++ b/Sources/Sentry/SentrySDK.m @@ -149,6 +149,7 @@ + (void)startWithOptionsObject:(SentryOptions *)options SentryClient *newClient = [[SentryClient alloc] initWithOptions:options]; [newClient.fileManager moveAppStateToPreviousAppState]; + [newClient.fileManager moveBreadcrumbsToPreviousBreadcrumbs]; // The Hub needs to be initialized with a client so that closing a session // can happen. diff --git a/Sources/Sentry/SentryScope.m b/Sources/Sentry/SentryScope.m index 48f3b4fe41a..067174e1a62 100644 --- a/Sources/Sentry/SentryScope.m +++ b/Sources/Sentry/SentryScope.m @@ -133,7 +133,7 @@ - (void)addBreadcrumb:(SentryBreadcrumb *)crumb } for (id observer in self.observers) { - [observer addBreadcrumb:crumb]; + [observer addSerializedBreadcrumb:[crumb serialize]]; } } } diff --git a/Sources/Sentry/include/SentryEvent+Private.h b/Sources/Sentry/include/SentryEvent+Private.h index 734f1e5aeb8..f750acc84ef 100644 --- a/Sources/Sentry/include/SentryEvent+Private.h +++ b/Sources/Sentry/include/SentryEvent+Private.h @@ -8,5 +8,6 @@ SentryEvent (Private) * This indicates whether this event is a result of a crash. */ @property (nonatomic) BOOL isCrashEvent; +@property (nonatomic, strong) NSArray *serializedBreadcrumbs; @end diff --git a/Sources/Sentry/include/SentryFileManager.h b/Sources/Sentry/include/SentryFileManager.h index 44548397295..e53edf6c2c1 100644 --- a/Sources/Sentry/include/SentryFileManager.h +++ b/Sources/Sentry/include/SentryFileManager.h @@ -15,6 +15,10 @@ NS_SWIFT_NAME(SentryFileManager) SENTRY_NO_INIT @property (nonatomic, readonly) NSString *sentryPath; +@property (nonatomic, readonly) NSString *breadcrumbsFilePathOne; +@property (nonatomic, readonly) NSString *breadcrumbsFilePathTwo; +@property (nonatomic, readonly) NSString *previousBreadcrumbsFilePathOne; +@property (nonatomic, readonly) NSString *previousBreadcrumbsFilePathTwo; - (nullable instancetype)initWithOptions:(SentryOptions *)options andCurrentDateProvider:(id)currentDateProvider @@ -70,6 +74,9 @@ SENTRY_NO_INIT - (SentryAppState *_Nullable)readPreviousAppState; - (void)deleteAppState; +- (void)moveBreadcrumbsToPreviousBreadcrumbs; +- (NSArray *)readPreviousBreadcrumbs; + - (NSNumber *_Nullable)readTimezoneOffset; - (void)storeTimezoneOffset:(NSInteger)offset; - (void)deleteTimezoneOffset; diff --git a/Sources/Sentry/include/SentryOutOfMemoryScopeObserver.h b/Sources/Sentry/include/SentryOutOfMemoryScopeObserver.h new file mode 100644 index 00000000000..da27299c823 --- /dev/null +++ b/Sources/Sentry/include/SentryOutOfMemoryScopeObserver.h @@ -0,0 +1,18 @@ +#import "SentryDefines.h" +#import "SentryScopeObserver.h" + +@class SentryFileManager; + +NS_ASSUME_NONNULL_BEGIN + +/// This scope observer is used by the Out of Memory integration to write breadcrumbs to disk. +/// The overhead is ~0.015 seconds for 1000 breadcrumbs. +@interface SentryOutOfMemoryScopeObserver : NSObject +SENTRY_NO_INIT + +- (instancetype)initWithMaxBreadcrumbs:(NSInteger)maxBreadcrumbs + fileManager:(SentryFileManager *)fileManager; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/include/SentryScopeObserver.h b/Sources/Sentry/include/SentryScopeObserver.h index 32aba7aff1a..a0fada9c772 100644 --- a/Sources/Sentry/include/SentryScopeObserver.h +++ b/Sources/Sentry/include/SentryScopeObserver.h @@ -25,7 +25,7 @@ NS_ASSUME_NONNULL_BEGIN - (void)setLevel:(enum SentryLevel)level; -- (void)addBreadcrumb:(SentryBreadcrumb *)crumb; +- (void)addSerializedBreadcrumb:(NSDictionary *)crumb; - (void)clearBreadcrumbs; diff --git a/Tests/SentryTests/Helper/SentryFileManagerTests.swift b/Tests/SentryTests/Helper/SentryFileManagerTests.swift index 6b3d833415a..123715e426d 100644 --- a/Tests/SentryTests/Helper/SentryFileManagerTests.swift +++ b/Tests/SentryTests/Helper/SentryFileManagerTests.swift @@ -577,6 +577,20 @@ class SentryFileManagerTests: XCTestCase { XCTAssertNotNil(sut.readTimezoneOffset()) } + func testReadPreviousBreadcrumbs() { + let observer = SentryOutOfMemoryScopeObserver(maxBreadcrumbs: 2, fileManager: sut) + + let serializedBreadcrumb = TestData.crumb.serialize() + + for _ in 0..<3 { + observer.addSerializedBreadcrumb(serializedBreadcrumb) + } + + sut.moveBreadcrumbsToPreviousBreadcrumbs() + let result = sut.readPreviousBreadcrumbs() + XCTAssertEqual(result.count, 3) + } + func testReadGarbageTimezoneOffset() throws { try "garbage".write(to: URL(fileURLWithPath: sut.timezoneOffsetFilePath), atomically: true, encoding: .utf8) XCTAssertNil(sut.readTimezoneOffset()) diff --git a/Tests/SentryTests/Integrations/OutOfMemory/SentryOutOfMemoryScopeObserverTests.swift b/Tests/SentryTests/Integrations/OutOfMemory/SentryOutOfMemoryScopeObserverTests.swift new file mode 100644 index 00000000000..c26e10ad1a4 --- /dev/null +++ b/Tests/SentryTests/Integrations/OutOfMemory/SentryOutOfMemoryScopeObserverTests.swift @@ -0,0 +1,121 @@ +import XCTest + +class SentryOutOfMemoryScopeObserverTests: XCTestCase { + private class Fixture { + let breadcrumb: Breadcrumb + let options: Options + let fileManager: SentryFileManager + let currentDate = TestCurrentDateProvider() + + init() { + breadcrumb = TestData.crumb + breadcrumb.data = nil + + options = Options() + fileManager = try! SentryFileManager(options: options, andCurrentDateProvider: currentDate) + } + + func getSut() -> SentryOutOfMemoryScopeObserver { + return getSut(fileManager: self.fileManager) + } + + func getSut(fileManager: SentryFileManager) -> SentryOutOfMemoryScopeObserver { + return SentryOutOfMemoryScopeObserver(maxBreadcrumbs: 10, fileManager: fileManager) + } + } + + private var fixture: Fixture! + private var sut: SentryOutOfMemoryScopeObserver! + + override func setUp() { + super.setUp() + + fixture = Fixture() + sut = fixture.getSut() + } + + override func tearDown() { + super.tearDown() + fixture.fileManager.deleteAllFolders() + } + + // Test that we're storing the serialized breadcrumb in a proper JSON string + func testStoreBreadcrumb() throws { + let breadcrumb = fixture.breadcrumb.serialize() as! [String: String] + + sut.addSerializedBreadcrumb(breadcrumb) + + let fileOneContents = try String(contentsOfFile: fixture.fileManager.breadcrumbsFilePathOne) + let firstLine = String(fileOneContents.split(separator: "\n").first!) + let dict = try JSONSerialization.jsonObject(with: firstLine.data(using: .utf8)!) as! [String: String] + + XCTAssertEqual(dict, breadcrumb) + } + + func testStoreInMultipleFiles() throws { + let breadcrumb = fixture.breadcrumb.serialize() + + for _ in 0..<9 { + sut.addSerializedBreadcrumb(breadcrumb) + } + + var fileOneContents = try String(contentsOfFile: fixture.fileManager.breadcrumbsFilePathOne) + var fileOneLines = fileOneContents.split(separator: "\n") + XCTAssertEqual(fileOneLines.count, 9) + + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.fileManager.breadcrumbsFilePathTwo)) + + // Now store one more, which means it'll change over to the second file (which should be empty) + sut.addSerializedBreadcrumb(breadcrumb) + + fileOneContents = try String(contentsOfFile: fixture.fileManager.breadcrumbsFilePathOne) + fileOneLines = fileOneContents.split(separator: "\n") + XCTAssertEqual(fileOneLines.count, 10) + + var fileTwoContents = try String(contentsOfFile: fixture.fileManager.breadcrumbsFilePathTwo) + XCTAssertEqual(fileTwoContents, "") + + // Next one will be stored in the second file + sut.addSerializedBreadcrumb(breadcrumb) + + fileTwoContents = try String(contentsOfFile: fixture.fileManager.breadcrumbsFilePathTwo) + var fileTwoLines = fileTwoContents.split(separator: "\n") + + XCTAssertEqual(fileOneLines.count, 10) + XCTAssertEqual(fileTwoLines.count, 1) + + // Store 10 more + for _ in 0..<10 { + sut.addSerializedBreadcrumb(breadcrumb) + } + + fileOneContents = try String(contentsOfFile: fixture.fileManager.breadcrumbsFilePathOne) + fileOneLines = fileOneContents.split(separator: "\n") + XCTAssertEqual(fileOneLines.count, 1) + + fileTwoContents = try String(contentsOfFile: fixture.fileManager.breadcrumbsFilePathTwo) + fileTwoLines = fileTwoContents.split(separator: "\n") + XCTAssertEqual(fileTwoLines.count, 10) + } + + func testClearBreadcrumbs() throws { + let breadcrumb = fixture.breadcrumb.serialize() + + for _ in 0..<15 { + sut.addSerializedBreadcrumb(breadcrumb) + } + + var fileOneContents = try String(contentsOfFile: fixture.fileManager.breadcrumbsFilePathOne) + XCTAssertEqual(fileOneContents.count, 1_210) + + let fileTwoContents = try String(contentsOfFile: fixture.fileManager.breadcrumbsFilePathTwo) + XCTAssertEqual(fileTwoContents.count, 605) + + sut.clearBreadcrumbs() + + fileOneContents = try String(contentsOfFile: fixture.fileManager.breadcrumbsFilePathOne) + XCTAssertEqual(fileOneContents.count, 0) + + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.fileManager.breadcrumbsFilePathTwo)) + } +} diff --git a/Tests/SentryTests/Integrations/OutOfMemory/SentryOutOfMemoryTrackerTests.swift b/Tests/SentryTests/Integrations/OutOfMemory/SentryOutOfMemoryTrackerTests.swift index fa72aeacfaf..d75d41c0255 100644 --- a/Tests/SentryTests/Integrations/OutOfMemory/SentryOutOfMemoryTrackerTests.swift +++ b/Tests/SentryTests/Integrations/OutOfMemory/SentryOutOfMemoryTrackerTests.swift @@ -195,7 +195,7 @@ class SentryOutOfMemoryTrackerTests: NotificationCenterTestCase { sut.start() assertOOMEventSent() } - + func testANR_NoOOM() { sut.start() goToForeground() @@ -207,7 +207,22 @@ class SentryOutOfMemoryTrackerTests: NotificationCenterTestCase { sut.start() assertNoOOMSent() } - + + func testAppOOM_WithBreadcrumbs() { + let breadcrumb = TestData.crumb + + let sentryOutOfMemoryScopeObserver = SentryOutOfMemoryScopeObserver(maxBreadcrumbs: 10, fileManager: fixture.fileManager) + sentryOutOfMemoryScopeObserver.addSerializedBreadcrumb(breadcrumb.serialize()) + + sut.start() + goToForeground() + + fixture.fileManager.moveAppStateToPreviousAppState() + fixture.fileManager.moveBreadcrumbsToPreviousBreadcrumbs() + sut.start() + assertOOMEventSent(expectedBreadcrumbs: 1) + } + func testAppOOM_WithOnlyHybridSdkDidBecomeActive() { sut.start() hybridSdkDidBecomeActive() @@ -281,12 +296,13 @@ class SentryOutOfMemoryTrackerTests: NotificationCenterTestCase { } } - private func assertOOMEventSent() { + private func assertOOMEventSent(expectedBreadcrumbs: Int = 0) { XCTAssertEqual(1, fixture.client.captureCrashEventInvocations.count) let crashEvent = fixture.client.captureCrashEventInvocations.first?.event XCTAssertEqual(SentryLevel.fatal, crashEvent?.level) - XCTAssertEqual([], crashEvent?.breadcrumbs) + XCTAssertEqual(crashEvent?.breadcrumbs?.count, 0) + XCTAssertEqual(crashEvent?.serializedBreadcrumbs?.count, expectedBreadcrumbs) XCTAssertEqual(1, crashEvent?.exceptions?.count) @@ -298,7 +314,7 @@ class SentryOutOfMemoryTrackerTests: NotificationCenterTestCase { XCTAssertEqual(false, exception?.mechanism?.handled) XCTAssertEqual("out_of_memory", exception?.mechanism?.type) } - + private func assertNoOOMSent() { XCTAssertEqual(0, fixture.client.captureCrashEventInvocations.count) } diff --git a/Tests/SentryTests/Integrations/SentryCrash/SentryCrashScopeObserverTests.swift b/Tests/SentryTests/Integrations/SentryCrash/SentryCrashScopeObserverTests.swift index a2ba1621253..79442d79a44 100644 --- a/Tests/SentryTests/Integrations/SentryCrash/SentryCrashScopeObserverTests.swift +++ b/Tests/SentryTests/Integrations/SentryCrash/SentryCrashScopeObserverTests.swift @@ -204,7 +204,7 @@ class SentryCrashScopeObserverTests: XCTestCase { func testAddCrumb() { let sut = fixture.sut let crumb = TestData.crumb - sut.add(crumb) + sut.addSerializedBreadcrumb(crumb.serialize()) assertOneCrumbSetToScope(crumb: crumb) } @@ -216,14 +216,14 @@ class SentryCrashScopeObserverTests: XCTestCase { func testCallConfigureCrumbTwice() { let sut = fixture.sut let crumb = TestData.crumb - sut.add(crumb) + sut.addSerializedBreadcrumb(crumb.serialize()) sentrycrash_scopesync_configureBreadcrumbs(fixture.maxBreadcrumbs) let scope = sentrycrash_scopesync_getScope() XCTAssertEqual(0, scope?.pointee.currentCrumb) - sut.add(crumb) + sut.addSerializedBreadcrumb(crumb.serialize()) assertOneCrumbSetToScope(crumb: crumb) } @@ -234,7 +234,7 @@ class SentryCrashScopeObserverTests: XCTestCase { for i in 0...fixture.maxBreadcrumbs { let crumb = TestData.crumb crumb.message = "\(i)" - sut.add(crumb) + sut.addSerializedBreadcrumb(crumb.serialize()) crumbs.append(crumb) } crumbs.removeFirst() @@ -273,11 +273,11 @@ class SentryCrashScopeObserverTests: XCTestCase { sut.setExtras(fixture.extras) sut.setFingerprint(fixture.fingerprint) sut.setLevel(SentryLevel.fatal) - sut.add(TestData.crumb) - + sut.addSerializedBreadcrumb(TestData.crumb.serialize()) + sut.clear() - - assertEmptyScope() + + assertEmptyScope() } func testEmptyScope() { diff --git a/Tests/SentryTests/SentrySDKTests.swift b/Tests/SentryTests/SentrySDKTests.swift index 47fdc84948a..13d9ffe079a 100644 --- a/Tests/SentryTests/SentrySDKTests.swift +++ b/Tests/SentryTests/SentrySDKTests.swift @@ -536,6 +536,24 @@ class SentrySDKTests: XCTestCase { let timestamp = self.fixture.currentDate.date().addingTimeInterval(TimeInterval(amount)) XCTAssertEqual(timestamp, SentrySDK.getAppStartMeasurement()?.appStartTimestamp) } + + func testMovesBreadcrumbsToPreviousBreadcrumbs() throws { + let options = Options() + options.dsn = SentrySDKTests.dsnAsString + + let filemanager = try SentryFileManager(options: options, andCurrentDateProvider: TestCurrentDateProvider()) + let observer = SentryOutOfMemoryScopeObserver(maxBreadcrumbs: 10, fileManager: filemanager) + let serializedBreadcrumb = TestData.crumb.serialize() + + for _ in 0..<3 { + observer.addSerializedBreadcrumb(serializedBreadcrumb) + } + + SentrySDK.start(options: options) + + let result = filemanager.readPreviousBreadcrumbs() + XCTAssertEqual(result.count, 3) + } private func givenSdkWithHub() { SentrySDK.setCurrentHub(fixture.hub) diff --git a/Tests/SentryTests/SentryScopeSwiftTests.swift b/Tests/SentryTests/SentryScopeSwiftTests.swift index ee36b86426c..0fd895dd21f 100644 --- a/Tests/SentryTests/SentryScopeSwiftTests.swift +++ b/Tests/SentryTests/SentryScopeSwiftTests.swift @@ -455,7 +455,10 @@ class SentryScopeSwiftTests: XCTestCase { sut.add(crumb) sut.add(crumb) - XCTAssertEqual([crumb, crumb], observer.crumbs) + XCTAssertEqual( + [crumb.serialize() as! [String: AnyHashable], crumb.serialize() as! [String: AnyHashable]], + observer.crumbs + ) } func testScopeObserver_clearBreadcrumb() { @@ -516,11 +519,11 @@ class SentryScopeSwiftTests: XCTestCase { self.level = level } - var crumbs: [Breadcrumb] = [] - func add(_ crumb: Breadcrumb) { - crumbs.append(crumb) + var crumbs: [[String: AnyHashable]] = [] + func addSerializedBreadcrumb(_ crumb: [String: Any]) { + crumbs.append(crumb as! [String: AnyHashable]) } - + var clearBreadcrumbInvocations = 0 func clearBreadcrumbs() { clearBreadcrumbInvocations += 1 diff --git a/Tests/SentryTests/SentryTests-Bridging-Header.h b/Tests/SentryTests/SentryTests-Bridging-Header.h index 8845fa563b7..842a7f02f63 100644 --- a/Tests/SentryTests/SentryTests-Bridging-Header.h +++ b/Tests/SentryTests/SentryTests-Bridging-Header.h @@ -111,6 +111,7 @@ #import "SentryObjCRuntimeWrapper.h" #import "SentryOptions+Private.h" #import "SentryOutOfMemoryLogic.h" +#import "SentryOutOfMemoryScopeObserver.h" #import "SentryOutOfMemoryTracker.h" #import "SentryOutOfMemoryTrackingIntegration.h" #import "SentryPerformanceTracker.h" diff --git a/develop-docs/README.md b/develop-docs/README.md index d3ddb94d7f8..9a5110c50f9 100644 --- a/develop-docs/README.md +++ b/develop-docs/README.md @@ -127,7 +127,7 @@ Date: October 21st 2022 Contributors: @philipphofmann GH actions will remove the macOS-10.15 image, which contains an iOS 12 simulator on 12/1/22; see https://github.com/actions/runner-images/issues/5583. -Neither the[ macOS-11](https://github.com/actions/runner-images/blob/main/images/macos/macos-11-Readme.md#installed-sdks) nor the +Neither the [macOS-11](https://github.com/actions/runner-images/blob/main/images/macos/macos-11-Readme.md#installed-sdks) nor the [macOS-12](https://github.com/actions/runner-images/blob/main/images/macos/macos-12-Readme.md#installed-sdks) image contains an iOS 12 simulator. GH [concluded](https://github.com/actions/runner-images/issues/551#issuecomment-788822538) to not add more pre-installed simulators. SauceLabs doesn't support running unit tests and adding another cloud solution as Firebase TestLab would increase the complexity of CI. Not running the unit tests on @@ -135,3 +135,10 @@ iOS 12 opens a risk of introducing bugs, which has already happened in the past, the iOS 12 simulator a try. Related to [GH-2218](https://github.com/getsentry/sentry-cocoa/issues/2218) + +### Writing breadcrumbs to disk in the main thread + +Date November 15, 2022 +Contributors: @kevinrenskers, @brustolin and @philipphofmann + +For the benefit of OOM crashes, we write breadcrumbs to disk; see https://github.com/getsentry/sentry-cocoa/pull/2347. We have decided to do this in the main thread to ensure we're not missing out on any breadcrumbs. It's mainly the last breadcrumb(s) that are important to figure out what is causing an OOM. And since we're only appending to an open file stream, the overhead is acceptable compared to the benefit of having accurate breadcrumbs.