Session recordings in apps
On the web X3 records the DOM with rrweb (recordings). Apps have no DOM, so the app SDKs record a wireframe: the layout of the screen as boxes, texts as masked shapes, images as placeholders, inputs never. The wireframe is turned into rrweb events on the device and plays in the same X3 player as your web recordings, with a phone frame, the screen path instead of a URL and an App line (platform, OS and app version).
| Approach | How | Privacy | Cost |
|---|---|---|---|
| Wireframe (default) | the SDK reads the view tree and sends frames, colours, masked texts and placeholders; only changes are sent | content never enters the packet | low: a tree walk only after changes |
| Screenshot (opt-in per screen) | a low-resolution image at most once per second after changes, masked regions blacked out before encoding | good only with clean masking | noticeable CPU and memory |
| video | not offered | – | – |
Turning recordings on
Recordings need all of these:
- the recorder module: iOS
X3KitReplay, Androidio.x3growth:sdk-replay, React Native and Flutter bring it along; recording.mode = wireframeinconfigure;- the purpose
recordingsused (purposes.recordings = true) and granted in your consent mode (consent); - recordings enabled in the project (Einstellungen › Aufnahmen & Heatmaps, Settings › Recordings & heatmaps), with the project's level, sampling, daily caps and screen exclusions.
The stricter side always wins. Recordings use the same smart keep rules as the web: converted, lead, hot, friction, a sample and your watch rules decide which sessions are kept after they end.
X3.configure(key: "pk_3x_YOUR_APP_KEY", options: X3Options(
consent: X3ConsentOptions(mode: .optIn, purposes: .init(recordings: true)),
recording: X3RecordingOptions(mode: .wireframe, indicator: .pill, sampleRate: 1.0, wifiOnly: false)
))| Option | Default | |
|---|---|---|
mode | off | wireframe turns the recorder on |
maskAllText | true | texts as shapes; false shows texts unless masked (the server's PII scan still runs) |
maskAllImages | true | images stay placeholders in wireframe mode in any case |
screenshotScreens | [] | screens recorded as screenshots (below) |
indicator | pill | pill, dot or hidden (below) |
sampleRate | 1.0 | share of sessions recorded, decided per session |
wifiOnly | false | upload only on Wi-Fi or ethernet (the buffer keeps up to 8 MB meanwhile) |
What is recorded
- screens with their wireframe, scrolling, taps with position and tap target name, swipes, the keyboard opening and closing (height only), rotation;
- friction markers: rage taps (3 taps within 1 second within 30 pt), dead taps (a tap on an interactive element without any change within 500 ms);
- your tracked events as markers (names only), background and foreground.
Never recorded, under every option: what users type (inputs appear as empty or "••••" fields),
keystrokes, secure and password fields, payment and card fields, WebViews, maps, camera and video
previews, SurfaceView / TextureView, the clipboard, network traffic and logs.
Masking
Masking happens on the device, before anything is sent; the server scans the packets a second time for personal data. The default (strict) masks every text and image. Unmask on purpose, view by view:
| iOS | Android | React Native | Flutter | |
|---|---|---|---|---|
| always mask | view.x3Masking = .mask, SwiftUI .x3Mask() | X3.setMasking(view, X3Masking.MASK), Modifier.x3Mask() | <X3Mask> | not in 1.0.0 (the Flutter view is one masked area) |
| show text | view.x3Masking = .unmask, SwiftUI .x3Unmask() | X3.setMasking(view, X3Masking.UNMASK), Modifier.x3Unmask() | <X3Unmask> | not in 1.0.0 |
| tap target name | view.x3Anchor = "buy_button", .x3Anchor("buy_button"), else accessibilityIdentifier | X3.setAnchor(view, "buy_button"), Modifier.x3Anchor(…) / testTag, else the resource entry name | testID | not in 1.0.0 |
The closest marked ancestor wins (.mask inside an .unmask subtree masks again). Inputs stay
masked inside unmasked subtrees. The project can also unmask or mask by tap target name for all
apps (unmaskAnchors, maskAnchors in the recording settings), without a new app release.
Excluded screens. The project's recording exclusions apply to screen paths
(*login*, *account*, *checkout*, *payment*, *password* … by default): the SDK does not
record them, and the server refuses them again.
SwiftUI. SwiftUI draws text into internal layers that X3 never reads. In wireframes SwiftUI
text is always a placeholder shape, also with .x3Unmask(); .x3Unmask() makes it visible only in
screenshot mode. .x3Mask() wins over .x3Unmask(). UIKit, React Native and Android Views are read
directly, so .unmask shows their text in wireframes.
Jetpack Compose. sdk-replay reads Compose through the semantics tree (texts, editable and
password fields, test tags). Without Compose on the classpath a Compose host is one box.
Flutter. Flutter draws its own pixels; the native recorder of 1.0.0 records a Flutter screen as one masked area with taps and screen changes, not its widgets (Flutter).
Screenshot mode
Wireframes are the default for every screen and need no setup.
For screens the view tree cannot describe (custom drawing, canvas, charts, maps you want to see),
list them in screenshotScreens and allow them for the app (Einstellungen › Apps › Nordlicht ›
Aufnahmen, switch Screenshot-Modus erlauben, Allow screenshot mode). Both are needed.
recording: X3RecordingOptions(mode: .wireframe, screenshotScreens: ["Chart", "/plans/compare"])On those screens the SDK captures the window at most once per second and only after changes, blacks out every masked region, input and (with masking on) every text and image before encoding, scales to at most 360 pt wide and stores a JPEG of at most 60 KB. Each image is sent once and stored as a replay asset; the player shows a Screenshot chip for such recordings. Use it sparingly: it costs more CPU, memory and data than wireframes, and custom drawings can contain personal data the masking cannot know about.
Recording indicator
Apple's App Review Guideline 2.5.14 requires apps that record or log user activity to get explicit consent and to show a clear visual indication while they record. The SDK therefore shows an indicator while recording, top trailing inside the safe area, not captured in the recording, labelled for VoiceOver and TalkBack:
indicator | Look |
|---|---|
pill (default) | a small pill with a red dot and the text Aufnahme / Recording |
dot | only the red dot |
hidden | nothing |
Turning the indicator off is your decision and your risk in App Review and towards your users.
Combine it with consent: in mode opt_in the recorder never runs before the user granted
Sitzungen aufzeichnen (Session recordings).
Performance
| Budget | Value | When exceeded |
|---|---|---|
| main thread per capture | p95 below 4 ms, at most 2 captures per second | throttle to 1 per second, then pause until the next screen |
| CPU on average | below 2 percentage points extra | Low Power Mode, battery saver or thermal state serious: recording pauses |
| memory | buffer at most 8 MB | oldest packets dropped (the recording shows a gap) |
| network | below 50 KB per minute compressed | upload every 10 seconds and when the app goes to the background; optional Wi-Fi only |
| binary size | iOS X3KitReplay ≤ 300 KB, Android sdk-replay ≤ 200 KB | separate module: 0 bytes without recordings |
Per session at most 20 MB and 400 packets. Captures happen only after the view tree changed, so a static screen costs nothing.
Watching, retention and deletion
App recordings appear in Sitzungen next to web recordings, with the same playlists, filters, summaries and sharing rules; the visit shows the App card. Retention, keep decisions and deletion follow the project's recording settings (what is kept, deleting). When a user revokes Sitzungen aufzeichnen, the SDK stops at once and X3 deletes the recordings of the current session.