Contents

Analyzing app performance with MetricKit

Work with the metric values, diagnostic data, and environments in MetricKit reports.

Overview

MetricKit reports contain a rich set of typed measurements, diagnostic data, and environmental context. MetricResult carries individual metric values (scalars, histograms, or statistics depending on the metric), and you can use the metricGroup property to route them to category-specific handlers. DiagnosticReport wraps a single DiagnosticResult case with type-specific properties and a CallStackTree you can navigate to locate the code involved. MetricReport and DiagnosticReport include an environment with device, operating system, and app context. Both also conform to Codable, so you can serialize them with JSONEncoder for storage or upload.

Filter groups of metrics

Every MetricResult case carries a metricGroup property that returns a MetricGroup value identifying the category the measurement belongs to, such as CPU, GPU, memory, or disk I/O. You can use metricGroup to filter the values array without writing an exhaustive switch. This pattern is useful when routing measurements to category-specific handlers, logging only a subset of metrics, or building a summary that groups data by category.

let memoryValues = entry.values.filter { $0.metricGroup == .memory }

Understand measurements

The majority of values are scalar values expressed as a Measurement. A Measurement pairs a Double value with a unit. MetricKit uses unit types including:

Work with histogram distributions

Several MetricResult cases expose a Histogram rather than a scalar value. A Histogram contains an ordered array of buckets, each with a lower bound, an upper bound, and a count of observations that fell within that range.

Iterating through the buckets gives you the full distribution of measured values:

case let .hangTime(metric):
    for bucket in metric.histogram.buckets {
        print(
            "\(bucket.lowerBound)\(bucket.upperBound):"
            + " \(bucket.count) hangs"
        )
    }

Work with average statistics

Some metrics report a single averaged value rather than a distribution. For example, SuspendedMemoryMetric exposes a value property of type AverageStatistics. It provides three properties: an average, a count, and an optional standard deviation. A count of zero means the sample count isn’t available for this reporting period. standardDeviation is nil when unavailable.

case let .suspendedMemory(metric):
    let statistics = metric.value
    print("Average suspended memory: \(statistics.average)")

    if statistics.count > 0 {
        // A count equal to 0 means it's unavailable.
        print("Sample count: \(statistics.count)")
    }

    if let standardDeviation = statistics.standardDeviation {
        print("Std dev: \(standardDeviation)")
    }

Read location and disk space metrics

Not every metric condenses to a single value. MetricResult.locationActivityTime(_:) breaks location accuracy usage into six tiers, each a Measurement<UnitDuration>:

case let .locationActivityTime(metric):
    print("Best accuracy for navigation: \(metric.bestAccuracyForNavigation)")
    print("Best accuracy:               \(metric.bestAccuracy)")
    print("Ten meters:                  \(metric.tenMeters)")
    print("One hundred meters:          \(metric.oneHundredMeter)")
    print("One kilometer:               \(metric.oneKilometer)")
    print("Three kilometers:            \(metric.threeKilometers)")

MetricResult.totalFileCount(_:) and MetricResult.totalFileSize(_:) distinguish binary content from data content. Use them to understand how your app’s storage breaks down between executable code and user data:

case let .totalFileSize(metric):
    print("Binary size: \(metric.binaryFileSize)")
    print("Data size:   \(metric.dataFileSize)")

case let .totalFileCount(metric):
    print("Binary files: \(metric.binaryFileCount)")
    print("Data files:   \(metric.dataFileCount)")

case let .totalDiskSpaceCapacity(metric):
    print("Device capacity: \(metric.capacity)")

MetricResult.metalFrameRate(_:) provides frame statistics for a specific Metal layer, including the layer name, frame count, active drawing duration, and a frames-per-second measurement:

case let .metalFrameRate(metric):
    print("Layer: \(metric.layerName)")
    print("Frames: \(metric.frameCount)")
    print("Active drawing: \(metric.activeDrawingDuration)")
    print("FPS: \(metric.framesPerSecond)")

Extract diagnostic details

Each DiagnosticReport wraps a single DiagnosticResult case. Switch over the result to access the type-specific properties of each diagnostic. MemoryExceptionDiagnostic is only available on iOS.

