diff --git a/CHANGELOG.md b/CHANGELOG.md index e68aa48340..2cfbdf822f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,8 @@ ### Features -- Add integration to collect Metrics, can be enabled by setting `options.enableMetrics = true` (#6956) +- Add integration to collect Metrics, can be enabled by setting `options.experimental.enableMetrics = true` (#6956) +- Add implementation for Metrics Protocol with modification of items in `options.experimental.beforeSendMetrics` (#6960) ### Fixes diff --git a/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift b/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift index 257706bbe5..e6e40ea941 100644 --- a/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift +++ b/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift @@ -161,6 +161,19 @@ public struct SentrySDKWrapper { // Integration: Metrics options.experimental.enableMetrics = SentrySDKOverrides.Metrics.enable.boolValue + options.experimental.beforeSendMetric = { metric in + // Modify the metric in the callback + var modifiedMetric = metric + + // Modify the value of the metric + if case .counter(let value) = modifiedMetric.value, modifiedMetric.name == "test.metric" { + modifiedMetric.value = .counter(value + 100) + } + + // Add a custom attribute to the metric + modifiedMetric.attributes["custom-attribute"] = .init(string: "some-value") + return modifiedMetric + } // Experimental features options.enableFileManagerSwizzling = !SentrySDKOverrides.Other.disableFileManagerSwizzling.boolValue diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index 05c0e88be7..390e0e37ff 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -779,7 +779,11 @@ D468C0622D3669A200964230 /* SentryFileIOTracker+SwiftHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D468C0612D3669A200964230 /* SentryFileIOTracker+SwiftHelpers.swift */; }; D46B041D2EDF168400AF4A0A /* SentryMetricsIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D46B041C2EDF167D00AF4A0A /* SentryMetricsIntegration.swift */; }; D46B04202EDF175C00AF4A0A /* SentryMetricsIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D46B041F2EDF175600AF4A0A /* SentryMetricsIntegrationTests.swift */; }; + D46B04482EDF25E100AF4A0A /* SentryMetric.swift in Sources */ = {isa = PBXBuildFile; fileRef = D46B04472EDF25E100AF4A0A /* SentryMetric.swift */; }; + D46B044F2EDF260A00AF4A0A /* SentryMetricsBatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D46B044E2EDF260A00AF4A0A /* SentryMetricsBatcher.swift */; }; D473ACD72D8090FC000F1CC6 /* FileManager+SentryTracing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D473ACD62D8090FC000F1CC6 /* FileManager+SentryTracing.swift */; }; + D4749EF02EF042C1000F9AE7 /* SentryMetricTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4749EEF2EF042C1000F9AE7 /* SentryMetricTests.swift */; }; + D4749F482EF062FD000F9AE7 /* SentryMetricsBatcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4749F472EF062FD000F9AE7 /* SentryMetricsBatcherTests.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 */; }; @@ -789,6 +793,9 @@ D48891CC2E98F22A00212823 /* SentryInfoPlistWrapperProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48891C62E98F21D00212823 /* SentryInfoPlistWrapperProvider.swift */; }; D48891CE2E98F28E00212823 /* SentryInfoPlistWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48891CD2E98F28E00212823 /* SentryInfoPlistWrapper.swift */; }; D48891D02E98F2E700212823 /* SentryInfoPlistError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48891CF2E98F2E600212823 /* SentryInfoPlistError.swift */; }; + D48953FE2EF5756A0086F240 /* SentryMetricValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48953FD2EF5756A0086F240 /* SentryMetricValue.swift */; }; + D48954052EF575960086F240 /* SentryMetricValueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48954042EF575960086F240 /* SentryMetricValueTests.swift */; }; + D48954072EF579360086F240 /* SentryEnvelopeItemTypesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48954062EF579320086F240 /* SentryEnvelopeItemTypesTests.swift */; }; D48E8B8B2D3E79610032E35E /* SentryTraceOrigin.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48E8B8A2D3E79610032E35E /* SentryTraceOrigin.swift */; }; D48E8B9D2D3E82AC0032E35E /* SentrySpanOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48E8B9C2D3E82AC0032E35E /* SentrySpanOperation.swift */; }; D490648A2DFAE1F600555785 /* SentryScreenshotOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D49064892DFAE1F600555785 /* SentryScreenshotOptions.swift */; }; @@ -2151,9 +2158,13 @@ D468C0612D3669A200964230 /* SentryFileIOTracker+SwiftHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SentryFileIOTracker+SwiftHelpers.swift"; sourceTree = ""; }; D46B041C2EDF167D00AF4A0A /* SentryMetricsIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryMetricsIntegration.swift; sourceTree = ""; }; D46B041F2EDF175600AF4A0A /* SentryMetricsIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryMetricsIntegrationTests.swift; sourceTree = ""; }; + D46B04472EDF25E100AF4A0A /* SentryMetric.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryMetric.swift; sourceTree = ""; }; + D46B044E2EDF260A00AF4A0A /* SentryMetricsBatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryMetricsBatcher.swift; sourceTree = ""; }; D46D45E12D5F3FD600A1CB35 /* Sentry_Base.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = Sentry_Base.xctestplan; sourceTree = ""; }; D46D45E92D5F411700A1CB35 /* SentrySwiftUI_Base.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = SentrySwiftUI_Base.xctestplan; path = Plans/SentrySwiftUI_Base.xctestplan; sourceTree = SOURCE_ROOT; }; D473ACD62D8090FC000F1CC6 /* FileManager+SentryTracing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+SentryTracing.swift"; sourceTree = ""; }; + D4749EEF2EF042C1000F9AE7 /* SentryMetricTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryMetricTests.swift; sourceTree = ""; }; + D4749F472EF062FD000F9AE7 /* SentryMetricsBatcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryMetricsBatcherTests.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 = ""; }; @@ -2163,6 +2174,9 @@ D48891C62E98F21D00212823 /* SentryInfoPlistWrapperProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryInfoPlistWrapperProvider.swift; sourceTree = ""; }; D48891CD2E98F28E00212823 /* SentryInfoPlistWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryInfoPlistWrapper.swift; sourceTree = ""; }; D48891CF2E98F2E600212823 /* SentryInfoPlistError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryInfoPlistError.swift; sourceTree = ""; }; + D48953FD2EF5756A0086F240 /* SentryMetricValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryMetricValue.swift; sourceTree = ""; }; + D48954042EF575960086F240 /* SentryMetricValueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryMetricValueTests.swift; sourceTree = ""; }; + D48954062EF579320086F240 /* SentryEnvelopeItemTypesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryEnvelopeItemTypesTests.swift; sourceTree = ""; }; D48E8B8A2D3E79610032E35E /* SentryTraceOrigin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryTraceOrigin.swift; sourceTree = ""; }; D48E8B9C2D3E82AC0032E35E /* SentrySpanOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentrySpanOperation.swift; sourceTree = ""; }; D49064892DFAE1F600555785 /* SentryScreenshotOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryScreenshotOptions.swift; sourceTree = ""; }; @@ -3450,6 +3464,8 @@ 7BFC16AC2524BCE700FF6266 /* SentryMessageTests.swift */, 7BFC16B82524D4AF00FF6266 /* SentryMessage+Equality.h */, 7BFC16B92524D4AF00FF6266 /* SentryMessage+Equality.m */, + D4749EEF2EF042C1000F9AE7 /* SentryMetricTests.swift */, + D48954042EF575960086F240 /* SentryMetricValueTests.swift */, 7BC6EC03255C235F0059822A /* SentryFrameTests.swift */, 7BC6EC07255C36DE0059822A /* SentryStacktraceTests.swift */, 7BC6EC0B255C3DF80059822A /* SentryThreadTests.swift */, @@ -3623,6 +3639,7 @@ 62F05D2A2C0DB1F100916E3F /* SentryLogTestHelper.m */, 7BBD18BA24530D2600427C76 /* SentryFileManagerTests.swift */, FA21A2E92E60E9C700E7EADB /* EnvelopeComparison.swift */, + D48954062EF579320086F240 /* SentryEnvelopeItemTypesTests.swift */, 7BD4E8E727FD95900086C410 /* SentryMigrateSessionInitTests.m */, 631501BA1EE6F30B00512C5B /* SentrySwizzleTests.m */, 7B34721628086A9D0041F047 /* SentrySwizzleWrapperTests.swift */, @@ -4402,6 +4419,7 @@ D46B04162EDF167800AF4A0A /* Metrics */ = { isa = PBXGroup; children = ( + D46B044E2EDF260A00AF4A0A /* SentryMetricsBatcher.swift */, D46B041C2EDF167D00AF4A0A /* SentryMetricsIntegration.swift */, ); path = Metrics; @@ -4410,6 +4428,7 @@ D46B041E2EDF173A00AF4A0A /* Metrics */ = { isa = PBXGroup; children = ( + D4749F472EF062FD000F9AE7 /* SentryMetricsBatcherTests.swift */, D46B041F2EDF175600AF4A0A /* SentryMetricsIntegrationTests.swift */, ); path = Metrics; @@ -4686,12 +4705,12 @@ isa = PBXGroup; children = ( D4D7AA6D2EEAD89300E28DFB /* Batcher */, - F451FAA52E0B304E0050ACF2 /* LoadValidator.swift */, FA94E68B2E6B92BE00576666 /* SentryClientReport.swift */, - FA01BCB12E69352A00968DFA /* SentryDiscardedEvent.swift */, FAE57C072E83092A00B710F9 /* SentryDispatchFactory.swift */, FA94E6B12E6D265500576666 /* SentryEnvelope.swift */, FA3AEE772E68E2830092283E /* SentryEnvelopeHeader.swift */, + FA01BCB12E69352A00968DFA /* SentryDiscardedEvent.swift */, + F451FAA52E0B304E0050ACF2 /* LoadValidator.swift */, FA34C1A22E692A5000BC52AA /* SentryEnvelopeItem.swift */, 92235CAB2E15369900865983 /* SentryLogBatcher.swift */, 92235CAD2E15549C00865983 /* SentryLogger.swift */, @@ -4920,6 +4939,8 @@ 92ECD73F2E05AD500063EC10 /* SentryAttribute.swift */, 92ECD73D2E05AD2B0063EC10 /* SentryLogLevel.swift */, 9264E1EA2E2E385B00B077CF /* SentryLogMessage.swift */, + D46B04472EDF25E100AF4A0A /* SentryMetric.swift */, + D48953FD2EF5756A0086F240 /* SentryMetricValue.swift */, F458D1122E180BB00028273E /* SentryFileManagerProtocol.swift */, ); path = Protocol; @@ -5851,8 +5872,10 @@ D8ACE3C82762187200F5A213 /* SentryFileIOTrackerHelper.m in Sources */, D8B088B729C9E3FF00213258 /* SentryTracerConfiguration.m in Sources */, FA7206E12E0B37C80072FDD4 /* SentryProfileCollector.mm in Sources */, + D46B044F2EDF260A00AF4A0A /* SentryMetricsBatcher.swift in Sources */, 9264E1EB2E2E385E00B077CF /* SentryLogMessage.swift in Sources */, 8ECC674A25C23A20000E2BF6 /* SentryTransactionContext.m in Sources */, + D46B04482EDF25E100AF4A0A /* SentryMetric.swift in Sources */, 03BCC38C27E1C01A003232C7 /* SentryTime.mm in Sources */, A8F17B342902870300990B25 /* SentryHttpStatusCodeRange.m in Sources */, 62C97D3A2CC64E6B00DDA204 /* SentryUncaughtNSExceptions.m in Sources */, @@ -5861,6 +5884,7 @@ 8453421628BE8A9500C22EEC /* SentrySpanStatus.m in Sources */, 6292585B2DAFA5F70049388F /* SentryCrashCxaThrowSwapper.c in Sources */, FA8AFCEF2E843903007A0E18 /* SentryFileIOTracker.swift in Sources */, + D48953FE2EF5756A0086F240 /* SentryMetricValue.swift in Sources */, F4FE86BD2EECAC31003D845F /* SentryScreenshotOptions.swift in Sources */, F4FE86BE2EECAC31003D845F /* SentryScreenshotIntegration.swift in Sources */, 92D957732E05A44600E20E66 /* SentryAsyncLog.m in Sources */, @@ -6255,6 +6279,7 @@ FAC62B652E15A4100003909D /* SentrySDKThreadTests.swift in Sources */, D82915632C85EF0C00A6CDD4 /* SentryViewPhotographerTests.swift in Sources */, D8DBE0CA2C0E093000FAB1FD /* SentryTouchTrackerTests.swift in Sources */, + D48954072EF579360086F240 /* SentryEnvelopeItemTypesTests.swift in Sources */, D4AF7D2C2E9404ED004F0F59 /* SentryUIRedactBuilderTests+EdgeCases.swift in Sources */, D8F67AF42BE10F9600C9197B /* SentryUIRedactBuilderTests.swift in Sources */, 92ECD7482E05B57C0063EC10 /* SentryAttributeTests.swift in Sources */, @@ -6395,6 +6420,7 @@ F49D419A2DEA2FB000D9244E /* SentryCrashExceptionApplicationTests.swift in Sources */, D88817DD26D72BA500BF2251 /* SentryTraceContextTests.swift in Sources */, D4E3F35D2D4A864600F79E2B /* SentryNSDictionarySanitizeTests.swift in Sources */, + D48954052EF575960086F240 /* SentryMetricValueTests.swift in Sources */, 7B984A9F28E572AF001F4BEE /* CrashReport.swift in Sources */, 927D21FB2ED5DE8A00916D31 /* FlushLogsIntegrationTests.swift in Sources */, D4AF00252D2E93C400F5F3D7 /* SentryNSFileManagerSwizzlingTests.m in Sources */, @@ -6415,6 +6441,7 @@ 63FE722520DA66EC00CDBAE8 /* SentryCrashFileUtils_Tests.m in Sources */, D86130122BB563FD004C0F5E /* SentrySessionReplayIntegrationTests.swift in Sources */, 7BFC16BA2524D4AF00FF6266 /* SentryMessage+Equality.m in Sources */, + D4749EF02EF042C1000F9AE7 /* SentryMetricTests.swift in Sources */, 92136D672C9D7660002A9FB8 /* SentryNSURLRequestBuilderTests.swift in Sources */, F48E2E0A2E6637840073CB22 /* TestSentryCrashWrapper.swift in Sources */, D855B3E827D652AF00BCED76 /* SentryCoreDataTrackingIntegrationTest.swift in Sources */, @@ -6428,6 +6455,7 @@ 7B16FD022654F86B008177D3 /* SentrySysctlTests.swift in Sources */, 7BAF3DB5243C743E008A5414 /* SentryClientTests.swift in Sources */, D8F67AF12BE0D33F00C9197B /* UIImageHelperTests.swift in Sources */, + D4749F482EF062FD000F9AE7 /* SentryMetricsBatcherTests.swift in Sources */, 8EAE8E5E2681768000D6958B /* URLSessionTaskMock.m in Sources */, 62CB191E2E77F8B500AF5DA2 /* SentryDispatchSourceWrapperTests.swift in Sources */, D8CE69BC277E39C700C6EC5C /* SentryFileIOTrackingIntegrationObjCTests.m in Sources */, diff --git a/SentryTestUtils/Sources/TestClient.swift b/SentryTestUtils/Sources/TestClient.swift index 25fbbff091..8d795305e0 100644 --- a/SentryTestUtils/Sources/TestClient.swift +++ b/SentryTestUtils/Sources/TestClient.swift @@ -184,4 +184,10 @@ public class TestClient: SentryClientInternal { public override func captureLogs() { captureLogsInvocations.record(()) } + + public var captureMetricsDataInvocations = Invocations<(data: Data, count: NSNumber)>() + public override func captureMetricsData(_ data: Data, with itemCount: NSNumber) { + captureMetricsDataInvocations.record((data, itemCount)) + super.captureMetricsData(data, with: itemCount) + } } diff --git a/Sources/Sentry/SentryClient.m b/Sources/Sentry/SentryClient.m index 18176e7edd..b69e136c0a 100644 --- a/Sources/Sentry/SentryClient.m +++ b/Sources/Sentry/SentryClient.m @@ -1114,11 +1114,29 @@ - (void)captureLogs - (void)captureLogsData:(NSData *)data with:(NSNumber *)itemCount { - SentryEnvelopeItem *envelopeItem = - [[SentryEnvelopeItem alloc] initWithType:SentryEnvelopeItemTypes.log - data:data - contentType:@"application/vnd.sentry.items.log+json" - itemCount:itemCount]; + [self captureData:data + with:itemCount + type:SentryEnvelopeItemTypes.log + contentType:@"application/vnd.sentry.items.log+json"]; +} + +- (void)captureMetricsData:(NSData *)data with:(NSNumber *)itemCount +{ + [self captureData:data + with:itemCount + type:SentryEnvelopeItemTypes.traceMetric + contentType:@"application/vnd.sentry.items.trace-metric+json"]; +} + +- (void)captureData:(NSData *)data + with:(NSNumber *)itemCount + type:(NSString *)type + contentType:(NSString *)contentType +{ + SentryEnvelopeItem *envelopeItem = [[SentryEnvelopeItem alloc] initWithType:type + data:data + contentType:contentType + itemCount:itemCount]; SentryEnvelope *envelope = [[SentryEnvelope alloc] initWithHeader:[SentryEnvelopeHeader empty] singleItem:envelopeItem]; [self captureEnvelope:envelope]; diff --git a/Sources/Sentry/SentryDataCategoryMapper.m b/Sources/Sentry/SentryDataCategoryMapper.m index 2302938946..8c9f615307 100644 --- a/Sources/Sentry/SentryDataCategoryMapper.m +++ b/Sources/Sentry/SentryDataCategoryMapper.m @@ -18,6 +18,7 @@ NSString *const kSentryDataCategoryNameSpan = @"span"; NSString *const kSentryDataCategoryNameFeedback = @"feedback"; NSString *const kSentryDataCategoryNameLogItem = @"log_item"; +NSString *const kSentryDataCategoryNameTraceMetric = @"trace_metric"; NSString *const kSentryDataCategoryNameUnknown = @"unknown"; NS_ASSUME_NONNULL_BEGIN @@ -57,6 +58,9 @@ if ([itemType isEqualToString:SentryEnvelopeItemTypes.log]) { return kSentryDataCategoryLogItem; } + if ([itemType isEqualToString:SentryEnvelopeItemTypes.traceMetric]) { + return kSentryDataCategoryTraceMetric; + } return kSentryDataCategoryDefault; } @@ -113,6 +117,9 @@ if ([value isEqualToString:kSentryDataCategoryNameLogItem]) { return kSentryDataCategoryLogItem; } + if ([value isEqualToString:kSentryDataCategoryNameTraceMetric]) { + return kSentryDataCategoryTraceMetric; + } return kSentryDataCategoryUnknown; } @@ -148,6 +155,8 @@ return kSentryDataCategoryNameFeedback; case kSentryDataCategoryLogItem: return kSentryDataCategoryNameLogItem; + case kSentryDataCategoryTraceMetric: + return kSentryDataCategoryNameTraceMetric; default: // !!!: fall-through! case kSentryDataCategoryUnknown: diff --git a/Sources/Sentry/include/SentryClient+Private.h b/Sources/Sentry/include/SentryClient+Private.h index 9792434999..6ae53de4c2 100644 --- a/Sources/Sentry/include/SentryClient+Private.h +++ b/Sources/Sentry/include/SentryClient+Private.h @@ -83,6 +83,7 @@ NS_ASSUME_NONNULL_BEGIN - (void)_swiftCaptureLog:(NSObject *)log withScope:(SentryScope *)scope; - (void)captureLogs; +- (void)captureMetricsData:(NSData *)data with:(NSNumber *)itemCount; @end diff --git a/Sources/Sentry/include/SentryDataCategory.h b/Sources/Sentry/include/SentryDataCategory.h index dcc5684115..e604c6d936 100644 --- a/Sources/Sentry/include/SentryDataCategory.h +++ b/Sources/Sentry/include/SentryDataCategory.h @@ -20,5 +20,6 @@ typedef NS_ENUM(NSUInteger, SentryDataCategory) { kSentryDataCategorySpan = 11, kSentryDataCategoryFeedback = 12, kSentryDataCategoryLogItem = 13, - kSentryDataCategoryUnknown = 14, + kSentryDataCategoryTraceMetric = 14, + kSentryDataCategoryUnknown = 15, }; diff --git a/Sources/Swift/Helper/SentryEnvelopeItemType.swift b/Sources/Swift/Helper/SentryEnvelopeItemType.swift index 5fd8c49490..32429610ca 100644 --- a/Sources/Swift/Helper/SentryEnvelopeItemType.swift +++ b/Sources/Swift/Helper/SentryEnvelopeItemType.swift @@ -14,4 +14,5 @@ public static let statsd = "statsd" public static let profileChunk = "profile_chunk" public static let log = "log" + public static let traceMetric = "trace_metric" } diff --git a/Sources/Swift/Integrations/Metrics/SentryMetricsBatcher.swift b/Sources/Swift/Integrations/Metrics/SentryMetricsBatcher.swift new file mode 100644 index 0000000000..970a2fada6 --- /dev/null +++ b/Sources/Swift/Integrations/Metrics/SentryMetricsBatcher.swift @@ -0,0 +1,100 @@ +@_implementationOnly import _SentryPrivate +import Foundation + +protocol SentryMetricsBatcherProtocol { + func addMetric(_ metric: SentryMetric, scope: Scope) + @discardableResult func captureMetrics() -> TimeInterval +} + +protocol SentryMetricsBatcherOptionsProtocol { + var enableMetrics: Bool { get } + var beforeSendMetric: ((SentryMetric) -> SentryMetric?)? { get } + var environment: String { get } + var releaseName: String? { get } + var cacheDirectoryPath: String { get } + var sendDefaultPii: Bool { get } +} + +/// SentryMetricsBatcher is responsible for batching metrics with scope-based attribute enrichment. +struct SentryMetricsBatcher: SentryMetricsBatcherProtocol { + private let isEnabled: Bool + private let batcher: any BatcherProtocol + + /// Initializes a new MetricBatcher. + /// - Parameters: + /// - options: The Sentry configuration options + /// - flushTimeout: The timeout interval after which buffered metrics will be flushed + /// - maxMetricCount: Maximum number of metrics to batch before triggering an immediate flush. + /// - maxBufferSizeBytes: The maximum buffer size in bytes before triggering an immediate flush + /// - dateProvider: Instance used to determine current time + /// - dispatchQueue: A **serial** dispatch queue wrapper for thread-safe access to mutable state + /// - capturedDataCallback: The callback to handle captured metric batches. This callback is responsible + /// for invoking client.captureMetricsData() with the batched data. + /// + /// - 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: Metrics are flushed when either `maxMetricCount` or `maxBufferSizeBytes` limit is reached. + init( + options: SentryMetricsBatcherOptionsProtocol, + flushTimeout: TimeInterval = 5, + maxMetricCount: Int = 100, // Maximum 100 metrics per batch + maxBufferSizeBytes: Int = 1_024 * 1_024, // 1MB buffer size for trace metrics + dateProvider: SentryCurrentDateProvider, + dispatchQueue: SentryDispatchQueueWrapper, + capturedDataCallback: @escaping (_ data: Data, _ count: Int) -> Void + ) { + self.isEnabled = options.enableMetrics + self.batcher = Batcher( + config: .init( + sendDefaultPii: options.sendDefaultPii, + flushTimeout: flushTimeout, + maxItemCount: maxMetricCount, + maxBufferSizeBytes: maxBufferSizeBytes, + beforeSendItem: options.beforeSendMetric, + capturedDataCallback: capturedDataCallback + ), + metadata: .init( + environment: options.environment, + releaseName: options.releaseName, + installationId: SentryInstallation.cachedId(withCacheDirectoryPath: options.cacheDirectoryPath) + ), + buffer: InMemoryBatchBuffer(), + dateProvider: dateProvider, + dispatchQueue: dispatchQueue + ) + } + + /// Adds a metric to the batcher. + /// - Parameters: + /// - metric: The metric to add + /// - scope: The scope to add the metric to + func addMetric(_ metric: SentryMetric, scope: Scope) { + guard isEnabled else { + return + } + batcher.add(metric, scope: scope) + } + + /// Captures batched metrics synchronously and returns the duration. + /// - 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. + /// This is safe to call from any thread, but be aware that it uses dispatchSync internally, + /// so calling it from a context that holds locks or is on the batcher's queue itself could cause a deadlock. + @discardableResult func captureMetrics() -> TimeInterval { + return batcher.capture() + } +} + +extension Options: SentryMetricsBatcherOptionsProtocol { + // As soon as the feature is not experimental anymore, we can remove these two bridging methods. + + var enableMetrics: Bool { + return experimental.enableMetrics + } + + var beforeSendMetric: ((SentryMetric) -> SentryMetric?)? { + return experimental.beforeSendMetric + } +} diff --git a/Sources/Swift/Integrations/Metrics/SentryMetricsIntegration.swift b/Sources/Swift/Integrations/Metrics/SentryMetricsIntegration.swift index ea296a04b0..4c2d235dc3 100644 --- a/Sources/Swift/Integrations/Metrics/SentryMetricsIntegration.swift +++ b/Sources/Swift/Integrations/Metrics/SentryMetricsIntegration.swift @@ -1,13 +1,42 @@ -final class SentryMetricsIntegration: NSObject, SwiftIntegration { +@_implementationOnly import _SentryPrivate + +final class SentryMetricsIntegration: NSObject, SwiftIntegration { + private let metricBatcher: SentryMetricsBatcherProtocol + init?(with options: Options, dependencies: Dependencies) { guard options.experimental.enableMetrics else { return nil } - SentrySDKLog.debug("Integration initialized") + self.metricBatcher = SentryMetricsBatcher( + options: options, + dateProvider: dependencies.dateProvider, + dispatchQueue: dependencies.dispatchQueueWrapper, + capturedDataCallback: { data, count in + let hub = SentrySDKInternal.currentHub() + guard let client = hub.getClient() else { + SentrySDKLog.debug("MetricsIntegration: No client available, dropping metrics") + return + } + client.captureMetricsData(data, with: NSNumber(value: count)) + } + ) } - func uninstall() {} + func uninstall() { + // Flush any pending metrics before uninstalling. + // + // Note: This calls captureMetrics() synchronously, which uses dispatchSync internally. + // This is safe because uninstall() is typically called from the main thread during + // app lifecycle events, and the batcher's dispatch queue is a separate serial queue. + metricBatcher.captureMetrics() + } static var name: String { "SentryMetricsIntegration" } + + // MARK: - Public API for MetricsApi + + func addMetric(_ metric: SentryMetric, scope: Scope) { + metricBatcher.addMetric(metric, scope: scope) + } } diff --git a/Sources/Swift/Protocol/SentryAttribute.swift b/Sources/Swift/Protocol/SentryAttribute.swift index a8f8ddcd81..bb43ed2bdf 100644 --- a/Sources/Swift/Protocol/SentryAttribute.swift +++ b/Sources/Swift/Protocol/SentryAttribute.swift @@ -1,4 +1,4 @@ -/// A typed attribute that can be attached to structured item entries used by Logs +/// A typed attribute that can be attached to structured item entries used by Logs & Metrics /// /// `Attribute` provides a type-safe way to store structured data alongside item messages. /// Supports String, Bool, Int, and Double types. diff --git a/Sources/Swift/Protocol/SentryMetric.swift b/Sources/Swift/Protocol/SentryMetric.swift new file mode 100644 index 0000000000..4c477e7034 --- /dev/null +++ b/Sources/Swift/Protocol/SentryMetric.swift @@ -0,0 +1,103 @@ +/// A metric entry that captures metric data with associated attribute metadata. +/// +/// Use the `options.beforeSendMetric` callback to modify or filter metric data. +public struct SentryMetric { + /// A typed value of the metric + public typealias Value = SentryMetricValue + + /// A typed attribute that can be attached to structured item entries + public typealias Attribute = SentryAttribute + + /// The timestamp when the metric was recorded. + public var timestamp: Date + + /// The name of the metric (e.g., "api.response_time", "db.query.duration"). + /// + /// Metric names should follow a dot-separated hierarchical naming convention + /// to enable better organization and querying in Sentry. + public var name: String + + /// The trace ID to associate this metric with distributed tracing. + /// + /// This will be set to a valid non-empty value during processing by the batcher, + /// which applies scope-based attribute enrichment including trace context. + public var traceId: SentryId + + /// The numeric value of the metric. + /// + /// The setter performs automatic type conversion when needed: + /// - Setting a double on a counter: floors the value and converts to integer + /// - Setting an integer on a gauge/distribution: converts to double + /// + /// - Note: Counters use integer values, distributions and gauges use double values. + public var value: SentryMetricValue + + /// The unit of measurement for the metric value (optional). + /// + /// Examples: "millisecond", "byte", "connection", "request". This helps + /// provide context for the metric value when viewing in Sentry. + public var unit: String? + + /// A dictionary of structured attributes added to the metric. + /// + /// Attributes provide additional context and can be used for filtering and + /// grouping metrics in Sentry. Common attributes include endpoint names, + /// HTTP methods, status codes, etc. + public var attributes: [String: SentryAttribute] + + /// Creates a metric entry with the specified properties. + /// + /// - Note: This initializer is internal. Metrics should be created by the SDK through the public metrics API. + /// Users can modify metrics in the `beforeSendMetric` callback. + /// + /// - Parameters: + /// - timestamp: The timestamp when the metric was recorded + /// - traceId: The trace ID to associate this metric with distributed tracing + /// - name: The name of the metric + /// - value: The numeric value of the metric + /// - unit: The unit of measurement for the metric value (optional) + /// - attributes: A dictionary of structured attributes to add to the metric + internal init( + timestamp: Date, + traceId: SentryId, + name: String, + value: SentryMetricValue, + unit: String?, + attributes: [String: SentryAttribute] + ) { + self.timestamp = timestamp + self.traceId = traceId + self.name = name + self.unit = unit + self.attributes = attributes + self.value = value + } +} + +extension SentryMetric: Encodable { + private enum CodingKeys: String, CodingKey { + case timestamp + case traceId = "trace_id" + case name + case value + case type + case unit + case attributes + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(timestamp, forKey: .timestamp) + try container.encode(traceId.sentryIdString, forKey: .traceId) + try container.encode(name, forKey: .name) + try container.encodeIfPresent(unit, forKey: .unit) + try container.encode(attributes, forKey: .attributes) + + // We need to call the encode method instead of passing the value to the encoder + // so that the `type` and `value` are set on the same level as the other keys. + try value.encode(to: encoder) + } +} + +extension SentryMetric: BatcherItem {} diff --git a/Sources/Swift/Protocol/SentryMetricValue.swift b/Sources/Swift/Protocol/SentryMetricValue.swift new file mode 100644 index 0000000000..54524df44b --- /dev/null +++ b/Sources/Swift/Protocol/SentryMetricValue.swift @@ -0,0 +1,60 @@ +/// Represents the numeric value of a metric with type-safe distinction between integers and doubles. +/// +/// This enum provides type safety to prevent accidentally mixing integer and floating-point values, +/// especially useful in `beforeSendMetric` callbacks where you need to ensure counters remain integers +/// and distributions remain doubles. +/// +/// Example usage in `beforeSendMetric`: +/// ```swift +/// options.beforeSendMetric = { metric in +/// var modified = metric +/// switch modified.value { +/// case .counter(let intValue): +/// // Can safely modify as integer - e.g., for counters +/// modified.value = .counter(intValue + 1) +/// case .gauge(let doubleValue): +/// // Can safely modify as double - e.g., for gauges +/// modified.value = .gauge(doubleValue * 1.5) +/// case .distribution(let doubleValue): +/// // Can safely modify as double - e.g., for distributions +/// modified.value = .distribution(doubleValue * 1.5) +/// } +/// return modified +/// } +/// ``` +public enum SentryMetricValue: Equatable, Hashable { + /// Incrementing integer values that only increase (e.g., request counts) + case counter(_ value: UInt) + + /// Current value at a point in time that can fluctuate (e.g., active connections) + case gauge(_ value: Double) + + /// Statistical distribution of values for aggregation (e.g., response times) + case distribution(_ value: Double) +} + +extension SentryMetricValue: Encodable { + private enum CodingKeys: String, CodingKey { + case metricType = "type" + case value + } + + /// Encodes the value according to the metrics specification. + /// + /// Integer values are encoded as `Int64` and double values as `Double` to ensure + /// accurate representation in the metric payload. + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .counter(let value): + try container.encode("counter", forKey: .metricType) + try container.encode(Int64(truncatingIfNeeded: value), forKey: .value) + case .gauge(let value): + try container.encode("gauge", forKey: .metricType) + try container.encode(value, forKey: .value) + case .distribution(let value): + try container.encode("distribution", forKey: .metricType) + try container.encode(value, forKey: .value) + } + } +} diff --git a/Sources/Swift/SentryDependencyContainer.swift b/Sources/Swift/SentryDependencyContainer.swift index 66afed66fb..ef02ee5067 100644 --- a/Sources/Swift/SentryDependencyContainer.swift +++ b/Sources/Swift/SentryDependencyContainer.swift @@ -257,6 +257,16 @@ extension SentryFileManager: SentryFileManagerProtocol { } extension SentryDependencyContainer: ScreenshotSourceProvider { } #endif +protocol DateProviderProvider { + var dateProvider: SentryCurrentDateProvider { get } +} +extension SentryDependencyContainer: DateProviderProvider {} + +protocol DispatchQueueWrapperProvider { + var dispatchQueueWrapper: SentryDispatchQueueWrapper { get } +} +extension SentryDependencyContainer: DispatchQueueWrapperProvider { } + extension SentryDependencyContainer: AutoSessionTrackingProvider { } #if ((os(iOS) || os(tvOS) || (swift(>=5.9) && os(visionOS))) && !SENTRY_NO_UIKIT) || os(macOS) diff --git a/Sources/Swift/SentryExperimentalOptions.swift b/Sources/Swift/SentryExperimentalOptions.swift index 0c5a29d568..12171025eb 100644 --- a/Sources/Swift/SentryExperimentalOptions.swift +++ b/Sources/Swift/SentryExperimentalOptions.swift @@ -34,6 +34,10 @@ public final class SentryExperimentalOptions: NSObject { /// @note Default value is @c false. @objc public var enableMetrics: Bool = false + /// Use this callback to drop or modify a metric before the SDK sends it to Sentry. Return nil to + /// drop the metric. + public var beforeSendMetric: ((SentryMetric) -> SentryMetric?)? + @_spi(Private) public func validateOptions(_ options: [String: Any]?) { } } diff --git a/Sources/Swift/Tools/Batcher/BatcherItem.swift b/Sources/Swift/Tools/Batcher/BatcherItem.swift index a18db95d2e..cb7d5767af 100644 --- a/Sources/Swift/Tools/Batcher/BatcherItem.swift +++ b/Sources/Swift/Tools/Batcher/BatcherItem.swift @@ -1,5 +1,4 @@ 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/BatcherScope.swift b/Sources/Swift/Tools/Batcher/BatcherScope.swift index 4e43743bd4..01e9e87cb1 100644 --- a/Sources/Swift/Tools/Batcher/BatcherScope.swift +++ b/Sources/Swift/Tools/Batcher/BatcherScope.swift @@ -35,7 +35,9 @@ extension BatcherScope { 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 metadata.environment.count > 0 { + attributes["sentry.environment"] = .init(string: metadata.environment) + } if let releaseName = metadata.releaseName { attributes["sentry.release"] = .init(string: releaseName) } diff --git a/Tests/SentryTests/Helper/SentryEnvelopeItemTypesTests.swift b/Tests/SentryTests/Helper/SentryEnvelopeItemTypesTests.swift new file mode 100644 index 0000000000..2c05d32ac1 --- /dev/null +++ b/Tests/SentryTests/Helper/SentryEnvelopeItemTypesTests.swift @@ -0,0 +1,138 @@ +@_spi(Private) @testable import Sentry +import XCTest + +final class SentryEnvelopeItemTypesTests: XCTestCase { + + // MARK: - Event Type Tests + + func testEvent_shouldReturnCorrectString() { + // -- Act & Assert -- + XCTAssertEqual(SentryEnvelopeItemTypes.event, "event") + } + + // MARK: - Session Type Tests + + func testSession_shouldReturnCorrectString() { + // -- Act & Assert -- + XCTAssertEqual(SentryEnvelopeItemTypes.session, "session") + } + + // MARK: - Feedback Type Tests + + func testFeedback_shouldReturnCorrectString() { + // -- Act & Assert -- + XCTAssertEqual(SentryEnvelopeItemTypes.feedback, "feedback") + } + + // MARK: - Transaction Type Tests + + func testTransaction_shouldReturnCorrectString() { + // -- Act & Assert -- + XCTAssertEqual(SentryEnvelopeItemTypes.transaction, "transaction") + } + + // MARK: - Attachment Type Tests + + func testAttachment_shouldReturnCorrectString() { + // -- Act & Assert -- + XCTAssertEqual(SentryEnvelopeItemTypes.attachment, "attachment") + } + + // MARK: - Client Report Type Tests + + func testClientReport_shouldReturnCorrectString() { + // -- Act & Assert -- + XCTAssertEqual(SentryEnvelopeItemTypes.clientReport, "client_report") + } + + // MARK: - Profile Type Tests + + func testProfile_shouldReturnCorrectString() { + // -- Act & Assert -- + XCTAssertEqual(SentryEnvelopeItemTypes.profile, "profile") + } + + // MARK: - Replay Video Type Tests + + func testReplayVideo_shouldReturnCorrectString() { + // -- Act & Assert -- + XCTAssertEqual(SentryEnvelopeItemTypes.replayVideo, "replay_video") + } + + // MARK: - Statsd Type Tests + + func testStatsd_shouldReturnCorrectString() { + // -- Act & Assert -- + XCTAssertEqual(SentryEnvelopeItemTypes.statsd, "statsd") + } + + // MARK: - Profile Chunk Type Tests + + func testProfileChunk_shouldReturnCorrectString() { + // -- Act & Assert -- + XCTAssertEqual(SentryEnvelopeItemTypes.profileChunk, "profile_chunk") + } + + // MARK: - Log Type Tests + + func testLog_shouldReturnCorrectString() { + // -- Act & Assert -- + XCTAssertEqual(SentryEnvelopeItemTypes.log, "log") + } + + // MARK: - Trace Metric Type Tests + + func testTraceMetric_shouldReturnCorrectString() { + // -- Act & Assert -- + XCTAssertEqual(SentryEnvelopeItemTypes.traceMetric, "trace_metric") + } + + // MARK: - All Types Tests + + func testAllTypes_shouldHaveUniqueValues() { + // -- Arrange -- + let allTypes = [ + SentryEnvelopeItemTypes.event, + SentryEnvelopeItemTypes.session, + SentryEnvelopeItemTypes.feedback, + SentryEnvelopeItemTypes.transaction, + SentryEnvelopeItemTypes.attachment, + SentryEnvelopeItemTypes.clientReport, + SentryEnvelopeItemTypes.profile, + SentryEnvelopeItemTypes.replayVideo, + SentryEnvelopeItemTypes.statsd, + SentryEnvelopeItemTypes.profileChunk, + SentryEnvelopeItemTypes.log, + SentryEnvelopeItemTypes.traceMetric + ] + + // -- Act -- + let uniqueTypes = Set(allTypes) + + // -- Assert -- + XCTAssertEqual(allTypes.count, uniqueTypes.count, "All envelope item types should have unique string values") + } + + func testAllTypes_shouldNotBeEmpty() { + // -- Arrange -- + let allTypes = [ + SentryEnvelopeItemTypes.event, + SentryEnvelopeItemTypes.session, + SentryEnvelopeItemTypes.feedback, + SentryEnvelopeItemTypes.transaction, + SentryEnvelopeItemTypes.attachment, + SentryEnvelopeItemTypes.clientReport, + SentryEnvelopeItemTypes.profile, + SentryEnvelopeItemTypes.replayVideo, + SentryEnvelopeItemTypes.statsd, + SentryEnvelopeItemTypes.profileChunk, + SentryEnvelopeItemTypes.log, + SentryEnvelopeItemTypes.traceMetric + ] + + // -- Act & Assert -- + for type in allTypes { + XCTAssertFalse(type.isEmpty, "Envelope item type '\(type)' should not be empty") + } + } +} diff --git a/Tests/SentryTests/Integrations/Metrics/SentryMetricsBatcherTests.swift b/Tests/SentryTests/Integrations/Metrics/SentryMetricsBatcherTests.swift new file mode 100644 index 0000000000..2751fcd81d --- /dev/null +++ b/Tests/SentryTests/Integrations/Metrics/SentryMetricsBatcherTests.swift @@ -0,0 +1,801 @@ +@_spi(Private) @testable import Sentry +@_spi(Private) import SentryTestUtils +import XCTest + +final class SentryMetricsBatcherTests: XCTestCase { + + private var options: Options! + private var testDateProvider: TestCurrentDateProvider! + private var testCallbackHelper: TestMetricsBatcherCallbackHelper! + private var testDispatchQueue: TestSentryDispatchQueueWrapper! + private var scope: Scope! + + override func setUp() { + super.setUp() + + options = Options() + options.dsn = TestConstants.dsnForTestCase(type: Self.self) + options.experimental.enableMetrics = true + + testDateProvider = TestCurrentDateProvider() + testCallbackHelper = TestMetricsBatcherCallbackHelper() + testDispatchQueue = TestSentryDispatchQueueWrapper() + testDispatchQueue.dispatchAsyncExecutesBlock = true // Execute encoding immediately + + scope = Scope() + } + + override func tearDown() { + super.tearDown() + clearTestState() + testCallbackHelper = nil + testDispatchQueue = nil + scope = nil + } + + private func getSut() -> SentryMetricsBatcher { + return SentryMetricsBatcher( + options: options, + flushTimeout: 0.1, // Very small timeout for testing + maxMetricCount: 10, // Maximum 10 metrics per batch + maxBufferSizeBytes: 8_000, // byte limit for testing + dateProvider: testDateProvider, + dispatchQueue: testDispatchQueue, + capturedDataCallback: testCallbackHelper.captureCallback + ) + } + + // MARK: - Basic Functionality Tests + + func testAddMetric_whenMultipleMetrics_shouldBatchTogether() throws { + // -- Arrange -- + let metric1 = createTestMetric(name: "metric.one", value: .counter(1)) + let metric2 = createTestMetric(name: "metric.two", value: .counter(2)) + + XCTAssertEqual(testCallbackHelper.captureMetricsDataInvocations.count, 0) + + // -- Act -- + let sut = getSut() + sut.addMetric(metric1, scope: scope) + sut.addMetric(metric2, scope: scope) + + // Trigger flush manually + sut.captureMetrics() + + // -- Assert -- + XCTAssertEqual(testCallbackHelper.captureMetricsDataInvocations.count, 1) + + let capturedMetrics = testCallbackHelper.getCapturedMetrics() + XCTAssertEqual(capturedMetrics.element(at: 0)?["name"] as? String, "metric.one") + XCTAssertEqual(capturedMetrics.element(at: 1)?["name"] as? String, "metric.two") + + // Assert no further metrics + XCTAssertEqual(capturedMetrics.count, 2) + } + + // MARK: - Buffer Size Tests + + func testAddMetric_whenBufferReachesMaxSize_shouldFlushImmediately() throws { + // -- Arrange -- + // Create a metric with large attributes to exceed buffer size + var largeAttributes: [String: SentryMetric.Attribute] = [:] + for i in 0..<100 { + largeAttributes["key\(i)"] = .init(string: String(repeating: "A", count: 80)) + } + let largeMetric = SentryMetric( + timestamp: Date(), + traceId: SentryId(), + name: "large.metric", + value: .counter(1), + unit: nil, + attributes: largeAttributes + ) + + // -- Act -- + let sut = getSut() + sut.addMetric(largeMetric, scope: scope) + + // -- Assert -- + XCTAssertEqual(testCallbackHelper.captureMetricsDataInvocations.count, 1) + + let capturedMetrics = testCallbackHelper.getCapturedMetrics() + XCTAssertEqual(capturedMetrics.element(at: 0)?["name"] as? String, "large.metric") + + // Assert no further metrics + XCTAssertEqual(capturedMetrics.count, 1) + } + + // MARK: - Max Metric Count Tests + + func testAddMetric_whenMaxMetricCountReached_shouldFlush() throws { + // -- Act -- Add exactly maxMetricCount metrics + let sut = getSut() + for i in 0..<9 { + let metric = createTestMetric(name: "metric.\(i + 1)", value: .counter(UInt(i + 1))) + sut.addMetric(metric, scope: scope) + } + + XCTAssertEqual(testCallbackHelper.captureMetricsDataInvocations.count, 0) + + let metric = createTestMetric(name: "metric.10", value: .counter(10)) // Reached 10 max metrics limit + sut.addMetric(metric, scope: scope) + + // -- Assert -- Should have flushed once when reaching maxMetricCount + XCTAssertEqual(testCallbackHelper.captureMetricsDataInvocations.count, 1) + + let capturedMetrics = testCallbackHelper.getCapturedMetrics() + XCTAssertEqual(capturedMetrics.count, 10, "Should have captured exactly 10 metrics") + } + + // MARK: - Timeout Tests + + func testAddMetric_whenTimeoutExpires_shouldFlush() throws { + // -- Arrange -- + let metric = createTestMetric(name: "test.metric", value: .counter(1)) + + // -- Act -- + let sut = getSut() + sut.addMetric(metric, scope: scope) + + // -- Assert -- + XCTAssertEqual(testCallbackHelper.captureMetricsDataInvocations.count, 0) + 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(testCallbackHelper.captureMetricsDataInvocations.count, 1) + let capturedMetrics = testCallbackHelper.getCapturedMetrics() + XCTAssertEqual(capturedMetrics.count, 1) + } + + func testAddMetric_whenEmptyBuffer_shouldStartTimer() throws { + // -- Arrange -- + let metric1 = createTestMetric(name: "metric.1", value: .counter(1)) + let metric2 = createTestMetric(name: "metric.2", value: .counter(2)) + + // -- Act -- + let sut = getSut() + sut.addMetric(metric1, scope: scope) + + // -- Assert -- + XCTAssertEqual(testDispatchQueue.dispatchAfterWorkItemInvocations.count, 1) + XCTAssertEqual(testDispatchQueue.dispatchAfterWorkItemInvocations.first?.interval, 0.1) + + sut.addMetric(metric2, scope: scope) + + XCTAssertEqual(testDispatchQueue.dispatchAfterWorkItemInvocations.count, 1) + + // Should not flush immediately + XCTAssertEqual(testCallbackHelper.captureMetricsDataInvocations.count, 0) + } + + // MARK: - Default Values Tests + + func testInit_whenFlushTimeoutNotProvided_shouldUseDefaultValue() throws { + // -- Arrange -- + // Create a new batcher without specifying flushTimeout to use default + let defaultBatcher = SentryMetricsBatcher( + options: options, + dateProvider: testDateProvider, + dispatchQueue: testDispatchQueue, + capturedDataCallback: testCallbackHelper.captureCallback + ) + + let metric = createTestMetric(name: "test.metric", value: .counter(1)) + + // -- Act -- + defaultBatcher.addMetric(metric, scope: scope) + + // -- Assert -- + XCTAssertEqual(testDispatchQueue.dispatchAfterWorkItemInvocations.count, 1) + XCTAssertEqual(testDispatchQueue.dispatchAfterWorkItemInvocations.first?.interval, 5.0, "Default flushTimeout should be 5 seconds") + } + + func testInit_whenMaxMetricCountNotProvided_shouldUseDefaultValue() throws { + // -- Arrange -- + // Create a new batcher without specifying maxMetricCount to use default (100) + let defaultBatcher = SentryMetricsBatcher( + options: options, + dateProvider: testDateProvider, + dispatchQueue: testDispatchQueue, + capturedDataCallback: testCallbackHelper.captureCallback + ) + + // -- Act -- Add exactly 99 metrics (should not flush) + for i in 0..<99 { + let metric = createTestMetric(name: "metric.\(i + 1)", value: .counter(UInt(i + 1))) + defaultBatcher.addMetric(metric, scope: scope) + } + + XCTAssertEqual(testCallbackHelper.captureMetricsDataInvocations.count, 0, "Should not flush before reaching default maxMetricCount") + + // Add the 100th metric (should trigger flush) + let metric100 = createTestMetric(name: "metric.100", value: .counter(100)) + defaultBatcher.addMetric(metric100, scope: scope) + + // -- Assert -- + XCTAssertEqual(testCallbackHelper.captureMetricsDataInvocations.count, 1, "Should flush when reaching default maxMetricCount of 100") + + let capturedMetrics = testCallbackHelper.getCapturedMetrics() + XCTAssertEqual(capturedMetrics.count, 100, "Should have captured exactly 100 metrics") + } + + func testInit_whenMaxBufferSizeBytesNotProvided_shouldUseDefaultValue() throws { + // -- Arrange -- + // Create a new batcher without specifying maxBufferSizeBytes to use default (1MB) + // Note: Individual trace metrics must not exceed 2KB each (Relay's max_trace_metric_size limit), + // but the buffer can accumulate up to 1MB before flushing. + let defaultBatcher = SentryMetricsBatcher( + options: options, + flushTimeout: 0.1, + maxMetricCount: 100_000, // High count to avoid count-based flush, focus on size limit + dateProvider: testDateProvider, + dispatchQueue: testDispatchQueue, + capturedDataCallback: testCallbackHelper.captureCallback + ) + + // -- Act -- + // Add metrics until we exceed the 1MB buffer limit (approximately 500+ metrics at ~2KB each) + // We'll add enough to ensure we exceed the 1MB limit + for index in 0..<500 { + var attributes: [String: SentryMetric.Attribute] = [:] + // Create attributes that make the metric close to 2KB when serialized + // Each attribute with ~40 bytes of data, ~40 attributes should be close to 2KB + for i in 0..<40 { + attributes["key\(i)"] = .init(string: String(repeating: "A", count: 40)) + } + let metric = SentryMetric( + timestamp: Date(), + traceId: SentryId(), + name: "large.metric.\(index)", + value: .counter(UInt(index)), + unit: nil, + attributes: attributes + ) + defaultBatcher.addMetric(metric, scope: scope) + } + + // -- Assert -- + XCTAssertEqual(testCallbackHelper.captureMetricsDataInvocations.count, 1, "Should flush when exceeding default maxBufferSizeBytes of 1MB") + + let capturedMetrics = testCallbackHelper.getCapturedMetrics() + XCTAssertGreaterThan(capturedMetrics.count, 0, "Should have captured at least one metric") + XCTAssertLessThanOrEqual(capturedMetrics.count, 600, "Should not have captured more metrics than were added") + } + + // MARK: - Manual Capture Metrics Tests + + func testCaptureMetrics_whenMetricsExist_shouldCaptureImmediately() throws { + // -- Arrange -- + let metric1 = createTestMetric(name: "metric.1", value: .counter(1)) + let metric2 = createTestMetric(name: "metric.2", value: .counter(2)) + + // -- Act -- + let sut = getSut() + sut.addMetric(metric1, scope: scope) + sut.addMetric(metric2, scope: scope) + XCTAssertEqual(testCallbackHelper.captureMetricsDataInvocations.count, 0) + + let duration = sut.captureMetrics() + + // -- Assert -- + XCTAssertGreaterThanOrEqual(duration, 0, "captureMetrics should return a non-negative duration") + XCTAssertEqual(testCallbackHelper.captureMetricsDataInvocations.count, 1) + + let capturedMetrics = testCallbackHelper.getCapturedMetrics() + XCTAssertEqual(capturedMetrics.count, 2) + } + + func testCaptureMetrics_whenScheduledCaptureExists_shouldCancelScheduledCapture() throws { + // -- Arrange -- + let sut = getSut() + let metric = createTestMetric(name: "test.metric", value: .counter(1)) + sut.addMetric(metric, scope: scope) + XCTAssertEqual(testDispatchQueue.dispatchAfterWorkItemInvocations.count, 1) + let timerWorkItem = try XCTUnwrap(testDispatchQueue.dispatchAfterWorkItemInvocations.first?.workItem) + + // -- Act -- + let duration = sut.captureMetrics() + + // -- Assert -- + XCTAssertGreaterThanOrEqual(duration, 0, "captureMetrics should return a non-negative duration") + XCTAssertEqual(testCallbackHelper.captureMetricsDataInvocations.count, 1) + XCTAssertTrue(timerWorkItem.isCancelled) + } + + func testCaptureMetrics_whenMultipleMetrics_shouldMeasureDuration() throws { + // -- Arrange -- + let sut = getSut() + // Add multiple metrics to ensure there's actual work being done + for i in 0..<5 { + let metric = createTestMetric(name: "metric.\(i)", value: .counter(UInt(i))) + sut.addMetric(metric, scope: scope) + } + + // -- Act -- + let duration = sut.captureMetrics() + + // -- Assert -- + XCTAssertGreaterThanOrEqual(duration, 0, "captureMetrics should return a non-negative duration") + // Duration should be measurable (even if small) when metrics are processed + // We verify that captureMetrics actually measures time by checking it's >= 0 + // The actual duration depends on system performance, so we just verify it's non-negative + XCTAssertEqual(testCallbackHelper.captureMetricsDataInvocations.count, 1, "Should invoke callback once") + + let capturedMetrics = testCallbackHelper.getCapturedMetrics() + XCTAssertEqual(capturedMetrics.count, 5, "Should capture all 5 metrics") + } + + // MARK: - Metrics Disabled Tests + + func testAddMetric_whenMetricsDisabled_shouldNotAddMetrics() throws { + // -- Arrange -- + options.experimental.enableMetrics = false + + let metric = createTestMetric(name: "test.metric", value: .counter(1)) + + // -- Act -- + let sut = getSut() + sut.addMetric(metric, scope: scope) + let duration = sut.captureMetrics() + + // -- Assert -- + XCTAssertGreaterThanOrEqual(duration, 0, "captureMetrics should return a non-negative duration even when no metrics are captured") + XCTAssertEqual(testCallbackHelper.captureMetricsDataInvocations.count, 0) + } + + // MARK: - Edge Cases Tests + + func testFlush_whenBufferAlreadyFlushed_shouldDoNothing() throws { + // -- Arrange -- + var largeAttributes: [String: SentryMetric.Attribute] = [:] + for i in 0..<50 { + largeAttributes["key\(i)"] = .init(string: String(repeating: "B", count: 100)) + } + let metric1 = SentryMetric( + timestamp: Date(), + traceId: SentryId(), + name: "large.metric.1", + value: .counter(1), + unit: nil, + attributes: largeAttributes + ) + let metric2 = SentryMetric( + timestamp: Date(), + traceId: SentryId(), + name: "large.metric.2", + value: .counter(2), + unit: nil, + attributes: largeAttributes + ) + + // -- Act -- + let sut = getSut() + sut.addMetric(metric1, scope: scope) + XCTAssertEqual(testCallbackHelper.captureMetricsDataInvocations.count, 0) + XCTAssertEqual(testDispatchQueue.dispatchAfterWorkItemInvocations.count, 1) + let timerWorkItem = try XCTUnwrap(testDispatchQueue.dispatchAfterWorkItemInvocations.first?.workItem) + + sut.addMetric(metric2, scope: scope) + XCTAssertEqual(testCallbackHelper.captureMetricsDataInvocations.count, 1) + + timerWorkItem.perform() + + // -- Assert -- + XCTAssertEqual(testCallbackHelper.captureMetricsDataInvocations.count, 1) + } + + func testAddMetric_whenAfterFlush_shouldStartNewBatch() throws { + // -- Arrange -- + let metric1 = createTestMetric(name: "metric.1", value: .counter(1)) + let metric2 = createTestMetric(name: "metric.2", value: .counter(2)) + + // -- Act -- + let sut = getSut() + sut.addMetric(metric1, scope: scope) + let duration1 = sut.captureMetrics() + + XCTAssertGreaterThanOrEqual(duration1, 0) + XCTAssertEqual(testCallbackHelper.captureMetricsDataInvocations.count, 1) + + sut.addMetric(metric2, scope: scope) + let duration2 = sut.captureMetrics() + + // -- Assert -- + XCTAssertGreaterThanOrEqual(duration2, 0) + XCTAssertEqual(testCallbackHelper.captureMetricsDataInvocations.count, 2) + + // Verify each flush contains only one metric + let capturedMetrics = testCallbackHelper.getCapturedMetrics() + XCTAssertEqual(capturedMetrics.element(at: 0)?["name"] as? String, "metric.1") + XCTAssertEqual(capturedMetrics.element(at: 1)?["name"] as? String, "metric.2") + + // Assert no further metrics + XCTAssertEqual(capturedMetrics.count, 2) + } + + // MARK: - Attribute Enrichment Tests + + func testAddMetric_whenDefaultAttributesExist_shouldAddDefaultAttributes() throws { + // -- Arrange -- + options.environment = "test-environment" + options.releaseName = "1.0.0" + + let span = SentryTracer(transactionContext: TransactionContext(name: "Test Transaction", operation: "test-operation"), hub: nil) + scope.span = span + + let metric = createTestMetric(name: "test.metric", value: .counter(1)) + + // -- Act -- + let sut = getSut() + sut.addMetric(metric, scope: scope) + sut.captureMetrics() + + // -- Assert -- + let capturedMetrics = testCallbackHelper.getCapturedMetrics() + XCTAssertEqual(capturedMetrics.count, 1) + + let capturedMetric = try XCTUnwrap(capturedMetrics.first) + let attributes = try XCTUnwrap(capturedMetric["attributes"] as? [String: Any]) + + XCTAssertEqual(try XCTUnwrap(attributes["sentry.sdk.name"] as? [String: Any])["value"] as? String, SentryMeta.sdkName) + XCTAssertEqual(try XCTUnwrap(attributes["sentry.sdk.version"] as? [String: Any])["value"] as? String, SentryMeta.versionString) + XCTAssertEqual(try XCTUnwrap(attributes["sentry.environment"] as? [String: Any])["value"] as? String, "test-environment") + XCTAssertEqual(try XCTUnwrap(attributes["sentry.release"] as? [String: Any])["value"] as? String, "1.0.0") + } + + func testAddMetric_whenNilDefaultAttributes_shouldNotAddNilAttributes() throws { + // -- Arrange -- + options.releaseName = nil + + // No span set on scope + let metric = createTestMetric(name: "test.metric", value: .counter(1)) + + // -- Act -- + let sut = getSut() + sut.addMetric(metric, scope: scope) + sut.captureMetrics() + + // -- Assert -- + let capturedMetrics = testCallbackHelper.getCapturedMetrics() + let capturedMetric = try XCTUnwrap(capturedMetrics.first) + let attributes = try XCTUnwrap(capturedMetric["attributes"] as? [String: Any]) + + XCTAssertNil(attributes["sentry.release"]) + XCTAssertNil(attributes["sentry.trace.parent_span_id"]) + + // But should still have the non-nil defaults + XCTAssertEqual(try XCTUnwrap(attributes["sentry.sdk.name"] as? [String: Any])["value"] as? String, SentryMeta.sdkName) + XCTAssertEqual(try XCTUnwrap(attributes["sentry.sdk.version"] as? [String: Any])["value"] as? String, SentryMeta.versionString) + XCTAssertNotNil(attributes["sentry.environment"]) + } + + func testAddMetric_whenPropagationContextExists_shouldSetTraceIdFromPropagationContext() throws { + // -- Arrange -- + let expectedTraceId = SentryId() + let propagationContext = SentryPropagationContext(trace: expectedTraceId, spanId: SpanId()) + scope.propagationContext = propagationContext + + let metric = createTestMetric(name: "test.metric", value: .counter(1)) + + // -- Act -- + let sut = getSut() + sut.addMetric(metric, scope: scope) + sut.captureMetrics() + + // -- Assert -- + let capturedMetrics = testCallbackHelper.getCapturedMetrics() + let capturedMetric = try XCTUnwrap(capturedMetrics.first) + XCTAssertEqual(capturedMetric["trace_id"] as? String, expectedTraceId.sentryIdString) + } + + func testAddMetric_whenActiveSpanExists_shouldSetSpanIdFromActiveSpan() throws { + // -- Arrange -- + let span = SentryTracer(transactionContext: TransactionContext(name: "Test Transaction", operation: "test-operation"), hub: nil) + scope.span = span + + let metric = createTestMetric(name: "test.metric", value: .counter(1)) + + // -- Act -- + let sut = getSut() + sut.addMetric(metric, scope: scope) + sut.captureMetrics() + + // -- Assert -- + let capturedMetrics = testCallbackHelper.getCapturedMetrics() + let capturedMetric = try XCTUnwrap(capturedMetrics.first) + let attributes = try XCTUnwrap(capturedMetric["attributes"] as? [String: Any]) + XCTAssertEqual(try XCTUnwrap(attributes["span_id"] as? [String: Any])["value"] as? String, span.spanId.sentrySpanIdString) + } + + func testAddMetric_whenNoActiveSpan_shouldNotSetSpanId() throws { + // -- Arrange -- + // No span set on scope + let metric = createTestMetric(name: "test.metric", value: .counter(1)) + + // -- Act -- + let sut = getSut() + sut.addMetric(metric, scope: scope) + sut.captureMetrics() + + // -- Assert -- + let capturedMetrics = testCallbackHelper.getCapturedMetrics() + let capturedMetric = try XCTUnwrap(capturedMetrics.first) + let attributes = try XCTUnwrap(capturedMetric["attributes"] as? [String: Any]) + + XCTAssertNil(attributes["span_id"]) + XCTAssertNil(attributes["sentry.trace.parent_span_id"]) + } + + func testAddMetric_whenUserAttributesExist_shouldAddUserAttributes() throws { + // -- Arrange -- + options.sendDefaultPii = true + + let user = User() + user.userId = "123" + user.email = "test@test.com" + user.name = "test-name" + scope.setUser(user) + + let metric = createTestMetric(name: "test.metric", value: .counter(1)) + + // -- Act -- + let sut = getSut() + sut.addMetric(metric, scope: scope) + sut.captureMetrics() + + // -- Assert -- + let capturedMetrics = testCallbackHelper.getCapturedMetrics() + let capturedMetric = try XCTUnwrap(capturedMetrics.first) + let attributes = try XCTUnwrap(capturedMetric["attributes"] as? [String: Any]) + + let userIdAttr = try XCTUnwrap(attributes["user.id"] as? [String: Any]) + XCTAssertEqual(userIdAttr["value"] as? String, "123") + let userNameAttr = try XCTUnwrap(attributes["user.name"] as? [String: Any]) + XCTAssertEqual(userNameAttr["value"] as? String, "test-name") + let userIEmailAttr = try XCTUnwrap(attributes["user.email"] as? [String: Any]) + XCTAssertEqual(userIEmailAttr["value"] as? String, "test@test.com") + } + + func testAddMetric_whenSendDefaultPiiFalse_shouldNotAddUserAttributes() throws { + // -- Arrange -- + let installationId = SentryInstallation.id(withCacheDirectoryPath: options.cacheDirectoryPath) + options.sendDefaultPii = false + + let user = User() + user.userId = "123" + user.email = "test@test.com" + user.name = "test-name" + scope.setUser(user) + + let metric = createTestMetric(name: "test.metric", value: .counter(1)) + + // -- Act -- + let sut = getSut() + sut.addMetric(metric, scope: scope) + sut.captureMetrics() + + // -- Assert -- + let capturedMetrics = testCallbackHelper.getCapturedMetrics() + let capturedMetric = try XCTUnwrap(capturedMetrics.first) + let attributes = try XCTUnwrap(capturedMetric["attributes"] as? [String: Any]) + + let userIdAttr = try XCTUnwrap(attributes["user.id"] as? [String: Any]) + XCTAssertEqual(userIdAttr["value"] as? String, installationId) + XCTAssertNil(attributes["user.name"]) + XCTAssertNil(attributes["user.email"]) + } + + func testAddMetric_whenScopeAttributesExist_shouldAddScopeAttributes() throws { + // -- Arrange -- + scope.setAttribute(value: "scope-value", key: "scope-key") + + let metric = createTestMetric(name: "test.metric", value: .counter(1)) + + // -- Act -- + let sut = getSut() + sut.addMetric(metric, scope: scope) + sut.captureMetrics() + + // -- Assert -- + let capturedMetrics = testCallbackHelper.getCapturedMetrics() + let capturedMetric = try XCTUnwrap(capturedMetrics.first) + let attributes = try XCTUnwrap(capturedMetric["attributes"] as? [String: Any]) + + let scopeKeyAttr = try XCTUnwrap(attributes["scope-key"] as? [String: Any]) + XCTAssertEqual(scopeKeyAttr["value"] as? String, "scope-value") + } + + func testAddMetric_whenScopeAttributesExist_shouldNotOverrideExistingAttributes() throws { + // -- Arrange -- + scope.setAttribute(value: "scope-value", key: "existing-key") + + var metric = createTestMetric(name: "test.metric", value: .counter(1)) + metric.attributes["existing-key"] = .init(string: "metric-value") + + // -- Act -- + let sut = getSut() + sut.addMetric(metric, scope: scope) + sut.captureMetrics() + + // -- Assert -- + let capturedMetrics = testCallbackHelper.getCapturedMetrics() + let capturedMetric = try XCTUnwrap(capturedMetrics.first) + let attributes = try XCTUnwrap(capturedMetric["attributes"] as? [String: Any]) + + // Metric attribute should take precedence + let attr = try XCTUnwrap(attributes["existing-key"] as? [String: Any]) + XCTAssertEqual(attr["value"] as? String, "metric-value") + } + + // MARK: - BeforeSendMetric Tests + + func testAddMetric_whenBeforeSendMetricModifiesMetric_shouldCaptureModifiedMetric() throws { + // -- Arrange -- + options.experimental.beforeSendMetric = { metric in + var modifiedMetric = metric + modifiedMetric.attributes["test-attr"] = .init(string: "modified") + return modifiedMetric + } + + let metric = createTestMetric(name: "test.metric", value: .counter(1)) + + // -- Act -- + let sut = getSut() + sut.addMetric(metric, scope: scope) + let duration = sut.captureMetrics() + + // -- Assert -- + XCTAssertGreaterThanOrEqual(duration, 0, "captureMetrics should return a non-negative duration") + let capturedMetrics = testCallbackHelper.getCapturedMetrics() + let capturedMetric = try XCTUnwrap(capturedMetrics.first) + let attributes = try XCTUnwrap(capturedMetric["attributes"] as? [String: Any]) + let testAttr = try XCTUnwrap(attributes["test-attr"] as? [String: Any]) + XCTAssertEqual(testAttr["value"] as? String, "modified") + } + + func testAddMetric_whenBeforeSendMetricReturnsNil_shouldDropMetric() throws { + // -- Arrange -- + options.experimental.beforeSendMetric = { _ in nil } + + let metric = createTestMetric(name: "test.metric", value: .counter(1)) + + // -- Act -- + let sut = getSut() + sut.addMetric(metric, scope: scope) + let duration = sut.captureMetrics() + + // -- Assert -- + XCTAssertGreaterThanOrEqual(duration, 0, "captureMetrics should return a non-negative duration even when metric is dropped") + XCTAssertEqual(testCallbackHelper.captureMetricsDataInvocations.count, 0) + } + + // MARK: - Metric Type Tests + + func testAddMetric_whenCounterType_shouldCaptureCounter() throws { + // -- Arrange -- + let metric = createTestMetric( + name: "counter.metric", + value: .counter(42), + unit: "connection" + ) + + // -- Act -- + let sut = getSut() + sut.addMetric(metric, scope: scope) + sut.captureMetrics() + + // -- Assert -- + let capturedMetrics = testCallbackHelper.getCapturedMetrics() + let capturedMetric = try XCTUnwrap(capturedMetrics.first) + XCTAssertEqual(capturedMetric["type"] as? String, "counter") + XCTAssertEqual(capturedMetric["value"] as? Int64, 42) + XCTAssertEqual(capturedMetric["unit"] as? String, "connection") + + // Assert no additional metrics + XCTAssertEqual(capturedMetrics.count, 1) + } + + func testAddMetric_whenDistributionType_shouldCaptureDistribution() throws { + // -- Arrange -- + let metric = createTestMetric( + name: "distribution.metric", + value: .distribution(42.123456), + unit: "percent" + ) + + // -- Act -- + let sut = getSut() + sut.addMetric(metric, scope: scope) + sut.captureMetrics() + + // -- Assert -- + let capturedMetrics = testCallbackHelper.getCapturedMetrics() + let capturedMetric = try XCTUnwrap(capturedMetrics.first) + XCTAssertEqual(capturedMetric["type"] as? String, "distribution") + XCTAssertEqual(capturedMetric["value"] as? Double, 42.123456) + XCTAssertEqual(capturedMetric["unit"] as? String, "percent") + + // Assert no additional metrics + XCTAssertEqual(capturedMetrics.count, 1) + } + + func testAddMetric_whenGaugeType_shouldCaptureGauge() throws { + // -- Arrange -- + let metric = createTestMetric( + name: "gauge.metric", + value: .gauge(42.0), + unit: "connection" + ) + + // -- Act -- + let sut = getSut() + sut.addMetric(metric, scope: scope) + sut.captureMetrics() + + // -- Assert -- + let capturedMetrics = testCallbackHelper.getCapturedMetrics() + let capturedMetric = try XCTUnwrap(capturedMetrics.first) + XCTAssertEqual(capturedMetric["type"] as? String, "gauge") + XCTAssertEqual(capturedMetric["value"] as? Double, 42.0) + XCTAssertEqual(capturedMetric["unit"] as? String, "connection") + + // Assert no additional metrics + XCTAssertEqual(capturedMetrics.count, 1) + } + + // MARK: - Helper Methods + + private func createTestMetric(name: String, value: SentryMetric.Value, unit: String? = nil, attributes: [String: SentryMetric.Attribute] = [:]) -> SentryMetric { + return SentryMetric( + timestamp: Date(), + traceId: SentryId.empty, + name: name, + value: value, + unit: unit, + attributes: attributes + ) + } +} + +// MARK: - Test Callback Helper + +final class TestMetricsBatcherCallbackHelper { + var captureMetricsDataInvocations = Invocations<(data: Data, count: Int)>() + + // The callback that matches the MetricBatcher capturedDataCallback signature + var captureCallback: (Data, Int) -> Void { + return { [weak self] data, count in + self?.captureMetricsDataInvocations.record((data, count)) + } + } + + // Helper to get captured metrics + // Note: The batcher produces JSON in the format {"items":[...]} as verified by InMemoryBatchBuffer.batchedData + // + // Design decision: We use JSONSerialization instead of: + // 1. Decodable: Would introduce decoding logic in tests that could be wrong, creating a risk that tests pass + // even when the actual encoding/decoding logic is broken. + // 2. Direct string comparison: JSON key ordering is not guaranteed, so tests would be flaky. + // + // JSONSerialization provides a good middle ground: it parses the JSON structure without duplicating + // the encoding/decoding logic, and it's order-agnostic, making tests stable while still verifying + // the actual data structure produced by the batcher. + func getCapturedMetrics() -> [[String: Any]] { + var allMetrics: [[String: Any]] = [] + + for invocation in captureMetricsDataInvocations.invocations { + if let jsonObject = try? JSONSerialization.jsonObject(with: invocation.data) as? [String: Any], + let items = jsonObject["items"] as? [[String: Any]] { + for item in items { + allMetrics.append(item) + } + } + } + + return allMetrics + } +} diff --git a/Tests/SentryTests/Integrations/Metrics/SentryMetricsIntegrationTests.swift b/Tests/SentryTests/Integrations/Metrics/SentryMetricsIntegrationTests.swift index 18b012f68b..276bf7608d 100644 --- a/Tests/SentryTests/Integrations/Metrics/SentryMetricsIntegrationTests.swift +++ b/Tests/SentryTests/Integrations/Metrics/SentryMetricsIntegrationTests.swift @@ -3,8 +3,8 @@ import Foundation @_spi(Private) import SentryTestUtils import XCTest -class MetricsIntegrationTests: XCTestCase { - +class SentryMetricsIntegrationTests: XCTestCase { + override func tearDown() { super.tearDown() clearTestState() @@ -13,6 +13,9 @@ class MetricsIntegrationTests: XCTestCase { // MARK: - Tests func testStartSDK_whenIntegrationIsNotEnabled_shouldNotBeInstalled() { + // -- Arrange -- + // SDK not enabled in startSDK call + // -- Act -- startSDK(isEnabled: false) @@ -21,18 +24,130 @@ class MetricsIntegrationTests: XCTestCase { } func testStartSDK_whenIntegrationIsEnabled_shouldBeInstalled() { + // -- Arrange -- + // SDK enabled in startSDK call + // -- Act -- startSDK(isEnabled: true) // -- Assert -- XCTAssertEqual(SentrySDKInternal.currentHub().trimmedInstalledIntegrationNames().first, "Metrics") } + + func testAddMetric_whenMetricAdded_shouldAddToBatcher() throws { + // -- Arrange -- + try givenSdkWithHub() + let client = try XCTUnwrap(SentrySDKInternal.currentHub().getClient() as? TestClient, "Hub Client is not a `TestClient`") + + let integration = try getSut() + + let scope = Scope() + let metric = SentryMetric( + timestamp: Date(), + traceId: SentryId(), + name: "test.metric", + value: .counter(1), + unit: nil, + attributes: [:] + ) + + // -- Act -- + integration.addMetric(metric, scope: scope) + + // We can not rely on the SentrySDK.flush(), because we are using a test client which is not actually + // flushing integrations as of Dec 16, 2025. + // + // Calling uninstall will flush the data, allowing us to assert the client invocations + integration.uninstall() + + // -- Assert -- + let capturedMetrics = try XCTUnwrap(client.captureMetricsDataInvocations.first) + XCTAssertEqual(capturedMetrics.count.intValue, 1, "Should capture 1 metric") + XCTAssertFalse(capturedMetrics.data.isEmpty, "Captured metrics data should not be empty") + + // Assert no further invocations + XCTAssertEqual(client.captureMetricsDataInvocations.count, 1, "Metrics should be captured") + } + + func testUninstall_whenMetricsExist_shouldFlushMetrics() throws { + // -- Arrange -- + try givenSdkWithHub() + let client = try XCTUnwrap(SentrySDKInternal.currentHub().getClient() as? TestClient, "Hub Client is not a `TestClient`") + + let integration = try getSut() + + let scope = Scope() + let metric = SentryMetric( + timestamp: Date(), + traceId: SentryId(), + name: "test.metric", + value: .counter(1), + unit: nil, + attributes: [:] + ) + + integration.addMetric(metric, scope: scope) + + // -- Act -- + integration.uninstall() + + // -- Assert -- + let capturedMetrics = try XCTUnwrap(client.captureMetricsDataInvocations.first) + XCTAssertEqual(capturedMetrics.count.intValue, 1, "Should capture 1 metric") + XCTAssertFalse(capturedMetrics.data.isEmpty, "Captured metrics data should not be empty") + + // Assert no further invocations + XCTAssertEqual(client.captureMetricsDataInvocations.count, 1, "Uninstall should flush metrics") + } + + func testAddMetric_whenNoClientAvailable_shouldDropMetricsSilently() throws { + // -- Arrange -- + try givenSdkWithHub() + let integration = try getSut() + + // Create a new hub without a client to simulate no client scenario + let hubWithoutClient = SentryHubInternal( + client: nil, + andScope: Scope(), + andCrashWrapper: TestSentryCrashWrapper(processInfoWrapper: ProcessInfo.processInfo), + andDispatchQueue: SentryDispatchQueueWrapper() + ) + let originalHub = SentrySDKInternal.currentHub() + SentrySDKInternal.setCurrentHub(hubWithoutClient) + defer { + // Restore original hub for cleanup + SentrySDKInternal.setCurrentHub(originalHub) + } + + let scope = Scope() + let metric = SentryMetric( + timestamp: Date(), + traceId: SentryId(), + name: "test.metric", + value: .counter(1), + unit: nil, + attributes: [:] + ) + + // -- Act -- + integration.addMetric(metric, scope: scope) + integration.uninstall() + + // -- Assert -- + // Should not crash and metrics should be dropped silently + // The callback should handle nil client gracefully (verified by no crash) + } + + func testName_shouldReturnCorrectName() { + // -- Act & Assert -- + XCTAssertEqual(SentryMetricsIntegration.name, "SentryMetricsIntegration") + } // MARK: - Helpers private func startSDK(isEnabled: Bool, configure: ((Options) -> Void)? = nil) { SentrySDK.start { - $0.dsn = TestConstants.dsnForTestCase(type: MetricsIntegrationTests.self) + $0.dsn = TestConstants.dsnForTestCase(type: Self.self) $0.removeAllIntegrations() $0.experimental.enableMetrics = isEnabled @@ -40,4 +155,34 @@ class MetricsIntegrationTests: XCTestCase { configure?($0) } } + + private func givenSdkWithHub() throws { + let options = Options() + options.dsn = TestConstants.dsnForTestCase(type: Self.self) + options.removeAllIntegrations() + + options.experimental.enableMetrics = true + + let client = TestClient(options: options) + let hub = SentryHubInternal( + client: client, + andScope: Scope(), + andCrashWrapper: TestSentryCrashWrapper(processInfoWrapper: ProcessInfo.processInfo), + andDispatchQueue: SentryDispatchQueueWrapper() + ) + + SentrySDK.setStart(with: options) + SentrySDKInternal.setCurrentHub(hub) + + // Manually install the MetricsIntegration since we're not using SentrySDK.start() + let dependencies = SentryDependencyContainer.sharedInstance() + let integration = try XCTUnwrap(SentryMetricsIntegration(with: options, dependencies: dependencies) as? SentryIntegrationProtocol) + hub.addInstalledIntegration(integration, name: SentryMetricsIntegration.name) + + hub.startSession() + } + + private func getSut() throws -> SentryMetricsIntegration { + return try XCTUnwrap(SentrySDKInternal.currentHub().getInstalledIntegration(SentryMetricsIntegration.self) as? SentryMetricsIntegration) + } } diff --git a/Tests/SentryTests/Networking/SentryDataCategoryMapperTests.swift b/Tests/SentryTests/Networking/SentryDataCategoryMapperTests.swift index b6f4323ede..f99dcde940 100644 --- a/Tests/SentryTests/Networking/SentryDataCategoryMapperTests.swift +++ b/Tests/SentryTests/Networking/SentryDataCategoryMapperTests.swift @@ -32,9 +32,10 @@ class SentryDataCategoryMapperTests: XCTestCase { XCTAssertEqual(sentryDataCategoryForNSUInteger(11), .span) XCTAssertEqual(sentryDataCategoryForNSUInteger(12), .feedback) XCTAssertEqual(sentryDataCategoryForNSUInteger(13), .logItem) - XCTAssertEqual(sentryDataCategoryForNSUInteger(14), .unknown) + XCTAssertEqual(sentryDataCategoryForNSUInteger(14), .traceMetric) + XCTAssertEqual(sentryDataCategoryForNSUInteger(15), .unknown) - XCTAssertEqual(.unknown, sentryDataCategoryForNSUInteger(14), "Failed to map unknown category number to case .unknown") + XCTAssertEqual(.unknown, sentryDataCategoryForNSUInteger(15), "Failed to map unknown category number to case .unknown") } func testMapStringToCategory() { diff --git a/Tests/SentryTests/Protocol/SentryMetricTests.swift b/Tests/SentryTests/Protocol/SentryMetricTests.swift new file mode 100644 index 0000000000..1e3f37050b --- /dev/null +++ b/Tests/SentryTests/Protocol/SentryMetricTests.swift @@ -0,0 +1,128 @@ +@_spi(Private) @testable import Sentry +@_spi(Private) import SentryTestUtils +import XCTest + +final class SentryMetricTests: XCTestCase { + + private let testTimestamp = Date(timeIntervalSince1970: 1_234_567_890.987654) + private let testTraceId = SentryId(uuidString: "550e8400e29b41d4a716446655440000") + private let testSpanId = SpanId(value: "b0e6f15b45c36b12") + + // MARK: - Initializer Tests + + func testInit_shouldInitializeCorrectly() { + // -- Act -- + let metric = SentryMetric( + timestamp: testTimestamp, + traceId: testTraceId, + name: "api.requests", + value: .counter(1), + unit: "unit", + attributes: [:] + ) + + // -- Assert -- + XCTAssertEqual(metric.timestamp, testTimestamp) + XCTAssertEqual(metric.traceId, testTraceId) + XCTAssertEqual(metric.name, "api.requests") + XCTAssertEqual(metric.value, .counter(1)) + XCTAssertEqual(metric.unit, "unit") + XCTAssertEqual(metric.attributes.count, 0) + } + + // MARK: - Encoding Tests + + func testEncode_whenCounterMetric_shouldEncodeCorrectly() throws { + // -- Arrange -- + let metric = SentryMetric( + timestamp: testTimestamp, + traceId: testTraceId, + name: "api.requests", + value: .counter(1), + unit: nil, + attributes: [ + "endpoint": .init(string: "/api/users"), + "method": .init(string: "GET"), + "status_code": .init(integer: 200) + ] + ) + + // -- Act -- + let data = try encodeToJSONData(data: metric) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + // -- Assert -- + XCTAssertEqual(json["timestamp"] as? TimeInterval, 1_234_567_890.987654) + XCTAssertEqual(json["trace_id"] as? String, "550e8400e29b41d4a716446655440000") + XCTAssertNil(json["span_id"]) + XCTAssertEqual(json["name"] as? String, "api.requests") + XCTAssertEqual(json["value"] as? Int, 1) + XCTAssertEqual(json["type"] as? String, "counter") + XCTAssertNil(json["unit"]) + + let encodedAttributes = try XCTUnwrap(json["attributes"] as? [String: [String: Any]]) + XCTAssertEqual(encodedAttributes["endpoint"]?["type"] as? String, "string") + XCTAssertEqual(encodedAttributes["endpoint"]?["value"] as? String, "/api/users") + XCTAssertEqual(encodedAttributes["status_code"]?["type"] as? String, "integer") + XCTAssertEqual(encodedAttributes["status_code"]?["value"] as? Int, 200) + } + + func testEncode_whenDistributionMetric_shouldEncodeCorrectly() throws { + // -- Arrange -- + let metric = SentryMetric( + timestamp: testTimestamp, + traceId: testTraceId, + name: "api.response_time", + value: .distribution(125.5), + unit: "millisecond", + attributes: [:] + ) + + // -- Act -- + let data = try encodeToJSONData(data: metric) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + // -- Assert -- + XCTAssertEqual(json["name"] as? String, "api.response_time") + let value = try XCTUnwrap(json["value"] as? Double) + XCTAssertEqual(value, 125.5, accuracy: 0.001) + XCTAssertEqual(json["type"] as? String, "distribution") + XCTAssertEqual(json["unit"] as? String, "millisecond") + XCTAssertNil(json["span_id"]) + } + + func testEncode_whenGaugeMetric_shouldEncodeCorrectly() throws { + // -- Arrange -- + let metric = SentryMetric( + timestamp: testTimestamp, + traceId: testTraceId, + name: "db.connection_pool.active", + value: .gauge(42.0), + unit: "connection", + attributes: [ + "pool_name": .init(string: "main_db"), + "max_size": .init(integer: 100) + ] + ) + + // -- Act -- + let data = try encodeToJSONData(data: metric) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + // -- Assert -- + XCTAssertEqual(json["name"] as? String, "db.connection_pool.active") + let value = try XCTUnwrap(json["value"] as? Double) + XCTAssertEqual(value, 42.0, accuracy: 0.001) + XCTAssertEqual(json["type"] as? String, "gauge") + XCTAssertEqual(json["unit"] as? String, "connection") + } + + // MARK: - Helper Methods + + /// Encodes a Metric to JSON Data + private func encodeToJSONData(data: SentryMetric) throws -> Data { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .secondsSince1970 + return try encoder.encode(data) + } +} diff --git a/Tests/SentryTests/Protocol/SentryMetricValueTests.swift b/Tests/SentryTests/Protocol/SentryMetricValueTests.swift new file mode 100644 index 0000000000..0b5d30d199 --- /dev/null +++ b/Tests/SentryTests/Protocol/SentryMetricValueTests.swift @@ -0,0 +1,261 @@ +@_spi(Private) @testable import Sentry +import XCTest + +final class SentryMetricValueTests: XCTestCase { + + // MARK: - Encoding Tests + + func testEncode_whenCounter_shouldEncodeCorrectly() throws { + // -- Arrange -- + let metricValue = SentryMetricValue.counter(42) + + // -- Act -- + let data = try JSONEncoder().encode(metricValue) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + // -- Assert -- + XCTAssertEqual(json["type"] as? String, "counter") + XCTAssertEqual(json["value"] as? Int64, 42) + } + + func testEncode_whenCounterWithLargeValue_shouldEncodeAsInt64() throws { + // -- Arrange -- + // Use a value larger than Int64.max (9,223,372,036,854,775,807) but within UInt64 range + // to verify truncation behavior + let largeValue: UInt = 10_000_000_000_000_000_000 // 10 quintillion + let metricValue = SentryMetricValue.counter(largeValue) + + // -- Act -- + let data = try JSONEncoder().encode(metricValue) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + // -- Assert -- + XCTAssertEqual(json["type"] as? String, "counter") + // Verify truncation: Int64(truncatingIfNeeded:) wraps around when value exceeds Int64.max + XCTAssertEqual(json["value"] as? Int64, Int64(truncatingIfNeeded: largeValue)) + } + + func testEncode_whenDistribution_shouldEncodeCorrectly() throws { + // -- Arrange -- + let metricValue = SentryMetricValue.distribution(125.5) + + // -- Act -- + let data = try JSONEncoder().encode(metricValue) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + // -- Assert -- + XCTAssertEqual(json["type"] as? String, "distribution") + let value = try XCTUnwrap(json["value"] as? Double) + XCTAssertEqual(value, 125.5, accuracy: 0.001) + } + + func testEncode_whenGauge_shouldEncodeCorrectly() throws { + // -- Arrange -- + let metricValue = SentryMetricValue.gauge(42.0) + + // -- Act -- + let data = try JSONEncoder().encode(metricValue) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + // -- Assert -- + XCTAssertEqual(json["type"] as? String, "gauge") + let value = try XCTUnwrap(json["value"] as? Double) + XCTAssertEqual(value, 42.0, accuracy: 0.001) + } + + // MARK: - Equality Tests + + func testEquality_whenSameCounterValues_shouldBeEqual() { + // -- Arrange -- + let value1 = SentryMetricValue.counter(42) + let value2 = SentryMetricValue.counter(42) + + // -- Act & Assert -- + XCTAssertEqual(value1, value2) + } + + func testEquality_whenDifferentCounterValues_shouldNotBeEqual() { + // -- Arrange -- + let value1 = SentryMetricValue.counter(42) + let value2 = SentryMetricValue.counter(43) + + // -- Act & Assert -- + XCTAssertNotEqual(value1, value2) + } + + func testEquality_whenSameGaugeValues_shouldBeEqual() { + // -- Arrange -- + let value1 = SentryMetricValue.gauge(42.0) + let value2 = SentryMetricValue.gauge(42.0) + + // -- Act & Assert -- + XCTAssertEqual(value1, value2) + } + + func testEquality_whenDifferentGaugeValues_shouldNotBeEqual() { + // -- Arrange -- + let value1 = SentryMetricValue.gauge(42.0) + let value2 = SentryMetricValue.gauge(43.0) + + // -- Act & Assert -- + XCTAssertNotEqual(value1, value2) + } + + func testEquality_whenSameDistributionValues_shouldBeEqual() { + // -- Arrange -- + let value1 = SentryMetricValue.distribution(125.5) + let value2 = SentryMetricValue.distribution(125.5) + + // -- Act & Assert -- + XCTAssertEqual(value1, value2) + } + + func testEquality_whenDifferentTypes_shouldNotBeEqual() { + // -- Arrange -- + let counter = SentryMetricValue.counter(42) + let gauge = SentryMetricValue.gauge(42.0) + let distribution = SentryMetricValue.distribution(42.0) + + // -- Act & Assert -- + XCTAssertNotEqual(counter, gauge) + XCTAssertNotEqual(counter, distribution) + XCTAssertNotEqual(gauge, distribution) + } + + // MARK: - Hashable Tests + + func testHash_whenSameValues_shouldBeTreatedAsEqualInSet() { + // -- Arrange -- + let value1 = SentryMetricValue.counter(42) + let value2 = SentryMetricValue.counter(42) + + // -- Act -- + var set = Set() + set.insert(value1) + set.insert(value2) + + // -- Assert -- + XCTAssertEqual(set.count, 1, "Equal values should be treated as the same in a Set") + } + + func testHash_whenDifferentValues_shouldBeTreatedAsDifferentInSet() { + // -- Arrange -- + let value1 = SentryMetricValue.counter(42) + let value2 = SentryMetricValue.counter(43) + + // -- Act -- + var set = Set() + set.insert(value1) + set.insert(value2) + + // -- Assert -- + XCTAssertEqual(set.count, 2, "Different values should be treated as different in a Set") + } + + func testHash_whenDifferentTypes_shouldBeTreatedAsDifferentInSet() { + // -- Arrange -- + let counter = SentryMetricValue.counter(42) + let gauge = SentryMetricValue.gauge(42.0) + let distribution = SentryMetricValue.distribution(42.0) + + // -- Act -- + var set = Set() + set.insert(counter) + set.insert(gauge) + set.insert(distribution) + + // -- Assert -- + XCTAssertEqual(set.count, 3, "Different types should be treated as different in a Set") + } + + // MARK: - Edge Case Tests + + func testEncode_whenCounterZero_shouldEncodeCorrectly() throws { + // -- Arrange -- + let metricValue = SentryMetricValue.counter(0) + + // -- Act -- + let data = try JSONEncoder().encode(metricValue) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + // -- Assert -- + XCTAssertEqual(json["type"] as? String, "counter") + XCTAssertEqual(json["value"] as? Int64, 0) + } + + func testEncode_whenGaugeZero_shouldEncodeCorrectly() throws { + // -- Arrange -- + let metricValue = SentryMetricValue.gauge(0.0) + + // -- Act -- + let data = try JSONEncoder().encode(metricValue) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + // -- Assert -- + XCTAssertEqual(json["type"] as? String, "gauge") + XCTAssertEqual(json["value"] as? Double, 0.0) + } + + func testEncode_whenDistributionZero_shouldEncodeCorrectly() throws { + // -- Arrange -- + let metricValue = SentryMetricValue.distribution(0.0) + + // -- Act -- + let data = try JSONEncoder().encode(metricValue) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + // -- Assert -- + XCTAssertEqual(json["type"] as? String, "distribution") + XCTAssertEqual(json["value"] as? Double, 0.0) + } + + func testEncode_whenGaugeNegative_shouldEncodeCorrectly() throws { + // -- Arrange -- + let metricValue = SentryMetricValue.gauge(-42.5) + + // -- Act -- + let data = try JSONEncoder().encode(metricValue) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + // -- Assert -- + XCTAssertEqual(json["type"] as? String, "gauge") + let value = try XCTUnwrap(json["value"] as? Double) + XCTAssertEqual(value, -42.5, accuracy: 0.001) + } + + func testEncode_whenDistributionNegative_shouldEncodeCorrectly() throws { + // -- Arrange -- + let metricValue = SentryMetricValue.distribution(-125.5) + + // -- Act -- + let data = try JSONEncoder().encode(metricValue) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + // -- Assert -- + XCTAssertEqual(json["type"] as? String, "distribution") + let value = try XCTUnwrap(json["value"] as? Double) + XCTAssertEqual(value, -125.5, accuracy: 0.001) + } + + func testEncode_whenGaugeInfinity_shouldThrowError() { + // -- Arrange -- + let metricValue = SentryMetricValue.gauge(Double.infinity) + + // -- Act & Assert -- + XCTAssertThrowsError(try JSONEncoder().encode(metricValue)) { error in + // JSONEncoder cannot encode Double.infinity, so encoding should fail + XCTAssertTrue(error is EncodingError, "Encoding should throw an EncodingError for infinity") + } + } + + func testEncode_whenDistributionNaN_shouldThrowError() { + // -- Arrange -- + let metricValue = SentryMetricValue.distribution(Double.nan) + + // -- Act & Assert -- + XCTAssertThrowsError(try JSONEncoder().encode(metricValue)) { error in + // JSONEncoder cannot encode Double.nan, so encoding should fail + XCTAssertTrue(error is EncodingError, "Encoding should throw an EncodingError for NaN") + } + } +} diff --git a/Tests/SentryTests/SentryClientTests.swift b/Tests/SentryTests/SentryClientTests.swift index 06876f5626..875af53402 100644 --- a/Tests/SentryTests/SentryClientTests.swift +++ b/Tests/SentryTests/SentryClientTests.swift @@ -2470,6 +2470,31 @@ class SentryClientTests: XCTestCase { XCTAssertEqual(testBatcher.captureLogsInvocations.count, 1) } + func testCaptureMetricsData_whenCalled_shouldCreateEnvelopeWithCorrectItem() throws { + // -- Arrange -- + let testData = Data("test metrics data".utf8) + let itemCount = NSNumber(value: 5) + let sut = fixture.getSut() + + // -- Act -- + sut.captureMetricsData(testData, with: itemCount) + + // -- Assert -- + XCTAssertEqual(fixture.transport.sentEnvelopes.count, 1, "Should send exactly one envelope") + + let envelope = try XCTUnwrap(fixture.transport.sentEnvelopes.first) + XCTAssertEqual(envelope.items.count, 1, "Envelope should contain exactly one item") + + let envelopeItem = try XCTUnwrap(envelope.items.first) + XCTAssertEqual(envelopeItem.header.type, SentryEnvelopeItemTypes.traceMetric, "Envelope item type should be trace_metric") + XCTAssertEqual(envelopeItem.header.contentType, "application/vnd.sentry.items.trace-metric+json", "Content type should match expected value") + XCTAssertEqual(envelopeItem.header.itemCount, itemCount, "Item count should match provided value") + XCTAssertEqual(envelopeItem.data, testData, "Envelope item data should match provided data") + + // Verify envelope header is empty (as per implementation) + XCTAssertNil(envelope.header.eventId, "Envelope header eventId should be nil") + } + func testCaptureSentryWrappedException() throws { #if os(macOS) let exception = NSException(name: NSExceptionName("exception"), reason: "reason", userInfo: nil) diff --git a/Tests/SentryTests/SentryLogBatcherTests.swift b/Tests/SentryTests/SentryLogBatcherTests.swift index dc77c1ae1a..efb020a715 100644 --- a/Tests/SentryTests/SentryLogBatcherTests.swift +++ b/Tests/SentryTests/SentryLogBatcherTests.swift @@ -590,27 +590,27 @@ final class SentryLogBatcherTests: XCTestCase { XCTAssertEqual(attributes["integer-attribute"]?.value as? Int, 5) XCTAssertEqual(attributes["integer-attribute"]?.type, "integer") } - + 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 - + XCTAssertEqual(attributes["log-attribute"]?.value as? Bool, false) XCTAssertEqual(attributes["log-attribute"]?.type, "boolean") } - + // MARK: - Replay Attributes Tests #if canImport(UIKit) && !SENTRY_NO_UIKIT diff --git a/sdk_api.json b/sdk_api.json index d6cfe28807..83f664888b 100644 --- a/sdk_api.json +++ b/sdk_api.json @@ -36689,6 +36689,877 @@ } ] }, + { + "kind": "TypeDecl", + "name": "SentryMetric", + "printedName": "SentryMetric", + "children": [ + { + "kind": "TypeAlias", + "name": "Value", + "printedName": "Value", + "children": [ + { + "kind": "TypeNominal", + "name": "SentryMetricValue", + "printedName": "Sentry.SentryMetricValue", + "usr": "s:6Sentry0A11MetricValueO" + } + ], + "declKind": "TypeAlias", + "usr": "s:6Sentry0A6MetricV5Valuea", + "mangledName": "$s6Sentry0A6MetricV5Valuea", + "moduleName": "Sentry" + }, + { + "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:6Sentry0A6MetricV9Attributea", + "mangledName": "$s6Sentry0A6MetricV9Attributea", + "moduleName": "Sentry" + }, + { + "kind": "Var", + "name": "timestamp", + "printedName": "timestamp", + "children": [ + { + "kind": "TypeNominal", + "name": "Date", + "printedName": "Foundation.Date", + "usr": "s:10Foundation4DateV" + } + ], + "declKind": "Var", + "usr": "s:6Sentry0A6MetricV9timestamp10Foundation4DateVvp", + "mangledName": "$s6Sentry0A6MetricV9timestamp10Foundation4DateVvp", + "moduleName": "Sentry", + "declAttributes": [ + "HasStorage" + ], + "hasStorage": true, + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "Date", + "printedName": "Foundation.Date", + "usr": "s:10Foundation4DateV" + } + ], + "declKind": "Accessor", + "usr": "s:6Sentry0A6MetricV9timestamp10Foundation4DateVvg", + "mangledName": "$s6Sentry0A6MetricV9timestamp10Foundation4DateVvg", + "moduleName": "Sentry", + "implicit": true, + "accessorKind": "get" + }, + { + "kind": "Accessor", + "name": "Set", + "printedName": "Set()", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + }, + { + "kind": "TypeNominal", + "name": "Date", + "printedName": "Foundation.Date", + "usr": "s:10Foundation4DateV" + } + ], + "declKind": "Accessor", + "usr": "s:6Sentry0A6MetricV9timestamp10Foundation4DateVvs", + "mangledName": "$s6Sentry0A6MetricV9timestamp10Foundation4DateVvs", + "moduleName": "Sentry", + "implicit": true, + "accessorKind": "set" + } + ] + }, + { + "kind": "Var", + "name": "name", + "printedName": "name", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "declKind": "Var", + "usr": "s:6Sentry0A6MetricV4nameSSvp", + "mangledName": "$s6Sentry0A6MetricV4nameSSvp", + "moduleName": "Sentry", + "declAttributes": [ + "HasStorage" + ], + "hasStorage": true, + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "declKind": "Accessor", + "usr": "s:6Sentry0A6MetricV4nameSSvg", + "mangledName": "$s6Sentry0A6MetricV4nameSSvg", + "moduleName": "Sentry", + "implicit": true, + "accessorKind": "get" + }, + { + "kind": "Accessor", + "name": "Set", + "printedName": "Set()", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + }, + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "declKind": "Accessor", + "usr": "s:6Sentry0A6MetricV4nameSSvs", + "mangledName": "$s6Sentry0A6MetricV4nameSSvs", + "moduleName": "Sentry", + "implicit": true, + "accessorKind": "set" + } + ] + }, + { + "kind": "Var", + "name": "traceId", + "printedName": "traceId", + "children": [ + { + "kind": "TypeNominal", + "name": "SentryId", + "printedName": "Sentry.SentryId", + "usr": "c:objc(cs)SentryId" + } + ], + "declKind": "Var", + "usr": "s:6Sentry0A6MetricV7traceIdSo0aD0Cvp", + "mangledName": "$s6Sentry0A6MetricV7traceIdSo0aD0Cvp", + "moduleName": "Sentry", + "declAttributes": [ + "HasStorage" + ], + "hasStorage": true, + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "SentryId", + "printedName": "Sentry.SentryId", + "usr": "c:objc(cs)SentryId" + } + ], + "declKind": "Accessor", + "usr": "s:6Sentry0A6MetricV7traceIdSo0aD0Cvg", + "mangledName": "$s6Sentry0A6MetricV7traceIdSo0aD0Cvg", + "moduleName": "Sentry", + "implicit": true, + "accessorKind": "get" + }, + { + "kind": "Accessor", + "name": "Set", + "printedName": "Set()", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + }, + { + "kind": "TypeNominal", + "name": "SentryId", + "printedName": "Sentry.SentryId", + "usr": "c:objc(cs)SentryId" + } + ], + "declKind": "Accessor", + "usr": "s:6Sentry0A6MetricV7traceIdSo0aD0Cvs", + "mangledName": "$s6Sentry0A6MetricV7traceIdSo0aD0Cvs", + "moduleName": "Sentry", + "implicit": true, + "accessorKind": "set" + } + ] + }, + { + "kind": "Var", + "name": "value", + "printedName": "value", + "children": [ + { + "kind": "TypeNominal", + "name": "SentryMetricValue", + "printedName": "Sentry.SentryMetricValue", + "usr": "s:6Sentry0A11MetricValueO" + } + ], + "declKind": "Var", + "usr": "s:6Sentry0A6MetricV5valueAA0aB5ValueOvp", + "mangledName": "$s6Sentry0A6MetricV5valueAA0aB5ValueOvp", + "moduleName": "Sentry", + "declAttributes": [ + "HasStorage" + ], + "hasStorage": true, + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "SentryMetricValue", + "printedName": "Sentry.SentryMetricValue", + "usr": "s:6Sentry0A11MetricValueO" + } + ], + "declKind": "Accessor", + "usr": "s:6Sentry0A6MetricV5valueAA0aB5ValueOvg", + "mangledName": "$s6Sentry0A6MetricV5valueAA0aB5ValueOvg", + "moduleName": "Sentry", + "implicit": true, + "accessorKind": "get" + }, + { + "kind": "Accessor", + "name": "Set", + "printedName": "Set()", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + }, + { + "kind": "TypeNominal", + "name": "SentryMetricValue", + "printedName": "Sentry.SentryMetricValue", + "usr": "s:6Sentry0A11MetricValueO" + } + ], + "declKind": "Accessor", + "usr": "s:6Sentry0A6MetricV5valueAA0aB5ValueOvs", + "mangledName": "$s6Sentry0A6MetricV5valueAA0aB5ValueOvs", + "moduleName": "Sentry", + "implicit": true, + "accessorKind": "set" + } + ] + }, + { + "kind": "Var", + "name": "unit", + "printedName": "unit", + "children": [ + { + "kind": "TypeNominal", + "name": "Optional", + "printedName": "Swift.String?", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "usr": "s:Sq" + } + ], + "declKind": "Var", + "usr": "s:6Sentry0A6MetricV4unitSSSgvp", + "mangledName": "$s6Sentry0A6MetricV4unitSSSgvp", + "moduleName": "Sentry", + "declAttributes": [ + "HasInitialValue", + "HasStorage" + ], + "hasStorage": true, + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "Optional", + "printedName": "Swift.String?", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "usr": "s:Sq" + } + ], + "declKind": "Accessor", + "usr": "s:6Sentry0A6MetricV4unitSSSgvg", + "mangledName": "$s6Sentry0A6MetricV4unitSSSgvg", + "moduleName": "Sentry", + "implicit": true, + "accessorKind": "get" + }, + { + "kind": "Accessor", + "name": "Set", + "printedName": "Set()", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + }, + { + "kind": "TypeNominal", + "name": "Optional", + "printedName": "Swift.String?", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "usr": "s:Sq" + } + ], + "declKind": "Accessor", + "usr": "s:6Sentry0A6MetricV4unitSSSgvs", + "mangledName": "$s6Sentry0A6MetricV4unitSSSgvs", + "moduleName": "Sentry", + "implicit": true, + "accessorKind": "set" + } + ] + }, + { + "kind": "Var", + "name": "attributes", + "printedName": "attributes", + "children": [ + { + "kind": "TypeNominal", + "name": "Dictionary", + "printedName": "[Swift.String : Sentry.SentryAttribute]", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + }, + { + "kind": "TypeNominal", + "name": "SentryAttribute", + "printedName": "Sentry.SentryAttribute", + "usr": "c:@M@Sentry@objc(cs)SentryAttribute" + } + ], + "usr": "s:SD" + } + ], + "declKind": "Var", + "usr": "s:6Sentry0A6MetricV10attributesSDySSAA0A9AttributeCGvp", + "mangledName": "$s6Sentry0A6MetricV10attributesSDySSAA0A9AttributeCGvp", + "moduleName": "Sentry", + "declAttributes": [ + "HasStorage" + ], + "hasStorage": true, + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "Dictionary", + "printedName": "[Swift.String : Sentry.SentryAttribute]", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + }, + { + "kind": "TypeNominal", + "name": "SentryAttribute", + "printedName": "Sentry.SentryAttribute", + "usr": "c:@M@Sentry@objc(cs)SentryAttribute" + } + ], + "usr": "s:SD" + } + ], + "declKind": "Accessor", + "usr": "s:6Sentry0A6MetricV10attributesSDySSAA0A9AttributeCGvg", + "mangledName": "$s6Sentry0A6MetricV10attributesSDySSAA0A9AttributeCGvg", + "moduleName": "Sentry", + "implicit": true, + "accessorKind": "get" + }, + { + "kind": "Accessor", + "name": "Set", + "printedName": "Set()", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + }, + { + "kind": "TypeNominal", + "name": "Dictionary", + "printedName": "[Swift.String : Sentry.SentryAttribute]", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + }, + { + "kind": "TypeNominal", + "name": "SentryAttribute", + "printedName": "Sentry.SentryAttribute", + "usr": "c:@M@Sentry@objc(cs)SentryAttribute" + } + ], + "usr": "s:SD" + } + ], + "declKind": "Accessor", + "usr": "s:6Sentry0A6MetricV10attributesSDySSAA0A9AttributeCGvs", + "mangledName": "$s6Sentry0A6MetricV10attributesSDySSAA0A9AttributeCGvs", + "moduleName": "Sentry", + "implicit": true, + "accessorKind": "set" + } + ] + }, + { + "kind": "Function", + "name": "encode", + "printedName": "encode(to:)", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + }, + { + "kind": "TypeNominal", + "name": "Encoder", + "printedName": "any Swift.Encoder", + "usr": "s:s7EncoderP" + } + ], + "declKind": "Func", + "usr": "s:6Sentry0A6MetricV6encode2toys7Encoder_p_tKF", + "mangledName": "$s6Sentry0A6MetricV6encode2toys7Encoder_p_tKF", + "moduleName": "Sentry", + "isFromExtension": true, + "throwing": true, + "funcSelfKind": "NonMutating" + } + ], + "declKind": "Struct", + "usr": "s:6Sentry0A6MetricV", + "mangledName": "$s6Sentry0A6MetricV", + "moduleName": "Sentry", + "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": "Encodable", + "printedName": "Encodable", + "usr": "s:SE", + "mangledName": "$sSE" + } + ] + }, + { + "kind": "TypeDecl", + "name": "SentryMetricValue", + "printedName": "SentryMetricValue", + "children": [ + { + "kind": "Var", + "name": "counter", + "printedName": "counter", + "children": [ + { + "kind": "TypeFunc", + "name": "Function", + "printedName": "(Sentry.SentryMetricValue.Type) -> (Swift.UInt) -> Sentry.SentryMetricValue", + "children": [ + { + "kind": "TypeFunc", + "name": "Function", + "printedName": "(Swift.UInt) -> Sentry.SentryMetricValue", + "children": [ + { + "kind": "TypeNominal", + "name": "SentryMetricValue", + "printedName": "Sentry.SentryMetricValue", + "usr": "s:6Sentry0A11MetricValueO" + }, + { + "kind": "TypeNominal", + "name": "UInt", + "printedName": "Swift.UInt", + "usr": "s:Su" + } + ] + }, + { + "kind": "TypeNominal", + "name": "Metatype", + "printedName": "Sentry.SentryMetricValue.Type", + "children": [ + { + "kind": "TypeNominal", + "name": "SentryMetricValue", + "printedName": "Sentry.SentryMetricValue", + "usr": "s:6Sentry0A11MetricValueO" + } + ] + } + ] + } + ], + "declKind": "EnumElement", + "usr": "s:6Sentry0A11MetricValueO7counteryACSucACmF", + "mangledName": "$s6Sentry0A11MetricValueO7counteryACSucACmF", + "moduleName": "Sentry" + }, + { + "kind": "Var", + "name": "gauge", + "printedName": "gauge", + "children": [ + { + "kind": "TypeFunc", + "name": "Function", + "printedName": "(Sentry.SentryMetricValue.Type) -> (Swift.Double) -> Sentry.SentryMetricValue", + "children": [ + { + "kind": "TypeFunc", + "name": "Function", + "printedName": "(Swift.Double) -> Sentry.SentryMetricValue", + "children": [ + { + "kind": "TypeNominal", + "name": "SentryMetricValue", + "printedName": "Sentry.SentryMetricValue", + "usr": "s:6Sentry0A11MetricValueO" + }, + { + "kind": "TypeNominal", + "name": "Double", + "printedName": "Swift.Double", + "usr": "s:Sd" + } + ] + }, + { + "kind": "TypeNominal", + "name": "Metatype", + "printedName": "Sentry.SentryMetricValue.Type", + "children": [ + { + "kind": "TypeNominal", + "name": "SentryMetricValue", + "printedName": "Sentry.SentryMetricValue", + "usr": "s:6Sentry0A11MetricValueO" + } + ] + } + ] + } + ], + "declKind": "EnumElement", + "usr": "s:6Sentry0A11MetricValueO5gaugeyACSdcACmF", + "mangledName": "$s6Sentry0A11MetricValueO5gaugeyACSdcACmF", + "moduleName": "Sentry" + }, + { + "kind": "Var", + "name": "distribution", + "printedName": "distribution", + "children": [ + { + "kind": "TypeFunc", + "name": "Function", + "printedName": "(Sentry.SentryMetricValue.Type) -> (Swift.Double) -> Sentry.SentryMetricValue", + "children": [ + { + "kind": "TypeFunc", + "name": "Function", + "printedName": "(Swift.Double) -> Sentry.SentryMetricValue", + "children": [ + { + "kind": "TypeNominal", + "name": "SentryMetricValue", + "printedName": "Sentry.SentryMetricValue", + "usr": "s:6Sentry0A11MetricValueO" + }, + { + "kind": "TypeNominal", + "name": "Double", + "printedName": "Swift.Double", + "usr": "s:Sd" + } + ] + }, + { + "kind": "TypeNominal", + "name": "Metatype", + "printedName": "Sentry.SentryMetricValue.Type", + "children": [ + { + "kind": "TypeNominal", + "name": "SentryMetricValue", + "printedName": "Sentry.SentryMetricValue", + "usr": "s:6Sentry0A11MetricValueO" + } + ] + } + ] + } + ], + "declKind": "EnumElement", + "usr": "s:6Sentry0A11MetricValueO12distributionyACSdcACmF", + "mangledName": "$s6Sentry0A11MetricValueO12distributionyACSdcACmF", + "moduleName": "Sentry" + }, + { + "kind": "Function", + "name": "==", + "printedName": "==(_:_:)", + "children": [ + { + "kind": "TypeNominal", + "name": "Bool", + "printedName": "Swift.Bool", + "usr": "s:Sb" + }, + { + "kind": "TypeNominal", + "name": "SentryMetricValue", + "printedName": "Sentry.SentryMetricValue", + "usr": "s:6Sentry0A11MetricValueO" + }, + { + "kind": "TypeNominal", + "name": "SentryMetricValue", + "printedName": "Sentry.SentryMetricValue", + "usr": "s:6Sentry0A11MetricValueO" + } + ], + "declKind": "Func", + "usr": "s:6Sentry0A11MetricValueO2eeoiySbAC_ACtFZ", + "mangledName": "$s6Sentry0A11MetricValueO2eeoiySbAC_ACtFZ", + "moduleName": "Sentry", + "static": true, + "funcSelfKind": "NonMutating" + }, + { + "kind": "Function", + "name": "hash", + "printedName": "hash(into:)", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + }, + { + "kind": "TypeNominal", + "name": "Hasher", + "printedName": "Swift.Hasher", + "paramValueOwnership": "InOut", + "usr": "s:s6HasherV" + } + ], + "declKind": "Func", + "usr": "s:6Sentry0A11MetricValueO4hash4intoys6HasherVz_tF", + "mangledName": "$s6Sentry0A11MetricValueO4hash4intoys6HasherVz_tF", + "moduleName": "Sentry", + "funcSelfKind": "NonMutating" + }, + { + "kind": "Var", + "name": "hashValue", + "printedName": "hashValue", + "children": [ + { + "kind": "TypeNominal", + "name": "Int", + "printedName": "Swift.Int", + "usr": "s:Si" + } + ], + "declKind": "Var", + "usr": "s:6Sentry0A11MetricValueO04hashC0Sivp", + "mangledName": "$s6Sentry0A11MetricValueO04hashC0Sivp", + "moduleName": "Sentry", + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "Int", + "printedName": "Swift.Int", + "usr": "s:Si" + } + ], + "declKind": "Accessor", + "usr": "s:6Sentry0A11MetricValueO04hashC0Sivg", + "mangledName": "$s6Sentry0A11MetricValueO04hashC0Sivg", + "moduleName": "Sentry", + "accessorKind": "get" + } + ] + }, + { + "kind": "Function", + "name": "encode", + "printedName": "encode(to:)", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + }, + { + "kind": "TypeNominal", + "name": "Encoder", + "printedName": "any Swift.Encoder", + "usr": "s:s7EncoderP" + } + ], + "declKind": "Func", + "usr": "s:6Sentry0A11MetricValueO6encode2toys7Encoder_p_tKF", + "mangledName": "$s6Sentry0A11MetricValueO6encode2toys7Encoder_p_tKF", + "moduleName": "Sentry", + "isFromExtension": true, + "throwing": true, + "funcSelfKind": "NonMutating" + } + ], + "declKind": "Enum", + "usr": "s:6Sentry0A11MetricValueO", + "mangledName": "$s6Sentry0A11MetricValueO", + "moduleName": "Sentry", + "conformances": [ + { + "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": "Copyable", + "printedName": "Copyable", + "usr": "s:s8CopyableP", + "mangledName": "$ss8CopyableP" + }, + { + "kind": "Conformance", + "name": "Escapable", + "printedName": "Escapable", + "usr": "s:s9EscapableP", + "mangledName": "$ss9EscapableP" + }, + { + "kind": "Conformance", + "name": "Encodable", + "printedName": "Encodable", + "usr": "s:SE", + "mangledName": "$sSE" + } + ] + }, { "kind": "TypeDecl", "name": "SentryLog", @@ -47387,6 +48258,167 @@ } ] }, + { + "kind": "Var", + "name": "beforeSendMetric", + "printedName": "beforeSendMetric", + "children": [ + { + "kind": "TypeNominal", + "name": "Optional", + "printedName": "((Sentry.SentryMetric) -> Sentry.SentryMetric?)?", + "children": [ + { + "kind": "TypeFunc", + "name": "Function", + "printedName": "(Sentry.SentryMetric) -> Sentry.SentryMetric?", + "children": [ + { + "kind": "TypeNominal", + "name": "Optional", + "printedName": "Sentry.SentryMetric?", + "children": [ + { + "kind": "TypeNominal", + "name": "SentryMetric", + "printedName": "Sentry.SentryMetric", + "usr": "s:6Sentry0A6MetricV" + } + ], + "usr": "s:Sq" + }, + { + "kind": "TypeNominal", + "name": "SentryMetric", + "printedName": "Sentry.SentryMetric", + "usr": "s:6Sentry0A6MetricV" + } + ] + } + ], + "usr": "s:Sq" + } + ], + "declKind": "Var", + "usr": "s:6Sentry0A19ExperimentalOptionsC16beforeSendMetricAA0aF0VSgAFcSgvp", + "mangledName": "$s6Sentry0A19ExperimentalOptionsC16beforeSendMetricAA0aF0VSgAFcSgvp", + "moduleName": "Sentry", + "declAttributes": [ + "HasInitialValue", + "Final", + "HasStorage" + ], + "hasStorage": true, + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "Optional", + "printedName": "((Sentry.SentryMetric) -> Sentry.SentryMetric?)?", + "children": [ + { + "kind": "TypeFunc", + "name": "Function", + "printedName": "(Sentry.SentryMetric) -> Sentry.SentryMetric?", + "children": [ + { + "kind": "TypeNominal", + "name": "Optional", + "printedName": "Sentry.SentryMetric?", + "children": [ + { + "kind": "TypeNominal", + "name": "SentryMetric", + "printedName": "Sentry.SentryMetric", + "usr": "s:6Sentry0A6MetricV" + } + ], + "usr": "s:Sq" + }, + { + "kind": "TypeNominal", + "name": "SentryMetric", + "printedName": "Sentry.SentryMetric", + "usr": "s:6Sentry0A6MetricV" + } + ] + } + ], + "usr": "s:Sq" + } + ], + "declKind": "Accessor", + "usr": "s:6Sentry0A19ExperimentalOptionsC16beforeSendMetricAA0aF0VSgAFcSgvg", + "mangledName": "$s6Sentry0A19ExperimentalOptionsC16beforeSendMetricAA0aF0VSgAFcSgvg", + "moduleName": "Sentry", + "implicit": true, + "declAttributes": [ + "Final" + ], + "accessorKind": "get" + }, + { + "kind": "Accessor", + "name": "Set", + "printedName": "Set()", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + }, + { + "kind": "TypeNominal", + "name": "Optional", + "printedName": "((Sentry.SentryMetric) -> Sentry.SentryMetric?)?", + "children": [ + { + "kind": "TypeFunc", + "name": "Function", + "printedName": "(Sentry.SentryMetric) -> Sentry.SentryMetric?", + "children": [ + { + "kind": "TypeNominal", + "name": "Optional", + "printedName": "Sentry.SentryMetric?", + "children": [ + { + "kind": "TypeNominal", + "name": "SentryMetric", + "printedName": "Sentry.SentryMetric", + "usr": "s:6Sentry0A6MetricV" + } + ], + "usr": "s:Sq" + }, + { + "kind": "TypeNominal", + "name": "SentryMetric", + "printedName": "Sentry.SentryMetric", + "usr": "s:6Sentry0A6MetricV" + } + ] + } + ], + "usr": "s:Sq" + } + ], + "declKind": "Accessor", + "usr": "s:6Sentry0A19ExperimentalOptionsC16beforeSendMetricAA0aF0VSgAFcSgvs", + "mangledName": "$s6Sentry0A19ExperimentalOptionsC16beforeSendMetricAA0aF0VSgAFcSgvs", + "moduleName": "Sentry", + "implicit": true, + "declAttributes": [ + "Final" + ], + "accessorKind": "set" + } + ] + }, { "kind": "Constructor", "name": "init",