Skip to main content
3Nsofts logo3Nsofts
iOS ArchitectureUpdated · September 2026

MetricKit in iOS 27: Swift-First Reports and Performance by App State

Author
Ehsan Azish · 3NSOFTS
Updated
September 2026
Read time
14 min read
Level
Intermediate
Platform
iOS 27+, Swift concurrency, basic production observability

Implementation Notes

  • ~/ What broke: A production edge case that generic tutorials skip.
  • ~/ What to do: Ship the production fix with clear state, errors, and fallback behavior.
MetricKit iOS 27MetricManagerStateReportingiOS performance monitoringMetricReportDiagnosticReport

The rebuilt MetricKit API in iOS 27 replaces the old subscriber-oriented MXMetricManager workflow with a Swift-first MetricManager. Reports arrive through asynchronous sequences, daily metrics and event-driven diagnostics are separate types, and both can be encoded for analysis. StateReporting can then answer a more useful question than “is the app slow?”: which app state was active when it became slow?

This guide focuses on the production architecture around those APIs: ownership, cancellation, storage, privacy, migration, and the mistakes that can turn useful performance evidence into noisy telemetry.

The iOS 27 MetricKit and StateReporting APIs are beta as of September 2026. Verify names and availability against the Xcode and OS release you ship.


What changed in iOS 27

The previous API used MXMetricManager, MXMetricManagerSubscriber, and payload callbacks. The new API uses:

  • MetricManager as the collection entry point;
  • MetricReport for aggregated performance metrics;
  • DiagnosticReport for event-based failures and diagnostics;
  • MetricResult cases for typed metric values;
  • asynchronous sequences instead of a subscriber protocol;
  • Codable and Sendable report models;
  • optional StateReporting context for app-defined states.

Apple describes the new Swift API as the future of MetricKit. That makes a migration worthwhile, but it does not mean deleting the old path immediately when an app still supports earlier operating systems. Treat this as an availability-gated observability adapter.

Give collection one owner

Do not create a new MetricManager in every view or scene. Give it one long-lived owner and start collection once for the process lifetime. A small actor keeps report handling serialized without tying work to the main actor:

import MetricKit

actor PerformanceMonitor {
    private let manager = MetricManager()
    private var metricTask: Task<Void, Never>?
    private var diagnosticTask: Task<Void, Never>?

    func start() {
        guard metricTask == nil, diagnosticTask == nil else { return }

        metricTask = Task { [manager] in
            for await report in manager.metricReports {
                do {
                    try await persist(report)
                } catch {
                    // Record locally; do not crash the app's monitoring path.
                }
            }
        }

        diagnosticTask = Task { [manager] in
            for await report in manager.diagnosticReports {
                do {
                    try await persist(report)
                } catch {
                    // Keep collection alive if one report cannot be stored.
                }
            }
        }
    }

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

    private func persist<T: Encodable>(_ report: T) async throws {
        let data = try JSONEncoder().encode(report)
        try await ReportStore.shared.append(data)
    }
}

The storage implementation is application-specific. The important boundaries are stable: collection owns the sequences, persistence happens away from the UI, and cancellation belongs to the object that started the tasks.

Do not use Task.detached merely to escape isolation warnings. Reports are Sendable; use normal structured tasks and make the receiving store actor-safe.

Metrics and diagnostics have different lifecycles

Metric reports summarize performance over intervals. Examples include launch behavior, hangs, CPU, memory, disk, network, and graphics performance. Diagnostic reports are tied to events such as crashes, hangs, terminations, or memory exceptions.

Keep them in separate ingestion paths even if both ultimately become JSON:

MetricManager
├── metricReports      → interval aggregation → trend storage
└── diagnosticReports  → event triage         → incident storage

If the same database table accepts both without a type discriminator, downstream analysis becomes fragile. Store at least the report kind, schema version, app version, build number, and receipt time alongside the encoded report.

Read typed results instead of scraping JSON

Encoding an entire report is useful for durable storage, but production decisions should be based on typed MetricResult values. Apple’s examples iterate the typed values and switch over the result cases:

for await report in manager.metricReports {
    for entry in report.intervalEntries {
        for metric in entry.values {
            switch metric {
            case .peakMemory(let peak):
                processPeakMemory(peak.value)
            @unknown default:
                break
            }
        }
    }
}

Avoid assumptions about array ordering or the presence of every metric. Device capability, system version, reporting interval, and whether an event occurred can all affect what a report contains. Unknown or unavailable values are not zero.

Use state context for questions you can act on

An app-wide average can hide the feature that causes a regression. StateReporting lets an app define meaningful domains and states so supported MetricKit values can be grouped by context.

Useful state domains are small and stable:

  • current workflow: browsing, editing, export, sync;
  • media mode: idle, playback, recording;
  • document size class: small, medium, large;
  • inference mode: unavailable, preparing, running;
  • account state: signed out or signed in, without recording account identity.