switch report.result {
case let .crash(diagnostic):
    if let reason = diagnostic.terminationReason {
        log("Termination reason: \(reason.rawValue)")
    }
    if let exceptionType = diagnostic.exceptionType {
        log("Exception type: \(exceptionType)")
    }
    analyze(diagnostic.callStackTree)
case let .hang(diagnostic):
    log("Hang duration: \(diagnostic.hangDuration)")
    analyze(diagnostic.callStackTree)
case let .cpuException(diagnostic):
    log("CPU time: \(diagnostic.totalCPUTime)")
    log("Sampled time: \(diagnostic.totalSampledTime)")
    analyze(diagnostic.callStackTree)
case let .diskWriteException(diagnostic):
    log("Bytes written: \(diagnostic.totalBytesWritten)")
    analyze(diagnostic.callStackTree)
case let .appLaunch(diagnostic):
    log("Launch duration: \(diagnostic.launchDuration)")
case let .memoryException(diagnostic):
    analyze(diagnostic.callStackTree)
@unknown default:
    break
}

CallStackTree is the primary structure for analyzing crashes, hangs, and exceptions. It contains an array of CallStackThread values and supports binary metadata lookup by UUID.

The callStackPerThread property tells you how the frames are organized. When callStackPerThread is true, each thread has its own root frames. When it’s false, all frames across all threads are merged into a single thread.

The simplest way to iterate through every frame in the tree is forEachFrame(_:), which handles the recursive subFrames traversal for you:

var frames: [(address: UInt64, binaryName: String)] = []

diagnostic.callStackTree.forEachFrame { frame in
    guard let address = frame.address,
          let uuid = frame.binaryUUID,
          let info = diagnostic.callStackTree.binaryInfo[uuid]
    else { return }
    frames.append((address: address, binaryName: info.name))
}

binaryInfo is a dictionary, and the key is the same binaryUUID that each CallStackFrame carries. A CallStackTree.BinaryInfo value provides the binary’s uuid and name.

When you need to examine threads individually — for example, to identify the crashing thread separately — iterate callStackThreads directly. Each CallStackThread exposes a rootFrames array, and each CallStackFrame has a subFrames array for manual recursion:

for frame in frames {
    if let uuid = frame.binaryUUID,
       let info = tree.binaryInfo[uuid] {
        let indent = String(repeating: "  ", count: depth)
        print("\(indent)\(info.name) + "
            + "\(frame.offsetIntoBinaryTextSegment ?? 0)")
    }
    visitFrames(frame.subFrames, depth: depth + 1, tree: tree)
}

Review a report’s environment

MetricReport and DiagnosticReport both carry an environment that provides context about the device and session at the time of collection. The two environments have different optionality: environment is optional, while environment is required.

MetricReport.Environment includes osVersion, deviceType, regionFormat, and lowPowerModeEnabled. It also exposes hasExceededStateLimit, which is true when the number of unique states during the reporting period exceeded the system limit — some state data is then folded into the full-day interval entry rather than appearing in stateEntries.

DiagnosticReport.Environment provides additional app-specific context. Check it to understand the exact build context of a diagnostic event:

let environment = report.environment
print("OS: \(environment.osVersion), device: \(environment.deviceType)")
print("App: \(environment.applicationVersion) (\(environment.applicationBuildVersion))")

if environment.isTestFlightApp {
    print("Running under TestFlight")
}

if !environment.signpostData.isEmpty {
    print("Active signposts at event time:")
    for record in environment.signpostData {
        print("  \(record)")
    }
}

signpostData is an array of SignpostRecord values representing any OSSignposter intervals that were active when the diagnostic occurred. DiagnosticReport.Environment also provides a pid, bundleIdentifier, regionFormat, and states.

Serialize reports

Both MetricReport and DiagnosticReport conform to Codable. To send a MetricReport to your server, encode it inside your observation loop using JSONEncoder. Setting the encodingFormatKey in the encoder’s userInfo to MetricReport.EncodingFormat.byStateReportingDomain groups the encoded output by domain, so both state entries and interval entries in the resulting JSON contain your app’s performance metrics organized by each reporting domain and the states within it:

import MetricKit

for await report in manager.metricReports {
    do {
        let encoder = JSONEncoder()
        encoder.outputFormatting = .prettyPrinted

        let formatKey = MetricReport.encodingFormatKey
        encoder.userInfo[formatKey] = MetricReport.EncodingFormat.byStateReportingDomain

        let jsonData = try encoder.encode(report)
        // Send to server
    } catch {
        // Handle encoding error
    }
}

See Also

Essentials