MetricKit - OS 27 app health

What's New in MetricKit

MetricKit's WWDC26 update is not a small rename. In iOS 27, iPadOS 27, Mac Catalyst 27, and macOS 27, app-health data moves to Swift value types, async report streams, state-aware metrics, better launch attribution, new diagnostics, and clearer handoff to Instruments.

June 15, 2026 11 minute read WWDC26 session 222
MetricKit diagnostic icons frame from Apple's WWDC26 video
A real WWDC26 MetricKit frame showing the diagnostic areas covered by MetricKit.

Highlights

Swift-first reports

MetricManager replaces the singleton subscriber model with async sequences of MetricReport and DiagnosticReport.

Broader health signals

Launch, resume, hangs, hitches, scrolling, CPU, memory, GPU, storage, networking, signposts, and Metal frame rate all fit one result model.

State context

StateReporting lets the system attach workflow, experiment, or mode context to metrics and diagnostics.

Actionable diagnostics

DiagnosticResult unifies crash, hang, CPU, disk-write, app-launch, and memory-exception diagnostics.

The New App Health Model

MetricKit still answers the same operational question: what happened on real devices when your app was actually used? The OS 27 change is the shape of that answer. Instead of receiving Objective-C payload objects through MXMetricManager.shared, you create a MetricManager instance, keep it alive, and iterate typed async sequences.

Requires OS 27 SDK
Fallback for OS 26

Keep the old subscriber path for older targets. The old API is object-oriented and property-based: subscribe with MXMetricManager.shared.add(_:), receive MXMetricPayload and MXDiagnosticPayload, then map only the fields your telemetry pipeline already understands.

import MetricKit

final class LegacyMetricSubscriber: NSObject, MXMetricManagerSubscriber {
    func start() {
        MXMetricManager.shared.add(self)
    }

    func didReceive(_ payloads: [MXMetricPayload]) {
        for payload in payloads {
            exportLegacyMetrics(payload)
        }
    }

    func didReceive(_ payloads: [MXDiagnosticPayload]) {
        for payload in payloads {
            exportLegacyDiagnostics(payload)
        }
    }
}
Area OS 27 shape Availability note
Daily metrics MetricManager.metricReports yields MetricReport values. iOS 27, iPadOS 27, Mac Catalyst 27, macOS 27.
Diagnostics MetricManager.diagnosticReports yields one DiagnosticReport per event. iOS 27, iPadOS 27, Mac Catalyst 27, macOS 27, visionOS 27.
State context StateReporting records named domains and metadata for app state transitions. StateReporting is OS 27 across Apple platforms.
Simulator Real delivery is device-based; Xcode can simulate MetricKit payloads during development. Xcode 27 tooling.

Treat the app-side MetricKit stream as local telemetry. Your app receives reports and can log, store, or upload them if your privacy policy allows it. Xcode Organizer and App Store telemetry are separate Apple aggregation surfaces: useful for fleet-level trend spotting, but not a replacement for app-owned triage logic when you need to connect a regression to a feature flag, workflow, or internal release.

1. Subscribe to Reports

The new manager is instantiable. Create one owner during app startup, pass the StateReporting domains you want MetricKit to aggregate by, and keep the tasks alive for the app session.

Requires iOS 27 SDK
import Foundation
import MetricKit

protocol MetricSink: Sendable {
    func storeMetricReport(_ report: MetricReport) async
    func storeDiagnosticReport(_ report: DiagnosticReport) async
}

@available(iOS 27, macOS 27, *)
final class AppHealthCollector {
    private let manager: MetricManager
    private let sink: MetricSink
    private var metricTask: Task<Void, Never>?
    private var diagnosticTask: Task<Void, Never>?

    init(sink: MetricSink, stateDomains: Set<StateReportingDomain> = []) {
        self.sink = sink
        self.manager = MetricManager(enabledStateReportingDomains: stateDomains)
    }

    func start() {
        metricTask = Task.detached(priority: .utility) { [manager, sink] in
            for await report in manager.metricReports {
                await sink.storeMetricReport(report)
            }
        }

        diagnosticTask = Task.detached(priority: .utility) { [manager, sink] in
            for await report in manager.diagnosticReports {
                await sink.storeDiagnosticReport(report)
            }
        }
    }

    func stop() {
        metricTask?.cancel()
        diagnosticTask?.cancel()
    }
}

Keep the owner boring. The collector should not decide if a regression is important. Its job is to receive, serialize, and hand off reports to code you can test.

2. Filter What Your Team Cares About

MetricReport has intervalEntries for time-window aggregates, including the full-day aggregate, and stateEntries when state reporting is enabled. Each entry contains MetricResult values. The practical pattern is to keep a narrow allow-list, then expand it when an investigation needs more context.

Requires OS 27 SDK
import Foundation
import MetricKit

@available(iOS 27, macOS 27, *)
func selectedAppHealthMetrics(from entry: MetricReport.IntervalEntry) -> [MetricResult] {
    entry.values.filter { result in
        switch result {
        case .timeToFirstDraw,
             .optimizedTimeToFirstDraw,
             .extendedLaunch,
             .hangTime,
             .hitchTime,
             .scrollHitchTime,
             .peakMemory,
             .cpuTime,
             .gpuTime,
             .logicalDiskWrites,
             .foregroundTermination,
             .backgroundTermination,
             .signpostInterval:
            return true
        @unknown default:
            return false
        }
    }
}

This is deliberately not every metric. Most teams need a small default dashboard: launch, responsiveness, memory, CPU, storage writes, terminations, and a few custom signposts around critical workflows.

3. Convert to a Lightweight Model

The raw reports are Codable and Sendable, so archiving the full payload is easy. For day-to-day monitoring, also derive a tiny app-owned model with stable keys. That keeps server queries, alerts, and review comments independent from MetricKit's full JSON shape.

Requires OS 27 SDK
import Foundation
import MetricKit

struct AppHealthSummary: Codable, Sendable {
    var day: DateInterval
    var launchP95MS: Double?
    var optimizedLaunchP95MS: Double?
    var hangP95MS: Double?
    var peakMemoryMB: Double?
    var cpuSeconds: Double?
    var signposts: [SignpostSummary] = []
}

struct SignpostSummary: Codable, Sendable {
    var category: String
    var name: String
    var count: Int
    var p95MS: Double?
    var cpuSeconds: Double?
}

@available(iOS 27, macOS 27, *)
func makeSummary(from report: MetricReport) -> AppHealthSummary {
    let fullDay = report.intervalEntries.fullDayEntry
    var summary = AppHealthSummary(day: report.timeRange)

    for result in selectedAppHealthMetrics(from: fullDay) {
        switch result {
        case .timeToFirstDraw(let metric):
            summary.launchP95MS = percentile(0.95, from: metric.histogram, in: .milliseconds)
        case .optimizedTimeToFirstDraw(let metric):
            summary.optimizedLaunchP95MS = percentile(0.95, from: metric.histogram, in: .milliseconds)
        case .hangTime(let metric):
            summary.hangP95MS = percentile(0.95, from: metric.histogram, in: .milliseconds)
        case .peakMemory(let metric):
            summary.peakMemoryMB = metric.value.converted(to: .megabytes).value
        case .cpuTime(let metric):
            summary.cpuSeconds = metric.value.converted(to: .seconds).value
        case .signpostInterval(let metric):
            summary.signposts.append(
                SignpostSummary(
                    category: metric.signpostCategory,
                    name: metric.signpostName,
                    count: metric.totalCount,
                    p95MS: percentile(0.95, from: metric.signpostDuration, in: .milliseconds),
                    cpuSeconds: metric.cpuTime?.converted(to: .seconds).value
                )
            )
        default:
            break
        }
    }

    return summary
}

