Contents

Monitoring app performance with MetricKit

Receive daily performance and diagnostic reports from real device usage.

Overview

MetricKit collects performance data from real devices running your app. It provides profiling data to your app once per day, giving you a picture of how your app performs in conditions you can’t easily reproduce during development. You can collect statistics like CPU usage, memory consumption, network activity, launch time, disk I/O, and more, all through a single API.

When you use the StateReporting framework to describe your app’s behavior, MetricKit can attribute metrics like scroll hitch time or slow app launch to a particular feature, an app-configurable setting, or any other labeled states that you define. With performance data broken down by states, you have more evidence to make targeted, confident decisions for the next version of your app.

Understand the reporting model

MetricManager is the entry point for receiving metric data. Depending on what you need to measure, you create instances of MetricManager that are scoped to a module, a feature, or the lifetime of your app. For each instance, you can observe two asynchronous sequences: metricReports for daily aggregated performance data and diagnosticReports for event-based diagnostic reports. These reports conform to Codable, so you can encode them for upload to a backend database or archive them locally.

A MetricReport contains measurements organized into two kinds of entries, intervalEntries and stateEntries. intervalEntries contains metrics aggregated over time, including a full-day entry for the reporting period. Each MetricReport.IntervalEntry also includes a states array that records which states were active during that interval. stateEntries contains the same metrics segmented by the states your app reports through the StateReporting framework. The two views are complementary: interval entries give you a time-based picture with state information, and state entries reveal how performance varies across distinct user experiences. Both stateEntries and the states arrays are empty when your app does not adopt the StateReporting framework or when no states are active during the reporting period.

A DiagnosticReport contains discrete, event-based data. Access each report’s context through its DiagnosticReport.Environment, which includes information such as application version, operating system version, and device type. The results within each report are DiagnosticResult enum values such as DiagnosticResult.crash(_:), DiagnosticResult.hang(_:), DiagnosticResult.cpuException(_:), DiagnosticResult.diskWriteException(_:), and DiagnosticResult.appLaunch(_:).

MetricKit collects data continuously and delivers it once per day when conditions permit. The timeRange tells you the exact interval the report covers, and because each report reflects a full day’s worth of real usage, it’s more likely to encounter edge cases that are difficult to reproduce in a local test environment.

Set up a metric manager

Create and store your MetricManager in a long-lived property so the subscription remains active:

let manager = MetricManager()

To receive per-state metric entries, pass your StateReportingDomain values at initialization:

extension StateReportingDomain {
    static let experiments: StateReportingDomain = "com.example.app.experiments"
}

let manager = MetricManager(enabledStateReportingDomains: [.experiments])

A StateReportingDomain identifies a logical grouping of related states, such as an experiment group or a feature area. Separate domains allow multiple states to be active at the same time, such as a game tracking gameplay mode in one domain and graphics quality in another. A player moving between single-player and multi-player does not affect the graphics quality state. That state is tracked independently in its own domain. You only receive per-state data for domains you explicitly register, and a manager you initialize without domains receives only interval entries. Use a single MetricManager instance that registers all the domains your app needs, which reduces overhead and simplifies observation.

Use the ReportableMetadata() attribute to define a state type, then call reporter(for:stableMetadata:volatileMetadata:) to get a reporter and reportTransition(to:stableMetadata:volatileMetadata:) to signal state changes:

import StateReporting

@ReportableMetadata
struct GraphicsConfiguration {
    let resolution: Int
    let shadow: String
    let texture: String
}

let reporter = StateReporter.reporter(
    for: "com.example.app.graphics",
    stableMetadata: GraphicsConfiguration.self
)

reporter.reportTransition(
    to: "low",
    stableMetadata: GraphicsConfiguration(resolution: 720, shadow: "low", texture: "low")
)

// Signals that the graphics state is over.
reporter.reportTransition(to: nil)

ReportableMetadata() automatically generates the metadataDictionary conformance from the struct’s stored properties. Each property becomes part of the state data MetricKit records.

MetricKit only surfaces stable metadata. You can also pass volatileMetadata to your StateReporter, which is available to other diagnostic tools such as Instruments, but is not visible to MetricKit. For more information, see StateReporting.

Observe metric reports

Use for await to consume each MetricReport as it arrives. Each report provides two complementary views of your performance data: stateEntries with metrics segmented by app state, and intervalEntries with metrics aggregated over time windows. Iterate both to capture the full picture:

