diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index e0a64a086c..b2c7b768fd 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -1095,8 +1095,6 @@ D22743E729DEB953001A7EF9 /* UIApplicationSwizzlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61410166251A661D00E3C2D9 /* UIApplicationSwizzlerTests.swift */; }; D22743EB29DEC9E6001A7EF9 /* Casting+RUM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61411B0F24EC15AC0012EAB2 /* Casting+RUM.swift */; }; D22743EC29DEC9E6001A7EF9 /* Casting+RUM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61411B0F24EC15AC0012EAB2 /* Casting+RUM.swift */; }; - D22789362D64A0D7007E9DB0 /* UploadQualityMetric.swift in Sources */ = {isa = PBXBuildFile; fileRef = D22789352D64A0D3007E9DB0 /* UploadQualityMetric.swift */; }; - D22789372D64A0D7007E9DB0 /* UploadQualityMetric.swift in Sources */ = {isa = PBXBuildFile; fileRef = D22789352D64A0D3007E9DB0 /* UploadQualityMetric.swift */; }; D227A0A42C7622EA00C83324 /* BenchmarkProfiler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D227A0A32C7622EA00C83324 /* BenchmarkProfiler.swift */; }; D227A0A52C7622EA00C83324 /* BenchmarkProfiler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D227A0A32C7622EA00C83324 /* BenchmarkProfiler.swift */; }; D22F06D729DAFD500026CC3C /* FixedWidthInteger+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = D22F06D529DAFD500026CC3C /* FixedWidthInteger+Convenience.swift */; }; @@ -1150,6 +1148,8 @@ D23354FD2A42E32000AFCAE2 /* InternalExtended.swift in Sources */ = {isa = PBXBuildFile; fileRef = D23354FB2A42E32000AFCAE2 /* InternalExtended.swift */; }; D234613128B7713000055D4C /* FeatureContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D234613028B7712F00055D4C /* FeatureContextTests.swift */; }; D234613228B7713000055D4C /* FeatureContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D234613028B7712F00055D4C /* FeatureContextTests.swift */; }; + D2393EF82DB2555F006B3C75 /* UploadCycleMetric.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2393EF72DB2555F006B3C75 /* UploadCycleMetric.swift */; }; + D2393EF92DB2555F006B3C75 /* UploadCycleMetric.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2393EF72DB2555F006B3C75 /* UploadCycleMetric.swift */; }; D23F8E5229DDCD28001CFAE8 /* UIViewControllerHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F3CDA2251118FB00C816E5 /* UIViewControllerHandler.swift */; }; D23F8E5329DDCD28001CFAE8 /* RUMCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C3E63A24BF1A4B008053F2 /* RUMCommand.swift */; }; D23F8E5429DDCD28001CFAE8 /* ValuePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 611529A425E3DD51004F740E /* ValuePublisher.swift */; }; @@ -1447,6 +1447,10 @@ D29A9FDB29DDC6D1005C54A4 /* RUMEventFileOutputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FF282F24BC5E2D000B3D9B /* RUMEventFileOutputTests.swift */; }; D29CDD3228211A2200F7DAA5 /* TLVBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D29CDD3128211A2200F7DAA5 /* TLVBlock.swift */; }; D29CDD3328211A2200F7DAA5 /* TLVBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D29CDD3128211A2200F7DAA5 /* TLVBlock.swift */; }; + D2A133912DAFA8B200D84D3C /* MetricTelemetryAggregator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2A133902DAFA8B200D84D3C /* MetricTelemetryAggregator.swift */; }; + D2A133922DAFA8B200D84D3C /* MetricTelemetryAggregator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2A133902DAFA8B200D84D3C /* MetricTelemetryAggregator.swift */; }; + D2A133942DB1074000D84D3C /* MetricTelemetryAggregatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2A133932DB1074000D84D3C /* MetricTelemetryAggregatorTests.swift */; }; + D2A133952DB1074000D84D3C /* MetricTelemetryAggregatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2A133932DB1074000D84D3C /* MetricTelemetryAggregatorTests.swift */; }; D2A1EE23287740B500D28DFB /* ApplicationStatePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2A1EE22287740B500D28DFB /* ApplicationStatePublisher.swift */; }; D2A1EE24287740B500D28DFB /* ApplicationStatePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2A1EE22287740B500D28DFB /* ApplicationStatePublisher.swift */; }; D2A1EE32287DA51900D28DFB /* UserInfoPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2A1EE31287DA51900D28DFB /* UserInfoPublisher.swift */; }; @@ -3117,7 +3121,6 @@ D21C26ED28AFB65B005DD405 /* ErrorMessageReceiverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMessageReceiverTests.swift; sourceTree = ""; }; D22442C42CA301DA002E71E4 /* UIColor+SessionReplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+SessionReplay.swift"; sourceTree = ""; }; D224430C29E95D6600274EC7 /* CrashReportReceiverTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CrashReportReceiverTests.swift; sourceTree = ""; }; - D22789352D64A0D3007E9DB0 /* UploadQualityMetric.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadQualityMetric.swift; sourceTree = ""; }; D227A0A32C7622EA00C83324 /* BenchmarkProfiler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BenchmarkProfiler.swift; sourceTree = ""; }; D22C1F5B271484B400922024 /* LogEventMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogEventMapper.swift; sourceTree = ""; }; D22F06D529DAFD500026CC3C /* FixedWidthInteger+Convenience.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "FixedWidthInteger+Convenience.swift"; sourceTree = ""; }; @@ -3162,6 +3165,7 @@ D23354FB2A42E32000AFCAE2 /* InternalExtended.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternalExtended.swift; sourceTree = ""; }; D234613028B7712F00055D4C /* FeatureContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureContextTests.swift; sourceTree = ""; }; D236BE2729520FED00676E67 /* CrashReportReceiver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashReportReceiver.swift; sourceTree = ""; }; + D2393EF72DB2555F006B3C75 /* UploadCycleMetric.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadCycleMetric.swift; sourceTree = ""; }; D23F8E9929DDCD28001CFAE8 /* DatadogRUM.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = DatadogRUM.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D23F8ECD29DDCD38001CFAE8 /* DatadogRUMTests tvOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "DatadogRUMTests tvOS.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; D240684D27CE6C9E00C04F44 /* Example tvOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Example tvOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -3236,6 +3240,8 @@ D29A9FCB29DDBCC5005C54A4 /* DDTAssertValidRUMUUID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DDTAssertValidRUMUUID.swift; sourceTree = ""; }; D29CDD3128211A2200F7DAA5 /* TLVBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLVBlock.swift; sourceTree = ""; }; D29D5A4C273BF8B400A687C1 /* SwiftUIActionModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIActionModifier.swift; sourceTree = ""; }; + D2A133902DAFA8B200D84D3C /* MetricTelemetryAggregator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricTelemetryAggregator.swift; sourceTree = ""; }; + D2A133932DB1074000D84D3C /* MetricTelemetryAggregatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricTelemetryAggregatorTests.swift; sourceTree = ""; }; D2A1EE22287740B500D28DFB /* ApplicationStatePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationStatePublisher.swift; sourceTree = ""; }; D2A1EE31287DA51900D28DFB /* UserInfoPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserInfoPublisher.swift; sourceTree = ""; }; D2A1EE34287EB8DB00D28DFB /* ServerOffsetPublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerOffsetPublisherTests.swift; sourceTree = ""; }; @@ -5395,8 +5401,9 @@ 6174D6082BFDDD1E00EC7469 /* SDKMetrics */ = { isa = PBXGroup; children = ( - D2E6E8FA2D8039B200FF1398 /* BenchmarkURLSessionTaskDelegate.swift */, 614396712A67D74F00197326 /* BatchMetrics.swift */, + D2393EF72DB2555F006B3C75 /* UploadCycleMetric.swift */, + D2E6E8FA2D8039B200FF1398 /* BenchmarkURLSessionTaskDelegate.swift */, ); path = SDKMetrics; sourceTree = ""; @@ -5414,7 +5421,6 @@ children = ( 6174D60B2BFDDEDF00EC7469 /* SDKMetricFields.swift */, A7FA98CD2BA1A6930018D6B5 /* MethodCalledMetric.swift */, - D22789352D64A0D3007E9DB0 /* UploadQualityMetric.swift */, ); path = SDKMetrics; sourceTree = ""; @@ -5427,6 +5433,7 @@ 615E2B8D2D39444300D85243 /* ViewEndedController.swift */, 615E2B942D425F5600D85243 /* ViewEndedMetric.swift */, 11030D752D96EC5300732D5F /* ViewHitchesMetric.swift */, + D2A133902DAFA8B200D84D3C /* MetricTelemetryAggregator.swift */, ); path = SDKMetrics; sourceTree = ""; @@ -5436,6 +5443,7 @@ children = ( 6174D6192BFE449300EC7469 /* SessionEndedMetricTests.swift */, 61DCC8462C05CD0000CB59E5 /* SessionEndedMetricControllerTests.swift */, + D2A133932DB1074000D84D3C /* MetricTelemetryAggregatorTests.swift */, ); path = SDKMetrics; sourceTree = ""; @@ -8439,6 +8447,7 @@ 617699182A860D9D0030022B /* HTTPClient.swift in Sources */, D21C26C528A3B49C005DD405 /* FeatureStorage.swift in Sources */, 61133BD42423979B00786299 /* FileReader.swift in Sources */, + D2393EF82DB2555F006B3C75 /* UploadCycleMetric.swift in Sources */, D29294E0291D5ED100F8EFF9 /* ApplicationVersionPublisher.swift in Sources */, 61D3E0D9277B23F1008BE766 /* KronosNTPProtocol.swift in Sources */, 61D3E0DA277B23F1008BE766 /* KronosTimeFreeze.swift in Sources */, @@ -8942,7 +8951,6 @@ D2EBEE1F29BA160F00B15732 /* HTTPHeadersReader.swift in Sources */, D24EC3D92DD1F117007A7E8F /* SessionReplayCoreContext.swift in Sources */, E2AA55E72C32C6D9002FEF28 /* ApplicationNotifications.swift in Sources */, - D22789372D64A0D7007E9DB0 /* UploadQualityMetric.swift in Sources */, D263BCAF29DAFFEB00FA0E21 /* PerformancePresetOverride.swift in Sources */, D23039E7298D5236001A1FA3 /* NetworkConnectionInfo.swift in Sources */, D23039E9298D5236001A1FA3 /* TrackingConsent.swift in Sources */, @@ -9073,6 +9081,7 @@ D23F8E5929DDCD28001CFAE8 /* WebViewEventReceiver.swift in Sources */, 265496D32D81C5B10094B6E2 /* RUMAccount.swift in Sources */, D253EE972B988CA90010B589 /* ViewCache.swift in Sources */, + D2A133922DAFA8B200D84D3C /* MetricTelemetryAggregator.swift in Sources */, D23F8E5A29DDCD28001CFAE8 /* RUMResourceScope.swift in Sources */, D23F8E5C29DDCD28001CFAE8 /* RUMApplicationScope.swift in Sources */, 3CFF4F982C09E64C006F191D /* WatchdogTerminationMonitor.swift in Sources */, @@ -9212,6 +9221,7 @@ D23F8EB329DDCD38001CFAE8 /* ErrorMessageReceiverTests.swift in Sources */, 61C713C12A3C9DAD00FA735A /* RequestBuilderTests.swift in Sources */, D23F8EB429DDCD38001CFAE8 /* RUMApplicationScopeTests.swift in Sources */, + D2A133942DB1074000D84D3C /* MetricTelemetryAggregatorTests.swift in Sources */, 6105C5152D0C584F00C4C5EE /* INVMetricTests.swift in Sources */, D23F8EB629DDCD38001CFAE8 /* RUMViewsHandlerTests.swift in Sources */, 61C713CB2A3DC22700FA735A /* RUMTests.swift in Sources */, @@ -9520,6 +9530,7 @@ D29A9F6229DD85BB005C54A4 /* WebViewEventReceiver.swift in Sources */, 265496D42D81C5B10094B6E2 /* RUMAccount.swift in Sources */, D253EE962B988CA90010B589 /* ViewCache.swift in Sources */, + D2A133912DAFA8B200D84D3C /* MetricTelemetryAggregator.swift in Sources */, D29A9F8429DD85BB005C54A4 /* RUMResourceScope.swift in Sources */, D29A9F7329DD85BB005C54A4 /* RUMApplicationScope.swift in Sources */, 3CFF4F972C09E64C006F191D /* WatchdogTerminationMonitor.swift in Sources */, @@ -9659,6 +9670,7 @@ D29A9FBB29DDB483005C54A4 /* ErrorMessageReceiverTests.swift in Sources */, 61C713C02A3C9DAD00FA735A /* RequestBuilderTests.swift in Sources */, D29A9F9F29DDB483005C54A4 /* RUMApplicationScopeTests.swift in Sources */, + D2A133952DB1074000D84D3C /* MetricTelemetryAggregatorTests.swift in Sources */, 6105C5142D0C584F00C4C5EE /* INVMetricTests.swift in Sources */, D29A9FAA29DDB483005C54A4 /* RUMViewsHandlerTests.swift in Sources */, 61C713CA2A3DC22700FA735A /* RUMTests.swift in Sources */, @@ -9820,6 +9832,7 @@ 617699192A860D9D0030022B /* HTTPClient.swift in Sources */, D2FB1255292E0E99005B13F8 /* TrackingConsentPublisher.swift in Sources */, D26C49C0288982DA00802B2D /* FeatureUpload.swift in Sources */, + D2393EF92DB2555F006B3C75 /* UploadCycleMetric.swift in Sources */, D2CB6E8127C50EAE00A62B57 /* DataUploader.swift in Sources */, D2CB6E8827C50EAE00A62B57 /* FileReader.swift in Sources */, D2CB6E8D27C50EAE00A62B57 /* KronosNTPProtocol.swift in Sources */, @@ -10053,7 +10066,6 @@ D2EBEE2D29BA161100B15732 /* HTTPHeadersReader.swift in Sources */, D24EC3DA2DD1F117007A7E8F /* SessionReplayCoreContext.swift in Sources */, E2AA55E82C32C6D9002FEF28 /* ApplicationNotifications.swift in Sources */, - D22789362D64A0D7007E9DB0 /* UploadQualityMetric.swift in Sources */, D263BCB029DAFFEB00FA0E21 /* PerformancePresetOverride.swift in Sources */, D2DA2359298D57AA00C6C7E6 /* NetworkConnectionInfo.swift in Sources */, D2DA235A298D57AA00C6C7E6 /* TrackingConsent.swift in Sources */, diff --git a/DatadogCore/Sources/Core/DatadogCore.swift b/DatadogCore/Sources/Core/DatadogCore.swift index ac8bb31faa..5200046588 100644 --- a/DatadogCore/Sources/Core/DatadogCore.swift +++ b/DatadogCore/Sources/Core/DatadogCore.swift @@ -86,6 +86,9 @@ internal final class DatadogCore { /// - encryption: The on-disk data encryption. /// - contextProvider: The core context provider. /// - applicationVersion: The application version. + /// - maxBatchesPerUpload: Number of batch to process during an upload cycle. + /// - backgroundTasksEnabled: Enables upload background task. + /// - isRunFromExtension: Set `true` when the SDK is initialised from an extension. init( directory: CoreDirectory, dateProvider: DateProvider, diff --git a/DatadogCore/Sources/Core/MessageBus.swift b/DatadogCore/Sources/Core/MessageBus.swift index c264236fb6..c87f0c9ef9 100644 --- a/DatadogCore/Sources/Core/MessageBus.swift +++ b/DatadogCore/Sources/Core/MessageBus.swift @@ -88,9 +88,8 @@ internal final class MessageBus { /// - fallback: The fallback closure to call when the message could not be /// processed by any Features on the bus. func send(message: FeatureMessage, else fallback: @escaping () -> Void = {}) { - if // Configuration Telemetry Message - case .telemetry(let telemetry) = message, - case .configuration(let configuration) = telemetry { + if case .telemetry(.configuration(let configuration) ) = message { + // Configuration Telemetry Message return save(configuration: configuration) } diff --git a/DatadogCore/Sources/Core/Storage/FilesOrchestrator.swift b/DatadogCore/Sources/Core/Storage/FilesOrchestrator.swift index 58f3d1dae2..08c6ab619f 100644 --- a/DatadogCore/Sources/Core/Storage/FilesOrchestrator.swift +++ b/DatadogCore/Sources/Core/Storage/FilesOrchestrator.swift @@ -60,10 +60,6 @@ internal class FilesOrchestrator: FilesOrchestratorType { /// An extra information to include in metrics or `nil` if metrics should not be reported for this orchestrator. let metricsData: MetricsData? - /// Tracks number of pending batches in the track's directory - @ReadWriteLock - private var pendingBatches: Int = 0 - var trackName: String { metricsData?.trackName ?? "Unknown" } @@ -133,9 +129,8 @@ internal class FilesOrchestrator: FilesOrchestratorType { lastWritableFileObjectsCount = 1 lastWritableFileApproximatedSize = writeSize lastWritableFileLastWriteDate = dateProvider.now - - // Increment pending batches for telemetry - _pendingBatches.mutate { $0 += 1 } + // Increment pending batches in telemetry + incrementPendingBatches() return newFile } @@ -193,7 +188,7 @@ internal class FilesOrchestrator: FilesOrchestratorType { let files = try directory.files() // Reset pending batches for telemetry - pendingBatches = files.count + recordPendingBatches(count: files.count) let filesFromOldest = try files .compactMap { try deleteFileIfItsObsolete(file: $0, fileCreationDate: fileCreationDateFrom(fileName: $0.name)) } @@ -228,7 +223,7 @@ internal class FilesOrchestrator: FilesOrchestratorType { #endif try readableFile.delete() // Decrement pending batches at each batch deletion - _pendingBatches.mutate { $0 -= 1 } + incrementPendingBatches(by: -1) sendBatchDeletedMetric(batchFile: readableFile, deletionReason: deletionReason) } catch { telemetry.error("Failed to delete file", error: error) @@ -259,7 +254,7 @@ internal class FilesOrchestrator: FilesOrchestratorType { let fileWithSize = filesWithSizeSortedByCreationDate.removeFirst() try fileWithSize.file.delete() // Decrement pending batches at each batch deletion - _pendingBatches.mutate { $0 -= 1 } + incrementPendingBatches(by: -1) sendBatchDeletedMetric(batchFile: fileWithSize.file, deletionReason: .purged) sizeFreed += fileWithSize.size } @@ -272,7 +267,7 @@ internal class FilesOrchestrator: FilesOrchestratorType { if fileAge > performance.maxFileAgeForRead { try file.delete() // Decrement pending batches at each batch deletion - _pendingBatches.mutate { $0 -= 1 } + incrementPendingBatches(by: -1) sendBatchDeletedMetric(batchFile: file, deletionReason: .obsolete) return nil } else { @@ -309,13 +304,40 @@ internal class FilesOrchestrator: FilesOrchestratorType { BatchDeletedMetric.batchAgeKey: batchAge.toMilliseconds, BatchDeletedMetric.batchRemovalReasonKey: deletionReason.toString(), BatchDeletedMetric.inBackgroundKey: false, - BatchDeletedMetric.backgroundTasksEnabled: metricsData.backgroundTasksEnabled, - BatchDeletedMetric.pendingBatches: pendingBatches + BatchDeletedMetric.backgroundTasksEnabled: metricsData.backgroundTasksEnabled ], sampleRate: BatchDeletedMetric.sampleRate ) } + private func incrementPendingBatches(by increment: Double = 1) { + guard let metricsData = metricsData else { + return + } + + telemetry.increment( + metric: PendingBatchMetric.typeValue, + by: increment, + cardinalities: [ + BatchMetric.trackKey: .string(metricsData.trackName) + ] + ) + } + + private func recordPendingBatches(count: Int) { + guard let metricsData = metricsData else { + return + } + + telemetry.record( + metric: PendingBatchMetric.typeValue, + value: count, + cardinalities: [ + BatchMetric.trackKey: .string(metricsData.trackName) + ] + ) + } + /// Sends "Batch Closed" telemetry log. /// - Parameters: /// - fileName: The name of the batch that was closed. diff --git a/DatadogCore/Sources/Core/Upload/DataUploadWorker.swift b/DatadogCore/Sources/Core/Upload/DataUploadWorker.swift index ca7c6d803c..4970e8830a 100644 --- a/DatadogCore/Sources/Core/Upload/DataUploadWorker.swift +++ b/DatadogCore/Sources/Core/Upload/DataUploadWorker.swift @@ -65,6 +65,7 @@ internal class DataUploadWorker: DataUploadWorkerType { self.delay = delay self.featureName = featureName self.telemetry = telemetry + let readWorkItem = DispatchWorkItem { [weak self] in guard let self = self else { return @@ -73,20 +74,23 @@ internal class DataUploadWorker: DataUploadWorkerType { let context = contextProvider.read() let blockersForUpload = uploadConditions.blockersForUpload(with: context) let isSystemReady = blockersForUpload.isEmpty - let files = isSystemReady ? fileReader.readFiles(limit: maxBatchesPerUpload) : nil - if let files = files, !files.isEmpty { + let files = fileReader.readFiles(limit: maxBatchesPerUpload) + + if !files.isEmpty && isSystemReady { DD.logger.debug("⏳ (\(self.featureName)) Uploading batches...") self.backgroundTaskCoordinator?.beginBackgroundTask() self.uploadFile(from: files.reversed(), context: context) + sendUploadCycleMetric() } else { - let batchLabel = files?.isEmpty == false ? "YES" : (isSystemReady ? "NO" : "NOT CHECKED") + let batchLabel = files.isEmpty ? "NO" : "YES" DD.logger.debug("💡 (\(self.featureName)) No upload. Batch to upload: \(batchLabel), System conditions: \(blockersForUpload.description)") self.delay.increase() self.backgroundTaskCoordinator?.endBackgroundTask() self.scheduleNextCycle() - sendUploadQualityMetric(blockers: blockersForUpload) + sendBatchBlockedMetric(blockers: blockersForUpload, batchCount: files.count) } } + self.readWork = readWorkItem // Start sending batches immediately after initialization: @@ -106,6 +110,7 @@ internal class DataUploadWorker: DataUploadWorkerType { return } + let filesCount = files.count var files = files guard let file = files.popLast() else { self.scheduleNextCycle() @@ -121,12 +126,12 @@ internal class DataUploadWorker: DataUploadWorkerType { ) previousUploadStatus = uploadStatus - sendUploadQualityMetric(status: uploadStatus) if uploadStatus.needsRetry { DD.logger.debug(" → (\(self.featureName)) not delivered, will be retransmitted: \(uploadStatus.userDebugDescription)") self.delay.increase() self.scheduleNextCycle() + sendBatchBlockedMetric(status: uploadStatus, batchCount: filesCount) return } @@ -147,8 +152,7 @@ internal class DataUploadWorker: DataUploadWorkerType { previousUploadStatus = nil if let error = uploadStatus.error { - // Throw to report the request error accordingly - throw error + throw error // Throw to report the request error accordingly } } catch DataUploadError.httpError(statusCode: .unauthorized), DataUploadError.httpError(statusCode: .forbidden) { DD.logger.error("⚠️ Make sure that the provided token still exists and you're targeting the relevant Datadog site.") @@ -166,7 +170,6 @@ internal class DataUploadWorker: DataUploadWorkerType { self.fileReader.markBatchAsRead(batch, reason: .invalid) previousUploadStatus = nil self.telemetry.error("Failed to initiate '\(self.featureName)' data upload", error: error) - sendUploadQualityMetric(failure: "invalid") } } @@ -235,54 +238,50 @@ internal class DataUploadWorker: DataUploadWorkerType { } } - private func sendUploadQualityMetric(blockers: [DataUploadConditions.Blocker]) { - guard !blockers.isEmpty else { - return sendUploadQualityMetric() - } - - sendUploadQualityMetric( - failure: "blocker", - blockers: blockers.map { - switch $0 { - case .battery: return "low_battery" - case .lowPowerModeOn: return "lpm" - case .networkReachability: return "offline" - } - } + private func sendUploadCycleMetric() { + telemetry.increment( + metric: UploadCycleMetric.name, + cardinalities: [UploadCycleMetric.track: .string(featureName)] ) } - private func sendUploadQualityMetric(status: DataUploadStatus) { - guard let error = status.error else { - return sendUploadQualityMetric() + private func sendBatchBlockedMetric(blockers: [DataUploadConditions.Blocker], batchCount: Int) { + guard batchCount > 0, !blockers.isEmpty else { + return } - sendUploadQualityMetric( - failure: { - switch error { - case let .httpError(code): return "\(code)" - case let .networkError(error): return "\(error.code)" - } - }() - ) - } - - private func sendUploadQualityMetric() { - telemetry.metric( - name: UploadQualityMetric.name, - attributes: [ - UploadQualityMetric.track: featureName + telemetry.increment( + metric: BatchBlockedMetric.typeValue, + by: batchCount, + cardinalities: [ + BatchMetric.trackKey: .string(featureName), + BatchBlockedMetric.blockers: .array(blockers.map { + switch $0 { + case .battery: return .string("low_battery") + case .lowPowerModeOn: return .string("lpm") + case .networkReachability: return .string("offline") + } + }) ] ) } - private func sendUploadQualityMetric(failure: String, blockers: [String] = []) { - telemetry.metric( - name: UploadQualityMetric.name, - attributes: [ - UploadQualityMetric.track: featureName, - UploadQualityMetric.failure: failure, - UploadQualityMetric.blockers: blockers + private func sendBatchBlockedMetric(status: DataUploadStatus, batchCount: Int) { + guard let error = status.error else { + return + } + + telemetry.increment( + metric: BatchBlockedMetric.typeValue, + by: batchCount, + cardinalities: [ + BatchMetric.trackKey: .string(featureName), + BatchBlockedMetric.failure: .string({ + switch error { + case let .httpError(code): return "intake-code-\(code.rawValue)" + case let .networkError(error): return "network-code-\(error.code)" + } + }()) ] ) } diff --git a/DatadogCore/Sources/SDKMetrics/BatchMetrics.swift b/DatadogCore/Sources/SDKMetrics/BatchMetrics.swift index 70e2bc58bc..d008e616bf 100644 --- a/DatadogCore/Sources/SDKMetrics/BatchMetrics.swift +++ b/DatadogCore/Sources/SDKMetrics/BatchMetrics.swift @@ -130,3 +130,19 @@ internal enum BatchClosedMetric { /// If the batch was closed by core or after new batch was forced by the feature. static let forcedNewKey = "forced_new" } + +/// Definition of "Batch Blocked" telemetry. +internal enum BatchBlockedMetric { + /// Metric type value. + static let typeValue = "batch_blocked" + /// List of upload blocker reasons + static let blockers = "blockers" + /// The blocking failure reason. + static let failure = "failure" +} + +/// Definition of "Pending Batches" telemetry. +internal enum PendingBatchMetric { + /// Metric type value. + static let typeValue = "pending_batches" +} diff --git a/DatadogInternal/Sources/SDKMetrics/UploadQualityMetric.swift b/DatadogCore/Sources/SDKMetrics/UploadCycleMetric.swift similarity index 67% rename from DatadogInternal/Sources/SDKMetrics/UploadQualityMetric.swift rename to DatadogCore/Sources/SDKMetrics/UploadCycleMetric.swift index ea5a634ee0..244c0ab3dc 100644 --- a/DatadogInternal/Sources/SDKMetrics/UploadQualityMetric.swift +++ b/DatadogCore/Sources/SDKMetrics/UploadCycleMetric.swift @@ -6,18 +6,14 @@ import Foundation -/// Fields of the Upload Quality Metric. +/// Fields of the Upload Cycle Metric. /// /// This metric is not sent to Telemetry as-is, values are sent on the message-bus /// and aggregated internally by RUM's message receiver. The aggregate is sent as an /// attribute of the "RUM Session Ended" metric. -public enum UploadQualityMetric { +public enum UploadCycleMetric { /// Metric's name - public static let name = "upload_quality" + public static let name = "upload_cycle" /// The Metrics' upload track, or feature name. public static let track = "track" - /// The upload's failure description. - public static let failure = "failure" - /// The upload's blockers list. - public static let blockers = "blockers" } diff --git a/DatadogCore/Tests/Datadog/Core/Persistence/FilesOrchestrator+MetricsTests.swift b/DatadogCore/Tests/Datadog/Core/Persistence/FilesOrchestrator+MetricsTests.swift index 04c05b2e58..208f6f95c6 100644 --- a/DatadogCore/Tests/Datadog/Core/Persistence/FilesOrchestrator+MetricsTests.swift +++ b/DatadogCore/Tests/Datadog/Core/Persistence/FilesOrchestrator+MetricsTests.swift @@ -46,7 +46,7 @@ class FilesOrchestrator_MetricsTests: XCTestCase { // MARK: - "Batch Deleted" Metric - func testWhenReadableFileIsDeleted_itSendsBatchDeletedMetric() throws { + func testWhenReadableFileIsDeleted_itSendsTelemetryMetric() throws { // Given let orchestrator = createOrchestrator() let expectedBatchAge = storage.minFileAgeForRead + 1 @@ -63,8 +63,8 @@ class FilesOrchestrator_MetricsTests: XCTestCase { orchestrator.delete(readableFile: file, deletionReason: .intakeCode(responseCode: 202)) // Then - let metric = try XCTUnwrap(telemetry.messages.firstMetric(named: "Batch Deleted")) - DDAssertJSONEqual(metric.attributes, [ + let batchDeleted = try XCTUnwrap(telemetry.messages.firstMetricReport(named: "Batch Deleted")) + DDAssertJSONEqual(batchDeleted.attributes, [ "metric_type": "batch deleted", "track": "track name", "consent": "consent value", @@ -76,13 +76,20 @@ class FilesOrchestrator_MetricsTests: XCTestCase { "in_background": false, "background_tasks_enabled": false, "batch_age": expectedBatchAge.toMilliseconds, - "batch_removal_reason": "intake-code-202", - "pending_batches": 1 + "batch_removal_reason": "intake-code-202" ]) - XCTAssertEqual(metric.sampleRate, BatchDeletedMetric.sampleRate) + XCTAssertEqual(batchDeleted.sampleRate, BatchDeletedMetric.sampleRate) + + let pendingBatches = telemetry.messages.compactMap { $0.asMetricIncrement }.reduce(0) { count, metric in + XCTAssertEqual(metric.metric, "pending_batches") + XCTAssertEqual(metric.cardinalities["track"], .string("track name")) + return count + metric.increment + } + + XCTAssertEqual(pendingBatches, 1) } - func testWhenObsoleteFileIsDeleted_itSendsBatchDeletedMetric() throws { + func testWhenObsoleteFileIsDeleted_itSendsTelemetryMetric() throws { // Given: // - request some batch to be created let orchestrator = createOrchestrator() @@ -95,8 +102,8 @@ class FilesOrchestrator_MetricsTests: XCTestCase { _ = orchestrator.getReadableFiles() // Then - let metric = try XCTUnwrap(telemetry.messages.firstMetric(named: "Batch Deleted")) - DDAssertJSONEqual(metric.attributes, [ + let batchDeleted = try XCTUnwrap(telemetry.messages.firstMetricReport(named: "Batch Deleted")) + DDAssertJSONEqual(batchDeleted.attributes, [ "metric_type": "batch deleted", "track": "track name", "consent": "consent value", @@ -108,13 +115,29 @@ class FilesOrchestrator_MetricsTests: XCTestCase { "in_background": false, "background_tasks_enabled": false, "batch_age": (storage.maxFileAgeForRead + 1).toMilliseconds, - "batch_removal_reason": "obsolete", - "pending_batches": 0 + "batch_removal_reason": "obsolete" ]) - XCTAssertEqual(metric.sampleRate, BatchDeletedMetric.sampleRate) + XCTAssertEqual(batchDeleted.sampleRate, BatchDeletedMetric.sampleRate) + + let pendingBatches = telemetry.messages.reduce(0) { count, message in + switch message { + case let .metric(.record(metric, value, cardinalities)): + XCTAssertEqual(metric, "pending_batches") + XCTAssertEqual(cardinalities["track"], .string("track name")) + return value + case let .metric(.increment(metric, value, cardinalities)): + XCTAssertEqual(metric, "pending_batches") + XCTAssertEqual(cardinalities["track"], .string("track name")) + return count + value + default: + return count + } + } + + XCTAssertEqual(pendingBatches, 0) } - func testWhenDirectoryIsPurged_itSendsBatchDeletedMetrics() throws { + func testWhenDirectoryIsPurged_itSendsTelemetryMetrics() throws { // Given: some batch // - request batch to be created // - write more data than allowed directory size limit @@ -130,8 +153,8 @@ class FilesOrchestrator_MetricsTests: XCTestCase { _ = try orchestrator.getWritableFile(writeSize: 1) // Then - let metric = try XCTUnwrap(telemetry.messages.firstMetric(named: "Batch Deleted")) - DDAssertJSONEqual(metric.attributes, [ + let batchDeleted = try XCTUnwrap(telemetry.messages.firstMetricReport(named: "Batch Deleted")) + DDAssertJSONEqual(batchDeleted.attributes, [ "metric_type": "batch deleted", "track": "track name", "consent": "consent value", @@ -143,10 +166,17 @@ class FilesOrchestrator_MetricsTests: XCTestCase { "in_background": false, "background_tasks_enabled": false, "batch_age": expectedBatchAge.toMilliseconds, - "batch_removal_reason": "purged", - "pending_batches": 0 + "batch_removal_reason": "purged" ]) - XCTAssertEqual(metric.sampleRate, BatchDeletedMetric.sampleRate) + XCTAssertEqual(batchDeleted.sampleRate, BatchDeletedMetric.sampleRate) + + let pendingBatches = telemetry.messages.compactMap { $0.asMetricIncrement }.reduce(0) { count, metric in + XCTAssertEqual(metric.metric, "pending_batches") + XCTAssertEqual(metric.cardinalities["track"], .string("track name")) + return count + metric.increment + } + + XCTAssertEqual(pendingBatches, 1) } // MARK: - "Batch Closed" Metric @@ -175,7 +205,7 @@ class FilesOrchestrator_MetricsTests: XCTestCase { _ = try orchestrator.getWritableFile(writeSize: 1) // Then - let metric = try XCTUnwrap(telemetry.messages.firstMetric(named: "Batch Closed")) + let metric = try XCTUnwrap(telemetry.messages.firstMetricReport(named: "Batch Closed")) DDAssertReflectionEqual(metric.attributes, [ "metric_type": "batch closed", "track": "track name", diff --git a/DatadogCore/Tests/Datadog/Core/Upload/DataUploadWorkerTests.swift b/DatadogCore/Tests/Datadog/Core/Upload/DataUploadWorkerTests.swift index 4289d8b158..ee2ec5f512 100644 --- a/DatadogCore/Tests/Datadog/Core/Upload/DataUploadWorkerTests.swift +++ b/DatadogCore/Tests/Datadog/Core/Upload/DataUploadWorkerTests.swift @@ -89,10 +89,9 @@ class DataUploadWorkerTests: XCTestCase { XCTAssertEqual(try orchestrator.directory.files().count, 0) XCTAssertEqual(telemetry.messages.count, 3) - let metric = try XCTUnwrap(telemetry.messages.firstMetric(named: "upload_quality"), "An upload quality metric should be send to `telemetry`.") - XCTAssertEqual(metric.attributes["track"] as? String, featureName) - XCTAssertNil(metric.attributes["failure"]) - XCTAssertNil(metric.attributes["blockers"]) + let metric = try XCTUnwrap(telemetry.messages.firstMetricIncrement(named: "upload_cycle"), "An upload cycle metric should be send to `telemetry`.") + XCTAssertEqual(metric.increment, 1) + XCTAssertEqual(metric.cardinalities["track"], .string(featureName)) } func testItUploadsDataSequentiallyWithoutDelay_whenMaxBatchesPerUploadIsSet() throws { @@ -141,11 +140,10 @@ class DataUploadWorkerTests: XCTestCase { worker.cancelSynchronously() XCTAssertEqual(try orchestrator.directory.files().count, 1) - XCTAssertEqual(telemetry.messages.count, 2) - let metric = try XCTUnwrap(telemetry.messages.firstMetric(named: "upload_quality"), "An upload quality metric should be send to `telemetry`.") - XCTAssertEqual(metric.attributes["track"] as? String, featureName) - XCTAssertNil(metric.attributes["failure"]) - XCTAssertNil(metric.attributes["blockers"]) + XCTAssertEqual(telemetry.messages.count, 1) + let metric = try XCTUnwrap(telemetry.messages.firstMetricIncrement(named: "upload_cycle"), "An upload cycle metric should be send to `telemetry`.") + XCTAssertEqual(metric.increment, 1) + XCTAssertEqual(metric.cardinalities["track"], .string(featureName)) } func testGivenDataToUpload_whenUploadFinishesAndDoesNotNeedToBeRetried_thenDataIsDeleted() { @@ -526,9 +524,10 @@ class DataUploadWorkerTests: XCTestCase { ) } - func testWhenUploadIsBlocked_itDoesSendUploadQualityTelemetry() throws { + func testWhenUploadIsBlocked_itIncrementsBatchBlockedTelemetry() throws { // Given let telemetry = TelemetryMock() + writer.write(value: ["key": "value"]) // When let uploadExpectation = self.expectation(description: "Upload has started") @@ -565,11 +564,10 @@ class DataUploadWorkerTests: XCTestCase { worker.cancelSynchronously() // Then - XCTAssertEqual(telemetry.messages.count, 1) - let metric = try XCTUnwrap(telemetry.messages.firstMetric(named: "upload_quality"), "An upload quality metric should be send to `telemetry`.") - XCTAssertEqual(metric.attributes["failure"] as? String, "blocker") - XCTAssertEqual(metric.attributes["blockers"] as? [String], ["offline", "low_battery"]) - XCTAssertEqual(metric.attributes["track"] as? String, featureName) + let metric = try XCTUnwrap(telemetry.messages.firstMetricIncrement(named: BatchBlockedMetric.typeValue)) + XCTAssertEqual(metric.increment, 1) + XCTAssertEqual(metric.cardinalities["blockers"], .array([.string("offline"), .string("low_battery")])) + XCTAssertEqual(metric.cardinalities["track"], .string(featureName)) } func testWhenDataIsUploadedWithServerError_itDoesNotSendErrorTelemetry() throws { @@ -614,9 +612,9 @@ class DataUploadWorkerTests: XCTestCase { // Then XCTAssertEqual(telemetry.messages.count, 1) - let metric = try XCTUnwrap(telemetry.messages.firstMetric(named: "upload_quality"), "An upload quality metric should be send to `telemetry`.") - XCTAssertEqual(metric.attributes["failure"] as? String, "\(randomStatusCode)") - XCTAssertEqual(metric.attributes["track"] as? String, featureName) + let metric = try XCTUnwrap(telemetry.messages.firstMetricIncrement(named: "upload_cycle"), "An upload cycle metric should be send to `telemetry`.") + XCTAssertEqual(metric.increment, 1) + XCTAssertEqual(metric.cardinalities["track"], .string(featureName)) } func testWhenDataIsUploadedWithAlertingStatusCode_itSendsErrorTelemetry() throws { @@ -660,14 +658,9 @@ class DataUploadWorkerTests: XCTestCase { worker.cancelSynchronously() // Then - XCTAssertEqual(telemetry.messages.count, 2) let error = try XCTUnwrap(telemetry.messages.firstError(), "An error should be send to `telemetry`.") XCTAssertEqual(error.message,"Data upload finished with status code: \(randomStatusCode.rawValue)") - - let metric = try XCTUnwrap(telemetry.messages.firstMetric(named: "upload_quality"), "An upload quality metric should be send to `telemetry`.") - XCTAssertEqual(metric.attributes["failure"] as? String, "\(randomStatusCode)") - XCTAssertEqual(metric.attributes["track"] as? String, featureName) } func testWhenDataCannotBeUploadedDueToNetworkError_itSendsErrorTelemetry() throws { @@ -705,14 +698,54 @@ class DataUploadWorkerTests: XCTestCase { worker.cancelSynchronously() // Then - XCTAssertEqual(telemetry.messages.count, 2) - let error = try XCTUnwrap(telemetry.messages.firstError(), "An error should be send to `telemetry`.") XCTAssertEqual(error.message, #"Data upload finished with error - Error Domain=abc Code=0 "(null)""#) + } + + func testWhenDataIsUploadedWithRetryableStatusCode_itIncrementsBatchBlockedTelemetry() throws { + // Given + let telemetry = TelemetryMock() + writer.write(value: ["key": "value"]) + + let randomStatusCode: HTTPResponseStatusCode = [ + .requestTimeout, + .tooManyRequests, + .internalServerError, + .serviceUnavailable + ].randomElement()! + + // When + let startUploadExpectation = self.expectation(description: "Upload has started") + let mockDataUploader = DataUploaderMock( + uploadStatus: .mockWith(needsRetry: true, error: .httpError(statusCode: randomStatusCode)) + ) + + mockDataUploader.onUpload = { previousUploadStatus in + XCTAssertNil(previousUploadStatus) + startUploadExpectation.fulfill() + } - let metric = try XCTUnwrap(telemetry.messages.firstMetric(named: "upload_quality"), "An upload quality metric should be send to `telemetry`.") - XCTAssertEqual(metric.attributes["failure"] as? String, "\(nserror.code)") - XCTAssertEqual(metric.attributes["track"] as? String, featureName) + let featureName: String = .mockRandom() + let worker = DataUploadWorker( + queue: uploaderQueue, + fileReader: reader, + dataUploader: mockDataUploader, + contextProvider: .mockAny(), + uploadConditions: .alwaysUpload(), + delay: DataUploadDelay(performance: UploadPerformanceMock.veryQuickInitialUpload), + featureName: featureName, + telemetry: telemetry, + maxBatchesPerUpload: .mockRandom(min: 1, max: 100) + ) + + wait(for: [startUploadExpectation], timeout: 0.5) + worker.cancelSynchronously() + + // Then + let metric = try XCTUnwrap(telemetry.messages.firstMetricIncrement(named: BatchBlockedMetric.typeValue)) + XCTAssertEqual(metric.increment, 1) + XCTAssertEqual(metric.cardinalities["failure"], .string("intake-code-\(randomStatusCode.rawValue)")) + XCTAssertEqual(metric.cardinalities["track"], .string(featureName)) } func testWhenDataCannotBePreparedForUpload_itSendsErrorTelemetry() throws { @@ -751,9 +784,9 @@ class DataUploadWorkerTests: XCTestCase { let error = try XCTUnwrap(telemetry.messages.firstError(), "An error should be send to `telemetry`.") XCTAssertEqual(error.message, #"Failed to initiate 'some-feature' data upload - Failed to prepare upload"#) - let metric = try XCTUnwrap(telemetry.messages.firstMetric(named: "upload_quality"), "An upload quality metric should be send to `telemetry`.") - XCTAssertEqual(metric.attributes["failure"] as? String, "invalid") - XCTAssertEqual(metric.attributes["track"] as? String, "some-feature") + let metric = try XCTUnwrap(telemetry.messages.firstMetricIncrement(named: "upload_cycle"), "An upload cycle metric should be send to `telemetry`.") + XCTAssertEqual(metric.increment, 1) + XCTAssertEqual(metric.cardinalities["track"], .string("some-feature")) } // MARK: - Tearing Down diff --git a/DatadogInternal/Sources/Concurrency/ReadWriteLock.swift b/DatadogInternal/Sources/Concurrency/ReadWriteLock.swift index 8bd3687a8e..2a66de6b7f 100644 --- a/DatadogInternal/Sources/Concurrency/ReadWriteLock.swift +++ b/DatadogInternal/Sources/Concurrency/ReadWriteLock.swift @@ -51,9 +51,10 @@ public final class ReadWriteLock: @unchecked Sendable { /// The lock will be acquired once for writing before invoking the closure. /// /// - Parameter closure: The closure with the mutable value. - public func mutate(_ closure: (inout Value) throws -> Void) rethrows { + @discardableResult + public func mutate(_ closure: (inout Value) throws -> T) rethrows -> T { pthread_rwlock_wrlock(rwlock) defer { pthread_rwlock_unlock(rwlock) } - try closure(&value) + return try closure(&value) } } diff --git a/DatadogInternal/Sources/SDKMetrics/SDKMetricFields.swift b/DatadogInternal/Sources/SDKMetrics/SDKMetricFields.swift index 25d01e9d6b..e2bdf83033 100644 --- a/DatadogInternal/Sources/SDKMetrics/SDKMetricFields.swift +++ b/DatadogInternal/Sources/SDKMetrics/SDKMetricFields.swift @@ -10,6 +10,8 @@ import Foundation public enum SDKMetricFields { /// Metric type key. It expects `String` value. public static let typeKey = "metric_type" + /// Metric value key. It expects `Double` value. + public static let valueKey = "value" /// The first sample rate applied to the metric. public static let headSampleRate = "head_sample_rate" /// Key referencing the session ID (`String`) that the metric should be sent with. It expects `String` value. diff --git a/DatadogInternal/Sources/Storage.swift b/DatadogInternal/Sources/Storage.swift index e5a51ca0a1..a388ff1f36 100644 --- a/DatadogInternal/Sources/Storage.swift +++ b/DatadogInternal/Sources/Storage.swift @@ -14,6 +14,12 @@ public protocol Storage { func mostRecentModifiedFileAt(before: Date) throws -> Date? } +extension DatadogCoreProtocol { + /// Provides access to the `Storage` associated with the core. + /// - Returns: The `Storage` instance. + public var storage: Storage { CoreStorage(core: self) } +} + internal struct CoreStorage: Storage { /// A weak core reference. private weak var core: DatadogCoreProtocol? diff --git a/DatadogInternal/Sources/Telemetry/Telemetry.swift b/DatadogInternal/Sources/Telemetry/Telemetry.swift index 117234f738..9d6d7cf85c 100644 --- a/DatadogInternal/Sources/Telemetry/Telemetry.swift +++ b/DatadogInternal/Sources/Telemetry/Telemetry.swift @@ -65,35 +65,63 @@ public struct ConfigurationTelemetry: Equatable { public let useWorkerUrl: Bool? } -/// A telemetry event that can be sampled in addition to the global telemetry sample rate. -public protocol SampledTelemetry { - /// The sample rate for this metric, applied in addition to the telemetry sample rate. - var sampleRate: SampleRate { get } -} - -public struct MetricTelemetry: SampledTelemetry { +public enum MetricTelemetry { /// The default sample rate for metric events (15%), applied in addition to the telemetry sample rate (20% by default). public static let defaultSampleRate: SampleRate = 15 - /// The name of the metric. - public let name: String + /// Cardinality of a metric used for aggregation. + public typealias Cardinalities = [String: Cardinality] - /// The attributes associated with this metric. - public let attributes: [String: Encodable] + /// Single cardinality of a metric. + public enum Cardinality: Hashable { + case string(String) + case array([Self]) + } - /// The sample rate for this metric, applied in addition to the telemetry sample rate. - /// - /// Must be a value between `0` (reject all) and `100` (keep all). - /// - /// Note: This sample rate is compounded with the telemetry sample rate. For example, if the telemetry sample rate is 20% (default) - /// and this metric's sample rate is 15%, the effective sample rate for this metric will be 3%. - /// - /// This sample rate is applied in the telemetry receiver, after the metric has been processed by the SDK core (tail-based sampling). - public let sampleRate: SampleRate + public struct Event { + /// The name of the metric. + public let name: String + + /// The attributes associated with this metric. + public let attributes: [String: Encodable] + + /// The sample rate for this metric, applied in addition to the telemetry sample rate. + /// + /// Must be a value between `0` (reject all) and `100` (keep all). + /// + /// Note: This sample rate is compounded with the telemetry sample rate. For example, if the telemetry sample rate is 20% (default) + /// and this metric's sample rate is 15%, the effective sample rate for this metric will be 3%. + /// + /// This sample rate is applied in the telemetry receiver, after the metric has been processed by the SDK core (tail-based sampling). + public let sampleRate: SampleRate + + /// Creates an Metric Telemtry object. + /// + /// - Parameters: + /// - name: The name of the metric. + /// - attributes: The attributes associated with this metric. + /// - sampleRate: The sample rate for this metric, applied in addition to the telemetry sample rate. + public init( + name: String, + attributes: [String: Encodable], + sampleRate: SampleRate + ) { + self.name = name + self.attributes = attributes + self.sampleRate = sampleRate + } + } + + /// Increment a Counter metric aggregation. + case increment(String, by: Double, cardinalities: Cardinalities) + /// Record value of a Gauge metric aggregation. + case record(String, value: Double, cardinalities: Cardinalities) + /// Log a metric event (no aggregation applied + case report(MetricTelemetry.Event) } /// Describes the type of the usage telemetry events supported by the SDK. -public struct UsageTelemetry: SampledTelemetry { +public struct UsageTelemetry { /// Supported usage telemetry events. public enum Event { /// setTrackingConsent API @@ -172,7 +200,9 @@ public enum TelemetryMessage { case error(id: String, message: String, kind: String, stack: String) /// A configuration telemetry. case configuration(ConfigurationTelemetry) + /// A metric telemetry. case metric(MetricTelemetry) + /// A usage telemetry. case usage(UsageTelemetry) } @@ -238,9 +268,9 @@ public extension Telemetry { } let executionTime = -metric.startTime.timeIntervalSinceNow.toInt64Nanoseconds - send( - telemetry: .metric( - MetricTelemetry( + self.metric( + .report( + .init( name: MethodCalledMetric.name, attributes: [ MethodCalledMetric.executionTime: executionTime, @@ -359,6 +389,90 @@ extension Telemetry { self.error(message, error: DDError(error: error), file: file, line: line) } + /// Increments a counter metric by a specified value. + /// + /// This method creates a counter metric that can be used to track the number of times an event occurs. + /// The metric will be incremented by the specified value each time this method is called. + /// + /// - Parameters: + /// - metric: The name of the metric to increment. + /// - value: The amount to increment the metric by. Defaults to 1. + /// - cardinalities: The dimensions along which the metric will be aggregated. + public func increment(metric: String, by value: Double = 1, cardinalities: MetricTelemetry.Cardinalities) { + // swiftlint:disable:previous function_default_parameter_at_end + self.metric(.increment(metric, by: value, cardinalities: cardinalities)) + } + + /// Increments a counter metric by a specified integer value. + /// + /// This method creates a counter metric that can be used to track the number of times an event occurs. + /// The metric will be incremented by the specified integer value each time this method is called. + /// + /// - Parameters: + /// - metric: The name of the metric to increment. + /// - value: The integer amount to increment the metric by. + /// - cardinalities: The dimensions along which the metric will be aggregated. + public func increment(metric: String, by value: Int, cardinalities: MetricTelemetry.Cardinalities) { + self.metric(.increment(metric, by: Double(value), cardinalities: cardinalities)) + } + + /// Records a gauge metric with a specified value. + /// + /// This method creates a gauge metric that can be used to track a value that can go up and down. + /// The metric will be recorded with the specified value each time this method is called. + /// + /// - Parameters: + /// - metric: The name of the metric to record. + /// - value: The value to record for the metric. + /// - cardinalities: The dimensions along which the metric will be aggregated. + public func record(metric: String, value: Double, cardinalities: MetricTelemetry.Cardinalities) { + self.metric(.record(metric, value: value, cardinalities: cardinalities)) + } + + /// Records a gauge metric with a specified integer value. + /// + /// This method creates a gauge metric that can be used to track a value that can go up and down. + /// The metric will be recorded with the specified integer value each time this method is called. + /// + /// - Parameters: + /// - metric: The name of the metric to record. + /// - value: The integer value to record for the metric. + /// - cardinalities: The dimensions along which the metric will be aggregated. + public func record(metric: String, value: Int, cardinalities: MetricTelemetry.Cardinalities) { + self.metric(.record(metric, value: Double(value), cardinalities: cardinalities)) + } + + /// Sends a metric telemetry message to the Datadog backend. + /// + /// This method is used to send various types of metric telemetry data, including: + /// - Counter metrics (increments) + /// - Gauge metrics (recorded values) + /// - Custom metric events + /// + /// - Parameter metric: The metric telemetry data to send. + public func metric(_ metric: MetricTelemetry) { + send(telemetry: .metric(metric)) + } + + /// Collects a metric value. + /// + /// Metrics are reported as debug telemetry. Unlike regular events, they are not subject to duplicate filtering and + /// are sampled at a different rate. Metric attributes are used to create facets for later querying and graphing. + /// + /// - Parameters: + /// - name: The name of the metric. + /// - attributes: The attributes associated with this metric. + /// - sampleRate: The sample rate for this metric, applied in addition to the telemetry sample rate (15% by default). + /// Must be a value between `0` (reject all) and `100` (keep all). + /// + /// Note: This sample rate is compounded with the telemetry sample rate. For example, if the telemetry sample rate is 20% (default) + /// and this metric's sample rate is 15%, the effective sample rate for this metric will be 3%. + /// + /// This sample rate is applied in the telemetry receiver, after the metric has been processed by the SDK core (tail-based sampling). + public func metric(name: String, attributes: [String: Encodable], sampleRate: SampleRate = MetricTelemetry.defaultSampleRate) { + self.metric(.report(.init(name: name, attributes: attributes, sampleRate: sampleRate))) + } + /// Report a Configuration Telemetry. /// /// The configuration can be partial, the telemetry supports accumulation of @@ -478,25 +592,6 @@ extension Telemetry { useWorkerUrl: useWorkerUrl )) } - - /// Collects a metric value. - /// - /// Metrics are reported as debug telemetry. Unlike regular events, they are not subject to duplicate filtering and - /// are sampled at a different rate. Metric attributes are used to create facets for later querying and graphing. - /// - /// - Parameters: - /// - name: The name of the metric. - /// - attributes: The attributes associated with this metric. - /// - sampleRate: The sample rate for this metric, applied in addition to the telemetry sample rate (15% by default). - /// Must be a value between `0` (reject all) and `100` (keep all). - /// - /// Note: This sample rate is compounded with the telemetry sample rate. For example, if the telemetry sample rate is 20% (default) - /// and this metric's sample rate is 15%, the effective sample rate for this metric will be 3%. - /// - /// This sample rate is applied in the telemetry receiver, after the metric has been processed by the SDK core (tail-based sampling). - public func metric(name: String, attributes: [String: Encodable], sampleRate: SampleRate = MetricTelemetry.defaultSampleRate) { - send(telemetry: .metric(MetricTelemetry(name: name, attributes: attributes, sampleRate: sampleRate))) - } } public struct NOPTelemetry: Telemetry { @@ -539,12 +634,6 @@ extension DatadogCoreProtocol { public var telemetry: Telemetry { CoreTelemetry(core: self) } } -extension DatadogCoreProtocol { - /// Provides access to the `Storage` associated with the core. - /// - Returns: The `Storage` instance. - public var storage: Storage { CoreStorage(core: self) } -} - extension ConfigurationTelemetry { public func merged(with other: Self) -> Self { .init( @@ -606,3 +695,15 @@ extension ConfigurationTelemetry { ) } } + +extension MetricTelemetry.Cardinality: Encodable { + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case let .string(value): + try container.encode(value) + case let .array(value): + try container.encode(value) + } + } +} diff --git a/DatadogInternal/Tests/Telemetry/TelemetryTests.swift b/DatadogInternal/Tests/Telemetry/TelemetryTests.swift index 4b3a45a20a..5072cbf97f 100644 --- a/DatadogInternal/Tests/Telemetry/TelemetryTests.swift +++ b/DatadogInternal/Tests/Telemetry/TelemetryTests.swift @@ -125,7 +125,7 @@ class TelemetryTests: XCTestCase { telemetry.metric(name: "metric name", attributes: ["attribute": "value"], sampleRate: 4.21) // Then - let metric = try XCTUnwrap(telemetry.messages.compactMap({ $0.asMetric }).first) + let metric = try XCTUnwrap(telemetry.messages.compactMap({ $0.asMetricReport }).first) XCTAssertEqual(metric.name, "metric name") XCTAssertEqual(metric.attributes as? [String: String], ["attribute": "value"]) XCTAssertEqual(metric.sampleRate, 4.21) @@ -136,7 +136,7 @@ class TelemetryTests: XCTestCase { telemetry.metric(name: "metric name", attributes: [:]) // Then - let metric = try XCTUnwrap(telemetry.messages.compactMap({ $0.asMetric }).first) + let metric = try XCTUnwrap(telemetry.messages.compactMap({ $0.asMetricReport }).first) XCTAssertEqual(metric.sampleRate, MetricTelemetry.defaultSampleRate) } @@ -149,7 +149,7 @@ class TelemetryTests: XCTestCase { let metricTrace = telemetry.startMethodCalled(operationName: .mockAny(), callerClass: .mockAny(), headSampleRate: 100) telemetry.stopMethodCalled(metricTrace, isSuccessful: .mockAny()) - let metric = try XCTUnwrap(telemetry.messages.firstMetric(named: MethodCalledMetric.name)) + let metric = try XCTUnwrap(telemetry.messages.firstMetricReport(named: MethodCalledMetric.name)) XCTAssertEqual(metric.sampleRate, MetricTelemetry.defaultSampleRate) } @@ -157,7 +157,7 @@ class TelemetryTests: XCTestCase { let metricTrace = telemetry.startMethodCalled(operationName: .mockAny(), callerClass: .mockAny(), headSampleRate: 100) telemetry.stopMethodCalled(metricTrace, isSuccessful: .mockAny(), tailSampleRate: 42.5) - let metric = try XCTUnwrap(telemetry.messages.firstMetric(named: MethodCalledMetric.name)) + let metric = try XCTUnwrap(telemetry.messages.firstMetricReport(named: MethodCalledMetric.name)) XCTAssertEqual(metric.sampleRate, 42.5) } @@ -172,7 +172,7 @@ class TelemetryTests: XCTestCase { telemetry.stopMethodCalled(metricTrace, isSuccessful: isSuccessful) // Then - let metric = try XCTUnwrap(telemetry.messages.firstMetric(named: MethodCalledMetric.name)) + let metric = try XCTUnwrap(telemetry.messages.firstMetricReport(named: MethodCalledMetric.name)) XCTAssertEqual(metric.attributes[SDKMetricFields.typeKey] as? String, MethodCalledMetric.typeValue) XCTAssertEqual(metric.attributes[SDKMetricFields.headSampleRate] as? SampleRate, 100) XCTAssertEqual(metric.attributes[MethodCalledMetric.operationName] as? String, operationName) @@ -200,10 +200,10 @@ class TelemetryTests: XCTestCase { XCTAssertEqual(receiver.messages.lastTelemetry?.asConfiguration?.batchSize, 123) core.telemetry.metric(name: "metric name", attributes: [:], sampleRate: 15) - XCTAssertEqual(receiver.messages.lastTelemetry?.asMetric?.name, "metric name") + XCTAssertEqual(receiver.messages.lastTelemetry?.asMetricReport?.name, "metric name") let metricTrace = core.telemetry.startMethodCalled(operationName: .mockAny(), callerClass: .mockAny(), headSampleRate: 100) core.telemetry.stopMethodCalled(metricTrace) - XCTAssertEqual(receiver.messages.lastTelemetry?.asMetric?.name, MethodCalledMetric.name) + XCTAssertEqual(receiver.messages.lastTelemetry?.asMetricReport?.name, MethodCalledMetric.name) } } diff --git a/DatadogRUM/Sources/Integrations/TelemetryInterceptor.swift b/DatadogRUM/Sources/Integrations/TelemetryInterceptor.swift index 3786f73dda..ec4b7b9185 100644 --- a/DatadogRUM/Sources/Integrations/TelemetryInterceptor.swift +++ b/DatadogRUM/Sources/Integrations/TelemetryInterceptor.swift @@ -20,12 +20,6 @@ internal struct TelemetryInterceptor: FeatureMessageReceiver { switch telemetry { case .error(let id, let message, let kind, let stack): interceptError(id: id, message: message, kind: kind, stack: stack) - case .metric(let metric) where metric.name == UploadQualityMetric.name: - // Intercept the 'upload_quality' metric for aggregation in the rse - // metric - interceptUploadQualityMetric(attributes: metric.attributes) - return true // do not forward the message - default: break } @@ -36,8 +30,4 @@ internal struct TelemetryInterceptor: FeatureMessageReceiver { private func interceptError(id: String, message: String, kind: String, stack: String) { sessionEndedMetric.track(sdkErrorKind: kind, in: nil) // `nil` - track in current session } - - private func interceptUploadQualityMetric(attributes: [String: Encodable]) { - sessionEndedMetric.track(uploadQuality: attributes, in: nil) // `nil` - track in current session - } } diff --git a/DatadogRUM/Sources/Integrations/TelemetryReceiver.swift b/DatadogRUM/Sources/Integrations/TelemetryReceiver.swift index b4257bfff2..4e722641d8 100644 --- a/DatadogRUM/Sources/Integrations/TelemetryReceiver.swift +++ b/DatadogRUM/Sources/Integrations/TelemetryReceiver.swift @@ -20,6 +20,9 @@ internal final class TelemetryReceiver: FeatureMessageReceiver { /// Additional sampler for configuration telemetry events, applied in addition to the `sampler`. let configurationExtraSampler: Sampler + // Metric telemetry aggregator + let metricAggregator: MetricTelemetryAggregator + /// Keeps track of current session @ReadWriteLock private var currentSessionID: String? @@ -33,22 +36,35 @@ internal final class TelemetryReceiver: FeatureMessageReceiver { private var eventsCount: Int = 0 /// Creates a RUM Telemetry instance. - /// + /// /// - Parameters: /// - featureScope: RUM feature scope. /// - dateProvider: Current device time provider. /// - sampler: Telemetry events sampler. /// - configurationExtraSampler: Extra sampler for configuration events (applied on top of `sampler`). + /// - metricAggregator: The aggregation used for metrics telemetry + /// - notificationCenter: The Notification center to observe. init( featureScope: FeatureScope, dateProvider: DateProvider, sampler: Sampler, - configurationExtraSampler: Sampler + configurationExtraSampler: Sampler, + metricAggregator: MetricTelemetryAggregator = MetricTelemetryAggregator(), + notificationCenter: NotificationCenter = .default ) { self.featureScope = featureScope self.dateProvider = dateProvider self.sampler = sampler self.configurationExtraSampler = configurationExtraSampler + self.metricAggregator = metricAggregator + + // observe application entering background + notificationCenter.addObserver( + self, + selector: #selector(applicationDidEnterBackground), + name: ApplicationNotifications.didEnterBackground, + object: nil + ) } /// Receives a message from the bus. @@ -79,23 +95,23 @@ internal final class TelemetryReceiver: FeatureMessageReceiver { error(id: id, message: message, kind: kind, stack: stack) case .configuration(let configuration): send(configuration: configuration) - case let .metric(metric): - if sampled(event: metric) { + case let .metric(.report(metric)): + if Sampler(samplingRate: metric.sampleRate).sample() { send(metric: metric) } case .usage(let usage): - if sampled(event: usage) { + if Sampler(samplingRate: usage.sampleRate).sample() { send(usage: usage) } + case let .metric(.increment(metric, by: value, cardinalities)): + metricAggregator.increment(metric, by: value, cardinalities: cardinalities) + case let .metric(.record(metric, value, cardinalities)): + metricAggregator.record(metric, value: value, cardinalities: cardinalities) } return true } - private func sampled(event: SampledTelemetry) -> Bool { - return Sampler(samplingRate: event.sampleRate).sample() - } - /// Sends a `TelemetryDebugEvent` event. /// see. https://github.com/DataDog/rum-events-format/blob/master/schemas/telemetry/debug-schema.json /// @@ -248,7 +264,48 @@ internal final class TelemetryReceiver: FeatureMessageReceiver { } } - private func send(metric: MetricTelemetry) { + private func send(metric: String, attributes: [String: Encodable], sampleRate: SampleRate) { + let date = dateProvider.now + + record(event: nil) { context, writer in + let rum = context.additionalContext(ofType: RUMCoreContext.self) + + // Override sessionID using standard `SDKMetricFields`, otherwise use current RUM session ID: + var attributes = attributes + let sessionIDOverride: String? = attributes.removeValue(forKey: SDKMetricFields.sessionIDOverrideKey)?.dd.decode() + let sessionID = sessionIDOverride ?? rum?.sessionID + + // Calculates the composition of sample rates. The metric can have up to 3 layers of sampling. + var effectiveSampleRate = sampleRate.composed(with: self.sampler.samplingRate) + if let headSampleRate = attributes.removeValue(forKey: SDKMetricFields.headSampleRate) as? SampleRate { + effectiveSampleRate = effectiveSampleRate.composed(with: headSampleRate) + } + + let event = TelemetryDebugEvent( + dd: .init(), + action: rum?.userActionID.map { .init(id: $0) }, + application: rum.map { .init(id: $0.applicationID) }, + date: date.addingTimeInterval(context.serverTimeOffset).timeIntervalSince1970.toInt64Milliseconds, + effectiveSampleRate: Double(effectiveSampleRate), + experimentalFeatures: nil, + service: "dd-sdk-ios", + session: sessionID.map { .init(id: $0) }, + source: .init(rawValue: context.source) ?? .ios, + telemetry: .init( + device: .init(context.device), + message: "[Mobile Metric] \(metric)", + os: .init(context.device), + telemetryInfo: attributes + ), + version: context.sdkVersion, + view: rum?.viewID.map { .init(id: $0) } + ) + + writer.write(value: event) + } + } + + private func send(metric: MetricTelemetry.Event) { let date = dateProvider.now record(event: nil) { context, writer in @@ -317,6 +374,15 @@ internal final class TelemetryReceiver: FeatureMessageReceiver { } } } + + @objc + private func applicationDidEnterBackground() { + // Report aggregated telemetry metrics when + // the application enters background. + for metric in metricAggregator.flush() { + send(metric: metric) + } + } } private extension TelemetryUsageEvent.Telemetry.Usage { diff --git a/DatadogRUM/Sources/SDKMetrics/MetricTelemetryAggregator.swift b/DatadogRUM/Sources/SDKMetrics/MetricTelemetryAggregator.swift new file mode 100644 index 0000000000..6cd031e09b --- /dev/null +++ b/DatadogRUM/Sources/SDKMetrics/MetricTelemetryAggregator.swift @@ -0,0 +1,96 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ + +import Foundation +import DatadogInternal + +/// A class that aggregates metric telemetry data before sending it to the Datadog. +/// +/// This aggregator supports two types of metrics: +/// - Counter metrics: Values that can only increase (e.g., number of events) +/// - Gauge metrics: Values that can go up and down (e.g., current memory usage) +/// +/// Metrics can be aggregated along different dimensions using cardinalities, allowing for +/// detailed analysis of metric data across various contexts. +internal final class MetricTelemetryAggregator { + private typealias AggregationKey = MetricTelemetry.Cardinalities + private typealias AggregationValue = [String: Double] + + /// The sample rate to apply to aggregated metrics. + let sampleRate: SampleRate + + /// Thread-safe storage for metric aggregations. + @ReadWriteLock + private var aggregations: [AggregationKey: AggregationValue] = [:] + + /// Creates a new metric telemetry aggregator. + /// + /// - Parameter sampleRate: The sample rate to apply to aggregated metrics. + /// Defaults to maximum sample rate (100%). + init(sampleRate: SampleRate = .maxSampleRate) { + self.sampleRate = sampleRate + } + + /// Increments a counter metric by a specified value. + /// + /// This method adds the specified value to the current value of the metric. + /// If the metric doesn't exist, it will be initialized with the specified value. + /// + /// - Parameters: + /// - metric: The name of the metric to increment. + /// - value: The amount to increment the metric by. + /// - cardinalities: The dimensions along which the metric will be aggregated. + func increment(_ metric: String, by value: Double, cardinalities: MetricTelemetry.Cardinalities) { + _aggregations.mutate { aggregations in + var aggregation = aggregations[cardinalities, default: [metric: 0]] + aggregation[metric, default: 0] += value + aggregations[cardinalities] = aggregation + } + } + + /// Records a gauge metric with a specified value. + /// + /// This method sets the metric to the specified value, replacing any previous value. + /// Gauge metrics are used for values that can fluctuate up and down. + /// + /// - Parameters: + /// - metric: The name of the metric to record. + /// - value: The value to record for the metric. + /// - cardinalities: The dimensions along which the metric will be aggregated. + func record(_ metric: String, value: Double, cardinalities: MetricTelemetry.Cardinalities) { + _aggregations.mutate { aggregations in + var aggregation = aggregations[cardinalities, default: [metric: 0]] + aggregation[metric, default: 0] = value + aggregations[cardinalities] = aggregation + } + } + + /// Flushes all aggregated metrics and returns them as telemetry events. + /// + /// This method: + /// 1. Converts all aggregated metrics into telemetry events + /// 2. Clears the internal aggregation storage + /// 3. Returns the generated events + /// + /// - Returns: An array of metric telemetry events ready to be sent to the backend. + func flush() -> [MetricTelemetry.Event] { + _aggregations.mutate { aggregations in + defer { aggregations = [:] } + + return aggregations.map { key, value in + // Group metrics with same cardinality in the same + // telemetry event + var attributes: [String: Encodable] = key + attributes.merge(value, uniquingKeysWith: { $1 }) + return MetricTelemetry.Event( + name: value.keys.joined(separator: ","), + attributes: attributes, + sampleRate: sampleRate + ) + } + } + } +} diff --git a/DatadogRUM/Sources/SDKMetrics/SessionEndedMetric.swift b/DatadogRUM/Sources/SDKMetrics/SessionEndedMetric.swift index 142da312cc..02956b8cac 100644 --- a/DatadogRUM/Sources/SDKMetrics/SessionEndedMetric.swift +++ b/DatadogRUM/Sources/SDKMetrics/SessionEndedMetric.swift @@ -95,9 +95,6 @@ internal class SessionEndedMetric { /// Indicates if the session was stopped through `stopSession()` API. private var wasStopped = false - /// Information about the upload quality during the session. - private var uploadQuality: [String: Attributes.UploadQuality] = [:] - /// If `RUM.Configuration.trackBackgroundEvents` was enabled for this session. private let tracksBackgroundEvents: Bool @@ -199,43 +196,6 @@ internal class SessionEndedMetric { wasStopped = true } - /// Tracks the upload quality metric for aggregation. - /// - /// - Parameters: - /// - attributes: The upload quality attributes - func track(uploadQuality attributes: [String: Encodable]) { - guard let track = attributes[UploadQualityMetric.track] as? String else { - return - } - - let uploadQuality = self.uploadQuality[track] ?? Attributes.UploadQuality( - cycleCount: 0, - failureCount: [:], - blockerCount: [:] - ) - - var failureCount = uploadQuality.failureCount - var blockerCount = uploadQuality.blockerCount - - if let failure = attributes[UploadQualityMetric.failure] as? String { - // Merge by incrementing values - failureCount.merge([failure: 1], uniquingKeysWith: +) - } - - if let blockers = attributes[UploadQualityMetric.blockers] as? [String] { - // Merge by incrementing values - blockerCount = blockers.reduce(into: blockerCount) { count, blocker in - count[blocker, default: 0] += 1 - } - } - - self.uploadQuality[track] = Attributes.UploadQuality( - cycleCount: uploadQuality.cycleCount + 1, - failureCount: failureCount, - blockerCount: blockerCount - ) - } - // MARK: - Exporting Attributes /// Set of quality and diagnostic attributes for the Session Ended metric. @@ -347,24 +307,6 @@ internal class SessionEndedMetric { /// Information on number of events missed due to absence of an active view. let noViewEventsCount: NoViewEventsCount - struct UploadQuality: Encodable { - let cycleCount: Int - let failureCount: [String: Int] - let blockerCount: [String: Int] - - enum CodingKeys: String, CodingKey { - case cycleCount = "cycle_count" - case failureCount = "failure_count" - case blockerCount = "blocker_count" - } - } - - /// Information about the upload quality during the session. - /// The upload quality is splitting between upload track name. - /// Tracks upload quality during the session, aggregating them by track name. - /// Each track reports its own upload quality metrics. - let uploadQuality: [String: UploadQuality] - enum CodingKeys: String, CodingKey { case processType = "process_type" case precondition @@ -376,7 +318,6 @@ internal class SessionEndedMetric { case sdkErrorsCount = "sdk_errors_count" case ntpOffset = "ntp_offset" case noViewEventsCount = "no_view_events_count" - case uploadQuality = "upload_quality" } } @@ -448,8 +389,7 @@ internal class SessionEndedMetric { resources: missedEvents[.resource] ?? 0, errors: missedEvents[.error] ?? 0, longTasks: missedEvents[.longTask] ?? 0 - ), - uploadQuality: uploadQuality + ) ) ] } diff --git a/DatadogRUM/Sources/SDKMetrics/SessionEndedMetricController.swift b/DatadogRUM/Sources/SDKMetrics/SessionEndedMetricController.swift index c5f4a7ab77..ad81a46ea8 100644 --- a/DatadogRUM/Sources/SDKMetrics/SessionEndedMetricController.swift +++ b/DatadogRUM/Sources/SDKMetrics/SessionEndedMetricController.swift @@ -117,14 +117,6 @@ internal final class SessionEndedMetricController { } } - /// Tracks the upload quality metric for aggregation. - /// - /// - Parameters: - /// - attributes: The upload quality attributes - func track(uploadQuality attributes: [String: Encodable], in sessionID: RUMUUID?) { - updateMetric(for: sessionID) { $0?.track(uploadQuality: attributes) } - } - private func updateMetric(for sessionID: RUMUUID?, _ mutation: (inout SessionEndedMetric?) throws -> Void) { _metricsBySessionID.mutate { metrics in guard let sessionID = (sessionID ?? pendingSessionIDs.last) else { diff --git a/DatadogRUM/Tests/Integrations/TelemetryInterceptorTests.swift b/DatadogRUM/Tests/Integrations/TelemetryInterceptorTests.swift index 685cc32bb5..ff5952cc05 100644 --- a/DatadogRUM/Tests/Integrations/TelemetryInterceptorTests.swift +++ b/DatadogRUM/Tests/Integrations/TelemetryInterceptorTests.swift @@ -27,28 +27,8 @@ class TelemetryInterceptorTests: XCTestCase { // Then metricController.endMetric(sessionID: sessionID, with: .mockRandom()) - let metric = try XCTUnwrap(telemetry.messages.lastMetric(named: SessionEndedMetric.Constants.name)) + let metric = try XCTUnwrap(telemetry.messages.lastMetricReport(named: SessionEndedMetric.Constants.name)) let rse = try XCTUnwrap(metric.attributes[SessionEndedMetric.Constants.rseKey] as? SessionEndedMetric.Attributes) XCTAssertEqual(rse.sdkErrorsCount.total, 1) } - - func testWhenInterceptingUploadQualityMetric_itItUpdatesSessionEndedMetric() throws { - let sessionID: RUMUUID = .mockRandom() - - // Given - let metricController = SessionEndedMetricController(telemetry: telemetry, sampleRate: 100) - let interceptor = TelemetryInterceptor(sessionEndedMetric: metricController) - - // When - metricController.startMetric(sessionID: sessionID, precondition: .mockRandom(), context: .mockAny(), tracksBackgroundEvents: .mockRandom()) - let metricTelemetry: TelemetryMessage = .metric(MetricTelemetry(name: UploadQualityMetric.name, attributes: [UploadQualityMetric.track: "feature"], sampleRate: .mockRandom())) - let result = interceptor.receive(message: .telemetry(metricTelemetry), from: NOPDatadogCore()) - XCTAssertTrue(result) - - // Then - metricController.endMetric(sessionID: sessionID, with: .mockRandom()) - let metric = try XCTUnwrap(telemetry.messages.lastMetric(named: SessionEndedMetric.Constants.name)) - let rse = try XCTUnwrap(metric.attributes[SessionEndedMetric.Constants.rseKey] as? SessionEndedMetric.Attributes) - XCTAssertEqual(rse.uploadQuality["feature"]?.cycleCount, 1) - } } diff --git a/DatadogRUM/Tests/SDKMetrics/MetricTelemetryAggregatorTests.swift b/DatadogRUM/Tests/SDKMetrics/MetricTelemetryAggregatorTests.swift new file mode 100644 index 0000000000..e8e6319b01 --- /dev/null +++ b/DatadogRUM/Tests/SDKMetrics/MetricTelemetryAggregatorTests.swift @@ -0,0 +1,98 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ + +import XCTest +import DatadogInternal +import TestUtilities +@testable import DatadogRUM + +class MetricTelemetryAggregatorTests: XCTestCase { + func testCounterIncrement() throws { + // Given + let aggregator = MetricTelemetryAggregator() + + let metric1: String = .mockRandom() + + let cardinalities1: MetricTelemetry.Cardinalities = [ + .mockRandom(): .string(.mockRandom()) + ] + + let metric2: String = .mockRandom() + let cardinalities2: MetricTelemetry.Cardinalities = [ + .mockRandom(): .string(.mockRandom()) + ] + + let iterations: Int = .mockRandom(min: 0, max: 100) + + // When + for _ in 0.. SpanEvent /// The sampling rate for spans created with the default tracer. diff --git a/TestUtilities/Sources/Mocks/DatadogInternal/TelemetryMocks.swift b/TestUtilities/Sources/Mocks/DatadogInternal/TelemetryMocks.swift index a7ea4dd35e..ffd3b964de 100644 --- a/TestUtilities/Sources/Mocks/DatadogInternal/TelemetryMocks.swift +++ b/TestUtilities/Sources/Mocks/DatadogInternal/TelemetryMocks.swift @@ -71,28 +71,58 @@ public class TelemetryMock: Telemetry, CustomStringConvertible { description.append("\n - [error] \(message), kind: \(kind), stack: \(stack)") case .configuration(let configuration): description.append("\n- [configuration] \(configuration)") - case let .metric(metric): + case let .metric(.report(metric)): let attributesString = metric.attributes.map({ "\($0.key): \($0.value)" }).joined(separator: ", ") description.append("\n- [metric] '\(metric.name)' (" + attributesString + ")") case .usage(let usage): description.append("\n- [usage] \(usage)") + case let .metric(.increment(metric, by: value, attributes)): + let attributesString = attributes.map({ "\($0.key): \($0.value)" }).joined(separator: ", ") + description.append("\n- [metric] '\(metric)' + \(value) (" + attributesString + ")") + case let .metric(.record(metric, value, attributes)): + let attributesString = attributes.map({ "\($0.key): \($0.value)" }).joined(separator: ", ") + description.append("\n- [metric] '\(metric)' = \(value) (" + attributesString + ")") } } } public extension Array where Element == TelemetryMessage { /// Returns properties of the first metric message of given name. - func firstMetric(named metricName: String) -> MetricTelemetry? { - return compactMap({ $0.asMetric }) + func firstMetricReport(named metricName: String) -> MetricTelemetry.Event? { + return compactMap({ $0.asMetricReport }) .first(where: { $0.name == metricName }) } /// Returns properties of the first metric message of given name. - func lastMetric(named metricName: String) -> MetricTelemetry? { - return compactMap({ $0.asMetric }) + func lastMetricReport(named metricName: String) -> MetricTelemetry.Event? { + return compactMap({ $0.asMetricReport }) .last(where: { $0.name == metricName }) } + /// Returns properties of the first metric message of given name. + func firstMetricIncrement(named metricName: String) -> (metric: String, increment: Double, cardinalities: MetricTelemetry.Cardinalities)? { + return compactMap({ $0.asMetricIncrement }) + .first(where: { $0.metric == metricName }) + } + + /// Returns properties of the first metric message of given name. + func lastMetricIncrement(named metricName: String) -> (metric: String, increment: Double, cardinalities: MetricTelemetry.Cardinalities)? { + return compactMap({ $0.asMetricIncrement }) + .last(where: { $0.metric == metricName }) + } + + /// Returns properties of the first metric message of given name. + func firstMetricRecord(named metricName: String) -> (metric: String, value: Double, cardinalities: MetricTelemetry.Cardinalities)? { + return compactMap({ $0.asMetricRecord }) + .first(where: { $0.metric == metricName }) + } + + /// Returns properties of the first metric message of given name. + func lastMetricRecord(named metricName: String) -> (metric: String, value: Double, cardinalities: MetricTelemetry.Cardinalities)? { + return compactMap({ $0.asMetricRecord }) + .last(where: { $0.metric == metricName }) + } + /// Returns attributes of the first debug telemetry in this array. func firstDebug() -> (id: String, message: String, attributes: [String: Encodable]?)? { return compactMap { $0.asDebug }.first @@ -139,14 +169,30 @@ public extension TelemetryMessage { return configuration } - /// Extracts metric attributes if this is metric message. - var asMetric: MetricTelemetry? { - guard case let .metric(metric) = self else { + /// Extracts metric report if this is metric message. + var asMetricReport: MetricTelemetry.Event? { + guard case let .metric(.report(metric)) = self else { return nil } return metric } + /// Extracts metric increment if this is metric message. + var asMetricIncrement: (metric: String, increment: Double, cardinalities: MetricTelemetry.Cardinalities)? { + guard case let .metric(.increment(name, value, cardinalities)) = self else { + return nil + } + return (name, value, cardinalities) + } + + /// Extracts metric record if this is metric message. + var asMetricRecord: (metric: String, value: Double, cardinalities: MetricTelemetry.Cardinalities)? { + guard case let .metric(.record(name, value, cardinalities)) = self else { + return nil + } + return (name, value, cardinalities) + } + var asUsage: UsageTelemetry? { guard case let .usage(usage) = self else { return nil