Showing 4 changed files with 148 additions and 15 deletions
+1 -1
HealthProbe/Doc/04-project/IMPLEMENTATION_STATUS.md
@@ -52,7 +52,7 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m
52 52
 - Snapshots timeline/detail rows, Data Types list/detail rows, and record drill-down are archive/cache-backed for new archive v2 observations when cache rows exist.
53 53
 - Legacy SwiftData-only snapshots are reset for archive v2 test installs rather than migrated.
54 54
 - Capture strategy and some legacy SwiftData transition paths may still decode or cache too much data for low-end devices.
55
-- Very large first-run HealthKit imports may still require adaptive paging, retryable partial progress, and background-friendly collection beyond the current smaller pages, chunked persistence, and prepared-statement reuse.
55
+- Very large first-run HealthKit imports may still require adaptive paging, retryable partial progress, and background-friendly collection beyond the current smaller pages, chunked persistence, and prepared-statement reuse. Diagnostic import reports now also expose explicit per-metric and aggregate fetch / processing / insert / finalize timings so large import runs can be compared without inferring phases from progress counters.
56 56
 - Old prototype database compatibility is no longer required.
57 57
 - Initial SQLite archive tests cover open/init/reset/idempotency, snapshot-level observation grouping, legacy mirror removal, small observation diffs, large synthetic diff pagination, formal timing/memory metrics, materialized aggregate comparison, source/provenance breakdowns, consolidation-evidence labels, export preview, paged JSON output, and manifest row persistence.
58 58
 - Initial Core Data cache tests cover full rebuild from SQLite and delete-cache-then-rebuild without losing archive data.
