From 1a86fb26a5ca6bd309d33bf541bcfacc75e451c9 Mon Sep 17 00:00:00 2001 From: Dhiogo Brustolin Date: Thu, 11 Apr 2024 16:38:08 +0200 Subject: [PATCH] feat: Session Replay (#3625) Added session replay Co-authored-by: Philipp Hofmann --- CHANGELOG.md | 6 + Samples/iOS-Swift/iOS-Swift/AppDelegate.swift | 10 +- Sentry.podspec | 6 +- Sentry.xcodeproj/project.pbxproj | 150 +++++++++- SentryTestUtils/TestCurrentDateProvider.swift | 4 + SentryTestUtils/TestTransport.swift | 1 + Sources/Configuration/SentryNoUI.xcconfig | 5 + Sources/Sentry/Public/SentryOptions.h | 11 +- Sources/Sentry/SentryBaseIntegration.m | 14 + Sources/Sentry/SentryClient.m | 56 +++- Sources/Sentry/SentryCoreGraphicsHelper.m | 18 ++ Sources/Sentry/SentryDataCategoryMapper.m | 9 + Sources/Sentry/SentryDateUtil.m | 5 + Sources/Sentry/SentryEnvelope.m | 40 +++ Sources/Sentry/SentryHub.m | 10 + Sources/Sentry/SentryMsgPackSerializer.m | 110 +++++++ Sources/Sentry/SentryOptions.m | 18 +- Sources/Sentry/SentryReplayEvent.m | 41 +++ Sources/Sentry/SentryReplayRecording.m | 75 +++++ Sources/Sentry/SentryReplayType.m | 14 + Sources/Sentry/SentrySerialization.m | 17 +- Sources/Sentry/SentrySessionReplay.m | 276 ++++++++++++++++++ .../Sentry/SentrySessionReplayIntegration.m | 129 ++++++++ .../include/HybridPublic/SentryEnvelope.h | 2 + .../HybridPublic/SentryEnvelopeItemType.h | 1 + .../Sentry/include/SentryBaseIntegration.h | 1 + Sources/Sentry/include/SentryClient+Private.h | 7 +- .../Sentry/include/SentryCoreGraphicsHelper.h | 13 + Sources/Sentry/include/SentryDataCategory.h | 3 +- .../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/SentryPrivate.h | 8 +- Sources/Sentry/include/SentryReplayEvent.h | 40 +++ .../Sentry/include/SentryReplayRecording.h | 45 +++ Sources/Sentry/include/SentryReplayType.h | 16 + Sources/Sentry/include/SentrySerialization.h | 6 +- Sources/Sentry/include/SentrySessionReplay.h | 65 +++++ .../include/SentrySessionReplayIntegration.h | 12 + .../SessionReplay/SentryOnDemandReplay.swift | 188 ++++++++++++ .../SessionReplay/SentryPixelBuffer.swift | 49 ++++ .../SessionReplay/SentryReplayOptions.swift | 101 +++++++ .../SessionReplay/SentryVideoInfo.swift | 28 ++ .../Swift/Protocol/SentryRedactOptions.swift | 7 + Sources/Swift/SentryExperimentalOptions.swift | 18 ++ .../Swift/Tools/SentryViewPhotographer.swift | 115 ++++++++ .../Helper/SentryDateUtilTests.swift | 8 + .../Helper/SentrySerializationTests.swift | 17 ++ .../SentryReplayEventTests.swift | 30 ++ .../SentryReplayRecordingTests.swift | 41 +++ .../SentrySessionReplayIntegrationTests.swift | 73 +++++ .../SentrySessionReplayTests.swift | 210 +++++++++++++ .../SentryDataCategoryMapperTests.swift | 6 +- .../Protocol/SentryEnvelopeTests.swift | 6 + Tests/SentryTests/SentryClientTests.swift | 84 ++++++ Tests/SentryTests/SentryHubTests.swift | 28 ++ .../SentryMsgPackSerializerTests.m | 103 +++++++ Tests/SentryTests/SentryOptionsTest.m | 24 ++ .../SentryTests/SentryTests-Bridging-Header.h | 17 ++ 61 files changed, 2407 insertions(+), 38 deletions(-) create mode 100644 Sources/Configuration/SentryNoUI.xcconfig create mode 100644 Sources/Sentry/SentryCoreGraphicsHelper.m 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/SentrySessionReplay.m create mode 100644 Sources/Sentry/SentrySessionReplayIntegration.m create mode 100644 Sources/Sentry/include/SentryCoreGraphicsHelper.h 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 Sources/Sentry/include/SentrySessionReplay.h create mode 100644 Sources/Sentry/include/SentrySessionReplayIntegration.h create mode 100644 Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift create mode 100644 Sources/Swift/Integrations/SessionReplay/SentryPixelBuffer.swift create mode 100644 Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift create mode 100644 Sources/Swift/Integrations/SessionReplay/SentryVideoInfo.swift create mode 100644 Sources/Swift/Protocol/SentryRedactOptions.swift create mode 100644 Sources/Swift/SentryExperimentalOptions.swift create mode 100644 Sources/Swift/Tools/SentryViewPhotographer.swift create mode 100644 Tests/SentryTests/Integrations/SessionReplay/SentryReplayEventTests.swift create mode 100644 Tests/SentryTests/Integrations/SessionReplay/SentryReplayRecordingTests.swift create mode 100644 Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift create mode 100644 Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift create mode 100644 Tests/SentryTests/SentryMsgPackSerializerTests.m diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c065daa139..f0ddfc61d63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +- Add Session Replay, which is **still experimental**. (#3625) + ## 8.24.0 ### Features diff --git a/Samples/iOS-Swift/iOS-Swift/AppDelegate.swift b/Samples/iOS-Swift/iOS-Swift/AppDelegate.swift index 5bf093e78b7..7125eb93257 100644 --- a/Samples/iOS-Swift/iOS-Swift/AppDelegate.swift +++ b/Samples/iOS-Swift/iOS-Swift/AppDelegate.swift @@ -17,13 +17,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate { let dsn = DSNStorage.shared.getDSN() ?? AppDelegate.defaultDSN DSNStorage.shared.saveDSN(dsn: dsn) - SentrySDK.start { options in + SentrySDK.start(configureOptions: { options in options.dsn = dsn options.beforeSend = { event in return event } options.debug = true + if #available(iOS 16.0, *) { + options.experimental.sessionReplay = SentryReplayOptions(sessionSampleRate: 1, errorSampleRate: 1, redactAllText: false, redactAllImages: true) + } + if #available(iOS 15.0, *) { options.enableMetricKit = true } @@ -60,7 +64,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { options.sessionTrackingIntervalMillis = 5_000 options.attachScreenshot = true options.attachViewHierarchy = true - + #if targetEnvironment(simulator) options.enableSpotlight = true options.environment = "test-app" @@ -130,7 +134,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } return scope } - } + }) SentrySDK.metrics.increment(key: "app.start", value: 1.0, tags: ["view": "app-delegate"]) diff --git a/Sentry.podspec b/Sentry.podspec index cb279f41b67..2f4e5eb6851 100644 --- a/Sentry.podspec +++ b/Sentry.podspec @@ -32,7 +32,8 @@ Pod::Spec.new do |s| s.subspec 'Core' do |sp| sp.source_files = "Sources/Sentry/**/*.{h,hpp,m,mm,c,cpp}", - "Sources/SentryCrash/**/*.{h,hpp,m,mm,c,cpp}", "Sources/Swift/**/*.{swift,h,hpp,m,mm,c,cpp}", + "Sources/SentryCrash/**/*.{h,hpp,m,mm,c,cpp}", "Sources/Swift/**/*.{swift,h,hpp,m,mm,c,cpp}" + sp.preserve_path = "Sources/Sentry/include/module.modulemap" sp.public_header_files = "Sources/Sentry/Public/*.h" @@ -43,7 +44,8 @@ Pod::Spec.new do |s| s.subspec 'HybridSDK' do |sp| sp.source_files = "Sources/Sentry/**/*.{h,hpp,m,mm,c,cpp}", "Sources/SentryCrash/**/*.{h,hpp,m,mm,c,cpp}", "Sources/Swift/**/*.{swift,h,hpp,m,mm,c,cpp}" - + + sp.preserve_path = "Sources/Sentry/include/module.modulemap" sp.public_header_files = "Sources/Sentry/Public/*.h", "Sources/Sentry/include/HybridPublic/*.h" diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index b871048fd73..278402352b3 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -763,6 +763,12 @@ 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 */; }; + D802994E2BA836EF000F0081 /* SentryOnDemandReplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = D802994D2BA836EF000F0081 /* SentryOnDemandReplay.swift */; }; + D80299502BA83A88000F0081 /* SentryPixelBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D802994F2BA83A88000F0081 /* SentryPixelBuffer.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 */; }; @@ -779,9 +785,16 @@ D8199DC229376FC10074249E /* Sentry.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63AA759B1EB8AEF500D153DE /* Sentry.framework */; }; D81A346C291AECC7005A27A9 /* PrivateSentrySDKOnly.h in Headers */ = {isa = PBXBuildFile; fileRef = D81A346B291AECC7005A27A9 /* PrivateSentrySDKOnly.h */; settings = {ATTRIBUTES = (Private, ); }; }; D81FDF12280EA1060045E0E4 /* SentryScreenShotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D81FDF10280EA0080045E0E4 /* SentryScreenShotTests.swift */; }; + D820CDB32BB1886100BA339D /* SentrySessionReplay.m in Sources */ = {isa = PBXBuildFile; fileRef = D820CDB22BB1886100BA339D /* SentrySessionReplay.m */; }; + D820CDB42BB1886100BA339D /* SentrySessionReplay.h in Headers */ = {isa = PBXBuildFile; fileRef = D820CDB12BB1886100BA339D /* SentrySessionReplay.h */; }; + D820CDB72BB1895F00BA339D /* SentrySessionReplayIntegration.m in Sources */ = {isa = PBXBuildFile; fileRef = D820CDB62BB1895F00BA339D /* SentrySessionReplayIntegration.m */; }; + D820CDB82BB1895F00BA339D /* SentrySessionReplayIntegration.h in Headers */ = {isa = PBXBuildFile; fileRef = D820CDB52BB1895F00BA339D /* SentrySessionReplayIntegration.h */; }; + D820CE132BB2F13C00BA339D /* SentryCoreGraphicsHelper.h in Headers */ = {isa = PBXBuildFile; fileRef = D820CE112BB2F13C00BA339D /* SentryCoreGraphicsHelper.h */; }; 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 */; }; @@ -806,15 +819,22 @@ D85D3BEA278DF63D001B2889 /* SentryByteCountFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D85D3BE9278DF63D001B2889 /* SentryByteCountFormatterTests.swift */; }; D8603DD6284F8497000E1227 /* SentryBaggage.m in Sources */ = {isa = PBXBuildFile; fileRef = D8603DD4284F8497000E1227 /* SentryBaggage.m */; }; D8603DD8284F894C000E1227 /* SentryBaggage.h in Headers */ = {isa = PBXBuildFile; fileRef = D8603DD7284F894C000E1227 /* SentryBaggage.h */; }; + D86130122BB563FD004C0F5E /* SentrySessionReplayIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86130112BB563FD004C0F5E /* SentrySessionReplayIntegrationTests.swift */; }; + D861301C2BB5A267004C0F5E /* SentrySessionReplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D861301B2BB5A267004C0F5E /* SentrySessionReplayTests.swift */; }; D865892F29D6ECA7000BE151 /* SentryCrashBinaryImageCache.h in Headers */ = {isa = PBXBuildFile; fileRef = D865892D29D6ECA7000BE151 /* SentryCrashBinaryImageCache.h */; }; D865893029D6ECA7000BE151 /* SentryCrashBinaryImageCache.c in Sources */ = {isa = PBXBuildFile; fileRef = D865892E29D6ECA7000BE151 /* SentryCrashBinaryImageCache.c */; }; D867063D27C3BC2400048851 /* SentryCoreDataTrackingIntegration.h in Headers */ = {isa = PBXBuildFile; fileRef = D867063A27C3BC2400048851 /* SentryCoreDataTrackingIntegration.h */; }; 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 */; }; + D878C6A82BC7F01C0039D6A3 /* SentryCoreGraphicsHelper.m in Sources */ = {isa = PBXBuildFile; fileRef = D820CE122BB2F13C00BA339D /* SentryCoreGraphicsHelper.m */; }; + D87C89032BC43C9C0086C7DF /* SentryRedactOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87C89022BC43C9C0086C7DF /* SentryRedactOptions.swift */; }; + D87C892B2BC67BC20086C7DF /* SentryExperimentalOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87C892A2BC67BC20086C7DF /* SentryExperimentalOptions.swift */; }; D880E3A728573E87008A90DB /* SentryBaggageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D880E3A628573E87008A90DB /* SentryBaggageTests.swift */; }; D884A20527C80F6300074664 /* SentryCoreDataTrackerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D884A20327C80F2700074664 /* SentryCoreDataTrackerTest.swift */; }; D885266427739D01001269FC /* SentryFileIOTrackingIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D885266327739D01001269FC /* SentryFileIOTrackingIntegrationTests.swift */; }; @@ -822,6 +842,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 */; }; D8ACE3C72762187200F5A213 /* SentryNSDataSwizzling.m in Sources */ = {isa = PBXBuildFile; fileRef = D8ACE3C42762187200F5A213 /* SentryNSDataSwizzling.m */; }; @@ -846,7 +868,10 @@ D8C66A372A77B1F70015696A /* SentryPropagationContext.m in Sources */ = {isa = PBXBuildFile; fileRef = D8C66A352A77B1F70015696A /* SentryPropagationContext.m */; }; D8C67E9B28000E24007E326E /* SentryUIApplication.h in Headers */ = {isa = PBXBuildFile; fileRef = D8C67E9928000E23007E326E /* SentryUIApplication.h */; }; D8C67E9C28000E24007E326E /* SentryScreenshot.h in Headers */ = {isa = PBXBuildFile; fileRef = D8C67E9A28000E23007E326E /* SentryScreenshot.h */; }; + D8CAC02E2BA0663E00E38F34 /* SentryReplayOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8CAC02A2BA0663E00E38F34 /* SentryReplayOptions.swift */; }; + D8CAC02F2BA0663E00E38F34 /* SentryVideoInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8CAC02B2BA0663E00E38F34 /* SentryVideoInfo.swift */; }; D8CAC0412BA0984500E38F34 /* SentryIntegrationProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8CAC0402BA0984500E38F34 /* SentryIntegrationProtocol.swift */; }; + D8CAC0732BA4473000E38F34 /* SentryViewPhotographer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8CAC0722BA4473000E38F34 /* SentryViewPhotographer.swift */; }; D8CB74152947246600A5F964 /* SentryEnvelopeAttachmentHeader.h in Headers */ = {isa = PBXBuildFile; fileRef = D8CB74142947246600A5F964 /* SentryEnvelopeAttachmentHeader.h */; }; D8CB7417294724CC00A5F964 /* SentryEnvelopeAttachmentHeader.m in Sources */ = {isa = PBXBuildFile; fileRef = D8CB7416294724CC00A5F964 /* SentryEnvelopeAttachmentHeader.m */; }; D8CB74192947285A00A5F964 /* SentryEnvelopeItemHeader.h in Headers */ = {isa = PBXBuildFile; fileRef = D8CB74182947285A00A5F964 /* SentryEnvelopeItemHeader.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -860,6 +885,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 */ @@ -1752,6 +1778,12 @@ 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 = ""; }; + D802994D2BA836EF000F0081 /* SentryOnDemandReplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryOnDemandReplay.swift; sourceTree = ""; }; + D802994F2BA83A88000F0081 /* SentryPixelBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryPixelBuffer.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 = ""; }; @@ -1768,10 +1800,18 @@ D8199DD029377C130074249E /* SentrySwiftUI.podspec */ = {isa = PBXFileReference; explicitFileType = text.script.ruby; path = SentrySwiftUI.podspec; sourceTree = ""; }; D81A346B291AECC7005A27A9 /* PrivateSentrySDKOnly.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = PrivateSentrySDKOnly.h; path = include/HybridPublic/PrivateSentrySDKOnly.h; sourceTree = ""; }; D81FDF10280EA0080045E0E4 /* SentryScreenShotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryScreenShotTests.swift; sourceTree = ""; }; + D820CDB12BB1886100BA339D /* SentrySessionReplay.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentrySessionReplay.h; path = include/SentrySessionReplay.h; sourceTree = ""; }; + D820CDB22BB1886100BA339D /* SentrySessionReplay.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentrySessionReplay.m; sourceTree = ""; }; + D820CDB52BB1895F00BA339D /* SentrySessionReplayIntegration.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentrySessionReplayIntegration.h; path = include/SentrySessionReplayIntegration.h; sourceTree = ""; }; + D820CDB62BB1895F00BA339D /* SentrySessionReplayIntegration.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentrySessionReplayIntegration.m; sourceTree = ""; }; + D820CE112BB2F13C00BA339D /* SentryCoreGraphicsHelper.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryCoreGraphicsHelper.h; path = include/SentryCoreGraphicsHelper.h; sourceTree = ""; }; + D820CE122BB2F13C00BA339D /* SentryCoreGraphicsHelper.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryCoreGraphicsHelper.m; sourceTree = ""; }; D8292D7A2A38AF04009872F7 /* HTTPHeaderSanitizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPHeaderSanitizer.swift; sourceTree = ""; }; D8292D7C2A39A027009872F7 /* UrlSanitizedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UrlSanitizedTests.swift; sourceTree = ""; }; 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 = ""; }; @@ -1780,11 +1820,13 @@ D84DAD4F2B17428D003CF120 /* SentryTestUtilsDynamic.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SentryTestUtilsDynamic.h; sourceTree = ""; }; D84F833B2A1CC401005828E0 /* SentrySwiftAsyncIntegration.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentrySwiftAsyncIntegration.h; path = include/SentrySwiftAsyncIntegration.h; sourceTree = ""; }; D84F833C2A1CC401005828E0 /* SentrySwiftAsyncIntegration.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentrySwiftAsyncIntegration.m; sourceTree = ""; }; + D8511F722BAC8F750015E6FD /* Sentry.modulemap */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.module-map"; path = Sentry.modulemap; sourceTree = ""; }; D85596F1280580F10041FF8B /* SentryScreenshotIntegration.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryScreenshotIntegration.m; sourceTree = ""; }; D855AD61286ED6A4002573E1 /* SentryCrashTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryCrashTests.m; sourceTree = ""; }; D855B3E727D652AF00BCED76 /* SentryCoreDataTrackingIntegrationTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryCoreDataTrackingIntegrationTest.swift; sourceTree = ""; }; D855B3E927D652C700BCED76 /* TestCoreDataStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestCoreDataStack.swift; sourceTree = ""; }; D856272B2A374A8600FB8062 /* UrlSanitized.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UrlSanitized.swift; sourceTree = ""; }; + D85723EF2BBC3BDC004AC5E1 /* SentryNoUI.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = SentryNoUI.xcconfig; sourceTree = ""; }; D85790282976A69F00C6AC1F /* TestDebugImageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestDebugImageProvider.swift; sourceTree = ""; }; D85852B427ECEEDA00C6D8AE /* SentryScreenshot.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryScreenshot.m; sourceTree = ""; }; D85852B827EDDC5900C6D8AE /* SentryUIApplication.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryUIApplication.m; sourceTree = ""; }; @@ -1796,6 +1838,8 @@ D85D3BE9278DF63D001B2889 /* SentryByteCountFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryByteCountFormatterTests.swift; sourceTree = ""; }; D8603DD4284F8497000E1227 /* SentryBaggage.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryBaggage.m; sourceTree = ""; }; D8603DD7284F894C000E1227 /* SentryBaggage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = SentryBaggage.h; path = include/SentryBaggage.h; sourceTree = ""; }; + D86130112BB563FD004C0F5E /* SentrySessionReplayIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentrySessionReplayIntegrationTests.swift; sourceTree = ""; }; + D861301B2BB5A267004C0F5E /* SentrySessionReplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentrySessionReplayTests.swift; sourceTree = ""; }; D865892D29D6ECA7000BE151 /* SentryCrashBinaryImageCache.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SentryCrashBinaryImageCache.h; sourceTree = ""; }; D865892E29D6ECA7000BE151 /* SentryCrashBinaryImageCache.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = SentryCrashBinaryImageCache.c; sourceTree = ""; }; D867063A27C3BC2400048851 /* SentryCoreDataTrackingIntegration.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = SentryCoreDataTrackingIntegration.h; path = include/SentryCoreDataTrackingIntegration.h; sourceTree = ""; }; @@ -1803,10 +1847,14 @@ 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 = ""; }; D875ED0A276CC84700422FAC /* SentryNSDataTrackerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SentryNSDataTrackerTests.swift; sourceTree = ""; }; + D87C89022BC43C9C0086C7DF /* SentryRedactOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryRedactOptions.swift; sourceTree = ""; }; + D87C892A2BC67BC20086C7DF /* SentryExperimentalOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryExperimentalOptions.swift; sourceTree = ""; }; D878C6C02BC8048A0039D6A3 /* SentryPrivate.podspec */ = {isa = PBXFileReference; explicitFileType = text.script.ruby; path = SentryPrivate.podspec; sourceTree = ""; }; D880E3A628573E87008A90DB /* SentryBaggageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryBaggageTests.swift; sourceTree = ""; }; D880E3B02860A5A0008A90DB /* SentryEvent+Private.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "SentryEvent+Private.h"; path = "include/SentryEvent+Private.h"; sourceTree = ""; }; @@ -1816,6 +1864,8 @@ 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 = ""; }; D88D25E92B8E0BAC0073C3D5 /* module.modulemap */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.module-map"; path = module.modulemap; 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 = ""; }; @@ -1843,7 +1893,10 @@ D8C66A352A77B1F70015696A /* SentryPropagationContext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryPropagationContext.m; sourceTree = ""; }; D8C67E9928000E23007E326E /* SentryUIApplication.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = SentryUIApplication.h; path = include/SentryUIApplication.h; sourceTree = ""; }; D8C67E9A28000E23007E326E /* SentryScreenshot.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = SentryScreenshot.h; path = include/SentryScreenshot.h; sourceTree = ""; }; + D8CAC02A2BA0663E00E38F34 /* SentryReplayOptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SentryReplayOptions.swift; sourceTree = ""; }; + D8CAC02B2BA0663E00E38F34 /* SentryVideoInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SentryVideoInfo.swift; sourceTree = ""; }; D8CAC0402BA0984500E38F34 /* SentryIntegrationProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryIntegrationProtocol.swift; sourceTree = ""; }; + D8CAC0722BA4473000E38F34 /* SentryViewPhotographer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryViewPhotographer.swift; sourceTree = ""; }; D8CB74142947246600A5F964 /* SentryEnvelopeAttachmentHeader.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryEnvelopeAttachmentHeader.h; path = include/SentryEnvelopeAttachmentHeader.h; sourceTree = ""; }; D8CB7416294724CC00A5F964 /* SentryEnvelopeAttachmentHeader.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryEnvelopeAttachmentHeader.m; sourceTree = ""; }; D8CB74182947285A00A5F964 /* SentryEnvelopeItemHeader.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryEnvelopeItemHeader.h; path = Public/SentryEnvelopeItemHeader.h; sourceTree = ""; }; @@ -1860,6 +1913,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 */ @@ -2248,6 +2302,7 @@ 7BE0DC35272AE7BF004FA8B7 /* SentryCrash */, D85596EF280580BE0041FF8B /* Screenshot */, 0A9BF4E028A114690068D266 /* ViewHierarchy */, + D80CD8D52B752FD9002F710B /* SessionReplay */, 7D7F0A5E23DF3D2C00A4629C /* SentryGlobalEventProcessor.h */, 7DAC588E23D8B2E0001CF26B /* SentryGlobalEventProcessor.m */, 7BA235622600B61200E12865 /* SentryInternalNotificationNames.h */, @@ -2404,6 +2459,7 @@ 84B7FA4729B2995A00AD93B1 /* DeploymentTargets.xcconfig */, 63AA75C51EB8B00100D153DE /* Sentry.xcconfig */, D8199DCF29376FF40074249E /* SentrySwiftUI.xcconfig */, + D85723EF2BBC3BDC004AC5E1 /* SentryNoUI.xcconfig */, ); path = Configuration; sourceTree = ""; @@ -2824,6 +2880,7 @@ 7BE0DC40272AEA0A004FA8B7 /* Performance */, 7BE0DC3F272AE9F0004FA8B7 /* Session */, 7BE0DC3E272AE9DC004FA8B7 /* SentryCrash */, + D80694C12B7CC85800B820E6 /* SessionReplay */, 7B59398324AB481B0003AAD2 /* NotificationCenterTestCase.swift */, 0A2D8D8628992260008720F6 /* SentryBaseIntegrationTests.swift */, ); @@ -3355,6 +3412,8 @@ D8F6A2452885512100320515 /* SentryPredicateDescriptor.m */, 0A2D8DA6289BC905008720F6 /* SentryViewHierarchy.h */, 0A2D8DA7289BC905008720F6 /* SentryViewHierarchy.m */, + D83D07992B7F9D1C00CC9674 /* SentryMsgPackSerializer.h */, + D83D079A2B7F9D1C00CC9674 /* SentryMsgPackSerializer.m */, ); name = Tools; sourceTree = ""; @@ -3412,6 +3471,7 @@ D800942328F82E8D005D3943 /* Swift */ = { isa = PBXGroup; children = ( + D8CAC02D2BA0663E00E38F34 /* Integrations */, 62262B892BA1C4B0004DA3DD /* Metrics */, 621D9F2D2B9B030E003D94DE /* Helper */, D8F016B42B962533007B9AFB /* Extensions */, @@ -3419,11 +3479,23 @@ D8F016B12B9622B7007B9AFB /* Protocol */, D856272A2A374A6800FB8062 /* Tools */, D800942628F82F3A005D3943 /* SwiftDescriptor.swift */, + D87C892A2BC67BC20086C7DF /* SentryExperimentalOptions.swift */, D8B665BB2B95F5A100BD0E7B /* module.modulemap */, ); path = Swift; sourceTree = ""; }; + D80694C12B7CC85800B820E6 /* SessionReplay */ = { + isa = PBXGroup; + children = ( + D80694C22B7CC86E00B820E6 /* SentryReplayEventTests.swift */, + D80694C52B7CCFA100B820E6 /* SentryReplayRecordingTests.swift */, + D86130112BB563FD004C0F5E /* SentrySessionReplayIntegrationTests.swift */, + D861301B2BB5A267004C0F5E /* SentrySessionReplayTests.swift */, + ); + path = SessionReplay; + sourceTree = ""; + }; D808FB85281AB2EF009A2A33 /* UIEvents */ = { isa = PBXGroup; children = ( @@ -3433,6 +3505,25 @@ path = UIEvents; sourceTree = ""; }; + D80CD8D52B752FD9002F710B /* SessionReplay */ = { + isa = PBXGroup; + children = ( + D86B7B5A2B7A529C0017E8D9 /* SentryReplayEvent.h */, + D86B7B5B2B7A529C0017E8D9 /* SentryReplayEvent.m */, + D80694CB2B7E0A3E00B820E6 /* SentryReplayType.h */, + D80694CC2B7E0A3E00B820E6 /* SentryReplayType.m */, + D88D6C1B2B7B5A8800C8C633 /* SentryReplayRecording.h */, + D88D6C1C2B7B5A8800C8C633 /* SentryReplayRecording.m */, + D820CDB12BB1886100BA339D /* SentrySessionReplay.h */, + D820CDB22BB1886100BA339D /* SentrySessionReplay.m */, + D820CDB52BB1895F00BA339D /* SentrySessionReplayIntegration.h */, + D820CDB62BB1895F00BA339D /* SentrySessionReplayIntegration.m */, + D820CE112BB2F13C00BA339D /* SentryCoreGraphicsHelper.h */, + D820CE122BB2F13C00BA339D /* SentryCoreGraphicsHelper.m */, + ); + name = SessionReplay; + sourceTree = ""; + }; D8199DB329376ECC0074249E /* SentrySwiftUI */ = { isa = PBXGroup; children = ( @@ -3465,6 +3556,7 @@ D84541172A2DC2CD00E2B11C /* SentryBinaryImageCacheTests.swift */, D84541192A2DC55100E2B11C /* SentryBinaryImageCache+Private.h */, D8292D7C2A39A027009872F7 /* UrlSanitizedTests.swift */, + D8F8F5562B835BC600AC5465 /* SentryMsgPackSerializerTests.m */, D8B425112B9A0FD6000BFDF3 /* StringExtensionTests.swift */, ); name = Tools; @@ -3493,6 +3585,7 @@ children = ( D856272B2A374A8600FB8062 /* UrlSanitized.swift */, D8292D7A2A38AF04009872F7 /* HTTPHeaderSanitizer.swift */, + D8CAC0722BA4473000E38F34 /* SentryViewPhotographer.swift */, ); path = Tools; sourceTree = ""; @@ -3610,15 +3703,36 @@ isa = PBXGroup; children = ( D8B0542D2A7D2C720056BAF6 /* PrivacyInfo.xcprivacy */, + D8511F722BAC8F750015E6FD /* Sentry.modulemap */, ); path = Resources; sourceTree = ""; }; + D8CAC02C2BA0663E00E38F34 /* SessionReplay */ = { + isa = PBXGroup; + children = ( + D8CAC02A2BA0663E00E38F34 /* SentryReplayOptions.swift */, + D8CAC02B2BA0663E00E38F34 /* SentryVideoInfo.swift */, + D802994D2BA836EF000F0081 /* SentryOnDemandReplay.swift */, + D802994F2BA83A88000F0081 /* SentryPixelBuffer.swift */, + ); + path = SessionReplay; + sourceTree = ""; + }; + D8CAC02D2BA0663E00E38F34 /* Integrations */ = { + isa = PBXGroup; + children = ( + D8CAC02C2BA0663E00E38F34 /* SessionReplay */, + ); + path = Integrations; + sourceTree = ""; + }; D8F016B12B9622B7007B9AFB /* Protocol */ = { isa = PBXGroup; children = ( D8F016B22B9622D6007B9AFB /* SentryId.swift */, D8CAC0402BA0984500E38F34 /* SentryIntegrationProtocol.swift */, + D87C89022BC43C9C0086C7DF /* SentryRedactOptions.swift */, ); path = Protocol; sourceTree = ""; @@ -3687,6 +3801,7 @@ 7B0A54222521C21E00A71716 /* SentryFrameRemover.h in Headers */, 63FE70CD20DA4C1000CDBAE8 /* SentryCrashDoctor.h in Headers */, D8C67E9B28000E24007E326E /* SentryUIApplication.h in Headers */, + D820CE132BB2F13C00BA339D /* SentryCoreGraphicsHelper.h in Headers */, 7B6438AA26A70F24000D0F65 /* UIViewController+Sentry.h in Headers */, 639FCFAC1EBC811400778193 /* SentryUser.h in Headers */, D8CB74192947285A00A5F964 /* SentryEnvelopeItemHeader.h in Headers */, @@ -3741,9 +3856,11 @@ 8EAE980B261E9F530073B6B3 /* SentryPerformanceTracker.h in Headers */, 63FE718520DA4C1100CDBAE8 /* SentryCrashC.h in Headers */, 8EA1ED0D2669028C00E62B98 /* SentryUIViewControllerSwizzling.h in Headers */, + D820CDB82BB1895F00BA339D /* SentrySessionReplayIntegration.h in Headers */, 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 */, @@ -3778,7 +3895,9 @@ 7B8713AE26415ADF006D6004 /* SentryAppStartTrackingIntegration.h in Headers */, 7B7D873224864BB900D2ECFF /* SentryCrashMachineContextWrapper.h in Headers */, 861265F92404EC1500C4AFDE /* NSArray+SentrySanitize.h in Headers */, + D820CDB42BB1886100BA339D /* SentrySessionReplay.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 */, @@ -3889,10 +4008,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 */, @@ -4203,6 +4324,7 @@ 0A2D8D9628997845008720F6 /* NSLocale+Sentry.m in Sources */, 7B0DC730288698F70039995F /* NSMutableDictionary+Sentry.m in Sources */, 7BD4BD4527EB29F50071F4FF /* SentryClientReport.m in Sources */, + D87C89032BC43C9C0086C7DF /* SentryRedactOptions.swift in Sources */, 631E6D341EBC679C00712345 /* SentryQueueableRequestManager.m in Sources */, 7B8713B426415BAA006D6004 /* SentryAppStartTracker.m in Sources */, 7BDB03BB2513652900BAE198 /* SentryDispatchQueueWrapper.m in Sources */, @@ -4232,6 +4354,7 @@ 15E0A8ED240F2CB000F044E3 /* SentrySerialization.m in Sources */, 7BC85235245880AE005A70F0 /* SentryDataCategoryMapper.m in Sources */, 7B7A30C824B48389005A4C6E /* SentryCrashWrapper.m in Sources */, + D8CAC0732BA4473000E38F34 /* SentryViewPhotographer.swift in Sources */, D8ACE3C92762187200F5A213 /* SentryFileIOTrackingIntegration.m in Sources */, 63FE713B20DA4C1100CDBAE8 /* SentryCrashFileUtils.c in Sources */, 63FE716920DA4C1100CDBAE8 /* SentryCrashStackCursor.c in Sources */, @@ -4275,6 +4398,7 @@ 844EDC77294144DB00C86F34 /* SentrySystemWrapper.mm in Sources */, 630435FF1EBCA9D900C4D3FA /* SentryNSURLRequest.m in Sources */, 62C1AFAB2B7E10EA0038C5F7 /* SentrySpotlightTransport.m in Sources */, + D87C892B2BC67BC20086C7DF /* SentryExperimentalOptions.swift in Sources */, 7B5CAF7727F5A68C00ED0DB6 /* SentryNSURLRequestBuilder.m in Sources */, 639FCFA11EBC804600778193 /* SentryException.m in Sources */, D80CD8D42B75144B002F710B /* SwiftDescriptor.swift in Sources */, @@ -4292,6 +4416,7 @@ D8F6A2472885512100320515 /* SentryPredicateDescriptor.m in Sources */, A839D89A24864BA8003B7AFD /* SentrySystemEventBreadcrumbs.m in Sources */, 7D082B8323C628790029866B /* SentryMeta.m in Sources */, + D8CAC02F2BA0663E00E38F34 /* SentryVideoInfo.swift in Sources */, 63FE710720DA4C1000CDBAE8 /* SentryCrashStackCursor_SelfThread.m in Sources */, 63FE711120DA4C1000CDBAE8 /* SentryCrashDebug.c in Sources */, 7B883F49253D714C00879E62 /* SentryCrashUUIDConversion.c in Sources */, @@ -4313,6 +4438,8 @@ 7B56D73324616D9500B842DA /* SentryConcurrentRateLimitsDictionary.m in Sources */, 8ECC674825C23A20000E2BF6 /* SentryTransaction.m in Sources */, 0A80E433291017C300095219 /* SentryWatchdogTerminationScopeObserver.m in Sources */, + D88D6C1E2B7B5A8800C8C633 /* SentryReplayRecording.m in Sources */, + D8CAC02E2BA0663E00E38F34 /* SentryReplayOptions.swift in Sources */, 7BECF42826145CD900D9826E /* SentryMechanismMeta.m in Sources */, 8E7C982F2693D56000E6336C /* SentryTraceHeader.m in Sources */, 63FE715F20DA4C1100CDBAE8 /* SentryCrashID.c in Sources */, @@ -4335,8 +4462,10 @@ 63FE70FD20DA4C1000CDBAE8 /* SentryCrashCachedData.c in Sources */, A8F17B2E2901765900990B25 /* SentryRequest.m in Sources */, 7BE1E33424F7E3CB009D3AD0 /* SentryMigrateSessionInit.m in Sources */, + D80299502BA83A88000F0081 /* SentryPixelBuffer.swift in Sources */, 15E0A8F22411A45A00F044E3 /* SentrySession.m in Sources */, 844EDCE62947DC3100C86F34 /* SentryNSTimerFactory.m in Sources */, + D83D079C2B7F9D1C00CC9674 /* SentryMsgPackSerializer.m in Sources */, 7B6D1261265F784000C9BE4B /* PrivateSentrySDKOnly.mm in Sources */, 63BE85711ECEC6DE00DC44F5 /* SentryDateUtils.m in Sources */, 7BD4BD4927EB2A5D0071F4FF /* SentryDiscardedEvent.m in Sources */, @@ -4346,10 +4475,12 @@ 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 */, 639FCFA51EBC809A00778193 /* SentryStacktrace.m in Sources */, + D820CDB32BB1886100BA339D /* SentrySessionReplay.m in Sources */, 63FE70DF20DA4C1000CDBAE8 /* SentryCrashMonitorType.c in Sources */, 7BF9EF7E2722B91F00B5BBEF /* SentryDefaultObjCRuntimeWrapper.m in Sources */, 7BC3936E25B1AB72004F03D3 /* SentryLevelMapper.m in Sources */, @@ -4375,6 +4506,7 @@ 63FE712D20DA4C1100CDBAE8 /* SentryCrashJSONCodecObjC.m in Sources */, 7BBD18932449BEDD00427C76 /* SentryDefaultRateLimits.m in Sources */, 7BD729982463E93500EA3610 /* SentryDateUtil.m in Sources */, + D878C6A82BC7F01C0039D6A3 /* SentryCoreGraphicsHelper.m in Sources */, 62262B882BA1C490004DA3DD /* SentryStatsdClient.m in Sources */, 639FCF9D1EBC7F9500778193 /* SentryThread.m in Sources */, 8E8C57A225EEFC07001CEEFA /* SentrySampling.m in Sources */, @@ -4425,8 +4557,10 @@ 8453421228BE855D00C22EEC /* SentrySampleDecision.m in Sources */, 7B7D872E2486482600D2ECFF /* SentryStacktraceBuilder.m in Sources */, 861265FA2404EC1500C4AFDE /* NSArray+SentrySanitize.m in Sources */, + D802994E2BA836EF000F0081 /* SentryOnDemandReplay.swift in Sources */, D8603DD6284F8497000E1227 /* SentryBaggage.m in Sources */, 63FE711520DA4C1000CDBAE8 /* SentryCrashJSONCodec.c in Sources */, + D86B7B5D2B7A529C0017E8D9 /* SentryReplayEvent.m in Sources */, 03F84D3327DD4191008FE43F /* SentryMachLogging.cpp in Sources */, D85852BA27EDDC5900C6D8AE /* SentryUIApplication.m in Sources */, 7B4E375F258231FC00059C93 /* SentryAttachment.m in Sources */, @@ -4442,6 +4576,7 @@ 0A2D8D5B289815C0008720F6 /* SentryBaseIntegration.m in Sources */, 62262B912BA1C520004DA3DD /* CounterMetric.swift in Sources */, 639FCF991EBC7B9700778193 /* SentryEvent.m in Sources */, + D820CDB72BB1895F00BA339D /* SentrySessionReplayIntegration.m in Sources */, 632F43521F581D5400A18A36 /* SentryCrashExceptionApplication.m in Sources */, 62A2F4422BA9AE12000C9FDD /* SetMetric.swift in Sources */, 620379DD2AFE1432005AC0C1 /* SentryBuildAppStartSpans.m in Sources */, @@ -4494,6 +4629,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 /* SentryCrashNSErrorUtilTests.m in Sources */, 69BEE6F72620729E006DF9DF /* UrlSessionDelegateSpy.swift in Sources */, @@ -4529,6 +4665,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 */, @@ -4595,6 +4732,7 @@ 62BAD74E2BA1C58D00EBAAFC /* EncodeMetricTests.swift in Sources */, 7BE0DC29272A9E1C004FA8B7 /* SentryBreadcrumbTrackerTests.swift in Sources */, 63FE722520DA66EC00CDBAE8 /* SentryCrashFileUtils_Tests.m in Sources */, + D86130122BB563FD004C0F5E /* SentrySessionReplayIntegrationTests.swift in Sources */, 7BFC16BA2524D4AF00FF6266 /* SentryMessage+Equality.m in Sources */, 7B4260342630315C00B36EDD /* SampleError.swift in Sources */, D855B3E827D652AF00BCED76 /* SentryCoreDataTrackingIntegrationTest.swift in Sources */, @@ -4623,6 +4761,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 */, @@ -4679,6 +4818,7 @@ 7B965728268321CD00C66E25 /* SentryCrashScopeObserverTests.swift in Sources */, 626866742BA89683006995EA /* BucketMetricsAggregatorTests.swift in Sources */, 7BD86ECB264A6DB5005439DB /* TestSysctl.swift in Sources */, + D861301C2BB5A267004C0F5E /* SentrySessionReplayTests.swift in Sources */, 7B0DC73428869BF40039995F /* NSMutableDictionarySentryTests.swift in Sources */, 7B6ADFCF26A02CAE0076C206 /* SentryCrashReportTests.swift in Sources */, D8B76B062808066D000A58C4 /* SentryScreenshotIntegrationTests.swift in Sources */, @@ -5276,7 +5416,7 @@ }; 841C60C42A69DE6B00E1C00F /* Debug_without_UIKit */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 63AA75C51EB8B00100D153DE /* Sentry.xcconfig */; + baseConfigurationReference = D85723EF2BBC3BDC004AC5E1 /* SentryNoUI.xcconfig */; buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; BUILD_LIBRARY_FOR_DISTRIBUTION = YES; @@ -5769,7 +5909,7 @@ }; 8483D06B2AC7627800143615 /* Release_without_UIKit */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 63AA75C51EB8B00100D153DE /* Sentry.xcconfig */; + baseConfigurationReference = D85723EF2BBC3BDC004AC5E1 /* SentryNoUI.xcconfig */; buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; BUILD_LIBRARY_FOR_DISTRIBUTION = YES; @@ -6324,7 +6464,6 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; }; name = Debug; @@ -6381,7 +6520,6 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; }; name = Debug_without_UIKit; @@ -6435,7 +6573,6 @@ SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; }; name = Test; @@ -6489,7 +6626,6 @@ SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; }; name = TestCI; @@ -6543,7 +6679,6 @@ SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; }; name = Release; @@ -6597,7 +6732,6 @@ SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; }; name = Release_without_UIKit; diff --git a/SentryTestUtils/TestCurrentDateProvider.swift b/SentryTestUtils/TestCurrentDateProvider.swift index d80436ff57d..3a62a39f63d 100644 --- a/SentryTestUtils/TestCurrentDateProvider.swift +++ b/SentryTestUtils/TestCurrentDateProvider.swift @@ -43,6 +43,10 @@ public class TestCurrentDateProvider: SentryCurrentDateProvider { setDate(date: date().addingTimeInterval(TimeInterval(nanoseconds) / 1e9)) internalSystemTime += nanoseconds } + + public func advanceBy(interval: TimeInterval) { + setDate(date: date().addingTimeInterval(interval)) + } public var timezoneOffsetValue = 0 public override func timezoneOffset() -> Int { diff --git a/SentryTestUtils/TestTransport.swift b/SentryTestUtils/TestTransport.swift index 5e3a31bbbf6..eab268c9299 100644 --- a/SentryTestUtils/TestTransport.swift +++ b/SentryTestUtils/TestTransport.swift @@ -1,3 +1,4 @@ +import _SentryPrivate import Foundation @objc diff --git a/Sources/Configuration/SentryNoUI.xcconfig b/Sources/Configuration/SentryNoUI.xcconfig new file mode 100644 index 00000000000..ed2b439eaa4 --- /dev/null +++ b/Sources/Configuration/SentryNoUI.xcconfig @@ -0,0 +1,5 @@ +#include "Sentry.xcconfig" + +//This is how we avoid linking UIKit from Swift code +//when compiling without UIKit +OTHER_SWIFT_FLAGS = -DSENTRY_NO_UIKIT diff --git a/Sources/Sentry/Public/SentryOptions.h b/Sources/Sentry/Public/SentryOptions.h index f079aa4bd44..8f67f545c8d 100644 --- a/Sources/Sentry/Public/SentryOptions.h +++ b/Sources/Sentry/Public/SentryOptions.h @@ -3,7 +3,9 @@ NS_ASSUME_NONNULL_BEGIN -@class SentryDsn, SentryMeasurementValue, SentryHttpStatusCodeRange, SentryScope; +@class SentryDsn, SentryMeasurementValue, SentryHttpStatusCodeRange, SentryScope, + SentryReplayOptions; +@class SentryExperimentalOptions; NS_SWIFT_NAME(Options) @interface SentryOptions : NSObject @@ -269,6 +271,7 @@ NS_SWIFT_NAME(Options) * @note Default value is @c NO . */ @property (nonatomic, assign) BOOL enablePreWarmedAppStartTracing; + #endif // SENTRY_UIKIT_AVAILABLE /** @@ -604,6 +607,12 @@ NS_SWIFT_NAME(Options) */ @property (nullable, nonatomic, copy) SentryBeforeEmitMetricCallback beforeEmitMetric; +/** + * This aggregates options for experimental features. + * Be aware that the options available for experimental can change at any time. + */ +@property (nonatomic, readonly) SentryExperimentalOptions *experimental; + @end NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/SentryBaseIntegration.m b/Sources/Sentry/SentryBaseIntegration.m index ce9dc99ac6d..e1fc0b7ad81 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 "SentrySwift.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.experimental.sessionReplay.errorSampleRate == 0 + && options.experimental.sessionReplay.sessionSampleRate == 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/SentryClient.m b/Sources/Sentry/SentryClient.m index 5db9a2fe27b..0dd2139a01c 100644 --- a/Sources/Sentry/SentryClient.m +++ b/Sources/Sentry/SentryClient.m @@ -11,7 +11,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" @@ -27,13 +27,16 @@ #import "SentryMechanismMeta.h" #import "SentryMessage.h" #import "SentryMeta.h" +#import "SentryMsgPackSerializer.h" #import "SentryNSDictionarySanitize.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,44 @@ - (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; + } + + 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 alloc] initWithId:replayEvent.eventId] + items:@[ videoEnvelopeItem ]]; + + [self captureEnvelope:envelope]; +} + - (void)captureEnvelope:(SentryEnvelope *)envelope { if ([self isDisabled]) { @@ -553,9 +587,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]) { + // Transactions and replays have their own 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; @@ -582,8 +618,8 @@ - (SentryEvent *_Nullable)prepareEvent:(SentryEvent *)event [self setSdk:event]; - // We don't want to attach debug meta and stacktraces for transactions; - if (eventIsNotATransaction) { + // We don't want to attach debug meta and stacktraces for transactions and replays. + if (eventIsNotATransaction && eventIsNotReplay) { BOOL shouldAttachStacktrace = alwaysAttachStacktrace || self.options.attachStacktrace || (nil != event.exceptions && [event.exceptions count] > 0); @@ -623,6 +659,10 @@ - (SentryEvent *_Nullable)prepareEvent:(SentryEvent *)event event = [scope applyToEvent:event maxBreadcrumb:self.options.maxBreadcrumbs]; + if (!eventIsNotReplay) { + event.breadcrumbs = nil; + } + if ([self isWatchdogTermination:event isCrashEvent:isCrashEvent]) { // Remove some mutable properties from the device/app contexts which are no longer // applicable diff --git a/Sources/Sentry/SentryCoreGraphicsHelper.m b/Sources/Sentry/SentryCoreGraphicsHelper.m new file mode 100644 index 00000000000..56bb3816299 --- /dev/null +++ b/Sources/Sentry/SentryCoreGraphicsHelper.m @@ -0,0 +1,18 @@ +#import "SentryCoreGraphicsHelper.h" +#if SENTRY_HAS_UIKIT +@implementation SentryCoreGraphicsHelper ++ (CGMutablePathRef)excludeRect:(CGRect)rectangle fromPath:(CGMutablePathRef)path +{ +# if (TARGET_OS_IOS || TARGET_OS_TV) +# ifdef __IPHONE_16_0 + if (@available(iOS 16.0, tvOS 16.0, *)) { + CGPathRef exclude = CGPathCreateWithRect(rectangle, nil); + CGPathRef newPath = CGPathCreateCopyBySubtractingPath(path, exclude, YES); + return CGPathCreateMutableCopy(newPath); + } +# endif // defined(__IPHONE_16_0) +# endif // (TARGET_OS_IOS || TARGET_OS_TV) + return path; +} +@end +#endif // SENTRY_HAS_UIKIT diff --git a/Sources/Sentry/SentryDataCategoryMapper.m b/Sources/Sentry/SentryDataCategoryMapper.m index b971bf19502..33664570c23 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 kSentryDataCategoryNameMetricBucket = @"metric_bucket"; NSString *const kSentryDataCategoryNameUnknown = @"unknown"; @@ -34,6 +35,9 @@ if ([itemType isEqualToString:SentryEnvelopeItemTypeProfile]) { return kSentryDataCategoryProfile; } + if ([itemType isEqualToString:SentryEnvelopeItemTypeReplayVideo]) { + return kSentryDataCategoryReplay; + } // The envelope item type used for metrics is statsd whereas the client report category for // discarded events is metric_bucket. if ([itemType isEqualToString:SentryEnvelopeItemTypeStatsd]) { @@ -79,6 +83,9 @@ if ([value isEqualToString:kSentryDataCategoryNameProfile]) { return kSentryDataCategoryProfile; } + if ([value isEqualToString:kSentryDataCategoryNameReplay]) { + return kSentryDataCategoryReplay; + } if ([value isEqualToString:kSentryDataCategoryNameMetricBucket]) { return kSentryDataCategoryMetricBucket; } @@ -114,6 +121,8 @@ return kSentryDataCategoryNameMetricBucket; case kSentryDataCategoryUnknown: return kSentryDataCategoryNameUnknown; + case kSentryDataCategoryReplay: + return kSentryDataCategoryNameReplay; } } diff --git a/Sources/Sentry/SentryDateUtil.m b/Sources/Sentry/SentryDateUtil.m index 49f2287af7d..ff7fb9fbced 100644 --- a/Sources/Sentry/SentryDateUtil.m +++ b/Sources/Sentry/SentryDateUtil.m @@ -46,6 +46,11 @@ + (NSDate *_Nullable)getMaximumDate:(NSDate *_Nullable)first andOther:(NSDate *_ } } ++ (long)millisecondsSince1970:(NSDate *)date +{ + return (long)([date timeIntervalSince1970] * 1000); +} + @end NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/SentryEnvelope.m b/Sources/Sentry/SentryEnvelope.m index fbe35f5c2d7..3ce89060b37 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,38 @@ - (_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]]; + NSData *recording = [SentrySerialization dataWithReplayRecording:replayRecording]; + 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_ERROR(@"Could not create MessagePack for session replay envelope item."); + return nil; + } + + NSData *envelopeItemContent = [NSData dataWithContentsOfURL:envelopeContentUrl]; + + NSError *error; + if (![NSFileManager.defaultManager removeItemAtURL:envelopeContentUrl error:&error]) { + SENTRY_LOG_ERROR(@"Cound not delete temporary replay content from disk: %@", error); + } + 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 a5891d5c21c..81be5ef1355 100644 --- a/Sources/Sentry/SentryHub.m +++ b/Sources/Sentry/SentryHub.m @@ -313,6 +313,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/SentryOptions.m b/Sources/Sentry/SentryOptions.m index 24640b20046..8e114e7773f 100644 --- a/Sources/Sentry/SentryOptions.m +++ b/Sources/Sentry/SentryOptions.m @@ -15,17 +15,16 @@ #import "SentryOptions+Private.h" #import "SentrySDK.h" #import "SentryScope.h" +#import "SentrySessionReplayIntegration.h" +#import "SentrySwift.h" #import "SentrySwiftAsyncIntegration.h" - #import #if SENTRY_HAS_UIKIT # import "SentryAppStartTrackingIntegration.h" # import "SentryFramesTrackingIntegration.h" # import "SentryPerformanceTrackingIntegration.h" -# if SENTRY_HAS_UIKIT -# import "SentryScreenshotIntegration.h" -# endif // SENTRY_HAS_UIKIT +# import "SentryScreenshotIntegration.h" # import "SentryUIEventTrackingIntegration.h" # import "SentryViewHierarchyIntegration.h" # import "SentryWatchdogTerminationTrackingIntegration.h" @@ -59,6 +58,9 @@ - (void)setMeasurement:(SentryMeasurementValue *)measurement NSStringFromClass([SentryUIEventTrackingIntegration class]), NSStringFromClass([SentryViewHierarchyIntegration class]), NSStringFromClass([SentryWatchdogTerminationTrackingIntegration class]), +# if !TARGET_OS_VISION + NSStringFromClass([SentrySessionReplayIntegration class]), +# endif #endif // SENTRY_HAS_UIKIT NSStringFromClass([SentryANRTrackingIntegration class]), NSStringFromClass([SentryAutoBreadcrumbTrackingIntegration class]), @@ -104,7 +106,7 @@ - (instancetype)init self.enableTimeToFullDisplayTracing = NO; self.initialScope = ^SentryScope *(SentryScope *scope) { return scope; }; - + _experimental = [[SentryExperimentalOptions alloc] init]; _enableTracing = NO; _enableTracingManual = NO; #if SENTRY_HAS_UIKIT @@ -387,7 +389,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; }]; @@ -407,6 +408,7 @@ - (BOOL)validateOptions:(NSDictionary *)options [self setBool:options[@"enablePreWarmedAppStartTracing"] block:^(BOOL value) { self->_enablePreWarmedAppStartTracing = value; }]; + #endif // SENTRY_HAS_UIKIT [self setBool:options[@"enableAppHangTracking"] @@ -522,6 +524,10 @@ - (BOOL)validateOptions:(NSDictionary *)options self.beforeEmitMetric = options[@"beforeEmitMetric"]; } + if ([options[@"experimental"] isKindOfClass:NSDictionary.class]) { + [self.experimental validateOptions:options[@"experimental"]]; + } + return YES; } diff --git a/Sources/Sentry/SentryReplayEvent.m b/Sources/Sentry/SentryReplayEvent.m new file mode 100644 index 00000000000..c5b28c8485c --- /dev/null +++ b/Sources/Sentry/SentryReplayEvent.m @@ -0,0 +1,41 @@ +#import "SentryReplayEvent.h" +#import "SentryDateUtil.h" +#import "SentryEnvelopeItemType.h" +#import "SentrySwift.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"] = @(self.replayStartTimestamp.timeIntervalSince1970); + result[@"trace_ids"] = trace_ids; + result[@"replay_id"] = self.eventId.sentryIdString; + result[@"segment_id"] = @(self.segmentId); + result[@"replay_type"] = nameForSentryReplayType(self.replayType); + result[@"error_ids"] = @[]; + + 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 f81b2e45600..841567515a9 100644 --- a/Sources/Sentry/SentrySerialization.m +++ b/Sources/Sentry/SentrySerialization.m @@ -7,6 +7,7 @@ #import "SentryError.h" #import "SentryLevelMapper.h" #import "SentryLog.h" +#import "SentryReplayRecording.h" #import "SentrySdkInfo.h" #import "SentrySession.h" #import "SentrySwift.h" @@ -16,15 +17,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); } @@ -321,6 +322,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/SentrySessionReplay.m b/Sources/Sentry/SentrySessionReplay.m new file mode 100644 index 00000000000..d6830ed4f19 --- /dev/null +++ b/Sources/Sentry/SentrySessionReplay.m @@ -0,0 +1,276 @@ +#import "SentrySessionReplay.h" +#import "SentryAttachment+Private.h" +#import "SentryDependencyContainer.h" +#import "SentryDisplayLinkWrapper.h" +#import "SentryFileManager.h" +#import "SentryHub+Private.h" +#import "SentryLog.h" +#import "SentryRandom.h" +#import "SentryReplayEvent.h" +#import "SentryReplayRecording.h" +#import "SentrySDK+Private.h" +#import "SentrySwift.h" + +#if SENTRY_HAS_UIKIT && !TARGET_OS_VISION + +NS_ASSUME_NONNULL_BEGIN + +@interface +SentrySessionReplay () + +@property (nonatomic) BOOL isRunning; + +@property (nonatomic) BOOL isFullSession; + +@end + +@implementation SentrySessionReplay { + NSURL *_urlToCache; + UIView *_rootView; + NSDate *_lastScreenShot; + NSDate *_videoSegmentStart; + NSDate *_sessionStart; + NSMutableArray *imageCollection; + SentryId *sessionReplayId; + SentryReplayOptions *_replayOptions; + SentryOnDemandReplay *_replayMaker; + SentryDisplayLinkWrapper *_displayLink; + SentryCurrentDateProvider *_dateProvider; + id _sentryRandom; + id _screenshotProvider; + int _currentSegmentId; + BOOL _processingScreenshot; + BOOL _reachedMaximumDuration; +} + +- (instancetype)initWithSettings:(SentryReplayOptions *)replayOptions + replayFolderPath:(NSURL *)folderPath + screenshotProvider:(id)screenshotProvider + replayMaker:(id)replayMaker + dateProvider:(SentryCurrentDateProvider *)dateProvider + random:(id)random + displayLinkWrapper:(SentryDisplayLinkWrapper *)displayLinkWrapper; +{ + if (self = [super init]) { + _replayOptions = replayOptions; + _dateProvider = dateProvider; + _sentryRandom = random; + _screenshotProvider = screenshotProvider; + _displayLink = displayLinkWrapper; + _isRunning = NO; + _urlToCache = folderPath; + _replayMaker = replayMaker; + _reachedMaximumDuration = NO; + } + return self; +} + +- (void)start:(UIView *)rootView fullSession:(BOOL)full +{ + if (rootView == nil) { + SENTRY_LOG_DEBUG(@"rootView cannot be nil. Session replay will not be recorded."); + return; + } + + if (_isRunning) { + return; + } + + @synchronized(self) { + if (_isRunning) { + return; + } + [_displayLink linkWithTarget:self selector:@selector(newFrame:)]; + _isRunning = YES; + } + + _rootView = rootView; + _lastScreenShot = _dateProvider.date; + _videoSegmentStart = nil; + _currentSegmentId = 0; + sessionReplayId = [[SentryId alloc] init]; + + imageCollection = [NSMutableArray array]; + if (full) { + [self startFullReplay]; + } +} + +- (void)startFullReplay +{ + _sessionStart = _lastScreenShot; + _isFullSession = YES; +} + +- (void)stop +{ + @synchronized(self) { + [_displayLink invalidate]; + _isRunning = NO; + } +} + +- (void)captureReplayForEvent:(SentryEvent *)event; +{ + if (_isFullSession || !_isRunning) { + return; + } + + if (event.error == nil && (event.exceptions == nil || event.exceptions.count == 0)) { + return; + } + + if ([_sentryRandom nextNumber] > _replayOptions.errorSampleRate) { + return; + } + + NSURL *finalPath = [_urlToCache URLByAppendingPathComponent:@"replay.mp4"]; + NSDate *replayStart = + [_dateProvider.date dateByAddingTimeInterval:-_replayOptions.errorReplayDuration]; + + [self createAndCapture:finalPath + duration:_replayOptions.errorReplayDuration + startedAt:replayStart]; + + [self startFullReplay]; +} + +- (void)newFrame:(CADisplayLink *)sender +{ + if (!_isRunning) { + return; + } + + NSDate *now = _dateProvider.date; + + if (_isFullSession && + [now timeIntervalSinceDate:_sessionStart] > _replayOptions.maximumDuration) { + _reachedMaximumDuration = YES; + [self prepareSegmentUntil:now]; + [self stop]; + return; + } + + if ([now timeIntervalSinceDate:_lastScreenShot] >= 1) { + [self takeScreenshot]; + _lastScreenShot = now; + + if (_videoSegmentStart == nil) { + _videoSegmentStart = now; + } else if (_isFullSession && + [now timeIntervalSinceDate:_videoSegmentStart] + >= _replayOptions.sessionSegmentDuration) { + [self prepareSegmentUntil:now]; + } + } +} + +- (void)prepareSegmentUntil:(NSDate *)date +{ + NSURL *pathToSegment = [_urlToCache URLByAppendingPathComponent:@"segments"]; + + if (![NSFileManager.defaultManager fileExistsAtPath:pathToSegment.path]) { + NSError *error; + if (![NSFileManager.defaultManager createDirectoryAtPath:pathToSegment.path + withIntermediateDirectories:YES + attributes:nil + error:&error]) { + SENTRY_LOG_ERROR(@"Can't create session replay segment folder. Error: %@", + error.localizedDescription); + return; + } + } + + pathToSegment = [pathToSegment + URLByAppendingPathComponent:[NSString stringWithFormat:@"%i.mp4", _currentSegmentId]]; + + NSDate *segmentStart = + [_dateProvider.date dateByAddingTimeInterval:-_replayOptions.sessionSegmentDuration]; + + [self createAndCapture:pathToSegment + duration:_replayOptions.sessionSegmentDuration + startedAt:segmentStart]; +} + +- (void)createAndCapture:(NSURL *)videoUrl + duration:(NSTimeInterval)duration + startedAt:(NSDate *)start +{ + [_replayMaker + createVideoWithDuration:duration + beginning:start + outputFileURL:videoUrl + error:nil + completion:^(SentryVideoInfo *videoInfo, NSError *error) { + if (error != nil) { + SENTRY_LOG_ERROR(@"Could not create replay video - %@", error); + } else { + [self captureSegment:self->_currentSegmentId++ + video:videoInfo + replayId:self->sessionReplayId + replayType:kSentryReplayTypeSession]; + + [self->_replayMaker releaseFramesUntil:videoInfo.end]; + self->_videoSegmentStart = nil; + } + }]; +} + +- (void)captureSegment:(NSInteger)segment + video:(SentryVideoInfo *)videoInfo + replayId:(SentryId *)replayid + replayType:(SentryReplayType)replayType +{ + SentryReplayEvent *replayEvent = [[SentryReplayEvent alloc] init]; + replayEvent.replayType = replayType; + replayEvent.eventId = replayid; + replayEvent.replayStartTimestamp = videoInfo.start; + replayEvent.segmentId = segment; + replayEvent.timestamp = videoInfo.end; + + SentryReplayRecording *recording = + [[SentryReplayRecording alloc] initWithSegmentId:replayEvent.segmentId + size:videoInfo.fileSize + start:videoInfo.start + duration:videoInfo.duration + frameCount:videoInfo.frameCount + frameRate:videoInfo.frameRate + height:videoInfo.height + width:videoInfo.width]; + + [SentrySDK.currentHub captureReplayEvent:replayEvent + replayRecording:recording + video:videoInfo.path]; + + NSError *error; + if (![NSFileManager.defaultManager removeItemAtURL:videoInfo.path error:&error]) { + SENTRY_LOG_ERROR(@"Cound not delete replay segment from disk: %@", error); + } +} + +- (void)takeScreenshot +{ + if (_processingScreenshot) { + return; + } + @synchronized(self) { + if (_processingScreenshot) { + return; + } + _processingScreenshot = YES; + } + + UIImage *screenshot = [_screenshotProvider imageWithView:_rootView options:_replayOptions]; + + _processingScreenshot = NO; + + dispatch_queue_t backgroundQueue + = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0); + dispatch_async(backgroundQueue, ^{ [self->_replayMaker addFrameWithImage:screenshot]; }); +} + +@end + +NS_ASSUME_NONNULL_END + +#endif // SENTRY_HAS_UIKIT diff --git a/Sources/Sentry/SentrySessionReplayIntegration.m b/Sources/Sentry/SentrySessionReplayIntegration.m new file mode 100644 index 00000000000..3664c37e058 --- /dev/null +++ b/Sources/Sentry/SentrySessionReplayIntegration.m @@ -0,0 +1,129 @@ +#import "SentrySessionReplayIntegration.h" + +#if SENTRY_HAS_UIKIT && !TARGET_OS_VISION + +# import "SentryClient+Private.h" +# import "SentryDependencyContainer.h" +# import "SentryDisplayLinkWrapper.h" +# import "SentryFileManager.h" +# import "SentryGlobalEventProcessor.h" +# import "SentryHub+Private.h" +# import "SentryOptions.h" +# import "SentryRandom.h" +# import "SentrySDK+Private.h" +# import "SentrySessionReplay.h" +# import "SentrySwift.h" +# import "SentryUIApplication.h" + +NS_ASSUME_NONNULL_BEGIN + +static NSString *SENTRY_REPLAY_FOLDER = @"replay"; + +API_AVAILABLE(ios(16.0), tvos(16.0)) +@interface +SentrySessionReplayIntegration () +@property (nonatomic, strong) SentrySessionReplay *sessionReplay; +@end + +API_AVAILABLE(ios(16.0), tvos(16.0)) +@interface +SentryViewPhotographer (SentryViewScreenshotProvider) +@end + +API_AVAILABLE(ios(16.0), tvos(16.0)) +@interface +SentryOnDemandReplay (SentryReplayMaker) +@end + +@implementation SentrySessionReplayIntegration + +- (BOOL)installWithOptions:(nonnull SentryOptions *)options +{ + if ([super installWithOptions:options] == NO) { + return NO; + } + + if (@available(iOS 16.0, tvOS 16.0, *)) { + SentryReplayOptions *replayOptions = options.experimental.sessionReplay; + + BOOL shouldReplayFullSession = + [self shouldReplayFullSession:replayOptions.sessionSampleRate]; + + if (!shouldReplayFullSession && replayOptions.errorSampleRate == 0) { + return NO; + } + + NSURL *docs = [NSURL + fileURLWithPath:[SentryDependencyContainer.sharedInstance.fileManager sentryPath]]; + docs = [docs URLByAppendingPathComponent:SENTRY_REPLAY_FOLDER]; + NSString *currentSession = [NSUUID UUID].UUIDString; + docs = [docs URLByAppendingPathComponent:currentSession]; + + if (![NSFileManager.defaultManager fileExistsAtPath:docs.path]) { + [NSFileManager.defaultManager createDirectoryAtURL:docs + withIntermediateDirectories:YES + attributes:nil + error:nil]; + } + + SentryOnDemandReplay *replayMaker = + [[SentryOnDemandReplay alloc] initWithOutputPath:docs.path]; + replayMaker.bitRate = replayOptions.replayBitRate; + replayMaker.cacheMaxSize + = (NSInteger)(shouldReplayFullSession ? replayOptions.sessionSegmentDuration + : replayOptions.errorReplayDuration); + + self.sessionReplay = [[SentrySessionReplay alloc] + initWithSettings:replayOptions + replayFolderPath:docs + screenshotProvider:SentryViewPhotographer.shared + replayMaker:replayMaker + dateProvider:SentryDependencyContainer.sharedInstance.dateProvider + random:SentryDependencyContainer.sharedInstance.random + + displayLinkWrapper:[[SentryDisplayLinkWrapper alloc] init]]; + + [self.sessionReplay + start:SentryDependencyContainer.sharedInstance.application.windows.firstObject + fullSession:[self shouldReplayFullSession:replayOptions.sessionSampleRate]]; + + [NSNotificationCenter.defaultCenter addObserver:self + selector:@selector(stop) + name:UIApplicationDidEnterBackgroundNotification + object:nil]; + + [SentryGlobalEventProcessor.shared + addEventProcessor:^SentryEvent *_Nullable(SentryEvent *_Nonnull event) { + [self.sessionReplay captureReplayForEvent:event]; + return event; + }]; + + return YES; + } else { + return NO; + } +} + +- (void)stop +{ + [self.sessionReplay stop]; +} + +- (SentryIntegrationOption)integrationOptions +{ + return kIntegrationOptionEnableReplay; +} + +- (void)uninstall +{ +} + +- (BOOL)shouldReplayFullSession:(CGFloat)rate +{ + return [SentryDependencyContainer.sharedInstance.random nextNumber] < rate; +} + +@end +NS_ASSUME_NONNULL_END + +#endif // SENTRY_HAS_UIKIT diff --git a/Sources/Sentry/include/HybridPublic/SentryEnvelope.h b/Sources/Sentry/include/HybridPublic/SentryEnvelope.h index d9d55084c72..6caa365e029 100644 --- a/Sources/Sentry/include/HybridPublic/SentryEnvelope.h +++ b/Sources/Sentry/include/HybridPublic/SentryEnvelope.h @@ -72,6 +72,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 fed1a34577f..12616156570 100644 --- a/Sources/Sentry/include/HybridPublic/SentryEnvelopeItemType.h +++ b/Sources/Sentry/include/HybridPublic/SentryEnvelopeItemType.h @@ -5,4 +5,5 @@ 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"; static NSString *const SentryEnvelopeItemTypeStatsd = @"statsd"; diff --git a/Sources/Sentry/include/SentryBaseIntegration.h b/Sources/Sentry/include/SentryBaseIntegration.h index f78866b8377..c8adad48fc9 100644 --- a/Sources/Sentry/include/SentryBaseIntegration.h +++ b/Sources/Sentry/include/SentryBaseIntegration.h @@ -22,6 +22,7 @@ typedef NS_OPTIONS(NSUInteger, SentryIntegrationOption) { kIntegrationOptionAttachViewHierarchy = 1 << 15, kIntegrationOptionEnableCrashHandler = 1 << 16, kIntegrationOptionEnableMetricKit = 1 << 17, + kIntegrationOptionEnableReplay = 1 << 18, }; @class SentryOptions; 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/SentryCoreGraphicsHelper.h b/Sources/Sentry/include/SentryCoreGraphicsHelper.h new file mode 100644 index 00000000000..e561984de1b --- /dev/null +++ b/Sources/Sentry/include/SentryCoreGraphicsHelper.h @@ -0,0 +1,13 @@ +#import "SentryDefines.h" +#import +#import + +NS_ASSUME_NONNULL_BEGIN +#if SENTRY_HAS_UIKIT + +@interface SentryCoreGraphicsHelper : NSObject ++ (CGMutablePathRef)excludeRect:(CGRect)rectangle fromPath:(CGMutablePathRef)path; +@end + +#endif +NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/include/SentryDataCategory.h b/Sources/Sentry/include/SentryDataCategory.h index 67da2b9ff49..7b4e281deb1 100644 --- a/Sources/Sentry/include/SentryDataCategory.h +++ b/Sources/Sentry/include/SentryDataCategory.h @@ -15,5 +15,6 @@ typedef NS_ENUM(NSUInteger, SentryDataCategory) { kSentryDataCategoryUserFeedback = 6, kSentryDataCategoryProfile = 7, kSentryDataCategoryMetricBucket = 8, - kSentryDataCategoryUnknown = 9 + kSentryDataCategoryReplay = 9, + kSentryDataCategoryUnknown = 10 }; diff --git a/Sources/Sentry/include/SentryDataCategoryMapper.h b/Sources/Sentry/include/SentryDataCategoryMapper.h index 021f99e71e1..2fcfd9303f4 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 kSentryDataCategoryNameMetricBucket; FOUNDATION_EXPORT NSString *const kSentryDataCategoryNameUnknown; diff --git a/Sources/Sentry/include/SentryDateUtil.h b/Sources/Sentry/include/SentryDateUtil.h index 0b27914b21c..333e20eb208 100644 --- a/Sources/Sentry/include/SentryDateUtil.h +++ b/Sources/Sentry/include/SentryDateUtil.h @@ -13,6 +13,8 @@ SENTRY_NO_INIT + (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 faedf16321d..b2b29f67fb5 100644 --- a/Sources/Sentry/include/SentryEnvelope+Private.h +++ b/Sources/Sentry/include/SentryEnvelope+Private.h @@ -2,6 +2,8 @@ NS_ASSUME_NONNULL_BEGIN +@class SentryReplayEvent; +@class SentryReplayRecording; @class SentryClientReport; @interface @@ -9,6 +11,10 @@ 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 275109fb451..0a95cf674cc 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; @protocol SentryIntegrationProtocol; NS_ASSUME_NONNULL_BEGIN @@ -34,6 +36,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/SentryPrivate.h b/Sources/Sentry/include/SentryPrivate.h index 60473b17298..5d66a9971e1 100644 --- a/Sources/Sentry/include/SentryPrivate.h +++ b/Sources/Sentry/include/SentryPrivate.h @@ -1,10 +1,14 @@ // Sentry internal headers that are needed for swift code - #import "SentryBaggage.h" -#import "SentryBaseIntegration.h" #import "SentryDispatchQueueWrapper.h" #import "SentryNSDataUtils.h" #import "SentryRandom.h" +#import "SentryReplayRecording.h" +#import "SentryReplayType.h" #import "SentrySdkInfo.h" #import "SentryStatsdClient.h" #import "SentryTime.h" + +// Headers that also import SentryDefines should be at the end of this list +// otherwise it wont compile +#import "SentryCoreGraphicsHelper.h" diff --git a/Sources/Sentry/include/SentryReplayEvent.h b/Sources/Sentry/include/SentryReplayEvent.h new file mode 100644 index 00000000000..14a9fd382d5 --- /dev/null +++ b/Sources/Sentry/include/SentryReplayEvent.h @@ -0,0 +1,40 @@ +#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 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..40cedc079dd --- /dev/null +++ b/Sources/Sentry/include/SentryReplayRecording.h @@ -0,0 +1,45 @@ +#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..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 @@ -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; @@ -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/Sources/Sentry/include/SentrySessionReplay.h b/Sources/Sentry/include/SentrySessionReplay.h new file mode 100644 index 00000000000..953f11fdf81 --- /dev/null +++ b/Sources/Sentry/include/SentrySessionReplay.h @@ -0,0 +1,65 @@ +#import "SentryDefines.h" +#import + +#if SENTRY_HAS_UIKIT && !TARGET_OS_VISION +# import + +@class SentryReplayOptions; +@class SentryEvent; +@class SentryCurrentDateProvider; +@class SentryDisplayLinkWrapper; +@class SentryVideoInfo; + +@protocol SentryRandom; +@protocol SentryRedactOptions; + +NS_ASSUME_NONNULL_BEGIN + +@protocol SentryReplayMaker + +- (void)addFrameWithImage:(UIImage *)image; +- (void)releaseFramesUntil:(NSDate *)date; +- (BOOL)createVideoWithDuration:(NSTimeInterval)duration + beginning:(NSDate *)beginning + outputFileURL:(NSURL *)outputFileURL + error:(NSError *_Nullable *_Nullable)error + completion: + (void (^)(SentryVideoInfo *_Nullable, NSError *_Nullable))completion; + +@end + +@protocol SentryViewScreenshotProvider +- (UIImage *)imageWithView:(UIView *)view options:(id)options; +@end + +API_AVAILABLE(ios(16.0), tvos(16.0)) +@interface SentrySessionReplay : NSObject + +- (instancetype)initWithSettings:(SentryReplayOptions *)replayOptions + replayFolderPath:(NSURL *)folderPath + screenshotProvider:(id)photographer + replayMaker:(id)replayMaker + dateProvider:(SentryCurrentDateProvider *)dateProvider + random:(id)random + displayLinkWrapper:(SentryDisplayLinkWrapper *)displayLinkWrapper; + +/** + * Start recording the session using rootView as image source. + * If full is @c YES, we transmit the entire session to sentry. + */ +- (void)start:(UIView *)rootView fullSession:(BOOL)full; + +/** + * Stop recording the session replay + */ +- (void)stop; + +/** + * Captures a replay for given event. + */ +- (void)captureReplayForEvent:(SentryEvent *)event; + +@end + +NS_ASSUME_NONNULL_END +#endif // SENTRY_HAS_UIKIT diff --git a/Sources/Sentry/include/SentrySessionReplayIntegration.h b/Sources/Sentry/include/SentrySessionReplayIntegration.h new file mode 100644 index 00000000000..4500aeaa3d9 --- /dev/null +++ b/Sources/Sentry/include/SentrySessionReplayIntegration.h @@ -0,0 +1,12 @@ +#import "SentryBaseIntegration.h" +#import "SentryDefines.h" +#import "SentrySwift.h" +#import + +NS_ASSUME_NONNULL_BEGIN +#if SENTRY_HAS_UIKIT && !TARGET_OS_VISION +@interface SentrySessionReplayIntegration : SentryBaseIntegration + +@end +#endif // SENTRY_HAS_UIKIT && !TARGET_OS_VISION +NS_ASSUME_NONNULL_END diff --git a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift new file mode 100644 index 00000000000..8c069b3c540 --- /dev/null +++ b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift @@ -0,0 +1,188 @@ +#if canImport(UIKit) && !SENTRY_NO_UIKIT +#if os(iOS) || os(tvOS) + +@_implementationOnly import _SentryPrivate +import AVFoundation +import CoreGraphics +import Foundation +import UIKit + +struct SentryReplayFrame { + let imagePath: String + let time: Date + + init(imagePath: String, time: Date) { + self.imagePath = imagePath + self.time = time + } +} + +enum SentryOnDemandReplayError: Error { + case cantReadVideoSize +} + +@available(iOS 16.0, tvOS 16.0, *) +@objcMembers +class SentryOnDemandReplay: NSObject { + private let _outputPath: String + private let _onDemandDispatchQueue: DispatchQueue + + private var _starttime = Date() + private var _frames = [SentryReplayFrame]() + private var _currentPixelBuffer: SentryPixelBuffer? + + var videoWidth = 200 + var videoHeight = 434 + + var bitRate = 20_000 + var frameRate = 1 + var cacheMaxSize = UInt.max + + init(outputPath: String) { + self._outputPath = outputPath + _onDemandDispatchQueue = DispatchQueue(label: "io.sentry.sessionreplay.ondemand") + } + + func addFrame(image: UIImage) { + _onDemandDispatchQueue.async { + self.asyncAddFrame(image: image) + } + } + + private func asyncAddFrame(image: UIImage) { + guard let data = resizeImage(image, maxWidth: 300)?.pngData() else { return } + + let date = Date() + let interval = date.timeIntervalSince(_starttime) + let imagePath = (_outputPath as NSString).appendingPathComponent("\(interval).png") + do { + try data.write(to: URL(fileURLWithPath: imagePath)) + } catch { + print("[SentryOnDemandReplay] Could not save replay frame. Error: \(error)") + return + } + _frames.append(SentryReplayFrame(imagePath: imagePath, time: date)) + + while _frames.count > cacheMaxSize { + let first = _frames.removeFirst() + try? FileManager.default.removeItem(at: URL(fileURLWithPath: first.imagePath)) + } + } + + private func resizeImage(_ originalImage: UIImage, maxWidth: CGFloat) -> UIImage? { + let originalSize = originalImage.size + let aspectRatio = originalSize.width / originalSize.height + + let newWidth = min(originalSize.width, maxWidth) + let newHeight = newWidth / aspectRatio + + let newSize = CGSize(width: newWidth, height: newHeight) + + UIGraphicsBeginImageContextWithOptions(newSize, false, 1) + originalImage.draw(in: CGRect(x: 0, y: 0, width: newSize.width, height: newSize.height)) + let resizedImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return resizedImage + } + + func releaseFramesUntil(_ date: Date) { + _onDemandDispatchQueue.async { + while let first = self._frames.first, first.time < date { + self._frames.removeFirst() + try? FileManager.default.removeItem(at: URL(fileURLWithPath: first.imagePath)) + } + } + } + + func createVideoWith(duration: TimeInterval, beginning: Date, outputFileURL: URL, completion: @escaping (SentryVideoInfo?, Error?) -> Void) throws { + let videoWriter = try AVAssetWriter(url: outputFileURL, fileType: .mov) + + let videoSettings = createVideoSettings() + + let videoWriterInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings) + let bufferAttributes: [String: Any] = [ + String(kCVPixelBufferPixelFormatTypeKey): kCVPixelFormatType_32ARGB + ] + + let pixelBufferAdaptor = AVAssetWriterInputPixelBufferAdaptor(assetWriterInput: videoWriterInput, sourcePixelBufferAttributes: bufferAttributes) + + videoWriter.add(videoWriterInput) + videoWriter.startWriting() + videoWriter.startSession(atSourceTime: .zero) + + var frameCount = 0 + let (frames, start, end) = filterFrames(beginning: beginning, end: beginning.addingTimeInterval(duration)) + + if frames.isEmpty { return } + + _currentPixelBuffer = SentryPixelBuffer(size: CGSize(width: videoWidth, height: videoHeight)) + + videoWriterInput.requestMediaDataWhenReady(on: _onDemandDispatchQueue) { [weak self] in + guard let self = self else { return } + + if frameCount < frames.count { + let imagePath = frames[frameCount] + + if let image = UIImage(contentsOfFile: imagePath) { + let presentTime = CMTime(seconds: Double(frameCount), preferredTimescale: CMTimeScale(self.frameRate)) + guard self._currentPixelBuffer?.append(image: image, pixelBufferAdapter: pixelBufferAdaptor, presentationTime: presentTime) == true else { + completion(nil, videoWriter.error) + videoWriterInput.markAsFinished() + return + } + } + frameCount += 1 + } else { + videoWriterInput.markAsFinished() + videoWriter.finishWriting { + var videoInfo: SentryVideoInfo? + if videoWriter.status == .completed { + do { + let fileAttributes = try FileManager.default.attributesOfItem(atPath: outputFileURL.path) + guard let fileSize = fileAttributes[FileAttributeKey.size] as? Int else { + completion(nil, SentryOnDemandReplayError.cantReadVideoSize) + return + } + videoInfo = SentryVideoInfo(path: outputFileURL, height: self.videoHeight, width: self.videoWidth, duration: TimeInterval(frames.count / self.frameRate), frameCount: frames.count, frameRate: self.frameRate, start: start, end: end, fileSize: fileSize) + } catch { + completion(nil, error) + } + } + completion(videoInfo, videoWriter.error) + } + } + } + } + + private func filterFrames(beginning: Date, end: Date) -> ([String], firstFrame: Date, lastFrame: Date) { + var frames = [String]() + + var start = Date() + var actualEnd = Date() + + for frame in _frames { + if frame.time < beginning { continue } else if frame.time > end { break } + if frame.time < start { start = frame.time } + + actualEnd = frame.time + frames.append(frame.imagePath) + } + return (frames, start, actualEnd) + } + + private func createVideoSettings() -> [String: Any] { + return [ + AVVideoCodecKey: AVVideoCodecType.h264, + AVVideoWidthKey: videoWidth, + AVVideoHeightKey: videoHeight, + AVVideoCompressionPropertiesKey: [ + AVVideoAverageBitRateKey: bitRate, + AVVideoProfileLevelKey: AVVideoProfileLevelH264BaselineAutoLevel + ] as [String: Any] + ] + } +} + +#endif // os(iOS) || os(tvOS) +#endif // canImport(UIKit) diff --git a/Sources/Swift/Integrations/SessionReplay/SentryPixelBuffer.swift b/Sources/Swift/Integrations/SessionReplay/SentryPixelBuffer.swift new file mode 100644 index 00000000000..264e2b5c056 --- /dev/null +++ b/Sources/Swift/Integrations/SessionReplay/SentryPixelBuffer.swift @@ -0,0 +1,49 @@ +#if canImport(UIKit) && !SENTRY_NO_UIKIT +#if os(iOS) || os(tvOS) + +import AVFoundation +import CoreGraphics +import Foundation +import UIKit + +class SentryPixelBuffer { + private var pixelBuffer: CVPixelBuffer? + private let rgbColorSpace = CGColorSpaceCreateDeviceRGB() + private let size: CGSize + + init?(size: CGSize) { + self.size = size + let status = CVPixelBufferCreate(kCFAllocatorDefault, Int(size.width), Int(size.height), kCVPixelFormatType_32ARGB, nil, &pixelBuffer) + if status != kCVReturnSuccess { + return nil + } + } + + func append(image: UIImage, pixelBufferAdapter: AVAssetWriterInputPixelBufferAdaptor, presentationTime: CMTime) -> Bool { + guard let pixelBuffer = pixelBuffer else { return false } + + CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly) + let pixelData = CVPixelBufferGetBaseAddress(pixelBuffer) + + guard + let cgimage = image.cgImage, + let context = CGContext( + data: pixelData, + width: Int(size.width), + height: Int(size.height), + bitsPerComponent: 8, + bytesPerRow: CVPixelBufferGetBytesPerRow(pixelBuffer), + space: rgbColorSpace, + bitmapInfo: CGImageAlphaInfo.noneSkipFirst.rawValue + ) else { + return false + } + + context.draw(cgimage, in: CGRect(x: 0, y: 0, width: size.width, height: size.height)) + CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) + + return pixelBufferAdapter.append(pixelBuffer, withPresentationTime: presentationTime) + } +} +#endif // os(iOS) || os(tvOS) +#endif // canImport(UIKit) && !SENTRY_NO_UIKIT diff --git a/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift b/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift new file mode 100644 index 00000000000..ce0f3fcfb32 --- /dev/null +++ b/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift @@ -0,0 +1,101 @@ +import Foundation + +@objcMembers +public class SentryReplayOptions: NSObject, SentryRedactOptions { + /** + * Indicates the percentage in which the replay for the session will be created. + * - 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 0. + */ + public var sessionSampleRate: Float + + /** + * Indicates the percentage in which a 30 seconds replay will be send with error events. + * - Specifying 0 means never, 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 0. + */ + public var errorSampleRate: Float + + /** + * Indicates whether session replay should redact all text in the app + * by drawing a black rectangle over it. + * + * - note: The default is true + */ + public var redactAllText = true + + /** + * Indicates whether session replay should redact all non-bundled image + * in the app by drawing a black rectangle over it. + * + * - note: The default is true + */ + public var redactAllImages = true + + /** + * 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; + */ + let replayBitRate = 20_000 + + /** + * Number of frames per second of the replay. + * The more the havier the process is. + */ + let frameRate = 1 + + /** + * The scale related to the window size at which the replay will be created + */ + let sizeScale = 0.8 + + /** + * The maximum duration of replays for error events. + */ + let errorReplayDuration = TimeInterval(30) + + /** + * The maximum duration of the segment of a session replay. + */ + let sessionSegmentDuration = TimeInterval(5) + + /** + * The maximum duration of a replay session. + */ + let maximumDuration = TimeInterval(3_600) + + /** + * Inittialize session replay options disabled + */ + public override init() { + self.sessionSampleRate = 0 + self.errorSampleRate = 0 + } + + /** + * Initialize session replay options + * - parameters: + * - sessionSampleRate Indicates the percentage in which the replay for the session will be created. + * - errorSampleRate Indicates the percentage in which a 30 seconds replay will be send with + * error events. + */ + public init(sessionSampleRate: Float = 0, errorSampleRate: Float = 0, redactAllText: Bool = true, redactAllImages: Bool = true) { + self.sessionSampleRate = sessionSampleRate + self.errorSampleRate = errorSampleRate + self.redactAllText = redactAllText + self.redactAllImages = redactAllImages + } + + convenience init(dictionary: [String: Any]) { + let sessionSampleRate = (dictionary["sessionSampleRate"] as? NSNumber)?.floatValue ?? 0 + let onErrorSampleRate = (dictionary["errorSampleRate"] as? NSNumber)?.floatValue ?? 0 + let redactAllText = (dictionary["redactAllText"] as? NSNumber)?.boolValue ?? true + let redactAllImages = (dictionary["redactAllImages"] as? NSNumber)?.boolValue ?? true + self.init(sessionSampleRate: sessionSampleRate, errorSampleRate: onErrorSampleRate, redactAllText: redactAllText, redactAllImages: redactAllImages) + } +} diff --git a/Sources/Swift/Integrations/SessionReplay/SentryVideoInfo.swift b/Sources/Swift/Integrations/SessionReplay/SentryVideoInfo.swift new file mode 100644 index 00000000000..2d7518f9e7b --- /dev/null +++ b/Sources/Swift/Integrations/SessionReplay/SentryVideoInfo.swift @@ -0,0 +1,28 @@ +import Foundation + +@objcMembers +class SentryVideoInfo: NSObject { + + let path: URL + let height: Int + let width: Int + let duration: TimeInterval + let frameCount: Int + let frameRate: Int + let start: Date + let end: Date + let fileSize: Int + + init(path: URL, height: Int, width: Int, duration: TimeInterval, frameCount: Int, frameRate: Int, start: Date, end: Date, fileSize: Int) { + self.height = height + self.width = width + self.duration = duration + self.frameCount = frameCount + self.frameRate = frameRate + self.start = start + self.end = end + self.path = path + self.fileSize = fileSize + } + +} diff --git a/Sources/Swift/Protocol/SentryRedactOptions.swift b/Sources/Swift/Protocol/SentryRedactOptions.swift new file mode 100644 index 00000000000..cdd38e819a1 --- /dev/null +++ b/Sources/Swift/Protocol/SentryRedactOptions.swift @@ -0,0 +1,7 @@ +import Foundation + +@objc +protocol SentryRedactOptions { + var redactAllText: Bool { get } + var redactAllImages: Bool { get } +} diff --git a/Sources/Swift/SentryExperimentalOptions.swift b/Sources/Swift/SentryExperimentalOptions.swift new file mode 100644 index 00000000000..9cf1a1947fd --- /dev/null +++ b/Sources/Swift/SentryExperimentalOptions.swift @@ -0,0 +1,18 @@ +@objcMembers +public class SentryExperimentalOptions: NSObject { + #if canImport(UIKit) + /** + * Settings to configure the session replay. + */ + public var sessionReplay = SentryReplayOptions(sessionSampleRate: 0, errorSampleRate: 0) + #endif + + func validateOptions(_ options: [String: Any]?) { + #if canImport(UIKit) + if let sessionReplayOptions = options?["sessionReplay"] as? [String: Any] { + sessionReplay = SentryReplayOptions(dictionary: sessionReplayOptions) + } + #endif + } + +} diff --git a/Sources/Swift/Tools/SentryViewPhotographer.swift b/Sources/Swift/Tools/SentryViewPhotographer.swift new file mode 100644 index 00000000000..36667e4dec2 --- /dev/null +++ b/Sources/Swift/Tools/SentryViewPhotographer.swift @@ -0,0 +1,115 @@ +#if canImport(UIKit) && !SENTRY_NO_UIKIT +#if os(iOS) || os(tvOS) + +@_implementationOnly import _SentryPrivate +import CoreGraphics +import Foundation +import UIKit + +@available(iOS, introduced: 16.0) +@available(tvOS, introduced: 16.0) +@objcMembers +class SentryViewPhotographer: NSObject { + + //This is a list of UIView subclasses that will be ignored during redact process + private var ignoreClasses: [AnyClass] = [] + //This is a list of UIView subclasses that need to be redacted from screenshot + private var redactClasses: [AnyClass] = [] + + static let shared = SentryViewPhotographer() + + override init() { +#if os(iOS) + ignoreClasses = [ UISlider.self, UISwitch.self ] +#endif // os(iOS) + redactClasses = [ UILabel.self, UITextView.self, UITextField.self ] + [ + "_TtCOCV7SwiftUI11DisplayList11ViewUpdater8Platform13CGDrawingView", + "_TtC7SwiftUIP33_A34643117F00277B93DEBAB70EC0697122_UIShapeHitTestingView", + "SwiftUI._UIGraphicsView", "SwiftUI.ImageLayer" + ].compactMap { NSClassFromString($0) } + } + + @objc(imageWithView:options:) + func image(view: UIView, options: SentryRedactOptions) -> UIImage? { + UIGraphicsBeginImageContextWithOptions(view.bounds.size, true, 0) + + defer { + UIGraphicsEndImageContext() + } + + guard let currentContext = UIGraphicsGetCurrentContext() else { return nil } + + view.layer.render(in: currentContext) + self.mask(view: view, context: currentContext, options: options) + + guard let screenshot = UIGraphicsGetImageFromCurrentImageContext() else { return nil } + return screenshot + } + + private func mask(view: UIView, context: CGContext, options: SentryRedactOptions?) { + UIColor.black.setFill() + let maskPath = self.buildPath(view: view, + path: CGMutablePath(), + area: view.frame, + redactText: options?.redactAllText ?? true, + redactImage: options?.redactAllImages ?? true) + context.addPath(maskPath) + context.fillPath() + } + + private func shouldIgnore(view: UIView) -> Bool { + ignoreClasses.contains { view.isKind(of: $0) } + } + + private func shouldRedact(view: UIView) -> Bool { + return redactClasses.contains { view.isKind(of: $0) } + } + + private func shouldRedact(imageView: UIImageView) -> Bool { + // Checking the size is to avoid redact gradient backgroud that + // are usually small lines repeating + guard let image = imageView.image, image.size.width > 10 && image.size.height > 10 else { return false } + return image.imageAsset?.value(forKey: "_containingBundle") == nil + } + + private func buildPath(view: UIView, path: CGMutablePath, area: CGRect, redactText: Bool, redactImage: Bool) -> CGMutablePath { + let rectInWindow = view.convert(view.bounds, to: nil) + + if (!redactImage && !redactText) || !area.intersects(rectInWindow) || view.isHidden || view.alpha == 0 { + return path + } + + var result = path + + let ignore = shouldIgnore(view: view) + + let redact: Bool = { + if redactImage, let imageView = view as? UIImageView { + return shouldRedact(imageView: imageView) + } + return redactText && shouldRedact(view: view) + }() + + if !ignore && redact { + result.addRect(rectInWindow) + return result + } else if isOpaqueOrHasBackground(view) { + result = SentryCoreGraphicsHelper.excludeRect(rectInWindow, from: result).takeRetainedValue() + } + + if !ignore { + for subview in view.subviews { + result = buildPath(view: subview, path: path, area: area, redactText: redactText, redactImage: redactImage) + } + } + + return result + } + + private func isOpaqueOrHasBackground(_ view: UIView) -> Bool { + return view.isOpaque || (view.backgroundColor != nil && (view.backgroundColor?.cgColor.alpha ?? 0) > 0.9) + } +} + +#endif // os(iOS) || os(tvOS) +#endif // canImport(UIKit) && !SENTRY_NO_UIKIT diff --git a/Tests/SentryTests/Helper/SentryDateUtilTests.swift b/Tests/SentryTests/Helper/SentryDateUtilTests.swift index ebb161c9f31..17898e59432 100644 --- a/Tests/SentryTests/Helper/SentryDateUtilTests.swift +++ b/Tests/SentryTests/Helper/SentryDateUtilTests.swift @@ -1,3 +1,4 @@ +import Nimble import SentryTestUtils import XCTest @@ -55,4 +56,11 @@ class SentryDateUtilTests: XCTestCase { XCTAssertNil(SentryDateUtil.getMaximumDate(nil, andOther: nil)) } + func testJavascriptDate() { + let testDate = Date(timeIntervalSince1970: 60) + let timestamp = SentryDateUtil.millisecondsSince1970(testDate) + + expect(timestamp) == 60_000 + } + } 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) diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentryReplayEventTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentryReplayEventTests.swift new file mode 100644 index 00000000000..00dc0162ab8 --- /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.eventId = 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 + 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/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift new file mode 100644 index 00000000000..57af570ad3f --- /dev/null +++ b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift @@ -0,0 +1,73 @@ +import Foundation +import Nimble +@testable import Sentry +import SentryTestUtils +import XCTest + +#if os(iOS) || os(tvOS) + +class SentrySessionReplayIntegrationTests: XCTestCase { + + override func setUpWithError() throws { + guard #available(iOS 16.0, tvOS 16.0, *) else { + throw XCTSkip("iOS version not supported") + } + } + + override func tearDown() { + super.tearDown() + clearTestState() + } + + func startSDK(sessionSampleRate: Float, errorSampleRate: Float) { + if #available(iOS 16.0, tvOS 16.0, *) { + SentrySDK.start { + $0.experimental.sessionReplay = SentryReplayOptions(sessionSampleRate: sessionSampleRate, errorSampleRate: errorSampleRate) + $0.setIntegrations([SentrySessionReplayIntegration.self]) + } + } + } + + func testNoInstall() { + startSDK(sessionSampleRate: 0, errorSampleRate: 0) + + expect(SentrySDK.currentHub().trimmedInstalledIntegrationNames().count) == 0 + expect(SentryGlobalEventProcessor.shared().processors.count) == 0 + } + + func testInstallFullSessionReplay() { + startSDK(sessionSampleRate: 1, errorSampleRate: 0) + + expect(SentrySDK.currentHub().trimmedInstalledIntegrationNames().count) == 1 + expect(SentryGlobalEventProcessor.shared().processors.count) == 1 + } + + func testNoInstallFullSessionReplayBecauseOfRandom() { + + SentryDependencyContainer.sharedInstance().random = TestRandom(value: 0.3) + + startSDK(sessionSampleRate: 0.2, errorSampleRate: 0) + + expect(SentrySDK.currentHub().trimmedInstalledIntegrationNames().count) == 0 + expect(SentryGlobalEventProcessor.shared().processors.count) == 0 + } + + func testInstallFullSessionReplayBecauseOfRandom() { + + SentryDependencyContainer.sharedInstance().random = TestRandom(value: 0.1) + + startSDK(sessionSampleRate: 0.2, errorSampleRate: 0) + + expect(SentrySDK.currentHub().trimmedInstalledIntegrationNames().count) == 1 + expect(SentryGlobalEventProcessor.shared().processors.count) == 1 + } + + func testInstallErrorReplay() { + startSDK(sessionSampleRate: 0, errorSampleRate: 0.1) + + expect(SentrySDK.currentHub().trimmedInstalledIntegrationNames().count) == 1 + expect(SentryGlobalEventProcessor.shared().processors.count) == 1 + } +} + +#endif diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift new file mode 100644 index 00000000000..1925b4eb2e9 --- /dev/null +++ b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift @@ -0,0 +1,210 @@ +import Foundation +import Nimble +@testable import Sentry +import SentryTestUtils +import XCTest + +#if os(iOS) || os(tvOS) +class SentrySessionReplayTests: XCTestCase { + + private class ScreenshotProvider: NSObject, SentryViewScreenshotProvider { + func image(with view: UIView, options: SentryRedactOptions) -> UIImage { UIImage.add } + } + + private class TestReplayMaker: NSObject, SentryReplayMaker { + struct CreateVideoCall { + var duration: TimeInterval + var beginning: Date + var outputFileURL: URL + var completion: ((Sentry.SentryVideoInfo?, Error?) -> Void) + } + + var lastCallToCreateVideo: CreateVideoCall? + func createVideo(withDuration duration: TimeInterval, beginning: Date, outputFileURL: URL, completion: @escaping (SentryVideoInfo?, Error?) -> Void) throws { + lastCallToCreateVideo = CreateVideoCall(duration: duration, + beginning: beginning, + outputFileURL: outputFileURL, + completion: completion) + + try? "Video Data".write(to: outputFileURL, atomically: true, encoding: .utf8) + + let videoInfo = SentryVideoInfo(path: outputFileURL, height: 1_024, width: 480, duration: duration, frameCount: 5, frameRate: 1, start: beginning, end: beginning.addingTimeInterval(duration), fileSize: 10) + + completion(videoInfo, nil) + } + + var lastFrame: UIImage? + func addFrame(with image: UIImage) { + lastFrame = image + } + + var lastReleaseUntil: Date? + func releaseFrames(until date: Date) { + lastReleaseUntil = date + } + } + + private class ReplayHub: SentryHub { + var lastEvent: SentryReplayEvent? + var lastRecording: SentryReplayRecording? + var lastVideo: URL? + + override func capture(_ replayEvent: SentryReplayEvent, replayRecording: SentryReplayRecording, video videoURL: URL) { + lastEvent = replayEvent + lastRecording = replayRecording + lastVideo = videoURL + } + } + + @available(iOS 16.0, tvOS 16.0, *) + private class Fixture { + let dateProvider = TestCurrentDateProvider() + let random = TestRandom(value: 0) + let screenshotProvider = ScreenshotProvider() + let displayLink = TestDisplayLinkWrapper() + let rootView = UIView() + let hub = ReplayHub(client: nil, andScope: nil) + let replayMaker = TestReplayMaker() + let cacheFolder = FileManager.default.temporaryDirectory + + func getSut(options: SentryReplayOptions = .init(sessionSampleRate: 0, errorSampleRate: 0) ) -> SentrySessionReplay { + return SentrySessionReplay(settings: options, + replayFolderPath: cacheFolder, + screenshotProvider: screenshotProvider, + replayMaker: replayMaker, + dateProvider: dateProvider, + random: random, + displayLinkWrapper: displayLink) + } + } + + override func setUpWithError() throws { + guard #available(iOS 16.0, tvOS 16.0, *) else { + throw XCTSkip("iOS version not supported") + } + } + + override func setUp() { + super.setUp() + } + + override func tearDown() { + super.tearDown() + clearTestState() + } + + @available(iOS 16.0, tvOS 16, *) + private func startFixture() -> Fixture { + let fixture = Fixture() + SentrySDK.setCurrentHub(fixture.hub) + return fixture + } + + @available(iOS 16.0, tvOS 16, *) + func testDontSentReplay_NoFullSession() { + let fixture = startFixture() + let sut = fixture.getSut() + sut.start(fixture.rootView, fullSession: false) + + fixture.dateProvider.advance(by: 1) + Dynamic(sut).newFrame(nil) + fixture.dateProvider.advance(by: 5) + Dynamic(sut).newFrame(nil) + + expect(fixture.hub.lastEvent) == nil + } + + @available(iOS 16.0, tvOS 16, *) + func testSentReplay_FullSession() { + let fixture = startFixture() + let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, errorSampleRate: 1)) + sut.start(fixture.rootView, fullSession: true) + + fixture.dateProvider.advance(by: 1) + + let start = fixture.dateProvider.date() + + Dynamic(sut).newFrame(nil) + fixture.dateProvider.advance(by: 5) + Dynamic(sut).newFrame(nil) + + guard let videoArguments = fixture.replayMaker.lastCallToCreateVideo else { + fail("Replay maker create video was not called") + return + } + + expect(videoArguments.duration) == 5 + expect(videoArguments.beginning) == start + expect(videoArguments.outputFileURL) == fixture.cacheFolder.appendingPathComponent("segments/0.mp4") + + expect(fixture.hub.lastRecording) != nil + expect(fixture.hub.lastVideo) == videoArguments.outputFileURL + assertFullSession(sut, expected: true) + } + + @available(iOS 16.0, tvOS 16, *) + func testDontSentReplay_NotFullSession() { + let fixture = startFixture() + let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, errorSampleRate: 1)) + sut.start(fixture.rootView, fullSession: false) + + fixture.dateProvider.advance(by: 1) + + Dynamic(sut).newFrame(nil) + fixture.dateProvider.advance(by: 5) + Dynamic(sut).newFrame(nil) + + let videoArguments = fixture.replayMaker.lastCallToCreateVideo + + expect(videoArguments) == nil + assertFullSession(sut, expected: false) + } + + @available(iOS 16.0, tvOS 16, *) + func testChangeReplayMode_forErrorEvent() { + let fixture = startFixture() + let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, errorSampleRate: 1)) + sut.start(fixture.rootView, fullSession: false) + + let event = Event(error: NSError(domain: "Some error", code: 1)) + + sut.capture(for: event) + assertFullSession(sut, expected: true) + } + + @available(iOS 16.0, tvOS 16, *) + func testDontChangeReplayMode_forNonErrorEvent() { + let fixture = startFixture() + let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, errorSampleRate: 1)) + sut.start(fixture.rootView, fullSession: false) + + let event = Event(level: .info) + + sut.capture(for: event) + + assertFullSession(sut, expected: false) + } + + @available(iOS 16.0, tvOS 16, *) + func testSessionReplayMaximumDuration() { + let fixture = startFixture() + let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, errorSampleRate: 1)) + sut.start(fixture.rootView, fullSession: true) + + Dynamic(sut).newFrame(nil) + fixture.dateProvider.advance(by: 5) + Dynamic(sut).newFrame(nil) + expect(Dynamic(sut).isRunning) == true + fixture.dateProvider.advance(by: 3_600) + Dynamic(sut).newFrame(nil) + + expect(Dynamic(sut).isRunning) == false + } + + @available(iOS 16.0, tvOS 16, *) + func assertFullSession(_ sessionReplay: SentrySessionReplay, expected: Bool) { + expect(Dynamic(sessionReplay).isFullSession) == expected + } +} + +#endif diff --git a/Tests/SentryTests/Networking/SentryDataCategoryMapperTests.swift b/Tests/SentryTests/Networking/SentryDataCategoryMapperTests.swift index 35b05344e2f..0d6175ef157 100644 --- a/Tests/SentryTests/Networking/SentryDataCategoryMapperTests.swift +++ b/Tests/SentryTests/Networking/SentryDataCategoryMapperTests.swift @@ -11,6 +11,7 @@ class SentryDataCategoryMapperTests: XCTestCase { expect(sentryDataCategoryForEnvelopItemType("attachment")) == .attachment expect(sentryDataCategoryForEnvelopItemType("profile")) == .profile expect(sentryDataCategoryForEnvelopItemType("statsd")) == .metricBucket + expect(sentryDataCategoryForEnvelopItemType("replay_video")) == .replay expect(sentryDataCategoryForEnvelopItemType("unknown item type")) == .default } @@ -24,7 +25,8 @@ class SentryDataCategoryMapperTests: XCTestCase { expect(sentryDataCategoryForNSUInteger(6)) == .userFeedback expect(sentryDataCategoryForNSUInteger(7)) == .profile expect(sentryDataCategoryForNSUInteger(8)) == .metricBucket - 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 +41,7 @@ class SentryDataCategoryMapperTests: XCTestCase { expect(sentryDataCategoryForString(kSentryDataCategoryNameUserFeedback)) == .userFeedback expect(sentryDataCategoryForString(kSentryDataCategoryNameProfile)) == .profile expect(sentryDataCategoryForString(kSentryDataCategoryNameMetricBucket)) == .metricBucket + expect(sentryDataCategoryForString(kSentryDataCategoryNameReplay)) == .replay expect(sentryDataCategoryForString(kSentryDataCategoryNameUnknown)) == .unknown XCTAssertEqual(.unknown, sentryDataCategoryForString("gdfagdfsa"), "Failed to map unknown category name to case .unknown") @@ -54,6 +57,7 @@ class SentryDataCategoryMapperTests: XCTestCase { expect(nameForSentryDataCategory(.userFeedback)) == kSentryDataCategoryNameUserFeedback expect(nameForSentryDataCategory(.profile)) == kSentryDataCategoryNameProfile expect(nameForSentryDataCategory(.metricBucket)) == kSentryDataCategoryNameMetricBucket + expect(nameForSentryDataCategory(.replay)) == kSentryDataCategoryNameReplay expect(nameForSentryDataCategory(.unknown)) == kSentryDataCategoryNameUnknown } } diff --git a/Tests/SentryTests/Protocol/SentryEnvelopeTests.swift b/Tests/SentryTests/Protocol/SentryEnvelopeTests.swift index d5ff4eb7ddb..49f771008ba 100644 --- a/Tests/SentryTests/Protocol/SentryEnvelopeTests.swift +++ b/Tests/SentryTests/Protocol/SentryEnvelopeTests.swift @@ -237,6 +237,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 ca9dfdc8a77..14e83c0c935 100644 --- a/Tests/SentryTests/SentryClientTests.swift +++ b/Tests/SentryTests/SentryClientTests.swift @@ -1587,6 +1587,90 @@ 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 + } + + 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() diff --git a/Tests/SentryTests/SentryHubTests.swift b/Tests/SentryTests/SentryHubTests.swift index cea9a3eb3c9..18cf26b0931 100644 --- a/Tests/SentryTests/SentryHubTests.swift +++ b/Tests/SentryTests/SentryHubTests.swift @@ -744,6 +744,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/SentryOptionsTest.m b/Tests/SentryTests/SentryOptionsTest.m index 693a84ad9c8..8e345e638ff 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], @@ -604,6 +605,8 @@ - (void)assertDefaultValues:(SentryOptions *)options XCTAssertEqual(options.enableUserInteractionTracing, YES); XCTAssertEqual(options.enablePreWarmedAppStartTracing, NO); XCTAssertEqual(options.attachViewHierarchy, NO); + XCTAssertEqual(options.experimental.sessionReplay.errorSampleRate, 0); + XCTAssertEqual(options.experimental.sessionReplay.sessionSampleRate, 0); #endif XCTAssertFalse(options.enableTracing); XCTAssertTrue(options.enableAppHangTracking); @@ -781,6 +784,27 @@ - (void)testEnablePreWarmedAppStartTracking [self testBooleanField:@"enablePreWarmedAppStartTracing" defaultValue:NO]; } +- (void)testSessionReplaySettingsInit +{ + if (@available(iOS 16.0, tvOS 16.0, *)) { + SentryOptions *options = [self getValidOptions:@{ + @"experimental" : + @ { @"sessionReplay" : @ { @"sessionSampleRate" : @2, @"errorSampleRate" : @4 } } + }]; + XCTAssertEqual(options.experimental.sessionReplay.sessionSampleRate, 2); + XCTAssertEqual(options.experimental.sessionReplay.errorSampleRate, 4); + } +} + +- (void)testSessionReplaySettingsDefaults +{ + if (@available(iOS 16.0, tvOS 16.0, *)) { + SentryOptions *options = [self getValidOptions:@{ @"sessionReplayOptions" : @ {} }]; + XCTAssertEqual(options.experimental.sessionReplay.sessionSampleRate, 0); + XCTAssertEqual(options.experimental.sessionReplay.errorSampleRate, 0); + } +} + #endif #if SENTRY_HAS_METRIC_KIT diff --git a/Tests/SentryTests/SentryTests-Bridging-Header.h b/Tests/SentryTests/SentryTests-Bridging-Header.h index 2df351f5a29..dd54ed6b6b0 100644 --- a/Tests/SentryTests/SentryTests-Bridging-Header.h +++ b/Tests/SentryTests/SentryTests-Bridging-Header.h @@ -12,6 +12,8 @@ #if SENTRY_HAS_UIKIT # import "MockUIScene.h" # import "SentryFramesTracker+TestInit.h" +# import "SentrySessionReplay.h" +# import "SentrySessionReplayIntegration.h" # import "SentryUIApplication+Private.h" # import "SentryUIApplication.h" # import "SentryUIDeviceWrapper.h" @@ -36,6 +38,7 @@ #import "NSMutableDictionary+Sentry.h" #import "NSURLProtocolSwizzle.h" #import "PrivateSentrySDKOnly.h" +#import "Sentry/Sentry-Swift.h" #import "SentryANRTracker.h" #import "SentryANRTrackingIntegration.h" #import "SentryAppStartMeasurement.h" @@ -59,6 +62,7 @@ #import "SentryCoreDataSwizzling.h" #import "SentryCoreDataTracker+Test.h" #import "SentryCoreDataTrackingIntegration.h" +#import "SentryCrashBinaryImageCache.h" #import "SentryCrashBinaryImageProvider.h" #import "SentryCrashC.h" #import "SentryCrashDebug.h" @@ -94,13 +98,17 @@ #import "SentryDiscardReason.h" #import "SentryDiscardReasonMapper.h" #import "SentryDiscardedEvent.h" +#import "SentryDispatchFactory.h" #import "SentryDispatchQueueWrapper.h" +#import "SentryDispatchSourceWrapper.h" #import "SentryDisplayLinkWrapper.h" #import "SentryDsn.h" #import "SentryEnvelope+Private.h" +#import "SentryEnvelopeAttachmentHeader.h" #import "SentryEnvelopeItemType.h" #import "SentryEnvelopeRateLimit.h" #import "SentryEvent+Private.h" +#import "SentryExtraContextProvider.h" #import "SentryFileContents.h" #import "SentryFileIOTrackingIntegration.h" #import "SentryFileManager+Test.h" @@ -125,10 +133,12 @@ #import "SentryLog+TestInit.h" #import "SentryLog.h" #import "SentryLogOutput.h" +#import "SentryMeasurementValue.h" #import "SentryMechanism.h" #import "SentryMechanismMeta.h" #import "SentryMeta.h" #import "SentryMigrateSessionInit.h" +#import "SentryMsgPackSerializer.h" #import "SentryNSDataTracker.h" #import "SentryNSDataUtils.h" #import "SentryNSError.h" @@ -144,16 +154,20 @@ #import "SentryObjCRuntimeWrapper.h" #import "SentryOptions+HybridSDKs.h" #import "SentryOptions+Private.h" +#import "SentryPerformanceTracker+Testing.h" #import "SentryPerformanceTracker.h" #import "SentryPerformanceTrackingIntegration.h" #import "SentryPredicateDescriptor.h" +#import "SentryPropagationContext.h" #import "SentryQueueableRequestManager.h" #import "SentryRateLimitParser.h" #import "SentryRateLimits.h" #import "SentryReachability.h" +#import "SentryReplayEvent.h" #import "SentryRetryAfterHeaderParser.h" #import "SentrySDK+Private.h" #import "SentrySDK+Tests.h" +#import "SentrySampleDecision+Private.h" #import "SentryScope+Private.h" #import "SentryScopeObserver.h" #import "SentryScopeSyncC.h" @@ -185,6 +199,8 @@ #import "SentryStacktrace.h" #import "SentryStacktraceBuilder.h" #import "SentrySubClassFinder.h" +#import "SentrySwift.h" +#import "SentrySwiftAsyncIntegration.h" #import "SentrySwizzleWrapper.h" #import "SentrySysctl.h" #import "SentrySystemEventBreadcrumbs.h" @@ -217,3 +233,4 @@ #import "TestSentrySpan.h" #import "TestSentryViewHierarchy.h" #import "URLSessionTaskMock.h" +@import _SentryPrivate;