MetricKit Extended and Compatibility Patterns
Use these patterns after choosing the iOS/iPadOS 27 MetricManager path or the isolated iOS 26 MXMetricManager compatibility path in the main skill.
Beta-sensitive: The iOS/iPadOS 27 examples follow Apple's current beta documentation. They have not been locally compiler-verified because Xcode 27 is unavailable. Re-check availability and signatures with the shipping SDK.
Contents
- Durable Report Outbox
- Metric and Diagnostic Analysis
- Key Metric Result Catalog
- Call Stack Trees
- Custom Signpost Metrics
- Extended Launch
- iOS 26 Compatibility
- Xcode Organizer
- Apple Documentation
Durable Report Outbox
The report consumer should perform one small, reliable operation: encode and durably enqueue the complete report.
Use an outbox record that includes:
- encoded
MetricReportorDiagnosticReportbytes - report kind
- local schema version
- app version and build
- receipt timestamp
- stable deduplication key
- upload-attempt count and next-attempt timestamp
Keep raw reports until the server accepts them or a documented retention limit expires. Parse derived fields in a separate worker so a parser failure cannot discard the source evidence.
@available(iOS 27.0, *)
func enqueue(_ report: MetricReport) async throws {
let data = try JSONEncoder().encode(report)
try await outbox.insert(
kind: .metric,
payload: data,
deduplicationKey: stableKey(for: report)
)
}
@available(iOS 27.0, *)
func enqueue(_ report: DiagnosticReport) async throws {
let data = try JSONEncoder().encode(report)
try await outbox.insert(
kind: .diagnostic,
payload: data,
deduplicationKey: stableKey(for: report)
)
}outbox and stableKey(for:) are application-owned abstractions. Build the key from stable report metadata and the encoded report rather than memory identity.
The upload worker should:
- Read a bounded batch.
- Upload with authentication and request-level idempotency.
- Retry transient failures with exponential backoff and jitter.
- Quarantine invalid records without blocking later records.
- Delete or mark uploaded only after server acknowledgement.
- Apply byte and age limits so telemetry cannot consume unbounded storage.
Do not rely on a modern backfill call. Apple's iOS 27 MetricManager documentation exposes the live metricReports and diagnosticReports sequences, not replacements for pastPayloads or pastDiagnosticPayloads.
Metric and Diagnostic Analysis
Daily metric reports
Use MetricReport.timeRange to establish the aggregation window. Treat environment as optional and retain the entire interval/state structure even if the first dashboard only uses intervalEntries.fullDayEntry.
@available(iOS 27.0, *)
func analyze(_ report: MetricReport) {
let fullDay = report.intervalEntries.fullDayEntry
for result in fullDay.values {
switch result {
case .hangTime(let value):
recordHangTime(value, interval: report.timeRange)
case .scrollHitchTime(let value):
recordScrollHitchTime(value, interval: report.timeRange)
case .cpuTime(let value):
recordCPUTime(value, interval: report.timeRange)
case .peakMemory(let value):
recordPeakMemory(value, interval: report.timeRange)
case .foregroundTermination(let value):
recordForegroundTerminations(value, interval: report.timeRange)
case .extendedLaunch(let value):
recordExtendedLaunch(value, interval: report.timeRange)
case .signpostInterval(let value):
recordSignpostInterval(value, interval: report.timeRange)
@unknown default:
break // The raw encoded report remains in the outbox.
}
}
}Metric reports are normally daily aggregates. Compare distributions across app versions, hardware, OS versions, and relevant environment fields. Avoid attributing a full-day value to one action without supporting local traces.
Individual diagnostic reports
Process the report only after durable storage:
@available(iOS 27.0, *)
func analyze(_ report: DiagnosticReport) {
switch report.result {
case .crash(let crash):
symbolicate(crash.callStackTree)
case .hang(let hang):
recordHang(hang.hangDuration)
symbolicate(hang.callStackTree)
case .cpuException(let exception):
recordCPUException(
totalCPUTime: exception.totalCPUTime,
sampledTime: exception.totalSampledTime
)
symbolicate(exception.callStackTree)
case .diskWriteException(let exception):
recordDiskWriteException(exception.totalBytesWritten)
symbolicate(exception.callStackTree)
case .appLaunch(let launch):
recordLaunchDiagnostic(launch.launchDuration)
symbolicate(launch.callStackTree)
case .memoryException(let memory):
symbolicate(memory.callStackTree)
@unknown default:
break // Preserve and revisit the raw report after updating the parser.
}
}Diagnostics are event-based and intended for prompt delivery when the system produces them. They are not a complete ledger of all crashes, hangs, launches, or resource events.
Key Metric Result Catalog
Load this catalog when mapping exact MetricResult cases into a parser or dashboard:
| Area | MetricResult cases |
|---|---|
| Responsiveness | hangTime, hitchTime, scrollHitchTime |
| Terminations | foregroundTermination, backgroundTermination |
| Runtime | totalForegroundTime, totalBackgroundTime, totalBackgroundAudioTime, totalBackgroundLocationTime, locationActivityTime |
| CPU and memory | cpuTime, cpuInstructionsCount, peakMemory, suspendedMemory |
| Network | totalWiFiUpload, totalWiFiDownload, totalCellularUpload, totalCellularDownload, cellularConditionTime |
| Launch | timeToFirstDraw, applicationResumeTime, optimizedTimeToFirstDraw, extendedLaunch |
| Storage | logicalDiskWrites, totalFileCount, totalFileSize, totalDiskSpaceCapacity |
| Display and GPU | pixelLuminance, gpuTime, metalFrameRate |
| Custom intervals | signpostInterval |
Call Stack Trees
CallStackTree is Codable and retains the structure needed for later analysis. Keep the whole tree plus binaryInfo before producing flattened display frames.
Use forEachFrame when the consumer only needs frame traversal. Use callStackThreads when thread relationships matter. Symbolicate against the exact archive and dSYMs for the report's app build.
Recommended server-side pipeline:
- Read the encoded diagnostic report.
- Resolve the app version/build to its archive.
- Match binary identifiers from
binaryInfo. - Symbolicate offsets against the matching dSYMs.
- Cluster signatures without deleting the original tree.
Custom Signpost Metrics
Use the retained iOS 27 manager to create a log handle:
let log = manager.logHandle(category: "Database")
mxSignpost(.begin, log: log, name: "Migration")
await migrateDatabase()
mxSignpost(.end, log: log, name: "Migration")The resulting aggregate is a MetricResult.signpostInterval value. Keep signpost category and name stable across releases so comparisons remain meaningful.
Prefer mxSignpost for MetricKit intervals that need resource measurements. Apple's documentation notes that OSSignposter intervals created with the MetricKit handle do not populate the same resource-measurement fields.
Extended Launch
Track launch-critical work through the retained manager:
@MainActor
@available(iOS 27.0, *)
func loadLaunchData(using manager: MetricManager) async throws -> AppData {
try await manager.trackLaunchTask(
id: "load-app-data",
onTrackingError: { error in
recordLaunchTrackingError(error)
}
) {
try await loadAppData()
}
}The tracked closure's result and application error propagate normally. onTrackingError reports MetricManager.LaunchTaskError without replacing the closure's outcome. Keep LaunchTaskID values stable and do not track noncritical background work as launch work.
Extended launch results appear under MetricResult.extendedLaunch.
iOS 26 Compatibility
Use this branch only for deployment targets that include iOS/iPadOS 26 or earlier supported versions. MXMetricManager is deprecated in iOS 27.
Subscriber and past payloads
Register one retained subscriber as early as possible:
final class LegacyMetricsSubscriber: NSObject, MXMetricManagerSubscriber {
static let shared = LegacyMetricsSubscriber()
private override init() {
super.init()
}
func start() {
let manager = MXMetricManager.shared
manager.add(self)
// Legacy-only backfill APIs.
persist(manager.pastPayloads)
persist(manager.pastDiagnosticPayloads)
}
func didReceive(_ payloads: [MXMetricPayload]) {
persist(payloads)
}
func didReceive(_ payloads: [MXDiagnosticPayload]) {
persist(payloads)
}
private func persist(_ payloads: [MXMetricPayload]) {
for payload in payloads {
durableLegacyOutbox.enqueue(payload.jsonRepresentation())
}
}
private func persist(_ payloads: [MXDiagnosticPayload]) {
for payload in payloads {
durableLegacyOutbox.enqueue(payload.jsonRepresentation())
}
}
}The outbox calls above are application-owned placeholders. Make them durable before parsing or uploading. Prevent duplicate add(_:) calls in the app's lifecycle code.
Route at launch:
if #available(iOS 27.0, *) {
modernMetricsService.start()
} else {
LegacyMetricsSubscriber.shared.start()
}Legacy signposts
let log = MXMetricManager.makeLogHandle(category: "Database")
mxSignpost(.begin, log: log, name: "Migration")
await migrateDatabase()
mxSignpost(.end, log: log, name: "Migration")Legacy signpost aggregates are available through MXMetricPayload.signpostMetrics.
Legacy extended launch
try MXMetricManager.extendLaunchMeasurement(forTaskID: "load-app-data")
defer {
try? MXMetricManager.finishExtendedLaunchMeasurement(forTaskID: "load-app-data")
}
await loadAppData()Pair the calls on every control-flow path. Use defer where it can guarantee completion without changing asynchronous behavior.
Xcode Organizer
Start with Organizer when Apple-provided aggregation is sufficient. A custom ingestion backend adds value when the product needs:
- correlations with feature flags, experiments, or backend incidents
- custom alert thresholds and retention
- cross-platform observability
- report-level symbolication and clustering
- export into an existing telemetry warehouse
Keep privacy and data-minimization requirements explicit. Avoid attaching user content or direct identifiers to MetricKit reports.