Skip to main content

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)
Keep in mind

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​

PropertyTypeDefaultDescription
autoStartEnabledBool—Start and stop recording together with the app lifecycle
framePerSecondInt—Capture frame rate, clamped to 1...12
sessionDurationInMsTimeInterval—Maximum duration of one replay session, in milliseconds
metadataURLURL—Endpoint the replay archives and metadata are uploaded to
itemsCountInUploadedArchiveInt30Screenshots packed into one uploaded zip, clamped to 1...50
qualityPAPulseSessionReplayImageQuality.lowJPEG compression quality: .low (0.1), .medium (0.3), .high (0.5)
ignoreDebugModeBoolfalseAllow Session Replay to run while Debug Mode is enabled
shouldRemoveDuplicationBooltrueDrop a frame when it is identical to the previous one
shouldDrawRedactClassNameBooltrueDraw the class name of a masked view on top of its mask
allowFastRenderingBoolfalseUse the faster, less accurate snapshot path
shouldPauseOnScrollingBooltrueSuspend 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)?nilCalled on the main actor right before each capture
onAfterScreenshotCapture(@MainActor (JSON) -> Void)?nilCalled 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​

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:

  1. If an ancestor already forced redaction (because its class is in maskedContainerViewClasses), the view is redacted — this wins over everything below.
  2. Otherwise, an exemption wins: the view was passed to SessionReplayMaskHelper.unmaskView(_:), or its class is in whiteListViewClasses / whiteContainerListViewClasses.
  3. Otherwise the view is redacted if it was passed to SessionReplayMaskHelper.maskView(_:) / maskSwiftUI(_:), or its class is in maskedViewClasses / 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)
Keep in mind

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 itemsCountInUploadedArchive items
  • 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 rateImpact
1 FPSMinimal battery and CPU cost, good for user-journey analysis
2-3 FPSBalanced detail and cost, suitable for most use cases

Storage​

Approximate on-device storage per minute at 1 FPS:

QualityJPEG compressionStorage per minute
.low0.1~0.6-1.2 MB
.medium0.3~0.9-1.8 MB
.high0.5~1.4-2.6 MB