Skip to content

feat(replay): add textMaskPolicy for glyph-level text masking - #581

Draft
lukas-roqqu wants to merge 2 commits into
PostHog:mainfrom
lukas-roqqu:feat/replay-text-mask-policy
Draft

lukas-roqqu wants to merge 2 commits into
PostHog:mainfrom
lukas-roqqu:feat/replay-text-mask-policy

Conversation

@lukas-roqqu

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

Fixes #580.

Text masking in Flutter replay is all-or-nothing per node: maskAllTexts blacks out every text node, and PostHogMaskWidget / PostHogUnmaskWidget move whole subtrees. Apps that render a label and a sensitive value in the same node ('Account $number', a Text.rich amount with a currency span, a note field holding Rent for flat 402) can't keep the label and hide the value. With the default on, a replay of a fintech app is a screen of black boxes; with it off, every value in the app has to be found and wrapped, forever.

This adds sessionReplayConfig.textMaskPolicy: a function that sees each text node's rendered string and returns what to mask.

config.sessionReplayConfig.textMaskPolicy = PostHogTextMaskPolicies.digits();
typedef PostHogTextMaskPolicy = PostHogTextMask Function(String text);

sealed class PostHogTextMask {
  const factory PostHogTextMask.all();
  const factory PostHogTextMask.none();
  const factory PostHogTextMask.only(Iterable<TextRange> ranges);
  const factory PostHogTextMask.except(Iterable<TextRange> ranges);
}

abstract final class PostHogTextMaskPolicies {
  static PostHogTextMaskPolicy digits();               // mask runs of digits
  static PostHogTextMaskPolicy redact(RegExp pattern); // mask every match
  static PostHogTextMaskPolicy reveal(RegExp pattern); // mask everything but the matches
}

A custom policy is just a closure, so an app can combine patterns or decide per node:

config.sessionReplayConfig.textMaskPolicy = (text) => PostHogTextMask.only([
  for (final m in email.allMatches(text)) TextRange(start: m.start, end: m.end),
  for (final m in digits.allMatches(text)) TextRange(start: m.start, end: m.end),
]);

How it works

  • TextMaskPolicyParser runs on the render object element of a text node (RenderParagraph for Text / RichText, RenderEditable for inputs). It hands the policy toPlainText(includeSemanticsLabels: false) so offsets line up with the laid-out text, and turns the returned ranges into rects with getBoxesForSelection. A range that wraps yields one rect per line. Boxes use the full line height (tight glyph boxes left 1px slivers above and below revealed words in except mode).
  • In ElementObjectParser.relateRenderObject, the existing Text-widget branch steps aside when a policy is set, and the RenderParagraph / RenderEditable branch asks the parser first. Everything else in the walk is unchanged.
  • Precedence is unchanged: PostHogUnmaskWidgetPostHogMaskWidget and the sensitive-input floor (obscured / password / OTP / email / phone) → the policy → maskAllTexts. With a policy set, it decides for the text nodes it handles, so maskAllTexts can stay at its default and the policy still keeps labels readable. Images and custom paint are not touched.
  • masksAnyContent includes the policy, so setting one with every maskAll* flag off still runs the mask walk.
  • Fails closed: a policy that throws, or returns a range outside the text, masks the whole node. The reason is logged in debug.
  • Flutter widget tree only, like the rest of the masking; it can be set at runtime.

Demo

Real replay frames: each image below is the wireframes[0].base64 payload the SDK uploaded in its $snapshot event, captured from the example app on an iPhone 17 Pro simulator against a local ingest endpoint. Nothing is drawn on top afterwards; this is what the replay player receives. The screen is the new Text Mask Policy (Replay) page in the example app.

On screen maskAllTexts (default, no policy) PostHogTextMaskPolicies.digits()
screen off digits
reveal(RegExp(r'\b(Total balance|VISA|EXP|Recent)\b')) custom: emails + digits digits() on text inputs
reveal custom inputs

Things to look at: the balance RichText masks 2,450,000 and .00 as two rects around the span; Ada Lovelace · Account 0123456789 keeps the name and masks the number; the transaction dates mask 12 and 2026 and keep Sep; the note field masks 402 and keeps Rent for flat; the email field stays fully masked under every policy because it is a sensitive input.