+97 -12
HealthProbe/Services/HealthKitService.swift
@@ -932,11 +932,12 @@ final class HealthKitService {
932 932
         let earliestResult = await earliestTask
933 933
         let latestResult = await latestTask
934 934
         var apiCalls = [earliestResult.apiCall, latestResult.apiCall]
935
+        let dateFetchElapsedSeconds = earliestResult.apiCall.elapsedSeconds + latestResult.apiCall.elapsedSeconds
935 936
 
936 937
         guard earliestResult.apiCall.status == .complete, latestResult.apiCall.status == .complete else {
937 938
             let status = firstImpairedStatus(in: apiCalls)
938 939
             let quality = diagnosticQuality(for: status)
939
-            return TypeCountFetchResult(
940
+            var result = TypeCountFetchResult(
940 941
                 typeIdentifier: monitoredType.id,
941 942
                 displayName: monitoredType.displayName,
942 943
                 count: -1,
@@ -953,6 +954,8 @@ final class HealthKitService {
953 954
                 records: [],
954 955
                 recordArchiveData: nil
955 956
             )
957
+            result.timingBreakdown.fetchElapsedSeconds = dateFetchElapsedSeconds
958
+            return result
956 959
         }
957 960
 
958 961
         let earliest = earliestResult.value ?? nil
@@ -979,7 +982,7 @@ final class HealthKitService {
979 982
         guard distributionResult.apiCall.status == .complete, let distribution = distributionResult.value else {
980 983
             let status = distributionResult.apiCall.status
981 984
             let quality = diagnosticQuality(for: status)
982
-            return TypeCountFetchResult(
985
+            var result = TypeCountFetchResult(
983 986
                 typeIdentifier: monitoredType.id,
984 987
                 displayName: monitoredType.displayName,
985 988
                 count: -1,
@@ -996,6 +999,8 @@ final class HealthKitService {
996 999
                 records: [],
997 1000
                 recordArchiveData: nil
998 1001
             )
1002
+            result.timingBreakdown.fetchElapsedSeconds = dateFetchElapsedSeconds
1003
+            return result
999 1004
         }
1000 1005
 
1001 1006
         let contentHash = distribution.contentHash ?? HashService.typeHash(
@@ -1028,9 +1033,13 @@ final class HealthKitService {
1028 1033
             detail: "Preparing record archive",
1029 1034
             recordCount: distribution.totalCount
1030 1035
         )
1036
+        var timingBreakdown = distribution.timingBreakdown
1037
+        timingBreakdown.fetchElapsedSeconds += dateFetchElapsedSeconds
1038
+        let recordArchiveStartedAt = Date()
1031 1039
         let recordArchiveData = distribution.recordArchiveData ?? HealthRecordArchive.encode(distribution.records)
1040
+        timingBreakdown.processingElapsedSeconds += Date().timeIntervalSince(recordArchiveStartedAt)
1032 1041
 
1033
-        return TypeCountFetchResult(
1042
+        var result = TypeCountFetchResult(
1034 1043
             typeIdentifier: monitoredType.id,
1035 1044
             displayName: monitoredType.displayName,
1036 1045
             count: distribution.totalCount,
@@ -1055,6 +1064,8 @@ final class HealthKitService {
1055 1064
             records: [],
1056 1065
             recordArchiveData: recordArchiveData
1057 1066
         )
1067
+        result.timingBreakdown = timingBreakdown
1068
+        return result
1058 1069
     }
1059 1070
 
1060 1071
     // MARK: - HealthKit queries
@@ -1072,6 +1083,7 @@ final class HealthKitService {
1072 1083
         let startedFromAnchor = anchor != nil
1073 1084
         let incrementalStrategy = DistributionCaptureConfiguration.incrementalStrategy
1074 1085
         var persistenceState = incrementalStrategy.makePersistenceState()
1086
+        var captureTimings = DistributionCaptureTimings.zero
1075 1087
         let estimatedPageCount = startedFromAnchor
1076 1088
             ? Self.estimatedPageCount(
1077 1089
                 for: previousDistribution.count,
@@ -1097,6 +1109,7 @@ final class HealthKitService {
1097 1109
                 samplesPerSecond: 0
1098 1110
             )
1099 1111
 
1112
+            let firstPageFetchStartedAt = Date()
1100 1113
             let page = try await withTimeout(seconds: DistributionCaptureConfiguration.pageTimeoutSeconds) {
1101 1114
                 try await self.fetchDistributionPage(
1102 1115
                     for: sampleType,
@@ -1105,6 +1118,7 @@ final class HealthKitService {
1105 1118
                     limit: incrementalStrategy.queryPageLimit
1106 1119
                 )
1107 1120
             }
1121
+            captureTimings.fetchElapsedSeconds += Date().timeIntervalSince(firstPageFetchStartedAt)
1108 1122
             let archiveResult = try await archivePage(
1109 1123
                 page,
1110 1124
                 sampleType: sampleType,
@@ -1118,6 +1132,7 @@ final class HealthKitService {
1118 1132
                 persistenceState: persistenceState
1119 1133
             )
1120 1134
             persistenceState = archiveResult.persistenceState
1135
+            captureTimings.insertElapsedSeconds += archiveResult.insertElapsedSeconds
1121 1136
             anchor = page.anchor
1122 1137
 
1123 1138
             if page.samples.isEmpty, page.deletedObjects.isEmpty,
@@ -1127,11 +1142,13 @@ final class HealthKitService {
1127 1142
                     earliestDate: earliestDate,
1128 1143
                     latestDate: latestDate
1129 1144
                ) {
1145
+                let verificationStartedAt = Date()
1130 1146
                 try await archiveStore.markVerification(
1131 1147
                     sampleType: sampleType,
1132 1148
                     verifiedAt: Date(),
1133 1149
                     observationID: archiveObservationID
1134 1150
                 )
1151
+                captureTimings.finalizeElapsedSeconds += Date().timeIntervalSince(verificationStartedAt)
1135 1152
                 progress?.updateBlockProgress(
1136 1153
                     typeIdentifier,
1137 1154
                     detail: "No HealthKit delta",
@@ -1139,7 +1156,15 @@ final class HealthKitService {
1139 1156
                     elapsedSeconds: Date().timeIntervalSince(progressStarted),
1140 1157
                     samplesPerSecond: 0
1141 1158
                 )
1142
-                return unchanged
1159
+                return SampleDistribution(
1160
+                    totalCount: unchanged.totalCount,
1161
+                    bins: unchanged.bins,
1162
+                    records: unchanged.records,
1163
+                    contentHash: unchanged.contentHash,
1164
+                    yearlyCounts: unchanged.yearlyCounts,
1165
+                    recordArchiveData: unchanged.recordArchiveData,
1166
+                    timingBreakdown: captureTimings.importBreakdown
1167
+                )
1143 1168
             }
1144 1169
 
1145 1170
             firstDeltaPage = page
@@ -1157,11 +1182,15 @@ final class HealthKitService {
1157 1182
             )
1158 1183
         }
1159 1184
 
1185
+        let recordMapStartedAt = Date()
1160 1186
         var recordMap = startedFromAnchor ? try previousDistribution.makeRecordMap() : [:]
1187
+        captureTimings.processingElapsedSeconds += Date().timeIntervalSince(recordMapStartedAt)
1161 1188
         var shouldFetchNextPage = true
1162 1189
 
1163 1190
         if let firstDeltaPage {
1191
+            let firstPageApplyStartedAt = Date()
1164 1192
             applyDistributionPage(firstDeltaPage, sampleType: sampleType, to: &recordMap)
1193
+            captureTimings.processingElapsedSeconds += Date().timeIntervalSince(firstPageApplyStartedAt)
1165 1194
             processedEventCount += pageEventCount(firstDeltaPage)
1166 1195
             shouldFetchNextPage = firstDeltaPage.samples.count + firstDeltaPage.deletedObjects.count >= incrementalStrategy.queryPageLimit
1167 1196
             progress?.updateBlockProgress(
@@ -1197,6 +1226,7 @@ final class HealthKitService {
1197 1226
                 )
1198 1227
             )
1199 1228
 
1229
+            let pageFetchStartedAt = Date()
1200 1230
             let page = try await withTimeout(seconds: DistributionCaptureConfiguration.pageTimeoutSeconds) {
1201 1231
                 try await self.fetchDistributionPage(
1202 1232
                     for: sampleType,
@@ -1205,6 +1235,7 @@ final class HealthKitService {
1205 1235
                     limit: incrementalStrategy.queryPageLimit
1206 1236
                 )
1207 1237
             }
1238
+            captureTimings.fetchElapsedSeconds += Date().timeIntervalSince(pageFetchStartedAt)
1208 1239
             let archiveResult = try await archivePage(
1209 1240
                 page,
1210 1241
                 sampleType: sampleType,
@@ -1218,9 +1249,12 @@ final class HealthKitService {
1218 1249
                 persistenceState: persistenceState
1219 1250
             )
1220 1251
             persistenceState = archiveResult.persistenceState
1252
+            captureTimings.insertElapsedSeconds += archiveResult.insertElapsedSeconds
1221 1253
             anchor = page.anchor
1222 1254
 
1255
+            let applyStartedAt = Date()
1223 1256
             applyDistributionPage(page, sampleType: sampleType, to: &recordMap)
1257
+            captureTimings.processingElapsedSeconds += Date().timeIntervalSince(applyStartedAt)
1224 1258
             processedEventCount += pageEventCount(page)
1225 1259
             shouldFetchNextPage = page.samples.count + page.deletedObjects.count >= incrementalStrategy.queryPageLimit
1226 1260
             progress?.updateBlockProgress(
@@ -1239,8 +1273,11 @@ final class HealthKitService {
1239 1273
             )
1240 1274
         }
1241 1275
 
1276
+        let verificationStartedAt = Date()
1242 1277
         try await archiveStore.markVerification(sampleType: sampleType, verifiedAt: Date(), observationID: archiveObservationID)
1278
+        captureTimings.finalizeElapsedSeconds += Date().timeIntervalSince(verificationStartedAt)
1243 1279
 
1280
+        let sortedRecordsStartedAt = Date()
1244 1281
         let sortedKeys = recordMap.keys.sorted {
1245 1282
             guard let left = recordMap[$0],
1246 1283
                   let right = recordMap[$1] else {
@@ -1270,6 +1307,7 @@ final class HealthKitService {
1270 1307
             typeIdentifier: typeIdentifier,
1271 1308
             recordFingerprints: sortedRecords.map(\.recordFingerprint)
1272 1309
         )
1310
+        captureTimings.processingElapsedSeconds += Date().timeIntervalSince(sortedRecordsStartedAt)
1273 1311
 
1274 1312
         progress?.updateBlockProgress(
1275 1313
             typeIdentifier,
@@ -1284,7 +1322,8 @@ final class HealthKitService {
1284 1322
                 records: [],
1285 1323
                 contentHash: nil,
1286 1324
                 yearlyCounts: nil,
1287
-                recordArchiveData: nil
1325
+                recordArchiveData: nil,
1326
+                timingBreakdown: captureTimings.importBreakdown
1288 1327
             )
1289 1328
         }
1290 1329
 
@@ -1306,7 +1345,8 @@ final class HealthKitService {
1306 1345
             records: sortedRecords,
1307 1346
             contentHash: contentHash,
1308 1347
             yearlyCounts: nil,
1309
-            recordArchiveData: nil
1348
+            recordArchiveData: nil,
1349
+            timingBreakdown: captureTimings.importBreakdown
1310 1350
         )
1311 1351
     }
1312 1352
 
@@ -1321,6 +1361,7 @@ final class HealthKitService {
1321 1361
     ) async throws -> SampleDistribution {
1322 1362
         let importStrategy = DistributionCaptureConfiguration.initialImportStrategy(for: typeIdentifier)
1323 1363
         var persistenceState = importStrategy.makePersistenceState()
1364
+        var captureTimings = DistributionCaptureTimings.zero
1324 1365
         var anchor: HKQueryAnchor?
1325 1366
         var pageNumber = 0
1326 1367
         var recordCount = 0
@@ -1350,6 +1391,7 @@ final class HealthKitService {
1350 1391
                 )
1351 1392
             )
1352 1393
 
1394
+            let pageFetchStartedAt = Date()
1353 1395
             let page = try await withTimeout(seconds: DistributionCaptureConfiguration.pageTimeoutSeconds) {
1354 1396
                 try await self.fetchDistributionPage(
1355 1397
                     for: sampleType,
@@ -1358,6 +1400,7 @@ final class HealthKitService {
1358 1400
                     limit: importStrategy.queryPageLimit
1359 1401
                 )
1360 1402
             }
1403
+            captureTimings.fetchElapsedSeconds += Date().timeIntervalSince(pageFetchStartedAt)
1361 1404
             let archiveResult = try await archivePage(
1362 1405
                 page,
1363 1406
                 sampleType: sampleType,
@@ -1371,8 +1414,10 @@ final class HealthKitService {
1371 1414
                 persistenceState: persistenceState
1372 1415
             )
1373 1416
             persistenceState = archiveResult.persistenceState
1417
+            captureTimings.insertElapsedSeconds += archiveResult.insertElapsedSeconds
1374 1418
             anchor = page.anchor
1375 1419
 
1420
+            let processStartedAt = Date()
1376 1421
             for sample in page.samples {
1377 1422
                 autoreleasepool {
1378 1423
                     let value = Self.recordValue(for: sample, sampleType: sampleType, typeIdentifier: typeIdentifier)
@@ -1384,6 +1429,7 @@ final class HealthKitService {
1384 1429
                     recordCount += 1
1385 1430
                 }
1386 1431
             }
1432
+            captureTimings.processingElapsedSeconds += Date().timeIntervalSince(processStartedAt)
1387 1433
 
1388 1434
             processedEventCount += pageEventCount(page)
1389 1435
             shouldFetchNextPage = page.samples.count + page.deletedObjects.count >= importStrategy.queryPageLimit
@@ -1405,7 +1451,9 @@ final class HealthKitService {
1405 1451
             await Task.yield()
1406 1452
         }
1407 1453
 
1454
+        let finalizeHashStartedAt = Date()
1408 1455
         let contentHash = hashBuilder.finalize()
1456
+        captureTimings.processingElapsedSeconds += Date().timeIntervalSince(finalizeHashStartedAt)
1409 1457
         progress?.updateBlockProgress(
1410 1458
             typeIdentifier,
1411 1459
             detail: pageNumber == 1 ? "Imported 1 page" : "Imported \(pageNumber) pages",
@@ -1417,7 +1465,9 @@ final class HealthKitService {
1417 1465
             )
1418 1466
         )
1419 1467
 
1468
+        let verificationStartedAt = Date()
1420 1469
         try await archiveStore.markVerification(sampleType: sampleType, verifiedAt: Date(), observationID: archiveObservationID)
1470
+        captureTimings.finalizeElapsedSeconds += Date().timeIntervalSince(verificationStartedAt)
1421 1471
 
1422 1472
         guard recordCount > 0 || anchor != nil else {
1423 1473
             return SampleDistribution(
@@ -1426,7 +1476,8 @@ final class HealthKitService {
1426 1476
                 records: [],
1427 1477
                 contentHash: nil,
1428 1478
                 yearlyCounts: nil,
1429
-                recordArchiveData: nil
1479
+                recordArchiveData: nil,
1480
+                timingBreakdown: captureTimings.importBreakdown
1430 1481
             )
1431 1482
         }
1432 1483
 
@@ -1434,6 +1485,10 @@ final class HealthKitService {
1434 1485
         let rawBinEnd = latestDate ?? latestRecordDate ?? binStart
1435 1486
         let binEnd = rawBinEnd > binStart ? rawBinEnd : binStart.addingTimeInterval(1)
1436 1487
 
1488
+        let archiveFinalizeStartedAt = Date()
1489
+        let recordArchiveData = archiveWriter.finalize()
1490
+        captureTimings.processingElapsedSeconds += Date().timeIntervalSince(archiveFinalizeStartedAt)
1491
+
1437 1492
         return SampleDistribution(
1438 1493
             totalCount: recordCount,
1439 1494
             bins: [
@@ -1448,7 +1503,8 @@ final class HealthKitService {
1448 1503
             records: [],
1449 1504
             contentHash: contentHash,
1450 1505
             yearlyCounts: yearMap,
1451
-            recordArchiveData: archiveWriter.finalize()
1506
+            recordArchiveData: recordArchiveData,
1507
+            timingBreakdown: captureTimings.importBreakdown
1452 1508
         )
1453 1509
     }
1454 1510
 
@@ -1579,6 +1635,7 @@ final class HealthKitService {
1579 1635
         let completedEventCountBeforePage = processedEventCount ?? 0
1580 1636
         var persistedSampleCount = 0
1581 1637
         var persistedDeletedCount = 0
1638
+        var insertElapsedSeconds: TimeInterval = 0
1582 1639
         let observedAt = Date()
1583 1640
         if !page.samples.isEmpty {
1584 1641
             var batchStart = 0
@@ -1616,8 +1673,10 @@ final class HealthKitService {
1616 1673
                     observedAt: observedAt,
1617 1674
                     observationID: observationID
1618 1675
                 )
1676
+                let batchElapsedSeconds = Date().timeIntervalSince(batchStartedAt)
1677
+                insertElapsedSeconds += batchElapsedSeconds
1619 1678
                 persistedSampleCount += sampleBatch.count
1620
-                persistenceState.registerBatchDuration(Date().timeIntervalSince(batchStartedAt))
1679
+                persistenceState.registerBatchDuration(batchElapsedSeconds)
1621 1680
                 batchStart = batchEnd
1622 1681
                 await Task.yield()
1623 1682
             }
@@ -1649,12 +1708,14 @@ final class HealthKitService {
1649 1708
                         }()
1650 1709
                     )
1651 1710
                 }
1711
+                let batchStartedAt = Date()
1652 1712
                 let deletedCount = try await archiveStore.recordDisappearances(
1653 1713
                     sampleUUIDHashes: deleteBatch,
1654 1714
                     sampleTypeIdentifier: sampleType.identifier,
1655 1715
                     observedMissingAt: observedAt,
1656 1716
                     observationID: observationID
1657 1717
                 )
1718
+                insertElapsedSeconds += Date().timeIntervalSince(batchStartedAt)
1658 1719
                 _ = deletedCount
1659 1720
                 persistedDeletedCount += deleteBatch.count
1660 1721
                 deleteBatchStart = deleteBatchEnd
@@ -1664,7 +1725,8 @@ final class HealthKitService {
1664 1725
         return DistributionPageArchiveResult(
1665 1726
             persistenceState: persistenceState,
1666 1727
             persistedSampleCount: persistedSampleCount,
1667
-            persistedDeletedCount: persistedDeletedCount
1728
+            persistedDeletedCount: persistedDeletedCount,
1729
+            insertElapsedSeconds: insertElapsedSeconds
1668 1730
         )
1669 1731
     }
1670 1732
 
@@ -2134,6 +2196,7 @@ private struct SampleDistribution: Sendable {
2134 2196
     let contentHash: String?
2135 2197
     let yearlyCounts: [Int: Int]?
2136 2198
     let recordArchiveData: Data?
2199
+    let timingBreakdown: ImportTimingBreakdown
2137 2200
 }
2138 2201
 
2139 2202
 private struct SampleDistributionPage: Sendable {
@@ -2318,7 +2381,8 @@ private struct PreviousDistributionState: Sendable {
2318 2381
             records: [],
2319 2382
             contentHash: effectiveContentHash,
2320 2383
             yearlyCounts: yearlyCounts,
2321
-            recordArchiveData: recordArchiveData
2384
+            recordArchiveData: recordArchiveData,
2385
+            timingBreakdown: .zero
2322 2386
         )
2323 2387
     }
2324 2388
 
@@ -2398,6 +2462,7 @@ private struct TypeCountFetchResult: Sendable {
2398 2462
     var suggestedRetryTimeout: TimeInterval = 0
2399 2463
     var timeoutCount: Int = 0
2400 2464
     var successCount: Int = 0
2465
+    var timingBreakdown: ImportTimingBreakdown = .zero
2401 2466
 
2402 2467
     mutating func applyTimeoutProfile(_ profile: LocalMetricTimeoutProfile) {
2403 2468
         timeoutMode = profile.timeoutMode
@@ -2485,7 +2550,8 @@ private extension SnapshotFetchProgress {
2485 2550
             learnedTimeout: result.learnedTimeout,
2486 2551
             suggestedRetryTimeout: result.suggestedRetryTimeout,
2487 2552
             timeoutCount: result.timeoutCount,
2488
-            successCount: result.successCount
2553
+            successCount: result.successCount,
2554
+            timingBreakdown: result.timingBreakdown
2489 2555
         )
2490 2556
     }
2491 2557
 
@@ -2546,6 +2612,25 @@ struct DistributionPageArchiveResult: Equatable {
2546 2612
     let persistenceState: DistributionPagePersistenceState
2547 2613
     let persistedSampleCount: Int
2548 2614
     let persistedDeletedCount: Int
2615
+    let insertElapsedSeconds: TimeInterval
2616
+}
2617
+
2618
+private struct DistributionCaptureTimings: Sendable, Equatable {
2619
+    var fetchElapsedSeconds: TimeInterval = 0
2620
+    var processingElapsedSeconds: TimeInterval = 0
2621
+    var insertElapsedSeconds: TimeInterval = 0
2622
+    var finalizeElapsedSeconds: TimeInterval = 0
2623
+
2624
+    static let zero = DistributionCaptureTimings()
2625
+
2626
+    var importBreakdown: ImportTimingBreakdown {
2627
+        ImportTimingBreakdown(
2628
+            fetchElapsedSeconds: fetchElapsedSeconds,
2629
+            processingElapsedSeconds: processingElapsedSeconds,
2630
+            insertElapsedSeconds: insertElapsedSeconds,
2631
+            finalizeElapsedSeconds: finalizeElapsedSeconds
2632
+        )
2633
+    }
2549 2634
 }
2550 2635
 
2551 2636
 enum DistributionCaptureConfiguration {
+27 -1
HealthProbe/Utilities/SnapshotFetchProgress.swift
@@ -1,5 +1,18 @@
1 1
 import Foundation
2 2
 
3
+struct ImportTimingBreakdown: Sendable, Equatable {
4
+    var fetchElapsedSeconds: TimeInterval = 0
5
+    var processingElapsedSeconds: TimeInterval = 0
6
+    var insertElapsedSeconds: TimeInterval = 0
7
+    var finalizeElapsedSeconds: TimeInterval = 0
8
+
9
+    static let zero = ImportTimingBreakdown()
10
+
11
+    var accountedElapsedSeconds: TimeInterval {
12
+        fetchElapsedSeconds + processingElapsedSeconds + insertElapsedSeconds + finalizeElapsedSeconds
13
+    }
14
+}
15
+
3 16
 struct HealthKitAPICallResult: Sendable, Equatable {
4 17
     enum Status: String, Sendable {
5 18
         case complete
@@ -99,6 +112,7 @@ final class SnapshotFetchProgress {
99 112
         var suggestedRetryTimeout: TimeInterval = 0
100 113
         var timeoutCount: Int = 0
101 114
         var successCount: Int = 0
115
+        var timingBreakdown: ImportTimingBreakdown = .zero
102 116
         var blockProgress: String = ""
103 117
         var blockElapsedSeconds: TimeInterval = 0
104 118
         var blockSamplesPerSecond: Double = 0
@@ -127,6 +141,16 @@ final class SnapshotFetchProgress {
127 141
     var totalRecords: Int {
128 142
         types.reduce(0) { $0 + max($1.recordCount, 0) }
129 143
     }
144
+    var aggregateTimingBreakdown: ImportTimingBreakdown {
145
+        types.reduce(.zero) { partial, type in
146
+            var combined = partial
147
+            combined.fetchElapsedSeconds += type.timingBreakdown.fetchElapsedSeconds
148
+            combined.processingElapsedSeconds += type.timingBreakdown.processingElapsedSeconds
149
+            combined.insertElapsedSeconds += type.timingBreakdown.insertElapsedSeconds
150
+            combined.finalizeElapsedSeconds += type.timingBreakdown.finalizeElapsedSeconds
151
+            return combined
152
+        }
153
+    }
130 154
 
131 155
     init(monitoredTypes: [(id: String, displayName: String)]) {
132 156
         self.totalTypeCount = monitoredTypes.count
@@ -188,7 +212,8 @@ final class SnapshotFetchProgress {
188 212
         learnedTimeout: TimeInterval,
189 213
         suggestedRetryTimeout: TimeInterval,
190 214
         timeoutCount: Int,
191
-        successCount: Int
215
+        successCount: Int,
216
+        timingBreakdown: ImportTimingBreakdown
192 217
     ) {
193 218
         let index = visibleTypeIndex(for: id)
194 219
         types[index].quality = quality
@@ -207,6 +232,7 @@ final class SnapshotFetchProgress {
207 232
         types[index].suggestedRetryTimeout = suggestedRetryTimeout
208 233
         types[index].timeoutCount = timeoutCount
209 234
         types[index].successCount = successCount
235
+        types[index].timingBreakdown = timingBreakdown
210 236
     }
211 237
 
212 238
     func updateTimeoutProfile(
+23 -1
HealthProbe/Views/Dashboard/DashboardView.swift
@@ -132,6 +132,10 @@ struct DashboardView: View {
132 132
         return "\(Int(seconds) / 60)m \(Int(seconds) % 60)s"
133 133
     }
134 134
 
135
+    private func timingResidual(totalElapsed: TimeInterval, breakdown: ImportTimingBreakdown) -> TimeInterval {
136
+        max(0, totalElapsed - breakdown.accountedElapsedSeconds)
137
+    }
138
+
135 139
     private func qualityLabel(progress: SnapshotFetchProgress) -> String {
136 140
         switch viewModel.snapshotProgress {
137 141
         case .complete: return "Complete"
@@ -325,7 +329,7 @@ struct DashboardView: View {
325 329
         lines.append("operationResult: \(operationResultValue())")
326 330
         lines.append("primaryObjectType: snapshot")
327 331
         lines.append("primaryObjectID: \(snapshotID)")
328
-        lines.append("reportSchemaVersion: 1")
332
+        lines.append("reportSchemaVersion: 2")
329 333
         lines.append("reportGeneratedAt: \(reportGeneratedAt)")
330 334
         lines.append("")
331 335
         lines.append("OPERATION SUMMARY")
@@ -346,6 +350,7 @@ struct DashboardView: View {
346 350
         lines.append("Do not infer deletion from partial snapshots")
347 351
         lines.append("All-values-missing metrics require review before saving")
348 352
         lines.append("Timeout means HealthProbe cancelled after configured timeout, not necessarily HealthKit denial")
353
+        lines.append("Per-metric timing sums are cumulative work and can exceed wall-clock duration because type fetches overlap")
349 354
         lines.append("")
350 355
         lines.append("CONFIGURATION")
351 356
         lines.append("adaptiveTimeoutsEnabled: \(progress.adaptiveTimeoutsEnabled ? "true" : "false")")
@@ -363,6 +368,18 @@ struct DashboardView: View {
363 368
         lines.append("STATISTICS")
364 369
         lines.append("Records:    \(progress.totalRecords)")
365 370
         lines.append("Types:      \(progress.types.count)/\(progress.totalTypeCount) processed, \(progress.completedCount) complete, \(degraded.count) degraded")
371
+        lines.append("WallClockDuration: \(duration)")
372
+        let aggregateTiming = progress.aggregateTimingBreakdown
373
+        let summedMetricTotalElapsed = progress.types.reduce(0) { $0 + max(0, $1.totalElapsedSeconds) }
374
+        let summedMetricResidualElapsed = progress.types.reduce(0) { partial, type in
375
+            partial + timingResidual(totalElapsed: type.totalElapsedSeconds, breakdown: type.timingBreakdown)
376
+        }
377
+        lines.append("SummedMetricTotalElapsed: \(formatDuration(summedMetricTotalElapsed))")
378
+        lines.append("SummedFetchElapsed: \(formatDuration(aggregateTiming.fetchElapsedSeconds))")
379
+        lines.append("SummedProcessingElapsed: \(formatDuration(aggregateTiming.processingElapsedSeconds))")
380
+        lines.append("SummedInsertElapsed: \(formatDuration(aggregateTiming.insertElapsedSeconds))")
381
+        lines.append("SummedFinalizeElapsed: \(formatDuration(aggregateTiming.finalizeElapsedSeconds))")
382
+        lines.append("SummedResidualElapsed: \(formatDuration(summedMetricResidualElapsed))")
366 383
         lines.append("")
367 384
         lines.append(failedLines)
368 385
 
@@ -401,6 +418,11 @@ struct DashboardView: View {
401 418
             lines.append("  timeoutCount: \(type.timeoutCount)")
402 419
             lines.append("  successCount: \(type.successCount)")
403 420
             lines.append("  totalElapsed: \(formatDuration(type.totalElapsedSeconds))")
421
+            lines.append("  fetchElapsed: \(formatDuration(type.timingBreakdown.fetchElapsedSeconds))")
422
+            lines.append("  processingElapsed: \(formatDuration(type.timingBreakdown.processingElapsedSeconds))")
423
+            lines.append("  insertElapsed: \(formatDuration(type.timingBreakdown.insertElapsedSeconds))")
424
+            lines.append("  finalizeElapsed: \(formatDuration(type.timingBreakdown.finalizeElapsedSeconds))")
425
+            lines.append("  residualElapsed: \(formatDuration(timingResidual(totalElapsed: type.totalElapsedSeconds, breakdown: type.timingBreakdown)))")
404 426
             if type.blockElapsedSeconds > 0 {
405 427
                 lines.append("  fetchProgressElapsed: \(formatDuration(type.blockElapsedSeconds))")
406 428
             }