A trace that looks perfect on your phone does not describe customers’ devices. They may be hot, low on storage, restoring state, or processing a large database. By the time a review says “the app freezes,” useful context is gone.

MetricKit closes part of that gap. It collects system-produced performance data on device and gives your app aggregate metrics and actionable diagnostics. It is not session replay, and it is not a replacement for a profiler. Think of it as the production signal that tells you where to investigate next.

This article builds a pipeline around established MXMetricManager: subscribe early, preserve the payload, queue it durably, upload idempotently, aggregate by release, then investigate with Instruments. We will keep Apple’s redesigned iOS 27 preview API separate.

1. Understand What MetricKit Actually Gives You

MetricKit has two complementary outputs:

  • Metrics describe trends over a reporting interval: launch and resume distributions, hang time, peak memory, CPU time, logical disk writes, animation responsiveness, exits, and custom signposts.
  • Diagnostics explain individual failures such as crashes, hangs, CPU exceptions, and disk-write exceptions, often with call-stack information.

Metrics answer “did version 8.4 get worse?” Diagnostics help answer “which code path should we inspect?” Neither tells you exactly what every user did before an incident.

The established MXMetricManager API is available from iOS 13. It delivers metric reports at most once per day per metric source, and a callback can contain multiple payloads, including previously undelivered reports. On iOS 15 and later, diagnostics can be delivered promptly when generated and available, but delivery remains opportunistic—not guaranteed real-time. Design for missing days, duplicates, late arrival, and more than one payload in a callback.

This is also why a device-level report is not an analytics event. Do not join it to a person’s clickstream. Aggregate it across enough installations that it becomes an engineering signal rather than a user record.

2. Register a Long-Lived Subscriber Early

MetricKit begins accumulating after your process first accesses MXMetricManager.shared. Register a long-lived object during launch, retain it for the life of the process, and keep the callback cheap. Apple documents registration and delivery as safe in performance-sensitive launch code, but JSON serialization and disk I/O still do not belong on the callback path.

import Foundation
import MetricKit

final class MetricKitSubscriber: NSObject, MXMetricManagerSubscriber {
    private let inbox: MetricReportInbox

    init(inbox: MetricReportInbox) {
        self.inbox = inbox
        super.init()
    }

    func start() {
        MXMetricManager.shared.add(self)
    }

    func stop() {
        MXMetricManager.shared.remove(self)
    }

    func didReceive(_ payloads: [MXMetricPayload]) {
        let reports = payloads.map {
            PendingReport(kind: .metric, json: $0.jsonRepresentation())
        }

        Task {
            do { try await inbox.enqueue(reports) }
            catch { Self.recordPipelineError(error) }
        }
    }

    func didReceive(_ payloads: [MXDiagnosticPayload]) {
        let reports = payloads.map {
            PendingReport(kind: .diagnostic, json: $0.jsonRepresentation())
        }

        Task {
            do { try await inbox.enqueue(reports) }
            catch { Self.recordPipelineError(error) }
        }
    }

    private static func recordPipelineError(_ error: Error) {
        // Send a bounded counter through a separate logger; never include payload data.
    }
}

Create it from your app composition root rather than a transient scene:

import UIKit

@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
    private let inbox = MetricReportInbox()
    private lazy var metrics = MetricKitSubscriber(inbox: inbox)
    private var protectedDataObserver: NSObjectProtocol?

    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        metrics.start()
        retryReports()
        protectedDataObserver = NotificationCenter.default.addObserver(
            forName: UIApplication.protectedDataDidBecomeAvailableNotification,
            object: nil,
            queue: .main
        ) { [weak self] _ in
            self?.retryReports()
        }
        return true
    }

    private func retryReports() {
        Task {
            do { try await inbox.retryPendingUploads() }
            catch { /* Record a bounded pipeline-health counter. */ }
        }
    }
}

An actor is a natural boundary here because both callbacks can arrive independently while an earlier upload is running. If your app is adopting strict concurrency broadly, the same isolation principles from choosing concurrency-safe structs and classes in Swift 6 apply: keep non-Sendable framework objects inside the synchronous callback, extract Data, and pass only owned values across the task boundary.

3. Preserve Raw Reports Before Transforming Them

It is tempting to read five properties from each payload and discard the rest. That permanently limits future investigations. Apple already provides jsonRepresentation(), so preserve those bytes first and derive server-side projections later.

The queue needs three properties:

  1. Durability: a terminated process must not lose an undelivered report.
  2. Idempotency: retries must not create duplicate observations.
  3. Bounded growth: a broken endpoint must not fill the device.

Here is the core model and an actor-backed inbox. The storage type is deliberately injected so production code can use an Application Support directory while tests use memory.

import CryptoKit
import Foundation

struct PendingReport: Codable, Sendable {
    enum Kind: String, Codable, Sendable {
        case metric
        case diagnostic
    }

    let id: String
    let kind: Kind
    let json: Data
    let receivedAt: Date

    init(kind: Kind, json: Data, receivedAt: Date = .now) {
        let digest = SHA256.hash(data: json)
        self.id = digest.map { String(format: "%02x", $0) }.joined()
        self.kind = kind
        self.json = json
        self.receivedAt = receivedAt
    }
}

protocol MetricReportStore: Sendable {
    func load() throws -> [PendingReport]
    func save(_ reports: [PendingReport]) throws
}

protocol MetricReportTransport: Sendable {
    func upload(_ report: PendingReport) async throws
}

actor MetricReportInbox {
    enum QueueError: Error {
        case inFlightReportMissing
    }

    private let store: MetricReportStore
    private let transport: MetricReportTransport
    private var reports: [PendingReport]?
    private var isUploading = false

    init(
        store: MetricReportStore = FileMetricReportStore(),
        transport: MetricReportTransport = URLSessionMetricReportTransport()
    ) {
        self.store = store
        self.transport = transport
    }

    func enqueue(_ incoming: [PendingReport]) async throws {
        try loadIfNeeded()
        var candidate = reports!
        var known = Set(candidate.map(\.id))
        var droppedCount = 0

        for report in incoming where known.insert(report.id).inserted {
            guard candidate.count < 100 else {
                // Preserve the durable FIFO prefix, including any report in flight.
                // New arrivals lose the race for capacity and are counted as dropped.
                droppedCount += 1
                continue
            }
            candidate.append(report)
        }

        // Durability is the commit point. Never expose new reports to the
        // uploader until the complete candidate queue reaches protected storage.
        try store.save(candidate)
        reports = candidate
        recordDroppedIncoming(droppedCount)
        try await retryPendingUploads()
    }

    func retryPendingUploads() async throws {
        try loadIfNeeded()
        guard !isUploading else { return }
        isUploading = true
        defer { isUploading = false }

        while let report = reports!.first {
            try await transport.upload(report)

            // enqueue may run while upload is suspended. Remove only the exact
            // in-flight ID from the latest queue snapshot, never its new head.
            guard let index = reports!.firstIndex(where: { $0.id == report.id }) else {
                throw QueueError.inFlightReportMissing
            }
            var remaining = reports!
            remaining.remove(at: index)

            // Persist first so disk and memory advance at the same commit point.
            // A failed save keeps the item queued; server idempotency makes retry safe.
            try store.save(remaining)
            reports = remaining
        }
    }

    private func loadIfNeeded() throws {
        guard reports == nil else { return }
        reports = try store.load()
    }

    private func recordDroppedIncoming(_ count: Int) {
        guard count > 0 else { return }
        // Emit a bounded pipeline-health counter through a separate logger.
    }
}

struct FileMetricReportStore: MetricReportStore {
    private var fileURL: URL {
        get throws {
            var directory = try FileManager.default.url(
                for: .applicationSupportDirectory,
                in: .userDomainMask,
                appropriateFor: nil,
                create: true
            ).appendingPathComponent("MetricReports", isDirectory: true)
            try FileManager.default.createDirectory(
                at: directory,
                withIntermediateDirectories: true
            )
            var values = URLResourceValues()
            values.isExcludedFromBackup = true
            try directory.setResourceValues(values)
            return directory.appendingPathComponent("pending.json")
        }
    }

    func load() throws -> [PendingReport] {
        let url = try fileURL
        do {
            let data = try Data(contentsOf: url)
            return try JSONDecoder().decode([PendingReport].self, from: data)
        } catch let error as CocoaError where error.code == .fileReadNoSuchFile {
            return []
        } catch {
            // Corruption, permissions, and protected-data failures are not emptiness.
            throw error
        }
    }

    func save(_ reports: [PendingReport]) throws {
        let data = try JSONEncoder().encode(reports)
        try data.write(to: fileURL, options: [.atomic, .completeFileProtection])
    }
}

Avoid UserDefaults; reports can be large. The 100-report cap is a product decision, not an Apple recommendation. At capacity, this example preserves the existing FIFO queue—including an oldest report currently uploading—and rejects excess new unique arrivals while recording their count. Because it uses complete file protection, reads can fail before the first unlock. That failure deliberately leaves reports as nil: the app must wait for protectedDataDidBecomeAvailableNotification and retry. Treating “unreadable” as “empty” would let the next callback overwrite a real queue that is merely protected or corrupted.

4. Upload Safely and Idempotently

The endpoint should accept the Apple JSON body without requiring the app to understand every schema field. Send report kind, app build, and a content-derived idempotency key in headers. Do not attach email, account ID, advertising ID, precise location, screen text, or arbitrary breadcrumbs.

import Foundation

struct URLSessionMetricReportTransport: MetricReportTransport {
    private let endpoint = URL(string: "https://telemetry.example.com/v1/metrickit")!

    func upload(_ report: PendingReport) async throws {
        var request = URLRequest(url: endpoint)
        request.httpMethod = "POST"
        request.httpBody = report.json
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue(report.id, forHTTPHeaderField: "Idempotency-Key")
        request.setValue(report.kind.rawValue, forHTTPHeaderField: "X-Report-Kind")
        request.setValue(appBuild, forHTTPHeaderField: "X-App-Build")

        let (_, response) = try await URLSession.shared.data(for: request)
        guard let http = response as? HTTPURLResponse,
              (200..<300).contains(http.statusCode) else {
            throw UploadError.rejected
        }
    }

    private var appBuild: String {
        Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown"
    }

    enum UploadError: Error {
        case rejected
    }
}

Use exponential backoff with jitter in a production scheduler, honor constrained networking, and stop retrying permanent 4xx failures. The server must enforce uniqueness on the idempotency key because an app can crash after the server commits but before the client deletes its queue entry.

Payloads include interval timestamps, the latest app version, whether the interval spans multiple versions, and limited system metadata—not a complete device snapshot. Put upgrade-spanning reports in a separate mixed-version bucket or exclude them from release comparisons.

5. Turn Payloads into Release Health Signals

Store the raw document in restricted object storage, then normalize only the fields needed for dashboards. Useful release-level measures include:

  • median and tail launch-duration histogram buckets;
  • cumulative hang time divided by foreground runtime;
  • abnormal exits per 1,000 accepted metric report-days, plus diagnostic-stack shares;
  • peak memory and memory-related exits;
  • cumulative CPU time relative to active time;
  • logical writes relative to foreground runtime;
  • custom journey duration, CPU, memory, and write distributions.

Ratios need defensible denominators. “42 hangs” is meaningless without exposure. A practical, measurable denominator is an accepted metric report-day: one successfully deduplicated payload interval assigned to a calendar reporting day. Track abnormal exits per 1,000 report-days and each diagnostic cluster as a share of accepted diagnostic payloads. These are coverage-sensitive operational rates, not estimates of unique affected people; opportunistic delivery still biases them. Publish accepted-payload counts beside every rate, require a minimum cohort size, and compare like-for-like OS and app versions.

Alert on sustained change, not one noisy bucket. For example: notify when the current build’s p90 launch bucket regresses by 20 percent against the previous stable build for two reporting days and the cohort exceeds your minimum. Keep separate dashboards for OS versions and major device capability classes, but resist tiny slices that undermine privacy and statistical usefulness.

For memory, revisit ARC, retain cycles, and iOS memory-management behavior. For CPU or copying regressions, the ownership decisions discussed in Swift ownership and expensive value copies may give you a concrete hypothesis—but only profiling can confirm it.

6. Measure User Journeys with MXSignpost

System metrics tell you that the app regressed. A small number of meaningful signposts can tell you whether checkout, timeline hydration, document import, or another critical journey owns the cost.

For the established API, create the log with MXMetricManager.makeLogHandle(category:) and mark a balanced interval with mxSignpost. Keep names static and low-cardinality.

import MetricKit
import os

enum JourneyMetrics {
    private static let log = MXMetricManager.makeLogHandle(category: "CriticalJourneys")

    static func measureCheckout<T>(
        operation: () async throws -> T
    ) async rethrows -> T {
        let id = OSSignpostID(log: log)
        mxSignpost(.begin, log: log, name: "Checkout", signpostID: id)
        defer {
            mxSignpost(.end, log: log, name: "Checkout", signpostID: id)
        }
        return try await operation()
    }
}
let receipt = try await JourneyMetrics.measureCheckout {
    try await checkoutService.submit(cart)
}

MetricKit limits retained custom signposts to control overhead, so instrument critical operations rather than every function. Never put order IDs, URLs, search queries, or user-entered text into signpost names or metadata. Keep dynamic request debugging in a separately governed logging system.