💚 How did you test it?

  • New test/text_mask_policy_test.dart (21 tests):
    • presets: digits() on an amount, a card number, separate numbers, text without digits; redact() on emails; reveal() on a currency code.
    • in the widget tree, asserting on the rects PostHogMaskController.getMaskElements returns against getBoxesForSelection: digits masks only the amount of a Text; RichText spans; offsets stay aligned when a span has a semanticsLabel; a non-sensitive TextField (Room 402) masks only 402; an obscured TextField is still masked in full under a policy that would reveal it; PostHogUnmaskWidget reveals a node the policy would mask; PostHogMaskWidget masks a node the policy would reveal; the policy overrides maskAllTexts = true; all masks the node exactly once; except masks everything but the revealed range, checked by painting the masks with ImageMaskPainter and reading pixels; a throwing policy masks the whole node; an out-of-range range masks the whole node; a wrapped range yields one rect per line; a policy alone turns the mask walk on with every flag off; a policy set at runtime applies on the next walk.
  • Full flutter test in posthog_flutter: 628 passed. dart analyze . clean, dart format --set-exit-if-changed ./ clean, make checkApiDart up to date (snapshot regenerated with make updateApiDart).
  • Example app on an iPhone 17 Pro simulator (iOS 26.5): the frames above, switching policies at runtime with the segmented control. Not run on Android or web.

