From 0f3280e5394d44e1af0465d1399428c263bc316f Mon Sep 17 00:00:00 2001 From: Dhiogo Brustolin Date: Fri, 9 Feb 2024 09:53:04 +0100 Subject: [PATCH 1/8] Update project.pbxproj --- Sentry.xcodeproj/project.pbxproj | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index 4fa5d95953f..3898d01c380 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -2174,6 +2174,7 @@ 6383953423ABA269000C1594 /* Integrations */ = { isa = PBXGroup; children = ( + D80CD8D52B752FD9002F710B /* SessionReplay */, 7B08A3432924CF4E0059603A /* MetricKit */, 7B127B0B27CF6EF600A71ED2 /* ANR */, 7BE0DC33272AE74A004FA8B7 /* Breadcrumbs */, @@ -3366,6 +3367,13 @@ path = UIEvents; sourceTree = ""; }; + D80CD8D52B752FD9002F710B /* SessionReplay */ = { + isa = PBXGroup; + children = ( + ); + name = SessionReplay; + sourceTree = ""; + }; D8105B37297A86B800299F03 /* Recovered References */ = { isa = PBXGroup; children = ( From 0e524b593e42eda15f7016fe3045e4e845b69591 Mon Sep 17 00:00:00 2001 From: Dhiogo Brustolin Date: Mon, 12 Feb 2024 10:07:40 +0100 Subject: [PATCH 2/8] feat: Add Session Replay --- CHANGELOG.md | 4 ++++ Sentry.xcodeproj/project.pbxproj | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79e164c9634..0a6ec90de02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +- Add Session Replay (#) + ### Improvements - Cache installationID async to avoid file IO on the main thread when starting the SDK (#3601) diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index 3898d01c380..c6fd97719aa 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -2174,7 +2174,6 @@ 6383953423ABA269000C1594 /* Integrations */ = { isa = PBXGroup; children = ( - D80CD8D52B752FD9002F710B /* SessionReplay */, 7B08A3432924CF4E0059603A /* MetricKit */, 7B127B0B27CF6EF600A71ED2 /* ANR */, 7BE0DC33272AE74A004FA8B7 /* Breadcrumbs */, @@ -2184,6 +2183,7 @@ 7BE0DC35272AE7BF004FA8B7 /* SentryCrash */, D85596EF280580BE0041FF8B /* Screenshot */, 0A9BF4E028A114690068D266 /* ViewHierarchy */, + D80CD8D52B752FD9002F710B /* SessionReplay */, 7D7F0A5E23DF3D2C00A4629C /* SentryGlobalEventProcessor.h */, 7DAC588E23D8B2E0001CF26B /* SentryGlobalEventProcessor.m */, 7BA235622600B61200E12865 /* SentryInternalNotificationNames.h */, From cd816d54f76d46a6bfc452d86120d221f4a1a67a Mon Sep 17 00:00:00 2001 From: Dhiogo Brustolin Date: Mon, 12 Feb 2024 10:09:00 +0100 Subject: [PATCH 3/8] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a6ec90de02..abde07f0309 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features -- Add Session Replay (#) +- Add Session Replay (#3625) ### Improvements From 88c754764b59241b394135d3462210c8f8df381c Mon Sep 17 00:00:00 2001 From: Dhiogo Brustolin Date: Thu, 29 Feb 2024 10:26:25 +0100 Subject: [PATCH 4/8] feat(Session Replay): ReplayEvent, ReplayRecording and Envelope handling (#3638) Added SentryReplayEven, SentryReplayRecording and Envelope handling for this new types. Co-authored-by: Philipp Hofmann --- Sentry.xcodeproj/project.pbxproj | 52 +++++++++ Sources/Sentry/SentryClient.m | 44 ++++++- Sources/Sentry/SentryDataCategoryMapper.m | 9 ++ Sources/Sentry/SentryDateUtil.m | 5 + Sources/Sentry/SentryEnvelope.m | 39 +++++++ Sources/Sentry/SentryHub.m | 10 ++ Sources/Sentry/SentryMsgPackSerializer.m | 110 ++++++++++++++++++ Sources/Sentry/SentryReplayEvent.m | 41 +++++++ Sources/Sentry/SentryReplayRecording.m | 75 ++++++++++++ Sources/Sentry/SentryReplayType.m | 14 +++ Sources/Sentry/SentrySerialization.m | 6 +- .../include/HybridPublic/SentryEnvelope.h | 2 + .../HybridPublic/SentryEnvelopeItemType.h | 1 + Sources/Sentry/include/SentryClient+Private.h | 7 +- Sources/Sentry/include/SentryDataCategory.h | 4 +- .../Sentry/include/SentryDataCategoryMapper.h | 1 + Sources/Sentry/include/SentryDateUtil.h | 2 + .../Sentry/include/SentryEnvelope+Private.h | 6 + Sources/Sentry/include/SentryHub+Private.h | 6 + .../Sentry/include/SentryMsgPackSerializer.h | 33 ++++++ Sources/Sentry/include/SentryReplayEvent.h | 45 +++++++ .../Sentry/include/SentryReplayRecording.h | 46 ++++++++ Sources/Sentry/include/SentryReplayType.h | 16 +++ Sources/Sentry/include/SentrySerialization.h | 2 +- .../Helper/SentryDateUtilTests.swift | 8 ++ .../SentryReplayEventTests.swift | 30 +++++ .../SentryReplayRecordingTests.swift | 41 +++++++ .../SentryDataCategoryMapperTests.swift | 7 +- .../Protocol/SentryEnvelopeTests.swift | 7 ++ Tests/SentryTests/SentryClientTests.swift | 63 ++++++++++ Tests/SentryTests/SentryHubTests.swift | 28 +++++ .../SentryMsgPackSerializerTests.m | 103 ++++++++++++++++ .../SentryTests/SentryTests-Bridging-Header.h | 3 + 33 files changed, 853 insertions(+), 13 deletions(-) create mode 100644 Sources/Sentry/SentryMsgPackSerializer.m create mode 100644 Sources/Sentry/SentryReplayEvent.m create mode 100644 Sources/Sentry/SentryReplayRecording.m create mode 100644 Sources/Sentry/SentryReplayType.m create mode 100644 Sources/Sentry/include/SentryMsgPackSerializer.h create mode 100644 Sources/Sentry/include/SentryReplayEvent.h create mode 100644 Sources/Sentry/include/SentryReplayRecording.h create mode 100644 Sources/Sentry/include/SentryReplayType.h create mode 100644 Tests/SentryTests/Integrations/SessionReplay/SentryReplayEventTests.swift create mode 100644 Tests/SentryTests/Integrations/SessionReplay/SentryReplayRecordingTests.swift create mode 100644 Tests/SentryTests/SentryMsgPackSerializerTests.m diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index a60c07f24a1..704647195a2 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -747,6 +747,10 @@ A8F17B2E2901765900990B25 /* SentryRequest.m in Sources */ = {isa = PBXBuildFile; fileRef = A8F17B2D2901765900990B25 /* SentryRequest.m */; }; A8F17B342902870300990B25 /* SentryHttpStatusCodeRange.m in Sources */ = {isa = PBXBuildFile; fileRef = A8F17B332902870300990B25 /* SentryHttpStatusCodeRange.m */; }; D8019910286B089000C277F0 /* SentryCrashReportSinkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D801990F286B089000C277F0 /* SentryCrashReportSinkTests.swift */; }; + D80694C42B7CC9AE00B820E6 /* SentryReplayEventTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D80694C22B7CC86E00B820E6 /* SentryReplayEventTests.swift */; }; + D80694C72B7CD22B00B820E6 /* SentryReplayRecordingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D80694C52B7CCFA100B820E6 /* SentryReplayRecordingTests.swift */; }; + D80694CD2B7E0A3E00B820E6 /* SentryReplayType.h in Headers */ = {isa = PBXBuildFile; fileRef = D80694CB2B7E0A3E00B820E6 /* SentryReplayType.h */; }; + D80694CE2B7E0A3E00B820E6 /* SentryReplayType.m in Sources */ = {isa = PBXBuildFile; fileRef = D80694CC2B7E0A3E00B820E6 /* SentryReplayType.m */; }; D808FB88281AB33C009A2A33 /* SentryUIEventTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D808FB86281AB31D009A2A33 /* SentryUIEventTrackerTests.swift */; }; D808FB8B281BCE96009A2A33 /* TestSentrySwizzleWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D808FB89281BCE46009A2A33 /* TestSentrySwizzleWrapper.swift */; }; D808FB92281BF6EC009A2A33 /* SentryUIEventTrackingIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D808FB90281BF6E9009A2A33 /* SentryUIEventTrackingIntegrationTests.swift */; }; @@ -764,6 +768,8 @@ D8292D7D2A39A027009872F7 /* UrlSanitizedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8292D7C2A39A027009872F7 /* UrlSanitizedTests.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 */; }; + D83D079C2B7F9D1C00CC9674 /* SentryMsgPackSerializer.m in Sources */ = {isa = PBXBuildFile; fileRef = D83D079A2B7F9D1C00CC9674 /* SentryMsgPackSerializer.m */; }; D84541182A2DC2CD00E2B11C /* SentryBinaryImageCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D84541172A2DC2CD00E2B11C /* SentryBinaryImageCacheTests.swift */; }; D84793262788737D00BE8E99 /* SentryByteCountFormatter.m in Sources */ = {isa = PBXBuildFile; fileRef = D84793242788737D00BE8E99 /* SentryByteCountFormatter.m */; }; D8479328278873A100BE8E99 /* SentryByteCountFormatter.h in Headers */ = {isa = PBXBuildFile; fileRef = D8479327278873A100BE8E99 /* SentryByteCountFormatter.h */; }; @@ -795,6 +801,8 @@ D867063E27C3BC2400048851 /* SentryCoreDataSwizzling.h in Headers */ = {isa = PBXBuildFile; fileRef = D867063B27C3BC2400048851 /* SentryCoreDataSwizzling.h */; }; D867063F27C3BC2400048851 /* SentryCoreDataTracker.h in Headers */ = {isa = PBXBuildFile; fileRef = D867063C27C3BC2400048851 /* SentryCoreDataTracker.h */; }; D86B6835294348A400B8B1FC /* SentryAttachment+Private.h in Headers */ = {isa = PBXBuildFile; fileRef = D86B6834294348A400B8B1FC /* SentryAttachment+Private.h */; }; + D86B7B5C2B7A529C0017E8D9 /* SentryReplayEvent.h in Headers */ = {isa = PBXBuildFile; fileRef = D86B7B5A2B7A529C0017E8D9 /* SentryReplayEvent.h */; }; + D86B7B5D2B7A529C0017E8D9 /* SentryReplayEvent.m in Sources */ = {isa = PBXBuildFile; fileRef = D86B7B5B2B7A529C0017E8D9 /* SentryReplayEvent.m */; }; D86F419827C8FEFA00490520 /* SentryCoreDataTrackerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86F419727C8FEFA00490520 /* SentryCoreDataTrackerExtension.swift */; }; D8751FA5274743710032F4DE /* SentryNSURLSessionTaskSearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8751FA4274743710032F4DE /* SentryNSURLSessionTaskSearchTests.swift */; }; D875ED0B276CC84700422FAC /* SentryNSDataTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D875ED0A276CC84700422FAC /* SentryNSDataTrackerTests.swift */; }; @@ -805,6 +813,8 @@ D88817D826D7149100BF2251 /* SentryTraceContext.m in Sources */ = {isa = PBXBuildFile; fileRef = D88817D626D7149100BF2251 /* SentryTraceContext.m */; }; D88817DA26D72AB800BF2251 /* SentryTraceContext.h in Headers */ = {isa = PBXBuildFile; fileRef = D88817D926D72AB800BF2251 /* SentryTraceContext.h */; settings = {ATTRIBUTES = (Private, ); }; }; D88817DD26D72BA500BF2251 /* SentryTraceStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D88817DB26D72B7B00BF2251 /* SentryTraceStateTests.swift */; }; + D88D6C1D2B7B5A8800C8C633 /* SentryReplayRecording.h in Headers */ = {isa = PBXBuildFile; fileRef = D88D6C1B2B7B5A8800C8C633 /* SentryReplayRecording.h */; }; + D88D6C1E2B7B5A8800C8C633 /* SentryReplayRecording.m in Sources */ = {isa = PBXBuildFile; fileRef = D88D6C1C2B7B5A8800C8C633 /* SentryReplayRecording.m */; }; D8918B222849FA6D00701F9A /* SentrySDKIntegrationTestsBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8918B212849FA6D00701F9A /* SentrySDKIntegrationTestsBase.swift */; }; D8AB40DB2806EC1900E5E9F7 /* SentryScreenshotIntegration.h in Headers */ = {isa = PBXBuildFile; fileRef = D8AB40DA2806EC1900E5E9F7 /* SentryScreenshotIntegration.h */; }; D8ABB0BC29264275005D1E24 /* Sentry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D81A349B291D0C0B005A27A9 /* Sentry.swift */; }; @@ -839,6 +849,7 @@ D8F6A2472885512100320515 /* SentryPredicateDescriptor.m in Sources */ = {isa = PBXBuildFile; fileRef = D8F6A2452885512100320515 /* SentryPredicateDescriptor.m */; }; D8F6A24B2885515C00320515 /* SentryPredicateDescriptor.h in Headers */ = {isa = PBXBuildFile; fileRef = D8F6A24A2885515B00320515 /* SentryPredicateDescriptor.h */; }; D8F6A24E288553A800320515 /* SentryPredicateDescriptorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F6A24C2885534E00320515 /* SentryPredicateDescriptorTests.swift */; }; + D8F8F5572B835BC600AC5465 /* SentryMsgPackSerializerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = D8F8F5562B835BC600AC5465 /* SentryMsgPackSerializerTests.m */; }; D8FFE50C2703DBB400607131 /* SwizzlingCallTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8FFE50B2703DAAE00607131 /* SwizzlingCallTests.swift */; }; /* End PBXBuildFile section */ @@ -1735,6 +1746,10 @@ A8F17B332902870300990B25 /* SentryHttpStatusCodeRange.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryHttpStatusCodeRange.m; sourceTree = ""; }; D800942628F82F3A005D3943 /* SwiftDescriptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftDescriptor.swift; sourceTree = ""; }; D801990F286B089000C277F0 /* SentryCrashReportSinkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryCrashReportSinkTests.swift; sourceTree = ""; }; + D80694C22B7CC86E00B820E6 /* SentryReplayEventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryReplayEventTests.swift; sourceTree = ""; }; + D80694C52B7CCFA100B820E6 /* SentryReplayRecordingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryReplayRecordingTests.swift; sourceTree = ""; }; + D80694CB2B7E0A3E00B820E6 /* SentryReplayType.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryReplayType.h; path = include/SentryReplayType.h; sourceTree = ""; }; + D80694CC2B7E0A3E00B820E6 /* SentryReplayType.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryReplayType.m; sourceTree = ""; }; D808FB86281AB31D009A2A33 /* SentryUIEventTrackerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryUIEventTrackerTests.swift; sourceTree = ""; }; D808FB89281BCE46009A2A33 /* TestSentrySwizzleWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestSentrySwizzleWrapper.swift; sourceTree = ""; }; D808FB90281BF6E9009A2A33 /* SentryUIEventTrackingIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryUIEventTrackingIntegrationTests.swift; sourceTree = ""; }; @@ -1759,6 +1774,8 @@ D8292D7C2A39A027009872F7 /* UrlSanitizedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UrlSanitizedTests.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 = ""; }; + D83D079A2B7F9D1C00CC9674 /* SentryMsgPackSerializer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryMsgPackSerializer.m; sourceTree = ""; }; D84541172A2DC2CD00E2B11C /* SentryBinaryImageCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryBinaryImageCacheTests.swift; sourceTree = ""; }; D84541192A2DC55100E2B11C /* SentryBinaryImageCache+Private.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SentryBinaryImageCache+Private.h"; sourceTree = ""; }; D84793242788737D00BE8E99 /* SentryByteCountFormatter.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryByteCountFormatter.m; sourceTree = ""; }; @@ -1790,6 +1807,8 @@ D867063C27C3BC2400048851 /* SentryCoreDataTracker.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = SentryCoreDataTracker.h; path = include/SentryCoreDataTracker.h; sourceTree = ""; }; D86B6820293F39E000B8B1FC /* TestSentryViewHierarchy.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = TestSentryViewHierarchy.h; sourceTree = ""; }; D86B6834294348A400B8B1FC /* SentryAttachment+Private.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "SentryAttachment+Private.h"; path = "include/SentryAttachment+Private.h"; sourceTree = ""; }; + D86B7B5A2B7A529C0017E8D9 /* SentryReplayEvent.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryReplayEvent.h; path = include/SentryReplayEvent.h; sourceTree = ""; }; + D86B7B5B2B7A529C0017E8D9 /* SentryReplayEvent.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryReplayEvent.m; sourceTree = ""; }; D86F419727C8FEFA00490520 /* SentryCoreDataTrackerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryCoreDataTrackerExtension.swift; 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 = ""; }; @@ -1801,6 +1820,8 @@ D88817D626D7149100BF2251 /* SentryTraceContext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryTraceContext.m; sourceTree = ""; }; D88817D926D72AB800BF2251 /* SentryTraceContext.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = SentryTraceContext.h; path = include/SentryTraceContext.h; sourceTree = ""; }; D88817DB26D72B7B00BF2251 /* SentryTraceStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryTraceStateTests.swift; sourceTree = ""; }; + D88D6C1B2B7B5A8800C8C633 /* SentryReplayRecording.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryReplayRecording.h; path = include/SentryReplayRecording.h; sourceTree = ""; }; + D88D6C1C2B7B5A8800C8C633 /* SentryReplayRecording.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryReplayRecording.m; sourceTree = ""; }; D8918B212849FA6D00701F9A /* SentrySDKIntegrationTestsBase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentrySDKIntegrationTestsBase.swift; sourceTree = ""; }; D8AB40DA2806EC1900E5E9F7 /* SentryScreenshotIntegration.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = SentryScreenshotIntegration.h; path = include/SentryScreenshotIntegration.h; sourceTree = ""; }; D8ACE3C42762187200F5A213 /* SentryNSDataSwizzling.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SentryNSDataSwizzling.m; sourceTree = ""; }; @@ -1839,6 +1860,7 @@ 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 = ""; }; D8F6A24C2885534E00320515 /* SentryPredicateDescriptorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryPredicateDescriptorTests.swift; sourceTree = ""; }; + D8F8F5562B835BC600AC5465 /* SentryMsgPackSerializerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryMsgPackSerializerTests.m; sourceTree = ""; }; D8FFE50B2703DAAE00607131 /* SwizzlingCallTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwizzlingCallTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -2772,6 +2794,7 @@ 7BE0DC40272AEA0A004FA8B7 /* Performance */, 7BE0DC3F272AE9F0004FA8B7 /* Session */, 7BE0DC3E272AE9DC004FA8B7 /* SentryCrash */, + D80694C12B7CC85800B820E6 /* SessionReplay */, 7B59398324AB481B0003AAD2 /* NotificationCenterTestCase.swift */, 0A2D8D8628992260008720F6 /* SentryBaseIntegrationTests.swift */, ); @@ -3311,6 +3334,8 @@ D8F6A2452885512100320515 /* SentryPredicateDescriptor.m */, 0A2D8DA6289BC905008720F6 /* SentryViewHierarchy.h */, 0A2D8DA7289BC905008720F6 /* SentryViewHierarchy.m */, + D83D07992B7F9D1C00CC9674 /* SentryMsgPackSerializer.h */, + D83D079A2B7F9D1C00CC9674 /* SentryMsgPackSerializer.m */, ); name = Tools; sourceTree = ""; @@ -3376,6 +3401,15 @@ path = Swift; sourceTree = ""; }; + D80694C12B7CC85800B820E6 /* SessionReplay */ = { + isa = PBXGroup; + children = ( + D80694C22B7CC86E00B820E6 /* SentryReplayEventTests.swift */, + D80694C52B7CCFA100B820E6 /* SentryReplayRecordingTests.swift */, + ); + path = SessionReplay; + sourceTree = ""; + }; D808FB85281AB2EF009A2A33 /* UIEvents */ = { isa = PBXGroup; children = ( @@ -3388,6 +3422,12 @@ D80CD8D52B752FD9002F710B /* SessionReplay */ = { isa = PBXGroup; children = ( + D86B7B5A2B7A529C0017E8D9 /* SentryReplayEvent.h */, + D86B7B5B2B7A529C0017E8D9 /* SentryReplayEvent.m */, + D80694CB2B7E0A3E00B820E6 /* SentryReplayType.h */, + D80694CC2B7E0A3E00B820E6 /* SentryReplayType.m */, + D88D6C1B2B7B5A8800C8C633 /* SentryReplayRecording.h */, + D88D6C1C2B7B5A8800C8C633 /* SentryReplayRecording.m */, ); name = SessionReplay; sourceTree = ""; @@ -3430,6 +3470,7 @@ D84541172A2DC2CD00E2B11C /* SentryBinaryImageCacheTests.swift */, D84541192A2DC55100E2B11C /* SentryBinaryImageCache+Private.h */, D8292D7C2A39A027009872F7 /* UrlSanitizedTests.swift */, + D8F8F5562B835BC600AC5465 /* SentryMsgPackSerializerTests.m */, ); name = Tools; sourceTree = ""; @@ -3642,6 +3683,7 @@ 7B98D7E425FB7A7200C5A389 /* SentryAppState.h in Headers */, 7BDEAA022632A4580001EA25 /* SentryOptions+Private.h in Headers */, A8AFFCCD29069C3E00967CD7 /* SentryHttpStatusCodeRange.h in Headers */, + D83D079B2B7F9D1C00CC9674 /* SentryMsgPackSerializer.h in Headers */, D84F833D2A1CC401005828E0 /* SentrySwiftAsyncIntegration.h in Headers */, 15E0A8EA240F2C9000F044E3 /* SentrySerialization.h in Headers */, 63FE70EF20DA4C1000CDBAE8 /* SentryCrashMonitor_AppState.h in Headers */, @@ -3677,6 +3719,7 @@ 7B7D873224864BB900D2ECFF /* SentryCrashMachineContextWrapper.h in Headers */, 861265F92404EC1500C4AFDE /* NSArray+SentrySanitize.h in Headers */, 63FE712320DA4C1000CDBAE8 /* SentryCrashID.h in Headers */, + D88D6C1D2B7B5A8800C8C633 /* SentryReplayRecording.h in Headers */, 7DC27EC523997EB7006998B5 /* SentryAutoBreadcrumbTrackingIntegration.h in Headers */, 63FE707F20DA4C1000CDBAE8 /* SentryCrashVarArgs.h in Headers */, 03F84D2627DD414C008FE43F /* SentryThreadMetadataCache.hpp in Headers */, @@ -3788,10 +3831,12 @@ D8AB40DB2806EC1900E5E9F7 /* SentryScreenshotIntegration.h in Headers */, 7B3B83722833832B0001FDEB /* SentrySpanOperations.h in Headers */, 7BF9EF722722A84800B5BBEF /* SentryClassRegistrator.h in Headers */, + D86B7B5C2B7A529C0017E8D9 /* SentryReplayEvent.h in Headers */, 63FE715520DA4C1100CDBAE8 /* SentryCrashStackCursor_MachineContext.h in Headers */, 62E081A929ED4260000F69FC /* SentryBreadcrumbDelegate.h in Headers */, 15360CF02433A16D00112302 /* SentryInstallation.h in Headers */, 63FE714720DA4C1100CDBAE8 /* SentryCrashMachineContext.h in Headers */, + D80694CD2B7E0A3E00B820E6 /* SentryReplayType.h in Headers */, 7BA61CAB247BA98100C130A8 /* SentryDebugImageProvider.h in Headers */, 7BC63F0828081242009D9E37 /* SentrySwizzleWrapper.h in Headers */, 638DC9A01EBC6B6400A66E41 /* SentryRequestOperation.h in Headers */, @@ -4249,6 +4294,7 @@ 7B56D73324616D9500B842DA /* SentryConcurrentRateLimitsDictionary.m in Sources */, 8ECC674825C23A20000E2BF6 /* SentryTransaction.m in Sources */, 0A80E433291017C300095219 /* SentryWatchdogTerminationScopeObserver.m in Sources */, + D88D6C1E2B7B5A8800C8C633 /* SentryReplayRecording.m in Sources */, 7BECF42826145CD900D9826E /* SentryMechanismMeta.m in Sources */, 8E7C982F2693D56000E6336C /* SentryTraceHeader.m in Sources */, 63FE715F20DA4C1100CDBAE8 /* SentryCrashID.c in Sources */, @@ -4271,6 +4317,7 @@ 7BE1E33424F7E3CB009D3AD0 /* SentryMigrateSessionInit.m in Sources */, 15E0A8F22411A45A00F044E3 /* SentrySession.m in Sources */, 844EDCE62947DC3100C86F34 /* SentryNSTimerFactory.m in Sources */, + D83D079C2B7F9D1C00CC9674 /* SentryMsgPackSerializer.m in Sources */, 7B6D1261265F784000C9BE4B /* PrivateSentrySDKOnly.mm in Sources */, 63BE85711ECEC6DE00DC44F5 /* NSDate+SentryExtras.m in Sources */, 7BD4BD4927EB2A5D0071F4FF /* SentryDiscardedEvent.m in Sources */, @@ -4279,6 +4326,7 @@ 7B127B0F27CF6F4700A71ED2 /* SentryANRTrackingIntegration.m in Sources */, 62C316832B1F2EA1000D7031 /* SentryDelayedFramesTracker.m in Sources */, D8BFE37329A3782F002E73F3 /* SentryTimeToDisplayTracker.m in Sources */, + D80694CE2B7E0A3E00B820E6 /* SentryReplayType.m in Sources */, 15360CCF2432777500112302 /* SentrySessionTracker.m in Sources */, 6334314320AD9AE40077E581 /* SentryMechanism.m in Sources */, 63FE70D320DA4C1000CDBAE8 /* SentryCrashMonitor_AppState.c in Sources */, @@ -4352,6 +4400,7 @@ 861265FA2404EC1500C4AFDE /* NSArray+SentrySanitize.m in Sources */, D8603DD6284F8497000E1227 /* SentryBaggage.m in Sources */, 63FE711520DA4C1000CDBAE8 /* SentryCrashJSONCodec.c in Sources */, + D86B7B5D2B7A529C0017E8D9 /* SentryReplayEvent.m in Sources */, 03F84D3327DD4191008FE43F /* SentryMachLogging.cpp in Sources */, 84F993C42A62A74000EC0190 /* SentryCurrentDateProvider.m in Sources */, D85852BA27EDDC5900C6D8AE /* SentryUIApplication.m in Sources */, @@ -4414,6 +4463,7 @@ 7B3B473E25D6CEA500D01640 /* SentryNSErrorTests.swift in Sources */, 632331F62404FFA8008D91D6 /* SentryScopeTests.m in Sources */, D808FB88281AB33C009A2A33 /* SentryUIEventTrackerTests.swift in Sources */, + D8F8F5572B835BC600AC5465 /* SentryMsgPackSerializerTests.m in Sources */, 0A283E79291A67E000EF4126 /* SentryUIDeviceWrapperTests.swift in Sources */, 63FE720D20DA66EC00CDBAE8 /* NSError+SimpleConstructor_Tests.m in Sources */, 69BEE6F72620729E006DF9DF /* UrlSessionDelegateSpy.swift in Sources */, @@ -4447,6 +4497,7 @@ D8137D54272B53070082656C /* TestSentrySpan.m in Sources */, 7BECF432261463E600D9826E /* SentryMechanismMetaTests.swift in Sources */, 7BE8E8462593313500C4DA1F /* SentryAttachment+Equality.m in Sources */, + D80694C72B7CD22B00B820E6 /* SentryReplayRecordingTests.swift in Sources */, 63FE721F20DA66EC00CDBAE8 /* SentryCrashSignalInfo_Tests.m in Sources */, 0ADC33F128D9BE940078D980 /* TestSentryUIDeviceWrapper.swift in Sources */, 63FE721420DA66EC00CDBAE8 /* SentryCrashMemory_Tests.m in Sources */, @@ -4537,6 +4588,7 @@ 7BD4BD4B27EB2DC20071F4FF /* SentryDiscardedEventTests.swift in Sources */, 63FE721A20DA66EC00CDBAE8 /* SentryCrashSysCtl_Tests.m in Sources */, 7B88F30424BC8E6500ADF90A /* SentrySerializationTests.swift in Sources */, + D80694C42B7CC9AE00B820E6 /* SentryReplayEventTests.swift in Sources */, 7B34721728086A9D0041F047 /* SentrySwizzleWrapperTests.swift in Sources */, 8EC4CF5025C3A0070093DEE9 /* SentrySpanContextTests.swift in Sources */, 7BE0DC2F272ABAF6004FA8B7 /* SentryAutoBreadcrumbTrackingIntegrationTests.swift in Sources */, diff --git a/Sources/Sentry/SentryClient.m b/Sources/Sentry/SentryClient.m index d7435185011..a5ec52eb94a 100644 --- a/Sources/Sentry/SentryClient.m +++ b/Sources/Sentry/SentryClient.m @@ -13,7 +13,7 @@ #import "SentryDependencyContainer.h" #import "SentryDispatchQueueWrapper.h" #import "SentryDsn.h" -#import "SentryEnvelope.h" +#import "SentryEnvelope+Private.h" #import "SentryEnvelopeItemType.h" #import "SentryEvent.h" #import "SentryException.h" @@ -30,12 +30,15 @@ #import "SentryMechanismMeta.h" #import "SentryMessage.h" #import "SentryMeta.h" +#import "SentryMsgPackSerializer.h" #import "SentryNSError.h" #import "SentryOptions+Private.h" #import "SentryPropagationContext.h" #import "SentryRandom.h" +#import "SentryReplayEvent.h" #import "SentrySDK+Private.h" #import "SentryScope+Private.h" +#import "SentrySerialization.h" #import "SentrySession.h" #import "SentryStacktraceBuilder.h" #import "SentrySwift.h" @@ -472,13 +475,46 @@ - (void)captureSession:(SentrySession *)session } SentryEnvelopeItem *item = [[SentryEnvelopeItem alloc] initWithSession:session]; - SentryEnvelopeHeader *envelopeHeader = [[SentryEnvelopeHeader alloc] initWithId:nil - traceContext:nil]; - SentryEnvelope *envelope = [[SentryEnvelope alloc] initWithHeader:envelopeHeader + SentryEnvelope *envelope = [[SentryEnvelope alloc] initWithHeader:[SentryEnvelopeHeader empty] singleItem:item]; [self captureEnvelope:envelope]; } +- (void)captureReplayEvent:(SentryReplayEvent *)replayEvent + replayRecording:(SentryReplayRecording *)replayRecording + video:(NSURL *)videoURL + withScope:(SentryScope *)scope +{ + replayEvent = (SentryReplayEvent *)[self prepareEvent:replayEvent + withScope:scope + alwaysAttachStacktrace:NO]; + + if (![replayEvent isKindOfClass:SentryReplayEvent.class]) { + SENTRY_LOG_DEBUG(@"The event preprocessor didn't update the replay event in place. The " + @"replay was discarded."); + return; + } + + // breadcrumbs for replay will be send with ReplayRecording + replayEvent.breadcrumbs = nil; + + SentryEnvelopeItem *videoEnvelopeItem = + [[SentryEnvelopeItem alloc] initWithReplayEvent:replayEvent + replayRecording:replayRecording + video:videoURL]; + + if (videoEnvelopeItem == nil) { + SENTRY_LOG_DEBUG(@"The Session Replay segment will not be sent to Sentry because an " + @"Envelope Item could not be created."); + return; + } + + SentryEnvelope *envelope = [[SentryEnvelope alloc] initWithHeader:[SentryEnvelopeHeader empty] + items:@[ videoEnvelopeItem ]]; + + [self captureEnvelope:envelope]; +} + - (void)captureEnvelope:(SentryEnvelope *)envelope { if ([self isDisabled]) { diff --git a/Sources/Sentry/SentryDataCategoryMapper.m b/Sources/Sentry/SentryDataCategoryMapper.m index ed7df3829f5..820afa56502 100644 --- a/Sources/Sentry/SentryDataCategoryMapper.m +++ b/Sources/Sentry/SentryDataCategoryMapper.m @@ -11,6 +11,7 @@ NSString *const kSentryDataCategoryNameAttachment = @"attachment"; NSString *const kSentryDataCategoryNameUserFeedback = @"user_report"; NSString *const kSentryDataCategoryNameProfile = @"profile"; +NSString *const kSentryDataCategoryNameReplay = @"replay"; NSString *const kSentryDataCategoryNameUnknown = @"unknown"; NS_ASSUME_NONNULL_BEGIN @@ -33,6 +34,9 @@ if ([itemType isEqualToString:SentryEnvelopeItemTypeProfile]) { return kSentryDataCategoryProfile; } + if ([itemType isEqualToString:SentryEnvelopeItemTypeReplayVideo]) { + return kSentryDataCategoryReplay; + } return kSentryDataCategoryDefault; } @@ -73,6 +77,9 @@ if ([value isEqualToString:kSentryDataCategoryNameProfile]) { return kSentryDataCategoryProfile; } + if ([value isEqualToString:kSentryDataCategoryNameReplay]) { + return kSentryDataCategoryReplay; + } return kSentryDataCategoryUnknown; } @@ -103,6 +110,8 @@ return kSentryDataCategoryNameProfile; case kSentryDataCategoryUnknown: return kSentryDataCategoryNameUnknown; + case kSentryDataCategoryReplay: + return kSentryDataCategoryNameReplay; } } diff --git a/Sources/Sentry/SentryDateUtil.m b/Sources/Sentry/SentryDateUtil.m index f362b345fc1..299805e48e4 100644 --- a/Sources/Sentry/SentryDateUtil.m +++ b/Sources/Sentry/SentryDateUtil.m @@ -38,6 +38,11 @@ + (NSDate *_Nullable)getMaximumDate:(NSDate *_Nullable)first andOther:(NSDate *_ } } ++ (long)millisecondsSince1970:(NSDate *)date +{ + return (NSInteger)([date timeIntervalSince1970] * 1000); +} + @end NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/SentryEnvelope.m b/Sources/Sentry/SentryEnvelope.m index fbe35f5c2d7..e6752312dd6 100644 --- a/Sources/Sentry/SentryEnvelope.m +++ b/Sources/Sentry/SentryEnvelope.m @@ -9,6 +9,9 @@ #import "SentryLog.h" #import "SentryMessage.h" #import "SentryMeta.h" +#import "SentryMsgPackSerializer.h" +#import "SentryReplayEvent.h" +#import "SentryReplayRecording.h" #import "SentrySdkInfo.h" #import "SentrySerialization.h" #import "SentrySession.h" @@ -48,6 +51,11 @@ - (instancetype)initWithId:(nullable SentryId *)eventId return self; } ++ (instancetype)empty +{ + return [[SentryEnvelopeHeader alloc] initWithId:nil traceContext:nil]; +} + @end @implementation SentryEnvelopeItem @@ -198,6 +206,37 @@ - (_Nullable instancetype)initWithAttachment:(SentryAttachment *)attachment return [self initWithHeader:itemHeader data:data]; } +- (nullable instancetype)initWithReplayEvent:(SentryReplayEvent *)replayEvent + replayRecording:(SentryReplayRecording *)replayRecording + video:(NSURL *)videoURL +{ + NSData *replayEventData = [SentrySerialization dataWithJSONObject:[replayEvent serialize]]; + NSMutableData *recording = [NSMutableData data]; + [recording appendData:[SentrySerialization + dataWithJSONObject:[replayRecording headerForReplayRecording]]]; + [recording appendData:[SentrySerialization dataWithJSONObject:[replayRecording serialize]]]; + + NSURL *envelopeContentUrl = + [[videoURL URLByDeletingPathExtension] URLByAppendingPathExtension:@"dat"]; + + BOOL success = [SentryMsgPackSerializer serializeDictionaryToMessagePack:@{ + @"replay_event" : replayEventData, + @"replay_recording" : recording, + @"replay_video" : videoURL + } + intoFile:envelopeContentUrl]; + if (success == NO) { + SENTRY_LOG_DEBUG(@"Could not create MessagePack for session replay envelope item."); + return nil; + } + + NSData *envelopeItemContent = [NSData dataWithContentsOfURL:envelopeContentUrl]; + return [self initWithHeader:[[SentryEnvelopeItemHeader alloc] + initWithType:SentryEnvelopeItemTypeReplayVideo + length:envelopeItemContent.length] + data:envelopeItemContent]; +} + @end @implementation SentryEnvelope diff --git a/Sources/Sentry/SentryHub.m b/Sources/Sentry/SentryHub.m index ef1727fb722..17e0cba9d45 100644 --- a/Sources/Sentry/SentryHub.m +++ b/Sources/Sentry/SentryHub.m @@ -297,6 +297,16 @@ - (SentryId *)captureEvent:(SentryEvent *)event return SentryId.empty; } +- (void)captureReplayEvent:(SentryReplayEvent *)replayEvent + replayRecording:(SentryReplayRecording *)replayRecording + video:(NSURL *)videoURL +{ + [_client captureReplayEvent:replayEvent + replayRecording:replayRecording + video:videoURL + withScope:self.scope]; +} + - (id)startTransactionWithName:(NSString *)name operation:(NSString *)operation { return [self startTransactionWithContext:[[SentryTransactionContext alloc] diff --git a/Sources/Sentry/SentryMsgPackSerializer.m b/Sources/Sentry/SentryMsgPackSerializer.m new file mode 100644 index 00000000000..1bbe76e027b --- /dev/null +++ b/Sources/Sentry/SentryMsgPackSerializer.m @@ -0,0 +1,110 @@ +#import "SentryMsgPackSerializer.h" +#import "SentryLog.h" + +@implementation SentryMsgPackSerializer + ++ (BOOL)serializeDictionaryToMessagePack: + (NSDictionary> *)dictionary + intoFile:(NSURL *)path +{ + NSOutputStream *outputStream = [[NSOutputStream alloc] initWithURL:path append:NO]; + [outputStream open]; + + uint8_t mapHeader = (uint8_t)(0x80 | dictionary.count); // Map up to 15 elements + [outputStream write:&mapHeader maxLength:sizeof(uint8_t)]; + + for (NSString *key in dictionary) { + id value = dictionary[key]; + + NSData *keyData = [key dataUsingEncoding:NSUTF8StringEncoding]; + uint8_t str8Header = (uint8_t)0xD9; // String up to 255 characters + uint8_t keyLength = (uint8_t)keyData.length; + [outputStream write:&str8Header maxLength:sizeof(uint8_t)]; + [outputStream write:&keyLength maxLength:sizeof(uint8_t)]; + + [outputStream write:keyData.bytes maxLength:keyData.length]; + + NSInteger dataLength = [value streamSize]; + if (dataLength <= 0) { + // MsgPack is being used strictly for session replay. + // An item with a length of 0 will not be useful. + // If we plan to use MsgPack for something else, + // this needs to be re-evaluated. + SENTRY_LOG_DEBUG(@"Data for MessagePack dictionary has no content - Input: %@", value); + return NO; + } + + uint32_t valueLength = (uint32_t)dataLength; + // We will always use the 4 bytes data length for simplicity. + // Worst case we're losing 3 bytes. + uint8_t bin32Header = (uint8_t)0xC6; + [outputStream write:&bin32Header maxLength:sizeof(uint8_t)]; + valueLength = NSSwapHostIntToBig(valueLength); + [outputStream write:(uint8_t *)&valueLength maxLength:sizeof(uint32_t)]; + + NSInputStream *inputStream = [value asInputStream]; + [inputStream open]; + + uint8_t buffer[1024]; + NSInteger bytesRead; + + while ([inputStream hasBytesAvailable]) { + bytesRead = [inputStream read:buffer maxLength:sizeof(buffer)]; + if (bytesRead > 0) { + [outputStream write:buffer maxLength:bytesRead]; + } else if (bytesRead < 0) { + SENTRY_LOG_DEBUG(@"Error reading bytes from input stream - Input: %@ - %li", value, + (long)bytesRead); + + [inputStream close]; + [outputStream close]; + return NO; + } + } + + [inputStream close]; + } + [outputStream close]; + + return YES; +} + +@end + +@implementation +NSURL (SentryStreameble) + +- (NSInputStream *)asInputStream +{ + return [[NSInputStream alloc] initWithURL:self]; +} + +- (NSInteger)streamSize +{ + NSFileManager *fileManager = [NSFileManager defaultManager]; + NSError *error; + NSDictionary *attributes = [fileManager attributesOfItemAtPath:self.path error:&error]; + if (attributes == nil) { + SENTRY_LOG_DEBUG(@"Could not read file attributes - File: %@ - %@", self, error); + return -1; + } + NSNumber *fileSize = attributes[NSFileSize]; + return [fileSize unsignedIntegerValue]; +} + +@end + +@implementation +NSData (SentryStreameble) + +- (NSInputStream *)asInputStream +{ + return [[NSInputStream alloc] initWithData:self]; +} + +- (NSInteger)streamSize +{ + return self.length; +} + +@end diff --git a/Sources/Sentry/SentryReplayEvent.m b/Sources/Sentry/SentryReplayEvent.m new file mode 100644 index 00000000000..c7298e03c49 --- /dev/null +++ b/Sources/Sentry/SentryReplayEvent.m @@ -0,0 +1,41 @@ +#import "SentryReplayEvent.h" +#import "SentryDateUtil.h" +#import "SentryEnvelopeItemType.h" +#import "SentryId.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation SentryReplayEvent + +- (instancetype)init +{ + if (self = [super init]) { + self.type = SentryEnvelopeItemTypeReplayVideo; + } + return self; +} + +- (NSDictionary *)serialize +{ + NSMutableDictionary *result = [[super serialize] mutableCopy]; + + NSMutableArray *trace_ids = [[NSMutableArray alloc] initWithCapacity:self.traceIds.count]; + + for (SentryId *traceId in self.traceIds) { + [trace_ids addObject:traceId.sentryIdString]; + } + + result[@"urls"] = self.urls; + result[@"replay_start_timestamp"] = + @([SentryDateUtil millisecondsSince1970:self.replayStartTimestamp]); + result[@"trace_ids"] = trace_ids; + result[@"replay_id"] = self.replayId.sentryIdString; + result[@"segment_id"] = @(self.segmentId); + result[@"replay_type"] = nameForSentryReplayType(self.replayType); + + return result; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/SentryReplayRecording.m b/Sources/Sentry/SentryReplayRecording.m new file mode 100644 index 00000000000..059ac1bfff7 --- /dev/null +++ b/Sources/Sentry/SentryReplayRecording.m @@ -0,0 +1,75 @@ +#import "SentryReplayRecording.h" +#import "SentryDateUtil.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation SentryReplayRecording + +- (instancetype)initWithSegmentId:(NSInteger)segmentId + size:(NSInteger)size + start:(NSDate *)start + duration:(NSTimeInterval)duration + frameCount:(NSInteger)frameCount + frameRate:(NSInteger)frameRate + height:(NSInteger)height + width:(NSInteger)width +{ + if (self = [super init]) { + self.segmentId = segmentId; + self.size = size; + self.start = start; + self.duration = duration; + self.frameCount = frameCount; + self.frameRate = frameRate; + self.height = height; + self.width = width; + } + return self; +} + +- (NSDictionary *)headerForReplayRecording +{ + return @{ @"segment_id" : @(self.segmentId) }; +} + +- (NSArray *> *)serialize +{ + + long timestamp = [SentryDateUtil millisecondsSince1970:self.start]; + + // This format is defined by RRWeb + // empty values are required by the format + NSDictionary *metaInfo = @{ + @"type" : @4, + @"timestamp" : @(timestamp), + @"data" : @ { @"href" : @"", @"height" : @(self.height), @"width" : @(self.width) } + }; + + NSDictionary *recordingInfo = @{ + @"type" : @5, + @"timestamp" : @(timestamp), + @"data" : @ { + @"tag" : @"video", + @"payload" : @ { + @"segmentId" : @(self.segmentId), + @"size" : @(self.size), + @"duration" : @(self.duration), + @"encoding" : SentryReplayEncoding, + @"container" : SentryReplayContainer, + @"height" : @(self.height), + @"width" : @(self.width), + @"frameCount" : @(self.frameCount), + @"frameRateType" : SentryReplayFrameRateType, + @"frameRate" : @(self.frameRate), + @"left" : @0, + @"top" : @0, + } + } + }; + + return @[ metaInfo, recordingInfo ]; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/SentryReplayType.m b/Sources/Sentry/SentryReplayType.m new file mode 100644 index 00000000000..c4d200310f7 --- /dev/null +++ b/Sources/Sentry/SentryReplayType.m @@ -0,0 +1,14 @@ +#import "SentryReplayType.h" + +NSString *const kSentryReplayTypeNameBuffer = @"buffer"; +NSString *const kSentryReplayTypeNameSession = @"session"; + +NSString *_Nonnull nameForSentryReplayType(SentryReplayType replayType) +{ + switch (replayType) { + case kSentryReplayTypeBuffer: + return kSentryReplayTypeNameBuffer; + case kSentryReplayTypeSession: + return kSentryReplayTypeNameSession; + } +} diff --git a/Sources/Sentry/SentrySerialization.m b/Sources/Sentry/SentrySerialization.m index 27fe09500f0..6e16cb05b97 100644 --- a/Sources/Sentry/SentrySerialization.m +++ b/Sources/Sentry/SentrySerialization.m @@ -16,15 +16,15 @@ @implementation SentrySerialization -+ (NSData *_Nullable)dataWithJSONObject:(NSDictionary *)dictionary ++ (NSData *_Nullable)dataWithJSONObject:(id)jsonObject { - if (![NSJSONSerialization isValidJSONObject:dictionary]) { + if (![NSJSONSerialization isValidJSONObject:jsonObject]) { SENTRY_LOG_ERROR(@"Dictionary is not a valid JSON object."); return nil; } NSError *error = nil; - NSData *data = [NSJSONSerialization dataWithJSONObject:dictionary options:0 error:&error]; + NSData *data = [NSJSONSerialization dataWithJSONObject:jsonObject options:0 error:&error]; if (error) { SENTRY_LOG_ERROR(@"Internal error while serializing JSON: %@", error); } diff --git a/Sources/Sentry/include/HybridPublic/SentryEnvelope.h b/Sources/Sentry/include/HybridPublic/SentryEnvelope.h index 8006c6d07ac..4d7efdafe85 100644 --- a/Sources/Sentry/include/HybridPublic/SentryEnvelope.h +++ b/Sources/Sentry/include/HybridPublic/SentryEnvelope.h @@ -68,6 +68,8 @@ SENTRY_NO_INIT */ @property (nullable, nonatomic, copy) NSDate *sentAt; ++ (instancetype)empty; + @end @interface SentryEnvelopeItem : NSObject diff --git a/Sources/Sentry/include/HybridPublic/SentryEnvelopeItemType.h b/Sources/Sentry/include/HybridPublic/SentryEnvelopeItemType.h index d999cfdd47e..b0ad1fc0b3f 100644 --- a/Sources/Sentry/include/HybridPublic/SentryEnvelopeItemType.h +++ b/Sources/Sentry/include/HybridPublic/SentryEnvelopeItemType.h @@ -5,3 +5,4 @@ static NSString *const SentryEnvelopeItemTypeTransaction = @"transaction"; static NSString *const SentryEnvelopeItemTypeAttachment = @"attachment"; static NSString *const SentryEnvelopeItemTypeClientReport = @"client_report"; static NSString *const SentryEnvelopeItemTypeProfile = @"profile"; +static NSString *const SentryEnvelopeItemTypeReplayVideo = @"replay_video"; diff --git a/Sources/Sentry/include/SentryClient+Private.h b/Sources/Sentry/include/SentryClient+Private.h index a9bcd469818..5bd2d6f3387 100644 --- a/Sources/Sentry/include/SentryClient+Private.h +++ b/Sources/Sentry/include/SentryClient+Private.h @@ -3,7 +3,7 @@ #import "SentryDiscardReason.h" @class SentrySession, SentryEnvelopeItem, SentryId, SentryAttachment, SentryThreadInspector, - SentryEnvelope; + SentryReplayEvent, SentryReplayRecording, SentryEnvelope; NS_ASSUME_NONNULL_BEGIN @@ -42,6 +42,11 @@ SentryClient () additionalEnvelopeItems:(NSArray *)additionalEnvelopeItems NS_SWIFT_NAME(capture(event:scope:additionalEnvelopeItems:)); +- (void)captureReplayEvent:(SentryReplayEvent *)replayEvent + replayRecording:(SentryReplayRecording *)replayRecording + video:(NSURL *)videoURL + withScope:(SentryScope *)scope; + - (void)captureSession:(SentrySession *)session NS_SWIFT_NAME(capture(session:)); /** diff --git a/Sources/Sentry/include/SentryDataCategory.h b/Sources/Sentry/include/SentryDataCategory.h index ff62ecbc21f..9b3a5c1a8cc 100644 --- a/Sources/Sentry/include/SentryDataCategory.h +++ b/Sources/Sentry/include/SentryDataCategory.h @@ -14,7 +14,8 @@ typedef NS_ENUM(NSUInteger, SentryDataCategory) { kSentryDataCategoryAttachment = 5, kSentryDataCategoryUserFeedback = 6, kSentryDataCategoryProfile = 7, - kSentryDataCategoryUnknown = 8 + kSentryDataCategoryReplay = 8, + kSentryDataCategoryUnknown = 9 }; static DEPRECATED_MSG_ATTRIBUTE( @@ -29,5 +30,6 @@ static DEPRECATED_MSG_ATTRIBUTE( @"attachment", @"user_report", @"profile", + @"replay", @"unkown", }; diff --git a/Sources/Sentry/include/SentryDataCategoryMapper.h b/Sources/Sentry/include/SentryDataCategoryMapper.h index 41e4ece8d49..47f2121d40d 100644 --- a/Sources/Sentry/include/SentryDataCategoryMapper.h +++ b/Sources/Sentry/include/SentryDataCategoryMapper.h @@ -11,6 +11,7 @@ FOUNDATION_EXPORT NSString *const kSentryDataCategoryNameTransaction; FOUNDATION_EXPORT NSString *const kSentryDataCategoryNameAttachment; FOUNDATION_EXPORT NSString *const kSentryDataCategoryNameUserFeedback; FOUNDATION_EXPORT NSString *const kSentryDataCategoryNameProfile; +FOUNDATION_EXPORT NSString *const kSentryDataCategoryNameReplay; FOUNDATION_EXPORT NSString *const kSentryDataCategoryNameUnknown; SentryDataCategory sentryDataCategoryForNSUInteger(NSUInteger value); diff --git a/Sources/Sentry/include/SentryDateUtil.h b/Sources/Sentry/include/SentryDateUtil.h index 60c8fdb6562..8cb845c984a 100644 --- a/Sources/Sentry/include/SentryDateUtil.h +++ b/Sources/Sentry/include/SentryDateUtil.h @@ -9,6 +9,8 @@ NS_SWIFT_NAME(DateUtil) + (NSDate *_Nullable)getMaximumDate:(NSDate *_Nullable)first andOther:(NSDate *_Nullable)second; ++ (long)millisecondsSince1970:(NSDate *)date; + @end NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/include/SentryEnvelope+Private.h b/Sources/Sentry/include/SentryEnvelope+Private.h index 98682fa1a2f..a31018b5e29 100644 --- a/Sources/Sentry/include/SentryEnvelope+Private.h +++ b/Sources/Sentry/include/SentryEnvelope+Private.h @@ -2,11 +2,17 @@ NS_ASSUME_NONNULL_BEGIN +@class SentryReplayEvent, SentryReplayRecording; + @interface SentryEnvelopeItem () - (instancetype)initWithClientReport:(SentryClientReport *)clientReport; +- (nullable instancetype)initWithReplayEvent:(SentryReplayEvent *)replayEvent + replayRecording:(SentryReplayRecording *)replayRecording + video:(NSURL *)videoURL; + @end NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/include/SentryHub+Private.h b/Sources/Sentry/include/SentryHub+Private.h index f37cd70115b..4ec899c44b5 100644 --- a/Sources/Sentry/include/SentryHub+Private.h +++ b/Sources/Sentry/include/SentryHub+Private.h @@ -10,6 +10,8 @@ @class SentrySession; @class SentryTracer; @class SentryTracerConfiguration; +@class SentryReplayEvent; +@class SentryReplayRecording; NS_ASSUME_NONNULL_BEGIN @@ -33,6 +35,10 @@ SentryHub () - (void)captureCrashEvent:(SentryEvent *)event withScope:(SentryScope *)scope; +- (void)captureReplayEvent:(SentryReplayEvent *)replayEvent + replayRecording:(SentryReplayRecording *)replayRecording + video:(NSURL *)videoURL; + - (void)closeCachedSessionWithTimestamp:(NSDate *_Nullable)timestamp; - (SentryTracer *)startTransactionWithContext:(SentryTransactionContext *)transactionContext diff --git a/Sources/Sentry/include/SentryMsgPackSerializer.h b/Sources/Sentry/include/SentryMsgPackSerializer.h new file mode 100644 index 00000000000..d6a1485e372 --- /dev/null +++ b/Sources/Sentry/include/SentryMsgPackSerializer.h @@ -0,0 +1,33 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@protocol SentryStreamable + +- (NSInputStream *)asInputStream; + +- (NSInteger)streamSize; + +@end + +/** + * This is a partial implementation of the MessagePack format. + * We only need to concatenate a list of NSData into an envelope item. + */ +@interface SentryMsgPackSerializer : NSObject + ++ (BOOL)serializeDictionaryToMessagePack: + (NSDictionary> *)dictionary + intoFile:(NSURL *)path; + +@end + +@interface +NSData (inputStreameble) +@end + +@interface +NSURL (inputStreameble) +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/include/SentryReplayEvent.h b/Sources/Sentry/include/SentryReplayEvent.h new file mode 100644 index 00000000000..ef20250097d --- /dev/null +++ b/Sources/Sentry/include/SentryReplayEvent.h @@ -0,0 +1,45 @@ +#import "SentryEvent.h" +#import "SentryReplayType.h" +#import + +NS_ASSUME_NONNULL_BEGIN + +@class SentryId; + +@interface SentryReplayEvent : SentryEvent + +/** + * Start time of the replay segment + */ +@property (nonatomic, strong) NSDate *replayStartTimestamp; + +/** + * Number of the segment in the replay. + * This is an incremental number + */ +@property (nonatomic) NSInteger segmentId; + +/** + * This will be used to store the name of the screens + * that appear during the duration of the replay segment. + */ +@property (nonatomic, strong) NSArray *urls; + +/** + * Trace ids happening during the duration of the replay segment. + */ +@property (nonatomic, strong) NSArray *traceIds; + +/** + * The replay id to which this segment belongs to. + */ +@property (nonatomic, strong) SentryId *replayId; + +/** + * The type of the replay + */ +@property (nonatomic) SentryReplayType replayType; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/include/SentryReplayRecording.h b/Sources/Sentry/include/SentryReplayRecording.h new file mode 100644 index 00000000000..c4b402a6db8 --- /dev/null +++ b/Sources/Sentry/include/SentryReplayRecording.h @@ -0,0 +1,46 @@ +#import "SentrySerializable.h" +#import + +NS_ASSUME_NONNULL_BEGIN + +static NSString *const SentryReplayEncoding = @"h264"; +static NSString *const SentryReplayContainer = @"mp4"; +static NSString *const SentryReplayFrameRateType = @"constant"; + +@interface SentryReplayRecording : NSObject + +@property (nonatomic) NSInteger segmentId; + +/** + * Video file size + */ +@property (nonatomic) NSInteger size; + +@property (nonatomic, strong) NSDate *start; + +@property (nonatomic) NSTimeInterval duration; + +@property (nonatomic) NSInteger frameCount; + +@property (nonatomic) NSInteger frameRate; + +@property (nonatomic) NSInteger height; + +@property (nonatomic) NSInteger width; + +- (instancetype)initWithSegmentId:(NSInteger)segmentId + size:(NSInteger)size + start:(NSDate *)start + duration:(NSTimeInterval)duration + frameCount:(NSInteger)frameCount + frameRate:(NSInteger)frameRate + height:(NSInteger)height + width:(NSInteger)width; + +- (NSArray *> *)serialize; + +- (NSDictionary *)headerForReplayRecording; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/include/SentryReplayType.h b/Sources/Sentry/include/SentryReplayType.h new file mode 100644 index 00000000000..93c018806b5 --- /dev/null +++ b/Sources/Sentry/include/SentryReplayType.h @@ -0,0 +1,16 @@ + +#import + +NS_ASSUME_NONNULL_BEGIN + +typedef NS_ENUM(NSInteger, SentryReplayType) { + kSentryReplayTypeBuffer = 0, // Replay triggered by an action + kSentryReplayTypeSession // Full session replay +}; + +FOUNDATION_EXPORT NSString *const kSentryReplayTypeNameBuffer; +FOUNDATION_EXPORT NSString *const kSentryReplayTypeNameSession; + +NSString *nameForSentryReplayType(SentryReplayType replayType); + +NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/include/SentrySerialization.h b/Sources/Sentry/include/SentrySerialization.h index fbfcec32e4d..704e9b5cfd7 100644 --- a/Sources/Sentry/include/SentrySerialization.h +++ b/Sources/Sentry/include/SentrySerialization.h @@ -8,7 +8,7 @@ static int const SENTRY_BAGGAGE_MAX_SIZE = 8192; @interface SentrySerialization : NSObject -+ (NSData *_Nullable)dataWithJSONObject:(NSDictionary *)dictionary; ++ (NSData *_Nullable)dataWithJSONObject:(id)jsonObject; + (NSData *_Nullable)dataWithSession:(SentrySession *)session; diff --git a/Tests/SentryTests/Helper/SentryDateUtilTests.swift b/Tests/SentryTests/Helper/SentryDateUtilTests.swift index 507b1a3b3ad..50096006244 100644 --- a/Tests/SentryTests/Helper/SentryDateUtilTests.swift +++ b/Tests/SentryTests/Helper/SentryDateUtilTests.swift @@ -1,3 +1,4 @@ +import Nimble import SentryTestUtils import XCTest @@ -54,4 +55,11 @@ class SentryDateUtilTests: XCTestCase { XCTAssertNil(DateUtil.getMaximumDate(nil, andOther: nil)) } + func testJavascriptDate() { + let testDate = Date(timeIntervalSince1970: 60) + let timestamp = DateUtil.millisecondsSince1970(testDate) + + expect(timestamp) == 60_000 + } + } diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentryReplayEventTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentryReplayEventTests.swift new file mode 100644 index 00000000000..96391581a46 --- /dev/null +++ b/Tests/SentryTests/Integrations/SessionReplay/SentryReplayEventTests.swift @@ -0,0 +1,30 @@ +import Foundation +import Nimble +import XCTest + +class SentryReplayEventTests: XCTestCase { + + func test_Serialize() { + let sut = SentryReplayEvent() + sut.urls = ["Screen 1", "Screen 2"] + sut.replayStartTimestamp = Date(timeIntervalSince1970: 1) + + let traceIds = [SentryId(), SentryId()] + sut.traceIds = traceIds + + let replayId = SentryId() + sut.replayId = replayId + + sut.segmentId = 3 + + let result = sut.serialize() + + expect(result["urls"] as? [String]) == ["Screen 1", "Screen 2"] + expect(result["replay_start_timestamp"] as? Int) == 1_000 + expect(result["trace_ids"] as? [String]) == [ traceIds[0].sentryIdString, traceIds[1].sentryIdString] + expect(result["replay_id"] as? String) == replayId.sentryIdString + expect(result["segment_id"] as? Int) == 3 + expect(result["replay_type"] as? String) == "buffer" + } + +} diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentryReplayRecordingTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentryReplayRecordingTests.swift new file mode 100644 index 00000000000..3d8f01c3da3 --- /dev/null +++ b/Tests/SentryTests/Integrations/SessionReplay/SentryReplayRecordingTests.swift @@ -0,0 +1,41 @@ +import Foundation +import Nimble +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 data = sut.serialize() + + let metaInfo = data[0] + let metaInfoData = metaInfo["data"] as? [String: Any] + + let recordingInfo = data[1] + let recordingData = recordingInfo["data"] as? [String: Any] + let recordingPayload = recordingData?["payload"] as? [String: Any] + + expect(metaInfo["type"] as? Int) == 4 + expect(metaInfo["timestamp"] as? Int) == 2_000 + 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" + expect(recordingPayload?["segmentId"] as? Int) == 3 + expect(recordingPayload?["size"] as? Int) == 200 + expect(recordingPayload?["duration"] as? Int) == 5_000 + expect(recordingPayload?["encoding"] as? String) == "h264" + expect(recordingPayload?["container"] as? String) == "mp4" + expect(recordingPayload?["height"] as? Int) == 930 + expect(recordingPayload?["width"] as? Int) == 390 + expect(recordingPayload?["frameCount"] as? Int) == 5 + expect(recordingPayload?["frameRateType"] as? String) == "constant" + expect(recordingPayload?["frameRate"] as? Int) == 1 + expect(recordingPayload?["left"] as? Int) == 0 + expect(recordingPayload?["top"] as? Int) == 0 + } +} diff --git a/Tests/SentryTests/Networking/SentryDataCategoryMapperTests.swift b/Tests/SentryTests/Networking/SentryDataCategoryMapperTests.swift index 40df5a07408..8f31a4595c9 100644 --- a/Tests/SentryTests/Networking/SentryDataCategoryMapperTests.swift +++ b/Tests/SentryTests/Networking/SentryDataCategoryMapperTests.swift @@ -9,6 +9,7 @@ class SentryDataCategoryMapperTests: XCTestCase { XCTAssertEqual(.attachment, sentryDataCategoryForEnvelopItemType("attachment")) XCTAssertEqual(.profile, sentryDataCategoryForEnvelopItemType("profile")) XCTAssertEqual(.default, sentryDataCategoryForEnvelopItemType("unknown item type")) + XCTAssertEqual(.replay, sentryDataCategoryForEnvelopItemType("replay_video")) } func testMapIntegerToCategory() { @@ -20,9 +21,9 @@ class SentryDataCategoryMapperTests: XCTestCase { XCTAssertEqual(.attachment, sentryDataCategoryForNSUInteger(5)) XCTAssertEqual(.userFeedback, sentryDataCategoryForNSUInteger(6)) XCTAssertEqual(.profile, sentryDataCategoryForNSUInteger(7)) - XCTAssertEqual(.unknown, sentryDataCategoryForNSUInteger(8)) - - XCTAssertEqual(.unknown, sentryDataCategoryForNSUInteger(9), "Failed to map unknown category number to case .unknown") + XCTAssertEqual(.replay, sentryDataCategoryForNSUInteger(8)) + XCTAssertEqual(.unknown, sentryDataCategoryForNSUInteger(9)) + XCTAssertEqual(.unknown, sentryDataCategoryForNSUInteger(10), "Failed to map unknown category number to case .unknown") } func testMapStringToCategory() { diff --git a/Tests/SentryTests/Protocol/SentryEnvelopeTests.swift b/Tests/SentryTests/Protocol/SentryEnvelopeTests.swift index a2903c61179..d365a05283b 100644 --- a/Tests/SentryTests/Protocol/SentryEnvelopeTests.swift +++ b/Tests/SentryTests/Protocol/SentryEnvelopeTests.swift @@ -1,3 +1,4 @@ +import Nimble import SentryTestUtils import XCTest @@ -235,6 +236,12 @@ class SentryEnvelopeTests: XCTestCase { XCTAssertEqual(attachment.contentType, envelopeItem.header.contentType) } + func testEmptyHeader() { + let sut = SentryEnvelopeHeader.empty() + expect(sut.eventId) == nil + expect(sut.traceContext) == nil + } + func testInitWithFileAttachment() { writeDataToFile(data: fixture.data ?? Data()) diff --git a/Tests/SentryTests/SentryClientTests.swift b/Tests/SentryTests/SentryClientTests.swift index 8cccc5efde7..af2baea84d9 100644 --- a/Tests/SentryTests/SentryClientTests.swift +++ b/Tests/SentryTests/SentryClientTests.swift @@ -1571,6 +1571,69 @@ class SentryClientTest: XCTestCase { } } + func testCaptureReplayEvent() { + let sut = fixture.getSut() + let replayEvent = SentryReplayEvent() + replayEvent.segmentId = 2 + let replayRecording = SentryReplayRecording() + replayRecording.segmentId = 2 + + //Not a video url, but its ok for test the envelope + let movieUrl = Bundle(for: self.classForCoder).url(forResource: "Resources/raw", withExtension: "json") + + sut.capture(replayEvent, replayRecording: replayRecording, video: movieUrl!, with: Scope()) + let envelope = fixture.transport.sentEnvelopes.first + expect(envelope?.items[0].header.type) == SentryEnvelopeItemTypeReplayVideo + } + + func testCaptureReplayEvent_WrongEventFromEventProcessor() { + let sut = fixture.getSut() + sut.options.beforeSend = { _ in + return Event() + } + + let replayEvent = SentryReplayEvent() + let replayRecording = SentryReplayRecording() + + let movieUrl = Bundle(for: self.classForCoder).url(forResource: "Resources/raw", withExtension: "json") + sut.capture(replayEvent, replayRecording: replayRecording, video: movieUrl!, with: Scope()) + + //Nothing should be captured because beforeSend returned a non ReplayEvent + expect(self.fixture.transport.sentEnvelopes.count) == 0 + } + + func testCaptureReplayEvent_DontCaptureNilEvent() { + let sut = fixture.getSut() + sut.options.beforeSend = { _ in + return nil + } + + let replayEvent = SentryReplayEvent() + let replayRecording = SentryReplayRecording() + + let movieUrl = Bundle(for: self.classForCoder).url(forResource: "Resources/raw", withExtension: "json") + sut.capture(replayEvent, replayRecording: replayRecording, video: movieUrl!, with: Scope()) + + //Nothing should be captured because beforeSend returned nil + expect(self.fixture.transport.sentEnvelopes.count) == 0 + } + + func testCaptureReplayEvent_InvalidFile() { + let sut = fixture.getSut() + sut.options.beforeSend = { _ in + return nil + } + + let replayEvent = SentryReplayEvent() + let replayRecording = SentryReplayRecording() + + let movieUrl = URL(string: "NoFile")! + sut.capture(replayEvent, replayRecording: replayRecording, video: movieUrl, with: Scope()) + + //Nothing should be captured because beforeSend returned nil + expect(self.fixture.transport.sentEnvelopes.count) == 0 + } + private func givenEventWithDebugMeta() -> Event { let event = Event(level: SentryLevel.fatal) let debugMeta = DebugMeta() diff --git a/Tests/SentryTests/SentryHubTests.swift b/Tests/SentryTests/SentryHubTests.swift index 69cfd78389f..8e21e612779 100644 --- a/Tests/SentryTests/SentryHubTests.swift +++ b/Tests/SentryTests/SentryHubTests.swift @@ -736,6 +736,34 @@ class SentryHubTests: XCTestCase { assertNoEnvelopesCaptured() } + func testCaptureReplay() { + class SentryClientMockReplay: SentryClient { + var replayEvent: SentryReplayEvent? + var replayRecording: SentryReplayRecording? + var videoUrl: URL? + var scope: Scope? + override func capture(_ replayEvent: SentryReplayEvent, replayRecording: SentryReplayRecording, video videoURL: URL, with scope: Scope) { + self.replayEvent = replayEvent + self.replayRecording = replayRecording + self.videoUrl = videoURL + self.scope = scope + } + } + let mockClient = SentryClientMockReplay(options: fixture.options) + + let replayEvent = SentryReplayEvent() + let replayRecording = SentryReplayRecording() + let videoUrl = URL(string: "https://sentry.io")! + + sut.bindClient(mockClient) + sut.capture(replayEvent, replayRecording: replayRecording, video: videoUrl) + + expect(mockClient?.replayEvent) == replayEvent + expect(mockClient?.replayRecording) == replayRecording + expect(mockClient?.videoUrl) == videoUrl + expect(mockClient?.scope) == sut.scope + } + func testCaptureEnvelope_WithSession() { let envelope = SentryEnvelope(session: SentrySession(releaseName: "", distinctId: "")) sut.capture(envelope) diff --git a/Tests/SentryTests/SentryMsgPackSerializerTests.m b/Tests/SentryTests/SentryMsgPackSerializerTests.m new file mode 100644 index 00000000000..6606a1e121f --- /dev/null +++ b/Tests/SentryTests/SentryMsgPackSerializerTests.m @@ -0,0 +1,103 @@ +#import "SentryMsgPackSerializer.h" +#import +#import + +@interface SentryMsgPackSerializerTests : XCTestCase + +@end + +@implementation SentryMsgPackSerializerTests + +- (void)testSerializeNSData +{ + NSURL *tempDirectoryURL = [NSURL fileURLWithPath:NSTemporaryDirectory()]; + NSURL *tempFileURL = [tempDirectoryURL URLByAppendingPathComponent:@"test.dat"]; + + NSDictionary> *dictionary = @{ + @"key1" : [@"Data 1" dataUsingEncoding:NSUTF8StringEncoding], + @"key2" : [@"Data 2" dataUsingEncoding:NSUTF8StringEncoding] + }; + + BOOL result = [SentryMsgPackSerializer serializeDictionaryToMessagePack:dictionary + intoFile:tempFileURL]; + XCTAssertTrue(result); + NSData *tempFile = [NSData dataWithContentsOfURL:tempFileURL]; + [self assertMsgPack:tempFile]; + + [[NSFileManager defaultManager] removeItemAtURL:tempFileURL error:nil]; +} + +- (void)testSerializeURL +{ + NSURL *tempDirectoryURL = [NSURL fileURLWithPath:NSTemporaryDirectory()]; + NSURL *tempFileURL = [tempDirectoryURL URLByAppendingPathComponent:@"test.dat"]; + NSURL *file1URL = [tempDirectoryURL URLByAppendingPathComponent:@"file1.dat"]; + NSURL *file2URL = [tempDirectoryURL URLByAppendingPathComponent:@"file2.dat"]; + + [@"File 1" writeToURL:file1URL atomically:YES encoding:NSUTF8StringEncoding error:nil]; + [@"File 2" writeToURL:file2URL atomically:YES encoding:NSUTF8StringEncoding error:nil]; + + NSDictionary> *dictionary = + @{ @"key1" : file1URL, @"key2" : file2URL }; + + BOOL result = [SentryMsgPackSerializer serializeDictionaryToMessagePack:dictionary + intoFile:tempFileURL]; + XCTAssertTrue(result); + NSData *tempFile = [NSData dataWithContentsOfURL:tempFileURL]; + + [self assertMsgPack:tempFile]; + + [[NSFileManager defaultManager] removeItemAtURL:tempFileURL error:nil]; + [[NSFileManager defaultManager] removeItemAtURL:file1URL error:nil]; + [[NSFileManager defaultManager] removeItemAtURL:file2URL error:nil]; +} + +- (void)testSerializeInvalidFile +{ + NSURL *tempDirectoryURL = [NSURL fileURLWithPath:NSTemporaryDirectory()]; + NSURL *tempFileURL = [tempDirectoryURL URLByAppendingPathComponent:@"test.dat"]; + NSURL *file1URL = [tempDirectoryURL URLByAppendingPathComponent:@"notAFile.dat"]; + + NSDictionary> *dictionary = @{ @"key1" : file1URL }; + + BOOL result = [SentryMsgPackSerializer serializeDictionaryToMessagePack:dictionary + intoFile:tempFileURL]; + XCTAssertFalse(result); +} + +- (void)assertMsgPack:(NSData *)data +{ + NSInputStream *stream = [NSInputStream inputStreamWithData:data]; + [stream open]; + + uint8_t buffer[1024]; + [stream read:buffer maxLength:1]; + + XCTAssertEqual(buffer[0] & 0x80, 0x80); // Assert data is a dictionary + + uint8_t dicSize = buffer[0] & 0x0F; // Gets dictionary length + + for (int i = 0; i < dicSize; i++) { // for each item in the dictionary + [stream read:buffer maxLength:1]; + XCTAssertEqual(buffer[0], (uint8_t)0xD9); // Asserts key is a string of up to 255 + // characteres + [stream read:buffer maxLength:1]; + uint8_t stringLen = buffer[0]; // Gets string length + NSInteger read = [stream read:buffer maxLength:stringLen]; // read the key from the buffer + buffer[read] = 0; // append a null terminator to the string + NSString *key = [NSString stringWithCString:(char *)buffer encoding:NSUTF8StringEncoding]; + XCTAssertEqual(key.length, stringLen); + + [stream read:buffer maxLength:1]; + XCTAssertEqual(buffer[0], (uint8_t)0xC6); + [stream read:buffer maxLength:sizeof(uint32_t)]; + uint32_t dataLen = NSSwapBigIntToHost(*(uint32_t *)buffer); + [stream read:buffer maxLength:dataLen]; + } + + // We should be at the end of the data by now and nothing left to read + NSInteger IsEndOfFile = [stream read:buffer maxLength:1]; + XCTAssertEqual(IsEndOfFile, 0); +} + +@end diff --git a/Tests/SentryTests/SentryTests-Bridging-Header.h b/Tests/SentryTests/SentryTests-Bridging-Header.h index 6b7ae1e43ff..6adb9b2b09f 100644 --- a/Tests/SentryTests/SentryTests-Bridging-Header.h +++ b/Tests/SentryTests/SentryTests-Bridging-Header.h @@ -215,9 +215,12 @@ #import "SentryEnvelopeAttachmentHeader.h" #import "SentryExtraContextProvider.h" #import "SentryMeasurementValue.h" +#import "SentryMsgPackSerializer.h" #import "SentryNSProcessInfoWrapper.h" #import "SentryPerformanceTracker+Testing.h" #import "SentryPropagationContext.h" +#import "SentryReplayEvent.h" +#import "SentryReplayRecording.h" #import "SentrySampleDecision+Private.h" #import "SentrySpanOperations.h" #import "SentryTimeToDisplayTracker.h" From 2c6fff0e01989d7e5922b070a171c8823086d459 Mon Sep 17 00:00:00 2001 From: Dhiogo Brustolin Date: Thu, 29 Feb 2024 11:49:32 +0100 Subject: [PATCH 5/8] Feat(Session Replay): Replay Options (#3674) Settings for session replay in the Options --- Sentry.xcodeproj/project.pbxproj | 10 ++++ Sources/Sentry/Public/Sentry.h | 1 + Sources/Sentry/Public/SentryOptions.h | 12 ++++- Sources/Sentry/Public/SentryReplayOptions.h | 38 ++++++++++++++ Sources/Sentry/SentryBaseIntegration.m | 14 +++++ Sources/Sentry/SentryOptions.m | 14 +++-- Sources/Sentry/SentryReplayOptions.m | 51 +++++++++++++++++++ .../SentryReplayOptions+Private.h | 20 ++++++++ .../Sentry/include/SentryBaseIntegration.h | 1 + Tests/SentryTests/SentryOptionsTest.m | 25 +++++++++ .../SentryTests/SentryTests-Bridging-Header.h | 1 + 11 files changed, 182 insertions(+), 5 deletions(-) create mode 100644 Sources/Sentry/Public/SentryReplayOptions.h create mode 100644 Sources/Sentry/SentryReplayOptions.m create mode 100644 Sources/Sentry/include/HybridPublic/SentryReplayOptions+Private.h diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index 704647195a2..dd90698a65a 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -768,6 +768,8 @@ D8292D7D2A39A027009872F7 /* UrlSanitizedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8292D7C2A39A027009872F7 /* UrlSanitizedTests.swift */; }; D8370B6A273DF1E900F66E2D /* SentryNSURLSessionTaskSearch.m in Sources */ = {isa = PBXBuildFile; fileRef = D8370B68273DF1E900F66E2D /* SentryNSURLSessionTaskSearch.m */; }; D8370B6C273DF20F00F66E2D /* SentryNSURLSessionTaskSearch.h in Headers */ = {isa = PBXBuildFile; fileRef = D8370B6B273DF20F00F66E2D /* SentryNSURLSessionTaskSearch.h */; }; + D83D07812B7E5EFA00CC9674 /* SentryReplayOptions.h in Headers */ = {isa = PBXBuildFile; fileRef = D83D07802B7E5EFA00CC9674 /* SentryReplayOptions.h */; settings = {ATTRIBUTES = (Public, ); }; }; + D83D07832B7E5F2100CC9674 /* SentryReplayOptions.m in Sources */ = {isa = PBXBuildFile; fileRef = D83D07822B7E5F2100CC9674 /* SentryReplayOptions.m */; }; D83D079B2B7F9D1C00CC9674 /* SentryMsgPackSerializer.h in Headers */ = {isa = PBXBuildFile; fileRef = D83D07992B7F9D1C00CC9674 /* SentryMsgPackSerializer.h */; }; D83D079C2B7F9D1C00CC9674 /* SentryMsgPackSerializer.m in Sources */ = {isa = PBXBuildFile; fileRef = D83D079A2B7F9D1C00CC9674 /* SentryMsgPackSerializer.m */; }; D84541182A2DC2CD00E2B11C /* SentryBinaryImageCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D84541172A2DC2CD00E2B11C /* SentryBinaryImageCacheTests.swift */; }; @@ -1774,6 +1776,9 @@ D8292D7C2A39A027009872F7 /* UrlSanitizedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UrlSanitizedTests.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 = ""; }; + D83D07802B7E5EFA00CC9674 /* SentryReplayOptions.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = SentryReplayOptions.h; path = Public/SentryReplayOptions.h; sourceTree = ""; }; + D83D07822B7E5F2100CC9674 /* SentryReplayOptions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SentryReplayOptions.m; sourceTree = ""; }; + D83D07842B7E634F00CC9674 /* SentryReplayOptions+Private.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "SentryReplayOptions+Private.h"; path = "include/HybridPublic/SentryReplayOptions+Private.h"; sourceTree = ""; }; D83D07992B7F9D1C00CC9674 /* SentryMsgPackSerializer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryMsgPackSerializer.h; path = include/SentryMsgPackSerializer.h; sourceTree = ""; }; D83D079A2B7F9D1C00CC9674 /* SentryMsgPackSerializer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryMsgPackSerializer.m; sourceTree = ""; }; D84541172A2DC2CD00E2B11C /* SentryBinaryImageCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryBinaryImageCacheTests.swift; sourceTree = ""; }; @@ -3428,6 +3433,9 @@ D80694CC2B7E0A3E00B820E6 /* SentryReplayType.m */, D88D6C1B2B7B5A8800C8C633 /* SentryReplayRecording.h */, D88D6C1C2B7B5A8800C8C633 /* SentryReplayRecording.m */, + D83D07802B7E5EFA00CC9674 /* SentryReplayOptions.h */, + D83D07842B7E634F00CC9674 /* SentryReplayOptions+Private.h */, + D83D07822B7E5F2100CC9674 /* SentryReplayOptions.m */, ); name = SessionReplay; sourceTree = ""; @@ -3664,6 +3672,7 @@ 03F84D2727DD414C008FE43F /* SentryMachLogging.hpp in Headers */, 63295AF51EF3C7DB002D4490 /* NSDictionary+SentrySanitize.h in Headers */, 8E4A037825F6F52100000D77 /* SentrySampleDecision.h in Headers */, + D83D07812B7E5EFA00CC9674 /* SentryReplayOptions.h in Headers */, 63FE717920DA4C1100CDBAE8 /* SentryCrashReportStore.h in Headers */, 0AAE202128ED9BCC00D0CD80 /* SentryReachability.h in Headers */, D858FA662A29EAB3002A3503 /* SentryBinaryImageCache.h in Headers */, @@ -4279,6 +4288,7 @@ 63FE716720DA4C1100CDBAE8 /* SentryCrashCPU.c in Sources */, 63FE717320DA4C1100CDBAE8 /* SentryCrashC.c in Sources */, 63FE712120DA4C1000CDBAE8 /* SentryCrashSymbolicator.c in Sources */, + D83D07832B7E5F2100CC9674 /* SentryReplayOptions.m in Sources */, 63FE70D720DA4C1000CDBAE8 /* SentryCrashMonitor_MachException.c in Sources */, 7B96572226830D2400C66E25 /* SentryScopeSyncC.c in Sources */, 0A9BF4E228A114940068D266 /* SentryViewHierarchyIntegration.m in Sources */, diff --git a/Sources/Sentry/Public/Sentry.h b/Sources/Sentry/Public/Sentry.h index afaa72b0986..ac23d1d73d9 100644 --- a/Sources/Sentry/Public/Sentry.h +++ b/Sources/Sentry/Public/Sentry.h @@ -30,6 +30,7 @@ FOUNDATION_EXPORT const unsigned char SentryVersionString[]; #import "SentryMessage.h" #import "SentryNSError.h" #import "SentryOptions.h" +#import "SentryReplayOptions.h" #import "SentryRequest.h" #import "SentrySDK.h" #import "SentrySampleDecision.h" diff --git a/Sources/Sentry/Public/SentryOptions.h b/Sources/Sentry/Public/SentryOptions.h index 4bb0ce9680d..7d2e6c8a328 100644 --- a/Sources/Sentry/Public/SentryOptions.h +++ b/Sources/Sentry/Public/SentryOptions.h @@ -3,7 +3,8 @@ NS_ASSUME_NONNULL_BEGIN -@class SentryDsn, SentryMeasurementValue, SentryHttpStatusCodeRange, SentryScope; +@class SentryDsn, SentryMeasurementValue, SentryHttpStatusCodeRange, SentryScope, + SentryReplayOptions; NS_SWIFT_NAME(Options) @interface SentryOptions : NSObject @@ -269,6 +270,15 @@ NS_SWIFT_NAME(Options) * @note Default value is @c NO . */ @property (nonatomic, assign) BOOL enablePreWarmedAppStartTracing; + +/** + * @warning This is an experimental feature and may still have bugs. + * Settings to configure the session replay. + * @node Default value is @c nil . + */ +@property (nonatomic, strong) + SentryReplayOptions *sessionReplayOptions API_AVAILABLE(ios(16.0), tvos(16.0)); + #endif // SENTRY_UIKIT_AVAILABLE /** diff --git a/Sources/Sentry/Public/SentryReplayOptions.h b/Sources/Sentry/Public/SentryReplayOptions.h new file mode 100644 index 00000000000..3220a5f8c91 --- /dev/null +++ b/Sources/Sentry/Public/SentryReplayOptions.h @@ -0,0 +1,38 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface SentryReplayOptions : NSObject + +/** + * Indicates the percentage in which the replay for the session will be created. + * @discussion Specifying @c 0 means never, @c 1.0 means always. + * @note The value needs to be >= 0.0 and \<= 1.0. When setting a value out of range the SDK sets it + * to the default. + * @note The default is @c 0. + */ +@property (nonatomic) float replaysSessionSampleRate; + +/** + * Indicates the percentage in which a 30 seconds replay will be send with error events. + * @discussion Specifying @c 0 means never, @c 1.0 means always. + * @note The value needs to be >= 0.0 and \<= 1.0. When setting a value out of range the SDK sets it + * to the default. + * @note The default is @c 0. + */ +@property (nonatomic) float replaysOnErrorSampleRate; + +/** + * Inittialize the settings of session replay + * + * @param sessionSampleRate Indicates the percentage in which the replay for the session will be + * created. + * @param errorSampleRate Indicates the percentage in which a 30 seconds replay will be send with + * error events. + */ +- (instancetype)initWithReplaySessionSampleRate:(float)sessionSampleRate + replaysOnErrorSampleRate:(float)errorSampleRate; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/SentryBaseIntegration.m b/Sources/Sentry/SentryBaseIntegration.m index 5706d68e925..40b4cf97520 100644 --- a/Sources/Sentry/SentryBaseIntegration.m +++ b/Sources/Sentry/SentryBaseIntegration.m @@ -1,6 +1,7 @@ #import "SentryBaseIntegration.h" #import "SentryCrashWrapper.h" #import "SentryLog.h" +#import "SentryReplayOptions.h" #import #import #import @@ -140,6 +141,19 @@ - (BOOL)shouldBeEnabledWithOptions:(SentryOptions *)options [self logWithOptionName:@"attachViewHierarchy"]; return NO; } + + if (integrationOptions & kIntegrationOptionEnableReplay) { + if (@available(iOS 16.0, tvOS 16.0, *)) { + if (options.sessionReplayOptions.replaysOnErrorSampleRate == 0 + && options.sessionReplayOptions.replaysSessionSampleRate == 0) { + [self logWithOptionName:@"sessionReplaySettings"]; + return NO; + } + } else { + [self logWithReason:@"Session replay requires iOS 16 or above"]; + return NO; + } + } #endif if ((integrationOptions & kIntegrationOptionEnableCrashHandler) diff --git a/Sources/Sentry/SentryOptions.m b/Sources/Sentry/SentryOptions.m index a1b6f0958a5..151d523ebad 100644 --- a/Sources/Sentry/SentryOptions.m +++ b/Sources/Sentry/SentryOptions.m @@ -23,9 +23,8 @@ # import "SentryAppStartTrackingIntegration.h" # import "SentryFramesTrackingIntegration.h" # import "SentryPerformanceTrackingIntegration.h" -# if SENTRY_HAS_UIKIT -# import "SentryScreenshotIntegration.h" -# endif // SENTRY_HAS_UIKIT +# import "SentryReplayOptions+Private.h" +# import "SentryScreenshotIntegration.h" # import "SentryUIEventTrackingIntegration.h" # import "SentryViewHierarchyIntegration.h" # import "SentryWatchdogTerminationTrackingIntegration.h" @@ -382,7 +381,6 @@ - (BOOL)validateOptions:(NSDictionary *)options if ([self isBlock:options[@"initialScope"]]) { self.initialScope = options[@"initialScope"]; } - #if SENTRY_HAS_UIKIT [self setBool:options[@"enableUIViewControllerTracing"] block:^(BOOL value) { self->_enableUIViewControllerTracing = value; }]; @@ -402,6 +400,14 @@ - (BOOL)validateOptions:(NSDictionary *)options [self setBool:options[@"enablePreWarmedAppStartTracing"] block:^(BOOL value) { self->_enablePreWarmedAppStartTracing = value; }]; + + if (@available(iOS 16.0, tvOS 16.0, *)) { + if ([options[@"sessionReplayOptions"] isKindOfClass:NSDictionary.class]) { + self.sessionReplayOptions = + [[SentryReplayOptions alloc] initWithDictionary:options[@"sessionReplayOptions"]]; + } + } + #endif // SENTRY_HAS_UIKIT [self setBool:options[@"enableAppHangTracking"] diff --git a/Sources/Sentry/SentryReplayOptions.m b/Sources/Sentry/SentryReplayOptions.m new file mode 100644 index 00000000000..9e9922ff7ed --- /dev/null +++ b/Sources/Sentry/SentryReplayOptions.m @@ -0,0 +1,51 @@ +#import "SentryReplayOptions.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface +SentryReplayOptions () + +@property (nonatomic) NSInteger replayBitRate; + +@end + +@implementation SentryReplayOptions + +- (instancetype)init +{ + if (self = [super init]) { + self.replaysSessionSampleRate = 0; + self.replaysOnErrorSampleRate = 0; + self.replayBitRate = 20000; + } + return self; +} + +- (instancetype)initWithReplaySessionSampleRate:(float)sessionSampleRate + replaysOnErrorSampleRate:(float)errorSampleRate +{ + if (self = [self init]) { + self.replaysSessionSampleRate = sessionSampleRate; + self.replaysOnErrorSampleRate = errorSampleRate; + } + + return self; +} + +- (instancetype)initWithDictionary:(NSDictionary *)dictionary +{ + if (self = [self init]) { + if ([dictionary[@"replaysSessionSampleRate"] isKindOfClass:NSNumber.class]) { + self.replaysSessionSampleRate = [dictionary[@"replaysSessionSampleRate"] floatValue]; + } + + if ([dictionary[@"replaysOnErrorSampleRate"] isKindOfClass:NSNumber.class]) { + self.replaysOnErrorSampleRate = [dictionary[@"replaysOnErrorSampleRate"] floatValue]; + } + } + return self; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/include/HybridPublic/SentryReplayOptions+Private.h b/Sources/Sentry/include/HybridPublic/SentryReplayOptions+Private.h new file mode 100644 index 00000000000..2ef6e8094bb --- /dev/null +++ b/Sources/Sentry/include/HybridPublic/SentryReplayOptions+Private.h @@ -0,0 +1,20 @@ +#import "SentryReplayOptions.h" +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface +SentryReplayOptions (Private) + +/** + * Defines the quality of the session replay. + * Higher bit rates better quality, but also bigger files to transfer. + * @note The default value is @c 20000; + */ +@property (nonatomic) NSInteger replayBitRate; + +- (instancetype)initWithDictionary:(NSDictionary *)dictionary; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/include/SentryBaseIntegration.h b/Sources/Sentry/include/SentryBaseIntegration.h index c620ab20205..82952f16d4b 100644 --- a/Sources/Sentry/include/SentryBaseIntegration.h +++ b/Sources/Sentry/include/SentryBaseIntegration.h @@ -23,6 +23,7 @@ typedef NS_OPTIONS(NSUInteger, SentryIntegrationOption) { kIntegrationOptionAttachViewHierarchy = 1 << 15, kIntegrationOptionEnableCrashHandler = 1 << 16, kIntegrationOptionEnableMetricKit = 1 << 17, + kIntegrationOptionEnableReplay = 1 << 18, }; @interface SentryBaseIntegration : NSObject diff --git a/Tests/SentryTests/SentryOptionsTest.m b/Tests/SentryTests/SentryOptionsTest.m index 088b09f21f3..d8de03496a5 100644 --- a/Tests/SentryTests/SentryOptionsTest.m +++ b/Tests/SentryTests/SentryOptionsTest.m @@ -544,6 +544,7 @@ - (void)testNSNull_SetsDefaultValue #if SENTRY_HAS_UIKIT @"enableUIViewControllerTracing" : [NSNull null], @"attachScreenshot" : [NSNull null], + @"sessionReplayOptions" : [NSNull null], #endif @"enableAppHangTracking" : [NSNull null], @"appHangTimeoutInterval" : [NSNull null], @@ -603,6 +604,9 @@ - (void)assertDefaultValues:(SentryOptions *)options XCTAssertEqual(options.enableUserInteractionTracing, YES); XCTAssertEqual(options.enablePreWarmedAppStartTracing, NO); XCTAssertEqual(options.attachViewHierarchy, NO); + if (@available(iOS 16.0, tvOS 16.0, *)) { + XCTAssertNil(options.sessionReplayOptions); + } #endif XCTAssertFalse(options.enableTracing); XCTAssertTrue(options.enableAppHangTracking); @@ -778,6 +782,27 @@ - (void)testEnablePreWarmedAppStartTracking [self testBooleanField:@"enablePreWarmedAppStartTracing" defaultValue:NO]; } +- (void)testSessionReplaySettingsInit +{ + if (@available(iOS 16.0, tvOS 16.0, *)) { + SentryOptions *options = [self getValidOptions:@{ + @"sessionReplayOptions" : + @ { @"replaysSessionSampleRate" : @2, @"replaysOnErrorSampleRate" : @4 } + }]; + XCTAssertEqual(options.sessionReplayOptions.replaysSessionSampleRate, 2); + XCTAssertEqual(options.sessionReplayOptions.replaysOnErrorSampleRate, 4); + } +} + +- (void)testSessionReplaySettingsDefaults +{ + if (@available(iOS 16.0, tvOS 16.0, *)) { + SentryOptions *options = [self getValidOptions:@{ @"sessionReplayOptions" : @ {} }]; + XCTAssertEqual(options.sessionReplayOptions.replaysSessionSampleRate, 0); + XCTAssertEqual(options.sessionReplayOptions.replaysOnErrorSampleRate, 0); + } +} + #endif #if SENTRY_HAS_METRIC_KIT diff --git a/Tests/SentryTests/SentryTests-Bridging-Header.h b/Tests/SentryTests/SentryTests-Bridging-Header.h index 6adb9b2b09f..475682b3819 100644 --- a/Tests/SentryTests/SentryTests-Bridging-Header.h +++ b/Tests/SentryTests/SentryTests-Bridging-Header.h @@ -220,6 +220,7 @@ #import "SentryPerformanceTracker+Testing.h" #import "SentryPropagationContext.h" #import "SentryReplayEvent.h" +#import "SentryReplayOptions.h" #import "SentryReplayRecording.h" #import "SentrySampleDecision+Private.h" #import "SentrySpanOperations.h" From 90e65829a09ea20f7707eaccd16f5aceba941f3f Mon Sep 17 00:00:00 2001 From: Dhiogo Brustolin Date: Fri, 1 Mar 2024 11:28:00 +0100 Subject: [PATCH 6/8] ref(Session Replay):Replay recording serialization (#3677) --- Sources/Sentry/SentryEnvelope.m | 6 +----- Sources/Sentry/SentrySerialization.m | 11 +++++++++++ Sources/Sentry/include/SentrySerialization.h | 4 +++- .../Helper/SentrySerializationTests.swift | 17 +++++++++++++++++ 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/Sources/Sentry/SentryEnvelope.m b/Sources/Sentry/SentryEnvelope.m index e6752312dd6..52e13dc81a5 100644 --- a/Sources/Sentry/SentryEnvelope.m +++ b/Sources/Sentry/SentryEnvelope.m @@ -211,11 +211,7 @@ - (nullable instancetype)initWithReplayEvent:(SentryReplayEvent *)replayEvent video:(NSURL *)videoURL { NSData *replayEventData = [SentrySerialization dataWithJSONObject:[replayEvent serialize]]; - NSMutableData *recording = [NSMutableData data]; - [recording appendData:[SentrySerialization - dataWithJSONObject:[replayRecording headerForReplayRecording]]]; - [recording appendData:[SentrySerialization dataWithJSONObject:[replayRecording serialize]]]; - + NSData *recording = [SentrySerialization dataWithReplayRecording:replayRecording]; NSURL *envelopeContentUrl = [[videoURL URLByDeletingPathExtension] URLByAppendingPathExtension:@"dat"]; diff --git a/Sources/Sentry/SentrySerialization.m b/Sources/Sentry/SentrySerialization.m index 6e16cb05b97..3d5c06e5248 100644 --- a/Sources/Sentry/SentrySerialization.m +++ b/Sources/Sentry/SentrySerialization.m @@ -8,6 +8,7 @@ #import "SentryId.h" #import "SentryLevelMapper.h" #import "SentryLog.h" +#import "SentryReplayRecording.h" #import "SentrySdkInfo.h" #import "SentrySession.h" #import "SentryTraceContext.h" @@ -322,6 +323,16 @@ + (SentrySession *_Nullable)sessionWithData:(NSData *)sessionData return session; } ++ (NSData *)dataWithReplayRecording:(SentryReplayRecording *)replayRecording +{ + NSMutableData *recording = [NSMutableData data]; + [recording appendData:[SentrySerialization + dataWithJSONObject:[replayRecording headerForReplayRecording]]]; + [recording appendData:[@"\n" dataUsingEncoding:NSUTF8StringEncoding]]; + [recording appendData:[SentrySerialization dataWithJSONObject:[replayRecording serialize]]]; + return recording; +} + + (SentryAppState *_Nullable)appStateWithData:(NSData *)data { NSError *error = nil; diff --git a/Sources/Sentry/include/SentrySerialization.h b/Sources/Sentry/include/SentrySerialization.h index 704e9b5cfd7..0ff1c23951b 100644 --- a/Sources/Sentry/include/SentrySerialization.h +++ b/Sources/Sentry/include/SentrySerialization.h @@ -1,6 +1,6 @@ #import "SentryDefines.h" -@class SentrySession, SentryEnvelope, SentryAppState; +@class SentrySession, SentryEnvelope, SentryAppState, SentryReplayRecording; NS_ASSUME_NONNULL_BEGIN @@ -20,6 +20,8 @@ static int const SENTRY_BAGGAGE_MAX_SIZE = 8192; + (NSData *_Nullable)dataWithEnvelope:(SentryEnvelope *)envelope error:(NSError *_Nullable *_Nullable)error; ++ (NSData *)dataWithReplayRecording:(SentryReplayRecording *)replayRecording; + + (SentryEnvelope *_Nullable)envelopeWithData:(NSData *)data; + (SentryAppState *_Nullable)appStateWithData:(NSData *)sessionData; diff --git a/Tests/SentryTests/Helper/SentrySerializationTests.swift b/Tests/SentryTests/Helper/SentrySerializationTests.swift index 26f277c6639..a848b898773 100644 --- a/Tests/SentryTests/Helper/SentrySerializationTests.swift +++ b/Tests/SentryTests/Helper/SentrySerializationTests.swift @@ -1,3 +1,4 @@ +import Nimble import XCTest class SentrySerializationTests: XCTestCase { @@ -230,6 +231,22 @@ class SentrySerializationTests: XCTestCase { XCTAssertNil(SentrySerialization.session(with: data)) } + func testSerializeReplayRecording() { + class MockReplayRecording: SentryReplayRecording { + override func serialize() -> [[String: Any]] { + return [["KEY": "VALUE"]] + } + } + + 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 data = SentrySerialization.data(with: recording) + + let serialized = String(data: data, encoding: .utf8) + + expect(serialized) == "{\"segment_id\":5}\n[{\"KEY\":\"VALUE\"}]" + } + func testLevelFromEventData() { let envelopeItem = SentryEnvelopeItem(event: TestData.event) From 9bcf1e7b99127aba7dbf7cca1f31c3764a9dabbe Mon Sep 17 00:00:00 2001 From: Dhiogo Brustolin Date: Tue, 5 Mar 2024 09:52:27 +0100 Subject: [PATCH 7/8] feat(Session Replay): Prepare Session event (#3693) Handling session event during prepareEvent. --- Sources/Sentry/SentryClient.m | 9 ++++----- Sources/Sentry/SentryScope.m | 3 ++- Tests/SentryTests/SentryClientTests.swift | 21 +++++++++++++++++++++ 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/Sources/Sentry/SentryClient.m b/Sources/Sentry/SentryClient.m index a5ec52eb94a..0cd79aa658a 100644 --- a/Sources/Sentry/SentryClient.m +++ b/Sources/Sentry/SentryClient.m @@ -495,9 +495,6 @@ - (void)captureReplayEvent:(SentryReplayEvent *)replayEvent return; } - // breadcrumbs for replay will be send with ReplayRecording - replayEvent.breadcrumbs = nil; - SentryEnvelopeItem *videoEnvelopeItem = [[SentryEnvelopeItem alloc] initWithReplayEvent:replayEvent replayRecording:replayRecording @@ -589,9 +586,11 @@ - (SentryEvent *_Nullable)prepareEvent:(SentryEvent *)event BOOL eventIsNotATransaction = event.type == nil || ![event.type isEqualToString:SentryEnvelopeItemTypeTransaction]; + BOOL eventIsNotReplay + = event.type == nil || ![event.type isEqualToString:SentryEnvelopeItemTypeReplayVideo]; // Transactions have their own sampleRate - if (eventIsNotATransaction && [self isSampled:self.options.sampleRate]) { + if (eventIsNotATransaction && eventIsNotReplay && [self isSampled:self.options.sampleRate]) { SENTRY_LOG_DEBUG(@"Event got sampled, will not send the event"); [self recordLostEvent:kSentryDataCategoryError reason:kSentryDiscardReasonSampleRate]; return nil; @@ -619,7 +618,7 @@ - (SentryEvent *_Nullable)prepareEvent:(SentryEvent *)event [self setSdk:event]; // We don't want to attach debug meta and stacktraces for transactions; - if (eventIsNotATransaction) { + if (eventIsNotATransaction && eventIsNotReplay) { BOOL shouldAttachStacktrace = alwaysAttachStacktrace || self.options.attachStacktrace || (nil != event.exceptions && [event.exceptions count] > 0); diff --git a/Sources/Sentry/SentryScope.m b/Sources/Sentry/SentryScope.m index 2eba7d70bde..351e4ae971e 100644 --- a/Sources/Sentry/SentryScope.m +++ b/Sources/Sentry/SentryScope.m @@ -496,7 +496,8 @@ - (SentryEvent *__nullable)applyToEvent:(SentryEvent *)event event.fingerprint = fingerprints; } - if (event.breadcrumbs == nil) { + if (event.breadcrumbs == nil + && ![event.type isEqualToString:SentryEnvelopeItemTypeReplayVideo]) { NSArray *breadcrumbs = [self breadcrumbs]; event.breadcrumbs = [breadcrumbs subarrayWithRange:NSMakeRange(0, MIN(maxBreadcrumbs, [breadcrumbs count]))]; diff --git a/Tests/SentryTests/SentryClientTests.swift b/Tests/SentryTests/SentryClientTests.swift index af2baea84d9..e8c229995f4 100644 --- a/Tests/SentryTests/SentryClientTests.swift +++ b/Tests/SentryTests/SentryClientTests.swift @@ -1634,6 +1634,27 @@ class SentryClientTest: XCTestCase { expect(self.fixture.transport.sentEnvelopes.count) == 0 } + func testCaptureReplayEvent_noBradcrumbsThreadsDebugMeta() { + let sut = fixture.getSut() + let replayEvent = SentryReplayEvent() + replayEvent.segmentId = 2 + let replayRecording = SentryReplayRecording() + replayRecording.segmentId = 2 + + //Not a video url, but its ok for test the envelope + let movieUrl = Bundle(for: self.classForCoder).url(forResource: "Resources/raw", withExtension: "json") + + let scope = Scope() + scope.addBreadcrumb(Breadcrumb(level: .debug, category: "Test Breadcrumb")) + + sut.capture(replayEvent, replayRecording: replayRecording, video: movieUrl!, with: Scope()) + + expect(replayEvent.breadcrumbs) == nil + expect(replayEvent.threads) == nil + expect(replayEvent.debugMeta) == nil + + } + private func givenEventWithDebugMeta() -> Event { let event = Event(level: SentryLevel.fatal) let debugMeta = DebugMeta() From 0a08f99d4d2cc294892a65e4c1b1ca76e9144979 Mon Sep 17 00:00:00 2001 From: Dhiogo Brustolin Date: Tue, 5 Mar 2024 13:30:04 +0100 Subject: [PATCH 8/8] Fixing Merge --- .../Networking/SentryDataCategoryMapperTests.swift | 7 +++++-- Tests/SentryTests/SentryTests-Bridging-Header.h | 1 - 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Tests/SentryTests/Networking/SentryDataCategoryMapperTests.swift b/Tests/SentryTests/Networking/SentryDataCategoryMapperTests.swift index 143194912ae..8ccf0359b7c 100644 --- a/Tests/SentryTests/Networking/SentryDataCategoryMapperTests.swift +++ b/Tests/SentryTests/Networking/SentryDataCategoryMapperTests.swift @@ -10,7 +10,7 @@ class SentryDataCategoryMapperTests: XCTestCase { expect(sentryDataCategoryForEnvelopItemType("attachment")) == .attachment expect(sentryDataCategoryForEnvelopItemType("profile")) == .profile expect(sentryDataCategoryForEnvelopItemType("statsd")) == .statsd - expect(sentryDataCategoryForEnvelopItemType("statsd")) == .replay + expect(sentryDataCategoryForEnvelopItemType("replay_video")) == .replay expect(sentryDataCategoryForEnvelopItemType("unknown item type")) == .default } @@ -24,7 +24,8 @@ class SentryDataCategoryMapperTests: XCTestCase { expect(sentryDataCategoryForNSUInteger(6)) == .userFeedback expect(sentryDataCategoryForNSUInteger(7)) == .profile expect(sentryDataCategoryForNSUInteger(8)) == .statsd - expect(sentryDataCategoryForNSUInteger(9)) == .unknown + expect(sentryDataCategoryForNSUInteger(9)) == .replay + expect(sentryDataCategoryForNSUInteger(10)) == .unknown XCTAssertEqual(.unknown, sentryDataCategoryForNSUInteger(10), "Failed to map unknown category number to case .unknown") } @@ -39,6 +40,7 @@ class SentryDataCategoryMapperTests: XCTestCase { expect(sentryDataCategoryForString(kSentryDataCategoryNameUserFeedback)) == .userFeedback expect(sentryDataCategoryForString(kSentryDataCategoryNameProfile)) == .profile expect(sentryDataCategoryForString(kSentryDataCategoryNameStatsd)) == .statsd + expect(sentryDataCategoryForString(kSentryDataCategoryNameReplay)) == .replay expect(sentryDataCategoryForString(kSentryDataCategoryNameUnknown)) == .unknown XCTAssertEqual(.unknown, sentryDataCategoryForString("gdfagdfsa"), "Failed to map unknown category name to case .unknown") @@ -54,6 +56,7 @@ class SentryDataCategoryMapperTests: XCTestCase { expect(nameForSentryDataCategory(.userFeedback)) == kSentryDataCategoryNameUserFeedback expect(nameForSentryDataCategory(.profile)) == kSentryDataCategoryNameProfile expect(nameForSentryDataCategory(.statsd)) == kSentryDataCategoryNameStatsd + expect(nameForSentryDataCategory(.replay)) == kSentryDataCategoryNameReplay expect(nameForSentryDataCategory(.unknown)) == kSentryDataCategoryNameUnknown } } diff --git a/Tests/SentryTests/SentryTests-Bridging-Header.h b/Tests/SentryTests/SentryTests-Bridging-Header.h index 0c643ce2c71..ad42b1df4b1 100644 --- a/Tests/SentryTests/SentryTests-Bridging-Header.h +++ b/Tests/SentryTests/SentryTests-Bridging-Header.h @@ -220,7 +220,6 @@ #import "TestSentryCrashWrapper.h" #import "TestSentrySpan.h" #import "URLSessionTaskMock.h" -@import SentryPrivate; #import "SentryBinaryImageCache+Private.h" #import "SentryCrashBinaryImageCache.h" #import "SentryDispatchFactory.h"