RSS Amplifier

Anton’s Substack · Aug 18, 2026

iOS 27: StateReporter

0
Sign in to vote or save

Anton’s Substack · Anton’s Substack

If you’ve ever looked at a MetricKit report and wondered what the user was actually doing when a hang or hitch happened, you know the problem StateReporting is trying to solve.

MetricKit can tell us that the app spent too much time hanging. Instruments can show us a suspicious interval. But without application context, a performance issue can still be difficult to explain. Was the user browsing a list? Importing files? Running a cleanup? Looking at a heavy chart? We usually answer that with signposts, logs, analytics, or our own debugging breadcrumbs.

iOS 27 adds StateReporting, a framework for attaching application state to those diagnostics. We define a domain (think about it as unique ID), report transitions with a small state label, and optionally attach structured metadata. MetricKit can then aggregate performance metrics by those states, while Instruments shows the transitions in the Points of Interest instrument.

And this is it. StateReporting is not another analytics framework and it is not a general-purpose state container. It gives performance tools enough context to answer a much more useful question: what was the app doing when the metric was recorded? Probably you have heard about Sentry with close features. Apple is trying to cover this area also seems like.

StateReporting is currently a beta API in the iOS 27 SDK. Names and behavior may still change before the final release. Warning you as usual.

Let’s use a storage cleanup utility which is written to showcase this framework. Below I will provide a full code sample but for now take a look on UI:

Storage Cleanup - Just a Demo

The utility has one cleanup flow:

Idle
↓
Scanning
↓
Cleaning
↓
Completed

If something goes wrong, it can also move to Failed.

Error state

The obvious approach would be to send every progress value as another state:

Scanning 10%
Scanning 20%
Scanning 30%
...

That would be a mistake.

StateReporting separates state transitions from metadata that changes while the state is active. A state is identified by its label and stable metadata. Volatile metadata can change without creating a new state transition. This distinction is important because downstream diagnostics aggregate metrics by state, and creating thousands of slightly different states would fragment the data into useless buckets.

For our utility, Scanning, Cleaning, Completed, and Failed are good state labels. Progress and the current number of files found are better volatile metadata.

We don’t initialize StateReporter directly. The framework gives us one reporter per domain through reporter(for:stableMetadata:volatileMetadata:):

let reporter = StateReporter.reporter(
    for: "com.example.storage-cleaner.cleanup",
    stableMetadata: CleanupStableMetadata.self,
    volatileMetadata: CleanupVolatileMetadata.self
)

A domain is a reverse-DNS string describing one functional area of the app. In a larger utility you could have separate domains for cleanup, cloud sync, downloads, and VPN connection state. Only one state is active in each domain at a time, so independent domains let those states coexist.

There is one detail I would treat as an API contract rather than an implementation detail: the same domain must always use the same metadata types. StateReporter returns the same reporter instance for a given domain, and requesting that domain later with different generic metadata types is a runtime fatal error. Even docs are straight:

Warning! Calling reporter(for:stableMetadata:volatileMetadata:) for the same domain with different metadata types will crash at runtime. Keep that in mind and assign carefully.

So don’t casually change the domain string or reuse it for a completely different feature.

Metadata is represented by types conforming to ReportableMetadata. We can build the required dictionary manually, but the framework provides @ReportableMetadata, which generates the conformance for us:

@ReportableMetadata
struct CleanupStableMetadata: Equatable {
    let mode: String
    let scanStrategy: String
}
@ReportableMetadata
struct CleanupVolatileMetadata: Equatable {
    let progress: Double
    let filesFound: Int
    let reclaimableMegabytes: Double
}

The first type contains values that should participate in state aggregation. A cleanup running in "Temporary Files" mode with the "Safe" strategy is meaningfully different from another configuration, so those values are stable metadata.

The second type contains information that naturally changes while the state is active. Progress can move from 0.1 to 0.9 without us wanting nine different Scanning states. The same is true for file count and reclaimable storage.

The macro works with supported scalar values such as String, Int, Double, Date, and Bool. @ReportableMetadataKey can give a field a different metadata key, while @ReportableMetadataIgnored keeps a stored property out of the reported dictionary entirely.

When cleanup starts scanning, we report a transition:

reporter.reportTransition(
    to: "Scanning",
    stableMetadata: CleanupStableMetadata(
        mode: "Temporary Files",
        scanStrategy: "Safe"
    ),
    volatileMetadata: CleanupVolatileMetadata(
        progress: 0,
        filesFound: 0,
        reclaimableMegabytes: 0
    )
)

A transition happens when the label or stable metadata changes. Reporting the same label with the same stable metadata again is a not possible/valid.

That behavior is exactly what we want. Scanning isn’t an event that needs to be emitted repeatedly. It is a state that remains active until something meaningful changes.

The framework also requires non-empty labels. If the domain no longer has any active state, pass nil:

reporter.reportTransition(to: nil)

