diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c1aebd5c00..afa5a6b1d12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features +- Add breadcrumbs to session replay (#4002) - Add start time to network request breadcrumbs (#4008) - Add C++ exception support for `__cxa_rethrow` (#3996) - Add beforeCaptureScreenshot callback (#4016) diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index c3eb067021d..00425f196b9 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -814,6 +814,7 @@ D820CDB72BB1895F00BA339D /* SentrySessionReplayIntegration.m in Sources */ = {isa = PBXBuildFile; fileRef = D820CDB62BB1895F00BA339D /* SentrySessionReplayIntegration.m */; }; D820CDB82BB1895F00BA339D /* SentrySessionReplayIntegration.h in Headers */ = {isa = PBXBuildFile; fileRef = D820CDB52BB1895F00BA339D /* SentrySessionReplayIntegration.h */; }; D8292D7D2A39A027009872F7 /* UrlSanitizedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8292D7C2A39A027009872F7 /* UrlSanitizedTests.swift */; }; + D82DD1CD2BEEB1A0001AB556 /* SentryBreadcrumbReplayConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D82DD1CC2BEEB1A0001AB556 /* SentryBreadcrumbReplayConverterTests.swift */; }; D8370B6A273DF1E900F66E2D /* SentryNSURLSessionTaskSearch.m in Sources */ = {isa = PBXBuildFile; fileRef = D8370B68273DF1E900F66E2D /* SentryNSURLSessionTaskSearch.m */; }; D8370B6C273DF20F00F66E2D /* SentryNSURLSessionTaskSearch.h in Headers */ = {isa = PBXBuildFile; fileRef = D8370B6B273DF20F00F66E2D /* SentryNSURLSessionTaskSearch.h */; }; D83D079B2B7F9D1C00CC9674 /* SentryMsgPackSerializer.h in Headers */ = {isa = PBXBuildFile; fileRef = D83D07992B7F9D1C00CC9674 /* SentryMsgPackSerializer.h */; }; @@ -855,6 +856,9 @@ D86F419827C8FEFA00490520 /* SentryCoreDataTrackerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86F419727C8FEFA00490520 /* SentryCoreDataTrackerExtension.swift */; }; D8739CF32BECF70F007D2F66 /* SentryLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8739CF22BECF70F007D2F66 /* SentryLevel.swift */; }; D8739CF92BECFFB5007D2F66 /* SentryTransactionNameSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8739CF82BECFFB5007D2F66 /* SentryTransactionNameSource.swift */; }; + D8739D142BEE5049007D2F66 /* SentryRRWebSpanEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8739D132BEE5049007D2F66 /* SentryRRWebSpanEvent.swift */; }; + D8739D172BEEA33F007D2F66 /* SentryLevelHelper.h in Headers */ = {isa = PBXBuildFile; fileRef = D8739D152BEEA33F007D2F66 /* SentryLevelHelper.h */; }; + D8739D182BEEA33F007D2F66 /* SentryLevelHelper.m in Sources */ = {isa = PBXBuildFile; fileRef = D8739D162BEEA33F007D2F66 /* SentryLevelHelper.m */; }; D8751FA5274743710032F4DE /* SentryNSURLSessionTaskSearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8751FA4274743710032F4DE /* SentryNSURLSessionTaskSearchTests.swift */; }; D875ED0B276CC84700422FAC /* SentryNSDataTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D875ED0A276CC84700422FAC /* SentryNSDataTrackerTests.swift */; }; D87C89032BC43C9C0086C7DF /* SentryRedactOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87C89022BC43C9C0086C7DF /* SentryRedactOptions.swift */; }; @@ -888,6 +892,7 @@ D8B76B062808066D000A58C4 /* SentryScreenshotIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8B76B042808060E000A58C4 /* SentryScreenshotIntegrationTests.swift */; }; D8B76B0828081461000A58C4 /* TestSentryScreenShot.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8B76B0728081461000A58C4 /* TestSentryScreenShot.swift */; }; D8BBD32728FD9FC00011F850 /* SentrySwift.h in Headers */ = {isa = PBXBuildFile; fileRef = D8BBD32628FD9FBF0011F850 /* SentrySwift.h */; settings = {ATTRIBUTES = (Private, ); }; }; + D8BC28D62C00C6DF0054DA4D /* StringExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8BC28D42C00C6D30054DA4D /* StringExtensionsTests.swift */; }; D8BD2E6829361A0F00D96C6A /* PrivatesHeader.h in Headers */ = {isa = PBXBuildFile; fileRef = D8BD2E67293619F600D96C6A /* PrivatesHeader.h */; settings = {ATTRIBUTES = (Private, ); }; }; D8BFE37229A3782F002E73F3 /* SentryTimeToDisplayTracker.h in Headers */ = {isa = PBXBuildFile; fileRef = D8BFE37029A3782F002E73F3 /* SentryTimeToDisplayTracker.h */; }; D8BFE37329A3782F002E73F3 /* SentryTimeToDisplayTracker.m in Sources */ = {isa = PBXBuildFile; fileRef = D8BFE37129A3782F002E73F3 /* SentryTimeToDisplayTracker.m */; }; @@ -913,6 +918,7 @@ D8F67AEE2BE0D19200C9197B /* UIImageHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F67AED2BE0D19200C9197B /* UIImageHelper.swift */; }; D8F67AF12BE0D33F00C9197B /* UIImageHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F67AEF2BE0D31A00C9197B /* UIImageHelperTests.swift */; }; D8F67AF42BE10F9600C9197B /* UIRedactBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F67AF22BE10F7600C9197B /* UIRedactBuilderTests.swift */; }; + D8F67B1B2BE9728600C9197B /* SentryReplayBreadcrumbConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F67B1A2BE9728600C9197B /* SentryReplayBreadcrumbConverter.swift */; }; D8F67B222BEAB6CC00C9197B /* SentryRRWebEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F67B212BEAB6CC00C9197B /* SentryRRWebEvent.swift */; }; D8F6A2472885512100320515 /* SentryPredicateDescriptor.m in Sources */ = {isa = PBXBuildFile; fileRef = D8F6A2452885512100320515 /* SentryPredicateDescriptor.m */; }; D8F6A24B2885515C00320515 /* SentryPredicateDescriptor.h in Headers */ = {isa = PBXBuildFile; fileRef = D8F6A24A2885515B00320515 /* SentryPredicateDescriptor.h */; }; @@ -1864,6 +1870,7 @@ D820CDB62BB1895F00BA339D /* SentrySessionReplayIntegration.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentrySessionReplayIntegration.m; sourceTree = ""; }; D8292D7A2A38AF04009872F7 /* HTTPHeaderSanitizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPHeaderSanitizer.swift; sourceTree = ""; }; D8292D7C2A39A027009872F7 /* UrlSanitizedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UrlSanitizedTests.swift; sourceTree = ""; }; + D82DD1CC2BEEB1A0001AB556 /* SentryBreadcrumbReplayConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryBreadcrumbReplayConverterTests.swift; sourceTree = ""; }; D8370B68273DF1E900F66E2D /* SentryNSURLSessionTaskSearch.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryNSURLSessionTaskSearch.m; sourceTree = ""; }; D8370B6B273DF20F00F66E2D /* SentryNSURLSessionTaskSearch.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = SentryNSURLSessionTaskSearch.h; path = include/SentryNSURLSessionTaskSearch.h; sourceTree = ""; }; D83D07992B7F9D1C00CC9674 /* SentryMsgPackSerializer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryMsgPackSerializer.h; path = include/SentryMsgPackSerializer.h; sourceTree = ""; }; @@ -1907,6 +1914,9 @@ D86F419727C8FEFA00490520 /* SentryCoreDataTrackerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryCoreDataTrackerExtension.swift; sourceTree = ""; }; D8739CF22BECF70F007D2F66 /* SentryLevel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryLevel.swift; sourceTree = ""; }; D8739CF82BECFFB5007D2F66 /* SentryTransactionNameSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryTransactionNameSource.swift; sourceTree = ""; }; + D8739D132BEE5049007D2F66 /* SentryRRWebSpanEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryRRWebSpanEvent.swift; sourceTree = ""; }; + D8739D152BEEA33F007D2F66 /* SentryLevelHelper.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryLevelHelper.h; path = include/SentryLevelHelper.h; sourceTree = ""; }; + D8739D162BEEA33F007D2F66 /* SentryLevelHelper.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryLevelHelper.m; sourceTree = ""; }; D8751FA4274743710032F4DE /* SentryNSURLSessionTaskSearchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryNSURLSessionTaskSearchTests.swift; sourceTree = ""; }; D8757D142A209F7300BFEFCC /* SentrySampleDecision+Private.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "SentrySampleDecision+Private.h"; path = "include/SentrySampleDecision+Private.h"; sourceTree = ""; }; D875ED0A276CC84700422FAC /* SentryNSDataTrackerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SentryNSDataTrackerTests.swift; sourceTree = ""; }; @@ -1944,6 +1954,7 @@ D8B76B042808060E000A58C4 /* SentryScreenshotIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryScreenshotIntegrationTests.swift; sourceTree = ""; }; D8B76B0728081461000A58C4 /* TestSentryScreenShot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestSentryScreenShot.swift; sourceTree = ""; }; D8BBD32628FD9FBF0011F850 /* SentrySwift.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = SentrySwift.h; path = include/SentrySwift.h; sourceTree = ""; }; + D8BC28D42C00C6D30054DA4D /* StringExtensionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringExtensionsTests.swift; sourceTree = ""; }; D8BC83BA2AFCF08C00A662B7 /* SentryUIApplication+Private.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SentryUIApplication+Private.h"; sourceTree = ""; }; D8BD2E27292D1F7300D96C6A /* SDK.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = SDK.xcconfig; sourceTree = ""; }; D8BD2E67293619F600D96C6A /* PrivatesHeader.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = PrivatesHeader.h; path = include/HybridPublic/PrivatesHeader.h; sourceTree = ""; }; @@ -1974,6 +1985,7 @@ D8F67AED2BE0D19200C9197B /* UIImageHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImageHelper.swift; sourceTree = ""; }; D8F67AEF2BE0D31A00C9197B /* UIImageHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImageHelperTests.swift; sourceTree = ""; }; D8F67AF22BE10F7600C9197B /* UIRedactBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIRedactBuilderTests.swift; sourceTree = ""; }; + D8F67B1A2BE9728600C9197B /* SentryReplayBreadcrumbConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryReplayBreadcrumbConverter.swift; sourceTree = ""; }; D8F67B212BEAB6CC00C9197B /* SentryRRWebEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryRRWebEvent.swift; sourceTree = ""; }; D8F6A2452885512100320515 /* SentryPredicateDescriptor.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryPredicateDescriptor.m; sourceTree = ""; }; D8F6A24A2885515B00320515 /* SentryPredicateDescriptor.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = SentryPredicateDescriptor.h; path = include/SentryPredicateDescriptor.h; sourceTree = ""; }; @@ -2441,6 +2453,8 @@ 33042A0C29DAF79A00C60085 /* SentryExtraContextProvider.m */, D858FA642A29EAB3002A3503 /* SentryBinaryImageCache.h */, D858FA652A29EAB3002A3503 /* SentryBinaryImageCache.m */, + D8739D152BEEA33F007D2F66 /* SentryLevelHelper.h */, + D8739D162BEEA33F007D2F66 /* SentryLevelHelper.m */, ); name = Helper; sourceTree = ""; @@ -2507,6 +2521,7 @@ 8ED3D305264DFE700049393B /* SwiftDescriptorTests.swift */, 0A1B497228E597DD00D7BFA3 /* TestLogOutput.swift */, 7B6438AD26A710E6000D0F65 /* Categories */, + D8BC28D32C00C6A60054DA4D /* Extensions */, 7BD7299B24654CD500EA3610 /* Helper */, 7B944FA924697E9700A10721 /* Integrations */, 7BBD18AF24517E5D00427C76 /* Networking */, @@ -3584,6 +3599,7 @@ D86130112BB563FD004C0F5E /* SentrySessionReplayIntegrationTests.swift */, D861301B2BB5A267004C0F5E /* SentrySessionReplayTests.swift */, D8AFC0002BD252B900118BE1 /* SentryOnDemandReplayTests.swift */, + D82DD1CC2BEEB1A0001AB556 /* SentryBreadcrumbReplayConverterTests.swift */, ); path = SessionReplay; sourceTree = ""; @@ -3620,6 +3636,7 @@ D8F67B212BEAB6CC00C9197B /* SentryRRWebEvent.swift */, D81988C22BEC189C0020E36C /* SentryRRWebMetaEvent.swift */, D81988C62BEC18E20020E36C /* SentryRRWebVideoEvent.swift */, + D8739D132BEE5049007D2F66 /* SentryRRWebSpanEvent.swift */, D81988C82BEC19200020E36C /* SentryRRWebBreadcrumbEvent.swift */, ); path = RRWeb; @@ -3834,6 +3851,14 @@ path = Resources; sourceTree = ""; }; + D8BC28D32C00C6A60054DA4D /* Extensions */ = { + isa = PBXGroup; + children = ( + D8BC28D42C00C6D30054DA4D /* StringExtensionsTests.swift */, + ); + path = Extensions; + sourceTree = ""; + }; D8CAC02C2BA0663E00E38F34 /* SessionReplay */ = { isa = PBXGroup; children = ( @@ -3843,6 +3868,7 @@ D802994D2BA836EF000F0081 /* SentryOnDemandReplay.swift */, D802994F2BA83A88000F0081 /* SentryPixelBuffer.swift */, D8AFC03C2BDA79BF00118BE1 /* SentryReplayVideoMaker.swift */, + D8F67B1A2BE9728600C9197B /* SentryReplayBreadcrumbConverter.swift */, D81988BF2BEBFFF70020E36C /* SentryReplayRecording.swift */, ); path = SessionReplay; @@ -3969,6 +3995,7 @@ 7D0637032382B34300B30749 /* SentryScope.h in Headers */, 03F84D2727DD414C008FE43F /* SentryMachLogging.hpp in Headers */, 63295AF51EF3C7DB002D4490 /* SentryNSDictionarySanitize.h in Headers */, + D8739D172BEEA33F007D2F66 /* SentryLevelHelper.h in Headers */, 8E4A037825F6F52100000D77 /* SentrySampleDecision.h in Headers */, 63FE717920DA4C1100CDBAE8 /* SentryCrashReportStore.h in Headers */, 0AAE202128ED9BCC00D0CD80 /* SentryReachability.h in Headers */, @@ -4469,6 +4496,7 @@ 631E6D341EBC679C00712345 /* SentryQueueableRequestManager.m in Sources */, 7B8713B426415BAA006D6004 /* SentryAppStartTracker.m in Sources */, 7BDB03BB2513652900BAE198 /* SentryDispatchQueueWrapper.m in Sources */, + D8739D142BEE5049007D2F66 /* SentryRRWebSpanEvent.swift in Sources */, 7B6C5EDE264E8DF00010D138 /* SentryFramesTracker.m in Sources */, D84F833E2A1CC401005828E0 /* SentrySwiftAsyncIntegration.m in Sources */, 7B6438AB26A70F24000D0F65 /* UIViewController+Sentry.m in Sources */, @@ -4622,6 +4650,7 @@ 63BE85711ECEC6DE00DC44F5 /* SentryDateUtils.m in Sources */, 7BD4BD4927EB2A5D0071F4FF /* SentryDiscardedEvent.m in Sources */, 03F84D3827DD4191008FE43F /* SentryBacktrace.cpp in Sources */, + D8739D182BEEA33F007D2F66 /* SentryLevelHelper.m in Sources */, 62BAD7572BA2033F00EBAAFC /* SentryMetricsClient.swift in Sources */, 63FE712720DA4C1000CDBAE8 /* SentryCrashThread.c in Sources */, 7B127B0F27CF6F4700A71ED2 /* SentryANRTrackingIntegration.m in Sources */, @@ -4698,6 +4727,7 @@ D81988C72BEC18E20020E36C /* SentryRRWebVideoEvent.swift in Sources */, 621F61F12BEA073A005E654F /* SentryEnabledFeaturesBuilder.swift in Sources */, D88817D826D7149100BF2251 /* SentryTraceContext.m in Sources */, + D8F67B1B2BE9728600C9197B /* SentryReplayBreadcrumbConverter.swift in Sources */, 8EBF870926140D37001A6853 /* SentryPerformanceTracker.m in Sources */, D80CD8D02B75143F002F710B /* UrlSanitized.swift in Sources */, D8F016B32B9622D6007B9AFB /* SentryId.swift in Sources */, @@ -4931,6 +4961,7 @@ 7B6D98E924C6D336005502FA /* SentrySdkInfo+Equality.m in Sources */, 62F226B729A37C120038080D /* SentryBooleanSerialization.m in Sources */, 7B6438A726A70DDB000D0F65 /* UIViewControllerSentryTests.swift in Sources */, + D8BC28D62C00C6DF0054DA4D /* StringExtensionsTests.swift in Sources */, 15E0A8F0240F638200F044E3 /* SentrySerializationNilTests.m in Sources */, 0A2D8D8728992260008720F6 /* SentryBaseIntegrationTests.swift in Sources */, 7B6D135C27F4605D00331ED2 /* TestEnvelopeRateLimitDelegate.swift in Sources */, @@ -4960,6 +4991,7 @@ 62B86CFC29F052BB008F3947 /* SentryTestLogConfig.m in Sources */, D808FB92281BF6EC009A2A33 /* SentryUIEventTrackingIntegrationTests.swift in Sources */, 7BC6EC04255C235F0059822A /* SentryFrameTests.swift in Sources */, + D82DD1CD2BEEB1A0001AB556 /* SentryBreadcrumbReplayConverterTests.swift in Sources */, 0AE455AD28F584D2006680E5 /* SentryReachabilityTests.m in Sources */, 63FE720420DA66EC00CDBAE8 /* SentryCrashString_Tests.m in Sources */, 62872B632BA1B86100A4FA7D /* NSLockTests.swift in Sources */, diff --git a/Sources/Sentry/SentryLevelHelper.m b/Sources/Sentry/SentryLevelHelper.m new file mode 100644 index 00000000000..b27ed2365d7 --- /dev/null +++ b/Sources/Sentry/SentryLevelHelper.m @@ -0,0 +1,11 @@ +#import "SentryLevelHelper.h" +#import "SentryBreadcrumb+Private.h" + +@implementation SentryLevelHelper + ++ (NSUInteger)breadcrumbLevel:(SentryBreadcrumb *)breadcrumb +{ + return breadcrumb.level; +} + +@end diff --git a/Sources/Sentry/SentryScope.m b/Sources/Sentry/SentryScope.m index 0361433bd73..343ceea2351 100644 --- a/Sources/Sentry/SentryScope.m +++ b/Sources/Sentry/SentryScope.m @@ -30,11 +30,6 @@ */ @property (atomic, strong) NSMutableDictionary *extraDictionary; -/** - * Contains the breadcrumbs which will be sent with the event - */ -@property (atomic, strong) NSMutableArray *breadcrumbArray; - /** * This distribution of the application. */ diff --git a/Sources/Sentry/SentrySessionReplay.m b/Sources/Sentry/SentrySessionReplay.m index 74c6c995be0..2bf15da97c3 100644 --- a/Sources/Sentry/SentrySessionReplay.m +++ b/Sources/Sentry/SentrySessionReplay.m @@ -1,5 +1,6 @@ #import "SentrySessionReplay.h" #import "SentryAttachment+Private.h" +#import "SentryBreadcrumb+Private.h" #import "SentryDependencyContainer.h" #import "SentryDisplayLinkWrapper.h" #import "SentryEnvelopeItemType.h" @@ -42,6 +43,7 @@ @implementation SentrySessionReplay { int _currentSegmentId; BOOL _processingScreenshot; BOOL _reachedMaximumDuration; + SentryReplayBreadcrumbConverter *_breadcrumbConverter; } - (instancetype)initWithSettings:(SentryReplayOptions *)replayOptions @@ -62,6 +64,7 @@ - (instancetype)initWithSettings:(SentryReplayOptions *)replayOptions _urlToCache = folderPath; _replayMaker = replayMaker; _reachedMaximumDuration = NO; + _breadcrumbConverter = [[SentryReplayBreadcrumbConverter alloc] init]; } return self; } @@ -286,6 +289,14 @@ - (void)captureSegment:(NSInteger)segment replayEvent.segmentId = segment; replayEvent.timestamp = videoInfo.end; + __block NSArray *events; + + [SentrySDK.currentHub configureScope:^(SentryScope *_Nonnull scope) { + events = [self->_breadcrumbConverter convertWithBreadcrumbs:scope.breadcrumbArray + from:videoInfo.start + until:videoInfo.end]; + }]; + SentryReplayRecording *recording = [[SentryReplayRecording alloc] initWithSegmentId:replayEvent.segmentId size:videoInfo.fileSize @@ -294,7 +305,8 @@ - (void)captureSegment:(NSInteger)segment frameCount:videoInfo.frameCount frameRate:videoInfo.frameRate height:videoInfo.height - width:videoInfo.width]; + width:videoInfo.width + extraEvents:events]; [SentrySDK.currentHub captureReplayEvent:replayEvent replayRecording:recording diff --git a/Sources/Sentry/include/SentryLevelHelper.h b/Sources/Sentry/include/SentryLevelHelper.h new file mode 100644 index 00000000000..e599cfe6298 --- /dev/null +++ b/Sources/Sentry/include/SentryLevelHelper.h @@ -0,0 +1,16 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@class SentryBreadcrumb; + +/** + * This is a workaround to access SentryLevel value from swift + */ +@interface SentryLevelHelper : NSObject + ++ (NSUInteger)breadcrumbLevel:(SentryBreadcrumb *)breadcrumb; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/include/SentryPrivate.h b/Sources/Sentry/include/SentryPrivate.h index d65d7635b71..23fb7856a16 100644 --- a/Sources/Sentry/include/SentryPrivate.h +++ b/Sources/Sentry/include/SentryPrivate.h @@ -11,4 +11,5 @@ // Headers that also import SentryDefines should be at the end of this list // otherwise it wont compile #import "SentryDateUtil.h" +#import "SentryLevelHelper.h" #import "SentrySdkInfo.h" diff --git a/Sources/Sentry/include/SentryScope+Private.h b/Sources/Sentry/include/SentryScope+Private.h index ac581eb63a3..af030f2aaf4 100644 --- a/Sources/Sentry/include/SentryScope+Private.h +++ b/Sources/Sentry/include/SentryScope+Private.h @@ -23,6 +23,11 @@ SentryScope () @property (atomic, strong) SentryPropagationContext *propagationContext; +/** + * Contains the breadcrumbs which will be sent with the event + */ +@property (atomic, strong) NSMutableArray *breadcrumbArray; + /** * used to add values in event context. */ diff --git a/Sources/Swift/Extensions/StringExtensions.swift b/Sources/Swift/Extensions/StringExtensions.swift index 49b3e0002d2..fa8854e1512 100644 --- a/Sources/Swift/Extensions/StringExtensions.swift +++ b/Sources/Swift/Extensions/StringExtensions.swift @@ -37,4 +37,20 @@ extension String { let end = index(startIndex, offsetBy: range.upperBound) return self[startIndex.. String { + var result = "" + + var toUpper = false + for char in self { + if char == "_" { + toUpper = true + } else { + result.append(toUpper ? char.uppercased() : String(char)) + toUpper = false + } + } + + return result + } } diff --git a/Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebBreadcrumbEvent.swift b/Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebBreadcrumbEvent.swift index 9752c05edec..0b7fa8cf469 100644 --- a/Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebBreadcrumbEvent.swift +++ b/Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebBreadcrumbEvent.swift @@ -1,10 +1,10 @@ +@_implementationOnly import _SentryPrivate import Foundation -@objcMembers class SentryRRWebBreadcrumbEvent: SentryRRWebCustomEvent { - init(timestamp: Date, category: String, message: String?, level: SentryLevel, data: [String: Any]?) { + init(timestamp: Date, category: String, message: String? = nil, level: SentryLevel = .none, data: [String: Any]? = nil) { - var payload: [String: Any] = ["type": "default", "category": category, "level": level.rawValue ] + var payload: [String: Any] = ["type": "default", "category": category, "level": level.rawValue, "timestamp": timestamp.timeIntervalSince1970 ] if let message = message { payload["message"] = message diff --git a/Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebSpanEvent.swift b/Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebSpanEvent.swift new file mode 100644 index 00000000000..f391979b129 --- /dev/null +++ b/Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebSpanEvent.swift @@ -0,0 +1,17 @@ +@_implementationOnly import _SentryPrivate +import Foundation + +@objc class SentryRRWebSpanEvent: SentryRRWebCustomEvent { + + init(timestamp: Date, endTimestamp: Date, operation: String, description: String, data: [String: Any]) { + super.init(timestamp: timestamp, tag: "performanceSpan", payload: + [ + "op": operation, + "description": description, + "startTimestamp": timestamp.timeIntervalSince1970, + "endTimestamp": endTimestamp.timeIntervalSince1970, + "data": data + ] + ) + } +} diff --git a/Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebVideoEvent.swift b/Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebVideoEvent.swift index 611c1fca5ff..b5d3785c7b3 100644 --- a/Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebVideoEvent.swift +++ b/Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebVideoEvent.swift @@ -6,7 +6,7 @@ class SentryRRWebVideoEvent: SentryRRWebCustomEvent { init(timestamp: Date, segmentId: Int, size: Int, duration: TimeInterval, encoding: String, container: String, height: Int, width: Int, frameCount: Int, frameRateType: String, frameRate: Int, left: Int, top: Int) { super.init(timestamp: timestamp, tag: "video", payload: [ - "timestamp": SentryDateUtil.millisecondsSince1970(timestamp), + "timestamp": timestamp.timeIntervalSince1970, "segmentId": segmentId, "size": size, "duration": duration, diff --git a/Sources/Swift/Integrations/SessionReplay/SentryReplayBreadcrumbConverter.swift b/Sources/Swift/Integrations/SessionReplay/SentryReplayBreadcrumbConverter.swift new file mode 100644 index 00000000000..81c71a42c8c --- /dev/null +++ b/Sources/Swift/Integrations/SessionReplay/SentryReplayBreadcrumbConverter.swift @@ -0,0 +1,92 @@ +@_implementationOnly import _SentryPrivate +import Foundation + +@objcMembers +class SentryReplayBreadcrumbConverter: NSObject { + + private let supportedNetworkData = Set([ + "status_code", + "method", + "response_body_size", + "request_body_size", + "http.query", + "http.fragment"] + ) + + func convert(breadcrumbs: [Breadcrumb], from: Date, until: Date) -> [SentryRRWebEvent] { + breadcrumbs.filter { + guard let timestamp = $0.timestamp else { return false } + return timestamp >= from && timestamp <= until + } + .compactMap { convert(from: $0) } + } + + /** + * This function will convert the SDK breadcrumbs to session replay breadcrumbs in a format that the front-end understands. + * Any deviation in the information will cause the breadcrumb or the information itself to be discarded + * in order to avoid unknown behavior in the front-end. + */ + private func convert(from breadcrumb: Breadcrumb) -> SentryRRWebEvent? { + guard let timestamp = breadcrumb.timestamp else { return nil } + if breadcrumb.category == "http" { + return networkSpan(breadcrumb) + } else if breadcrumb.type == "navigation" { + return navigationBreadcrumb(breadcrumb) + } else if breadcrumb.category == "touch" { + return SentryRRWebBreadcrumbEvent(timestamp: timestamp, category: "ui.tap", message: breadcrumb.message) + } else if breadcrumb.type == "connectivity" && breadcrumb.category == "device.connectivity" { + guard let networkType = breadcrumb.data?["connectivity"] as? String, !networkType.isEmpty else { return nil } + return SentryRRWebBreadcrumbEvent(timestamp: timestamp, category: "device.connectivity", data: ["state": networkType]) + } else if let action = breadcrumb.data?["action"] as? String, action == "BATTERY_STATE_CHANGE" { + var data = breadcrumb.data?.filter({ item in item.key == "level" || item.key == "plugged" }) ?? [:] + + data["charging"] = data["plugged"] + data["plugged"] = nil + + return SentryRRWebBreadcrumbEvent(timestamp: timestamp, + category: "device.battery", + data: data) + } + + let level = getLevel(breadcrumb: breadcrumb) + return SentryRRWebBreadcrumbEvent(timestamp: timestamp, category: breadcrumb.category, message: breadcrumb.message, level: level, data: breadcrumb.data) + } + + private func navigationBreadcrumb(_ breadcrumb: Breadcrumb) -> SentryRRWebBreadcrumbEvent? { + guard let timestamp = breadcrumb.timestamp else { return nil } + + if breadcrumb.category == "app.lifecycle" { + guard let state = breadcrumb.data?["state"] else { return nil } + return SentryRRWebBreadcrumbEvent(timestamp: timestamp, category: "app.\(state)") + } else if let position = breadcrumb.data?["position"] as? String, breadcrumb.category == "device.orientation" { + return SentryRRWebBreadcrumbEvent(timestamp: timestamp, category: "device.orientation", data: ["position": position]) + } else { + if let to = breadcrumb.data?["screen"] as? String { + return SentryRRWebBreadcrumbEvent(timestamp: timestamp, category: "navigation", message: to, data: ["to": to]) + } else { + return nil + } + } + } + + private func networkSpan(_ breadcrumb: Breadcrumb) -> SentryRRWebSpanEvent? { + guard let timestamp = breadcrumb.timestamp, + let description = breadcrumb.data?["url"] as? String, + let startTimestamp = breadcrumb.data?["request_start"] as? Date + else { return nil } + var data = [String: Any]() + + breadcrumb.data?.forEach({ (key, value) in + guard supportedNetworkData.contains(key) else { return } + let newKey = key.replacingOccurrences(of: "http.", with: "") + data[newKey.snakeToCamelCase()] = value + }) + + //We dont have end of the request in the breadcrumb. + return SentryRRWebSpanEvent(timestamp: startTimestamp, endTimestamp: timestamp, operation: "resource.http", description: description, data: data) + } + + private func getLevel(breadcrumb: Breadcrumb) -> SentryLevel { + return SentryLevel(rawValue: SentryLevelHelper.breadcrumbLevel(breadcrumb)) ?? .none + } +} diff --git a/Sources/Swift/Integrations/SessionReplay/SentryReplayRecording.swift b/Sources/Swift/Integrations/SessionReplay/SentryReplayRecording.swift index 42488ade3cc..9eaed0ac313 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryReplayRecording.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryReplayRecording.swift @@ -10,14 +10,14 @@ class SentryReplayRecording: NSObject { let segmentId: Int - let meta: SentryRRWebMetaEvent - let video: SentryRRWebVideoEvent + let events: [SentryRRWebEvent] - init(segmentId: Int, size: Int, start: Date, duration: TimeInterval, frameCount: Int, frameRate: Int, height: Int, width: Int) { + init(segmentId: Int, size: Int, start: Date, duration: TimeInterval, frameCount: Int, frameRate: Int, height: Int, width: Int, extraEvents: [SentryRRWebEvent]?) { self.segmentId = segmentId - meta = SentryRRWebMetaEvent(timestamp: start, height: height, width: width) - video = SentryRRWebVideoEvent(timestamp: start, segmentId: segmentId, size: size, duration: duration, encoding: SentryReplayRecording.SentryReplayEncoding, container: SentryReplayRecording.SentryReplayContainer, height: height, width: width, frameCount: frameCount, frameRateType: SentryReplayRecording.SentryReplayFrameRateType, frameRate: frameRate, left: 0, top: 0) + let meta = SentryRRWebMetaEvent(timestamp: start, height: height, width: width) + let video = SentryRRWebVideoEvent(timestamp: start, segmentId: segmentId, size: size, duration: duration, encoding: SentryReplayRecording.SentryReplayEncoding, container: SentryReplayRecording.SentryReplayContainer, height: height, width: width, frameCount: frameCount, frameRateType: SentryReplayRecording.SentryReplayFrameRateType, frameRate: frameRate, left: 0, top: 0) + self.events = [meta, video] + (extraEvents ?? []) } func headerForReplayRecording() -> [String: Any] { @@ -25,10 +25,6 @@ class SentryReplayRecording: NSObject { } func serialize() -> [[String: Any]] { - let metaInfo = meta.serialize() - - let recordingInfo = video.serialize() - - return [metaInfo, recordingInfo] + return events.map { $0.serialize() } } } diff --git a/Tests/SentryTests/Extensions/StringExtensionsTests.swift b/Tests/SentryTests/Extensions/StringExtensionsTests.swift new file mode 100644 index 00000000000..77c509ff6b4 --- /dev/null +++ b/Tests/SentryTests/Extensions/StringExtensionsTests.swift @@ -0,0 +1,11 @@ +import Foundation +@testable import Sentry +import XCTest + +class StringExtensionsTests: XCTestCase { + func testSnakeToCamelCase() { + XCTAssertEqual("name_something".snakeToCamelCase(), "nameSomething") + XCTAssertEqual("name_something_else".snakeToCamelCase(), "nameSomethingElse") + XCTAssertEqual("KEEP_CASE".snakeToCamelCase(), "KEEPCASE") + } +} diff --git a/Tests/SentryTests/Helper/SentrySerializationTests.swift b/Tests/SentryTests/Helper/SentrySerializationTests.swift index 87f1153341c..d76ff4d6f4b 100644 --- a/Tests/SentryTests/Helper/SentrySerializationTests.swift +++ b/Tests/SentryTests/Helper/SentrySerializationTests.swift @@ -240,7 +240,7 @@ class SentrySerializationTests: XCTestCase { } let date = Date(timeIntervalSince1970: 2) - let recording = MockReplayRecording(segmentId: 5, size: 5_000, start: date, duration: 5_000, frameCount: 5, frameRate: 1, height: 320, width: 950) + let recording = MockReplayRecording(segmentId: 5, size: 5_000, start: date, duration: 5_000, frameCount: 5, frameRate: 1, height: 320, width: 950, extraEvents: []) let data = SentrySerialization.data(with: recording) let serialized = String(data: data, encoding: .utf8) diff --git a/Tests/SentryTests/Integrations/Breadcrumbs/SentryBreadcrumbTrackerTests.swift b/Tests/SentryTests/Integrations/Breadcrumbs/SentryBreadcrumbTrackerTests.swift index 5b6597d2172..062a5e0ed66 100644 --- a/Tests/SentryTests/Integrations/Breadcrumbs/SentryBreadcrumbTrackerTests.swift +++ b/Tests/SentryTests/Integrations/Breadcrumbs/SentryBreadcrumbTrackerTests.swift @@ -1,3 +1,4 @@ +@testable import Sentry import SentryTestUtils import XCTest @@ -55,6 +56,32 @@ class SentryBreadcrumbTrackerTests: XCTestCase { XCTAssertEqual(try XCTUnwrap(crumb.data?["connectivity"] as? String), $0.element) } } + + func testNetworkConnectivityBreadcrumbForSessionReplay() throws { + let testReachability = TestSentryReachability() + SentryDependencyContainer.sharedInstance().reachability = testReachability + let sut = SentryBreadcrumbTracker() + sut.start(with: delegate) + testReachability.setReachabilityState(state: SentryConnectivityCellular) + sut.stop() + + guard let breadcrumb = delegate.addCrumbInvocations.invocations.dropFirst().first else { + XCTFail("No connectivity breadcrumb") + return + } + + let breadcrumbConverter = SentryReplayBreadcrumbConverter() + let result = try XCTUnwrap(breadcrumbConverter.convert(breadcrumbs: [breadcrumb], + from: Date(timeIntervalSince1970: 0), + until: Date(timeIntervalSinceNow: 60)).first) + + let crumbData = try XCTUnwrap(result.data) + let payload = try XCTUnwrap(crumbData["payload"] as? [String: Any]) + let payloadData = try XCTUnwrap(payload["data"] as? [String: Any]) + + XCTAssertEqual(payload["category"] as? String, "device.connectivity") + XCTAssertEqual(payloadData["state"] as? String, "cellular") + } func testSwizzlingStarted_ViewControllerAppears_AddsUILifeCycleBreadcrumb() { let testReachability = TestSentryReachability() @@ -108,6 +135,93 @@ class SentryBreadcrumbTrackerTests: XCTestCase { clearTestState() } + + func testNavigationBreadcrumbForSessionReplay() throws { + //Call the previous test to create the breadcrumb into the delegate + testSwizzlingStarted_ViewControllerAppears_AddsUILifeCycleBreadcrumb() + + let sut = SentryReplayBreadcrumbConverter() + + guard let crumb = delegate.addCrumbInvocations.invocations.dropFirst().first else { + XCTFail("No navigation breadcrumb") + return + } + let result = sut.convert(breadcrumbs: [crumb], + from: Date(timeIntervalSince1970: 0), + until: Date(timeIntervalSinceNow: 60)) + + XCTAssertEqual(result.count, 1) + let event = result.first?.serialize() + let eventData = event?["data"] as? [String: Any] + let eventPayload = eventData?["payload"] as? [String: Any] + let payloadData = eventPayload?["data"] as? [String: Any] + + XCTAssertEqual(event?["type"] as? Int, 5) + XCTAssertEqual(eventData?["tag"] as? String, "breadcrumb") + XCTAssertEqual(eventPayload?["category"] as? String, "navigation") + XCTAssertEqual(payloadData?["to"] as? String, "UIViewController") + } + + func testAppLifeCycleBreadcrumbForSessionReplay() throws { + let scope = Scope() + let client = TestClient(options: Options()) + let hub = TestHub(client: client, andScope: scope) + SentrySDK.setCurrentHub(hub) + + let tracker = SentryBreadcrumbTracker() + tracker.start(with: delegate) + + NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + + let sut = SentryReplayBreadcrumbConverter() + guard let crumb = delegate.addCrumbInvocations.invocations.dropFirst().first else { + XCTFail("No life cycle breadcrumb") + return + } + let result = sut.convert(breadcrumbs: [crumb], + from: Date(timeIntervalSince1970: 0), + until: Date(timeIntervalSinceNow: 60)) + + XCTAssertEqual(result.count, 1) + let event = result.first?.serialize() + let eventData = event?["data"] as? [String: Any] + let eventPayload = eventData?["payload"] as? [String: Any] + + XCTAssertEqual(event?["type"] as? Int, 5) + XCTAssertEqual(eventData?["tag"] as? String, "breadcrumb") + XCTAssertEqual(eventPayload?["category"] as? String, "app.background") + } + + func testTouchBreadcrumbForSessionReplay() throws { + let scope = Scope() + let client = TestClient(options: Options()) + let hub = TestHub(client: client, andScope: scope) + SentrySDK.setCurrentHub(hub) + + let swizzlingWrapper = TestSentrySwizzleWrapper() + SentryDependencyContainer.sharedInstance().swizzleWrapper = swizzlingWrapper + + let tracker = SentryBreadcrumbTracker() + tracker.start(with: delegate) + tracker.startSwizzle() + + swizzlingWrapper.execute(action: "methodPressed:", target: self, sender: self, event: nil) + + let sut = SentryReplayBreadcrumbConverter() + guard let crumb = delegate.addCrumbInvocations.invocations.dropFirst().first else { + XCTFail("No touch breadcrumb") + return + } + + let result = try XCTUnwrap(sut.convert(breadcrumbs: [crumb], from: Date(timeIntervalSince1970: 0), + until: Date(timeIntervalSinceNow: 60)).first) + let crumbData = try XCTUnwrap(result.data) + let payload = try XCTUnwrap(crumbData["payload"] as? [String: Any]) + + XCTAssertEqual(payload["category"] as? String, "ui.tap") + XCTAssertEqual(payload["message"] as? String, "methodPressed:") + } + #endif } diff --git a/Tests/SentryTests/Integrations/Breadcrumbs/SentrySystemEventBreadcrumbsTest.swift b/Tests/SentryTests/Integrations/Breadcrumbs/SentrySystemEventBreadcrumbsTest.swift index e89c22fbecb..2b2346d3d0c 100644 --- a/Tests/SentryTests/Integrations/Breadcrumbs/SentrySystemEventBreadcrumbsTest.swift +++ b/Tests/SentryTests/Integrations/Breadcrumbs/SentrySystemEventBreadcrumbsTest.swift @@ -131,6 +131,29 @@ class SentrySystemEventBreadcrumbsTest: XCTestCase { expect(self.fixture.delegate.addCrumbInvocations.count) == 0 } + func testBatteryBreadcrumbForSessionReplay() throws { + let currentDevice = MyUIDevice() + sut = fixture.getSut(currentDevice: currentDevice) + postBatteryLevelNotification(uiDevice: currentDevice) + + guard let breadcrumb = fixture.delegate.addCrumbInvocations.first else { + XCTFail("No battery breadcrumb") + return + } + + let sut = SentryReplayBreadcrumbConverter() + let result = try XCTUnwrap(sut.convert(breadcrumbs: [breadcrumb], + from: Date(timeIntervalSince1970: 0), + until: Date(timeIntervalSinceNow: 60)).first) + let crumbData = try XCTUnwrap(result.data) + let payload = try XCTUnwrap(crumbData["payload"] as? [String: Any]) + let payloadData = try XCTUnwrap(payload["data"] as? [String: Any]) + + XCTAssertEqual(payload["category"] as? String, "device.battery") + XCTAssertEqual(payloadData["level"] as? Double, 100.0) + XCTAssertEqual(payloadData["charging"] as? Bool, true) + } + private func assertBatteryBreadcrumb(charging: Bool, level: Float) { XCTAssertEqual(1, fixture.delegate.addCrumbInvocations.count) @@ -185,12 +208,37 @@ class SentrySystemEventBreadcrumbsTest: XCTestCase { XCTAssertEqual(0, fixture.delegate.addCrumbInvocations.count, "there are breadcrumbs") } - private func assertPositionOrientationBreadcrumb(position: String) { + func testOrientationBreadcrumbForSessionReplay() throws { + let currentDevice = MyUIDevice() + sut = fixture.getSut(currentDevice: currentDevice) + NotificationCenter.default.post(Notification(name: UIDevice.orientationDidChangeNotification, object: currentDevice)) + + guard let breadcrumb = fixture.delegate.addCrumbInvocations.first else { + XCTFail("No orientation breadcrumb") + return + } + let sut = SentryReplayBreadcrumbConverter() + let result = try XCTUnwrap(sut.convert(breadcrumbs: [breadcrumb], + from: Date(timeIntervalSince1970: 0), + until: Date(timeIntervalSinceNow: 60))) + + XCTAssertEqual(result.count, 1) + let event = result.first?.serialize() + let eventData = event?["data"] as? [String: Any] + let eventPayload = eventData?["payload"] as? [String: Any] + let payloadData = eventPayload?["data"] as? [String: Any] + + XCTAssertEqual(event?["type"] as? Int, 5) + XCTAssertEqual(eventData?["tag"] as? String, "breadcrumb") + XCTAssertEqual(eventPayload?["category"] as? String, "device.orientation") + XCTAssertEqual(payloadData?["position"] as? String, "portrait") + } + + private func assertPositionOrientationBreadcrumb(position: String) { XCTAssertEqual(1, fixture.delegate.addCrumbInvocations.count) if let crumb = fixture.delegate.addCrumbInvocations.first { - XCTAssertEqual("device.orientation", crumb.category) XCTAssertEqual("navigation", crumb.type) XCTAssertEqual(SentryLevel.info, crumb.level) diff --git a/Tests/SentryTests/Integrations/Performance/Network/SentryNetworkTrackerTests.swift b/Tests/SentryTests/Integrations/Performance/Network/SentryNetworkTrackerTests.swift index fa3f43b2ac6..bc70bf273ac 100644 --- a/Tests/SentryTests/Integrations/Performance/Network/SentryNetworkTrackerTests.swift +++ b/Tests/SentryTests/Integrations/Performance/Network/SentryNetworkTrackerTests.swift @@ -1,7 +1,9 @@ import ObjectiveC +@testable import Sentry import SentryTestUtils import XCTest +// swiftlint:disable file_length class SentryNetworkTrackerTests: XCTestCase { private static let dsnAsString = TestConstants.dsnAsString(username: "SentrySessionTrackerTests") @@ -342,6 +344,36 @@ class SentryNetworkTrackerTests: XCTestCase { XCTAssertTrue(breadcrumb!.data!["request_start"] is Date) XCTAssertNil(breadcrumb!.data!["graphql_operation_name"]) } + + func testNetworkBreadcrumbForSessionReplay() throws { + assertStatus(status: .ok, state: .completed, response: createResponse(code: 200)) + + let breadcrumbs = Dynamic(fixture.scope).breadcrumbArray as [Breadcrumb]? + + let sut = SentryReplayBreadcrumbConverter() + guard let crumb = breadcrumbs?.first else { + XCTFail("No touch breadcrumb") + return + } + + let result = try XCTUnwrap(sut.convert(breadcrumbs: [crumb], + from: Date(timeIntervalSince1970: 0), + until: Date(timeIntervalSinceNow: 60)).first) + let crumbData = try XCTUnwrap(result.data) + let payload = try XCTUnwrap(crumbData["payload"] as? [String: Any]) + let payloadData = try XCTUnwrap(payload["data"] as? [String: Any]) + let start = try XCTUnwrap(crumb.data?["request_start"] as? Date) + + XCTAssertEqual(result.timestamp, start) + XCTAssertEqual(crumbData["tag"] as? String, "performanceSpan") + XCTAssertEqual(payload["description"] as? String, "https://www.domain.com/api") + XCTAssertEqual(payload["op"] as? String, "resource.http") + XCTAssertEqual(payload["startTimestamp"] as? Double, start.timeIntervalSince1970) + XCTAssertEqual(payload["endTimestamp"] as? Double, crumb.timestamp?.timeIntervalSince1970) + XCTAssertEqual(payloadData["statusCode"] as? Int, 200) + XCTAssertEqual(payloadData["query"] as? String, "query=value&query2=value2") + XCTAssertEqual(payloadData["fragment"] as? String, "fragment") + } func testBreadcrumb_GraphQLEnabled() { let body = """ diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentryBreadcrumbReplayConverterTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentryBreadcrumbReplayConverterTests.swift new file mode 100644 index 00000000000..99ff4e2caf2 --- /dev/null +++ b/Tests/SentryTests/Integrations/SessionReplay/SentryBreadcrumbReplayConverterTests.swift @@ -0,0 +1,170 @@ +import Foundation +@testable import Sentry +import XCTest + +class SentryBreadcrumbReplayConverterTests: XCTestCase { + + let from = Date(timeIntervalSince1970: 0) + let until = Date(timeIntervalSinceNow: 60) + + func testReplayBreadcrumbsWithEmptyArray() { + let sut = SentryReplayBreadcrumbConverter() + let result = sut.convert(breadcrumbs: [], from: from, until: until) + XCTAssertTrue(result.isEmpty) + } + + func testReplayBreadcrumbWithNilTimestamp() { + let sut = SentryReplayBreadcrumbConverter() + let breadcrumb = Breadcrumb(level: .debug, category: "Breadcrumb") + breadcrumb.timestamp = nil + let result = sut.convert(breadcrumbs: [breadcrumb], from: from, until: until) + XCTAssertEqual(result.count, 0) + } + + func testNavigationBreadcrumbAppLifecycle() { + let sut = SentryReplayBreadcrumbConverter() + let crumb = Breadcrumb(level: .info, category: "app.lifecycle") + crumb.type = "navigation" + crumb.data = ["state": "foreground"] + let result = sut.convert(breadcrumbs: [crumb], from: from, until: until) + + XCTAssertEqual(result.count, 1) + let event = result.first?.serialize() + let eventData = event?["data"] as? [String: Any] + let eventPayload = eventData?["payload"] as? [String: Any] + + XCTAssertEqual(event?["type"] as? Int, 5) + XCTAssertEqual(eventData?["tag"] as? String, "breadcrumb") + XCTAssertEqual(eventPayload?["category"] as? String, "app.foreground") + } + + func testNavigationBreadcrumbOrientation() { + let sut = SentryReplayBreadcrumbConverter() + let crumb = Breadcrumb(level: .info, category: "device.orientation") + crumb.type = "navigation" + crumb.data = ["position": "portrait"] + let result = sut.convert(breadcrumbs: [crumb], from: from, until: until) + + XCTAssertEqual(result.count, 1) + let event = result.first?.serialize() + let eventData = event?["data"] as? [String: Any] + let eventPayload = eventData?["payload"] as? [String: Any] + let payloadData = eventPayload?["data"] as? [String: Any] + + XCTAssertEqual(event?["type"] as? Int, 5) + XCTAssertEqual(eventData?["tag"] as? String, "breadcrumb") + XCTAssertEqual(eventPayload?["category"] as? String, "device.orientation") + XCTAssertEqual(payloadData?["position"] as? String, "portrait") + } + + func testNavigationBreadcrumbNavigate() { + let sut = SentryReplayBreadcrumbConverter() + let crumb = Breadcrumb(level: .info, category: "ui.lifecycle") + crumb.type = "navigation" + crumb.data = ["screen": "TestViewController"] + let result = sut.convert(breadcrumbs: [crumb], from: from, until: until) + + XCTAssertEqual(result.count, 1) + let event = result.first?.serialize() + let eventData = event?["data"] as? [String: Any] + let eventPayload = eventData?["payload"] as? [String: Any] + let payloadData = eventPayload?["data"] as? [String: Any] + + XCTAssertEqual(event?["type"] as? Int, 5) + XCTAssertEqual(eventData?["tag"] as? String, "breadcrumb") + XCTAssertEqual(eventPayload?["category"] as? String, "navigation") + XCTAssertEqual(payloadData?["to"] as? String, "TestViewController") + } + + func testHttpBreadcrumb() throws { + let sut = SentryReplayBreadcrumbConverter() + let breadcrumb = Breadcrumb(level: .info, category: "http") + let start = Date(timeIntervalSince1970: 5) + + breadcrumb.data = [ + "url": "https://test.com", + "method": "GET", + "response_body_size": 1_024, + "http.query": "query=value", + "http.fragment": "frag", + "status_code": 200, + "request_start": start + ] + + let result = try XCTUnwrap(sut.convert(breadcrumbs: [breadcrumb], from: from, until: until).first) + let crumbData = try XCTUnwrap(result.data) + let payload = try XCTUnwrap(crumbData["payload"] as? [String: Any]) + let payloadData = try XCTUnwrap(payload["data"] as? [String: Any]) + + XCTAssertEqual(result.timestamp, start) + XCTAssertEqual(crumbData["tag"] as? String, "performanceSpan") + XCTAssertEqual(payload["description"] as? String, "https://test.com") + XCTAssertEqual(payload["op"] as? String, "resource.http") + XCTAssertEqual(payload["startTimestamp"] as? Double, start.timeIntervalSince1970) + XCTAssertEqual(payload["endTimestamp"] as? Double, breadcrumb.timestamp?.timeIntervalSince1970) + XCTAssertEqual(payloadData["statusCode"] as? Int, 200) + XCTAssertEqual(payloadData["query"] as? String, "query=value") + XCTAssertEqual(payloadData["fragment"] as? String, "frag") + } + + func testTouchBreadcrumb() throws { + let sut = SentryReplayBreadcrumbConverter() + let breadcrumb = Breadcrumb(level: .info, category: "touch") + breadcrumb.message = "TestTapped:" + + let result = try XCTUnwrap(sut.convert(breadcrumbs: [breadcrumb], from: from, until: until).first) + let crumbData = try XCTUnwrap(result.data) + let payload = try XCTUnwrap(crumbData["payload"] as? [String: Any]) + + XCTAssertEqual(payload["category"] as? String, "ui.tap") + XCTAssertEqual(payload["message"] as? String, "TestTapped:") + } + + func testConnectivityBreadcrumb() throws { + let sut = SentryReplayBreadcrumbConverter() + let breadcrumb = Breadcrumb(level: .info, category: "device.connectivity") + breadcrumb.type = "connectivity" + breadcrumb.data = ["connectivity": "Wifi"] + + let result = try XCTUnwrap(sut.convert(breadcrumbs: [breadcrumb], from: from, until: until).first) + let crumbData = try XCTUnwrap(result.data) + let payload = try XCTUnwrap(crumbData["payload"] as? [String: Any]) + let payloadData = try XCTUnwrap(payload["data"] as? [String: Any]) + + XCTAssertEqual(payload["category"] as? String, "device.connectivity") + XCTAssertEqual(payloadData["state"] as? String, "Wifi") + } + + func testBatteryBreadcrumb() throws { + let sut = SentryReplayBreadcrumbConverter() + let breadcrumb = Breadcrumb(level: .info, category: "device.event") + breadcrumb.type = "system" + breadcrumb.data = ["level": 0.5, "plugged": true, "action": "BATTERY_STATE_CHANGE"] + + let result = try XCTUnwrap(sut.convert(breadcrumbs: [breadcrumb], from: from, until: until).first) + let crumbData = try XCTUnwrap(result.data) + let payload = try XCTUnwrap(crumbData["payload"] as? [String: Any]) + let payloadData = try XCTUnwrap(payload["data"] as? [String: Any]) + + XCTAssertEqual(payload["category"] as? String, "device.battery") + XCTAssertEqual(payloadData["level"] as? Double, 0.5) + XCTAssertEqual(payloadData["charging"] as? Bool, true) + } + + func testCustomBreadcrumbs() throws { + let sut = SentryReplayBreadcrumbConverter() + let breadcrumb = Breadcrumb(level: .info, category: "MyApp.MyBreadcrumb") + breadcrumb.type = "interation" + breadcrumb.data = ["SomeInfo": "Info"] + breadcrumb.message = "Custom message" + + let result = try XCTUnwrap(sut.convert(breadcrumbs: [breadcrumb], from: from, until: until).first) + let crumbData = try XCTUnwrap(result.data) + let payload = try XCTUnwrap(crumbData["payload"] as? [String: Any]) + let payloadData = try XCTUnwrap(payload["data"] as? [String: Any]) + + XCTAssertEqual(payload["category"] as? String, "MyApp.MyBreadcrumb") + XCTAssertEqual(payload["message"] as? String, "Custom message") + XCTAssertEqual(payloadData["SomeInfo"] as? String, "Info") + } +} diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentryReplayRecordingTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentryReplayRecordingTests.swift index e655127d977..566b4de2224 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentryReplayRecordingTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentryReplayRecordingTests.swift @@ -6,7 +6,7 @@ import XCTest class SentryReplayRecordingTests: XCTestCase { func test_serialize() { - let sut = SentryReplayRecording(segmentId: 3, size: 200, start: Date(timeIntervalSince1970: 2), duration: 5_000, frameCount: 5, frameRate: 1, height: 930, width: 390) + let sut = SentryReplayRecording(segmentId: 3, size: 200, start: Date(timeIntervalSince1970: 2), duration: 5_000, frameCount: 5, frameRate: 1, height: 930, width: 390, extraEvents: nil) let data = sut.serialize() @@ -22,7 +22,6 @@ class SentryReplayRecordingTests: XCTestCase { expect(metaInfoData?["href"] as? String) == "" expect(metaInfoData?["height"] as? Int) == 930 expect(metaInfoData?["width"] as? Int) == 390 - expect(recordingInfo["type"] as? Int) == 5 expect(recordingInfo["timestamp"] as? Int) == 2_000 expect(recordingData?["tag"] as? String) == "video" @@ -39,4 +38,17 @@ class SentryReplayRecordingTests: XCTestCase { expect(recordingPayload?["left"] as? Int) == 0 expect(recordingPayload?["top"] as? Int) == 0 } + + func test_serializeWithExtra() { + let date = Date(timeIntervalSince1970: 5) + let sut = SentryReplayRecording(segmentId: 3, size: 200, start: Date(timeIntervalSince1970: 2), duration: 5_000, frameCount: 5, frameRate: 1, height: 930, width: 390, extraEvents: [ + SentryRRWebEvent(type: .custom, timestamp: date, data: nil) + ]) + + let data = sut.serialize() + + let extraInfo = data[2] + expect(extraInfo["type"] as? Int) == 5 + expect(extraInfo["timestamp"] as? Int) == 5_000 + } } diff --git a/Tests/SentryTests/SentryClientTests.swift b/Tests/SentryTests/SentryClientTests.swift index 4744b6ff0d3..035bba36041 100644 --- a/Tests/SentryTests/SentryClientTests.swift +++ b/Tests/SentryTests/SentryClientTests.swift @@ -1602,7 +1602,7 @@ class SentryClientTest: XCTestCase { let sut = fixture.getSut() let replayEvent = SentryReplayEvent() replayEvent.segmentId = 2 - let replayRecording = SentryReplayRecording(segmentId: 2, size: 200, start: Date(timeIntervalSince1970: 2), duration: 5_000, frameCount: 5, frameRate: 1, height: 930, width: 390) + let replayRecording = SentryReplayRecording(segmentId: 2, size: 200, start: Date(timeIntervalSince1970: 2), duration: 5_000, frameCount: 5, frameRate: 1, height: 930, width: 390, extraEvents: []) //Not a video url, but its ok for test the envelope let movieUrl = Bundle(for: self.classForCoder).url(forResource: "Resources/raw", withExtension: "json") @@ -1619,7 +1619,7 @@ class SentryClientTest: XCTestCase { } let replayEvent = SentryReplayEvent() - let replayRecording = SentryReplayRecording(segmentId: 3, size: 200, start: Date(timeIntervalSince1970: 2), duration: 5_000, frameCount: 5, frameRate: 1, height: 930, width: 390) + let replayRecording = SentryReplayRecording(segmentId: 3, size: 200, start: Date(timeIntervalSince1970: 2), duration: 5_000, frameCount: 5, frameRate: 1, height: 930, width: 390, extraEvents: []) let movieUrl = Bundle(for: self.classForCoder).url(forResource: "Resources/raw", withExtension: "json") sut.capture(replayEvent, replayRecording: replayRecording, video: movieUrl!, with: Scope()) @@ -1635,7 +1635,7 @@ class SentryClientTest: XCTestCase { } let replayEvent = SentryReplayEvent() - let replayRecording = SentryReplayRecording(segmentId: 3, size: 200, start: Date(timeIntervalSince1970: 2), duration: 5_000, frameCount: 5, frameRate: 1, height: 930, width: 390) + let replayRecording = SentryReplayRecording(segmentId: 3, size: 200, start: Date(timeIntervalSince1970: 2), duration: 5_000, frameCount: 5, frameRate: 1, height: 930, width: 390, extraEvents: []) let movieUrl = Bundle(for: self.classForCoder).url(forResource: "Resources/raw", withExtension: "json") sut.capture(replayEvent, replayRecording: replayRecording, video: movieUrl!, with: Scope()) @@ -1651,7 +1651,7 @@ class SentryClientTest: XCTestCase { } let replayEvent = SentryReplayEvent() - let replayRecording = SentryReplayRecording(segmentId: 3, size: 200, start: Date(timeIntervalSince1970: 2), duration: 5_000, frameCount: 5, frameRate: 1, height: 930, width: 390) + let replayRecording = SentryReplayRecording(segmentId: 3, size: 200, start: Date(timeIntervalSince1970: 2), duration: 5_000, frameCount: 5, frameRate: 1, height: 930, width: 390, extraEvents: []) let movieUrl = URL(string: "NoFile")! sut.capture(replayEvent, replayRecording: replayRecording, video: movieUrl, with: Scope()) @@ -1664,7 +1664,7 @@ class SentryClientTest: XCTestCase { let sut = fixture.getSut() let replayEvent = SentryReplayEvent() replayEvent.segmentId = 2 - let replayRecording = SentryReplayRecording(segmentId: 2, size: 200, start: Date(timeIntervalSince1970: 2), duration: 5_000, frameCount: 5, frameRate: 1, height: 930, width: 390) + let replayRecording = SentryReplayRecording(segmentId: 2, size: 200, start: Date(timeIntervalSince1970: 2), duration: 5_000, frameCount: 5, frameRate: 1, height: 930, width: 390, extraEvents: []) //Not a video url, but its ok for test the envelope let movieUrl = Bundle(for: self.classForCoder).url(forResource: "Resources/raw", withExtension: "json") diff --git a/Tests/SentryTests/SentryHubTests.swift b/Tests/SentryTests/SentryHubTests.swift index f696a4a7bf3..2e4515d3834 100644 --- a/Tests/SentryTests/SentryHubTests.swift +++ b/Tests/SentryTests/SentryHubTests.swift @@ -772,7 +772,7 @@ class SentryHubTests: XCTestCase { let mockClient = SentryClientMockReplay(options: fixture.options) let replayEvent = SentryReplayEvent() - let replayRecording = SentryReplayRecording(segmentId: 3, size: 200, start: Date(timeIntervalSince1970: 2), duration: 5_000, frameCount: 5, frameRate: 1, height: 930, width: 390) + let replayRecording = SentryReplayRecording(segmentId: 3, size: 200, start: Date(timeIntervalSince1970: 2), duration: 5_000, frameCount: 5, frameRate: 1, height: 930, width: 390, extraEvents: []) let videoUrl = URL(string: "https://sentry.io")! sut.bindClient(mockClient)