Balanced begin/end intervals matter. If cancellation or an error skips the end, your measurement becomes misleading; defer protects that invariant. Signposts also pair well with local Instruments workflows because you can use the same conceptual journey while inspecting Time Profiler, Hangs, Allocations, or File Activity.

7. Connect Production Evidence Back to Instruments

A useful MetricKit alert produces a testable statement: “Build 812 doubled p90 cold-launch duration on iOS 26, and diagnostics cluster below database migration.” It should not produce “the app is slow.”

The investigation loop is:

  1. Pick the affected release, OS cohort, metric, and interval.
  2. Group diagnostic call stacks by meaningful frames after symbolication.
  3. Recreate the relevant device state—large store, cold cache, migration pending, or constrained resources.
  4. Profile that path with the appropriate Instruments template.
  5. Add an automated performance test where a stable workload exists.
  6. Ship the fix gradually and compare the next MetricKit cohorts.

For main-thread stalls, start with the reasoning in why async Swift code can still freeze the UI. A diagnostic stack is evidence about where execution stalled, not automatic proof of root cause. Locks, synchronous I/O, actor contention, and upstream work can make the visible frame an innocent boundary.

Keep dSYMs for every shipped build and verify that the backend can associate diagnostics with the correct symbols. Without symbol retention, an excellent collection pipeline can still leave you staring at addresses.

8. Privacy and Operational Guardrails

MetricKit’s aggregation reduces the temptation to recreate invasive telemetry, but your own transport and backend still need deliberate governance.

  • Collect only performance reports and coarse release context.
  • Encrypt in transit and at rest.
  • Restrict raw-report access to engineers who need it.
  • Define retention for raw and aggregated data separately.
  • Suppress dashboards below a minimum cohort size.
  • Document the collection in your privacy disclosures and internal data inventory.
  • Never enrich reports with a stable person or device identifier merely because it is technically convenient.

Monitor queue depth, accepted uploads, duplicate rejection, parse failures, oldest pending age, and coverage by release using bounded counters—not recursive MetricKit-style payloads.

9. Keep iOS 27 Preview APIs Separate

Everything above uses the established, shipping MXMetricManager, MXMetricPayload, MXDiagnosticPayload, and MXMetricManagerSubscriber family. In the iOS 27 SDK, Apple marks these APIs deprecated in favor of a Swift-first redesign. Deprecation in a preview SDK does not make your current production implementation invalid.

The preview design centers on MetricManager and asynchronous sequences such as metricReports and diagnosticReports. Reports are Codable, metrics are organized into interval entries and groups, and diagnostics are delivered promptly. Apple also adds Metal frame-rate metrics and memory-exception diagnostics.

StateReporting is the most important conceptual addition. Your app reports transitions between stable, meaningful states—such as “Reports tab” and “Spending tab”—within narrowly scoped domains. MetricKit can then aggregate performance by those states instead of blending the whole app together. Diagnostic environments can also include states active before an event. This is context, not a license to encode user identity or high-cardinality content.

Apple’s WWDC26 example configures a JSONEncoder with MetricReport.encodingFormatKey and MetricReport.EncodingFormat.byStateReportingDomain before encoding a preview MetricReport. That API, @ReportableMetadata, state-aware entries, and the precise iOS 27 report schema are beta concepts. Test them with the final SDK before committing a production backend contract.

A clean migration strategy is to keep the rest of your app dependent on a tiny internal interface—“start collection” and “enqueue encoded report”—then select an established or preview adapter by availability. Do not scatter if #available(iOS 27, *) throughout feature code, and do not mix examples from the two generations in one implementation.

10. Ship the Smallest Pipeline That Closes the Loop

Start with durable collection, idempotent upload, and launch, hang, and crash dashboards. Add other metrics when a team owns the alerts, and signpost only journeys that affect release decisions.

The hard part is the operational promise: review the trend, investigate, profile, fix, and confirm recovery. Without that loop, telemetry becomes an expensive archive.

MetricKit is most valuable when it stays modest. Let the operating system collect aggregate evidence, minimize what leaves the device, preserve the raw report, and make every alert lead to an engineering action.

Key Takeaways

  • Register one long-lived MXMetricManagerSubscriber early and move processing off the callback path.
  • Preserve Apple’s JSON payload before deriving metrics, and queue uploads durably with content-based idempotency.
  • Compare sufficiently large release and OS cohorts using rates and histograms, not isolated counts.
  • Use a few balanced, privacy-safe MXSignpost intervals for critical user journeys.
  • Treat MetricKit as the production signal and Instruments as the local diagnosis and verification tool.
  • Keep the established API and iOS 27’s Swift-first preview API clearly separated until the new SDK is final.

Primary references