Bad state domains create accidental analytics:

  • email addresses, document names, search terms, or URLs;
  • identifiers with unbounded cardinality;
  • free-form error text;
  • states so granular that a report can describe one person’s activity.

The goal is performance segmentation, not session replay. A state such as exportingLargeDocument can explain a memory peak. A state containing the document’s filename cannot.

State entries appear only when state reporting is enabled for the relevant domains. Apple also notes that only a subset of metrics is available by state, including supported hang, hitch, termination, signpost, location, and runtime values. Always keep the interval-wide view as the baseline.

A privacy-minimal upload pipeline

MetricKit gives you evidence from real devices; it does not require sending that evidence to your server. Choose the smallest architecture that answers the product’s needs.

For a local-first app, a support-bundle flow can be enough:

  1. Store a bounded number of reports on device.
  2. Display a human-readable summary in diagnostics settings.
  3. Let the person explicitly export a bundle when requesting support.
  4. Remove filenames, paths, user-generated strings, and stable device identifiers.
  5. State clearly what the exported bundle contains.

For automatic aggregation, add controls:

  • upload only over the conditions your product promises;
  • attach app/build and coarse device-family data, not identity;
  • limit retention;
  • strip custom metadata that is not required for the investigation;
  • keep ingestion failure independent from app startup;
  • update privacy disclosures to match the actual data flow.

Never block launch while waiting for a monitoring upload. Observability must fail open.

Migration from MXMetricManager

Use an adapter so the rest of the app does not care which MetricKit generation delivered the report:

protocol PerformanceReportSink: Sendable {
    func accept(_ envelope: PerformanceEnvelope) async
}

struct PerformanceEnvelope: Sendable {
    enum Kind: Sendable { case metric, diagnostic }
    let kind: Kind
    let payload: Data
    let receivedAt: Date
}

Then maintain two availability-specific collectors during migration:

  • iOS 27+: MetricManager asynchronous sequences;
  • earlier supported releases: the existing MXMetricManagerSubscriber path.

Normalize only fields your application actually queries. Trying to force every old and new payload field into one giant model usually loses the benefits of the new typed API and creates an expensive compatibility layer.

Operational checks before shipping

Verify ownership and cancellation

Confirm collection starts once, duplicate scene creation does not start duplicate consumers, and tasks cancel cleanly in tests.

Test partial reports

Feed the processor reports that omit a metric group. Missing data should produce “unavailable,” not a zero-value success state.

Version stored envelopes

The new APIs are beta. Store a schema version outside the encoded Apple report so migrations can distinguish your envelope format from the framework payload.

Bound local storage

Daily reports accumulate. Set a count or age limit, write atomically, and recover from a truncated final record without discarding every earlier report.

Separate developer diagnostics from product analytics

Launch time and hang rate help engineers. They do not automatically justify building behavioral profiles. Review each state domain and metadata field independently.

Validate on physical devices

Synthetic reports can prove decoding and UI behavior. Only device use over time proves that collection, report delivery, background persistence, and your chosen states work together.

Common failure modes

Starting collection from a view. SwiftUI can recreate views. Put monitoring in an application-owned service.

Assuming a daily report arrives at a precise time. Treat delivery as asynchronous and resumable.

Uploading the raw report before local persistence. Network failure should not discard the only copy. Persist first, upload second, mark completion atomically.

Using high-cardinality state names. Dynamic identifiers prevent useful aggregation and can expose personal data.

Turning unavailable into zero. “No metric was reported” and “the measured value was zero” are different states.

Migrating and changing the analytics model simultaneously. First establish parity, then add state-scoped questions one domain at a time.

Frequently asked questions

Does the new MetricManager replace MXMetricManager? For iOS 27 and aligned releases, Apple positions MetricManager as the replacement and the path to the new capabilities. Keep an availability-gated legacy collector if the app supports earlier systems.

Can reports be encoded directly? Yes. Apple documents the new report types as Codable, making durable storage or transfer straightforward. Encoding support does not remove the need for privacy review, retention limits, or schema versioning.

Are diagnostics delivered only once per day? The new model separates daily aggregated metrics from event-based diagnostic reports. Build separate consumers and do not assume identical timing.

Should every screen become a reported state? No. Add only bounded states connected to a performance question you can act on. Workflow phases usually provide more useful context than screen names.

Can MetricKit replace signposts and Instruments? No. Instruments and signposts are essential during development and controlled profiling. MetricKit adds aggregated evidence and diagnostics from real-world use; it complements those tools.

Primary references

The useful architectural shift is not merely replacing a delegate with for await. It is treating performance reports as a typed, versioned, privacy-reviewed data stream—and adding only enough app-state context to turn a regression into an actionable engineering task.

Authoritative References