Skip to main content

Session Replay

Session replay

Session Replay allows you to record and replay user sessions for debugging and analytics purposes.

Setup

First, initialize the Session Replay masking handler in your main() function:

import 'package:cx_flutter_plugin/cx_session_replay_masking.dart';

Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await SessionReplayMasking.initialize();

runZonedGuarded(() {
runApp(const MyApp());
}, (error, stackTrace) {
CxFlutterPlugin.reportError(error, {}, stackTrace.toString());
});
}

Initialize session replay

import 'package:cx_flutter_plugin/cx_session_replay_options.dart';

final options = CXSessionReplayOptions(
captureScale: 1.0, // Screenshot scale (0.0-1.0)
captureCompressQuality: 0.8, // JPEG compression quality (0.0-1.0)
sessionRecordingSampleRate: 100, // Percentage of sessions to record (0-100)
autoStartSessionRecording: true, // Start recording automatically
maskAllTexts: false, // Mask all text in screenshots
textsToMask: ['password', 'credit'], // Regex patterns — mask text containing these words
maskAllImages: false, // Mask all images in screenshots
);

// Masking for Flutter content is performed in DART on both iOS and Android: the
// native SDK requests a pre-masked bitmap of the Flutter view on each capture, and
// the plugin walks the render tree to black out text/images before handing the bytes
// to native. CXSessionReplayOptions is the single source of truth for masking —
// SessionReplayMasking.initialize() in main() only registers the handler and takes
// no masking arguments.
// textsToMask patterns are RegExp strings, case-sensitive by default ('password'
// will not match "Password"). Dart's RegExp has no inline (?i) flag — use a character
// class instead: '[Pp]assword'. Text fields (RenderEditable) are always masked while
// masking is active; their live content is not read, to avoid inspecting sensitive input.

await CxFlutterPlugin.initializeSessionReplay(options);

Masking

All masking is configured through CXSessionReplayOptions — that single object is the source of truth. SessionReplayMasking.initialize() in main() only registers the handler; it takes no masking arguments. To change masking later, just call initializeSessionReplay(newOptions) again.

Mask everything (text + images):

CXSessionReplayOptions(
maskAllTexts: true,
maskAllImages: true,
// ...other options
)

Mask only specific text (regex), leave the rest visible:

CXSessionReplayOptions(
maskAllTexts: false, // turn off "mask all text"
textsToMask: [r'\d{3}-\d{2}-\d{4}', // e.g. SSNs
'^Session.*', // text starting with "Session"
'password'], // RegExp strings, case-sensitive
maskAllImages: false,
)

Mask only images:

CXSessionReplayOptions(maskAllTexts: false, maskAllImages: true)

Mask one specific widget (regardless of the flags above) — wrap it in MaskedWidget:

import 'package:cx_flutter_plugin/cx_session_replay_masking.dart';

MaskedWidget(child: Image.network(avatarUrl))

// Conditional masking:
MaskedWidget(
isMasked: true, // set to false to temporarily disable
child: TextField(decoration: InputDecoration(labelText: 'Credit Card')),
)

Notes:

  • maskAllTexts and maskAllImages are independent — enable either or both.
  • maskAllImages covers Image.*, RawImage, and Container/CircleAvatar background images (DecorationImage). For content the render tree can't expose (platform views, native maps/WebViews), wrap it in MaskedWidget.
  • Icons (Icon, glyph fonts) are treated as neither text nor images and stay visible.

Masked interactions

When automatic user-interaction tracking (userActions) is enabled, a tap on content wrapped in MaskedWidget is reported with masking awareness:

  • The interaction event is still emitted (you keep your analytics), with interaction_context.is_masked_element: true.
  • The visible text is redacted: target_element_inner_text is reported as ***.
  • Identity fields and coordinates are reported as-is (target_element, element_classes, x/y).
  • The Session Replay tap marker is suppressed for that tap, so the replay never reveals which part of the masked area was touched.

The verdict is resolved on the tapped widget's tree (the same target the identity fields describe), in both directions:

  • A tap on an unmasked child rendered inside a MaskedWidget subtree is masked (masked ancestors count) — including platform views (e.g. a WebView) wrapped in MaskedWidget.
  • A tap anywhere on a control whose own subtree contains a MaskedWidget is masked — tapping a button's padding resolves the same as tapping its masked child Text, so the verdict never depends on whether the finger hit the text glyphs or the padding.

Only MaskedWidget drives the interaction verdict. The session-replay pixel policy — maskAllTexts, textsToMask, maskAllImages — masks pixels in the replay only: it suppresses tap markers over the masked regions, but taps on policy-masked content still report is_masked_element: false and their text in clear. The flag and the replay marker can therefore disagree for policy-masked content. This split is deliberate and matches the Android and iOS SDKs: the replay policy answers "should these pixels be hidden" per frame, which is not the same question as "may this text leave the device". Content whose interactions must be masked needs the explicit MaskedWidget opt-in, which feeds both.

Masking operates at three granularities, each deliberate:

  • Replay pixels are masked content-granular — exactly the text glyphs / images (the button chrome around a masked label stays visible).
  • Replay tap markers are suppressed control-granular: a masked label inside a button suppresses the marker for a press anywhere on that button (any mask source). Otherwise marker positions on a masked-label keypad would reconstruct the entered sequence. This matches classic Android natively (Button extends TextView — the whole button is masked); Jetpack Compose remains text-granular in the native SDK.
  • Interaction events carry the semantic verdict on the tapped control, from MaskedWidget only, as described above.

is_masked_element is reported on tap events, and is false whenever masking cannot be resolved (for example, the tapped widget cannot be determined). It is not part of scroll and swipe events, and native SDK versions older than the ones this plugin pins omit it entirely — so in beforeSend the field is nullable (event.interactionContext?.isMaskedElement) and a missing value means "not masked". Compare it with == true rather than treating it as a plain boolean.

Keypad guidance: because identity fields are reported as-is even for masked taps, keys of a masked PIN pad (or any per-character input) must share a single identifier — build every key from the same widget type and do not give keys per-digit ids, semantic labels, or tooltips. Otherwise the reported identity reveals exactly what masking hides.

If your privacy policy requires more than redaction, use beforeSend:

// Recipe 1 — drop every masked interaction entirely:
beforeSend: (event) {
if (event.interactionContext?.isMaskedElement == true) {
return null; // event is not sent
}
return event;
},

// Recipe 2 — keep the event, blank the coordinates:
beforeSend: (event) {
final interaction = event.interactionContext;
if (interaction?.isMaskedElement == true) {
interaction!.attributes?['x'] = 0;
interaction.attributes?['y'] = 0;
}
return event;
},

Check status

// Check if Session Replay is initialized
final isInitialized = await CxFlutterPlugin.isSessionReplayInitialized();

// Check if currently recording
final isRecording = await CxFlutterPlugin.isRecording();

Control recording

// Start recording
await CxFlutterPlugin.startSessionRecording();

// Stop recording
await CxFlutterPlugin.stopSessionRecording();

// Shutdown Session Replay
await CxFlutterPlugin.shutdownSessionReplay();

Capture manual screenshot

await CxFlutterPlugin.captureScreenshot();

Get session replay folder path

final folderPath = await CxFlutterPlugin.getSessionReplayFolderPath();

For more info check https://github.com/coralogix/cx-ios-sdk/tree/master/Coralogix/Docs.

Last updated on