@available(iOS 27, macOS 27, *)
func percentile<D: Dimension>(
    _ percentile: Double,
    from histogram: Histogram<D>,
    in unit: D
) -> Double? {
    let total = histogram.buckets.reduce(0) { $0 + $1.count }
    guard total > 0 else { return nil }

    let target = Int((Double(total) * percentile).rounded(.up))
    var runningTotal = 0

    for bucket in histogram.buckets {
        runningTotal += bucket.count
        if runningTotal >= target {
            return bucket.upperBound.converted(to: unit).value
        }
    }

    return histogram.buckets.last?.upperBound.converted(to: unit).value
}

The percentile helper uses the upper bound of the bucket where the percentile lands. That makes it a conservative approximation and avoids pretending the bucket distribution is more precise than MetricKit reports.

4. Print or Export a Compact Report

For local development, print the compact summary and save the full report JSON beside it. In production, upload the compact model by default and sample the full MetricKit payload only when you need deep triage.

Requires OS 27 SDK
import Foundation
import os

actor FileBackedMetricSink: MetricSink {
    private let logger = Logger(subsystem: "com.example.app", category: "AppHealth")
    private let encoder: JSONEncoder = {
        let encoder = JSONEncoder()
        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
        encoder.dateEncodingStrategy = .iso8601
        return encoder
    }()

    func storeMetricReport(_ report: MetricReport) async {
        let summary = makeSummary(from: report)
        logger.info("MetricKit daily health: \(String(describing: summary), privacy: .public)")

        let fullReportEncoder = JSONEncoder()
        fullReportEncoder.userInfo[MetricReport.encodingFormatKey] =
            MetricReport.EncodingFormat.byStateReportingDomain

        exportJSON(summary, name: "daily-health-summary.json")
        exportJSON(report, name: "metrickit-report.json", encoder: fullReportEncoder)
    }

    func storeDiagnosticReport(_ report: DiagnosticReport) async {
        logger.error("MetricKit diagnostic: \(String(describing: report.result), privacy: .public)")
        exportJSON(report, name: "metrickit-diagnostic.json")
    }

    private func exportJSON<Value: Encodable>(
        _ value: Value,
        name: String,
        encoder: JSONEncoder? = nil
    ) {
        let encoder = encoder ?? self.encoder
        guard let data = try? encoder.encode(value) else { return }
        let directory = URL.documentsDirectory.appending(
            path: "AppHealth",
            directoryHint: .isDirectory
        )
        try? FileManager.default.createDirectory(
            at: directory,
            withIntermediateDirectories: true
        )
        try? data.write(to: directory.appending(path: name))
    }
}

Illustrative console output

AppHealth day=2026-06-14
  launch.p95_ms=712 optimized_launch.p95_ms=418
  hang.p95_ms=260 peak_memory_mb=642 cpu_seconds=189.4
  signpost DocumentCapture/ReceiptScan count=38 p95_ms=1240 cpu_seconds=8.7

Illustrative JSON summary

{
  "day": {
    "start": "2026-06-14T00:00:00Z",
    "duration": 86400
  },
  "launchP95MS": 712,
  "optimizedLaunchP95MS": 418,
  "hangP95MS": 260,
  "peakMemoryMB": 642,
  "cpuSeconds": 189.4,
  "signposts": [
    {
      "category": "DocumentCapture",
      "name": "ReceiptScan",
      "count": 38,
      "p95MS": 1240,
      "cpuSeconds": 8.7
    }
  ]
}

StateReporting Makes Metrics Useful

The biggest practical upgrade is state context. StateReporting lets you define domains such as workflow, experiment variant, graphics quality, account mode, or editor surface. MetricKit can then populate stateEntries with scoped metrics and include active states on diagnostic reports.

Requires OS 27 SDK
import MetricKit
import StateReporting

enum AppHealthDomains {
    static let workflow = StateReportingDomain("com.example.app.workflow")
}

@ReportableMetadata
struct WorkflowMetadata: Sendable {
    let screen: String
    let accountTier: String
    let experiment: String
}

@ReportableMetadata
struct NoVolatileMetadata: Sendable {}

