Technical DocMobile SDK Session Replay

Mobile SDK Session Replay

Introduction

Session Replay is a feature available as an add-on to CS Apps Analytics. For more information, please reach out to your Contentsquare contact.

Pre-requisites

Updating to latest SDK version

In order to enable Session Replay in your app and get the most stable version, it is required to upgrade the SDK to its latest version.

If you are in the process of implementing the SDK for the 1st time (or choose to take this update as an opportunity to review your Privacy related implementation), make sure to follow the Android Privacy or iOS Privacy sections.

Testing & Debugging

Enable Session Replay on your device

Since not all sessions are recorded (depending on the percentage set in Contentsquare back-office), we have implemented an option to force replay recording on your device for testing and debugging purpose. This option can be enabled from the in-app features settings:

  1. Enable in-app features (See How To: for iOS & for Android
  2. Open in-app features settings with a long press on the snapshot button
  3. In the "Session replay settings" section, enable session Replay
  4. Kill the app
  5. Start app, a new session is starting with Session Replay enabled

[tab] Android

How do I know if Session Replay is enabled?

There are 2 places where you can check if Session Replay is enabled:

In the logs: The log I/CSLIB|SessionReplay: Starting Session Replay. will confirm that Session Replay is enabled.

In in-app features settings: Below the "Enable "will start at next app start" (see below Access the replay), you will either see:

  • No replay link available which means Session Replay is not running for the current session
  • Get Replay link which means Session Replay is running for the current session

[tab] iOS

How do I know if Session Replay is enabled?

There are 2 places where you can check if Session Replay is enabled:

In the logs: The log Session Recording is starting will confirm that Session Replay is enabled.

In in-app features settings: Below the "Enable "will start at next app start" (see below Access the replay), you will either see:

  • No replay link available which means Session Replay is not running for the current session
  • Get Replay link which means Session Replay is running for the current session

Access the Replay

[tab] Android

Since 4.6.0, the Session can be accessed by tapping on Get replay link button from the in-app features settings:

The replay will be available within 5 minutes. Only the "ended screen views" are processed (we know a screenview is ended when we start receiving data for the next screenview). This means that you will be able to replay your session up to the previous screenview if the session is still running.

[tab] iOS

The session can be accessed by tapping on Get replay link button from the in-app features settings:

The replay will be available within 5 minutes. Only the "ended screen views" are processed (we know a screenview is ended when we start receiving data for the next screenview). This means that you will be able to replay your session up to the previous screenview if the session is still running.

Provide custom fonts (iOS only)

On iOS, since text is collected as is for UIKit elements, it is required to get the custom fonts you use in your app so we can use them to render text properly.

Please provide the fonts requested by your Implementation Manager.

Personal Data Masking

The Session Replay feature records every interaction of your users with your app. In order to respect the user’s right to privacy, the Contentsquare SDK :

  • masks everything by default
  • allows you to control which part of the user interface is recorded via our masking & un-masking APIs.

Masking mechanisms

Masking mechanisms are different on iOS and Android:

[tab] Android

Every single UI element is converted into a highly pixelized image to reach a very low resolution. Text and images will appear very blurry so that the content cannot be identified.

Original VS Replay fully masked

[tab] iOS

For UIKit:

  • Text is replaced with character chain lala
  • Images, maps & webview are replaced by placeholders (grey background)
  • UITextField, UILabel, UIButton and UIPickerView are replaced by black rectangles.

For SwiftUI:

  • Images, maps & webview are replaced by placeholders (grey background)
  • other elements including but not limited to texts, text fields, picker are replaced by black squares
Original VS Replay fully masked

Default masking

[tab] Android

All Android View elements and their subclasses are fully masked by default. The SDK provides a simple API to change the default masking state:

/**
* Set the default masking of all the views.
* @param masked true mask all views by default.
*               false unmask all views by default.
*               The default value is true.
*/
setDefaultMasking(boolean masked)

If you change the default masking state to false, TextView & WebView will stay masked by default.

[tab] iOS

By default, only the following system components and their subclasses are masked:

  • UIKit: UITextField, UILabel, UITextView, UIImageView, UIButton and UIPickerView
  • SwiftUI: Text and Image

The SDK provides a simple API to change the default masking state:

/// Change the masking state for all types of views.
/// - Parameter mask: true restores the default masking state.
                      false unmasks every types of views.
static func setDefaultMasking(_ mask: Bool)

If you change the default masking state to false, UIKit.UITextField, UIKit.UITextView, SwiftUI.TextField, SwiftUI.TextEditor will stay masked by default.

Handling components not masked by default and containing Personal Data

If the app has custom components (or system components not listed above) containing Personal Data, it is critical to mask them using the APIs described in the next section.

Masking/Un-masking by type

[tab] Android

Use mask(Class type) and unmask(Class type) methods to mask or unmask a specific type.

/*
* Masks all instances of a specific {@link Class}
* and subclass of a view during session replay recording.
*
* @param type {@link Class} to be masked.
*/
public static void mask(@NonNull Class<?> type)
/**
* Unmasks all instances of a specific {@link Class}
* and subclass of a view during session replay recording.
*
* @param type {@link Class} to be unmasked.
*/
public static void unMask(@NonNull Class<?> type)

For example, given the views hierarchy:

The default masking state of all the views is masked by default and for example, you may want to unmask all TextView types, you can simply call unMask(TextView.class) then all instances of TextView type will be unmasked. In this example, the button is also impacted by the unmask API because the button is also a TextView type (a subclass of TextView). You can also notice that the EditText is also a TextView type but it is not impacted by unMask(TextView.class) call because the EditText type is masked by default see Masking rules priority.

[tab] iOS (UIKit)

Use mask(viewsOfType:) and unmask(viewsOfType:) methods if you want to handle a specific type (wether being a system component or a custom component).

Example 1: un-masking UIButtons If you are in default masking but you don’t want any button to be masked, you will only need to call unmask(viewsOfType: UIButton.self): to specify that all instances of UIButton and its subclasses should not be masked.

Example 2: masking custom component If you use the Stripe SDK to handle payments, Stripe uses STPPaymentCardTextField for credit card number fields. STPPaymentCardTextField is not a subclass of UITextField and therefore is not masked by default. You will need to call mask(viewsOfType: STPPaymentCardTextField.self) to specify that all instances of STPPaymentCardTextField and its subclasses should be masked.

[tab] iOS (SwiftUI)

Masking/unmasking by type can be achieved using the 3 following high-level methods:

/// Masks or unmasks all text components.
///
/// The text component is `UILabel` for UIKit and `Text` for SwiftUI.
static func maskTexts(_ mask: Bool)
/// Masks or unmasks all image components.
///
/// The image component is `UIImageView` for UIKit and `Image` for SwiftUI.
static func maskImages(_ mask: Bool)
/// Masks or unmasks all text input components.
///
/// The text input components are `UITextField`, `UITextView` for UIKit and
/// `TextField`, `SecureField`, `TextEditor` for SwiftUI.
static func maskTextInputs(_ mask: Bool)

SwiftUI masking can only operate on these given types ; containers, like VStack, cannot be masked. NOTE: Beware, using these APIs will also affect UIKit masking. If you wish, you can use only these APIs and mask/unmask both UIKit and SwiftUI components.

Masking/Un-masking by instances

[tab] Android

Use mask(View view) and unmask(View view) methods to mask or unmask a specific view instance.

/**
* Masks a specific {@link View} during session replay recording.
*
* @param view an instance of {@link View} to be masked.
*/
public static void mask(@NonNull View view)
/**
* Unmasks a specific {@link View} during session replay recording.
*
* @param view an instance of {@link View} to be unmasked.
*/
public static void unMask(@NonNull View view)

Following the previous example, we have the views hierarchy in which all instances of TextView type are unmasked with the default masking state of all the views are masked by default.

In this example you may want to mask a specific text view TextView1, you can simply call mask(textView1) then this instance of TextView1 will be masked. As you can see in this example the view instance rule overrides the type rule and the default rule.

[tab] iOS (UIKit)

Use mask(view:) and unmask(view:) methods to deal with a specific instance.

Following the previous example on un-masking UIButtons: you are in default masking, the UIButton are unmasked but a screen shows a button, myButton, that contains sensitive information. You will be able to mask it with mask(view: myButton).

[tab] iOS (SwiftUI)

You can mask or unmask a SwiftUI View instance by using the following view modifier:

@ViewBuilder
func csMasking(_ shouldMask: Bool) -> some View

This API must be called before any other modifiers, as some modifiers change the view hierarchy, which will cause our API to not work. It only works with Text, Button, EditButton, Image, AsyncImage, Link, NavigationLink, SecureField, TextEditor, TextField. You must call this API directly with these types, For example:

Text("").csMasking(true)

the following code will not work.

 VStack {
    Text("")
 }.csMasking(true)

Masking rules priority

[tab] Android

The SDK determines if a view is masked or unmasked by applying the rules in the following order:

PriorityRuleDefault ruleEffect
1View instance ruleThe initial instance rule is emptyIf a view is part of the instance rule this rule will be applied
2Type ruleThe initial type rule contains EditText type as masked by defaultIf a view is not part of the instance rule but it is part of type rule, the type rule will be applied for this view
3Default masking ruleThe default value is true, all views are masked by defaultIf no other rules exist for this view the default rule will be applied for this view

The default masking rule will be overridden by the type rule if there is a specific call to mask(Class type) or unMask(Class type) on a specific type and if there is a call to mask(View view) or unMask(View view) on a specific view instance, the view instance rule will be applied instead of the type rule and the default rule.

[tab] iOS

The SDK determines if a view is masked or not by applying the above rules in the following order. Once a rule is triggered, the state is set (subsequent rules won't be applied):

PriorityRuleView State
1Parent view is masked (UIKit only)Masked
2View is part of the unmasked instancesUnmasked
3View type is part of the masked typesMasked
4View is part of the masked instancesMasked
5ElseUnmasked

Masking & Unmasking behaviors on a parent view

[tab] Android

The masking of the ViewGroup (RelativeLayout, LinearLayout, ConstraintLayout, etc..) is supported. If a parent view is masked all of its children will inherit the masking state from the parent. The children's view masking state can be overridden by using a specific API to mask or unmask by instance or by type.

For example, given the view hierarchy with the default masking state. All the views are masked by default in the initial state and in this case, you may want to unmask all children’s views of the parent view LinearLayout1, you can simply call unMask(linearLayout1) then all children views of the parent view LinearLayout1 will be unmasked. As noticed here the EditText is not impacted from the parent call because the EditText type is masked by default Masking rules priority.

[tab] iOS (UIKit)
  • When a parent view is unmasked (type or instance), it will not unmask its children. That means that if you want to unmask a full screen or a screen area, you will have to unmask each instance/type through the use of our API.
  • When a parent view is masked, all its children will simply be excluded. The area will be replaced by a black rectangle.
Examples

Unmask an instance when its type is masked If you've masked UILabel types specifically or are in default masking, and you call unmask(view: myLabel), all UILabel instances will be masked except myLabel.

Mask an instance when its type is unmasked If you've unmasked UILabel type specifically or are using the default masking, and you call mask(view: myLabel), no UILabel will be masked except myLabel.

Masking a view with a parent view unmasked

  1. MyContainerView and UIButton types are masked
  2. one UIButton and one UIView are added to an instance of MyContainerView
  3. MyContainerView is unmasked
  4. the button will still be masked and the view will be unmasked.

Implementation recommendations

Where to perform masking operations

[tab] Android
  • Masking operation should be performed before the first draw of the view and it should always be called on the UI thread.
  • At the Activity level, It is recommended to call the masking API methods on the onCreated lifecycle method.
  • At the Fragment level, it is recommended to call the masking API methods on the onViewCreated.

The masking API stores the view element to be masked or unmasked inside a WeakHashMap, repeating the masking operation multiple times on the same element will override the old value that is already stored in the map.

[tab] iOS (UIKit)

Masking operations should always be performed before the target view is added to the window to avoid any Personal Data leak. For instance, you can set up masking in the didFinishLaunching callback of your app or if you need to change masking behavior while your app is launched, it can be done in loadView, viewDidLoad or viewWillAppear if you use a ViewController.

[tab] iOS (SwiftUI)

Masking operations should be done as early as possible, in the setup of your app for masking by types for instance. For masking by instance, you can do it in the body of your View by calling the modifier csMasking.

Keeping track of what is masked

The SDK doesn't provide a list of what is currently masked, if you need to keep track, you probably will have to write your specific wrapper.

Masking operations performance impact
[tab] Android

Repeating the same operation has little to no impact, e.g you can call unmask(View view) multiple times without impacting the SDK or your app performances.

[tab] iOS

Repeating the same operation has little to no impact, e.g you can call unmask(viewsOfType: UIButton.self) multiple times without impacting the SDK or your app performances.

Integrations

Contentsquare provides the ability to retrieve the link to the session replay to be attached to other vendors such as Voice of customers tools, Crash reporting tools.

[tab] Android

Use the following code to retrieve the current session replay link:

val replayLink = Contentsquare.currentSessionReplayLink()

When called, the SDK will log:

I/CSLIB: SessionReplay link: https://app.contentsquare.com/quick-playback/index.html?pid={projectId}&uu={userId}&sn={sessionNumber}&recordingType=cs

If you have a Contentsquare account, you can use this link to directly watch your current session replay on the Contentsquare Platform.

The replay will be available within 5 minutes. Only the "ended screen views" are processed (we know a screenview is ended when we start receiving data for the next screenview). This means that you will be able to replay the session up to the previous screenview if the session is still running.

[tab] iOS

Use the following code to retrieve the current session replay link:

let replayLink: URL? = Contentsquare.currentSessionReplayLink

When called, the SDK will log:

CSLIB ℹ️ Info: SessionReplay link: https://app.contentsquare.com/quick-playback/index.html?pid={projectId}&uu={userId}&sn={sessionNumber}&recordingType=cs

If you have a Contentsquare account, you can use this link to directly watch your current session replay on the Contentsquare Platform.

The replay will be available within 5 minutes. Only the "ended screen views" are processed (we know a screenview is ended when we start receiving data for the next screenview). This means that you will be able to replay the session up to the previous screenview if the session is still running.

How Session Replay works

Initialization

Sessions can be recorded for Session Replay if the Session Replay feature has been enabled for your project and the session matches the recording criteria.

The following conditions will have an impact on which sessions will be recorded:

  • User consent: The users have given their consent (if required)
  • Recording rate: The session is being drawn for recording (see Recording Rate below)
  • Compatibility: The OS version is supported.
  • App version: The app version is not part of the block list (see App version block list below)

Please note that, when Session Replay is turned off, no content specific to Session Replay is collected whatsoever. Also note that the standard Contentsquare analytics tracking remain unaffected by this.

Recording rate

Session Replay recording is based on a percentage of the total sessions. By default Session Replay recording is disabled.

During the early access phase, the percentage or recorded sessions will be set to 1% at the beginning. It will then be adjusted according to:

  • the traffic on your app
  • the volume of recorded sessions set in the contract

Compatibility

Refer to the respective compatibility sections: Android and iOS.

App version block list

Contentsquare team can add versions of your app in the block list to make sure Session Replay does not start on these versions. This can be useful when a problem is discovered on a specific version of your app, such as a Personal Data for which the masking was forgotten. This allows to keep Session Replay working on the other app versions (especially the new ones with the fix).

Recording

The SDK monitors the application lifecycle events and the view hierarchy, and generates session replay data from the behavior of the app, the content of the screen and the interaction of the user. These events are then locally stored, and eventually sent to our servers in batches. We then aggregate that data to create usable visual information into our Web Application, which you use to gather insights.

💡

Session recording will start at the 1st screenview event.

Quality levels

Session Replay has three quality levels available: High, Medium, Low. Quality level is defined by the frame rate and the image quality. The quality level is defined independently for Wifi and Cellular Network. The default level for both is Medium. For more information on network data consumption see: Performance Impact section.

If you require a particular setting, reach out to your Contentsquare contact that will make the adjustment in the project configuration.

Quality level examples

Android

Click on the image to open the it in full size and GIF format.

iOS

Click on the image to open the it in full size and GIF format.

Network & Storage

By default, Session Replay data can be sent over cellular network. If the particular context of your app and/or users requires a very limited impact on cellular data consumption, sending data over cellular network can be completely disabled. Once disabled, data will be sent when the device is using Wifi.

Reach out to your Contentsquare contact that will make the adjustment in the project configuration.

Storage

Before being sent, data is stored in local files on disk up to 30MB on iOS and 20MB on Android. If the limit is reached, Session Replay is stopped. It will restart at the next app launch once the SDK is able to send the data.

Requests

The maximum request size is 1Mbyte.

[tab] Android

Requests are sent:

[tab] iOS

Requests are sent:

Performance impact

[tab] Android

Performance impact mitigation
  • Most operations related to Session Recording are performed on dedicated background threads, for a minimal impact on the main thread. If CPU usage is too high for background tasks, some expensive calculations will be discarded. If too much time is spent on the main thread, the quality level will decrease. To make sure of this, we run performance tests on testing applications, along with the Android profiler.
  • We defined our network strategy to have the lesser impact on CPU, memory and data consumption of the users devices. We measure these impacts, using the Android profiler and dedicated logging system, for each release of our SDK.
  • Session Recording will also stop if we use up to 20Mbytes of local storage.
  • You can reduce the quality level if you want to favor performance impact over quality.
Performance test results

We always strive to be non-intrusive, and transparent to the developers of the client app. We apply this rule on the performance as well. These are the technical specifics we can share on performance, if you have any questions please feel free to reach out to us.

The performances results were obtained under the following conditions:

ConditionValue
Device modelPixel 2 real device
Android version30 (Android 11)
Quality levelHigh
Default Masking StateDisabled

That the numbers provided below will vary depending on the user's device and Android version.

PropertyValue
Total % on main thread (CPU Profiling)< 10 %
Memory overhead10 MB
Session Replay data transmitted over network during 1 minute of use:
- with "High quality" level< 500 kB
- with "Medium quality" level< 400 kB
- with "Low quality" level< 200 kB
Additional info on performance test procedure

CPU Usage Test We are profiling our Demo app with and without the SDK dependency by following the same use case. We are measuring the CPU peak for both versions of the app and we are computing the difference.

RAM Usage Test We are profiling our Demo app with and without the SDK dependency by following the same use case. We are measuring the RAM usage peak for both versions of the app and we are computing the difference.

Memory Leak Test We are profiling our demo app and we are using the ADB Monkey tool to create random user input events. During this time we are monitoring the RAM behavior for any anomaly. We are using the Memory Profiler to identify leaks.

Data consumption We replay a predefined, repeatable and automated user scenario on our demo app and all data sent is measured in kB/min.

[tab] iOS

We always strive to be non-intrusive, and transparent to the developers of the client app. We apply this rule on the performance as well. These are the technical specifics we can share on performance, if you have any questions please feel free to reach out to us.

Performance impact mitigation
  • Most operations related to Session Recording are performed on background threads, for the impact on the main thread to be minimal. To make sure of this, we run performance tests on testing applications, using XCTMetric and Hitch Time Ratio measures.
  • We also set up mechanisms that stop the Session Recording if we detect the feature is using too much memory.
  • We defined our network strategy to have the lesser impact on CPU and battery of the users devices. We measure these impacts, using Apple Dev Tools, for each release of our SDK.
  • Session Recording will also stop if we use up to 30Mbytes of local storage.
  • You can reduce the quality level if you want to favor performance impact over quality.
Performance test results

The following performances results were obtained under the following conditions:

ConditionValue
Device modeliPhone 12 mini 64GB
iOS version15.4
Test App built using Xcode version13.4
Test App built with Swift version5.5
Quality levelHigh
Default Masking StateDisabled

We conducted the tests using a default master-details app built using AdHoc distribution with no app thinning and with Swift symbols stripped. In the app, the SDK was making calls to the Public APIs, running and recording data in its default state.

The numbers provided below will vary depending on the user's device, iOS version, SDK version, as well as if you use Swift or not, which Swift version you use if you do, if you enabled bitcode or not and which options you used when building your IPA for App Store distribution.

Screens built with SwiftUI produce, on average, 120% of the SR data produced by similar screens built with UIKit.

PropertyValue
Max CPU overhead<20%
Max RAM usage<50Mbytes
Session Replay data transmitted over network during 1 minute of use
- with "High quality" level<2Mbytes
- with "Medium quality" level<1Mbytes
- with "Low quality" level<900Kbytes

Known limitations

[tab] Android

Maps & WebView

Maps & WebViews can contain potential Personal Data and are therefore masked with no ability to un-mask specific elements currently.

Jetpack Compose

Jetpack Compose is currently not supported but planned for 2022.

Views on top of the application views

If your application uses some library that display views on top of your application views hierarchy, like a MaterialShowcaseView, these views may not be visible in the Session Replay.

[tab] iOS

Maps & WebViews

Maps & WebViews can contain potential Personal Data and are therefore replaced by a placeholder with no ability to un-mask them currently.

NSAttributedString handling

The exported HTML doesn’t support the attributes expansion, obliqueness, paragraphStyle and textEffect.

Troubleshooting

Testing with an Android emulator / iOS simulator

If you struggle to watch a replay recorded on an emulator/simulator, it may be due to some network constraints applied on your computer (VPN, company network restrictions, etc.). Check your configuration and/or use a real device.