---
title: "How to add session replay to an iOS app with Swift"
description: "Add consent-controlled session replay to a SwiftUI report screen. Configure masking, keep one shared client, and verify a test recording."
canonical: "https://amplitude.com/en-us/build/ios-session-replay"
---

# How to add session replay to an iOS app with Swift

Add consent-controlled session replay to a SwiftUI report screen. Configure masking, keep one shared client, and verify a test recording.

Source: https://amplitude.com/en-us/build/ios-session-replay

---

# How to add session replay to an iOS app with Swift

Add session replay to a SwiftUI report screen, start capture after an explicit choice, and stop it when that choice is withdrawn. Allow about 30 minutes after Xcode and an iOS simulator are installed, plus time for the test replay to process.

- iOS 17+
- SwiftUI
- Unified Swift SDK 0.0.4
- 30 min

Chris Tam · Last updated 28 October 2026

SwiftUI builds a screen from views that change as someone uses the app. [Amplitude session replay](https://amplitude.com/session-replay) adds visual context to those interactions so you can investigate the report someone opened or the date range they changed.

In this tutorial, you'll build a report screen, add replay controls and masking, then check the resulting recording against a known sequence of actions.

A SwiftUI usage report with Allow replay and Stop replay controls, a synthetic note area configured for blocking, and Report Previewed and Report Range Changed events. You'll find the test session in Amplitude and inspect the recording before using real content.

## Prerequisites

- Xcode with an iOS 17+ simulator or device. Create an iOS App project using SwiftUI and Swift 5 language mode. The Unified package requires Swift tools 5.9 or later; iOS 17 is this sample's target, rather than the replay SDK's minimum.
- An Amplitude development project with replay capture available and quota remaining. An admin or manager can check Settings > Organizational Settings > Session Replay Settings for that project.
- The project's ingestion API key from [Organization settings > API Keys](https://amplitude.com/docs/admin/account-management/manage-your-api-keys-and-secret-keys), and its US or EU region.

[Create an Amplitude account](https://app.amplitude.com/signup) if you need a development project, and [compare plans](https://amplitude.com/pricing) for your replay allowance.

## 1. Create the app and add the Unified SDK

I create an iOS App project named AmplitudeReports, using SwiftUI, Swift and no persistence framework. Set the deployment target to iOS 17 and the Swift language version to Swift 5. Keep the generated Xcode project, assets, bundle identifier and signing settings. The nine custom files below make a complete sample; the [iOS analytics tutorial](https://amplitude.com/build/ios-product-analytics) is optional context. The [complete sample](https://github.com/amplitude/ampli-examples/tree/068969dcf5929ec65697073027f4ce9a664fd07c/ios/unified-swift-replay-reports) includes the dependency lock and local tests.

For an existing app, merge these additions into its current app entry point, authentication, navigation, privacy controls and build configuration. Preserve its existing instrumentation and shared client.

Following the [mobile SDK installation guide](https://amplitude.com/docs/get-started/install-mobile-sdk#ios), choose File > Add Package Dependencies and enter:

**Xcode package URL**

Xcode package URL

```text
https://github.com/amplitude/AmplitudeUnified-Swift
```

Choose Exact Version 0.0.4 and add the AmplitudeUnified product to the app target. Keep Xcode's generated Package.resolved with the project. The package includes the iOS replay integration; this sample uses that owned replay client and doesn't add a second replay plugin or analytics instance.

## 2. Configure the key and region

I create a Config folder beside the app source folder. Copy Local.xcconfig.example to Local.xcconfig and enter the development project's API key and US or EU region there. The blank example stays in source control. A mobile ingestion key ships with the app bundle; keep secret keys and server deployment keys out of the app.

**Config/Amplitude.xcconfig**

Config/Amplitude.xcconfig

```text
#include? "Local.xcconfig"
GENERATE_INFOPLIST_FILE = NO
INFOPLIST_FILE = AmplitudeReports/Info.plist
INFOPLIST_EXPAND_BUILD_SETTINGS = YES
```

**Config/Local.xcconfig.example**

Config/Local.xcconfig.example

```text
AMPLITUDE_API_KEY =
AMPLITUDE_SERVER_ZONE = US
```

**.gitignore**

.gitignore

```text
.build/
.swiftpm/
Config/Local.xcconfig
xcuserdata/
AmplitudeReports.xcodeproj/
```

Set Config/Amplitude.xcconfig as the app target's base configuration for Debug and Release under Info > Configurations. In an existing project, include these settings from its existing configuration rather than replacing it.

In Build Settings, check the resolved values: GENERATE\_INFOPLIST\_FILE is NO, INFOPLIST\_FILE is AmplitudeReports/Info.plist, and INFOPLIST\_EXPAND\_BUILD\_SETTINGS is YES. Target-level settings override xcconfig values, so remove conflicting overrides or set the target to those same values. Apple documents the precedence in [adding a build configuration file](https://developer.apple.com/documentation/xcode/adding-a-build-configuration-file-to-your-project).

Add the following plist outside Copy Bundle Resources. In an existing app, merge the two AMPLITUDE keys into its plist instead.

**AmplitudeReports/Info.plist**

AmplitudeReports/Info.plist

```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>CFBundleDevelopmentRegion</key><string>$(DEVELOPMENT_LANGUAGE)</string>
    <key>CFBundleExecutable</key><string>$(EXECUTABLE_NAME)</string>
    <key>CFBundleIdentifier</key><string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
    <key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
    <key>CFBundleName</key><string>$(PRODUCT_NAME)</string>
    <key>CFBundlePackageType</key><string>APPL</string>
    <key>CFBundleShortVersionString</key><string>$(MARKETING_VERSION)</string>
    <key>CFBundleVersion</key><string>$(CURRENT_PROJECT_VERSION)</string>
    <key>LSRequiresIPhoneOS</key><true/>
    <key>UIApplicationSceneManifest</key>
    <dict><key>UIApplicationSupportsMultipleScenes</key><false/></dict>
    <key>UILaunchScreen</key><dict/>
    <key>UISupportedInterfaceOrientations</key>
    <array><string>UIInterfaceOrientationPortrait</string></array>
    <key>AMPLITUDE_API_KEY</key><string>$(AMPLITUDE_API_KEY)</string>
    <key>AMPLITUDE_SERVER_ZONE</key><string>$(AMPLITUDE_SERVER_ZONE)</string>
</dict>
</plist>
```

**AmplitudeReports/Core/AnalyticsSettings.swift**

AmplitudeReports/Core/AnalyticsSettings.swift

```swift
import Foundation

struct AnalyticsSettings {
    enum Zone: String { case US, EU }
    let apiKey: String
    let zone: Zone

    init?(info: [String: Any]) {
        let key = (info["AMPLITUDE_API_KEY"] as? String ?? "")
            .trimmingCharacters(in: .whitespacesAndNewlines)
        let region = (info["AMPLITUDE_SERVER_ZONE"] as? String ?? "US")
            .trimmingCharacters(in: .whitespacesAndNewlines)
        guard !key.isEmpty, !key.contains("$("), let zone = Zone(rawValue: region) else {
            return nil
        }
        self.apiKey = key
        self.zone = zone
    }
```

A blank key, unresolved build variable or invalid region leaves the sample unconfigured. Rebuild after changing Local.xcconfig so the updated values reach the app bundle.

## 3. Initialize once with capture off

I keep the client in one shared main-actor service. The replay configuration uses sampleRate 1 for the development test, conservative masking, disabled remote overrides and autoStart false. Capture waits for the explicit Allow replay action. Autocapture and remote autocapture changes stay disabled, so the report events come from the button handlers.

**AmplitudeReports/Core/ReportAnalytics.swift**

AmplitudeReports/Core/ReportAnalytics.swift

```swift
import AmplitudeUnified
import Foundation
import Combine

@MainActor
final class ReportAnalytics: ObservableObject {
    static let shared = ReportAnalytics(settings: AnalyticsSettings(info: Bundle.main.infoDictionary ?? [:]))
    private let client: Amplitude?
    @Published private(set) var replayAllowed = false
    var configured: Bool { client != nil }

    convenience init(settings: AnalyticsSettings?) {
        guard let settings else {
            self.init(client: nil)
            return
        }
        let analytics = AnalyticsConfig(autocapture: [], enableAutoCaptureRemoteConfig: false)
        #if os(iOS)
        let replay = SessionReplayPlugin.Config(
            sampleRate: 1,
            maskLevel: .conservative,
            enableRemoteConfig: false,
            autoStart: false
        )
        let client = Amplitude(
            apiKey: settings.apiKey,
            serverZone: settings.zone == .EU ? .EU : .US,
            analyticsConfig: analytics,
            sessionReplayConfig: replay
        )
        #else
        // The same event/auth helper can be checked on macOS; replay is iOS-only.
        let client = Amplitude(
            apiKey: settings.apiKey,
            serverZone: settings.zone == .EU ? .EU : .US,
            analyticsConfig: analytics
        )
        #endif
        self.init(client: client)
    }

    init(client: Amplitude?) { self.client = client }

    func reportPreviewed(days: Int) {
        client?.track(eventType: "Report Previewed", eventProperties: properties(days: days))
    }

    func reportRangeChanged(days: Int) {
        client?.track(eventType: "Report Range Changed", eventProperties: properties(days: days))
    }

    // Call after the existing authentication system confirms the account.
    func accountSignedIn(userID: String, plan: String) {
        precondition(userID.count >= 5, "Use your canonical user ID, at least five characters")
        client?.setUserId(userId: userID)
        let identify = Identify()
        identify.set(property: "plan", value: plan)
        client?.identify(identify: identify)
    }

    // Connect this to the app's replay-specific consent decision.
    func setReplayAllowed(_ allowed: Bool) {
        #if os(iOS)
        guard let replay = client?.sessionReplay else {
            replayAllowed = false
            return
        }
        if allowed { replay.start() } else { replay.stop() }
        replayAllowed = allowed
        #else
        replayAllowed = false
        #endif
    }

    // Call only after confirmed logout, never for an anonymous/loading auth state.
    func accountSignedOut() {
        setReplayAllowed(false)
        client?.reset()
        // A fresh UUID separates logged-out events even when IDFV is available.
        client?.setDeviceId(deviceId: UUID().uuidString)
    }
    func flush() { client?.flush() }
    func deviceID() -> String? { client?.getDeviceId() }

    private func properties(days: Int) -> [String: Any] {
        ["report_type": "usage", "days": days, "platform": "iOS"]
    }
```

The setReplayAllowed method starts or stops the owned replay client. replayAllowed records the app's choice; it doesn't prove that a recording reached Amplitude. The choice lives in memory and starts off on a fresh launch. For a production app, connect the method to its existing consent system and apply its current choice before capture starts. Review [replay disclosure and consent guidance](https://amplitude.com/docs/session-replay/best-practices-for-managing-user-consent) for your application.

**Why.** Remote replay settings can override local settings. I disable them during this deterministic development test, then choose the production configuration after checking capture and masking. Reduce sampleRate from 1 to the intended production rate before rollout.

The sample continues sending its explicit analytics events when replay is off. Connect analytics collection to your app's separate analytics consent or opt-out policy where required. Stopping replay controls future capture; it doesn't remove content already collected.

The optional account callbacks preserve the analytics flow: set a canonical user ID and plan after confirmed sign-in; on confirmed logout, stop replay, reset identity and explicitly assign a fresh UUID device ID before tracking again. Reset alone can reuse IDFV with the pinned analytics SDK. Don't call logout from a generic anonymous or loading auth state.

## 4. Add the report and block synthetic private content

I keep event calls in explicit actions, outside View\.body. The blue report bar changes width when the date range changes, giving the recording a visual change to check even when text is masked.

**AmplitudeReports/ReportModel.swift**

AmplitudeReports/ReportModel.swift

```swift
import Combine
import Foundation

@MainActor
final class ReportModel: ObservableObject {
    @Published private(set) var days = 7
    @Published private(set) var opened = false
    @Published private(set) var status: String
    let analytics: ReportAnalytics

    init(analytics: ReportAnalytics? = nil) {
        let analytics = analytics ?? .shared
        self.analytics = analytics
        status = analytics.configured
            ? "Analytics configured; verify delivery separately"
            : "Analytics off: check the API key and region in the build configuration"
    }

    var report: String {
        opened ? "\(days)-day report: \(days * 12) sample actions" : "Open the sample report"
    }
    func openReport() {
        opened = true
        analytics.reportPreviewed(days: days)
    }
    func changeRange() {
        days = days == 7 ? 30 : 7
        opened = true
        analytics.reportRangeChanged(days: days)
    }
    func flushAndShowDevice() {
        analytics.flush()
        status = "Device ID: \(analytics.deviceID() ?? "not available yet"); flush requested"
    }
```

**AmplitudeReports/ContentView\.swift**

AmplitudeReports/ContentView\.swift

```swift
import SwiftUI
import AmplitudeSessionReplay

@MainActor
struct ContentView: View {
    @ObservedObject var model: ReportModel
    @ObservedObject private var analytics: ReportAnalytics
    @State private var note = "MASK_TEST_NOTE_42"

    init(model: ReportModel) {
        self.model = model
        self.analytics = model.analytics
    }

    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(alignment: .leading, spacing: 16) {
                    Text(model.status).font(.footnote).textSelection(.enabled)
                    Text(analytics.replayAllowed ? "Replay consent: allowed" : "Replay consent: off")
                        .accessibilityIdentifier("replay-consent")
                    Text("Allow a test recording of this demo report. Choose Stop replay to withdraw.")
                    Button("Allow replay") { analytics.setReplayAllowed(true) }
                        .disabled(!analytics.configured || analytics.replayAllowed)
                    Button("Stop replay") { analytics.setReplayAllowed(false) }
                        .disabled(!analytics.replayAllowed)
                    Divider()
                    Text(model.report)
                    RoundedRectangle(cornerRadius: 8)
                        .fill(Color.blue)
                        .frame(width: model.days == 7 ? 80 : 240, height: 40)
                        .accessibilityIdentifier("report-bar")
                    Button("Open report", action: model.openReport)
                    Button("Change date range", action: model.changeRange)
                    VStack(alignment: .leading) {
                        Text("Synthetic account: MASK_TEST_ACCOUNT_42")
                        TextField("Synthetic private note", text: $note)
                            .textFieldStyle(.roundedBorder)
                            .accessibilityIdentifier("private-note")
                    }
                    .amp_setBlocked(true)
                    Button("Flush and show device ID", action: model.flushAndShowDevice)
                }
                .padding()
            }
            .navigationTitle("Usage reports")
        }
```

I use amp\_setBlocked(true) on the synthetic account and note container to replace that area in replay. Import AmplitudeSessionReplay to use the SwiftUI modifier. Apply explicit blocking to every container that can show sensitive content. In inspected frames from this sample on an iOS 18.5 simulator with replay SDK 0.16.0, the blocked area was hidden, but other SwiftUI text remained readable with the conservative preset. Check your exact hierarchy in a processed recording before introducing real content; the preset and source modifier alone do not establish privacy.

The note stays in local view state. Neither report event sends its value or the account label as an event property. Masking on-screen content doesn't redact values you deliberately put into analytics event properties or logs.

**MCP.** Your agent can inspect the existing taxonomy through [Amplitude's MCP server](https://amplitude.com/mcp) before adding report events. Preserve existing names and properties when merging into an instrumented app.

## 5. Own the model at the app entry point

I keep one @main entry point. The app owns its report model with StateObject and shares it with ContentView. A background transition requests an analytics flush; this asynchronous request doesn't prove that uploads finish before iOS suspends the app.

**AmplitudeReports/AmplitudeReportsApp.swift**

AmplitudeReports/AmplitudeReportsApp.swift

```swift
import SwiftUI

@main
@MainActor
struct AmplitudeReportsApp: App {
    @StateObject private var model = ReportModel()
    @Environment(\.scenePhase) private var phase

    var body: some Scene {
        WindowGroup { ContentView(model: model) }
            .onChange(of: phase) { _, next in
                if next == .background { model.analytics.flush() }
            }
```

Add the Core and model files to the app target, select an iOS simulator and choose Product > Build, then run. In an existing app, merge the model and lifecycle behavior into its current scene structure.

Review the app's privacy disclosures and SDK privacy manifests before distribution. The SDK manifest doesn't describe every event property or recording choice your app adds.

## Verify it worked

Use only synthetic content and a development project. Note the test start time, then check this sequence:

1. **Before allowing replay.** On launch, Replay consent reads off. Open the report and change its range before allowing capture. Those actions still send the explicit analytics events; they must not appear as captured pre-consent screen activity in the recording.
2. **Known actions.** Tap Allow replay, wait a few seconds, open the report and change its range twice. Edit the synthetic note. Wait a few more seconds before tapping Stop replay, then change the range again. Record the order and timing so you can distinguish the captured interval from actions outside it.
3. **Analytics delivery.** Tap Flush and show device ID. Find that exact device ID in Amplitude's User Profiles and inspect its event stream. Confirm Report Previewed and Report Range Changed with report\_type usage, platform iOS and the expected days values. A requested flush alone doesn't prove delivery.
4. **Processed recording.** Open Session Replay, choose a date range that includes the test start, and use Add Filter to narrow by the exact test device ID. If filtering by report events, select only events inside the allowed recording interval; the same event names also occur before consent and after stopping. Replays are generally available about five minutes after a session begins. Open the recording and check the bar changes and action order against the allowed interval.
5. **Masking.** Inspect the processed recording around the synthetic account and note. Confirm MASK\_TEST\_ACCOUNT\_42, MASK\_TEST\_NOTE\_42 and your typed replacement aren't visible. Inspect every other area that could show sensitive content, including text displayed during editing. Explicitly block those containers and verify them in playback; do not assume the conservative preset hides every SwiftUI label. A successful upload or replay ID alone doesn't establish masking.
6. **Stopped capture.** Confirm the range change after Stop replay isn't captured. Analytics events may still arrive, and data collected before stopping can still upload. Terminate the app process and launch it again to confirm its in-memory replay choice starts off. Merely sending it to the background retains the current choice.

If the project uses the default session\_id, session matching works automatically. For a custom session definition, follow the [matching guide](https://amplitude.com/docs/session-replay/session-matching) and verify its identifiers; don't change organization-wide matching to make this sample work.

After checking the recording, set the intended production sample rate and apply your app's actual consent policy. Keep the development-only rate out of the production rollout.

## Troubleshooting

### Allow replay is disabled

The key or region didn't reach the app bundle. Check the app target's base configuration, plist substitutions and US/EU value, then rebuild.

### The compiler cannot find the SDK or privacy modifier

Link the AmplitudeUnified product to the app target, resolve the pinned package and keep import AmplitudeSessionReplay in ContentView. Adding a package URL without selecting its product doesn't link the SDK.

### The choice says allowed but no replay appears

That label reflects application state. Check the project and region, capture allowance, connectivity and SDK errors. Keep the app open during the test and allow processing time. Verify the recorded interval through the [capture checklist](https://amplitude.com/docs/session-replay/verify-capture).

### The recording includes content you intended to hide

Stop test capture and keep real data out of the screen. Check the effective mask level, remote override setting and modifier placement on the actual SwiftUI hierarchy, then repeat the recording check. Don't treat a successful build as a masking result.

### Events continue after Stop replay

Replay controls and analytics collection are separate in this sample. The two report buttons still call track. Apply your app's analytics consent or opt-out policy separately; don't interpret those events as proof that replay kept capturing.

## Frequently asked questions

### Does the consent label prove a recording is active?

No. It records the application's choice. Check the processed recording, its captured interval and masking before treating the integration as verified.

### Does stopping replay delete earlier data?

Stopping capture doesn't erase content already collected. Follow your organization's replay retention and deletion process for existing recordings.

### Can I add this to the iOS analytics sample?

Yes. Merge the replay configuration, consent controls and privacy modifier into its shared client and UI. Keep its existing event handlers and authentication integration. Avoid creating another client or replay plugin.

## Full code reference

Nine complete custom files. Keep the generated Xcode project and assets. Create the ignored Local.xcconfig from the blank example; don't commit a real key.

**Complete sample**

Complete sample

```text
// Config/Amplitude.xcconfig
#include? "Local.xcconfig"
GENERATE_INFOPLIST_FILE = NO
INFOPLIST_FILE = AmplitudeReports/Info.plist
INFOPLIST_EXPAND_BUILD_SETTINGS = YES

// Config/Local.xcconfig.example
AMPLITUDE_API_KEY =
AMPLITUDE_SERVER_ZONE = US

// .gitignore
.build/
.swiftpm/
Config/Local.xcconfig
xcuserdata/
AmplitudeReports.xcodeproj/

// AmplitudeReports/Info.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>CFBundleDevelopmentRegion</key><string>$(DEVELOPMENT_LANGUAGE)</string>
    <key>CFBundleExecutable</key><string>$(EXECUTABLE_NAME)</string>
    <key>CFBundleIdentifier</key><string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
    <key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
    <key>CFBundleName</key><string>$(PRODUCT_NAME)</string>
    <key>CFBundlePackageType</key><string>APPL</string>
    <key>CFBundleShortVersionString</key><string>$(MARKETING_VERSION)</string>
    <key>CFBundleVersion</key><string>$(CURRENT_PROJECT_VERSION)</string>
    <key>LSRequiresIPhoneOS</key><true/>
    <key>UIApplicationSceneManifest</key>
    <dict><key>UIApplicationSupportsMultipleScenes</key><false/></dict>
    <key>UILaunchScreen</key><dict/>
    <key>UISupportedInterfaceOrientations</key>
    <array><string>UIInterfaceOrientationPortrait</string></array>
    <key>AMPLITUDE_API_KEY</key><string>$(AMPLITUDE_API_KEY)</string>
    <key>AMPLITUDE_SERVER_ZONE</key><string>$(AMPLITUDE_SERVER_ZONE)</string>
</dict>
</plist>

// AmplitudeReports/Core/AnalyticsSettings.swift
import Foundation

struct AnalyticsSettings {
    enum Zone: String { case US, EU }
    let apiKey: String
    let zone: Zone

    init?(info: [String: Any]) {
        let key = (info["AMPLITUDE_API_KEY"] as? String ?? "")
            .trimmingCharacters(in: .whitespacesAndNewlines)
        let region = (info["AMPLITUDE_SERVER_ZONE"] as? String ?? "US")
            .trimmingCharacters(in: .whitespacesAndNewlines)
        guard !key.isEmpty, !key.contains("$("), let zone = Zone(rawValue: region) else {
            return nil
        }
        self.apiKey = key
        self.zone = zone
    }

// AmplitudeReports/Core/ReportAnalytics.swift
import AmplitudeUnified
import Foundation
import Combine

@MainActor
final class ReportAnalytics: ObservableObject {
    static let shared = ReportAnalytics(settings: AnalyticsSettings(info: Bundle.main.infoDictionary ?? [:]))
    private let client: Amplitude?
    @Published private(set) var replayAllowed = false
    var configured: Bool { client != nil }

    convenience init(settings: AnalyticsSettings?) {
        guard let settings else {
            self.init(client: nil)
            return
        }
        let analytics = AnalyticsConfig(autocapture: [], enableAutoCaptureRemoteConfig: false)
        #if os(iOS)
        let replay = SessionReplayPlugin.Config(
            sampleRate: 1,
            maskLevel: .conservative,
            enableRemoteConfig: false,
            autoStart: false
        )
        let client = Amplitude(
            apiKey: settings.apiKey,
            serverZone: settings.zone == .EU ? .EU : .US,
            analyticsConfig: analytics,
            sessionReplayConfig: replay
        )
        #else
        // The same event/auth helper can be checked on macOS; replay is iOS-only.
        let client = Amplitude(
            apiKey: settings.apiKey,
            serverZone: settings.zone == .EU ? .EU : .US,
            analyticsConfig: analytics
        )
        #endif
        self.init(client: client)
    }

    init(client: Amplitude?) { self.client = client }

    func reportPreviewed(days: Int) {
        client?.track(eventType: "Report Previewed", eventProperties: properties(days: days))
    }

    func reportRangeChanged(days: Int) {
        client?.track(eventType: "Report Range Changed", eventProperties: properties(days: days))
    }

    // Call after the existing authentication system confirms the account.
    func accountSignedIn(userID: String, plan: String) {
        precondition(userID.count >= 5, "Use your canonical user ID, at least five characters")
        client?.setUserId(userId: userID)
        let identify = Identify()
        identify.set(property: "plan", value: plan)
        client?.identify(identify: identify)
    }

    // Connect this to the app's replay-specific consent decision.
    func setReplayAllowed(_ allowed: Bool) {
        #if os(iOS)
        guard let replay = client?.sessionReplay else {
            replayAllowed = false
            return
        }
        if allowed { replay.start() } else { replay.stop() }
        replayAllowed = allowed
        #else
        replayAllowed = false
        #endif
    }

    // Call only after confirmed logout, never for an anonymous/loading auth state.
    func accountSignedOut() {
        setReplayAllowed(false)
        client?.reset()
        // A fresh UUID separates logged-out events even when IDFV is available.
        client?.setDeviceId(deviceId: UUID().uuidString)
    }
    func flush() { client?.flush() }
    func deviceID() -> String? { client?.getDeviceId() }

    private func properties(days: Int) -> [String: Any] {
        ["report_type": "usage", "days": days, "platform": "iOS"]
    }

// AmplitudeReports/ReportModel.swift
import Combine
import Foundation

@MainActor
final class ReportModel: ObservableObject {
    @Published private(set) var days = 7
    @Published private(set) var opened = false
    @Published private(set) var status: String
    let analytics: ReportAnalytics

    init(analytics: ReportAnalytics? = nil) {
        let analytics = analytics ?? .shared
        self.analytics = analytics
        status = analytics.configured
            ? "Analytics configured; verify delivery separately"
            : "Analytics off: check the API key and region in the build configuration"
    }

    var report: String {
        opened ? "\(days)-day report: \(days * 12) sample actions" : "Open the sample report"
    }
    func openReport() {
        opened = true
        analytics.reportPreviewed(days: days)
    }
    func changeRange() {
        days = days == 7 ? 30 : 7
        opened = true
        analytics.reportRangeChanged(days: days)
    }
    func flushAndShowDevice() {
        analytics.flush()
        status = "Device ID: \(analytics.deviceID() ?? "not available yet"); flush requested"
    }

// AmplitudeReports/ContentView.swift
import SwiftUI
import AmplitudeSessionReplay

@MainActor
struct ContentView: View {
    @ObservedObject var model: ReportModel
    @ObservedObject private var analytics: ReportAnalytics
    @State private var note = "MASK_TEST_NOTE_42"

    init(model: ReportModel) {
        self.model = model
        self.analytics = model.analytics
    }

    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(alignment: .leading, spacing: 16) {
                    Text(model.status).font(.footnote).textSelection(.enabled)
                    Text(analytics.replayAllowed ? "Replay consent: allowed" : "Replay consent: off")
                        .accessibilityIdentifier("replay-consent")
                    Text("Allow a test recording of this demo report. Choose Stop replay to withdraw.")
                    Button("Allow replay") { analytics.setReplayAllowed(true) }
                        .disabled(!analytics.configured || analytics.replayAllowed)
                    Button("Stop replay") { analytics.setReplayAllowed(false) }
                        .disabled(!analytics.replayAllowed)
                    Divider()
                    Text(model.report)
                    RoundedRectangle(cornerRadius: 8)
                        .fill(Color.blue)
                        .frame(width: model.days == 7 ? 80 : 240, height: 40)
                        .accessibilityIdentifier("report-bar")
                    Button("Open report", action: model.openReport)
                    Button("Change date range", action: model.changeRange)
                    VStack(alignment: .leading) {
                        Text("Synthetic account: MASK_TEST_ACCOUNT_42")
                        TextField("Synthetic private note", text: $note)
                            .textFieldStyle(.roundedBorder)
                            .accessibilityIdentifier("private-note")
                    }
                    .amp_setBlocked(true)
                    Button("Flush and show device ID", action: model.flushAndShowDevice)
                }
                .padding()
            }
            .navigationTitle("Usage reports")
        }

// AmplitudeReports/AmplitudeReportsApp.swift
import SwiftUI

@main
@MainActor
struct AmplitudeReportsApp: App {
    @StateObject private var model = ReportModel()
    @Environment(\.scenePhase) private var phase

    var body: some Scene {
        WindowGroup { ContentView(model: model) }
            .onChange(of: phase) { _, next in
                if next == .background { model.analytics.flush() }
            }
```

## Hand it to your agent

**Agent prompt**

Agent prompt

```text
Follow https://amplitude.com/build/ios-session-replay. Build the complete iOS 17+ SwiftUI report sample, or merge into the existing app without replacing authentication, navigation, privacy controls or instrumentation. Use AmplitudeUnified-Swift exactly 0.0.4 and one shared client with its owned replay integration. Read the project ingestion key and US/EU region from ignored local xcconfig/plist substitutions. For the development test, use replay sampleRate 1, maskLevel conservative, enableRemoteConfig false and autoStart false. Start only after the replay consent choice; stop when withdrawn or at confirmed logout. Apply existing analytics consent separately. Keep report events in button actions. Import AmplitudeSessionReplay and block the synthetic account/note container with amp_setBlocked(true). Explicitly block every sensitive container; do not rely on the conservative preset to hide every SwiftUI label. Never send sensitive contents in event properties or logs. Preserve canonical account IDs and plan after confirmed sign-in. Stop replay, reset and explicitly set a fresh UUID device ID after confirmed logout, before further tracking. Don't reset for anonymous/loading auth. Verify compilation, initial off-state, start/stop behavior, processed analytics events and the actual recording separately. Inspect masking and captured intervals in playback. No source-only privacy claims. Reduce sampleRate before production and connect the real consent policy.
```

## What this unlocks

[Product analytics](https://amplitude.com/amplitude-analytics) shows which report ranges people use. [Session replay](https://amplitude.com/session-replay) adds visual context to the allowed recording interval. After separate configuration, [feature flags](https://amplitude.com/feature-management) can vary the report layout and [guides and surveys](https://amplitude.com/guides-and-surveys) can collect feedback on it.

## Next steps

[MobileTrack iOS product analyticsReview the shared report events and account callbacks.](https://amplitude.com/build/ios-product-analytics)[MobileAdd Android session replayCompare the native Kotlin integration.](https://amplitude.com/build/android-session-replay)[PlatformVerify replay captureCheck recording, matching and masking before rollout.](https://amplitude.com/docs/session-replay/verify-capture)

Reference docs

[Install a mobile SDK →](https://amplitude.com/docs/get-started/install-mobile-sdk#ios)[Unified SDK for Swift →](https://amplitude.com/docs/sdks/analytics/ios/unified-sdk)[iOS replay configuration and privacy →](https://amplitude.com/docs/sdks/session-replay/session-replay-ios-plugin)[Manage replay consent →](https://amplitude.com/docs/session-replay/best-practices-for-managing-user-consent)[Find replays →](https://amplitude.com/docs/session-replay/find-replays)[Verify capture →](https://amplitude.com/docs/session-replay/verify-capture)

For agents

[View raw .md](/build/ios-session-replay.md)[llms.txt](/llms.txt)

## Inspect a test session

Start with synthetic content in your development project, then verify the processed recording before rollout.

[Create a free account](https://app.amplitude.com/signup)[Browse all Build tutorials](https://amplitude.com/build)
