Session Replay records what a user saw and did in your iOS application, so you can watch a bug happen instead of reconstructing it from a stack trace. Replays are linked to the RUM session they belong to, so you can move from an error straight to the recording.

Session Replay ships as its own product, AtatusSessionReplay, and runs on top of RUM.

Note:

Session Replay is available on iOS and iPadOS only. The module is empty on tvOS, so SessionReplay.enable does not exist there.

How it records

The SDK does not record the screen as video. It walks the view hierarchy and captures the shape of each view: its frame, background, border, corner radius, opacity, and the type-specific details such as label text, switch position, or image content. Those become wireframes that the player reassembles into a replay.

Two consequences follow from that design:

  • Masking happens on the device, before anything is written to disk. Text you mask never leaves the device at all.
  • Snapshots are taken when the screen changes, at most ten times a second, and not at all while the screen is static.

Prerequisites

  • RUM is set up and tracking views. See iOS Monitoring Setup.
  • iOS 15.0 or later.
  • The AtatusSessionReplay product is added to your target.
Warning:

Session Replay captures nothing without RUM. Every record is attached to the active RUM view, so an application that enables RUM without a views predicate, and without calling startView itself, produces empty replays.

Setup

1. Add the product

With Swift Package Manager, add the AtatusSessionReplay product to your target. With CocoaPods, declare the pod:

copy
icon/buttons/copy
pod 'AtatusSessionReplay', '~> 1.0'

2. Enable Session Replay

Enable it after RUM, in the same place you initialize the SDK:

copy
icon/buttons/copy
import AtatusCore
import AtatusRUM
import AtatusSessionReplay

Atatus.initialize(
    with: Atatus.Configuration(
        licenseKey: "<LICENSE_KEY>",
        env: "<ENV_NAME>",
        service: "<APP_NAME>"
    ),
    trackingConsent: .granted
)

RUM.enable(
    with: RUM.Configuration(
        uiKitViewsPredicate: DefaultUIKitRUMViewsPredicate(),
        uiKitActionsPredicate: DefaultUIKitRUMActionsPredicate()
    )
)

SessionReplay.enable(
    with: SessionReplay.Configuration(replaySampleRate: 20)
)

The defaults mask all text, all images, and all touches, so a replay recorded with no privacy configuration shows layout and interaction without revealing content. Relax that deliberately, and per view rather than globally.

Note:

SessionReplay.enable never throws. When something is wrong, such as the SDK not being initialized yet or the product being enabled twice, it prints the reason to the Xcode console and carries on. Check the console first when no replay appears.

Configuration

Option Default Purpose
replaySampleRate 100 Percentage of RUM-sampled sessions that record a replay
textAndInputPrivacyLevel .maskAll How much text and input content is masked
imagePrivacyLevel .maskAll Which images are recorded
touchPrivacyLevel .hide Whether touch indicators are recorded
startRecordingImmediately true Starts recording as soon as the product is enabled
customEndpoint nil Sends replay data to a different intake URL
featureFlags [.swiftui: false] Experimental toggles, including SwiftUI recording

Sampling

The replay rate applies to sessions RUM has already sampled, and the two multiply. With a RUM sessionSampleRate of 80 and a replaySampleRate of 20, one in five recorded sessions has a replay, which is 16 percent of all sessions:

copy
icon/buttons/copy
RUM.enable(with: RUM.Configuration(sessionSampleRate: 80))
SessionReplay.enable(with: SessionReplay.Configuration(replaySampleRate: 20))

The decision is made once per session and derived from the session ID, so sessions with a replay are always a subset of the sessions RUM kept. A session either has a complete replay or none.

Note:

replaySampleRate: 0 does not register the product at all, so SessionReplay.startRecording() has nothing to start. To keep recording off until the user asks for it, use startRecordingImmediately: false with a rate above 0.

Start and stop recording yourself

To hold recording back until a point you choose, for example until the user opts in, enable the product without starting it:

copy
icon/buttons/copy
SessionReplay.enable(
    with: SessionReplay.Configuration(
        replaySampleRate: 100,
        startRecordingImmediately: false
    )
)

// Later, when the user opts in
SessionReplay.startRecording()

// And to stop again
SessionReplay.stopRecording()

Stopping is not the same as withdrawing consent. It stops new records, and anything already recorded still uploads. To delete collected data, set consent to .notGranted or call Atatus.clearAllData().

Mask sensitive content

Text and input

textAndInputPrivacyLevel divides text into four kinds and treats each differently:

Level Sensitive fields Inputs and options Static text Placeholders
.maskAll (default) *** *** xxxx xxx ***
.maskAllInputs *** *** Visible Visible
.maskSensitiveInputs *** Visible Visible Visible

Static text is masked character by character, so "Jane Doe" becomes "xxxx xxx". Word lengths and spacing survive, which keeps the layout faithful, so treat the length of a string as something that does leave the device.

Sensitive fields are always ***, at every level and with no way to reveal them. A field counts as sensitive when it is a secure text entry field, or when its textContentType is a password, one-time code, email address, telephone number, credit card number, postal address, or postal code.

At .maskAll and .maskAllInputs, the state of a control is masked too: a switch loses its on and off position, a slider loses its value, a stepper and a date picker lose their selection, and a segmented control loses the selected segment.

Images

imagePrivacyLevel decides which images are recorded:

Level Behavior
.maskAll (default) No image content is recorded. Every image becomes a placeholder
.maskNonBundledOnly Records images that ship with your application, such as icons and SF Symbols, and masks everything loaded at runtime
.maskNone Records every image

.maskNonBundledOnly is the useful middle ground: your interface keeps its icons while user content, such as a profile photo or a document preview, stays masked.

Note:

In SwiftUI the SDK cannot tell whether an image came from your bundle, so .maskNonBundledOnly falls back to a size test: a raster image is recorded only when it is 100 points or smaller on both sides, and vector images are always recorded. Mask SwiftUI images you care about explicitly rather than relying on that heuristic.

Touches

touchPrivacyLevel is .hide by default, which records no touch coordinates at all. Set .show to draw touch indicators in the replay:

copy
icon/buttons/copy
SessionReplay.enable(
    with: SessionReplay.Configuration(
        replaySampleRate: 100,
        touchPrivacyLevel: .show
    )
)

Override privacy for one view

Global levels are the floor, not the ceiling. Any UIKit view can carry its own levels, which its subviews inherit:

copy
icon/buttons/copy
import AtatusSessionReplay

// Reveal a label that is not sensitive, although the global level masks all text
priceLabel.atatus.sessionReplayPrivacyOverrides.textAndInputPrivacy = .maskSensitiveInputs

// Record one specific image
logoImageView.atatus.sessionReplayPrivacyOverrides.imagePrivacy = .maskNone

// Record touches on this subtree only
checkoutView.atatus.sessionReplayPrivacyOverrides.touchPrivacy = .show

// Replace a view and everything inside it with an opaque block
creditCardFormView.atatus.sessionReplayPrivacyOverrides.hide = true

A child's value wins over its parent's, except hide, which cannot be undone by a child. A hidden view is recorded as a solid placeholder, so the replay keeps the space it occupied without any of its content.

Note:

An override changes the text of a switch, slider, stepper, segmented control, or date picker, but not its state. Those controls read the global textAndInputPrivacyLevel for their value, so set the global level or hide the view if the value itself is sensitive.

SwiftUI

SwiftUI recording is experimental and off by default. With the flag off, a SwiftUI screen is recorded as a single placeholder, which makes replays of a SwiftUI application close to useless. Turn it on to record the view tree:

copy
icon/buttons/copy
SessionReplay.enable(
    with: SessionReplay.Configuration(
        replaySampleRate: 20,
        featureFlags: [.swiftui: true]
    )
)
Warning:

SwiftUI recording reads SwiftUI's internal rendering structures, which change between iOS releases. Fidelity can vary by OS version, and a new major iOS release can reduce it until the SDK is updated. Test your replays after each iOS upgrade, and treat this flag as experimental.

To mask part of a SwiftUI hierarchy, wrap it in SessionReplayPrivacyView, which needs iOS 16 or later:

copy
icon/buttons/copy
import SwiftUI
import AtatusSessionReplay

struct ProfileView: View {
    let user: User

    var body: some View {
        VStack(spacing: 8) {
            Text("User Profile")

            SessionReplayPrivacyView(
                textAndInputPrivacy: .maskAll,
                imagePrivacy: .maskAll
            ) {
                HStack {
                    AsyncImage(url: user.imageURL)
                        .frame(width: 50, height: 50)
                    Text(user.name)
                }
            }
        }
    }
}

There is no view modifier equivalent, so the wrapper is the only way to scope privacy in SwiftUI. It is inert when Session Replay is not enabled, so it is safe to leave in place.

Web views and system screens

A WKWebView is recorded as a reserved area that the web content fills in from the page side. Install the browser agent in the page and enable WebView tracking natively:

copy
icon/buttons/copy
import AtatusWebViewTracking

WebViewTracking.enable(webView: webView, hosts: ["example.com"])

Without an instrumented page, the web view appears as an empty area in the replay. See WebView Tracking.

Some screens are never recorded because they belong to the system rather than your application. A Safari view controller appears as a placeholder labelled Safari, and the share sheet as one labelled Activity. Alerts are recorded normally.

Offline behavior

Records are compressed and written to disk, then uploaded when the network is reachable and the device is not low on battery or in Low Power Mode. Replay data is held for up to five hours, which is shorter than the retention for other events, so a device offline for longer than that loses the oldest part of the recording. Everything else uploads when connectivity returns.

Objective-C

Session Replay is available to Objective-C with AT-prefixed types:

copy
icon/buttons/copy
@import AtatusSessionReplay;

ATSessionReplayConfiguration *configuration =
    [[ATSessionReplayConfiguration alloc] initWithReplaySampleRate:20
                                          textAndInputPrivacyLevel:ATTextAndInputPrivacyLevelMaskAll
                                                 imagePrivacyLevel:ATImagePrivacyLevelMaskAll
                                                 touchPrivacyLevel:ATTouchPrivacyLevelHide];
[ATSessionReplay enableWith:configuration];

myLabel.atSessionReplayPrivacyOverrides.textAndInputPrivacy =
    ATTextAndInputPrivacyLevelOverrideMaskAll;
cardFormView.atSessionReplayPrivacyOverrides.hide = @YES;

startRecordingImmediately and customEndpoint are properties rather than initializer parameters in Objective-C, and SessionReplayPrivacyView has no Objective-C equivalent because it is SwiftUI only.

Next steps