The complete sample does that on Reset.

Now the scanner starts finding files…

The state is still Scanning, so reporting a new transition for every progress update would be wrong. Instead, we update only the volatile metadata:

reporter.reportVolatileMetadataUpdate(
    CleanupVolatileMetadata(
        progress: 0.6,
        filesFound: 840,
        reclaimableMegabytes: 540
    )
)

This changes the diagnostic context attached to the active state without creating a new state transition.

There is an important practical constraint here: StateReporting is rate-limited. Apple recommends reporting at human-interaction timescales, not every frame and not from a tight processing loop. If the API is called too frequently, updates can be dropped.

That is why the demo deliberately samples progress. The real cleanup algorithm could update its internal UI progress far more often without forwarding every value to StateReporting.

When scanning finishes, the label changes and we create a real transition:

reporter.reportTransition(
    to: "Cleaning",
    stableMetadata: stableMetadata,
    volatileMetadata: volatileMetadata
)

Now the diagnostic timeline can remain simple:

Scanning
Cleaning
Completed

instead of hundreds of progress-labelled pseudo-states.

This matters because diagnostic tools aggregate metrics using the state label and stable metadata. Dynamic labels such as "Scanning-\(progress)" would split the data into tiny buckets and make the whole feature much less useful.

This is probably the easiest part of the API to misuse.

A rule that works well:

Stable metadata describes the variant of the state. Volatile metadata describes what is happening inside that state.

For our cleanup utility, mode and strategy are stable. Progress, discovered files, and reclaimable storage are volatile.

If a value changes often, it is usually a poor stable-metadata candidate because each distinct value can fragment aggregation. Keep the set of labels and stable values intentionally small.

Failure doesn’t need a different reporting system. It is another state:

reporter.reportTransition(
    to: "Failed",
    stableMetadata: stableMetadata,
    volatileMetadata: volatileMetadata
)

Completion follows the same pattern:

reporter.reportTransition(
    to: "Completed",
    stableMetadata: stableMetadata,
    volatileMetadata: volatileMetadata
)

This is cleaner than building separate success and error reporting pipelines.

I would still avoid turning every error into its own state label. A state like "Failed" is useful. Dozens of labels such as "Failed-NoPermission" and "Failed-NoDiskSpace" start rebuilding an analytics event taxonomy inside StateReporting.

StateReporting doesn’t replace Logger.

Logger is still the right tool for implementation details:

logger.error("Failed to remove cache directory: \(url)")

StateReporting gives diagnostic tools the feature-level context around that failure:

State: Cleaning
Progress: 64%
Mode: Temporary Files
Strategy: Safe

One explains what happened. The other explains what the app was doing when it happened.

I would use both rather than forcing one to replace the other.

Each reporting domain has one active state, but an app can have several domains active at the same time.

A storage utility might report:

com.example.storage-cleaner.cleanup
State: Cleaning
com.example.storage-cleaner.sync
State: Uploading

Those concerns stay independent. That is much cleaner than building a giant combined enum such as cleaningAndUploading, scanningAndUploading, and every other possible combination.

When no meaningful state is active anymore, clear the domain:

reporter.reportTransition(to: nil)

I prefer doing this explicitly instead of leaving "Completed" active indefinitely. Otherwise a later diagnostic could look as if it happened during cleanup when the feature had actually finished long before.

The demo uses one StorageCleanupModel that owns both the UI state and a long-lived StateReporter.

Phase changes call:

reportTransition(...)

Progress changes inside the current phase call:

reportVolatileMetadataUpdate(...)

The SwiftUI view knows nothing about StateReporting. It simply renders the same model state it would render in a normal utility app.

I like that separation because StateReporting stays diagnostic infrastructure instead of leaking into the UI layer.

Full code is available here.

I don’t think every application needs StateReporting. It becomes valuable when an app already has clearly identifiable feature states and performance diagnostics matter.

Utilities are an obvious fit:

  • storage cleanup;

  • downloads;

  • VPN connection phases;

  • media processing;

  • imports and exports;

  • cloud synchronization;

  • document conversion;

  • local AI processing.

The labels should stay small and meaningful. If a feature doesn’t naturally have a few stable states, forcing it into StateReporting probably won’t create useful diagnostics.

A few rules are worth remembering:

  • A domain has only one active state at a time

  • The same domain must always use the same stable and volatile metadata types

  • Empty state labels are invalid; use nil to clear the active state

  • A transition occurs only when the label or stable metadata changes

  • Volatile metadata updates don’t create transitions

  • StateReporting is rate-limited

  • Keep labels and stable metadata low-cardinality

  • Don’t put sensitive or constantly changing identifiers into stable metadata

  • StateReporting complements Logger and diagnostic tools rather than replacing them

The framework itself is quite small. The difficult part is choosing states and metadata that will still produce useful diagnostic buckets six months later.

Happy coding!

No posts

Read the original on antongubarenko.substack.com

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.