diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index 935b0e3453..60b8a3626a 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -717,8 +717,8 @@ 92ECD7202E05A7DF0063EC10 /* SentryLogC.h in Headers */ = {isa = PBXBuildFile; fileRef = D8AE48B12C5786AA0092A2A6 /* SentryLogC.h */; settings = {ATTRIBUTES = (Private, ); }; }; 92ECD73C2E05ACE00063EC10 /* SentryLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92ECD73B2E05ACDE0063EC10 /* SentryLog.swift */; }; 92ECD73E2E05AD320063EC10 /* SentryLogLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92ECD73D2E05AD2B0063EC10 /* SentryLogLevel.swift */; }; - 92ECD7402E05AD580063EC10 /* SentryLogAttribute.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92ECD73F2E05AD500063EC10 /* SentryLogAttribute.swift */; }; - 92ECD7482E05B57C0063EC10 /* SentryLogAttributeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92ECD7472E05B5760063EC10 /* SentryLogAttributeTests.swift */; }; + 92ECD7402E05AD580063EC10 /* SentryAttribute.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92ECD73F2E05AD500063EC10 /* SentryAttribute.swift */; }; + 92ECD7482E05B57C0063EC10 /* SentryAttributeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92ECD7472E05B5760063EC10 /* SentryAttributeTests.swift */; }; 92F6726B29C8B7B100BFD34D /* SentryUser+Private.h in Headers */ = {isa = PBXBuildFile; fileRef = 92F6726A29C8B7B000BFD34D /* SentryUser+Private.h */; settings = {ATTRIBUTES = (Private, ); }; }; A811D867248E2770008A41EA /* SentrySystemEventBreadcrumbsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A811D866248E2770008A41EA /* SentrySystemEventBreadcrumbsTest.swift */; }; A839D89824864B80003B7AFD /* SentrySystemEventBreadcrumbs.h in Headers */ = {isa = PBXBuildFile; fileRef = A839D89724864B80003B7AFD /* SentrySystemEventBreadcrumbs.h */; }; @@ -780,6 +780,7 @@ D473ACD72D8090FC000F1CC6 /* FileManager+SentryTracing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D473ACD62D8090FC000F1CC6 /* FileManager+SentryTracing.swift */; }; D480F9D92DE47A50009A0594 /* TestSentryScopePersistentStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D480F9D82DE47A48009A0594 /* TestSentryScopePersistentStore.swift */; }; D480F9DB2DE47AF2009A0594 /* SentryScopePersistentStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D480F9DA2DE47AEB009A0594 /* SentryScopePersistentStoreTests.swift */; }; + D48225A12EEC4B6D00CDF32C /* BatcherMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48225A02EEC4B6D00CDF32C /* BatcherMetadata.swift */; }; D483AFA42E9D555300B43C27 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D483AFA32E9D555300B43C27 /* XCTest.framework */; }; D48724E02D3549CA005DE483 /* SentrySpanOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48724DF2D3549C6005DE483 /* SentrySpanOperationTests.swift */; }; D48724E22D354D16005DE483 /* SentryTraceOriginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48724E12D354D16005DE483 /* SentryTraceOriginTests.swift */; }; @@ -811,6 +812,15 @@ D4CD2A812DE9F91900DA9F59 /* SentryRedactRegionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4CD2A7D2DE9F91900DA9F59 /* SentryRedactRegionType.swift */; }; D4D0E1E82E9D040A00358814 /* SentrySessionReplayEnvironmentCheckerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4D0E1E22E9D040800358814 /* SentrySessionReplayEnvironmentCheckerTests.swift */; }; D4D12E7A2DFC608800DC45C4 /* SentryScreenshotOptionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4D12E792DFC607F00DC45C4 /* SentryScreenshotOptionsTests.swift */; }; + D4D7AA6E2EEAD89300E28DFB /* Batcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4D7AA672EEAD89300E28DFB /* Batcher.swift */; }; + D4D7AA6F2EEAD89300E28DFB /* BatchBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4D7AA6B2EEAD89300E28DFB /* BatchBuffer.swift */; }; + D4D7AA702EEAD89300E28DFB /* BatcherItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4D7AA692EEAD89300E28DFB /* BatcherItem.swift */; }; + D4D7AA712EEAD89300E28DFB /* InMemoryBatchBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4D7AA6C2EEAD89300E28DFB /* InMemoryBatchBuffer.swift */; }; + D4D7AA722EEAD89300E28DFB /* BatcherConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4D7AA682EEAD89300E28DFB /* BatcherConfig.swift */; }; + D4D7AA732EEAD89300E28DFB /* BatcherScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4D7AA6A2EEAD89300E28DFB /* BatcherScope.swift */; }; + D4D7AA762EEADF4B00E28DFB /* InMemoryBatchBufferTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4D7AA752EEADF4800E28DFB /* InMemoryBatchBufferTests.swift */; }; + D4D7AA782EEAE30F00E28DFB /* BatcherScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4D7AA772EEAE30B00E28DFB /* BatcherScopeTests.swift */; }; + D4DDC0F42EE8572F00F321F6 /* BatcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4DDC0F32EE8572F00F321F6 /* BatcherTests.swift */; }; D4DEE6592E439B2E00FCA5A9 /* SentryProfileTimeseriesTests.m in Sources */ = {isa = PBXBuildFile; fileRef = D4DEE6582E439B2E00FCA5A9 /* SentryProfileTimeseriesTests.m */; }; D4E3F35D2D4A864600F79E2B /* SentryNSDictionarySanitizeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D42E48582D48FC8F00D251BC /* SentryNSDictionarySanitizeTests.swift */; }; D4E3F35E2D4A877300F79E2B /* SentryNSDictionarySanitize+Tests.m in Sources */ = {isa = PBXBuildFile; fileRef = D41909942D490006002B83D0 /* SentryNSDictionarySanitize+Tests.m */; }; @@ -2073,8 +2083,8 @@ 92D957762E05A4F300E20E66 /* SentryAsyncLog.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryAsyncLog.h; path = include/SentryAsyncLog.h; sourceTree = ""; }; 92ECD73B2E05ACDE0063EC10 /* SentryLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryLog.swift; sourceTree = ""; }; 92ECD73D2E05AD2B0063EC10 /* SentryLogLevel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryLogLevel.swift; sourceTree = ""; }; - 92ECD73F2E05AD500063EC10 /* SentryLogAttribute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryLogAttribute.swift; sourceTree = ""; }; - 92ECD7472E05B5760063EC10 /* SentryLogAttributeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryLogAttributeTests.swift; sourceTree = ""; }; + 92ECD73F2E05AD500063EC10 /* SentryAttribute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryAttribute.swift; sourceTree = ""; }; + 92ECD7472E05B5760063EC10 /* SentryAttributeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryAttributeTests.swift; sourceTree = ""; }; 92F6726A29C8B7B000BFD34D /* SentryUser+Private.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = "SentryUser+Private.h"; path = "include/HybridPublic/SentryUser+Private.h"; sourceTree = ""; }; A811D866248E2770008A41EA /* SentrySystemEventBreadcrumbsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentrySystemEventBreadcrumbsTest.swift; sourceTree = ""; }; A839D89724864B80003B7AFD /* SentrySystemEventBreadcrumbs.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentrySystemEventBreadcrumbs.h; path = include/SentrySystemEventBreadcrumbs.h; sourceTree = ""; }; @@ -2141,6 +2151,7 @@ D473ACD62D8090FC000F1CC6 /* FileManager+SentryTracing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+SentryTracing.swift"; sourceTree = ""; }; D480F9D82DE47A48009A0594 /* TestSentryScopePersistentStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestSentryScopePersistentStore.swift; sourceTree = ""; }; D480F9DA2DE47AEB009A0594 /* SentryScopePersistentStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryScopePersistentStoreTests.swift; sourceTree = ""; }; + D48225A02EEC4B6D00CDF32C /* BatcherMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatcherMetadata.swift; sourceTree = ""; }; D483AFA32E9D555300B43C27 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/WatchOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; D48724DF2D3549C6005DE483 /* SentrySpanOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentrySpanOperationTests.swift; sourceTree = ""; }; D48724E12D354D16005DE483 /* SentryTraceOriginTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryTraceOriginTests.swift; sourceTree = ""; }; @@ -2174,6 +2185,15 @@ D4CD2A7D2DE9F91900DA9F59 /* SentryRedactRegionType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryRedactRegionType.swift; sourceTree = ""; }; D4D0E1E22E9D040800358814 /* SentrySessionReplayEnvironmentCheckerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentrySessionReplayEnvironmentCheckerTests.swift; sourceTree = ""; }; D4D12E792DFC607F00DC45C4 /* SentryScreenshotOptionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryScreenshotOptionsTests.swift; sourceTree = ""; }; + D4D7AA672EEAD89300E28DFB /* Batcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Batcher.swift; sourceTree = ""; }; + D4D7AA682EEAD89300E28DFB /* BatcherConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatcherConfig.swift; sourceTree = ""; }; + D4D7AA692EEAD89300E28DFB /* BatcherItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatcherItem.swift; sourceTree = ""; }; + D4D7AA6A2EEAD89300E28DFB /* BatcherScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatcherScope.swift; sourceTree = ""; }; + D4D7AA6B2EEAD89300E28DFB /* BatchBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchBuffer.swift; sourceTree = ""; }; + D4D7AA6C2EEAD89300E28DFB /* InMemoryBatchBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InMemoryBatchBuffer.swift; sourceTree = ""; }; + D4D7AA752EEADF4800E28DFB /* InMemoryBatchBufferTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InMemoryBatchBufferTests.swift; sourceTree = ""; }; + D4D7AA772EEAE30B00E28DFB /* BatcherScopeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatcherScopeTests.swift; sourceTree = ""; }; + D4DDC0F32EE8572F00F321F6 /* BatcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatcherTests.swift; sourceTree = ""; }; D4DEE6582E439B2E00FCA5A9 /* SentryProfileTimeseriesTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryProfileTimeseriesTests.m; sourceTree = ""; }; D4E942042E9D1CF300DB7521 /* TestSessionReplayEnvironmentChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestSessionReplayEnvironmentChecker.swift; sourceTree = ""; }; D4E9420B2E9D1D7600DB7521 /* TestSessionReplayEnvironmentCheckerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestSessionReplayEnvironmentCheckerTests.swift; sourceTree = ""; }; @@ -2229,7 +2249,6 @@ 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 = ""; }; D8479327278873A100BE8E99 /* SentryByteCountFormatter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = SentryByteCountFormatter.h; path = include/SentryByteCountFormatter.h; sourceTree = ""; }; D84D2CC22C29AD120011AF8A /* SentrySessionReplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentrySessionReplay.swift; sourceTree = ""; }; @@ -3406,7 +3425,7 @@ F4DCC9DC2E4AA9D0008ECE45 /* SentrySDKSettingsTests.swift */, 9264E1EC2E2E397400B077CF /* SentryLogMessageTests.swift */, 92B6BDAC2E05B9F700D538B3 /* SentryLogTests.swift */, - 92ECD7472E05B5760063EC10 /* SentryLogAttributeTests.swift */, + 92ECD7472E05B5760063EC10 /* SentryAttributeTests.swift */, 92B6BDA82E05B8F000D538B3 /* SentryLogLevelTests.swift */, D46712612DCD059500D4074A /* SentryRedactDefaultOptionsTests.swift */, 620078762D3906AD0022CB67 /* Codable */, @@ -4203,7 +4222,6 @@ D4009EA02D77196F0007AF30 /* ViewCapture */ = { isa = PBXGroup; children = ( - D4AF802E2E965188004F0F59 /* __Snapshots__ */, D82915622C85EF0C00A6CDD4 /* SentryViewPhotographerTests.swift */, D8F67AF22BE10F7600C9197B /* SentryUIRedactBuilderTests.swift */, D4AF7D292E940492004F0F59 /* SentryUIRedactBuilderTests+Common.swift */, @@ -4429,20 +4447,37 @@ path = InfoPlist; sourceTree = ""; }; - D4AF802E2E965188004F0F59 /* __Snapshots__ */ = { + D4CBA2522DE06D1600581618 /* SentryTestUtilsTests */ = { isa = PBXGroup; children = ( + D4563B342EB91769005B33E2 /* Resources */, + D4563B332EB91763005B33E2 /* Sources */, ); - path = __Snapshots__; + path = SentryTestUtilsTests; sourceTree = ""; }; - D4CBA2522DE06D1600581618 /* SentryTestUtilsTests */ = { + D4D7AA6D2EEAD89300E28DFB /* Batcher */ = { isa = PBXGroup; children = ( - D4563B342EB91769005B33E2 /* Resources */, - D4563B332EB91763005B33E2 /* Sources */, + D4D7AA672EEAD89300E28DFB /* Batcher.swift */, + D4D7AA682EEAD89300E28DFB /* BatcherConfig.swift */, + D48225A02EEC4B6D00CDF32C /* BatcherMetadata.swift */, + D4D7AA692EEAD89300E28DFB /* BatcherItem.swift */, + D4D7AA6A2EEAD89300E28DFB /* BatcherScope.swift */, + D4D7AA6B2EEAD89300E28DFB /* BatchBuffer.swift */, + D4D7AA6C2EEAD89300E28DFB /* InMemoryBatchBuffer.swift */, ); - path = SentryTestUtilsTests; + path = Batcher; + sourceTree = ""; + }; + D4D7AA742EEAD8F500E28DFB /* Batcher */ = { + isa = PBXGroup; + children = ( + D4D7AA772EEAE30B00E28DFB /* BatcherScopeTests.swift */, + D4DDC0F32EE8572F00F321F6 /* BatcherTests.swift */, + D4D7AA752EEADF4800E28DFB /* InMemoryBatchBufferTests.swift */, + ); + path = Batcher; sourceTree = ""; }; D4F2B5332D0C69CC00649E42 /* Recording */ = { @@ -4579,6 +4614,7 @@ D81FDF0F280E9FEC0045E0E4 /* Tools */ = { isa = PBXGroup; children = ( + D4D7AA742EEAD8F500E28DFB /* Batcher */, FAAB2EDF2E4BE96F00FE8B7E /* TestSentryNSApplication.swift */, D43A2A132DD4815E00114724 /* SentryWeakMapTests.swift */, D4009EA02D77196F0007AF30 /* ViewCapture */, @@ -4590,7 +4626,6 @@ D8CB742C294B294B00A5F964 /* MockUIScene.h */, D8CB742D294B294B00A5F964 /* MockUIScene.m */, D84541172A2DC2CD00E2B11C /* SentryBinaryImageCacheTests.swift */, - D84541192A2DC55100E2B11C /* SentryBinaryImageCache+Private.h */, D8292D7C2A39A027009872F7 /* UrlSanitizedTests.swift */, D8F8F5562B835BC600AC5465 /* SentryMsgPackSerializerTests.m */, D8F67AEF2BE0D31A00C9197B /* UIImageHelperTests.swift */, @@ -4631,18 +4666,19 @@ D856272A2A374A6800FB8062 /* Tools */ = { isa = PBXGroup; children = ( - FAEEBFDC2E736D4100E79CA9 /* SentryViewHierarchyProvider.swift */, + D4D7AA6D2EEAD89300E28DFB /* Batcher */, + F451FAA52E0B304E0050ACF2 /* LoadValidator.swift */, + FA94E68B2E6B92BE00576666 /* SentryClientReport.swift */, + FA01BCB12E69352A00968DFA /* SentryDiscardedEvent.swift */, FAE57C072E83092A00B710F9 /* SentryDispatchFactory.swift */, FA94E6B12E6D265500576666 /* SentryEnvelope.swift */, - FA94E68B2E6B92BE00576666 /* SentryClientReport.swift */, FA3AEE772E68E2830092283E /* SentryEnvelopeHeader.swift */, - FA01BCB12E69352A00968DFA /* SentryDiscardedEvent.swift */, - 92235CAD2E15549C00865983 /* SentryLogger.swift */, - 92235CAB2E15369900865983 /* SentryLogBatcher.swift */, - F451FAA52E0B304E0050ACF2 /* LoadValidator.swift */, FA34C1A22E692A5000BC52AA /* SentryEnvelopeItem.swift */, - FA90FAFC2E070A3B008CAAE8 /* SentryURLRequestFactory.swift */, + 92235CAB2E15369900865983 /* SentryLogBatcher.swift */, + 92235CAD2E15549C00865983 /* SentryLogger.swift */, FA67DCC02DDBD4C800896B02 /* SentrySDKLog+Configure.swift */, + FA90FAFC2E070A3B008CAAE8 /* SentryURLRequestFactory.swift */, + FAEEBFDC2E736D4100E79CA9 /* SentryViewHierarchyProvider.swift */, ); path = Tools; sourceTree = ""; @@ -4860,7 +4896,7 @@ FAEFA1292E4FAE1700C431D9 /* SentrySDKSettings.swift */, 620078752D38F1110022CB67 /* Codable */, 92ECD73B2E05ACDE0063EC10 /* SentryLog.swift */, - 92ECD73F2E05AD500063EC10 /* SentryLogAttribute.swift */, + 92ECD73F2E05AD500063EC10 /* SentryAttribute.swift */, 92ECD73D2E05AD2B0063EC10 /* SentryLogLevel.swift */, 9264E1EA2E2E385B00B077CF /* SentryLogMessage.swift */, F458D1122E180BB00028273E /* SentryFileManagerProtocol.swift */, @@ -5837,6 +5873,7 @@ D84793262788737D00BE8E99 /* SentryByteCountFormatter.m in Sources */, FAEFA12F2E4FAE1900C431D9 /* SentrySDKSettings.swift in Sources */, 63AA769E1EB9C57A00D153DE /* SentryError.mm in Sources */, + D48225A12EEC4B6D00CDF32C /* BatcherMetadata.swift in Sources */, 7B8713B026415B22006D6004 /* SentryAppStartTrackingIntegration.m in Sources */, 8E133FA225E72DEF00ABD0BF /* SentrySamplingContext.m in Sources */, F451FAA62E0B304E0050ACF2 /* LoadValidator.swift in Sources */, @@ -5894,6 +5931,12 @@ 843FB3232D0CD04D00558F18 /* SentryUserAccess.m in Sources */, 63FE716720DA4C1100CDBAE8 /* SentryCrashCPU.c in Sources */, 63FE717320DA4C1100CDBAE8 /* SentryCrashC.c in Sources */, + D4D7AA6E2EEAD89300E28DFB /* Batcher.swift in Sources */, + D4D7AA6F2EEAD89300E28DFB /* BatchBuffer.swift in Sources */, + D4D7AA702EEAD89300E28DFB /* BatcherItem.swift in Sources */, + D4D7AA712EEAD89300E28DFB /* InMemoryBatchBuffer.swift in Sources */, + D4D7AA722EEAD89300E28DFB /* BatcherConfig.swift in Sources */, + D4D7AA732EEAD89300E28DFB /* BatcherScope.swift in Sources */, 6293F5752D422A95002BC3BD /* SentryStacktraceCodable.swift in Sources */, 627C77892D50B6840055E966 /* SentryBreadcrumbCodable.swift in Sources */, 63FE70D720DA4C1000CDBAE8 /* SentryCrashMonitor_MachException.c in Sources */, @@ -6056,7 +6099,7 @@ FAE5798D2E7D9D4C00B710F9 /* SentrySysctl.swift in Sources */, D85596F3280580F10041FF8B /* SentryScreenshotIntegration.m in Sources */, 92ECD73E2E05AD320063EC10 /* SentryLogLevel.swift in Sources */, - 92ECD7402E05AD580063EC10 /* SentryLogAttribute.swift in Sources */, + 92ECD7402E05AD580063EC10 /* SentryAttribute.swift in Sources */, FA01BCB22E69352A00968DFA /* SentryDiscardedEvent.swift in Sources */, 7BAF3DCE243DCBFE008A5414 /* SentryTransportFactory.m in Sources */, F4E3DCCB2E1579240093CB80 /* SentryScopePersistentStore.swift in Sources */, @@ -6191,7 +6234,7 @@ D8DBE0CA2C0E093000FAB1FD /* SentryTouchTrackerTests.swift in Sources */, D4AF7D2C2E9404ED004F0F59 /* SentryUIRedactBuilderTests+EdgeCases.swift in Sources */, D8F67AF42BE10F9600C9197B /* SentryUIRedactBuilderTests.swift in Sources */, - 92ECD7482E05B57C0063EC10 /* SentryLogAttributeTests.swift in Sources */, + 92ECD7482E05B57C0063EC10 /* SentryAttributeTests.swift in Sources */, 63B819141EC352A7002FDF4C /* SentryInterfacesTests.m in Sources */, 92B6BDA92E05B8F600D538B3 /* SentryLogLevelTests.swift in Sources */, 62F05D2B2C0DB1F100916E3F /* SentryLogTestHelper.m in Sources */, @@ -6293,12 +6336,14 @@ D4D12E7A2DFC608800DC45C4 /* SentryScreenshotOptionsTests.swift in Sources */, 7B6C5ED6264E62CA0010D138 /* SentryTransactionTests.swift in Sources */, D81FDF12280EA1060045E0E4 /* SentryScreenshotSourceTests.swift in Sources */, + D4DDC0F42EE8572F00F321F6 /* BatcherTests.swift in Sources */, D8019910286B089000C277F0 /* SentryCrashReportSinkTests.swift in Sources */, D885266427739D01001269FC /* SentryFileIOTrackingIntegrationTests.swift in Sources */, 7BBD18992449DE9D00427C76 /* TestRateLimits.swift in Sources */, 7B04A9AB24EA5F8D00E710B1 /* SentryUserTests.swift in Sources */, 7BA61CCF247EB59500C130A8 /* SentryCrashUUIDConversionTests.swift in Sources */, 7BBD188D2448453600427C76 /* SentryHttpDateParserTests.swift in Sources */, + D4D7AA782EEAE30F00E28DFB /* BatcherScopeTests.swift in Sources */, F49D41982DEA27AF00D9244E /* SentryUseNSExceptionCallstackWrapperTests.swift in Sources */, 7B72D23A28D074BC0014798A /* TestExtensions.swift in Sources */, 7BBD18BB24530D2600427C76 /* SentryFileManagerTests.swift in Sources */, @@ -6440,6 +6485,7 @@ 7BC6EC14255C415E0059822A /* SentryExceptionTests.swift in Sources */, 7B82722927A319E900F4BFF4 /* SentryAutoSessionTrackingIntegrationTests.swift in Sources */, 62D6B2A72CCA354B004DDBF1 /* SentryUncaughtNSExceptionsTests.swift in Sources */, + D4D7AA762EEADF4B00E28DFB /* InMemoryBatchBufferTests.swift in Sources */, D875ED0B276CC84700422FAC /* SentryFileIOTrackerTests.swift in Sources */, D808FB92281BF6EC009A2A33 /* SentryUIEventTrackingIntegrationTests.swift in Sources */, 7BC6EC04255C235F0059822A /* SentryFrameTests.swift in Sources */, diff --git a/SentryTestUtils/Sources/TestClient.swift b/SentryTestUtils/Sources/TestClient.swift index 86b50cd838..25fbbff091 100644 --- a/SentryTestUtils/Sources/TestClient.swift +++ b/SentryTestUtils/Sources/TestClient.swift @@ -7,6 +7,7 @@ public class TestClient: SentryClientInternal { public override init?(options: NSObject) { super.init( options: options, + dateProvider: TestCurrentDateProvider(), transportAdapter: TestTransportAdapter(transports: [TestTransport()], options: options as! Options), fileManager: try! TestFileManager( options: options as? Options, @@ -23,9 +24,20 @@ public class TestClient: SentryClientInternal { // Without this override we get a fatal error: use of unimplemented initializer // see https://stackoverflow.com/questions/28187261/ios-swift-fatal-error-use-of-unimplemented-initializer-init - @_spi(Private) public override init(options: NSObject, transportAdapter: SentryTransportAdapter, fileManager: SentryFileManager, threadInspector: SentryDefaultThreadInspector, debugImageProvider: SentryDebugImageProvider, random: SentryRandomProtocol, locale: Locale, timezone: TimeZone) { + @_spi(Private) public override init( + options: NSObject, + dateProvider: SentryCurrentDateProvider, + transportAdapter: SentryTransportAdapter, + fileManager: SentryFileManager, + threadInspector: SentryDefaultThreadInspector, + debugImageProvider: SentryDebugImageProvider, + random: SentryRandomProtocol, + locale: Locale, + timezone: TimeZone + ) { super.init( options: options, + dateProvider: dateProvider, transportAdapter: transportAdapter, fileManager: fileManager, threadInspector: threadInspector, diff --git a/SentryTestUtils/Sources/TestCurrentDateProvider.swift b/SentryTestUtils/Sources/TestCurrentDateProvider.swift index 362bcb333f..977463f242 100644 --- a/SentryTestUtils/Sources/TestCurrentDateProvider.swift +++ b/SentryTestUtils/Sources/TestCurrentDateProvider.swift @@ -12,6 +12,7 @@ import Foundation public var driftTimeInterval = 0.1 private var _systemUptime: TimeInterval = 0 + private var _absoluteTime: UInt64 = 0 // NSLock isn't reentrant, so we use NSRecursiveLock. private let lock = NSRecursiveLock() @@ -36,6 +37,7 @@ import Foundation lock.synchronized { setDate(date: TestCurrentDateProvider.defaultStartingDate) internalSystemTime = 0 + _absoluteTime = 0 } } @@ -49,7 +51,9 @@ import Foundation public func advance(by seconds: TimeInterval) { lock.synchronized { setDate(date: date().addingTimeInterval(seconds)) - internalSystemTime += seconds.toNanoSeconds() + let nanoseconds = seconds.toNanoSeconds() + internalSystemTime += nanoseconds + _absoluteTime += nanoseconds } } @@ -57,13 +61,16 @@ import Foundation lock.synchronized { setDate(date: date().addingTimeInterval(nanoseconds.toTimeInterval())) internalSystemTime += nanoseconds + _absoluteTime += nanoseconds } } public func advanceBy(interval: TimeInterval) { lock.synchronized { setDate(date: date().addingTimeInterval(interval)) - internalSystemTime += interval.toNanoSeconds() + let nanoseconds = interval.toNanoSeconds() + internalSystemTime += nanoseconds + _absoluteTime += nanoseconds } } @@ -101,4 +108,10 @@ import Foundation return _timezoneOffsetValue } } + + public func getAbsoluteTime() -> UInt64 { + return lock.synchronized { + return _absoluteTime + } + } } diff --git a/Sources/Sentry/Public/SentryDefines.h b/Sources/Sentry/Public/SentryDefines.h index b6cfd21270..ae8e7f2e4b 100644 --- a/Sources/Sentry/Public/SentryDefines.h +++ b/Sources/Sentry/Public/SentryDefines.h @@ -65,6 +65,7 @@ -(instancetype)init NS_UNAVAILABLE; \ +(instancetype) new NS_UNAVAILABLE; +@class SentryAttribute; @class SentryBreadcrumb; @class SentryEvent; @class SentrySamplingContext; @@ -72,6 +73,12 @@ @class SentryLog; @protocol SentrySpan; +// Compatibility alias to maintain backward compatibility with existing Objective-C code. +// SentryLogAttribute is an alias for SentryAttribute, allowing code like +// [[SentryLogAttribute alloc] initWithString:...] to continue working, after `SentryLog.Attribute` +// was renamed to `SentryAttribute`. +@compatibility_alias SentryLogAttribute SentryAttribute; + /** * Block used for returning after a request finished */ diff --git a/Sources/Sentry/SentryClient.m b/Sources/Sentry/SentryClient.m index a7eca703e5..18176e7edd 100644 --- a/Sources/Sentry/SentryClient.m +++ b/Sources/Sentry/SentryClient.m @@ -85,6 +85,7 @@ - (_Nullable instancetype)initWithOptions:(SentryOptions *)options [[SentryDefaultThreadInspector alloc] initWithOptions:options]; return [self initWithOptions:options + dateProvider:SentryDependencyContainer.sharedInstance.dateProvider transportAdapter:transportAdapter fileManager:fileManager threadInspector:threadInspector @@ -95,6 +96,7 @@ - (_Nullable instancetype)initWithOptions:(SentryOptions *)options } - (instancetype)initWithOptions:(SentryOptions *)options + dateProvider:(id)dateProvider transportAdapter:(SentryTransportAdapter *)transportAdapter fileManager:(SentryFileManager *)fileManager threadInspector:(SentryDefaultThreadInspector *)threadInspector @@ -115,7 +117,9 @@ - (instancetype)initWithOptions:(SentryOptions *)options self.timezone = timezone; self.attachmentProcessors = [[NSMutableArray alloc] init]; - self.logBatcher = [[SentryLogBatcher alloc] initWithOptions:options delegate:self]; + self.logBatcher = [[SentryLogBatcher alloc] initWithOptions:options + dateProvider:dateProvider + delegate:self]; // The SDK stores the installationID in a file. The first call requires file IO. To avoid // executing this on the main thread, we cache the installationID async here. diff --git a/Sources/Swift/Core/Helper/SentryCurrentDateProvider.swift b/Sources/Swift/Core/Helper/SentryCurrentDateProvider.swift index e2cd69f4d8..6a9607453b 100644 --- a/Sources/Swift/Core/Helper/SentryCurrentDateProvider.swift +++ b/Sources/Swift/Core/Helper/SentryCurrentDateProvider.swift @@ -10,6 +10,13 @@ import Foundation func timezoneOffset() -> Int func systemTime() -> UInt64 func systemUptime() -> TimeInterval + func getAbsoluteTime() -> UInt64 +} + +extension SentryCurrentDateProvider { + public func getAbsoluteTime() -> UInt64 { + clock_gettime_nsec_np(CLOCK_UPTIME_RAW) + } } @objcMembers @@ -34,6 +41,10 @@ import Foundation ProcessInfo.processInfo.systemUptime } + public func getAbsoluteTime() -> UInt64 { + SentryDefaultCurrentDateProvider.getAbsoluteTime() + } + public static func getAbsoluteTime() -> UInt64 { clock_gettime_nsec_np(CLOCK_UPTIME_RAW) } diff --git a/Sources/Swift/Helper/SentryDispatchQueueWrapper.swift b/Sources/Swift/Helper/SentryDispatchQueueWrapper.swift index 9a6966716d..028e191336 100644 --- a/Sources/Swift/Helper/SentryDispatchQueueWrapper.swift +++ b/Sources/Swift/Helper/SentryDispatchQueueWrapper.swift @@ -1,5 +1,16 @@ @_implementationOnly import _SentryPrivate +protocol SentryDispatchQueueWrapperProtocol { + func dispatchSync(_ block: @escaping () -> Void) + func dispatchAsync(_ block: @escaping () -> Void) + func dispatch(after interval: TimeInterval, block: @escaping () -> Void) + func dispatchAsyncOnMainQueueIfNotMainThread(block: @escaping () -> Void) + func dispatchSyncOnMainQueue(block: @escaping () -> Void) + func dispatchSyncOnMainQueue(_ block: @escaping () -> Void, timeout: Double) + func dispatchOnce(_ predicate: UnsafeMutablePointer, block: @escaping () -> Void) + func dispatch(after interval: TimeInterval, workItem: DispatchWorkItem) +} + // This is the Swift version of `_SentryDispatchQueueWrapperInternal` // It exists to allow the implementation of `_SentryDispatchQueueWrapperInternal` // to be accessible to Swift without making that header file public @@ -76,3 +87,5 @@ return true } } + +extension SentryDispatchQueueWrapper: SentryDispatchQueueWrapperProtocol {} diff --git a/Sources/Swift/Protocol/SentryAttribute.swift b/Sources/Swift/Protocol/SentryAttribute.swift new file mode 100644 index 0000000000..a8f8ddcd81 --- /dev/null +++ b/Sources/Swift/Protocol/SentryAttribute.swift @@ -0,0 +1,106 @@ +/// A typed attribute that can be attached to structured item entries used by Logs +/// +/// `Attribute` provides a type-safe way to store structured data alongside item messages. +/// Supports String, Bool, Int, and Double types. +@objcMembers +public final class SentryAttribute: NSObject { + /// The type identifier for this attribute ("string", "boolean", "integer", "double") + public let type: String + /// The actual value stored in this attribute + public let value: Any + + public init(string value: String) { + self.type = "string" + self.value = value + super.init() + } + + public init(boolean value: Bool) { + self.type = "boolean" + self.value = value + super.init() + } + + public init(integer value: Int) { + self.type = "integer" + self.value = value + super.init() + } + + public init(double value: Double) { + self.type = "double" + self.value = value + super.init() + } + + /// Creates a double attribute from a float value + public init(float value: Float) { + self.type = "double" + self.value = Double(value) + super.init() + } + + internal init(value: Any) { + switch value { + case let stringValue as String: + self.type = "string" + self.value = stringValue + case let boolValue as Bool: + self.type = "boolean" + self.value = boolValue + case let intValue as Int: + self.type = "integer" + self.value = intValue + case let doubleValue as Double: + self.type = "double" + self.value = doubleValue + case let floatValue as Float: + self.type = "double" + self.value = Double(floatValue) + default: + // For any other type, convert to string representation + self.type = "string" + self.value = String(describing: value) + } + super.init() + } +} + +// MARK: - Internal Encodable Support +@_spi(Private) extension SentryAttribute: Encodable { + private enum CodingKeys: String, CodingKey { + case value + case type + } + + @_spi(Private) public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(type, forKey: .type) + + switch type { + case "string": + guard let stringValue = value as? String else { + throw EncodingError.invalidValue(value, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "Expected String but got \(Swift.type(of: value))")) + } + try container.encode(stringValue, forKey: .value) + case "boolean": + guard let boolValue = value as? Bool else { + throw EncodingError.invalidValue(value, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "Expected Bool but got \(Swift.type(of: value))")) + } + try container.encode(boolValue, forKey: .value) + case "integer": + guard let intValue = value as? Int else { + throw EncodingError.invalidValue(value, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "Expected Int but got \(Swift.type(of: value))")) + } + try container.encode(intValue, forKey: .value) + case "double": + guard let doubleValue = value as? Double else { + throw EncodingError.invalidValue(value, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "Expected Double but got \(Swift.type(of: value))")) + } + try container.encode(doubleValue, forKey: .value) + default: + try container.encode(String(describing: value), forKey: .value) + } + } +} diff --git a/Sources/Swift/Protocol/SentryLog.swift b/Sources/Swift/Protocol/SentryLog.swift index 75c79db0c1..bae876d95a 100644 --- a/Sources/Swift/Protocol/SentryLog.swift +++ b/Sources/Swift/Protocol/SentryLog.swift @@ -4,6 +4,9 @@ @objc @objcMembers public final class SentryLog: NSObject { + /// Alias for `SentryAttribute` to maintain backward compatibility after `SentryLog.Attribute` was renamed to `SentryAttribute`. + public typealias Attribute = SentryAttribute + /// The timestamp when the log event occurred public var timestamp: Date /// The trace ID to associate this log with distributed tracing. This will be set to a valid non-empty value during processing. diff --git a/Sources/Swift/Protocol/SentryLogAttribute.swift b/Sources/Swift/Protocol/SentryLogAttribute.swift deleted file mode 100644 index fbce4e2b03..0000000000 --- a/Sources/Swift/Protocol/SentryLogAttribute.swift +++ /dev/null @@ -1,109 +0,0 @@ -extension SentryLog { - /// A typed attribute that can be attached to structured log entries. - /// - /// `Attribute` provides a type-safe way to store structured data alongside log messages. - /// Supports String, Bool, Int, and Double types. - @objc(SentryLogAttribute) - @objcMembers - public final class Attribute: NSObject { - /// The type identifier for this attribute ("string", "boolean", "integer", "double") - public let type: String - /// The actual value stored in this attribute - public let value: Any - - public init(string value: String) { - self.type = "string" - self.value = value - super.init() - } - - public init(boolean value: Bool) { - self.type = "boolean" - self.value = value - super.init() - } - - public init(integer value: Int) { - self.type = "integer" - self.value = value - super.init() - } - - public init(double value: Double) { - self.type = "double" - self.value = value - super.init() - } - - /// Creates a double attribute from a float value - public init(float value: Float) { - self.type = "double" - self.value = Double(value) - super.init() - } - - internal init(value: Any) { - switch value { - case let stringValue as String: - self.type = "string" - self.value = stringValue - case let boolValue as Bool: - self.type = "boolean" - self.value = boolValue - case let intValue as Int: - self.type = "integer" - self.value = intValue - case let doubleValue as Double: - self.type = "double" - self.value = doubleValue - case let floatValue as Float: - self.type = "double" - self.value = Double(floatValue) - default: - // For any other type, convert to string representation - self.type = "string" - self.value = String(describing: value) - } - super.init() - } - } -} - -// MARK: - Internal Encodable Support -@_spi(Private) extension SentryLog.Attribute: Encodable { - private enum CodingKeys: String, CodingKey { - case value - case type - } - - @_spi(Private) public func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - - try container.encode(type, forKey: .type) - - switch type { - case "string": - guard let stringValue = value as? String else { - throw EncodingError.invalidValue(value, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "Expected String but got \(Swift.type(of: value))")) - } - try container.encode(stringValue, forKey: .value) - case "boolean": - guard let boolValue = value as? Bool else { - throw EncodingError.invalidValue(value, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "Expected Bool but got \(Swift.type(of: value))")) - } - try container.encode(boolValue, forKey: .value) - case "integer": - guard let intValue = value as? Int else { - throw EncodingError.invalidValue(value, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "Expected Int but got \(Swift.type(of: value))")) - } - try container.encode(intValue, forKey: .value) - case "double": - guard let doubleValue = value as? Double else { - throw EncodingError.invalidValue(value, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "Expected Double but got \(Swift.type(of: value))")) - } - try container.encode(doubleValue, forKey: .value) - default: - try container.encode(String(describing: value), forKey: .value) - } - } -} diff --git a/Sources/Swift/Tools/Batcher/BatchBuffer.swift b/Sources/Swift/Tools/Batcher/BatchBuffer.swift new file mode 100644 index 0000000000..84abe6b1a8 --- /dev/null +++ b/Sources/Swift/Tools/Batcher/BatchBuffer.swift @@ -0,0 +1,20 @@ +protocol BatchBuffer { + associatedtype Item + + /// Adds the given item to the storage + /// + /// - Throws: Can throw errors due to e.g. encoding errors + mutating func append(_ item: Item) throws + + /// Clears all items from the storage + mutating func clear() + + /// Number of elements in the storage + var itemsCount: Int { get } + + /// Sum of the size of encoded items in the storage + var itemsDataSize: Int { get } + + /// Returns the data collected in this storage in batched format + var batchedData: Data { get } +} diff --git a/Sources/Swift/Tools/Batcher/Batcher.swift b/Sources/Swift/Tools/Batcher/Batcher.swift new file mode 100644 index 0000000000..c8ab2b8d00 --- /dev/null +++ b/Sources/Swift/Tools/Batcher/Batcher.swift @@ -0,0 +1,168 @@ +@_implementationOnly import _SentryPrivate +import Foundation + +protocol BatcherProtocol: AnyObject { + associatedtype Item: BatcherItem + associatedtype Scope: BatcherScope + + func add(_ item: Item, scope: Scope) + func capture() -> TimeInterval +} + +final class Batcher, Item: BatcherItem, Scope: BatcherScope>: BatcherProtocol { + struct Config: BatcherConfig { + let flushTimeout: TimeInterval + let maxItemCount: Int + let maxBufferSizeBytes: Int + + let beforeSendItem: ((Item) -> Item?)? + + var capturedDataCallback: (Data, Int) -> Void = { _, _ in } + } + + struct Metadata: BatcherMetadata { + let environment: String + let releaseName: String? + let installationId: String? + } + + private let config: Config + private let metadata: Metadata + + private var buffer: Buffer + private let dateProvider: SentryCurrentDateProvider + private let dispatchQueue: SentryDispatchQueueWrapperProtocol + + private var timerWorkItem: DispatchWorkItem? + + /// Initializes a new `Batcher`. + /// - Parameters: + /// - config: The batcher configuration containing flush timeout, limits, and callbacks + /// - metadata: The batcher metadata containing fields like environment or release + /// - buffer: The buffer implementation for buffering items + /// - dateProvider: Provider for current date/time used for timing measurements + /// - dispatchQueue: A **serial** dispatch queue wrapper for thread-safe access to mutable state + /// + /// - Important: The `dispatchQueue` parameter MUST be a serial queue to ensure thread safety. + /// Passing a concurrent queue will result in undefined behavior and potential data races. + /// + /// - Note: Items are flushed when either `config.maxItemCount` or `config.maxBufferSizeBytes` limit is reached, + /// or after `config.flushTimeout` seconds have elapsed since the first item was added to an empty buffer. + @_spi(Private) public init( + config: Config, + metadata: Metadata, + buffer: Buffer, + dateProvider: SentryCurrentDateProvider, + dispatchQueue: SentryDispatchQueueWrapperProtocol + ) { + self.config = config + self.metadata = metadata + self.buffer = buffer + self.dateProvider = dateProvider + self.dispatchQueue = dispatchQueue + } + + /// Adds an item to the batcher with the given scope. + /// - Parameters: + /// - item: The item to add to the batch + /// - scope: The scope to apply to the item (adds attributes, trace ID, etc.) + /// + /// - Note: Scope application (attribute enrichment) and the `beforeSendItem` callback are executed + /// synchronously on the caller's thread. Only encoding and buffering happen asynchronously + /// on the batcher's serial dispatch queue. If `config.beforeSendItem` returns `nil`, + /// the item is dropped and not added to the batch. + func add(_ item: Item, scope: Scope) { + var item = item + scope.applyToItem(&item, config: config, metadata: metadata) + + // The before send item closure can be used to drop items by returning nil + // In case it is nil, we can stop processing + if let beforeSendItem = config.beforeSendItem { + // If the before send hook returns nil, the item should be dropped + guard let processedItem = beforeSendItem(item) else { + return + } + item = processedItem + } + + dispatchQueue.dispatchAsync { [weak self] in + self?.encodeAndBuffer(item: item) + } + } + + /// Captures all currently batched items synchronously and returns the duration of the operation. + /// - Returns: The time taken to capture items in seconds + /// + /// - Note: This method blocks until all items are captured. The batcher's buffer is cleared after capture. + /// + /// - Important: This method uses `dispatchSync` to synchronously execute on the batcher's serial dispatch queue. + /// **Do not call this method from within the batcher's dispatch queue or from within the + /// `capturedDataCallback` closure**, as this will cause a deadlock. This method should only be + /// called from external threads or queues (e.g., main thread, app lifecycle callbacks). + @discardableResult func capture() -> TimeInterval { + let startTimeNs = dateProvider.getAbsoluteTime() + dispatchQueue.dispatchSync { [weak self] in + self?.performCaptureItems() + } + let endTimeNs = dateProvider.getAbsoluteTime() + return TimeInterval(endTimeNs - startTimeNs) / 1_000_000_000.0 // Convert nanoseconds to seconds + } + + /// Encodes and buffers an item, triggering a flush if limits are reached. + /// + /// - Important: Only call this method from the serial dispatch queue to ensure thread safety. + /// - Parameter item: The item to encode and add to the buffer + private func encodeAndBuffer(item: Item) { + do { + let encodedItemsWereEmpty = buffer.itemsDataSize == 0 + try buffer.append(item) + + // Flush when we reach max item count or max buffer size + if buffer.itemsCount >= config.maxItemCount || buffer.itemsDataSize >= config.maxBufferSizeBytes { + performCaptureItems() + } else if encodedItemsWereEmpty && timerWorkItem == nil { + startTimer() + } + } catch { + SentrySDKLog.error("Failed to encode item: \(error)") + } + } + + /// Starts a timer that will trigger a flush after the configured timeout. + /// + /// - Important: Only call this method from the serial dispatch queue to ensure thread safety. + /// + /// - Note: The timer is only started when the buffer transitions from empty to non-empty. + private func startTimer() { + let timerWorkItem = DispatchWorkItem { [weak self] in + SentrySDKLog.debug("Timer fired, calling performFlush().") + self?.performCaptureItems() + } + self.timerWorkItem = timerWorkItem + dispatchQueue.dispatch(after: config.flushTimeout, workItem: timerWorkItem) + } + + /// Captures all buffered items by invoking the configured callback and clears the buffer. + /// + /// - Important: Only call this method from the serial dispatch queue to ensure thread safety. + /// + /// - Note: This method cancels any pending timer and clears the buffer after invoking the callback. + /// If the buffer is empty, the callback is not invoked. + private func performCaptureItems() { + // Reset items on function exit + defer { + buffer.clear() + } + + // Reset timer state + timerWorkItem?.cancel() + timerWorkItem = nil + + // Fetch and send any available data + guard buffer.itemsCount > 0 else { + SentrySDKLog.debug("No items to flush.") + return + } + config.capturedDataCallback(buffer.batchedData, buffer.itemsCount) + } +} diff --git a/Sources/Swift/Tools/Batcher/BatcherConfig.swift b/Sources/Swift/Tools/Batcher/BatcherConfig.swift new file mode 100644 index 0000000000..ba2861b07b --- /dev/null +++ b/Sources/Swift/Tools/Batcher/BatcherConfig.swift @@ -0,0 +1,11 @@ +protocol BatcherConfig { + associatedtype Item + + var flushTimeout: TimeInterval { get } + var maxItemCount: Int { get } + var maxBufferSizeBytes: Int { get } + + var beforeSendItem: ((Item) -> Item?)? { get } + + var capturedDataCallback: (_ data: Data, _ count: Int) -> Void { get } +} diff --git a/Sources/Swift/Tools/Batcher/BatcherItem.swift b/Sources/Swift/Tools/Batcher/BatcherItem.swift new file mode 100644 index 0000000000..a18db95d2e --- /dev/null +++ b/Sources/Swift/Tools/Batcher/BatcherItem.swift @@ -0,0 +1,5 @@ +protocol BatcherItem: Encodable { + var attributes: [String: SentryAttribute] { get set } + var traceId: SentryId { get set } + var body: String { get } +} diff --git a/Sources/Swift/Tools/Batcher/BatcherMetadata.swift b/Sources/Swift/Tools/Batcher/BatcherMetadata.swift new file mode 100644 index 0000000000..1131566883 --- /dev/null +++ b/Sources/Swift/Tools/Batcher/BatcherMetadata.swift @@ -0,0 +1,5 @@ +protocol BatcherMetadata { + var environment: String { get } + var releaseName: String? { get } + var installationId: String? { get } +} diff --git a/Sources/Swift/Tools/Batcher/BatcherScope.swift b/Sources/Swift/Tools/Batcher/BatcherScope.swift new file mode 100644 index 0000000000..983d0d7d6f --- /dev/null +++ b/Sources/Swift/Tools/Batcher/BatcherScope.swift @@ -0,0 +1,121 @@ +@_implementationOnly import _SentryPrivate + +protocol BatcherScope { + var replayId: String? { get } + var propagationContextTraceIdString: String { get } + var span: Span? { get } + var userObject: User? { get } + func getContextForKey(_ key: String) -> [String: Any]? + var attributes: [String: Any] { get } + + func applyToItem, Metadata: BatcherMetadata>( + _ item: inout Item, + config: Config, + metadata: Metadata + ) +} + +extension BatcherScope { + func applyToItem, Metadata: BatcherMetadata>( + _ item: inout Item, + config: Config, + metadata: Metadata + ) { + addDefaultAttributes(to: &item.attributes, config: config, metadata: metadata) + addOSAttributes(to: &item.attributes, config: config) + addDeviceAttributes(to: &item.attributes, config: config) + addUserAttributes(to: &item.attributes, config: config) + addReplayAttributes(to: &item.attributes, config: config) + addScopeAttributes(to: &item.attributes, config: config) + addDefaultUserIdIfNeeded(to: &item.attributes, config: config, metadata: metadata) + + item.traceId = SentryId(uuidString: propagationContextTraceIdString) + } + + private func addDefaultAttributes(to attributes: inout [String: SentryAttribute], config: any BatcherConfig, metadata: any BatcherMetadata) { + attributes["sentry.sdk.name"] = .init(string: SentryMeta.sdkName) + attributes["sentry.sdk.version"] = .init(string: SentryMeta.versionString) + attributes["sentry.environment"] = .init(string: metadata.environment) + if let releaseName = metadata.releaseName { + attributes["sentry.release"] = .init(string: releaseName) + } + if let span = self.span { + attributes["sentry.trace.parent_span_id"] = .init(string: span.spanId.sentrySpanIdString) + } + } + + private func addOSAttributes(to attributes: inout [String: SentryAttribute], config: any BatcherConfig) { + guard let osContext = self.getContextForKey(SENTRY_CONTEXT_OS_KEY) else { + return + } + if let osName = osContext["name"] as? String { + attributes["os.name"] = .init(string: osName) + } + if let osVersion = osContext["version"] as? String { + attributes["os.version"] = .init(string: osVersion) + } + } + + private func addDeviceAttributes(to attributes: inout [String: SentryAttribute], config: any BatcherConfig) { + guard let deviceContext = self.getContextForKey(SENTRY_CONTEXT_DEVICE_KEY) else { + return + } + // For Apple devices, brand is always "Apple" + attributes["device.brand"] = .init(string: "Apple") + + if let deviceModel = deviceContext["model"] as? String { + attributes["device.model"] = .init(string: deviceModel) + } + if let deviceFamily = deviceContext["family"] as? String { + attributes["device.family"] = .init(string: deviceFamily) + } + } + + private func addUserAttributes(to attributes: inout [String: SentryAttribute], config: any BatcherConfig) { + if let userId = userObject?.userId { + attributes["user.id"] = .init(string: userId) + } + if let userName = userObject?.name { + attributes["user.name"] = .init(string: userName) + } + if let userEmail = userObject?.email { + attributes["user.email"] = .init(string: userEmail) + } + } + + private func addReplayAttributes(to attributes: inout [String: SentryAttribute], config: any BatcherConfig) { +#if canImport(UIKit) && !SENTRY_NO_UIKIT +#if os(iOS) || os(tvOS) + if let scopeReplayId = replayId { + // Session mode: use scope replay ID + attributes["sentry.replay_id"] = .init(string: scopeReplayId) + } +#endif +#endif + } + + private func addScopeAttributes(to attributes: inout [String: SentryAttribute], config: any BatcherConfig) { + // Scope attributes should not override any existing attribute in the item + for (key, value) in self.attributes where attributes[key] == nil { + attributes[key] = .init(value: value) + } + } + + private func addDefaultUserIdIfNeeded( + to attributes: inout [String: SentryAttribute], + config: any BatcherConfig, + metadata: any BatcherMetadata + ) { + guard attributes["user.id"] == nil && attributes["user.name"] == nil && attributes["user.email"] == nil else { + return + } + + if let installationId = metadata.installationId { + // We only want to set the id if the customer didn't set a user so we at least set something to + // identify the user. + attributes["user.id"] = .init(value: installationId) + } + } +} + +extension Scope: BatcherScope {} diff --git a/Sources/Swift/Tools/Batcher/InMemoryBatchBuffer.swift b/Sources/Swift/Tools/Batcher/InMemoryBatchBuffer.swift new file mode 100644 index 0000000000..8265ccbea9 --- /dev/null +++ b/Sources/Swift/Tools/Batcher/InMemoryBatchBuffer.swift @@ -0,0 +1,31 @@ +struct InMemoryBatchBuffer: BatchBuffer { + private var elements: [Data] = [] + var itemsDataSize: Int = 0 + + private let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .secondsSince1970 + return encoder + }() + + init() {} + + mutating func append(_ item: Item) throws { + let encoded = try encoder.encode(item) + elements.append(encoded) + itemsDataSize += encoded.count + } + + mutating func clear() { + elements.removeAll() + itemsDataSize = 0 + } + + var itemsCount: Int { + elements.count + } + + var batchedData: Data { + Data("{\"items\":[".utf8) + elements.joined(separator: Data(",".utf8)) + Data("]}".utf8) + } +} diff --git a/Sources/Swift/Tools/SentryLogBatcher.swift b/Sources/Swift/Tools/SentryLogBatcher.swift index fef794a8c5..49f1c54c7c 100644 --- a/Sources/Swift/Tools/SentryLogBatcher.swift +++ b/Sources/Swift/Tools/SentryLogBatcher.swift @@ -9,22 +9,10 @@ import Foundation @objc @objcMembers @_spi(Private) public class SentryLogBatcher: NSObject { - private let options: Options - private let flushTimeout: TimeInterval - private let maxLogCount: Int - private let maxBufferSizeBytes: Int - private let dispatchQueue: SentryDispatchQueueWrapper - - // All mutable state is accessed from the same serial dispatch queue. - - // Every logs data is added sepratley. They are flushed together in an envelope. - private var encodedLogs: [Data] = [] - private var encodedLogsSize: Int = 0 - private var timerWorkItem: DispatchWorkItem? - + private let batcher: any BatcherProtocol private weak var delegate: SentryLogBatcherDelegate? - + /// Convenience initializer with default flush timeout, max log count (100), and buffer size. /// Creates its own serial dispatch queue with DEFAULT QoS for thread-safe access to mutable state. /// - Parameters: @@ -36,6 +24,7 @@ import Foundation /// - Note: Setting `maxLogCount` to 100. While Replay hard limit is 1000, we keep this lower, as it's hard to lower once released. @_spi(Private) public convenience init( options: Options, + dateProvider: SentryCurrentDateProvider, delegate: SentryLogBatcherDelegate ) { let dispatchQueue = SentryDispatchQueueWrapper(name: "io.sentry.log-batcher") @@ -44,6 +33,7 @@ import Foundation flushTimeout: 5, maxLogCount: 100, // Maximum 100 logs per batch maxBufferSizeBytes: 1_024 * 1_024, // 1MB buffer size + dateProvider: dateProvider, dispatchQueue: dispatchQueue, delegate: delegate ) @@ -67,204 +57,55 @@ import Foundation flushTimeout: TimeInterval, maxLogCount: Int, maxBufferSizeBytes: Int, + dateProvider: SentryCurrentDateProvider, dispatchQueue: SentryDispatchQueueWrapper, delegate: SentryLogBatcherDelegate ) { + self.batcher = Batcher( + config: .init( + flushTimeout: flushTimeout, + maxItemCount: maxLogCount, + maxBufferSizeBytes: maxBufferSizeBytes, + beforeSendItem: options.beforeSendLog, + capturedDataCallback: { [weak delegate] data, count in + guard let delegate else { + SentrySDKLog.debug("SentryLogBatcher: Delegate not set, not capturing logs data.") + return + } + delegate.capture(logsData: data as NSData, count: NSNumber(value: count)) + } + ), + metadata: .init( + environment: options.environment, + releaseName: options.releaseName, + installationId: SentryInstallation.cachedId(withCacheDirectoryPath: options.cacheDirectoryPath) + ), + buffer: InMemoryBatchBuffer(), + dateProvider: dateProvider, + dispatchQueue: dispatchQueue + ) self.options = options - self.flushTimeout = flushTimeout - self.maxLogCount = maxLogCount - self.maxBufferSizeBytes = maxBufferSizeBytes - self.dispatchQueue = dispatchQueue self.delegate = delegate super.init() } - + + /// Adds a log to the batcher. + /// - Parameters: + /// - log: The log to add + /// - scope: The scope to add the log to @_spi(Private) @objc public func addLog(_ log: SentryLog, scope: Scope) { guard options.enableLogs else { return } - - addDefaultAttributes(to: &log.attributes, scope: scope) - addOSAttributes(to: &log.attributes, scope: scope) - addDeviceAttributes(to: &log.attributes, scope: scope) - addUserAttributes(to: &log.attributes, scope: scope) - addReplayAttributes(to: &log.attributes, scope: scope) - addScopeAttributes(to: &log.attributes, scope: scope) - addDefaultUserIdIfNeeded(to: &log.attributes, scope: scope, options: options) - - let propagationContextTraceIdString = scope.propagationContextTraceIdString - log.traceId = SentryId(uuidString: propagationContextTraceIdString) - - var processedLog: SentryLog? = log - if let beforeSendLog = options.beforeSendLog { - processedLog = beforeSendLog(log) - } - if let processedLog { - SentrySDKLog.log( - message: "[SentryLogger] \(processedLog.body)", - andLevel: processedLog.level.toSentryLevel() - ) - dispatchQueue.dispatchAsync { [weak self] in - self?.encodeAndBuffer(log: processedLog) - } - } + batcher.add(log, scope: scope) } - - // Captures batched logs sync and returns the duration. + + /// Captures batched logs sync and returns the duration. @discardableResult @_spi(Private) @objc public func captureLogs() -> TimeInterval { - let startTimeNs = SentryDefaultCurrentDateProvider.getAbsoluteTime() - dispatchQueue.dispatchSync { [weak self] in - self?.performCaptureLogs() - } - let endTimeNs = SentryDefaultCurrentDateProvider.getAbsoluteTime() - return TimeInterval(endTimeNs - startTimeNs) / 1_000_000_000.0 // Convert nanoseconds to seconds - } - - // Helper - - private func addDefaultAttributes(to attributes: inout [String: SentryLog.Attribute], scope: Scope) { - attributes["sentry.sdk.name"] = .init(string: SentryMeta.sdkName) - attributes["sentry.sdk.version"] = .init(string: SentryMeta.versionString) - attributes["sentry.environment"] = .init(string: options.environment) - if let releaseName = options.releaseName { - attributes["sentry.release"] = .init(string: releaseName) - } - if let span = scope.span { - attributes["sentry.trace.parent_span_id"] = .init(string: span.spanId.sentrySpanIdString) - } - } - - private func addOSAttributes(to attributes: inout [String: SentryLog.Attribute], scope: Scope) { - guard let osContext = scope.getContextForKey(SENTRY_CONTEXT_OS_KEY) else { - return - } - if let osName = osContext["name"] as? String { - attributes["os.name"] = .init(string: osName) - } - if let osVersion = osContext["version"] as? String { - attributes["os.version"] = .init(string: osVersion) - } - } - - private func addDeviceAttributes(to attributes: inout [String: SentryLog.Attribute], scope: Scope) { - guard let deviceContext = scope.getContextForKey(SENTRY_CONTEXT_DEVICE_KEY) else { - return - } - // For Apple devices, brand is always "Apple" - attributes["device.brand"] = .init(string: "Apple") - - if let deviceModel = deviceContext["model"] as? String { - attributes["device.model"] = .init(string: deviceModel) - } - if let deviceFamily = deviceContext["family"] as? String { - attributes["device.family"] = .init(string: deviceFamily) - } - } - - private func addUserAttributes(to attributes: inout [String: SentryLog.Attribute], scope: Scope) { - guard let user = scope.userObject else { - return - } - if let userId = user.userId { - attributes["user.id"] = .init(string: userId) - } - if let userName = user.name { - attributes["user.name"] = .init(string: userName) - } - if let userEmail = user.email { - attributes["user.email"] = .init(string: userEmail) - } - } - - private func addReplayAttributes(to attributes: inout [String: SentryLog.Attribute], scope: Scope) { -#if canImport(UIKit) && !SENTRY_NO_UIKIT -#if os(iOS) || os(tvOS) - if let scopeReplayId = scope.replayId { - // Session mode: use scope replay ID - attributes["sentry.replay_id"] = .init(string: scopeReplayId) - } -#endif -#endif - } - - private func addScopeAttributes(to attributes: inout [String: SentryLog.Attribute], scope: Scope) { - // Scope attributes should not override any existing attribute in the log - for (key, value) in scope.attributes where attributes[key] == nil { - attributes[key] = .init(value: value) - } - } - - private func addDefaultUserIdIfNeeded(to attributes: inout [String: SentryLog.Attribute], scope: Scope, options: Options) { - guard attributes["user.id"] == nil && attributes["user.name"] == nil && attributes["user.email"] == nil else { - return - } - - if let installationId = SentryInstallation.cachedId(withCacheDirectoryPath: options.cacheDirectoryPath) { - // We only want to set the id if the customer didn't set a user so we at least set something to - // identify the user. - attributes["user.id"] = .init(value: installationId) - } - } - - // Only ever call this from the serial dispatch queue. - private func encodeAndBuffer(log: SentryLog) { - do { - let encodedLog = try encodeToJSONData(data: log) - - let encodedLogsWereEmpty = encodedLogs.isEmpty - - encodedLogs.append(encodedLog) - encodedLogsSize += encodedLog.count - - // Flush when we reach max log count or max buffer size - if encodedLogs.count >= maxLogCount || encodedLogsSize >= maxBufferSizeBytes { - performCaptureLogs() - } else if encodedLogsWereEmpty && timerWorkItem == nil { - startTimer() - } - } catch { - SentrySDKLog.error("Failed to encode log: \(error)") - } - } - - // Only ever call this from the serial dispatch queue. - private func startTimer() { - let timerWorkItem = DispatchWorkItem { [weak self] in - SentrySDKLog.debug("SentryLogBatcher: Timer fired, calling performFlush().") - self?.performCaptureLogs() - } - self.timerWorkItem = timerWorkItem - dispatchQueue.dispatch(after: flushTimeout, workItem: timerWorkItem) - } - - // Only ever call this from the serial dispatch queue. - private func performCaptureLogs() { - // Reset logs on function exit - defer { - encodedLogs.removeAll() - encodedLogsSize = 0 - } - - // Reset timer state - timerWorkItem?.cancel() - timerWorkItem = nil - - guard encodedLogs.count > 0 else { - SentrySDKLog.debug("SentryLogBatcher: No logs to flush.") - return - } - - // Create the payload. - let payloadData = Data("{\"items\":[".utf8) + encodedLogs.joined(separator: Data(",".utf8)) + Data("]}".utf8) - - // Send the payload. - - if let delegate { - delegate.capture(logsData: payloadData as NSData, count: NSNumber(value: encodedLogs.count)) - } else { - SentrySDKLog.debug("SentryLogBatcher: Delegate not set, not capturing logs data.") - } + return batcher.capture() } } + +extension SentryLog: BatcherItem {} diff --git a/Tests/SentryTests/Batcher/BatcherScopeTests.swift b/Tests/SentryTests/Batcher/BatcherScopeTests.swift new file mode 100644 index 0000000000..8dc4a30495 --- /dev/null +++ b/Tests/SentryTests/Batcher/BatcherScopeTests.swift @@ -0,0 +1,782 @@ +@_spi(Private) @testable import Sentry +@_spi(Private) import SentryTestUtils +import XCTest + +final class BatcherScopeTests: XCTestCase { + private struct TestItem: BatcherItem, Encodable { + var attributes: [String: SentryAttribute] + var traceId: SentryId + var body: String + + enum CodingKeys: String, CodingKey { + case body + case traceId = "trace_id" + case attributes + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(body, forKey: .body) + try container.encode(traceId.sentryIdString, forKey: .traceId) + try container.encode(attributes, forKey: .attributes) + } + } + + private struct TestConfig: BatcherConfig { + typealias Item = TestItem + + let flushTimeout: TimeInterval + let maxItemCount: Int + let maxBufferSizeBytes: Int + + let beforeSendItem: ((TestItem) -> TestItem?)? + + var capturedDataCallback: (Data, Int) -> Void + } + + private struct TestMetadata: BatcherMetadata { + let environment: String + let releaseName: String? + let installationId: String? + } + + private struct TestScope: BatcherScope { + var replayId: String? + var propagationContextTraceIdString: String + var span: Span? + var userObject: User? + var contextStore: [String: [String: Any]] = [:] + var attributes: [String: Any] = [:] + + func getContextForKey(_ key: String) -> [String: Any]? { + return contextStore[key] + } + + mutating func setContext(value: [String: Any], key: String) { + contextStore[key] = value + } + } + + // MARK: - Default Attributes Tests + + func testApplyToItem_shouldAddSDKName() { + // -- Arrange -- + let scope = TestScope(propagationContextTraceIdString: SentryId().sentryIdString) + let config = createTestConfig() + let metadata = createTestMetadata() + var item = createTestItem() + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + XCTAssertEqual(item.attributes["sentry.sdk.name"]?.value as? String, SentryMeta.sdkName) + } + + func testApplyToItem_shouldAddSDKVersion() { + // -- Arrange -- + let scope = TestScope(propagationContextTraceIdString: SentryId().sentryIdString) + let config = createTestConfig() + let metadata = createTestMetadata() + var item = createTestItem() + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + XCTAssertEqual(item.attributes["sentry.sdk.version"]?.value as? String, SentryMeta.versionString) + } + + func testApplyToItem_shouldAddEnvironment() { + // -- Arrange -- + let scope = TestScope(propagationContextTraceIdString: SentryId().sentryIdString) + let config = createTestConfig() + let metadata = createTestMetadata(environment: "test-environment") + var item = createTestItem() + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + XCTAssertEqual(item.attributes["sentry.environment"]?.value as? String, "test-environment") + } + + func testApplyToItem_withReleaseName_shouldAddRelease() { + // -- Arrange -- + let scope = TestScope(propagationContextTraceIdString: SentryId().sentryIdString) + let config = createTestConfig() + let metadata = createTestMetadata(releaseName: "test-release-1.0.0") + var item = createTestItem() + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + XCTAssertEqual(item.attributes["sentry.release"]?.value as? String, "test-release-1.0.0") + } + + func testApplyToItem_withoutReleaseName_shouldNotAddRelease() { + // -- Arrange -- + let scope = TestScope(propagationContextTraceIdString: SentryId().sentryIdString) + let config = createTestConfig() + let metadata = createTestMetadata(releaseName: nil) + var item = createTestItem() + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + XCTAssertNil(item.attributes["sentry.release"]) + } + + func testApplyToItem_withSpan_shouldAddParentSpanId() { + // -- Arrange -- + let spanId = SentryId() + let span = TestSpan(spanId: spanId) + let scope = TestScope( + propagationContextTraceIdString: SentryId().sentryIdString, + span: span + ) + let config = createTestConfig() + let metadata = createTestMetadata() + var item = createTestItem() + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + XCTAssertEqual(item.attributes["sentry.trace.parent_span_id"]?.value as? String, span.spanId.sentrySpanIdString) + } + + func testApplyToItem_withoutSpan_shouldNotAddParentSpanId() { + // -- Arrange -- + let scope = TestScope(propagationContextTraceIdString: SentryId().sentryIdString) + let config = createTestConfig() + let metadata = createTestMetadata() + var item = createTestItem() + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + XCTAssertNil(item.attributes["sentry.trace.parent_span_id"]) + } + + // MARK: - OS Attributes Tests + + func testApplyToItem_withOSContext_shouldAddOSName() { + // -- Arrange -- + var scope = TestScope(propagationContextTraceIdString: SentryId().sentryIdString) + scope.setContext(value: ["name": "iOS", "version": "17.0"], key: "os") + let config = createTestConfig() + let metadata = createTestMetadata() + var item = createTestItem() + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + XCTAssertEqual(item.attributes["os.name"]?.value as? String, "iOS") + } + + func testApplyToItem_withOSContext_shouldAddOSVersion() { + // -- Arrange -- + var scope = TestScope(propagationContextTraceIdString: SentryId().sentryIdString) + scope.setContext(value: ["name": "iOS", "version": "17.0"], key: "os") + let config = createTestConfig() + let metadata = createTestMetadata() + var item = createTestItem() + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + XCTAssertEqual(item.attributes["os.version"]?.value as? String, "17.0") + } + + func testApplyToItem_withOSContextWithoutName_shouldNotAddOSName() { + // -- Arrange -- + var scope = TestScope(propagationContextTraceIdString: SentryId().sentryIdString) + scope.setContext(value: ["version": "17.0"], key: "os") + let config = createTestConfig() + let metadata = createTestMetadata() + var item = createTestItem() + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + XCTAssertNil(item.attributes["os.name"]) + } + + func testApplyToItem_withOSContextWithoutVersion_shouldNotAddOSVersion() { + // -- Arrange -- + var scope = TestScope(propagationContextTraceIdString: SentryId().sentryIdString) + scope.setContext(value: ["name": "iOS"], key: "os") + let config = createTestConfig() + let metadata = createTestMetadata() + var item = createTestItem() + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + XCTAssertNil(item.attributes["os.version"]) + } + + func testApplyToItem_withoutOSContext_shouldNotAddOSAttributes() { + // -- Arrange -- + let scope = TestScope(propagationContextTraceIdString: SentryId().sentryIdString) + let config = createTestConfig() + let metadata = createTestMetadata() + var item = createTestItem() + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + XCTAssertNil(item.attributes["os.name"]) + XCTAssertNil(item.attributes["os.version"]) + } + + // MARK: - Device Attributes Tests + + func testApplyToItem_withDeviceContext_shouldAddDeviceBrand() { + // -- Arrange -- + var scope = TestScope(propagationContextTraceIdString: SentryId().sentryIdString) + scope.setContext(value: ["model": "iPhone15,2"], key: "device") + let config = createTestConfig() + let metadata = createTestMetadata() + var item = createTestItem() + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + XCTAssertEqual(item.attributes["device.brand"]?.value as? String, "Apple") + } + + func testApplyToItem_withDeviceContext_shouldAddDeviceModel() { + // -- Arrange -- + var scope = TestScope(propagationContextTraceIdString: SentryId().sentryIdString) + scope.setContext(value: ["model": "iPhone15,2"], key: "device") + let config = createTestConfig() + let metadata = createTestMetadata() + var item = createTestItem() + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + XCTAssertEqual(item.attributes["device.model"]?.value as? String, "iPhone15,2") + } + + func testApplyToItem_withDeviceContext_shouldAddDeviceFamily() { + // -- Arrange -- + var scope = TestScope(propagationContextTraceIdString: SentryId().sentryIdString) + scope.setContext(value: ["family": "iPhone"], key: "device") + let config = createTestConfig() + let metadata = createTestMetadata() + var item = createTestItem() + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + XCTAssertEqual(item.attributes["device.family"]?.value as? String, "iPhone") + } + + func testApplyToItem_withDeviceContextWithoutModel_shouldNotAddDeviceModel() { + // -- Arrange -- + var scope = TestScope(propagationContextTraceIdString: SentryId().sentryIdString) + scope.setContext(value: ["family": "iPhone"], key: "device") + let config = createTestConfig() + let metadata = createTestMetadata() + var item = createTestItem() + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + XCTAssertNil(item.attributes["device.model"]) + } + + func testApplyToItem_withDeviceContextWithoutFamily_shouldNotAddDeviceFamily() { + // -- Arrange -- + var scope = TestScope(propagationContextTraceIdString: SentryId().sentryIdString) + scope.setContext(value: ["model": "iPhone15,2"], key: "device") + let config = createTestConfig() + let metadata = createTestMetadata() + var item = createTestItem() + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + XCTAssertNil(item.attributes["device.family"]) + } + + func testApplyToItem_withoutDeviceContext_shouldNotAddDeviceAttributes() { + // -- Arrange -- + let scope = TestScope(propagationContextTraceIdString: SentryId().sentryIdString) + let config = createTestConfig() + let metadata = createTestMetadata() + var item = createTestItem() + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + XCTAssertNil(item.attributes["device.brand"]) + XCTAssertNil(item.attributes["device.model"]) + XCTAssertNil(item.attributes["device.family"]) + } + + // MARK: - User Attributes Tests + + func testApplyToItem_withUser_shouldAddUserId() { + // -- Arrange -- + let user = User(userId: "user-123") + let scope = TestScope( + propagationContextTraceIdString: SentryId().sentryIdString, + userObject: user + ) + let config = createTestConfig() + let metadata = createTestMetadata() + var item = createTestItem() + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + XCTAssertEqual(item.attributes["user.id"]?.value as? String, "user-123") + } + + func testApplyToItem_withUser_shouldAddUserName() { + // -- Arrange -- + let user = User() + user.name = "John Doe" + let scope = TestScope( + propagationContextTraceIdString: SentryId().sentryIdString, + userObject: user + ) + let config = createTestConfig() + let metadata = createTestMetadata() + var item = createTestItem() + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + XCTAssertEqual(item.attributes["user.name"]?.value as? String, "John Doe") + } + + func testApplyToItem_withUser_shouldAddUserEmail() { + // -- Arrange -- + let user = User() + user.email = "john@example.com" + let scope = TestScope( + propagationContextTraceIdString: SentryId().sentryIdString, + userObject: user + ) + let config = createTestConfig() + let metadata = createTestMetadata() + var item = createTestItem() + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + XCTAssertEqual(item.attributes["user.email"]?.value as? String, "john@example.com") + } + + func testApplyToItem_withUserWithAllFields_shouldAddAllUserAttributes() { + // -- Arrange -- + let user = User(userId: "user-123") + user.name = "John Doe" + user.email = "john@example.com" + let scope = TestScope( + propagationContextTraceIdString: SentryId().sentryIdString, + userObject: user + ) + let config = createTestConfig() + let metadata = createTestMetadata() + var item = createTestItem() + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + XCTAssertEqual(item.attributes["user.id"]?.value as? String, "user-123") + XCTAssertEqual(item.attributes["user.name"]?.value as? String, "John Doe") + XCTAssertEqual(item.attributes["user.email"]?.value as? String, "john@example.com") + } + + func testApplyToItem_withoutUser_shouldNotAddUserAttributes() { + // -- Arrange -- + let scope = TestScope(propagationContextTraceIdString: SentryId().sentryIdString) + let config = createTestConfig() + let metadata = createTestMetadata(installationId: nil) + var item = createTestItem() + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + XCTAssertNil(item.attributes["user.id"]) + XCTAssertNil(item.attributes["user.name"]) + XCTAssertNil(item.attributes["user.email"]) + } + + // MARK: - Replay Attributes Tests + +#if canImport(UIKit) && !SENTRY_NO_UIKIT +#if os(iOS) || os(tvOS) + func testApplyToItem_withReplayId_shouldAddReplayId() { + // -- Arrange -- + let scope = TestScope( + replayId: "replay-123", + propagationContextTraceIdString: SentryId().sentryIdString + ) + let config = createTestConfig() + let metadata = createTestMetadata() + var item = createTestItem() + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + XCTAssertEqual(item.attributes["sentry.replay_id"]?.value as? String, "replay-123") + } + + func testApplyToItem_withoutReplayId_shouldNotAddReplayId() { + // -- Arrange -- + let scope = TestScope(propagationContextTraceIdString: SentryId().sentryIdString) + let config = createTestConfig() + let metadata = createTestMetadata() + var item = createTestItem() + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + XCTAssertNil(item.attributes["sentry.replay_id"]) + } +#endif +#endif + + // MARK: - Scope Attributes Tests + + func testApplyToItem_withScopeAttributes_shouldAddScopeAttributes() { + // -- Arrange -- + var scope = TestScope(propagationContextTraceIdString: SentryId().sentryIdString) + scope.attributes = ["custom.key": "custom.value", "custom.number": 42, "custom.bool": true] + let config = createTestConfig() + let metadata = createTestMetadata() + var item = createTestItem() + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + XCTAssertEqual(item.attributes["custom.key"]?.value as? String, "custom.value") + XCTAssertEqual(item.attributes["custom.number"]?.value as? Int, 42) + XCTAssertEqual(item.attributes["custom.bool"]?.value as? Bool, true) + } + + func testApplyToItem_withScopeAttributes_whenItemHasExistingAttribute_shouldNotOverride() { + // -- Arrange -- + var scope = TestScope(propagationContextTraceIdString: SentryId().sentryIdString) + scope.attributes = ["custom.key": "scope.value"] + let config = createTestConfig() + let metadata = createTestMetadata() + var item = createTestItem() + item.attributes["custom.key"] = .init(string: "item.value") + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + // Scope attributes should not override existing item attributes + XCTAssertEqual(item.attributes["custom.key"]?.value as? String, "item.value") + } + + // MARK: - Default User ID Tests + + func testApplyToItem_withoutUserAndWithInstallationId_shouldAddInstallationIdAsUserId() { + // -- Arrange -- + let scope = TestScope(propagationContextTraceIdString: SentryId().sentryIdString) + let config = createTestConfig() + let metadata = createTestMetadata(installationId: "installation-123") + var item = createTestItem() + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + XCTAssertEqual(item.attributes["user.id"]?.value as? String, "installation-123") + } + + func testApplyToItem_withoutUserAndWithoutInstallationId_shouldNotAddUserId() { + // -- Arrange -- + let scope = TestScope(propagationContextTraceIdString: SentryId().sentryIdString) + let config = createTestConfig() + let metadata = createTestMetadata(installationId: nil) + var item = createTestItem() + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + XCTAssertNil(item.attributes["user.id"]) + } + + func testApplyToItem_withUser_shouldNotAddInstallationIdAsUserId() { + // -- Arrange -- + let user = User(userId: "user-123") + let scope = TestScope( + propagationContextTraceIdString: SentryId().sentryIdString, + userObject: user + ) + let config = createTestConfig() + let metadata = createTestMetadata(installationId: "installation-123") + var item = createTestItem() + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + XCTAssertEqual(item.attributes["user.id"]?.value as? String, "user-123") + XCTAssertNotEqual(item.attributes["user.id"]?.value as? String, "installation-123") + } + + func testApplyToItem_withUserName_shouldNotAddInstallationIdAsUserId() { + // -- Arrange -- + let user = User() + user.name = "John Doe" + let scope = TestScope( + propagationContextTraceIdString: SentryId().sentryIdString, + userObject: user + ) + let config = createTestConfig() + let metadata = createTestMetadata(installationId: "installation-123") + var item = createTestItem() + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + XCTAssertNil(item.attributes["user.id"]) + } + + func testApplyToItem_withUserEmail_shouldNotAddInstallationIdAsUserId() { + // -- Arrange -- + let user = User() + user.email = "john@example.com" + let scope = TestScope( + propagationContextTraceIdString: SentryId().sentryIdString, + userObject: user + ) + let config = createTestConfig() + let metadata = createTestMetadata(installationId: "installation-123") + var item = createTestItem() + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + XCTAssertNil(item.attributes["user.id"]) + } + + // MARK: - Trace ID Tests + + func testApplyToItem_shouldSetTraceId() { + // -- Arrange -- + let traceId = SentryId() + let scope = TestScope(propagationContextTraceIdString: traceId.sentryIdString) + let config = createTestConfig() + let metadata = createTestMetadata() + var item = createTestItem() + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + XCTAssertEqual(item.traceId, traceId) + } + + func testApplyToItem_shouldSetTraceIdFromPropagationContext() { + // -- Arrange -- + let traceId1 = SentryId() + let traceId2 = SentryId() + let scope = TestScope(propagationContextTraceIdString: traceId1.sentryIdString) + let config = createTestConfig() + let metadata = createTestMetadata() + var item = createTestItem() + item.traceId = traceId2 + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + XCTAssertEqual(item.traceId, traceId1) + } + + // MARK: - Integration Tests + + func testApplyToItem_withAllAttributes_shouldAddAllAttributes() { + // -- Arrange -- + let traceId = SentryId() + let spanId = SentryId() + let span = TestSpan(spanId: spanId) + let user = User(userId: "user-123") + user.name = "John Doe" + user.email = "john@example.com" + + var scope = TestScope( + propagationContextTraceIdString: traceId.sentryIdString, + span: span, + userObject: user + ) + scope.setContext(value: ["name": "iOS", "version": "17.0"], key: "os") + scope.setContext(value: ["model": "iPhone15,2", "family": "iPhone"], key: "device") + + let config = createTestConfig() + let metadata = createTestMetadata(environment: "production", releaseName: "1.0.0", installationId: "installation-123") + var item = createTestItem() + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + // Default attributes + XCTAssertEqual(item.attributes["sentry.sdk.name"]?.value as? String, SentryMeta.sdkName) + XCTAssertEqual(item.attributes["sentry.sdk.version"]?.value as? String, SentryMeta.versionString) + XCTAssertEqual(item.attributes["sentry.environment"]?.value as? String, "production") + XCTAssertEqual(item.attributes["sentry.release"]?.value as? String, "1.0.0") + XCTAssertEqual(item.attributes["sentry.trace.parent_span_id"]?.value as? String, span.spanId.sentrySpanIdString) + + // OS attributes + XCTAssertEqual(item.attributes["os.name"]?.value as? String, "iOS") + XCTAssertEqual(item.attributes["os.version"]?.value as? String, "17.0") + + // Device attributes + XCTAssertEqual(item.attributes["device.brand"]?.value as? String, "Apple") + XCTAssertEqual(item.attributes["device.model"]?.value as? String, "iPhone15,2") + XCTAssertEqual(item.attributes["device.family"]?.value as? String, "iPhone") + + // User attributes + XCTAssertEqual(item.attributes["user.id"]?.value as? String, "user-123") + XCTAssertEqual(item.attributes["user.name"]?.value as? String, "John Doe") + XCTAssertEqual(item.attributes["user.email"]?.value as? String, "john@example.com") + + // Trace ID + XCTAssertEqual(item.traceId, traceId) + } + + func testApplyToItem_withMinimalAttributes_shouldAddOnlyRequiredAttributes() { + // -- Arrange -- + let traceId = SentryId() + let scope = TestScope(propagationContextTraceIdString: traceId.sentryIdString) + let config = createTestConfig() + let metadata = createTestMetadata(environment: "test", releaseName: nil, installationId: nil) + var item = createTestItem() + + // -- Act -- + scope.applyToItem(&item, config: config, metadata: metadata) + + // -- Assert -- + // Should always have these + XCTAssertEqual(item.attributes["sentry.sdk.name"]?.value as? String, SentryMeta.sdkName) + XCTAssertEqual(item.attributes["sentry.sdk.version"]?.value as? String, SentryMeta.versionString) + XCTAssertEqual(item.attributes["sentry.environment"]?.value as? String, "test") + XCTAssertEqual(item.traceId, traceId) + + // Should not have these + XCTAssertNil(item.attributes["sentry.release"]) + XCTAssertNil(item.attributes["sentry.trace.parent_span_id"]) + XCTAssertNil(item.attributes["os.name"]) + XCTAssertNil(item.attributes["device.brand"]) + XCTAssertNil(item.attributes["user.id"]) + } + + // MARK: - Helpers + + private func createTestItem() -> TestItem { + return TestItem( + attributes: [:], + traceId: SentryId(), + body: "test body" + ) + } + + private func createTestConfig() -> TestConfig { + return TestConfig( + flushTimeout: 0.1, + maxItemCount: 10, + maxBufferSizeBytes: 8_000, + beforeSendItem: nil, + capturedDataCallback: { _, _ in } + ) + } + + private func createTestMetadata( + environment: String = "test-environment", + releaseName: String? = "test-release", + installationId: String? = "test-installation-id" + ) -> TestMetadata { + return TestMetadata( + environment: environment, + releaseName: releaseName, + installationId: installationId + ) + } +} + +// MARK: - Test Helpers + +private final class TestSpan: NSObject, Span { + var spanId: SpanId + + init(spanId: SentryId) { + // Create a SpanId from the SentryId by converting to UUID first + // SpanId uses first 16 characters of UUID string (without dashes) + let uuidString = spanId.sentryIdString + let uuidWithoutDashes = uuidString.replacingOccurrences(of: "-", with: "") + let spanIdValue = String(uuidWithoutDashes.prefix(16)) + self.spanId = SpanId(value: spanIdValue) + super.init() + } + + // MARK: - Properties required by Span + var traceId: SentryId = SentryId() + var parentSpanId: SpanId? + var sampled: SentrySampleDecision = .undecided + var operation: String = "test" + var origin: String = "test" + var spanDescription: String? + var status: SentrySpanStatus = .undefined + var timestamp: Date? + var startTimestamp: Date? + var data: [String: Any] { [:] } + var tags: [String: String] { [:] } + var isFinished: Bool { false } + var traceContext: TraceContext? { nil } + + // MARK: - Methods required by Span + func startChild(operation: String) -> Span { return self } + func startChild(operation: String, description: String?) -> Span { return self } + func setData(value: Any?, key: String) {} + func removeData(key: String) {} + func setTag(value: String, key: String) {} + func removeTag(key: String) {} + func setMeasurement(name: String, value: NSNumber) {} + func setMeasurement(name: String, value: NSNumber, unit: MeasurementUnit) {} + func finish() {} + func finish(status: SentrySpanStatus) {} + func toTraceHeader() -> TraceHeader { + return TraceHeader(trace: traceId, spanId: spanId, sampled: sampled) + } + func baggageHttpHeader() -> String? { return nil } + func serialize() -> [String: Any] { return [:] } +} diff --git a/Tests/SentryTests/Batcher/BatcherTests.swift b/Tests/SentryTests/Batcher/BatcherTests.swift new file mode 100644 index 0000000000..c18767b036 --- /dev/null +++ b/Tests/SentryTests/Batcher/BatcherTests.swift @@ -0,0 +1,352 @@ +@_spi(Private) @testable import Sentry +@_spi(Private) import SentryTestUtils +import XCTest + +private struct TestScope: BatcherScope { + var replayId: String? + var propagationContextTraceIdString: String + var span: Span? + var userObject: User? + var contextStore: [String: [String: Any]] = [:] + var attributes: [String: Any] = [:] + + init(propagationContextTraceIdString: String = SentryId().sentryIdString) { + self.propagationContextTraceIdString = propagationContextTraceIdString + } + + func getContextForKey(_ key: String) -> [String: Any]? { + return contextStore[key] + } +} + +private struct TestItem: BatcherItem { + var attributes: [String: SentryAttribute] + var traceId: SentryId + var body: String + + init(body: String = "test", attributes: [String: SentryAttribute] = [:]) { + self.body = body + self.attributes = attributes + self.traceId = SentryId.empty + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(body) + } +} + +// Note: MockBuffer must be a class (not struct) because Batcher stores it internally +// and we need to observe changes from the test. Using a struct would create a copy. +private class MockBuffer: BatchBuffer { + typealias Item = TestItem + + var appendedItems: [TestItem] = [] + var flushCallCount = 0 + var mockSize: Int = 0 + + func append(_ element: Item) throws { + appendedItems.append(element) + // Simulate size growth - each item adds ~100 bytes + mockSize += 100 + } + + func clear() { + appendedItems.removeAll() + mockSize = 0 + flushCallCount += 1 + } + + var itemsCount: Int { + appendedItems.count + } + + var itemsDataSize: Int { + mockSize + } + + var batchedData: Data { + // Return minimal data for testing - we don't need to decode it + Data("test".utf8) + } +} + +final class BatcherTests: XCTestCase { + private var capturedDataInvocations: Invocations<(data: Data, count: Int)>! + private var testBuffer: MockBuffer! + private var testDateProvider: TestCurrentDateProvider! + private var testDispatchQueue: TestSentryDispatchQueueWrapper! + private var testScope: TestScope! + + override func setUp() { + super.setUp() + capturedDataInvocations = .init() + testDateProvider = TestCurrentDateProvider() + testDispatchQueue = TestSentryDispatchQueueWrapper() + testDispatchQueue.dispatchAsyncExecutesBlock = true + testBuffer = MockBuffer() + testScope = TestScope() + } + + private func getSut( + flushTimeout: TimeInterval = 0.1, + maxItemCount: Int = 10, + maxBufferSizeBytes: Int = 8_000, + beforeSendItem: ((TestItem) -> TestItem?)? = nil + ) -> Batcher { + var config = Batcher.Config( + flushTimeout: flushTimeout, + maxItemCount: maxItemCount, + maxBufferSizeBytes: maxBufferSizeBytes, + beforeSendItem: beforeSendItem + ) + let metadata = Batcher.Metadata( + environment: "test", + releaseName: "test-release", + installationId: "test-installation-id" + ) + config.capturedDataCallback = { [weak self] data, count in + self?.capturedDataInvocations.record((data, count)) + } + + return Batcher( + config: config, + metadata: metadata, + buffer: testBuffer, + dateProvider: testDateProvider, + dispatchQueue: testDispatchQueue + ) + } + + // MARK: - Add Method Tests + + func testAdd_whenSingleItem_shouldAppendToBuffer() { + // -- Arrange -- + let sut = getSut() + let item = TestItem(body: "test item") + + // -- Act -- + sut.add(item, scope: testScope) + + // -- Assert -- + XCTAssertEqual(testBuffer.itemsCount, 1) + XCTAssertEqual(testBuffer.appendedItems.first?.body, "test item") + } + + func testAdd_whenMultipleItems_shouldBatchTogether() { + // -- Arrange -- + let sut = getSut() + + // -- Act -- + sut.add(TestItem(body: "Item 1"), scope: testScope) + sut.add(TestItem(body: "Item 2"), scope: testScope) + + // -- Assert -- + XCTAssertEqual(testBuffer.itemsCount, 2) + XCTAssertEqual(testBuffer.appendedItems[0].body, "Item 1") + XCTAssertEqual(testBuffer.appendedItems[1].body, "Item 2") + } + + // MARK: - Max Item Count Tests + + func testAdd_whenMaxItemCountReached_shouldFlushImmediately() { + // -- Arrange -- + let sut = getSut(maxItemCount: 3) + + // -- Act -- + sut.add(TestItem(body: "Item 1"), scope: testScope) + sut.add(TestItem(body: "Item 2"), scope: testScope) + XCTAssertEqual(capturedDataInvocations.count, 0) + + sut.add(TestItem(body: "Item 3"), scope: testScope) + + // -- Assert -- + XCTAssertEqual(capturedDataInvocations.count, 1) + XCTAssertEqual(testBuffer.flushCallCount, 1) + } + + // MARK: - Buffer Size Tests + + func testAdd_whenMaxBufferSizeReached_shouldFlushImmediately() { + // -- Arrange -- + let sut = getSut(maxBufferSizeBytes: 200) // Each item is ~100 bytes + + // -- Act -- + sut.add(TestItem(body: "Item 1"), scope: testScope) + XCTAssertEqual(capturedDataInvocations.count, 0) + + sut.add(TestItem(body: "Item 2"), scope: testScope) // Total ~200 bytes + + // -- Assert -- + XCTAssertEqual(capturedDataInvocations.count, 1) + XCTAssertEqual(testBuffer.flushCallCount, 1) + } + + // MARK: - Timeout Tests + + func testAdd_whenFirstItemAdded_shouldStartTimer() { + // -- Arrange -- + let sut = getSut() + + // -- Act -- + sut.add(TestItem(), scope: testScope) + + // -- Assert -- + XCTAssertEqual(testDispatchQueue.dispatchAfterWorkItemInvocations.count, 1) + XCTAssertEqual(testDispatchQueue.dispatchAfterWorkItemInvocations.first?.interval, 0.1) + } + + func testAdd_whenTimerFires_shouldFlushAfterDelay() { + // -- Arrange -- + let sut = getSut() + + // -- Act -- + sut.add(TestItem(), scope: testScope) + testDispatchQueue.invokeLastDispatchAfterWorkItem() + + // -- Assert -- + XCTAssertEqual(capturedDataInvocations.count, 1) + XCTAssertEqual(testBuffer.flushCallCount, 1) + } + + func testAdd_whenBufferNotEmpty_shouldNotStartAdditionalTimer() { + // -- Arrange -- + let sut = getSut() + + // -- Act -- + sut.add(TestItem(), scope: testScope) + let initialTimerCount = testDispatchQueue.dispatchAfterWorkItemInvocations.count + sut.add(TestItem(), scope: testScope) + + // -- Assert -- + XCTAssertEqual(testDispatchQueue.dispatchAfterWorkItemInvocations.count, initialTimerCount) + } + + // MARK: - Capture Method Tests + + func testCapture_whenItemsInBuffer_shouldFlushImmediately() { + // -- Arrange -- + let sut = getSut() + sut.add(TestItem(body: "Item 1"), scope: testScope) + sut.add(TestItem(body: "Item 2"), scope: testScope) + + // -- Act -- + _ = sut.capture() + + // -- Assert -- + XCTAssertEqual(capturedDataInvocations.count, 1) + XCTAssertEqual(testBuffer.flushCallCount, 1) + } + + func testCapture_whenMultipleItems_shouldPassCorrectItemCount() { + // -- Arrange -- + let sut = getSut() + sut.add(TestItem(body: "Item 1"), scope: testScope) + sut.add(TestItem(body: "Item 2"), scope: testScope) + sut.add(TestItem(body: "Item 3"), scope: testScope) + + // -- Act -- + _ = sut.capture() + + // -- Assert -- + XCTAssertEqual(capturedDataInvocations.count, 1) + let invocation = capturedDataInvocations.invocations.first! + XCTAssertEqual(invocation.1, 3, "Callback should receive item count, not byte size") + } + + func testCapture_whenEmptyBuffer_shouldNotCallCallback() { + // -- Arrange -- + let sut = getSut() + + // -- Act -- + _ = sut.capture() + + // -- Assert -- + // Note: flush() is always called (in defer block), but callback should not be called when empty + XCTAssertEqual(capturedDataInvocations.count, 0) + } + + func testCapture_whenTimerScheduled_shouldCancelTimer() { + // -- Arrange -- + let sut = getSut() + sut.add(TestItem(), scope: testScope) + let timerWorkItem = testDispatchQueue.dispatchAfterWorkItemInvocations.first?.workItem + + // -- Act -- + _ = sut.capture() + timerWorkItem?.perform() + + // -- Assert -- + XCTAssertEqual(capturedDataInvocations.count, 1, "Timer should be cancelled") + } + + // MARK: - BeforeSendItem Callback Tests + + func testAdd_whenBeforeSendItemModifiesItem_shouldAppendModifiedItem() { + // -- Arrange -- + var beforeSendCalled = false + let sut = getSut( + beforeSendItem: { item in + beforeSendCalled = true + var modified = item + modified.body = "Modified" + return modified + } + ) + + // -- Act -- + sut.add(TestItem(body: "Original"), scope: testScope) + // Check before capture since capture flushes the buffer + XCTAssertEqual(testBuffer.appendedItems.first?.body, "Modified") + _ = sut.capture() + + // -- Assert -- + XCTAssertTrue(beforeSendCalled) + XCTAssertEqual(capturedDataInvocations.count, 1) + } + + func testAdd_whenBeforeSendItemReturnsNil_shouldDropItem() { + // -- Arrange -- + let sut = getSut(beforeSendItem: { _ in nil }) + + // -- Act -- + sut.add(TestItem(), scope: testScope) + _ = sut.capture() + + // -- Assert -- + XCTAssertEqual(capturedDataInvocations.count, 0) + XCTAssertEqual(testBuffer.itemsCount, 0) + } + + // MARK: - Edge Cases Tests + + func testAdd_whenScheduledFlushAfterManualFlush_shouldNotFlushAgain() { + // -- Arrange -- + let sut = getSut(maxBufferSizeBytes: 200) + sut.add(TestItem(), scope: testScope) + let timerWorkItem = testDispatchQueue.dispatchAfterWorkItemInvocations.first?.workItem + + // -- Act -- + sut.add(TestItem(), scope: testScope) // Triggers immediate flush + timerWorkItem?.perform() // Try to flush again + + // -- Assert -- + XCTAssertEqual(capturedDataInvocations.count, 1) + XCTAssertEqual(testBuffer.flushCallCount, 1) + } + + func testAdd_whenAfterFlush_shouldStartNewBatch() { + // -- Arrange -- + let sut = getSut() + + // -- Act -- + sut.add(TestItem(body: "Item 1"), scope: testScope) + _ = sut.capture() + sut.add(TestItem(body: "Item 2"), scope: testScope) + _ = sut.capture() + + // -- Assert -- + XCTAssertEqual(capturedDataInvocations.count, 2) + XCTAssertEqual(testBuffer.flushCallCount, 2) + } +} diff --git a/Tests/SentryTests/Batcher/InMemoryBatchBufferTests.swift b/Tests/SentryTests/Batcher/InMemoryBatchBufferTests.swift new file mode 100644 index 0000000000..053620cf99 --- /dev/null +++ b/Tests/SentryTests/Batcher/InMemoryBatchBufferTests.swift @@ -0,0 +1,450 @@ +@_spi(Private) @testable import Sentry +@_spi(Private) import SentryTestUtils +import XCTest + +final class InMemoryBatchBufferTests: XCTestCase { + private struct TestElement: Codable, Equatable { + let id: Int + } + + private struct TestPayload: Decodable { + let items: [TestElement] + } + + // MARK: - Count Property Tests + + func testCount_withNoElements_shouldReturnZero() { + // -- Act -- + let sut = InMemoryBatchBuffer() + + // -- Assert -- + XCTAssertEqual(sut.itemsCount, 0) + } + + func testCount_withSingleElement_shouldReturnOne() throws { + // -- Arrange -- + var sut = InMemoryBatchBuffer() + + // -- Act -- + try sut.append(TestElement(id: 1)) + + // -- Assert -- + XCTAssertEqual(sut.itemsCount, 1) + } + + func testCount_withMultipleElements_shouldReturnCorrectCount() throws { + // -- Arrange -- + var sut = InMemoryBatchBuffer() + + // -- Act -- + try sut.append(TestElement(id: 1)) + try sut.append(TestElement(id: 2)) + try sut.append(TestElement(id: 3)) + + // -- Assert -- + XCTAssertEqual(sut.itemsCount, 3) + } + + // MARK: - Append Method Tests + + func testAppend_withSingleElement_shouldAddElement() throws { + // -- Arrange -- + var sut = InMemoryBatchBuffer() + + // -- Act -- + try sut.append(TestElement(id: 1)) + + // -- Assert -- + XCTAssertEqual(sut.itemsCount, 1) + let decoded = try decodePayload(data: sut.batchedData) + XCTAssertEqual(decoded.items, [TestElement(id: 1)]) + } + + func testAppend_withMultipleElements_shouldAddAllElements() throws { + // -- Arrange -- + var sut = InMemoryBatchBuffer() + + // -- Act -- + try sut.append(TestElement(id: 1)) + try sut.append(TestElement(id: 2)) + try sut.append(TestElement(id: 3)) + + // -- Assert -- + XCTAssertEqual(sut.itemsCount, 3) + let decoded = try decodePayload(data: sut.batchedData) + XCTAssertEqual(decoded.items, [ + TestElement(id: 1), + TestElement(id: 2), + TestElement(id: 3) + ]) + } + + func testAppend_withMultipleElements_shouldMaintainOrder() throws { + // -- Arrange -- + var sut = InMemoryBatchBuffer() + + // -- Act -- + try sut.append(TestElement(id: 1)) + try sut.append(TestElement(id: 2)) + try sut.append(TestElement(id: 3)) + + // -- Assert -- + let decoded = try decodePayload(data: sut.batchedData) + XCTAssertEqual(decoded.items[0].id, 1) + XCTAssertEqual(decoded.items[1].id, 2) + XCTAssertEqual(decoded.items[2].id, 3) + } + + func testAppend_shouldIncreaseSize() throws { + // -- Arrange -- + var sut = InMemoryBatchBuffer() + let initialSize = sut.itemsDataSize + XCTAssertEqual(initialSize, 0) + + // -- Act -- + let element1 = TestElement(id: 1) + let encoded1 = try JSONEncoder().encode(element1) + try sut.append(element1) + + // -- Assert -- + XCTAssertEqual(sut.itemsDataSize, encoded1.count) + + // -- Act -- + let element2 = TestElement(id: 2) + let encoded2 = try JSONEncoder().encode(element2) + try sut.append(element2) + + // -- Assert -- + XCTAssertEqual(sut.itemsDataSize, encoded1.count + encoded2.count) + } + + // MARK: - Flush Method Tests + + func testFlush_withNoElements_shouldDoNothing() { + // -- Arrange -- + var sut = InMemoryBatchBuffer() + + // Assert pre-condition + XCTAssertEqual(sut.itemsCount, 0) + XCTAssertEqual(sut.itemsDataSize, 0) + + // -- Act -- + sut.clear() + + // -- Assert -- + XCTAssertEqual(sut.itemsCount, 0) + XCTAssertEqual(sut.itemsDataSize, 0) + } + + func testFlush_withSingleElement_shouldClearStorage() throws { + // -- Arrange -- + var sut = InMemoryBatchBuffer() + try sut.append(TestElement(id: 1)) + + // Assert pre-condition + XCTAssertEqual(sut.itemsCount, 1) + XCTAssertGreaterThan(sut.itemsDataSize, 0) + + // -- Act -- + sut.clear() + + // -- Assert -- + XCTAssertEqual(sut.itemsCount, 0) + XCTAssertEqual(sut.itemsDataSize, 0) + let decoded = try decodePayload(data: sut.batchedData) + XCTAssertEqual(decoded.items, []) + } + + func testFlush_withMultipleElements_shouldClearStorage() throws { + // -- Arrange -- + var sut = InMemoryBatchBuffer() + try sut.append(TestElement(id: 1)) + try sut.append(TestElement(id: 2)) + try sut.append(TestElement(id: 3)) + + // Assert pre-condition + XCTAssertEqual(sut.itemsCount, 3) + XCTAssertGreaterThan(sut.itemsDataSize, 0) + + // -- Act -- + sut.clear() + + // -- Assert -- + XCTAssertEqual(sut.itemsCount, 0) + XCTAssertEqual(sut.itemsDataSize, 0) + let decoded = try decodePayload(data: sut.batchedData) + XCTAssertEqual(decoded.items, []) + } + + func testFlush_afterFlush_shouldAllowNewAppends() throws { + // -- Arrange -- + var sut = InMemoryBatchBuffer() + try sut.append(TestElement(id: 1)) + try sut.append(TestElement(id: 2)) + + // -- Act -- + sut.clear() + try sut.append(TestElement(id: 3)) + + // -- Assert -- + XCTAssertEqual(sut.itemsCount, 1) + let decoded = try decodePayload(data: sut.batchedData) + XCTAssertEqual(decoded.items, [TestElement(id: 3)]) + } + + // MARK: - Data Property Tests + + func testData_withNoElements_shouldReturnEmptyArray() throws { + // -- Arrange -- + let sut = InMemoryBatchBuffer() + + // -- Act -- + let data = sut.batchedData + + // -- Assert -- + let decoded = try decodePayload(data: data) + XCTAssertEqual(decoded.items, []) + } + + func testData_withSingleElement_shouldReturnSingleElement() throws { + // -- Arrange -- + var sut = InMemoryBatchBuffer() + try sut.append(TestElement(id: 1)) + + // -- Act -- + let data = sut.batchedData + + // -- Assert -- + let decoded = try decodePayload(data: data) + XCTAssertEqual(decoded.items, [TestElement(id: 1)]) + } + + func testData_withMultipleElements_shouldReturnAllElements() throws { + // -- Arrange -- + var sut = InMemoryBatchBuffer() + try sut.append(TestElement(id: 1)) + try sut.append(TestElement(id: 2)) + try sut.append(TestElement(id: 3)) + + // -- Act -- + let data = sut.batchedData + + // -- Assert -- + let decoded = try decodePayload(data: data) + XCTAssertEqual(decoded.items, [ + TestElement(id: 1), + TestElement(id: 2), + TestElement(id: 3) + ]) + } + + func testData_shouldReturnValidJSONFormat() throws { + // -- Arrange -- + var sut = InMemoryBatchBuffer() + try sut.append(TestElement(id: 1)) + try sut.append(TestElement(id: 2)) + + // -- Act -- + let data = sut.batchedData + + // -- Assert -- + // Verify it's valid JSON by decoding + let decoded = try decodePayload(data: data) + XCTAssertEqual(decoded.items.count, 2) + + // Verify JSON structure by checking string representation + let jsonString = String(data: data, encoding: .utf8) ?? "" + XCTAssertTrue(jsonString.hasPrefix("{\"items\":[")) + XCTAssertTrue(jsonString.hasSuffix("]}")) + } + + func testData_shouldMaintainElementOrder() throws { + // -- Arrange -- + var sut = InMemoryBatchBuffer() + try sut.append(TestElement(id: 10)) + try sut.append(TestElement(id: 20)) + try sut.append(TestElement(id: 30)) + + // -- Act -- + let data = sut.batchedData + + // -- Assert -- + let decoded = try decodePayload(data: data) + XCTAssertEqual(decoded.items[0].id, 10) + XCTAssertEqual(decoded.items[1].id, 20) + XCTAssertEqual(decoded.items[2].id, 30) + } + + // MARK: - Size Property Tests + + func testSize_withNoElements_shouldReturnZero() { + // -- Arrange -- + let sut = InMemoryBatchBuffer() + + // -- Act -- + let size = sut.itemsDataSize + + // -- Assert -- + XCTAssertEqual(size, 0) + } + + func testSize_withSingleElement_shouldReturnEncodedElementSize() throws { + // -- Arrange -- + var sut = InMemoryBatchBuffer() + let element = TestElement(id: 1) + let expectedSize = try JSONEncoder().encode(element).count + + // -- Act -- + try sut.append(element) + + // -- Assert -- + XCTAssertEqual(sut.itemsDataSize, expectedSize) + } + + func testSize_withMultipleElements_shouldReturnSumOfEncodedSizes() throws { + // -- Arrange -- + var sut = InMemoryBatchBuffer() + let element1 = TestElement(id: 1) + let element2 = TestElement(id: 2) + let element3 = TestElement(id: 3) + let encoder = JSONEncoder() + let expectedSize1 = try encoder.encode(element1).count + let expectedSize2 = try encoder.encode(element2).count + let expectedSize3 = try encoder.encode(element3).count + let expectedTotalSize = expectedSize1 + expectedSize2 + expectedSize3 + + // -- Act -- + try sut.append(element1) + try sut.append(element2) + try sut.append(element3) + + // -- Assert -- + XCTAssertEqual(sut.itemsDataSize, expectedTotalSize) + } + + func testSize_afterFlush_shouldReturnZero() throws { + // -- Arrange -- + var sut = InMemoryBatchBuffer() + try sut.append(TestElement(id: 1)) + try sut.append(TestElement(id: 2)) + + // Assert pre-condition + XCTAssertGreaterThan(sut.itemsDataSize, 0) + + // -- Act -- + sut.clear() + + // -- Assert -- + XCTAssertEqual(sut.itemsDataSize, 0) + } + + func testSize_shouldUpdateAfterEachAppend() throws { + // -- Arrange -- + var sut = InMemoryBatchBuffer() + let element1 = TestElement(id: 1) + let element2 = TestElement(id: 2) + let encoder = JSONEncoder() + let size1 = try encoder.encode(element1).count + let size2 = try encoder.encode(element2).count + + // -- Act & Assert -- + XCTAssertEqual(sut.itemsDataSize, 0) + + try sut.append(element1) + XCTAssertEqual(sut.itemsDataSize, size1) + + try sut.append(element2) + XCTAssertEqual(sut.itemsDataSize, size1 + size2) + } + + // MARK: - Integration Tests + + func testAppendFlushAppend_shouldWorkCorrectly() throws { + // -- Arrange -- + var sut = InMemoryBatchBuffer() + + // -- Act & Assert -- + try sut.append(TestElement(id: 1)) + XCTAssertEqual(sut.itemsCount, 1) + XCTAssertGreaterThan(sut.itemsDataSize, 0) + + sut.clear() + XCTAssertEqual(sut.itemsCount, 0) + XCTAssertEqual(sut.itemsDataSize, 0) + + try sut.append(TestElement(id: 2)) + try sut.append(TestElement(id: 3)) + XCTAssertEqual(sut.itemsCount, 2) + + let decoded = try decodePayload(data: sut.batchedData) + XCTAssertEqual(decoded.items, [ + TestElement(id: 2), + TestElement(id: 3) + ]) + } + + func testMultipleFlushCalls_shouldNotCauseIssues() throws { + // -- Arrange -- + var sut = InMemoryBatchBuffer() + try sut.append(TestElement(id: 1)) + + // -- Act -- + sut.clear() + sut.clear() + sut.clear() + + // -- Assert -- + XCTAssertEqual(sut.itemsCount, 0) + XCTAssertEqual(sut.itemsDataSize, 0) + let decoded = try decodePayload(data: sut.batchedData) + XCTAssertEqual(decoded.items, []) + } + + // MARK: - Date Encoding Tests + + private struct TestElementWithDate: Codable { + let id: Int + let timestamp: Date + } + + private struct TestPayloadWithDate: Decodable { + let items: [TestElementWithDate] + } + + func testAppend_withDateProperty_shouldEncodeAsSecondsSince1970() throws { + // -- Arrange -- + var sut = InMemoryBatchBuffer() + let expectedTimestamp = Date(timeIntervalSince1970: 1_234_567_890.987654) + let element = TestElementWithDate(id: 1, timestamp: expectedTimestamp) + + // -- Act -- + try sut.append(element) + + // -- Assert -- + let data = sut.batchedData + let jsonObject = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let items = try XCTUnwrap(jsonObject?["items"] as? [[String: Any]]) + let firstItem = try XCTUnwrap(items.first) + + // Verify timestamp is encoded as seconds since 1970 (not seconds since reference date) + let timestampValue = try XCTUnwrap(firstItem["timestamp"] as? TimeInterval) + XCTAssertEqual(timestampValue, 1_234_567_890.987654, accuracy: 0.000001) + + // Verify we can decode it back correctly + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + let decoded = try decoder.decode(TestPayloadWithDate.self, from: data) + let decodedItem = try XCTUnwrap(decoded.items.first) + XCTAssertEqual( + decodedItem.timestamp.timeIntervalSince1970, + expectedTimestamp.timeIntervalSince1970, + accuracy: 0.000001 + ) + } + + // MARK: - Helpers + + private func decodePayload(data: Data) throws -> TestPayload { + return try JSONDecoder().decode(TestPayload.self, from: data) + } +} diff --git a/Tests/SentryTests/Integrations/Feedback/SentryFeedbackTests.swift b/Tests/SentryTests/Integrations/Feedback/SentryFeedbackTests.swift index fb29a03d58..707e87231e 100644 --- a/Tests/SentryTests/Integrations/Feedback/SentryFeedbackTests.swift +++ b/Tests/SentryTests/Integrations/Feedback/SentryFeedbackTests.swift @@ -218,13 +218,15 @@ class SentryFeedbackTests: XCTestCase { let transport = TestTransport() let transportAdapter = TestTransportAdapter(transports: [transport], options: options) + let dateProvider = TestCurrentDateProvider() let client = SentryClientInternal( options: options, + dateProvider: dateProvider, transportAdapter: transportAdapter, fileManager: try XCTUnwrap(SentryFileManager( options: options, - dateProvider: TestCurrentDateProvider(), + dateProvider: dateProvider, dispatchQueueWrapper: TestSentryDispatchQueueWrapper() )), threadInspector: TestDefaultThreadInspector.instance, @@ -260,13 +262,15 @@ class SentryFeedbackTests: XCTestCase { let transport = TestTransport() let transportAdapter = TestTransportAdapter(transports: [transport], options: options) + let dateProvider = TestCurrentDateProvider() let client = SentryClientInternal( options: options, + dateProvider: dateProvider, transportAdapter: transportAdapter, fileManager: try XCTUnwrap(SentryFileManager( options: options, - dateProvider: TestCurrentDateProvider(), + dateProvider: dateProvider, dispatchQueueWrapper: TestSentryDispatchQueueWrapper() )), threadInspector: TestDefaultThreadInspector.instance, @@ -302,13 +306,15 @@ class SentryFeedbackTests: XCTestCase { let transport = TestTransport() let transportAdapter = TestTransportAdapter(transports: [transport], options: options) - + let dateProvider = TestCurrentDateProvider() + let client = SentryClientInternal( options: options, + dateProvider: dateProvider, transportAdapter: transportAdapter, fileManager: try XCTUnwrap(SentryFileManager( options: options, - dateProvider: TestCurrentDateProvider(), + dateProvider: dateProvider, dispatchQueueWrapper: TestSentryDispatchQueueWrapper() )), threadInspector: TestDefaultThreadInspector.instance, diff --git a/Tests/SentryTests/Protocol/SentryLogAttributeTests.swift b/Tests/SentryTests/Protocol/SentryAttributeTests.swift similarity index 99% rename from Tests/SentryTests/Protocol/SentryLogAttributeTests.swift rename to Tests/SentryTests/Protocol/SentryAttributeTests.swift index e521aba9d2..3640d9351c 100644 --- a/Tests/SentryTests/Protocol/SentryLogAttributeTests.swift +++ b/Tests/SentryTests/Protocol/SentryAttributeTests.swift @@ -1,7 +1,7 @@ @testable import Sentry import XCTest -final class SentryLogAttributeTests: XCTestCase { +final class SentryAttributeTests: XCTestCase { // MARK: - Encoding Tests diff --git a/Tests/SentryTests/SentryClient+TestInit.h b/Tests/SentryTests/SentryClient+TestInit.h index 2346528f9f..b8aee764e4 100644 --- a/Tests/SentryTests/SentryClient+TestInit.h +++ b/Tests/SentryTests/SentryClient+TestInit.h @@ -14,6 +14,7 @@ NS_ASSUME_NONNULL_BEGIN @interface SentryClientInternal () - (instancetype)initWithOptions:(NSObject *)options + dateProvider:(id)dateProvider transportAdapter:(SentryTransportAdapter *)transportAdapter fileManager:(SentryFileManager *)fileManager threadInspector:(SentryDefaultThreadInspector *)threadInspector diff --git a/Tests/SentryTests/SentryClientTests.swift b/Tests/SentryTests/SentryClientTests.swift index 38ef6e9d4d..06876f5626 100644 --- a/Tests/SentryTests/SentryClientTests.swift +++ b/Tests/SentryTests/SentryClientTests.swift @@ -10,6 +10,7 @@ extension SentryClientInternal { self.init( options: options, + dateProvider: SentryDependencyContainer.sharedInstance().dateProvider, transportAdapter: transportAdapter, fileManager: fileManager, threadInspector: SentryDefaultThreadInspector(options: options), @@ -112,6 +113,7 @@ class SentryClientTests: XCTestCase { client = SentryClientInternal( options: options, + dateProvider: dateProvider, transportAdapter: transportAdapter, fileManager: fileManager, threadInspector: threadInspector, @@ -2401,6 +2403,7 @@ class SentryClientTests: XCTestCase { flushTimeout: 5, maxLogCount: 100, maxBufferSizeBytes: 1_024 * 1_024, + dateProvider: TestCurrentDateProvider(), dispatchQueue: TestSentryDispatchQueueWrapper(), delegate: testDelegate ) @@ -2432,6 +2435,7 @@ class SentryClientTests: XCTestCase { flushTimeout: 5, maxLogCount: 100, maxBufferSizeBytes: 1_024 * 1_024, + dateProvider: TestCurrentDateProvider(), dispatchQueue: TestSentryDispatchQueueWrapper(), delegate: testDelegate ) @@ -2453,6 +2457,7 @@ class SentryClientTests: XCTestCase { flushTimeout: 5, maxLogCount: 100, maxBufferSizeBytes: 1_024 * 1_024, + dateProvider: TestCurrentDateProvider(), dispatchQueue: TestSentryDispatchQueueWrapper(), delegate: testDelegate ) diff --git a/Tests/SentryTests/SentryLogBatcherTests.swift b/Tests/SentryTests/SentryLogBatcherTests.swift index 30535d27b2..4d588c5e27 100644 --- a/Tests/SentryTests/SentryLogBatcherTests.swift +++ b/Tests/SentryTests/SentryLogBatcherTests.swift @@ -5,30 +5,35 @@ import XCTest final class SentryLogBatcherTests: XCTestCase { private var options: Options! + private var testDateProvider: TestCurrentDateProvider! private var testDelegate: TestLogBatcherDelegate! private var testDispatchQueue: TestSentryDispatchQueueWrapper! - private var sut: SentryLogBatcher! - private var scope: Scope! + private var scope: Scope! + + private func getSut() -> SentryLogBatcher { + return SentryLogBatcher( + options: options, + flushTimeout: 0.1, // Very small timeout for testing + maxLogCount: 10, // Maximum 10 logs per batch + maxBufferSizeBytes: 8_000, // byte limit for testing (log with attributes ~390 bytes) + dateProvider: testDateProvider, + dispatchQueue: testDispatchQueue, + delegate: testDelegate + ) + } override func setUp() { super.setUp() options = Options() - options.dsn = TestConstants.dsnAsString(username: "SentryLogBatcherTests") + options.dsn = TestConstants.dsnForTestCase(type: Self.self) options.enableLogs = true - + + testDateProvider = TestCurrentDateProvider() testDelegate = TestLogBatcherDelegate() testDispatchQueue = TestSentryDispatchQueueWrapper() testDispatchQueue.dispatchAsyncExecutesBlock = true // Execute encoding immediately - sut = SentryLogBatcher( - options: options, - flushTimeout: 0.1, // Very small timeout for testing - maxLogCount: 10, // Maximum 10 logs per batch - maxBufferSizeBytes: 8_000, // byte limit for testing (log with attributes ~390 bytes) - dispatchQueue: testDispatchQueue, - delegate: testDelegate - ) scope = Scope() } @@ -36,28 +41,23 @@ final class SentryLogBatcherTests: XCTestCase { super.tearDown() testDelegate = nil testDispatchQueue = nil - sut = nil scope = nil } // MARK: - Basic Functionality Tests func testAddMultipleLogs_BatchesTogether() throws { - // Arrange + // -- Arrange -- + let sut = getSut() let log1 = createTestLog(body: "Log 1") let log2 = createTestLog(body: "Log 2") - // Act + // -- Act -- sut.addLog(log1, scope: scope) sut.addLog(log2, scope: scope) - - // Assert - XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 0) - - // Trigger flush manually sut.captureLogs() - // Verify both logs are batched together + // -- Assert -- XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 1) let capturedLogs = testDelegate.getCapturedLogs() @@ -69,17 +69,17 @@ final class SentryLogBatcherTests: XCTestCase { // MARK: - Buffer Size Tests func testBufferReachesMaxSize_FlushesImmediately() throws { - // Arrange + // -- Arrange -- + let sut = getSut() let largeLogBody = String(repeating: "A", count: 8_000) // Larger than 8000 byte limit let largeLog = createTestLog(body: largeLogBody) - // Act + // -- Act -- sut.addLog(largeLog, scope: scope) - // Assert + // -- Assert -- XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 1) - // Verify the large log is sent let capturedLogs = testDelegate.getCapturedLogs() XCTAssertEqual(capturedLogs.count, 1) XCTAssertEqual(capturedLogs[0].body, largeLogBody) @@ -88,7 +88,10 @@ final class SentryLogBatcherTests: XCTestCase { // MARK: - Max Log Count Tests func testMaxLogCount_FlushesWhenReached() throws { - // Act - Add exactly maxLogCount logs + // -- Arrange -- + let sut = getSut() + + // -- Act -- for i in 0..<9 { let log = createTestLog(body: "Log \(i + 1)") sut.addLog(log, scope: scope) @@ -99,7 +102,7 @@ final class SentryLogBatcherTests: XCTestCase { let log = createTestLog(body: "Log \(10)") // Reached 10 max logs limit sut.addLog(log, scope: scope) - // Assert - Should have flushed once when reaching maxLogCount + // -- Assert -- XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 1) let capturedLogs = testDelegate.getCapturedLogs() @@ -109,50 +112,39 @@ final class SentryLogBatcherTests: XCTestCase { // MARK: - Timeout Tests func testTimeout_FlushesAfterDelay() throws { - // Arrange + // -- Arrange -- + let sut = getSut() let log = createTestLog() - // Act + // -- Act -- sut.addLog(log, scope: scope) + testDispatchQueue.invokeLastDispatchAfterWorkItem() - // Assert - XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 0) + // -- Assert -- XCTAssertEqual(testDispatchQueue.dispatchAfterWorkItemInvocations.count, 1) XCTAssertEqual(testDispatchQueue.dispatchAfterWorkItemInvocations.first?.interval, 0.1) - - // Manually trigger the timer to simulate timeout - testDispatchQueue.invokeLastDispatchAfterWorkItem() - - // Verify flush occurred XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 1) + let capturedLogs = testDelegate.getCapturedLogs() XCTAssertEqual(capturedLogs.count, 1) } func testAddingLogToEmptyBuffer_StartsTimer() throws { - // Arrange + // -- Arrange -- + let sut = getSut() let log1 = createTestLog(body: "Log 1") let log2 = createTestLog(body: "Log 2") - // Act + // -- Act -- sut.addLog(log1, scope: scope) - - // Assert - XCTAssertEqual(testDispatchQueue.dispatchAfterWorkItemInvocations.count, 1) - XCTAssertEqual(testDispatchQueue.dispatchAfterWorkItemInvocations.first?.interval, 0.1) - sut.addLog(log2, scope: scope) - - XCTAssertEqual(testDispatchQueue.dispatchAfterWorkItemInvocations.count, 1) - - // Should not flush immediately - XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 0) - - // Manually trigger the timer testDispatchQueue.invokeLastDispatchAfterWorkItem() - // Verify both logs are flushed together + // -- Assert -- + XCTAssertEqual(testDispatchQueue.dispatchAfterWorkItemInvocations.count, 1) + XCTAssertEqual(testDispatchQueue.dispatchAfterWorkItemInvocations.first?.interval, 0.1) XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 1) + let capturedLogs = testDelegate.getCapturedLogs() XCTAssertEqual(capturedLogs.count, 2) } @@ -160,18 +152,17 @@ final class SentryLogBatcherTests: XCTestCase { // MARK: - Manual Capture Logs Tests func testManualCaptureLogs_CapturesImmediately() throws { - // Arrange + // -- Arrange -- + let sut = getSut() let log1 = createTestLog(body: "Log 1") let log2 = createTestLog(body: "Log 2") - // Act + // -- Act -- sut.addLog(log1, scope: scope) sut.addLog(log2, scope: scope) - XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 0) - sut.captureLogs() - // Assert + // -- Assert -- XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 1) let capturedLogs = testDelegate.getCapturedLogs() @@ -179,71 +170,65 @@ final class SentryLogBatcherTests: XCTestCase { } func testManualCaptureLogs_CancelsScheduledCapture() throws { - // Arrange + // -- Arrange -- + let sut = getSut() let log = createTestLog() sut.addLog(log, scope: scope) - XCTAssertEqual(testDispatchQueue.dispatchAfterWorkItemInvocations.count, 1) let timerWorkItem = try XCTUnwrap(testDispatchQueue.dispatchAfterWorkItemInvocations.first?.workItem) - // Act + // -- Act -- sut.captureLogs() - - // Assert - XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 1, "Manual flush should work") - timerWorkItem.perform() - XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 1, "Timer should be cancelled") + + // -- Assert -- + XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 1, "Manual flush should work and timer should be cancelled") } func testManualCaptureLogs_WithEmptyBuffer_DoesNothing() { - // Act + // -- Arrange -- + let sut = getSut() + + // -- Act -- sut.captureLogs() - // Assert + // -- Assert -- XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 0) } // MARK: - Edge Cases Tests func testScheduledFlushAfterBufferAlreadyFlushed_DoesNothing() throws { - // Arrange + // -- Arrange -- + let sut = getSut() let largeLogBody = String(repeating: "B", count: 4_000) let log1 = createTestLog(body: largeLogBody) let log2 = createTestLog(body: largeLogBody) - // Act + // -- Act -- sut.addLog(log1, scope: scope) - XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 0) - XCTAssertEqual(testDispatchQueue.dispatchAfterWorkItemInvocations.count, 1) let timerWorkItem = try XCTUnwrap(testDispatchQueue.dispatchAfterWorkItemInvocations.first?.workItem) - sut.addLog(log2, scope: scope) - XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 1) - timerWorkItem.perform() - // Assert + // -- Assert -- XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 1) } func testAddLogAfterFlush_StartsNewBatch() throws { - // Arrange + // -- Arrange -- + let sut = getSut() let log1 = createTestLog(body: "Log 1") let log2 = createTestLog(body: "Log 2") - // Act + // -- Act -- sut.addLog(log1, scope: scope) sut.captureLogs() - - XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 1) - sut.addLog(log2, scope: scope) sut.captureLogs() - // Assert + // -- Assert -- XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 2) - // Verify each flush contains only one log let allCapturedLogs = testDelegate.getCapturedLogs() XCTAssertEqual(allCapturedLogs.count, 2) XCTAssertEqual(allCapturedLogs[0].body, "Log 1") @@ -253,12 +238,13 @@ final class SentryLogBatcherTests: XCTestCase { // MARK: - Integration Tests func testConcurrentAdds_ThreadSafe() throws { - // Arrange + // -- Arrange -- let sutWithRealQueue = SentryLogBatcher( options: options, flushTimeout: 5, maxLogCount: 1_000, // Maximum 1000 logs per batch maxBufferSizeBytes: 10_000, + dateProvider: testDateProvider, dispatchQueue: SentryDispatchQueueWrapper(), delegate: testDelegate ) @@ -266,7 +252,7 @@ final class SentryLogBatcherTests: XCTestCase { let expectation = XCTestExpectation(description: "Concurrent adds") expectation.expectedFulfillmentCount = 10 - // Act + // -- Act -- for i in 0..<10 { DispatchQueue.global().async { let log = self.createTestLog(body: "Log \(i)") @@ -277,18 +263,19 @@ final class SentryLogBatcherTests: XCTestCase { wait(for: [expectation], timeout: 1.0) sutWithRealQueue.captureLogs() - // Assert + // -- Assert -- let capturedLogs = self.testDelegate.getCapturedLogs() XCTAssertEqual(capturedLogs.count, 10, "All 10 concurrently added logs should be in the batch") } func testDispatchAfterTimeoutWithRealDispatchQueue() throws { - // Arrange + // -- Arrange -- let sutWithRealQueue = SentryLogBatcher( options: options, flushTimeout: 0.2, maxLogCount: 1_000, // Maximum 1000 logs per batch maxBufferSizeBytes: 10_000, + dateProvider: testDateProvider, dispatchQueue: SentryDispatchQueueWrapper(), delegate: testDelegate ) @@ -296,16 +283,14 @@ final class SentryLogBatcherTests: XCTestCase { let log = createTestLog(body: "Real timeout test log") let expectation = XCTestExpectation(description: "Real timeout flush") - // Act + // -- Act -- sutWithRealQueue.addLog(log, scope: scope) - XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 0) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { expectation.fulfill() } wait(for: [expectation], timeout: 1.0) - // Assert + // -- Assert -- XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 1, "Timeout should trigger flush") let capturedLogs = self.testDelegate.getCapturedLogs() @@ -316,17 +301,20 @@ final class SentryLogBatcherTests: XCTestCase { // MARK: - Attribute Enrichment Tests func testAddLog_AddsDefaultAttributes() throws { + // -- Arrange -- options.environment = "test-environment" options.releaseName = "1.0.0" - + let sut = getSut() + let span = SentryTracer(transactionContext: TransactionContext(name: "Test Transaction", operation: "test-operation"), hub: nil) scope.span = span - let log = createTestLog(body: "Test log message") + + // -- Act -- sut.addLog(log, scope: scope) sut.captureLogs() - // Verify the log was batched and sent + // -- Assert -- let capturedLogs = testDelegate.getCapturedLogs() XCTAssertEqual(capturedLogs.count, 1) @@ -341,51 +329,60 @@ final class SentryLogBatcherTests: XCTestCase { } func testAddLog_DoesNotAddNilDefaultAttributes() throws { + // -- Arrange -- options.releaseName = nil - // No span set on scope - + let sut = getSut() let log = createTestLog(body: "Test log message") + + // -- Act -- sut.addLog(log, scope: scope) sut.captureLogs() + // -- Assert -- let capturedLogs = testDelegate.getCapturedLogs() let capturedLog = try XCTUnwrap(capturedLogs.first) let attributes = capturedLog.attributes XCTAssertNil(attributes["sentry.release"]) XCTAssertNil(attributes["sentry.trace.parent_span_id"]) - - // But should still have the non-nil defaults XCTAssertEqual(attributes["sentry.sdk.name"]?.value as? String, SentryMeta.sdkName) XCTAssertEqual(attributes["sentry.sdk.version"]?.value as? String, SentryMeta.versionString) XCTAssertNotNil(attributes["sentry.environment"]) } func testAddLog_SetsTraceIdFromPropagationContext() throws { + // -- Arrange -- let expectedTraceId = SentryId() let propagationContext = SentryPropagationContext(trace: expectedTraceId, spanId: SpanId()) scope.propagationContext = propagationContext - + let sut = getSut() let log = createTestLog(body: "Test log message with trace ID") + + // -- Act -- sut.addLog(log, scope: scope) sut.captureLogs() + // -- Assert -- let capturedLogs = testDelegate.getCapturedLogs() let capturedLog = try XCTUnwrap(capturedLogs.first) XCTAssertEqual(capturedLog.traceId, expectedTraceId) } func testAddLog_AddsUserAttributes() throws { + // -- Arrange -- let user = User() user.userId = "123" user.email = "test@test.com" user.name = "test-name" scope.setUser(user) - + let sut = getSut() let log = createTestLog(body: "Test log message with user") + + // -- Act -- sut.addLog(log, scope: scope) sut.captureLogs() + // -- Assert -- let capturedLogs = testDelegate.getCapturedLogs() let capturedLog = try XCTUnwrap(capturedLogs.first) let attributes = capturedLog.attributes @@ -396,15 +393,18 @@ final class SentryLogBatcherTests: XCTestCase { } func testAddLog_DoesNotAddNilUserAttributes() throws { + // -- Arrange -- let user = User() user.userId = "123" - // email and name are nil scope.setUser(user) - + let sut = getSut() let log = createTestLog(body: "Test log message with partial user") + + // -- Act -- sut.addLog(log, scope: scope) sut.captureLogs() + // -- Assert -- let capturedLogs = testDelegate.getCapturedLogs() let capturedLog = try XCTUnwrap(capturedLogs.first) let attributes = capturedLog.attributes @@ -415,13 +415,15 @@ final class SentryLogBatcherTests: XCTestCase { } func testAddLog_NoUserAtributesAreSetIfInstallationIdIsNotCached() throws { - // No user set on scope - // InstallationId not cached - + // -- Arrange -- + let sut = getSut() let log = createTestLog(body: "Test log message without user") + + // -- Act -- sut.addLog(log, scope: scope) sut.captureLogs() + // -- Assert -- let capturedLogs = testDelegate.getCapturedLogs() let capturedLog = try XCTUnwrap(capturedLogs.first) let attributes = capturedLog.attributes @@ -432,14 +434,16 @@ final class SentryLogBatcherTests: XCTestCase { } func testAddLog_OnlySetsUserIdToInstallationIdWhenNoUserIsSet() throws { - // No user set on scope - // Create and cache installationId + // -- Arrange -- _ = SentryInstallation.id(withCacheDirectoryPath: options.cacheDirectoryPath) - + let sut = getSut() let log = createTestLog(body: "Test log message without user") + + // -- Act -- sut.addLog(log, scope: scope) sut.captureLogs() + // -- Assert -- let capturedLogs = testDelegate.getCapturedLogs() let capturedLog = try XCTUnwrap(capturedLogs.first) let attributes = capturedLog.attributes @@ -451,16 +455,19 @@ final class SentryLogBatcherTests: XCTestCase { } func testAddLog_AddsOSAndDeviceAttributes() throws { + // -- Arrange -- let osContext = ["name": "iOS", "version": "16.0.1"] let deviceContext = ["family": "iOS", "model": "iPhone14,4"] - scope.setContext(value: osContext, key: "os") scope.setContext(value: deviceContext, key: "device") - + let sut = getSut() let log = createTestLog(body: "Test log message") + + // -- Act -- sut.addLog(log, scope: scope) sut.captureLogs() + // -- Assert -- let capturedLogs = testDelegate.getCapturedLogs() let capturedLog = try XCTUnwrap(capturedLogs.first) let attributes = capturedLog.attributes @@ -473,16 +480,19 @@ final class SentryLogBatcherTests: XCTestCase { } func testAddLog_HandlesPartialOSAndDeviceAttributes() throws { - let osContext = ["name": "macOS"] // Missing version - let deviceContext = ["family": "macOS"] // Missing model - + // -- Arrange -- + let osContext = ["name": "macOS"] + let deviceContext = ["family": "macOS"] scope.setContext(value: osContext, key: "os") scope.setContext(value: deviceContext, key: "device") - + let sut = getSut() let log = createTestLog(body: "Test log message") + + // -- Act -- sut.addLog(log, scope: scope) sut.captureLogs() + // -- Assert -- let capturedLogs = testDelegate.getCapturedLogs() let capturedLog = try XCTUnwrap(capturedLogs.first) let attributes = capturedLog.attributes @@ -495,14 +505,17 @@ final class SentryLogBatcherTests: XCTestCase { } func testAddLog_HandlesMissingOSAndDeviceContext() throws { - // Clear any OS and device context + // -- Arrange -- scope.removeContext(key: "os") scope.removeContext(key: "device") - + let sut = getSut() let log = createTestLog(body: "Test log message") + + // -- Act -- sut.addLog(log, scope: scope) sut.captureLogs() + // -- Assert -- let capturedLogs = testDelegate.getCapturedLogs() let capturedLog = try XCTUnwrap(capturedLogs.first) let attributes = capturedLog.attributes @@ -515,16 +528,20 @@ final class SentryLogBatcherTests: XCTestCase { } func testAddLog_AddsScopeAttributes() throws { + // -- Arrange -- let scope = Scope() scope.setAttribute(value: "aString", key: "string-attribute") scope.setAttribute(value: false, key: "bool-attribute") scope.setAttribute(value: 1.765, key: "double-attribute") scope.setAttribute(value: 5, key: "integer-attribute") - + let sut = getSut() let log = createTestLog(body: "Test log message with user") + + // -- Act -- sut.addLog(log, scope: scope) sut.captureLogs() + // -- Assert -- let capturedLogs = testDelegate.getCapturedLogs() let capturedLog = try XCTUnwrap(capturedLogs.first) let attributes = capturedLog.attributes @@ -540,13 +557,17 @@ final class SentryLogBatcherTests: XCTestCase { } func testAddLog_ScopeAttributesDoNotOverrideLogAttribute() throws { + // -- Arrange -- let scope = Scope() scope.setAttribute(value: true, key: "log-attribute") - + let sut = getSut() let log = createTestLog(body: "Test log message with user", attributes: [ "log-attribute": .init(value: false)]) + + // -- Act -- sut.addLog(log, scope: scope) sut.captureLogs() + // -- Assert -- let capturedLogs = testDelegate.getCapturedLogs() let capturedLog = try XCTUnwrap(capturedLogs.first) let attributes = capturedLog.attributes @@ -560,14 +581,17 @@ final class SentryLogBatcherTests: XCTestCase { #if canImport(UIKit) && !SENTRY_NO_UIKIT #if os(iOS) || os(tvOS) func testAddLog_ReplayAttributes_SessionMode_AddsReplayId() throws { - // Set replayId on scope (session mode) + // -- Arrange -- let replayId = "12345678-1234-1234-1234-123456789012" scope.replayId = replayId - + let sut = getSut() let log = createTestLog(body: "Test message") + + // -- Act -- sut.addLog(log, scope: scope) sut.captureLogs() + // -- Assert -- let capturedLogs = testDelegate.getCapturedLogs() let capturedLog = try XCTUnwrap(capturedLogs.first) XCTAssertEqual(capturedLog.attributes["sentry.replay_id"]?.value as? String, replayId) @@ -575,13 +599,16 @@ final class SentryLogBatcherTests: XCTestCase { } func testAddLog_ReplayAttributes_NoReplayId_NoAttributesAdded() throws { - // Don't set replayId on scope + // -- Arrange -- scope.replayId = nil - + let sut = getSut() let log = createTestLog(body: "Test message") + + // -- Act -- sut.addLog(log, scope: scope) sut.captureLogs() + // -- Assert -- let capturedLogs = testDelegate.getCapturedLogs() let capturedLog = try XCTUnwrap(capturedLogs.first) XCTAssertNil(capturedLog.attributes["sentry.replay_id"]) @@ -593,6 +620,7 @@ final class SentryLogBatcherTests: XCTestCase { // MARK: - BeforeSendLog Callback Tests func testBeforeSendLog_ReturnsModifiedLog() throws { + // -- Arrange -- var beforeSendCalled = false options.beforeSendLog = { log in beforeSendCalled = true @@ -606,11 +634,14 @@ final class SentryLogBatcherTests: XCTestCase { return log } - + let sut = getSut() let log = createTestLog(level: .info, body: "Original message") + + // -- Act -- sut.addLog(log, scope: scope) sut.captureLogs() + // -- Assert -- XCTAssertTrue(beforeSendCalled) let capturedLogs = testDelegate.getCapturedLogs() @@ -621,27 +652,35 @@ final class SentryLogBatcherTests: XCTestCase { } func testBeforeSendLog_ReturnsNil_LogNotCaptured() { + // -- Arrange -- var beforeSendCalled = false options.beforeSendLog = { _ in beforeSendCalled = true return nil // Drop the log } - + let sut = getSut() let log = createTestLog(body: "This log should be dropped") + + // -- Act -- sut.addLog(log, scope: scope) sut.captureLogs() + // -- Assert -- XCTAssertTrue(beforeSendCalled) XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 0) } func testBeforeSendLog_NotSet_LogCapturedUnmodified() throws { + // -- Arrange -- options.beforeSendLog = nil - + let sut = getSut() let log = createTestLog(level: .debug, body: "Debug message") + + // -- Act -- sut.addLog(log, scope: scope) sut.captureLogs() + // -- Assert -- let capturedLogs = testDelegate.getCapturedLogs() XCTAssertEqual(capturedLogs.count, 1) @@ -651,20 +690,24 @@ final class SentryLogBatcherTests: XCTestCase { } func testBeforeSendLog_PreservesOriginalLogAttributes() throws { + // -- Arrange -- options.beforeSendLog = { log in log.attributes["added_by_callback"] = SentryLog.Attribute(string: "callback_value") return log } + let sut = getSut() let logAttributes: [String: SentryLog.Attribute] = [ "original_key": SentryLog.Attribute(string: "original_value"), "user_id": SentryLog.Attribute(integer: 12_345) ] - let log = createTestLog(body: "Test message", attributes: logAttributes) + + // -- Act -- sut.addLog(log, scope: scope) sut.captureLogs() + // -- Assert -- let capturedLogs = testDelegate.getCapturedLogs() let capturedLog = try XCTUnwrap(capturedLogs.first) let attributes = capturedLog.attributes @@ -675,15 +718,16 @@ final class SentryLogBatcherTests: XCTestCase { } func testAddLog_WithLogsDisabled_DoesNotCaptureLog() { - // Arrange + // -- Arrange -- options.enableLogs = false + let sut = getSut() let log = createTestLog(body: "This log should be ignored") - // Act + // -- Act -- sut.addLog(log, scope: scope) sut.captureLogs() - // Assert + // -- Assert -- XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 0) let capturedLogs = testDelegate.getCapturedLogs() XCTAssertEqual(capturedLogs.count, 0) diff --git a/sdk_api.json b/sdk_api.json index 3de45287fa..cd0b7a2b29 100644 --- a/sdk_api.json +++ b/sdk_api.json @@ -18955,6 +18955,22 @@ } ] }, + { + "kind": "TypeAlias", + "name": "SentryLogAttribute", + "printedName": "SentryLogAttribute", + "children": [ + { + "kind": "TypeNominal", + "name": "SentryAttribute", + "printedName": "Sentry.SentryAttribute", + "usr": "c:@M@Sentry@objc(cs)SentryAttribute" + } + ], + "declKind": "TypeAlias", + "usr": "c:@SentryLogAttribute", + "moduleName": "Sentry" + }, { "kind": "TypeDecl", "name": "SentryMessage", @@ -33390,6 +33406,23 @@ "name": "SentryLog", "printedName": "SentryLog", "children": [ + { + "kind": "TypeAlias", + "name": "Attribute", + "printedName": "Attribute", + "children": [ + { + "kind": "TypeNominal", + "name": "SentryAttribute", + "printedName": "Sentry.SentryAttribute", + "usr": "c:@M@Sentry@objc(cs)SentryAttribute" + } + ], + "declKind": "TypeAlias", + "usr": "s:6Sentry0A3LogC9Attributea", + "mangledName": "$s6Sentry0A3LogC9Attributea", + "moduleName": "Sentry" + }, { "kind": "Var", "name": "timestamp", @@ -33711,10 +33744,17 @@ "usr": "s:SS" }, { - "kind": "TypeNominal", + "kind": "TypeNameAlias", "name": "Attribute", "printedName": "Sentry.SentryLog.Attribute", - "usr": "s:6Sentry0A3LogC9AttributeC" + "children": [ + { + "kind": "TypeNominal", + "name": "SentryAttribute", + "printedName": "Sentry.SentryAttribute", + "usr": "c:@M@Sentry@objc(cs)SentryAttribute" + } + ] } ], "usr": "s:SD" @@ -33722,7 +33762,7 @@ ], "declKind": "Var", "usr": "c:@M@Sentry@objc(cs)SentryLog(py)attributes", - "mangledName": "$s6Sentry0A3LogC10attributesSDySSAC9AttributeCGvp", + "mangledName": "$s6Sentry0A3LogC10attributesSDySSAA0A9AttributeCGvp", "moduleName": "Sentry", "declAttributes": [ "Final", @@ -33748,10 +33788,17 @@ "usr": "s:SS" }, { - "kind": "TypeNominal", + "kind": "TypeNameAlias", "name": "Attribute", "printedName": "Sentry.SentryLog.Attribute", - "usr": "s:6Sentry0A3LogC9AttributeC" + "children": [ + { + "kind": "TypeNominal", + "name": "SentryAttribute", + "printedName": "Sentry.SentryAttribute", + "usr": "c:@M@Sentry@objc(cs)SentryAttribute" + } + ] } ], "usr": "s:SD" @@ -33759,7 +33806,7 @@ ], "declKind": "Accessor", "usr": "c:@M@Sentry@objc(cs)SentryLog(im)attributes", - "mangledName": "$s6Sentry0A3LogC10attributesSDySSAC9AttributeCGvg", + "mangledName": "$s6Sentry0A3LogC10attributesSDySSAA0A9AttributeCGvg", "moduleName": "Sentry", "implicit": true, "declAttributes": [ @@ -33790,10 +33837,17 @@ "usr": "s:SS" }, { - "kind": "TypeNominal", + "kind": "TypeNameAlias", "name": "Attribute", "printedName": "Sentry.SentryLog.Attribute", - "usr": "s:6Sentry0A3LogC9AttributeC" + "children": [ + { + "kind": "TypeNominal", + "name": "SentryAttribute", + "printedName": "Sentry.SentryAttribute", + "usr": "c:@M@Sentry@objc(cs)SentryAttribute" + } + ] } ], "usr": "s:SD" @@ -33801,7 +33855,7 @@ ], "declKind": "Accessor", "usr": "c:@M@Sentry@objc(cs)SentryLog(im)setAttributes:", - "mangledName": "$s6Sentry0A3LogC10attributesSDySSAC9AttributeCGvs", + "mangledName": "$s6Sentry0A3LogC10attributesSDySSAA0A9AttributeCGvs", "moduleName": "Sentry", "implicit": true, "declAttributes": [ @@ -33982,10 +34036,17 @@ "usr": "s:SS" }, { - "kind": "TypeNominal", + "kind": "TypeNameAlias", "name": "Attribute", "printedName": "Sentry.SentryLog.Attribute", - "usr": "s:6Sentry0A3LogC9AttributeC" + "children": [ + { + "kind": "TypeNominal", + "name": "SentryAttribute", + "printedName": "Sentry.SentryAttribute", + "usr": "c:@M@Sentry@objc(cs)SentryAttribute" + } + ] } ], "usr": "s:SD" @@ -33993,7 +34054,7 @@ ], "declKind": "Constructor", "usr": "c:@M@Sentry@objc(cs)SentryLog(im)initWithLevel:body:attributes:", - "mangledName": "$s6Sentry0A3LogC5level4body10attributesA2C5LevelO_SSSDySSAC9AttributeCGtcfc", + "mangledName": "$s6Sentry0A3LogC5level4body10attributesA2C5LevelO_SSSDySSAA0A9AttributeCGtcfc", "moduleName": "Sentry", "objc_name": "initWithLevel:body:attributes:", "declAttributes": [ @@ -34017,10 +34078,17 @@ "printedName": "Sentry.SentryLog.Attribute?", "children": [ { - "kind": "TypeNominal", + "kind": "TypeNameAlias", "name": "Attribute", "printedName": "Sentry.SentryLog.Attribute", - "usr": "s:6Sentry0A3LogC9AttributeC" + "children": [ + { + "kind": "TypeNominal", + "name": "SentryAttribute", + "printedName": "Sentry.SentryAttribute", + "usr": "c:@M@Sentry@objc(cs)SentryAttribute" + } + ] } ], "usr": "s:Sq" @@ -34034,7 +34102,7 @@ ], "declKind": "Func", "usr": "c:@M@Sentry@objc(cs)SentryLog(im)setAttribute:forKey:", - "mangledName": "$s6Sentry0A3LogC12setAttribute_6forKeyyAC0D0CSg_SStF", + "mangledName": "$s6Sentry0A3LogC12setAttribute_6forKeyyAA0aD0CSg_SStF", "moduleName": "Sentry", "declAttributes": [ "Final", @@ -34523,322 +34591,6 @@ "mangledName": "$sSY" } ] - }, - { - "kind": "TypeDecl", - "name": "Attribute", - "printedName": "Attribute", - "children": [ - { - "kind": "Var", - "name": "type", - "printedName": "type", - "children": [ - { - "kind": "TypeNominal", - "name": "String", - "printedName": "Swift.String", - "usr": "s:SS" - } - ], - "declKind": "Var", - "usr": "s:6Sentry0A3LogC9AttributeC4typeSSvp", - "mangledName": "$s6Sentry0A3LogC9AttributeC4typeSSvp", - "moduleName": "Sentry", - "declAttributes": [ - "Final", - "ObjC", - "HasStorage" - ], - "isLet": true, - "hasStorage": true, - "accessors": [ - { - "kind": "Accessor", - "name": "Get", - "printedName": "Get()", - "children": [ - { - "kind": "TypeNominal", - "name": "String", - "printedName": "Swift.String", - "usr": "s:SS" - } - ], - "declKind": "Accessor", - "usr": "s:6Sentry0A3LogC9AttributeC4typeSSvg", - "mangledName": "$s6Sentry0A3LogC9AttributeC4typeSSvg", - "moduleName": "Sentry", - "implicit": true, - "declAttributes": [ - "Final", - "ObjC" - ], - "accessorKind": "get" - } - ] - }, - { - "kind": "Var", - "name": "value", - "printedName": "value", - "children": [ - { - "kind": "TypeNominal", - "name": "ProtocolComposition", - "printedName": "Any" - } - ], - "declKind": "Var", - "usr": "s:6Sentry0A3LogC9AttributeC5valueypvp", - "mangledName": "$s6Sentry0A3LogC9AttributeC5valueypvp", - "moduleName": "Sentry", - "declAttributes": [ - "Final", - "ObjC", - "HasStorage" - ], - "isLet": true, - "hasStorage": true, - "accessors": [ - { - "kind": "Accessor", - "name": "Get", - "printedName": "Get()", - "children": [ - { - "kind": "TypeNominal", - "name": "ProtocolComposition", - "printedName": "Any" - } - ], - "declKind": "Accessor", - "usr": "s:6Sentry0A3LogC9AttributeC5valueypvg", - "mangledName": "$s6Sentry0A3LogC9AttributeC5valueypvg", - "moduleName": "Sentry", - "implicit": true, - "declAttributes": [ - "Final", - "ObjC" - ], - "accessorKind": "get" - } - ] - }, - { - "kind": "Constructor", - "name": "init", - "printedName": "init(string:)", - "children": [ - { - "kind": "TypeNominal", - "name": "Attribute", - "printedName": "Sentry.SentryLog.Attribute", - "usr": "s:6Sentry0A3LogC9AttributeC" - }, - { - "kind": "TypeNominal", - "name": "String", - "printedName": "Swift.String", - "usr": "s:SS" - } - ], - "declKind": "Constructor", - "usr": "s:6Sentry0A3LogC9AttributeC6stringAESS_tcfc", - "mangledName": "$s6Sentry0A3LogC9AttributeC6stringAESS_tcfc", - "moduleName": "Sentry", - "objc_name": "initWithString:", - "declAttributes": [ - "ObjC" - ], - "init_kind": "Designated" - }, - { - "kind": "Constructor", - "name": "init", - "printedName": "init(boolean:)", - "children": [ - { - "kind": "TypeNominal", - "name": "Attribute", - "printedName": "Sentry.SentryLog.Attribute", - "usr": "s:6Sentry0A3LogC9AttributeC" - }, - { - "kind": "TypeNominal", - "name": "Bool", - "printedName": "Swift.Bool", - "usr": "s:Sb" - } - ], - "declKind": "Constructor", - "usr": "s:6Sentry0A3LogC9AttributeC7booleanAESb_tcfc", - "mangledName": "$s6Sentry0A3LogC9AttributeC7booleanAESb_tcfc", - "moduleName": "Sentry", - "objc_name": "initWithBoolean:", - "declAttributes": [ - "ObjC" - ], - "init_kind": "Designated" - }, - { - "kind": "Constructor", - "name": "init", - "printedName": "init(integer:)", - "children": [ - { - "kind": "TypeNominal", - "name": "Attribute", - "printedName": "Sentry.SentryLog.Attribute", - "usr": "s:6Sentry0A3LogC9AttributeC" - }, - { - "kind": "TypeNominal", - "name": "Int", - "printedName": "Swift.Int", - "usr": "s:Si" - } - ], - "declKind": "Constructor", - "usr": "s:6Sentry0A3LogC9AttributeC7integerAESi_tcfc", - "mangledName": "$s6Sentry0A3LogC9AttributeC7integerAESi_tcfc", - "moduleName": "Sentry", - "objc_name": "initWithInteger:", - "declAttributes": [ - "ObjC" - ], - "init_kind": "Designated" - }, - { - "kind": "Constructor", - "name": "init", - "printedName": "init(double:)", - "children": [ - { - "kind": "TypeNominal", - "name": "Attribute", - "printedName": "Sentry.SentryLog.Attribute", - "usr": "s:6Sentry0A3LogC9AttributeC" - }, - { - "kind": "TypeNominal", - "name": "Double", - "printedName": "Swift.Double", - "usr": "s:Sd" - } - ], - "declKind": "Constructor", - "usr": "s:6Sentry0A3LogC9AttributeC6doubleAESd_tcfc", - "mangledName": "$s6Sentry0A3LogC9AttributeC6doubleAESd_tcfc", - "moduleName": "Sentry", - "objc_name": "initWithDouble:", - "declAttributes": [ - "ObjC" - ], - "init_kind": "Designated" - }, - { - "kind": "Constructor", - "name": "init", - "printedName": "init(float:)", - "children": [ - { - "kind": "TypeNominal", - "name": "Attribute", - "printedName": "Sentry.SentryLog.Attribute", - "usr": "s:6Sentry0A3LogC9AttributeC" - }, - { - "kind": "TypeNominal", - "name": "Float", - "printedName": "Swift.Float", - "usr": "s:Sf" - } - ], - "declKind": "Constructor", - "usr": "s:6Sentry0A3LogC9AttributeC5floatAESf_tcfc", - "mangledName": "$s6Sentry0A3LogC9AttributeC5floatAESf_tcfc", - "moduleName": "Sentry", - "objc_name": "initWithFloat:", - "declAttributes": [ - "ObjC" - ], - "init_kind": "Designated" - } - ], - "declKind": "Class", - "usr": "s:6Sentry0A3LogC9AttributeC", - "mangledName": "$s6Sentry0A3LogC9AttributeC", - "moduleName": "Sentry", - "objc_name": "SentryLogAttribute", - "declAttributes": [ - "Final", - "ObjCMembers", - "ObjC" - ], - "isFromExtension": true, - "superclassUsr": "c:objc(cs)NSObject", - "hasMissingDesignatedInitializers": true, - "superclassNames": [ - "ObjectiveC.NSObject" - ], - "conformances": [ - { - "kind": "Conformance", - "name": "Copyable", - "printedName": "Copyable", - "usr": "s:s8CopyableP", - "mangledName": "$ss8CopyableP" - }, - { - "kind": "Conformance", - "name": "Escapable", - "printedName": "Escapable", - "usr": "s:s9EscapableP", - "mangledName": "$ss9EscapableP" - }, - { - "kind": "Conformance", - "name": "NSObjectProtocol", - "printedName": "NSObjectProtocol", - "usr": "c:objc(pl)NSObject" - }, - { - "kind": "Conformance", - "name": "Equatable", - "printedName": "Equatable", - "usr": "s:SQ", - "mangledName": "$sSQ" - }, - { - "kind": "Conformance", - "name": "Hashable", - "printedName": "Hashable", - "usr": "s:SH", - "mangledName": "$sSH" - }, - { - "kind": "Conformance", - "name": "CVarArg", - "printedName": "CVarArg", - "usr": "s:s7CVarArgP", - "mangledName": "$ss7CVarArgP" - }, - { - "kind": "Conformance", - "name": "CustomStringConvertible", - "printedName": "CustomStringConvertible", - "usr": "s:s23CustomStringConvertibleP", - "mangledName": "$ss23CustomStringConvertibleP" - }, - { - "kind": "Conformance", - "name": "CustomDebugStringConvertible", - "printedName": "CustomDebugStringConvertible", - "usr": "s:s28CustomDebugStringConvertibleP", - "mangledName": "$ss28CustomDebugStringConvertibleP" - } - ] } ], "declKind": "Class", @@ -54353,6 +54105,320 @@ } ] }, + { + "kind": "TypeDecl", + "name": "SentryAttribute", + "printedName": "SentryAttribute", + "children": [ + { + "kind": "Var", + "name": "type", + "printedName": "type", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "declKind": "Var", + "usr": "c:@M@Sentry@objc(cs)SentryAttribute(py)type", + "mangledName": "$s6Sentry0A9AttributeC4typeSSvp", + "moduleName": "Sentry", + "declAttributes": [ + "Final", + "ObjC", + "HasStorage" + ], + "isLet": true, + "hasStorage": true, + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "declKind": "Accessor", + "usr": "c:@M@Sentry@objc(cs)SentryAttribute(im)type", + "mangledName": "$s6Sentry0A9AttributeC4typeSSvg", + "moduleName": "Sentry", + "implicit": true, + "declAttributes": [ + "Final", + "ObjC" + ], + "accessorKind": "get" + } + ] + }, + { + "kind": "Var", + "name": "value", + "printedName": "value", + "children": [ + { + "kind": "TypeNominal", + "name": "ProtocolComposition", + "printedName": "Any" + } + ], + "declKind": "Var", + "usr": "c:@M@Sentry@objc(cs)SentryAttribute(py)value", + "mangledName": "$s6Sentry0A9AttributeC5valueypvp", + "moduleName": "Sentry", + "declAttributes": [ + "Final", + "ObjC", + "HasStorage" + ], + "isLet": true, + "hasStorage": true, + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "ProtocolComposition", + "printedName": "Any" + } + ], + "declKind": "Accessor", + "usr": "c:@M@Sentry@objc(cs)SentryAttribute(im)value", + "mangledName": "$s6Sentry0A9AttributeC5valueypvg", + "moduleName": "Sentry", + "implicit": true, + "declAttributes": [ + "Final", + "ObjC" + ], + "accessorKind": "get" + } + ] + }, + { + "kind": "Constructor", + "name": "init", + "printedName": "init(string:)", + "children": [ + { + "kind": "TypeNominal", + "name": "SentryAttribute", + "printedName": "Sentry.SentryAttribute", + "usr": "c:@M@Sentry@objc(cs)SentryAttribute" + }, + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "declKind": "Constructor", + "usr": "c:@M@Sentry@objc(cs)SentryAttribute(im)initWithString:", + "mangledName": "$s6Sentry0A9AttributeC6stringACSS_tcfc", + "moduleName": "Sentry", + "objc_name": "initWithString:", + "declAttributes": [ + "ObjC" + ], + "init_kind": "Designated" + }, + { + "kind": "Constructor", + "name": "init", + "printedName": "init(boolean:)", + "children": [ + { + "kind": "TypeNominal", + "name": "SentryAttribute", + "printedName": "Sentry.SentryAttribute", + "usr": "c:@M@Sentry@objc(cs)SentryAttribute" + }, + { + "kind": "TypeNominal", + "name": "Bool", + "printedName": "Swift.Bool", + "usr": "s:Sb" + } + ], + "declKind": "Constructor", + "usr": "c:@M@Sentry@objc(cs)SentryAttribute(im)initWithBoolean:", + "mangledName": "$s6Sentry0A9AttributeC7booleanACSb_tcfc", + "moduleName": "Sentry", + "objc_name": "initWithBoolean:", + "declAttributes": [ + "ObjC" + ], + "init_kind": "Designated" + }, + { + "kind": "Constructor", + "name": "init", + "printedName": "init(integer:)", + "children": [ + { + "kind": "TypeNominal", + "name": "SentryAttribute", + "printedName": "Sentry.SentryAttribute", + "usr": "c:@M@Sentry@objc(cs)SentryAttribute" + }, + { + "kind": "TypeNominal", + "name": "Int", + "printedName": "Swift.Int", + "usr": "s:Si" + } + ], + "declKind": "Constructor", + "usr": "c:@M@Sentry@objc(cs)SentryAttribute(im)initWithInteger:", + "mangledName": "$s6Sentry0A9AttributeC7integerACSi_tcfc", + "moduleName": "Sentry", + "objc_name": "initWithInteger:", + "declAttributes": [ + "ObjC" + ], + "init_kind": "Designated" + }, + { + "kind": "Constructor", + "name": "init", + "printedName": "init(double:)", + "children": [ + { + "kind": "TypeNominal", + "name": "SentryAttribute", + "printedName": "Sentry.SentryAttribute", + "usr": "c:@M@Sentry@objc(cs)SentryAttribute" + }, + { + "kind": "TypeNominal", + "name": "Double", + "printedName": "Swift.Double", + "usr": "s:Sd" + } + ], + "declKind": "Constructor", + "usr": "c:@M@Sentry@objc(cs)SentryAttribute(im)initWithDouble:", + "mangledName": "$s6Sentry0A9AttributeC6doubleACSd_tcfc", + "moduleName": "Sentry", + "objc_name": "initWithDouble:", + "declAttributes": [ + "ObjC" + ], + "init_kind": "Designated" + }, + { + "kind": "Constructor", + "name": "init", + "printedName": "init(float:)", + "children": [ + { + "kind": "TypeNominal", + "name": "SentryAttribute", + "printedName": "Sentry.SentryAttribute", + "usr": "c:@M@Sentry@objc(cs)SentryAttribute" + }, + { + "kind": "TypeNominal", + "name": "Float", + "printedName": "Swift.Float", + "usr": "s:Sf" + } + ], + "declKind": "Constructor", + "usr": "c:@M@Sentry@objc(cs)SentryAttribute(im)initWithFloat:", + "mangledName": "$s6Sentry0A9AttributeC5floatACSf_tcfc", + "moduleName": "Sentry", + "objc_name": "initWithFloat:", + "declAttributes": [ + "ObjC" + ], + "init_kind": "Designated" + } + ], + "declKind": "Class", + "usr": "c:@M@Sentry@objc(cs)SentryAttribute", + "mangledName": "$s6Sentry0A9AttributeC", + "moduleName": "Sentry", + "declAttributes": [ + "Final", + "ObjCMembers", + "ObjC" + ], + "superclassUsr": "c:objc(cs)NSObject", + "hasMissingDesignatedInitializers": true, + "superclassNames": [ + "ObjectiveC.NSObject" + ], + "conformances": [ + { + "kind": "Conformance", + "name": "Copyable", + "printedName": "Copyable", + "usr": "s:s8CopyableP", + "mangledName": "$ss8CopyableP" + }, + { + "kind": "Conformance", + "name": "Escapable", + "printedName": "Escapable", + "usr": "s:s9EscapableP", + "mangledName": "$ss9EscapableP" + }, + { + "kind": "Conformance", + "name": "NSObjectProtocol", + "printedName": "NSObjectProtocol", + "usr": "c:objc(pl)NSObject" + }, + { + "kind": "Conformance", + "name": "Equatable", + "printedName": "Equatable", + "usr": "s:SQ", + "mangledName": "$sSQ" + }, + { + "kind": "Conformance", + "name": "Hashable", + "printedName": "Hashable", + "usr": "s:SH", + "mangledName": "$sSH" + }, + { + "kind": "Conformance", + "name": "CVarArg", + "printedName": "CVarArg", + "usr": "s:s7CVarArgP", + "mangledName": "$ss7CVarArgP" + }, + { + "kind": "Conformance", + "name": "CustomStringConvertible", + "printedName": "CustomStringConvertible", + "usr": "s:s23CustomStringConvertibleP", + "mangledName": "$ss23CustomStringConvertibleP" + }, + { + "kind": "Conformance", + "name": "CustomDebugStringConvertible", + "printedName": "CustomDebugStringConvertible", + "usr": "s:s28CustomDebugStringConvertibleP", + "mangledName": "$ss28CustomDebugStringConvertibleP" + } + ] + }, { "kind": "TypeDecl", "name": "SentryUserFeedbackFormConfiguration",