Skip to content

Session replay: maskAllTexts does not mask text drawn by CustomPainter (RenderCustomPaint is not registered) #568

Description

@lukas-roqqu

Summary

With maskAllTexts: true, text drawn by a CustomPainter onto a Canvas is not masked and is captured in the clear in session replay screenshots.

This is not a per-widget opt-out — the config claims all text is masked, the replay looks correctly masked (every Text widget is a blob), and the canvas-drawn values sit in plain view alongside them. There is no signal that anything was missed.

Cause

lib/src/replay/element_parsers/element_parsers_const.dart registers masking for four render object types only:

ElementParsersConst(this._factory, PostHogSessionReplayConfig? config) {
  if (config?.maskAllImages ?? true) {
    registerElementParser<RenderImage>();
  }
  if (config?.maskAllTexts ?? true) {
    registerElementParser<RenderParagraph>();
    registerElementParser<RenderTransform>();
    registerElementParser<RenderEditable>();
  }
}

RenderCustomPaint is absent, so any subtree painted through CustomPainter.paint is walked past. A TextPainter.paint(canvas, offset) call inside a painter produces no RenderParagraph, so nothing matches and no mask rect is emitted.

Reproduction

class ValuePainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final tp = TextPainter(
      text: const TextSpan(
        text: '₦2,450,000.00',
        style: TextStyle(fontSize: 24, color: Colors.black),
      ),
      textDirection: TextDirection.ltr,
    )..layout();
    tp.paint(canvas, Offset.zero);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

// In a PostHogWidget subtree, with sessionReplay: true and maskAllTexts: true:
Column(
  children: const [
    Text('₦2,450,000.00'),                                 // masked
    SizedBox(width: 200, height: 40,
      child: CustomPaint(painter: ValuePainter())),        // NOT masked
  ],
)

Both strings are identical. In the recording the first is masked and the second is readable.

Expected

With maskAllTexts: true, canvas-drawn text is masked — or, if that is not feasible, CustomPaint subtrees are masked wholesale by default when either masking flag is on, since their contents cannot be introspected.

Impact

This affects any chart or custom-painted component that renders values — portfolio charts, balance rings, sparklines with labels. These are common in finance apps, which are also the apps most likely to enable masking in the first place. Because the failure is silent and the surrounding widgets are masked, a team reviewing their own recordings has no way to notice.

Environment

  • posthog_flutter 5.39.0
  • Flutter 3.44.7 (stable)
  • iOS 26.x simulator and device

Possible workaround for others hitting this

Wrap the painted widget in PostHogMaskWidget. That works, but it is opt-in and per-site — the default posture of maskAllTexts: true gives no protection here.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions