Session Replay
Session Replay captures and records app screenshots for debugging, user-experience analysis and issue identification. It ships in the PAPulse product, so no extra dependency is needed, but it is disabled by default and must be configured explicitly before the analytics session starts.
Configuration
Session Replay is configured with a single PAPulseSessionReplayOptions object. Five values are required by the initializer; everything else is a mutable property with a default.
let sessionReplayOptions = PAPulseSessionReplayOptions(
autoStartEnabled: true, // start/stop with the app lifecycle
framePerSecond: 2, // 1...12, recommended 1-3
sessionDurationInMs: 5 * 60 * 1000, // 5 minutes, in milliseconds
metadataURL: URL(string: "https://your-upload-endpoint.example.com")!,
itemsCountInUploadedArchive: 30 // 1...50, screenshots per uploaded zip
)
sessionReplayOptions.quality = .low
sessionReplayOptions.shouldPauseOnScrolling = true
PAAnalytics.configSet(sessionReplayOptions: sessionReplayOptions)
framePerSecond and itemsCountInUploadedArchive are clamped by the initializer to their supported ranges, and a negative sessionDurationInMs is clamped to 0. Out-of-range values are corrected silently rather than rejected.
Options reference
| Property | Type | Default | Description |
|---|---|---|---|
autoStartEnabled | Bool | — | Start and stop recording together with the app lifecycle |
framePerSecond | Int | — | Capture frame rate, clamped to 1...12 |
sessionDurationInMs | TimeInterval | — | Maximum duration of one replay session, in milliseconds |
metadataURL | URL | — | Endpoint the replay archives and metadata are uploaded to |
itemsCountInUploadedArchive | Int | 30 | Screenshots packed into one uploaded zip, clamped to 1...50 |
quality | PAPulseSessionReplayImageQuality | .low | JPEG compression quality: .low (0.1), .medium (0.3), .high (0.5) |
ignoreDebugMode | Bool | false | Allow Session Replay to run while Debug Mode is enabled |
shouldRemoveDuplication | Bool | true | Drop a frame when it is identical to the previous one |
shouldDrawRedactClassName | Bool | true | Draw the class name of a masked view on top of its mask |
allowFastRendering | Bool | false | Use the faster, less accurate snapshot path |
shouldPauseOnScrolling | Bool | true | Suspend capture while the user is scrolling |
maskedViewClasses | [AnyClass] | [] | View classes masked with a solid rectangle |
maskedContainerViewClasses | [AnyClass] | [] | Container classes whose whole content is masked |
whiteListViewClasses | [AnyClass] | [] | View classes always captured without masking |
whiteContainerListViewClasses | [AnyClass] | [] | Container classes whose content is always captured |
onBeforeScreenshotCapture | (@MainActor () -> JSON)? | nil | Called on the main actor right before each capture |
onAfterScreenshotCapture | (@MainActor (JSON) -> Void)? | nil | Called on the main actor after each capture |
The configured quality also drives how often archives are uploaded: .low every 5 s, .medium every 3 s, .high every 2 s.
Reconfiguring with options equal to the active ones is a no-op. Reconfiguring with different options tears the current recording down first and starts over.
Recording control
Automatic mode (recommended)
With autoStartEnabled = true the SDK follows the app lifecycle:
- Starts when the app enters the foreground
- Stops when the app enters the background, or when the session duration expires
- Resumes when the app returns to the foreground, opening a new session if the duration had expired
Manual mode
PAAnalytics.startSessionReplayRecording()
PAAnalytics.stopSessionReplayRecording()
Both work regardless of autoStartEnabled, but mixing them with autoStartEnabled = true means the SDK may also start and stop sessions on lifecycle events. Set autoStartEnabled = false for exclusive manual control.
To shut the feature down completely — stop recording, discard the pending uploads and release the configured options — call:
PAAnalytics.clearSessionReplayRecording()
After this, Session Replay stays off until configSet(sessionReplayOptions:) is called again.
Privacy and masking
Masking is resolved per view while the SDK walks the layer tree. For each view:
- If an ancestor already forced redaction (because its class is in
maskedContainerViewClasses), the view is redacted — this wins over everything below. - Otherwise, an exemption wins: the view was passed to
SessionReplayMaskHelper.unmaskView(_:), or its class is inwhiteListViewClasses/whiteContainerListViewClasses. - Otherwise the view is redacted if it was passed to
SessionReplayMaskHelper.maskView(_:)/maskSwiftUI(_:), or its class is inmaskedViewClasses/maskedContainerViewClasses.
// Views masked with a solid rectangle
sessionReplayOptions.maskedViewClasses = [SensitiveView.self, PaymentInfoView.self]
// Container views whose content should be masked
sessionReplayOptions.maskedContainerViewClasses = [SecureContainerView.self]
// Views always captured without masking
sessionReplayOptions.whiteListViewClasses = [AppLogoView.self, BrandNavigationBar.self]
// Container views whose content should always be captured
sessionReplayOptions.whiteContainerListViewClasses = [SafeContainerView.self]
Masking individual views
When masking by class is too coarse, SessionReplayMaskHelper marks a specific view instance. The mark is stored on the view itself and is permanent for that instance.
// Redact this view
SessionReplayMaskHelper.maskView(cardNumberLabel)
// Exempt this view from masking, even if its class is in a masked list
SessionReplayMaskHelper.unmaskView(promoBannerView)
// Treat this view as opaque and draw nothing beneath it
SessionReplayMaskHelper.clipOutView(backgroundImageView)
// Redact a SwiftUI-hosted view, using the dedicated SwiftUI redaction pass
SessionReplayMaskHelper.maskSwiftUI(hostingView)
Use maskSwiftUI(_:) for SwiftUI hierarchies, where the backing view classes are private implementation details and cannot be listed in maskedViewClasses. Its regions are drawn by a separate redaction pass, but the view is still subject to the exemption rules above — marking the same view with unmaskView(_:) suppresses it.
Capture hooks
onBeforeScreenshotCapture and onAfterScreenshotCapture let the host app observe the capture cycle, for example to record which screen a frame belongs to. Both run on the main actor. The dictionary returned by the "before" hook is handed back to the "after" hook; if the "before" hook is not set, the "after" hook is not called.
sessionReplayOptions.onBeforeScreenshotCapture = {
["screen": Router.current.screenName]
}
sessionReplayOptions.onAfterScreenshotCapture = { context in
Log.debug("captured frame for \(context["screen"] ?? "-")")
}
Debug Mode interaction
Session Replay and Debug Mode are mutually exclusive by default:
- Calling
configSet(sessionReplayOptions:)while Debug Mode is enabled is rejected, and the SDK logs "Session Replay feature is disabled in Debug Mode". - Enabling Debug Mode while Session Replay is running terminates the recording.
Set ignoreDebugMode = true on the options to opt out of that behavior and keep recording in Debug Mode — useful for QA builds.
Session management
File storage
- Location: local device storage, in a dedicated Session Replay directory
- Organization: files are grouped by session ID
- Format: JPEG images with the configured compression
- Cleanup: files are removed after a successful upload
Upload process
- Batching: images are packed into zip archives of
itemsCountInUploadedArchiveitems - Background: uploads run on background queues and never block the UI
- Retry: failed uploads are retried automatically
- Network-aware: upload timing adapts to network conditions
Performance considerations
Frame rate
| Frame rate | Impact |
|---|---|
| 1 FPS | Minimal battery and CPU cost, good for user-journey analysis |
| 2-3 FPS | Balanced detail and cost, suitable for most use cases |
Storage
Approximate on-device storage per minute at 1 FPS:
| Quality | JPEG compression | Storage per minute |
|---|---|---|
.low | 0.1 | ~0.6-1.2 MB |
.medium | 0.3 | ~0.9-1.8 MB |
.high | 0.5 | ~1.4-2.6 MB |