While writing the tree tests I noticed getMaskElements(includeAllWidgets: true) returns the PostHogMaskWidget / sensitive-input rects twice (once from extractMaskWidgetRects, once from extractRects). It's pre-existing, harmless for painting, and not changed here.

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed. (docs PR on posthog.com to follow once the API shape is agreed)
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm changeset to generate a changeset file (.changeset/text-mask-policy.md, minor)

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Directed by @lukas-roqqu (I can't set the assignee from a fork; please assign me). Written with Claude Code (Claude Fable 5.1), using the Dart MCP server for hot restart and glint for driving the simulator during the captures. The tests, checks and captures above were run, not described.

Decisions along the way:

  • A policy function returning a sealed decision rather than a bare RegExp config field: a single regex can't express reveal/except or per-node choices, and this way the SDK never interprets patterns itself. The presets cover the regex cases.
  • The policy decides for text nodes even when maskAllTexts is true, so the safe default stays on and the policy only relaxes it where it says so. Sensitive inputs and PostHogMaskWidget still win.
  • Fail closed on a throwing policy or a bad range. Masking too much is recoverable; a leaked balance isn't.
  • Range boxes use the full line height. The first captures used tight glyph boxes and except left thin strips of the line above and below each revealed word. RenderEditable.getBoxesForSelection has no boxHeightStyle, so its boxes are grown to the caret rect of their line instead.
  • Rejected: doing this in ImageMaskPainter (no access to text layout) and a per-widget wrapper (doesn't help interpolated strings, and is the status quo).

Text masking was all-or-nothing per node. sessionReplayConfig.textMaskPolicy
lets an app decide, per text node, whether to mask all of it, none of it,
only some character ranges, or everything except some ranges, so amounts
and account numbers can be hidden while the labels around them stay
readable. Ships with PostHogTextMaskPolicies.digits(), redact(RegExp) and
reveal(RegExp).

Applies to Text, RichText and non-sensitive text inputs. PostHogUnmaskWidget,
PostHogMaskWidget and the sensitive-input floor keep precedence; the policy
overrides maskAllTexts for the nodes it handles and fails closed when it
throws or returns a range outside the text.

Fixes PostHog#580
@lukas-roqqu
lukas-roqqu requested a review from a team as a code owner September 15, 2026 17:22

@marandaneto marandaneto left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated advisory code review. Two reproduced findings.

}
final List<TextBox> lineBoxes;
try {
lineBoxes = _boxesForRange(renderObject, range);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: Partial grapheme selections can leave digits visible — For Code 1\u20e3, digits() selects the digit but not its combining mark. Flutter returns no selection box for that partial glyph, so _rangeRects emits no mask and exposes content the policy requested to redact. Expand masking selections to complete glyphs or fail closed when they cannot be represented safely. Reproduction: reproduced — flutter test test/text_mask_policy_test.dart --plain-name 'review:' with the added combining-digit regression test fails the glyph-coverage assertion on this head; a temporary whole-node fallback for empty selection boxes makes it pass.

activeElementData.addChildren(mask);
}
// A text node has no maskable descendants; keep the current parent.
return null;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: Preserve unmask precedence inside WidgetSpan — A RenderParagraph can have widget descendants through WidgetSpan. Returning null makes their unmask markers siblings of the policy masks rather than descendants, so subtractUnmaskRects never applies them. Consequently, .all() masks a nested PostHogUnmaskWidget despite its documented precedence. Preserve the ancestry needed to subtract those unmask regions. Reproduction: reproduced — flutter test test/text_mask_policy_test.dart --plain-name 'review:' with the added WidgetSpan regression test shows the explicitly unmasked text remains covered; temporarily returning the single mask as the parent makes it pass.

@marandaneto
marandaneto requested a review from a team September 16, 2026 08:42
@marandaneto

Copy link
Copy Markdown
Member

i think we should adopt similar controls to https://posthog.com/docs/session-replay/privacy
eg maskInputOptions``maskInputFn, maskTextSelector, maskTextFn and similar controls so its not very different than the rest
eg we give a function that has the widget and you decide what to do with it, its more flexible than the current approach i think, wdyt?

@marandaneto

Copy link
Copy Markdown
Member

thanks for the pr @lukas-roqqu
left a comment and a suggestion
moving to draft until we address those

@marandaneto
marandaneto marked this pull request as draft September 16, 2026 08:48
…sk precedence

Two blocking findings from review on PR PostHog#581:

- A validated range that produced no selection boxes (e.g. it splits a
  base character from a combining mark fused to it on screen, such as a
  keycap digit sequence) was silently treated as nothing to mask instead
  of a layout failure, shipping that glyph unmasked. Now falls back to
  masking the whole node, matching every other fail-closed case.

- Returning null after adding a policy's mask rects discarded ancestry
  for the rest of the walk, so a PostHogMaskWidget/PostHogUnmaskWidget
  nested in a WidgetSpan attached as a sibling of the masks instead of a
  descendant, and subtractUnmaskRects never saw it. When a decision
  produces exactly one rect, that rect is now returned as the active
  parent so nested structure attaches correctly; more than one rect has
  no single box to nest under, but none is needed there either, since a
  WidgetSpan can't geometrically overlap a sibling text glyph run.

Two new regression tests cover both. Full suite: 630 passed.
@lukas-roqqu

Copy link
Copy Markdown
Contributor Author

Thanks for the review, both blocking findings confirmed and fixed in 127fa9e, with a regression test for each:

  • The grapheme-cluster case: an empty box list for a validated range is now treated as a layout failure (same fail-closed path as everything else) instead of silently masking nothing.
  • The WidgetSpan case: when a decision produces exactly one mask rect, the walk now keeps descending into it instead of discarding ancestry, so a nested PostHogMaskWidget/PostHogUnmaskWidget attaches as its descendant again and subtractUnmaskRects sees it. (A decision with more than one rect can't have this problem — a WidgetSpan occupies its own inline slot and can't overlap a sibling text glyph run.)

On the API shape: agreed that handing the callback only the plain text is the less flexible half of this, and I don't want to reinvent something unlike the rest of the SDK's controls. Concretely, what if PostHogTextMaskPolicy became PostHogTextMask Function(String text, Widget widget) — same callback shape you're describing (you get the widget, you decide), just typed as a decision (all()/none()/only(ranges)/except(ranges)) rather than a boolean or a masked-text string? That keeps the part that's actually load-bearing here: glyph-range precision needs ranges, not a yes/no or a replacement string, and the sealed return type is what makes "fails closed on a bad range" a compile-time-checked exhaustive switch rather than a convention. Everything else — precedence, presets, fail-closed behavior — stays as is; it's a one-parameter addition plus updating the presets to ignore it. Happy to push that if it lines up with what you had in mind, or if you had a different shape for the callback's return value itself, let me know and I'll match it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Session replay: mask part of a text node (e.g. only the digits) instead of the whole node

2 participants