let manager = MetricManager(
    enabledStateReportingDomains: [StateReportingDomain("com.example.app.experiments")]
)

for await report in manager.metricReports {
    // Metrics segmented by app state.
    for entry in report.stateEntries {
        for value in entry.values {
            switch value {
            case let .hangTime(metric): uploadMetric(metric.histogram, state: entry.state.label)
            case let .scrollHitchTime(metric): uploadMetric(metric.histogram, state: entry.state.label)
            @unknown default: break
            }
        }
    }

    // Metrics aggregated over time intervals.
    for entry in report.intervalEntries {
        for value in entry.values {
            switch value {
            case let .cpuTime(metric): uploadMetric(metric.value)
            case let .peakMemory(metric): uploadMetric(metric.value)
            @unknown default: break
            }
        }
    }
}

Include an @unknown default case in each switch statement to handle any additional metrics.

When you don’t register any state reporting domains, stateEntries is empty and all performance data appears in the full-day interval entry, accessible through fullDayEntry, with an empty states array.

Observe diagnostic reports

diagnosticReports delivers one DiagnosticReport per event. Each report represents a single occurrence of a crash, hang, or exception:

for await report in manager.diagnosticReports {
    switch report.result {
    case let .crash(diagnostic):
        upload(diagnostic.callStackTree, version: report.environment.applicationVersion)
    case let .hang(diagnostic):
        log(diagnostic.hangDuration)
    case let .cpuException(diagnostic):
        log(diagnostic.totalCPUTime)
    case let .diskWriteException(diagnostic):
        log(diagnostic.totalBytesWritten)
    @unknown default:
        break
    }
}

A DiagnosticResult.crash(_:) result carries a CrashDiagnostic with a callStackTree, essential for diagnosing crashes that happen in production. A DiagnosticResult.hang(_:) result carries a HangDiagnostic with the call stack at the time of the hang, pointing directly to which code blocks the main thread.

The environment property’s states array contains the StateReporting states active immediately before the event. This context helps you reproduce issues that only occur under specific app conditions.

Capture custom metrics with signposts

Signposts let you measure the duration of specific operations you define in your app, such as network requests, database queries, image processing pipelines, or any other work you want to track in production. MetricKit aggregates these measurements and delivers them as MetricResult.signpostInterval(_:) values in your daily MetricReport.

Use logHandle(category:) to get an OSLog object tied to the MetricKit collection pipeline:

let networkLog = MetricManager.logHandle(category: "NetworkRequests")

Surround each custom operation with the signpost wrapper mxSignpost(_:dso:log:name:signpostID:_:_:), enabling the full SignpostIntervalMetric result:

mxSignpost(.begin, log: networkLog, name: "fetchUserProfile")
await fetchUserProfile()
mxSignpost(.end, log: networkLog, name: "fetchUserProfile")

The associated SignpostIntervalMetric tells you how the operation performed: its name and category, how many times it ran, and a Histogram of observed durations:

case let .signpostInterval(metric):
    print("Operation: \(metric.signpostName) (\(metric.signpostCategory))")
    print("Total occurrences: \(metric.totalCount)")
    for bucket in metric.signpostDuration.buckets {
        print("\(bucket.lowerBound)\(bucket.upperBound): \(bucket.count)")
    }

SignpostIntervalMetric also exposes optional resource-consumption properties — cpuTime, logicalWrites, averageMemory, hitchTimeRatio, totalHitchTime, and totalAnimationTime — which MetricKit populates when it has enough data to report them.

Measure extended launch

Wrap launch-critical asynchronous work in trackLaunchTask(id:onTrackingError:_:) to extend the MetricKit launch measurement. Standard launch metrics end at applicationDidFinishLaunching. Many apps also perform asynchronous work that forms part of the perceived launch experience, such as data bootstrapping, configuration fetching, or initial content loading. Tracking this work captures the full time users wait before the app is ready:

await manager.trackLaunchTask(id: "bootstrapData") {
    await bootstrapApplication()
}

The onTrackingError closure receives a MetricManager.LaunchTaskError when MetricKit cannot record the measurement, letting you log the issue without interrupting the launch work. MetricKit reports the result as MetricResult.extendedLaunch(_:) in the daily report.

See Also

Essentials