feat(replay): add textMaskPolicy for glyph-level text masking - #581
lukas-roqqu wants to merge 2 commits into
Conversation
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
marandaneto
left a comment
There was a problem hiding this comment.
Automated advisory code review. Two reproduced findings.
| } | ||
| final List<TextBox> lineBoxes; | ||
| try { | ||
| lineBoxes = _boxesForRange(renderObject, range); |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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.
|
i think we should adopt similar controls to https://posthog.com/docs/session-replay/privacy |
|
thanks for the pr @lukas-roqqu |
…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.
|
Thanks for the review, both blocking findings confirmed and fixed in 127fa9e, with a regression test for each:
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 |
💡 Motivation and Context
Fixes #580.
Text masking in Flutter replay is all-or-nothing per node:
maskAllTextsblacks out every text node, andPostHogMaskWidget/PostHogUnmaskWidgetmove whole subtrees. Apps that render a label and a sensitive value in the same node ('Account $number', aText.richamount with a currency span, a note field holdingRent 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.A custom policy is just a closure, so an app can combine patterns or decide per node:
How it works
TextMaskPolicyParserruns on the render object element of a text node (RenderParagraphforText/RichText,RenderEditablefor inputs). It hands the policytoPlainText(includeSemanticsLabels: false)so offsets line up with the laid-out text, and turns the returned ranges into rects withgetBoxesForSelection. 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 inexceptmode).ElementObjectParser.relateRenderObject, the existingText-widget branch steps aside when a policy is set, and theRenderParagraph/RenderEditablebranch asks the parser first. Everything else in the walk is unchanged.PostHogUnmaskWidget→PostHogMaskWidgetand the sensitive-input floor (obscured / password / OTP / email / phone) → the policy →maskAllTexts. With a policy set, it decides for the text nodes it handles, somaskAllTextscan stay at its default and the policy still keeps labels readable. Images and custom paint are not touched.masksAnyContentincludes the policy, so setting one with everymaskAll*flag off still runs the mask walk.Demo
Real replay frames: each image below is the
wireframes[0].base64payload the SDK uploaded in its$snapshotevent, 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.maskAllTexts(default, no policy)PostHogTextMaskPolicies.digits()reveal(RegExp(r'\b(Total balance|VISA|EXP|Recent)\b'))digits()on text inputsThings to look at: the balance
RichTextmasks2,450,000and.00as two rects around the₦span;Ada Lovelace · Account 0123456789keeps the name and masks the number; the transaction dates mask12and2026and keepSep; the note field masks402and keepsRent for flat; the email field stays fully masked under every policy because it is a sensitive input.💚 How did you test it?
test/text_mask_policy_test.dart(21 tests):digits()on an amount, a card number, separate numbers, text without digits;redact()on emails;reveal()on a currency code.PostHogMaskController.getMaskElementsreturns againstgetBoxesForSelection: digits masks only the amount of aText;RichTextspans; offsets stay aligned when a span has asemanticsLabel; a non-sensitiveTextField(Room 402) masks only402; an obscuredTextFieldis still masked in full under a policy that would reveal it;PostHogUnmaskWidgetreveals a node the policy would mask;PostHogMaskWidgetmasks a node the policy would reveal; the policy overridesmaskAllTexts = true;allmasks the node exactly once;exceptmasks everything but the revealed range, checked by painting the masks withImageMaskPainterand 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.flutter testinposthog_flutter: 628 passed.dart analyze .clean,dart format --set-exit-if-changed ./clean,make checkApiDartup to date (snapshot regenerated withmake updateApiDart).While writing the tree tests I noticed
getMaskElements(includeAllWidgets: true)returns thePostHogMaskWidget/ sensitive-input rects twice (once fromextractMaskWidgetRects, once fromextractRects). It's pre-existing, harmless for painting, and not changed here.📝 Checklist
If releasing new changes
pnpm changesetto 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:
RegExpconfig field: a single regex can't expressreveal/exceptor per-node choices, and this way the SDK never interprets patterns itself. The presets cover the regex cases.maskAllTextsis true, so the safe default stays on and the policy only relaxes it where it says so. Sensitive inputs andPostHogMaskWidgetstill win.exceptleft thin strips of the line above and below each revealed word.RenderEditable.getBoxesForSelectionhas noboxHeightStyle, so its boxes are grown to the caret rect of their line instead.ImageMaskPainter(no access to text layout) and a per-widget wrapper (doesn't help interpolated strings, and is the status quo).