@available(iOS 27, macOS 27, *)
let workflowReporter = StateReporter.reporter(
    for: AppHealthDomains.workflow.rawValue,
    stableMetadata: WorkflowMetadata.self,
    volatileMetadata: NoVolatileMetadata.self
)

@available(iOS 27, macOS 27, *)
func reportWorkflow(screen: String, accountTier: String, experiment: String) {
    workflowReporter.reportTransition(
        to: screen,
        stableMetadata: WorkflowMetadata(
            screen: screen,
            accountTier: accountTier,
            experiment: experiment
        ),
        volatileMetadata: nil
    )
}

Use state sparingly. Apple documents that state updates more frequent than user-interaction timescales can be rate limited, so report coarse transitions: selected tab, current editor, checkout phase, rendering mode, or experiment variant. Do not report a new state for every scroll event or every model update.

Illustrative markdown report generated from state entries

## Daily App Health - 2026-06-14

| State | Launch p95 | Hang p95 | Scroll hitch p95 | Peak memory |
| --- | ---: | ---: | ---: | ---: |
| Reports / premium / list-v2 | 690 ms | 180 ms | 14 ms | 612 MB |
| Spending / premium / card-v2 | 735 ms | 840 ms | 31 ms | 648 MB |

Action: investigate Spending card-v2. Hangs and scroll hitches moved together after the layout experiment.

5. Turn Diagnostics Into Action Items

DiagnosticReport is event-oriented. Each report has a result enum that carries a typed diagnostic. Crash and memory diagnostics include call stack trees. Crash diagnostics also expose exception and termination details. Hang diagnostics are sampled: do not assume every hang becomes a report.

Requires OS 27 SDK
import MetricKit

struct DebuggingAction: Sendable {
    var title: String
    var ownerHint: String
    var evidence: String
}

@available(iOS 27, macOS 27, *)
func actionItem(from report: DiagnosticReport) -> DebuggingAction? {
    switch report.result {
    case .crash(let diagnostic):
        return DebuggingAction(
            title: "Investigate crash termination",
            ownerHint: "Runtime stability",
            evidence: [
                diagnostic.terminationReason.map { "reason=\($0)" },
                diagnostic.signal.map { "signal=\($0)" },
                diagnostic.exceptionType.map { "exception=\($0)" }
            ].compactMap { $0 }.joined(separator: " ")
        )

    case .hang(let diagnostic):
        return DebuggingAction(
            title: "Profile main-thread responsiveness",
            ownerHint: "UI performance",
            evidence: "Call stack tree captured for hang diagnostic: \(diagnostic.callStackTree)"
        )

    case .appLaunch(let diagnostic):
        return DebuggingAction(
            title: "Profile app launch path",
            ownerHint: "Startup",
            evidence: "Launch diagnostic: \(diagnostic)"
        )

    case .memoryException(let diagnostic):
        return DebuggingAction(
            title: "Investigate fatal memory exception",
            ownerHint: "Memory",
            evidence: "Call stack tree: \(diagnostic.callStackTree)"
        )

    case .cpuException, .diskWriteException:
        return DebuggingAction(
            title: "Inspect diagnostic report",
            ownerHint: "Performance",
            evidence: String(describing: report.result)
        )

    @unknown default:
        return nil
    }
}

Illustrative triage output

[MetricKit Diagnostic]
kind: hang
state: Spending / premium / card-v2
firstSeen: 2026-06-14T16:22:09Z
action: Profile main-thread responsiveness in Spending card layout.
next step: reproduce in Instruments with Points of Interest and StateReporting transitions visible.

This is where MetricKit and Instruments meet. MetricKit tells you the issue exists on real devices and gives you state and stack context. Instruments is still where you reproduce, profile, and verify the fix.

Metric Areas Worth Wiring Up

Launch and Resume

TimeToFirstDrawMetric measures launch time until the first Core Animation commit. OptimizedTimeToFirstDrawMetric, ApplicationResumeTimeMetric, and ExtendedLaunchMetric help split startup into visible launch, optimized launch, resume, and app-defined launch tasks.

Requires OS 27 SDK
let manager = MetricManager(
    enabledStateReportingDomains: [AppHealthDomains.workflow]
)

let mainStore = try await manager.trackLaunchTask(
    id: LaunchTaskID("open-main-store"),
    onTrackingError: { error in
        assertionFailure("MetricKit launch tracking failed: \(error)")
    }
) {
    try await MainStore.open()
}

Keep launch task identifiers stable and specific: open-main-store, hydrate-cache, register-models. Avoid names like startup, which are too broad to assign.

Hangs, Hitches, and Scrolling

HangTimeMetric is app responsiveness. HitchTimeMetric is animation responsiveness. ScrollHitchTimeMetric narrows that lens to scrolling. These are the metrics most likely to change when a feature ships heavy layout, synchronous I/O, or expensive model observation.

Requires OS 27 SDK

For state-aware reports, hang, hitch, scroll hitch, termination counts, signpost intervals, and app runtime metrics can appear in stateEntries. CPU time, memory, network, disk I/O, GPU, app launch, and disk-space metrics stay in interval entries.

CPU, Memory, Storage, Network, GPU

The new result enum keeps resource metrics in the same loop as launch and responsiveness. Watch CPUTimeMetric, CPUInstructionsCountMetric, PeakMemoryMetric, SuspendedMemoryMetric, LogicalDiskWritesMetric, total network transfer metrics, GPUTimeMetric, and MetalFrameRateMetric when your app has heavy content feeds, media processing, games, ML inference, or persistent document editing.

Requires OS 27 SDK

MetalFrameRateMetric is especially useful for games and custom renderers because it measures frame rate statistics for a specific CAMetalLayer. Use it alongside GPU time and signpost intervals so a frame-rate drop can be tied back to a renderer phase or gameplay mode.

Custom Signposts

Signpost intervals are still the bridge from system metrics to product workflows. In OS 27, create the log handle with MetricManager.logHandle(category:), wrap operations with mxSignpost, and read SignpostIntervalMetric for count, duration histogram, CPU time, average memory, logical writes, and hitch-related measurements.

Requires OS 27 SDK
import MetricKit

let documentCaptureLog = MetricManager.logHandle(category: "DocumentCapture")

func scanReceipt() async throws -> Receipt {
    mxSignpost(.begin, log: documentCaptureLog, name: "ReceiptScan")
    defer {
        mxSignpost(.end, log: documentCaptureLog, name: "ReceiptScan")
    }

    return try await receiptScanner.scan()
}

Redact Before Export

MetricKit data is performance telemetry, not user analytics, but your own StateReporting metadata and signpost names can still leak implementation detail or customer context. Keep domains reverse-DNS style, use coarse metadata values, and strip anything that looks like a user identifier before upload.

App-owned policy
struct MetricRedactor {
    private let blockedKeys = ["email", "userID", "accountID", "documentID", "receiptID"]

    func redactedMetadata(_ metadata: [String: String]) -> [String: String] {
        metadata.reduce(into: [:]) { output, pair in
            if blockedKeys.contains(pair.key) {
                output[pair.key] = "<redacted>"
            } else {
                output[pair.key] = pair.value
            }
        }
    }
}

Adoption Checklist

Create one long-lived MetricManager owner. Start the metric and diagnostic async sequences during app startup and forward reports to a testable sink.
Archive full reports, but monitor a compact summary. Keep full JSON for debugging. Use a small app-owned model for alerting and review comments.
Add StateReporting before the next experiment. Workflow and variant context is what turns "hangs increased" into "the card layout experiment caused hangs."
Wrap critical workflows with MetricKit signposts. Use signposts for capture, import, export, sync, checkout, inference, rendering, and document open operations.
Use diagnostics to create a next action. Every crash, hang, app-launch, disk-write, CPU, or memory exception report should map to an owner and a profiling step.

Sources