From c3cf50bb0b9e872c3a7d279ffe5a48498c29feef Mon Sep 17 00:00:00 2001 From: Malte Nottmeyer Date: Mon, 20 Apr 2026 15:55:38 +0200 Subject: [PATCH] feat(sr): capture Icon widgets as raster images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register IconRecorder in the capture pipeline, reuse identical icon rasters per recorder, and respect image privacy levels. Add example screen coverage, unit tests, and changelog entry. Made-with: Cursor feat(session_replay): add configurable icon raster logical size Rasterize icons at DatadogSessionReplayConfiguration.iconRasterLogicalSize (default 20) × DPR, cache by glyph/font/color, and dedupe concurrent work. Align glyph layout with Flutter Icon for replay; export config on public API. Made-with: Cursor feat(session_replay): add phased icon raster timings and outcome type Introduce _IconRasterOutcome so raster work reports per-phase ms (toImage, toByteData, save) and defers rich debug logging until after the shared in-flight future completes. Made-with: Cursor chore: revert icon layouting changes fix(session-replay): dispose TextPainter after icon rasterization Made-with: Cursor feat(session_replay): parallel icon processing and raster size Resolve AdditionalProcessingElement work with Future.wait so icon rasterization is not serialized. Add DatadogSessionReplayConfiguration.iconRasterLogicalSize (default 20) and plumb it into IconRecorder for canonical glyph bitmaps. Add golden coverage for Icon image privacy levels and extend icon_recorder tests. Co-authored-by: Cursor --- .../goldens/global_mask_all_icons.png | Bin 0 -> 21207 bytes .../goldens/global_mask_non-asset_icons.png | Bin 0 -> 21595 bytes .../goldens/global_mask_none_icons.png | Bin 0 -> 21569 bytes .../goldens/override_mask_all_icons.png | Bin 0 -> 21254 bytes .../golden_test/icon_masking_golden_test.dart | 122 ++++++ .../example/lib/screens/images_screen.dart | 11 + .../lib/datadog_session_replay.dart | 10 + .../element_recorders/icon_recorder.dart | 279 ++++++++++++++ .../lib/src/capture/recorder.dart | 37 +- .../lib/src/datadog_session_replay.dart | 1 + .../test/capture/icon_recorder_test.dart | 354 ++++++++++++++++++ .../test/datadog_session_replay_test.dart | 6 + 12 files changed, 810 insertions(+), 10 deletions(-) create mode 100644 packages/datadog_session_replay/example/golden_test/goldens/global_mask_all_icons.png create mode 100644 packages/datadog_session_replay/example/golden_test/goldens/global_mask_non-asset_icons.png create mode 100644 packages/datadog_session_replay/example/golden_test/goldens/global_mask_none_icons.png create mode 100644 packages/datadog_session_replay/example/golden_test/goldens/override_mask_all_icons.png create mode 100644 packages/datadog_session_replay/example/golden_test/icon_masking_golden_test.dart create mode 100644 packages/datadog_session_replay/lib/src/capture/element_recorders/icon_recorder.dart create mode 100644 packages/datadog_session_replay/test/capture/icon_recorder_test.dart diff --git a/packages/datadog_session_replay/example/golden_test/goldens/global_mask_all_icons.png b/packages/datadog_session_replay/example/golden_test/goldens/global_mask_all_icons.png new file mode 100644 index 0000000000000000000000000000000000000000..9370e6aa7aa71b224e8be5d912d160ba8653ee3f GIT binary patch literal 21207 zcmeI4T}V@59LE2%rJH42_Mw@InOdNw0}*4JnP@o>F%dG!an5+o^ZfqrdCy06 zQsNmmS1(s0a#O{}CKHL(L@pwcGt<-cZbis^2#aG>DI#Vm6J=L1>w@CsGcnZu#(R#) zk5sXzQgkgQgREfB*}c(kwX?@FB4$h#CShE7r&##tx^rAeU`SBLouKTaryCB|zj|gI znQhspQ07e~6l%1-3(Xp4leT4X#J8)2seF?fXb0c+ zb5{%acC1qf->itQ*v_}5*f=l6=E;(uE4f^&IX^r2q(%i@XU8D4fkJ?bye?U9Mcx9Wet*^Y- zx49!Uw@%h17x#aO>XMLtapSk^9+eWw$(%Y;Tr8;c8CcLon8+wC2xPaVkDRkxMjZr) z@rFP)wi+_n;MEuk%2pN&EuIqwgW;BJI8rWuz(^m5VqRg0*l zv*g@DZXMCjW7B!wgC$O|M4a16?QXw+FG#R)=?dMPK+wb{1PlQ~5Frgn14;%*0;GWx z0<{6P0kwgr0@8pqAPq8j^;j zA!&>PKmkzjuc08R=L-8)m$1?4YrA@C4%?2CkPHrvjS5C*;sHIK2=w Ub=3Q>W?6++#U;kJ$Ea`o0*CumJpcdz literal 0 HcmV?d00001 diff --git a/packages/datadog_session_replay/example/golden_test/goldens/global_mask_non-asset_icons.png b/packages/datadog_session_replay/example/golden_test/goldens/global_mask_non-asset_icons.png new file mode 100644 index 0000000000000000000000000000000000000000..31f3e43e07a06d04cbe6bd24bee14c84f354ea2e GIT binary patch literal 21595 zcmeHPZA?>F7=D2|Dro6|h#v^XvDFPCCf%eW1z|X>TVN2QYy+ut*6fRtfEB2e29m+T zj5^0$x5l#JvL)SW(Iu2Yp@uLg6qwsOV6l`h$3#jU+7J11x9z=@EZYwfvjln1pPqB> z>AlU<=Xu`uoO{oG|K-$_)qd;!0AMw5PjUvpNdT|+bc13Mt4*MOg}J-ZOpB?hySCTwTN*P~;*$xgTliQwQ zQ`c3ndoJd*+!N<1ZfLe{cXpiQzB7L4aKHv}Z*S{5%h`TbS_5@J{#TRp_irtb9-Fq; zkG2W5-xaUor_BzHMt66P=BmODKW*oMv}^i;ohQ9}$36I78Z*A|PM(#n3(G&H_5tbD z!R&ShbhT;&iF0Xw@=!&$7nw!z0|ob}`A}>YS2tWZ5*Tu0SBN%#~QT$Xl_ey z--N9I%`W+pO&0&mvjXUD9<$22yY>3&;SI`^zDQPSkys!IYqnN6p*iV*Cx4x3$XD$*1O!@!5dgOSmCz5Zl(+~wkrw39Z4=~J2PK zAT>yjFj}TMWwM6iyH(Ch1(+O0+bvoY@XrWMg|7<)%`>|IpM_oo}X~S;%mz066XA%oKP}Vme`Eg4O=EdmHnAaW*kf8ocqP%4~_@+ksu{& zAl=an1CyNC*jKsUr*!re5S+STlFvw;u!MS~#{4e`ak58|0~7TH;SU#`C|clh*%Dht zoNHBWbcKsmllA9YCx+?ZL+Ox!>qA&?LNs0LI6pcSAMpcRlDKs8W6U~ItHfNDTB5D1_Z zpcSAMpcO1zD_}N?T$>8E{b(v?G*&&~rotx+b{g`T{_Z z+4)`oH+}#}B@&56qlpL-2q1lsKCpNvLhYb-Fs*Z`0OO9^3^0igcKnXVyO@!*gM`| UBKbVV`F=#;?M_WrCGpGt1`FT0#sB~S literal 0 HcmV?d00001 diff --git a/packages/datadog_session_replay/example/golden_test/goldens/global_mask_none_icons.png b/packages/datadog_session_replay/example/golden_test/goldens/global_mask_none_icons.png new file mode 100644 index 0000000000000000000000000000000000000000..30a5805095d7f2d5824333c3eb13f2795543cda8 GIT binary patch literal 21569 zcmeHPYfMvT7=B?b6cI(YNF1;>#<;NoThe7*22=4H%ae2_n+bOs7J)2~1$zrUHqEXn?oC9wDV)AxMm^yHkU z&-1+R_w{_|>_?$NcAH+^1ORp+!GRF~Hi-b%ES447Q~h9pL4Gg<0U-xiWD~KDoFTU@ z1Q9_2P~PtJHvk7h0{stsE*PprtDvii7dwG#39EEF&3O@z$iuPIYT*>*&t5wD+z_Bwn7LQNBlAh80ho z+`H>?X79=hG_7u$W~S!!@PKqUsm<%SSE1W*Mx)L|hVjV=>nP!=JRzaKZ7b(P-YB1! zpZOV2kf%NWqbPJn=Ho!visR-RjCUvQi5r!1MDtT8w2Z_{&(CLAEqUnoNG(+EqJu`} zs3zUB%}h{SMdHw`Ks{|Vo7ob%b)G}`_z`T8Zc)y673x=(NdbC$AEE{5lOw9jy_Mzn zdXkF#>7$|}@q_0WW}oVc}Ri{IQ?iKJ$_IN=6i38Fg_C#@y5N~=BmfO#=;h&hNjvQWD?0(6;GLa zH{L6M!lNtFfL@Vk_F*#l&E~L$`+q1GAIRA4)rR9oQiTM0JfEiY=6DVu{$#Z(eU;0G0l+)w&(v7Rs#wO4=GPkSzicU%4~~o+(=uNB zQfCWqtVYS1oJFWMPXur>NE@OWW^6QLiV8U6vu;ZvoWi61>|ZmRDrLtn$%gVk?7#9* z&=KckDiu__%|G^smM`oOyD1mhZWC8*SfrP-^Kyd)=kp^PJ+vCrvEyhR43YN--6}6tmIB~mqzI_k* zFDccqn*stcF#&;uKtceZ8c+>@R)AK3RzPt8)j$J*xdC$nssYtNB7jzaR)AK3R`7qe zf}}?mjq5+Hp2uho3o{MN&EBywFZeAsuV%n*N-2nm2?!*_+7KdE*}W6s<@<*B0+`N- z3`2&Y4O8j@R0FC3)qrXs5kM`Z9p#2U%bksnv2C8I zBAO-#SODKK%r6-TF)zEvZWFJ?h=~aZB*c0lgv;%L{&dM~>4@QiL-vIRmIow$^$+M! BJgxu$ literal 0 HcmV?d00001 diff --git a/packages/datadog_session_replay/example/golden_test/goldens/override_mask_all_icons.png b/packages/datadog_session_replay/example/golden_test/goldens/override_mask_all_icons.png new file mode 100644 index 0000000000000000000000000000000000000000..32ff6b6e7e1abcd1668c4ec8737f096f0b44d10a GIT binary patch literal 21254 zcmeHPT}V@57=9=IOl?gi)XdC&y{RxNHRX_w#T?YMSkMo`>_-YB6KzUH*$-(5T1a7- zb(P+PPQ0-UT&O55l!!3%ZImyV?7FuyeNOJkRsK-{C#y zW~M=>3W^FMB9%T}dw?j=L=>P@`m>dmH(z|&hi|?{Z&b2zQF)?_%@z3vbQ)^tiJl;e zCA~JySajbxlx8rdtw?<}a%1K81VzfN+Q|3X#O#8d$=f;}*^gC+Zt5<~Tq|6)yG}U< z#vj`q8xOYmCl0lCeLbJW4k?Y#$8&Skd_tCkKAZWZB)!Cs$030h9@08RIFC~|Sbcb0 z=6i^Tben>C92;g*@K}q3b0`)Mmbm7Cq>iTzHkZrg+*WtA@BHDrC;MIIX5GCPG5*Do z&TLERa~8Z!VdINXq*L{Dn$*_>=aaahs*L;P&tql>)sv-h((1BV%Xl%np3yb?0uCka z&b?dcZp^7v(3zK&?mnAiz}A0!uVb~}vkqb6!S(T$HG^tu9F@QD-EXwLy|I5i(W)_3 zc7WA37;2{8yaXrNcMjemvNrV>co`R81rBktKw3-9aSS=?b7kez(+*pGZ*y1m6-tLXL&e`z^~>F!K(Au4EM2g8U?KqlLVyqi zHAp0YYsxr)5Fi9<2$=)0M-Ie)Q!Hr0?PGv444I0zcAM(&AO#lD@ literal 0 HcmV?d00001 diff --git a/packages/datadog_session_replay/example/golden_test/icon_masking_golden_test.dart b/packages/datadog_session_replay/example/golden_test/icon_masking_golden_test.dart new file mode 100644 index 00000000..e0db6eda --- /dev/null +++ b/packages/datadog_session_replay/example/golden_test/icon_masking_golden_test.dart @@ -0,0 +1,122 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2025-Present Datadog, Inc. + +// ignore_for_file: invalid_use_of_visible_for_testing_member + +import 'package:datadog_common_test/datadog_common_test.dart'; +import 'package:datadog_session_replay/datadog_session_replay.dart'; +import 'package:datadog_session_replay/src/capture/capture_node.dart'; +import 'package:datadog_session_replay/src/capture/recorder.dart'; +import 'package:datadog_session_replay/src/datadog_session_replay_platform_interface.dart'; +import 'package:datadog_session_replay/src/rum_context.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'golden_test_helpers.dart'; +import 'mock_platform.dart'; + +void main() { + late SessionReplayRecorder recorder; + late RUMContext context; + late MockDatadogSessionReplayPlatform platform; + + setUp(() { + recorder = SessionReplayRecorder( + defaultCapturePrivacy: TreeCapturePrivacy( + textAndInputPrivacyLevel: TextAndInputPrivacyLevel.maskSensitiveInputs, + imagePrivacyLevel: ImagePrivacyLevel.maskNone, + ), + touchPrivacyLevel: TouchPrivacyLevel.show, + ); + platform = MockDatadogSessionReplayPlatform(); + DatadogSessionReplayPlatform.instance = platform; + + registerFallbackValue( + CapturedViewAttributes(paintBounds: Rect.zero, scaleX: 1.0, scaleY: 1.0), + ); + + context = RUMContext( + applicationId: randomString(), + sessionId: randomString(), + ); + recorder.updateContext(context); + }); + + tearDown(() { + platform.clearImages(); + }); + + Widget createIconFixture() { + return Padding( + padding: EdgeInsets.all(12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: const [ + Icon(Icons.favorite, color: Colors.red, size: 48), + Icon(Icons.home, color: Colors.blue, size: 48), + Icon(Icons.settings, size: 48), + ], + ), + ); + } + + testWidgets('global mask all icons', (tester) async { + recorder.defaultTreeCapturePrivacy = TreeCapturePrivacy( + textAndInputPrivacyLevel: TextAndInputPrivacyLevel.maskAll, + imagePrivacyLevel: ImagePrivacyLevel.maskAll, + ); + + final fixture = MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Mask All Icons')), + body: createIconFixture(), + ), + ); + await snapshotTest(tester, recorder, fixture); + }); + + testWidgets('global mask none icons', (tester) async { + recorder.defaultTreeCapturePrivacy = TreeCapturePrivacy( + textAndInputPrivacyLevel: TextAndInputPrivacyLevel.maskAll, + imagePrivacyLevel: ImagePrivacyLevel.maskNone, + ); + + final fixture = MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Mask No Icons')), + body: createIconFixture(), + ), + ); + await snapshotTest(tester, recorder, fixture); + }); + + testWidgets('global mask non-asset icons', (tester) async { + recorder.defaultTreeCapturePrivacy = TreeCapturePrivacy( + textAndInputPrivacyLevel: TextAndInputPrivacyLevel.maskAll, + imagePrivacyLevel: ImagePrivacyLevel.maskNonAssetsOnly, + ); + + final fixture = MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Mask Non-Asset Icons')), + body: createIconFixture(), + ), + ); + await snapshotTest(tester, recorder, fixture); + }); + + testWidgets('override mask all icons', (tester) async { + final fixture = MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Override Mask All Icons')), + body: SessionReplayPrivacy( + imagePrivacyLevel: ImagePrivacyLevel.maskAll, + child: createIconFixture(), + ), + ), + ); + await snapshotTest(tester, recorder, fixture); + }); +} diff --git a/packages/datadog_session_replay/example/lib/screens/images_screen.dart b/packages/datadog_session_replay/example/lib/screens/images_screen.dart index a0fa44fe..79b22eb8 100644 --- a/packages/datadog_session_replay/example/lib/screens/images_screen.dart +++ b/packages/datadog_session_replay/example/lib/screens/images_screen.dart @@ -19,6 +19,17 @@ class _ImagesScreenState extends State { body: SingleChildScrollView( child: Column( children: [ + Padding( + padding: const EdgeInsets.all(8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: const [ + Icon(Icons.favorite, color: Colors.red, size: 32), + Icon(Icons.home, color: Colors.blue, size: 32), + Icon(Icons.settings, size: 32), + ], + ), + ), Image.asset('assets/dd_logo_v_rgb.png'), Image.network( 'https://placehold.co/200x200.png', diff --git a/packages/datadog_session_replay/lib/datadog_session_replay.dart b/packages/datadog_session_replay/lib/datadog_session_replay.dart index 44b1216a..06c2dda6 100644 --- a/packages/datadog_session_replay/lib/datadog_session_replay.dart +++ b/packages/datadog_session_replay/lib/datadog_session_replay.dart @@ -153,6 +153,15 @@ class DatadogSessionReplayConfiguration { /// use [FontFamilyStrategy.smart] for web-friendly normalization. FontFamilyTransformConfig fontFamilyTransform; + /// Logical size (dp) at which icon glyphs are rasterized for session replay. + /// + /// The replay player scales the bitmap to the widget's on-screen bounds. + /// Smaller values reduce CPU/GPU work and memory; larger values improve + /// sharpness when icons are displayed big. + /// + /// Defaults to `20`. + double iconRasterLogicalSize; + DatadogSessionReplayConfiguration({ required this.replaySampleRate, this.textAndInputPrivacyLevel = TextAndInputPrivacyLevel.maskAll, @@ -161,6 +170,7 @@ class DatadogSessionReplayConfiguration { this.customEndpoint, this.startRecordingImmediately = true, this.fontFamilyTransform = const FontFamilyTransformConfig(), + this.iconRasterLogicalSize = 20.0, }); } diff --git a/packages/datadog_session_replay/lib/src/capture/element_recorders/icon_recorder.dart b/packages/datadog_session_replay/lib/src/capture/element_recorders/icon_recorder.dart new file mode 100644 index 00000000..8644310a --- /dev/null +++ b/packages/datadog_session_replay/lib/src/capture/element_recorders/icon_recorder.dart @@ -0,0 +1,279 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2025-Present Datadog, Inc. + +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; + +import '../../../datadog_session_replay.dart'; +import '../../datadog_session_replay_platform_interface.dart'; +import '../capture_node.dart'; +import '../recorder.dart'; +import '../view_tree_snapshot.dart'; +import 'image_recorder.dart'; + +// Match [image_recorder.dart] placeholder caption threshold. +const int _iconLabelMinWidth = 125; + +@immutable +class _IconCacheKey { + final int codePoint; + final String fontFamily; + final String? fontPackage; + final int colorSignature; + + const _IconCacheKey({ + required this.codePoint, + required this.fontFamily, + required this.fontPackage, + required this.colorSignature, + }); + + @override + bool operator ==(Object other) { + return other is _IconCacheKey && + other.codePoint == codePoint && + other.fontFamily == fontFamily && + other.fontPackage == fontPackage && + other.colorSignature == colorSignature; + } + + @override + int get hashCode => Object.hash( + codePoint, + fontFamily, + fontPackage, + colorSignature, + ); +} + +class IconRecorder implements ElementRecorder { + final KeyGenerator keyGenerator; + final double iconRasterLogicalSize; + + final Map<_IconCacheKey, int> _resourceKeyCache = {}; + final Map<_IconCacheKey, Future> _inFlight = {}; + + IconRecorder( + this.keyGenerator, { + this.iconRasterLogicalSize = 20.0, + }); + + @override + bool accepts(Widget widget) => widget is Icon; + + @override + CaptureNodeSemantics? captureSemantics( + Element element, + CapturedViewAttributes attributes, + TreeCapturePrivacy capturePrivacy, + ) { + final widget = element.widget; + if (widget is! Icon) return null; + + final elementId = keyGenerator.keyForElement(element); + + if (capturePrivacy.imagePrivacyLevel == ImagePrivacyLevel.maskAll) { + return _iconPlaceholder(elementId, attributes); + } + + final iconData = widget.icon; + if (iconData == null) { + return _iconPlaceholder(elementId, attributes); + } + final theme = IconTheme.of(element); + final color = widget.color ?? theme.color ?? const Color(0xFF000000); + final fontFamily = iconData.fontFamily; + if (fontFamily == null || fontFamily.isEmpty) { + return _iconPlaceholder(elementId, attributes); + } + + final dpr = _devicePixelRatio(element); + final rasterLogicalSize = iconRasterLogicalSize; + final widthPx = math.max(1, (rasterLogicalSize * dpr).ceil()); + final heightPx = widthPx; + + final cacheKey = _IconCacheKey( + codePoint: iconData.codePoint, + fontFamily: fontFamily, + fontPackage: iconData.fontPackage, + colorSignature: color.hashCode, + ); + + final cachedKey = _resourceKeyCache[cacheKey]; + if (cachedKey != null) { + return SpecificElement( + subtreeStrategy: CaptureNodeSubtreeStrategy.ignore, + nodes: [ + ResourceImageNode( + attributes, + wireframeId: elementId, + resourceKey: cachedKey, + ), + ], + ); + } + + final textDirection = widget.textDirection ?? + Directionality.maybeOf(element) ?? + TextDirection.ltr; + + return AdditionalProcessingElement( + subtreeStrategy: CaptureNodeSubtreeStrategy.ignore, + process: () => _ensureRasterized( + elementId: elementId, + attributes: attributes, + cacheKey: cacheKey, + iconData: iconData, + color: color, + widthPx: widthPx, + heightPx: heightPx, + textDirection: textDirection, + ), + ); + } + + Future _ensureRasterized({ + required int elementId, + required CapturedViewAttributes attributes, + required _IconCacheKey cacheKey, + required IconData iconData, + required Color color, + required int widthPx, + required int heightPx, + required TextDirection textDirection, + }) async { + final cached = _resourceKeyCache[cacheKey]; + if (cached != null) { + return SpecificElement( + subtreeStrategy: CaptureNodeSubtreeStrategy.ignore, + nodes: [ + ResourceImageNode( + attributes, + wireframeId: elementId, + resourceKey: cached, + ), + ], + ); + } + + final future = _inFlight.putIfAbsent( + cacheKey, + () => _performRaster( + cacheKey: cacheKey, + iconData: iconData, + color: color, + widthPx: widthPx, + heightPx: heightPx, + textDirection: textDirection, + ).whenComplete(() { + _inFlight.remove(cacheKey); + }), + ); + + final resourceKey = await future; + if (resourceKey == null) { + return _iconPlaceholder(elementId, attributes); + } + return SpecificElement( + subtreeStrategy: CaptureNodeSubtreeStrategy.ignore, + nodes: [ + ResourceImageNode( + attributes, + wireframeId: elementId, + resourceKey: resourceKey, + ), + ], + ); + } + + Future _performRaster({ + required _IconCacheKey cacheKey, + required IconData iconData, + required Color color, + required int widthPx, + required int heightPx, + required TextDirection textDirection, + }) async { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + + final fontSize = widthPx.toDouble(); + + final textPainter = TextPainter( + text: TextSpan( + text: String.fromCharCode(iconData.codePoint), + style: TextStyle( + fontFamily: iconData.fontFamily, + package: iconData.fontPackage, + fontSize: fontSize, + height: 1.0, + color: color, + inherit: false, + ), + ), + textDirection: textDirection, + textScaler: TextScaler.noScaling, + )..layout(); + + textPainter.paint(canvas, Offset.zero); + textPainter.dispose(); + + final picture = recorder.endRecording(); + final ui.Image raster; + try { + raster = await picture.toImage(widthPx, heightPx); + } catch (_) { + return null; + } finally { + picture.dispose(); + } + + try { + final byteData = + await raster.toByteData(format: ui.ImageByteFormat.rawRgba); + if (byteData == null) return null; + + final resourceKey = keyGenerator.keyForImage(raster); + await DatadogSessionReplayPlatform.instance.saveImageForProcessing( + resourceKey, + raster.width, + raster.height, + byteData, + ); + _resourceKeyCache[cacheKey] = resourceKey; + return resourceKey; + } finally { + raster.dispose(); + } + } + + static SpecificElement _iconPlaceholder( + int elementId, + CapturedViewAttributes attributes, + ) { + return SpecificElement( + subtreeStrategy: CaptureNodeSubtreeStrategy.ignore, + nodes: [ + PlaceholderNode( + attributes, + wireframeId: elementId, + caption: 'Icon', + minWidth: _iconLabelMinWidth, + ), + ], + ); + } + + static double _devicePixelRatio(Element element) { + final view = View.maybeOf(element); + if (view != null) { + return view.devicePixelRatio; + } + final views = WidgetsBinding.instance.platformDispatcher.views; + if (views.isEmpty) return 1.0; + return views.first.devicePixelRatio; + } +} diff --git a/packages/datadog_session_replay/lib/src/capture/recorder.dart b/packages/datadog_session_replay/lib/src/capture/recorder.dart index cc13d7ce..2b0a7b24 100644 --- a/packages/datadog_session_replay/lib/src/capture/recorder.dart +++ b/packages/datadog_session_replay/lib/src/capture/recorder.dart @@ -19,6 +19,7 @@ import 'element_recorders/cupertino_widgets/cupertino_radio_recorder.dart'; import 'element_recorders/cupertino_widgets/cupertino_switch_recorder.dart'; import 'element_recorders/custom_paint_recorder.dart'; import 'element_recorders/editable_text_recorder.dart'; +import 'element_recorders/icon_recorder.dart'; import 'element_recorders/image_recorder.dart'; import 'element_recorders/material_widgets/checkbox_recorder.dart'; import 'element_recorders/material_widgets/radio_recorder.dart'; @@ -151,11 +152,13 @@ class SessionReplayRecorder { DatadogTimeProvider timeProvider = const DefaultTimeProvider(), required TreeCapturePrivacy defaultCapturePrivacy, required TouchPrivacyLevel touchPrivacyLevel, + double iconRasterLogicalSize = 20.0, }) : this._( KeyGenerator(), timeProvider, defaultCapturePrivacy, touchPrivacyLevel, + iconRasterLogicalSize, ); SessionReplayRecorder._( @@ -163,10 +166,15 @@ class SessionReplayRecorder { this._timeProvider, this._defaultTreeCapturePrivacy, this._touchPrivacyLevel, + double iconRasterLogicalSize, ) { _elementRecorders.addAll([ ContainerRecorder(keyGenerator), TextElementRecorder(keyGenerator), + IconRecorder( + keyGenerator, + iconRasterLogicalSize: iconRasterLogicalSize, + ), EditableTextRecorder(keyGenerator), InputDecoratorRecorder(keyGenerator), ImageRecorder(keyGenerator), @@ -257,20 +265,29 @@ class SessionReplayRecorder { final addedProcessingTimelineTask = TimelineTask() ..start('Datadog SR Capture Processing'); - // Process anything that needs additional processing + // Process anything that needs additional processing (parallelize so + // multiple icons/images don't serialize their async work). final nodes = []; - for (var s in capturedSemantics) { - try { + final resolved = await Future.wait( + capturedSemantics.map((s) async { if (s is AdditionalProcessingElement) { - s = await s.process(); + try { + return await s.process(); + } catch (e, st) { + DatadogSessionReplayPlatform.instance.telemetryError( + 'Exception during session replay capture: $e', + e.runtimeType.toString(), + st.toString(), + ); + return null; + } } + return s; + }), + ); + for (final s in resolved) { + if (s != null) { nodes.addAll(s.nodes); - } catch (e, st) { - DatadogSessionReplayPlatform.instance.telemetryError( - 'Exception during session replay capture: $e', - e.runtimeType.toString(), - st.toString(), - ); } } addedProcessingTimelineTask.finish(); diff --git a/packages/datadog_session_replay/lib/src/datadog_session_replay.dart b/packages/datadog_session_replay/lib/src/datadog_session_replay.dart index 15bb12c5..54b577cb 100644 --- a/packages/datadog_session_replay/lib/src/datadog_session_replay.dart +++ b/packages/datadog_session_replay/lib/src/datadog_session_replay.dart @@ -68,6 +68,7 @@ class DatadogSessionReplay { imagePrivacyLevel: _configuration.imagePrivacyLevel, ), touchPrivacyLevel: _configuration.touchPrivacyLevel, + iconRasterLogicalSize: _configuration.iconRasterLogicalSize, ); void addElement(Key key, Element e) { diff --git a/packages/datadog_session_replay/test/capture/icon_recorder_test.dart b/packages/datadog_session_replay/test/capture/icon_recorder_test.dart new file mode 100644 index 00000000..c0ff478c --- /dev/null +++ b/packages/datadog_session_replay/test/capture/icon_recorder_test.dart @@ -0,0 +1,354 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2025-Present Datadog, Inc. + +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:datadog_common_test/datadog_common_test.dart'; +import 'package:datadog_session_replay/datadog_session_replay.dart'; +import 'package:datadog_session_replay/src/capture/capture_node.dart' + show CapturedViewAttributes; +import 'package:datadog_session_replay/src/capture/element_recorders/common_nodes.dart'; +import 'package:datadog_session_replay/src/capture/element_recorders/icon_recorder.dart'; +import 'package:datadog_session_replay/src/capture/element_recorders/image_recorder.dart'; +import 'package:datadog_session_replay/src/capture/element_recorders/text_recorder.dart'; +import 'package:datadog_session_replay/src/capture/recorder.dart'; +import 'package:datadog_session_replay/src/datadog_session_replay_platform_interface.dart'; +import 'package:datadog_session_replay/src/rum_context.dart'; +import 'package:datadog_session_replay/src/sr_data_models.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +import 'simple_test_capture.dart'; + +class MockDatadogSessionReplayPlatform extends Mock + with MockPlatformInterfaceMixin + implements DatadogSessionReplayPlatform {} + +void main() { + late MockDatadogSessionReplayPlatform platform; + late SessionReplayRecorder recorder; + late RUMContext context; + + setUp(() { + final keyGenerator = KeyGenerator(); + platform = MockDatadogSessionReplayPlatform(); + DatadogSessionReplayPlatform.instance = platform; + recorder = SessionReplayRecorder.withCustomRecorders( + [ + IconRecorder(keyGenerator), + TextElementRecorder(keyGenerator), + ], + defaultCapturePrivacy: TreeCapturePrivacy( + textAndInputPrivacyLevel: TextAndInputPrivacyLevel.maskSensitiveInputs, + imagePrivacyLevel: ImagePrivacyLevel.maskNone, + ), + touchPrivacyLevel: TouchPrivacyLevel.show, + ); + + registerFallbackValue( + CapturedViewAttributes(paintBounds: Rect.zero, scaleX: 1.0, scaleY: 1.0), + ); + registerFallbackValue(ByteData(1)); + + context = RUMContext( + applicationId: randomString(), + sessionId: randomString(), + ); + recorder.updateContext(context); + + when(() => platform.saveImageForProcessing(any(), any(), any(), any())) + .thenAnswer((_) => Future.value()); + when(() => platform.resourceIdForKey(any())).thenReturn('rid'); + }); + + testWidgets( + 'captures Icon as ResourceImageNode and saves bytes once for two identical icons', + (tester) async { + const iconSize = 32.0; + + final tree = MaterialApp( + home: SimpleTestCapture( + key: const Key('key'), + recorder: recorder, + child: const Stack( + children: [ + Positioned( + top: 10, + left: 10, + width: iconSize, + height: iconSize, + child: Icon(Icons.favorite, color: Colors.red, size: iconSize), + ), + Positioned( + top: 10, + left: 70, + width: iconSize, + height: iconSize, + child: Icon(Icons.favorite, color: Colors.red, size: iconSize), + ), + ], + ), + ), + ); + await tester.pumpWidget(tree); + + CaptureResult? capture1; + await tester.runAsync(() async { + capture1 = await recorder.performCapture(); + }); + expect(capture1, isNotNull); + expect(capture1!.viewTreeSnapshot.nodes.length, 2); + expect( + capture1!.viewTreeSnapshot.nodes.every((n) => n is ResourceImageNode), + isTrue); + final key1 = + (capture1!.viewTreeSnapshot.nodes[0] as ResourceImageNode).resourceKey; + final key2 = + (capture1!.viewTreeSnapshot.nodes[1] as ResourceImageNode).resourceKey; + expect(key1, key2); + + final expectedPx = math.max( + 1, + (20.0 * tester.view.devicePixelRatio).ceil(), + ); + + CaptureResult? capture2; + await tester.runAsync(() async { + capture2 = await recorder.performCapture(); + }); + expect(capture2, isNotNull); + expect(capture2!.viewTreeSnapshot.nodes.length, 2); + expect( + (capture2!.viewTreeSnapshot.nodes[0] as ResourceImageNode).resourceKey, + key1); + expect( + (capture2!.viewTreeSnapshot.nodes[1] as ResourceImageNode).resourceKey, + key1); + + // One rasterization for two identical icons; second capture is cache-only. + verify( + () => platform.saveImageForProcessing( + key1, + expectedPx, + expectedPx, + any(), + ), + ).called(1); + }); + + testWidgets('different icon color triggers second saveImageForProcessing', + (tester) async { + const iconSize = 28.0; + final tree = MaterialApp( + home: SimpleTestCapture( + key: const Key('key'), + recorder: recorder, + child: Stack( + children: [ + Positioned( + top: 10, + left: 10, + width: iconSize, + height: iconSize, + child: + const Icon(Icons.star, color: Colors.amber, size: iconSize), + ), + Positioned( + top: 10, + left: 60, + width: iconSize, + height: iconSize, + child: + const Icon(Icons.star, color: Colors.green, size: iconSize), + ), + ], + ), + ), + ); + await tester.pumpWidget(tree); + + await tester.runAsync(() async { + await recorder.performCapture(); + }); + + verify( + () => platform.saveImageForProcessing(any(), any(), any(), any()), + ).called(2); + }); + + testWidgets( + 'same icon at two display sizes saves image once (canonical raster cache)', + (tester) async { + const smallSize = 20.0; + const largeSize = 32.0; + final tree = MaterialApp( + home: SimpleTestCapture( + key: const Key('key'), + recorder: recorder, + child: Stack( + children: [ + Positioned( + top: 10, + left: 10, + width: largeSize, + height: largeSize, + child: const Icon(Icons.favorite, + color: Colors.red, size: largeSize), + ), + Positioned( + top: 10, + left: 60, + width: smallSize, + height: smallSize, + child: const Icon(Icons.favorite, + color: Colors.red, size: smallSize), + ), + ], + ), + ), + ); + await tester.pumpWidget(tree); + + CaptureResult? capture; + await tester.runAsync(() async { + capture = await recorder.performCapture(); + }); + expect(capture, isNotNull); + expect(capture!.viewTreeSnapshot.nodes.length, 2); + expect(capture!.viewTreeSnapshot.nodes.every((n) => n is ResourceImageNode), + isTrue); + final key1 = + (capture!.viewTreeSnapshot.nodes[0] as ResourceImageNode).resourceKey; + final key2 = + (capture!.viewTreeSnapshot.nodes[1] as ResourceImageNode).resourceKey; + expect(key1, key2); + + final expectedPx = math.max( + 1, + (20.0 * tester.view.devicePixelRatio).ceil(), + ); + verify( + () => platform.saveImageForProcessing( + key1, + expectedPx, + expectedPx, + any(), + ), + ).called(1); + }); + + testWidgets('maskAll shows Icon placeholder and does not save image', + (tester) async { + recorder.defaultTreeCapturePrivacy = TreeCapturePrivacy( + textAndInputPrivacyLevel: TextAndInputPrivacyLevel.maskSensitiveInputs, + imagePrivacyLevel: ImagePrivacyLevel.maskAll, + ); + + final tree = MaterialApp( + home: SimpleTestCapture( + key: const Key('key'), + recorder: recorder, + child: Stack( + children: [ + Positioned( + top: 20, + left: 20, + width: 40, + height: 40, + child: const Icon(Icons.info, size: 40), + ), + ], + ), + ), + ); + await tester.pumpWidget(tree); + + CaptureResult? capture; + await tester.runAsync(() async { + capture = await recorder.performCapture(); + }); + + expect(capture, isNotNull); + final wf = capture!.viewTreeSnapshot.nodes.single.buildWireframes().single; + expect(wf, isA()); + // Caption only when width >= PlaceholderNode minWidth (125); 40px is intentionally narrow. + expect((wf as SRPlaceholderWireframe).label, isNull); + verifyNever( + () => platform.saveImageForProcessing(any(), any(), any(), any())); + }); + + testWidgets('maskNonAssetsOnly still captures Material Icon', (tester) async { + recorder.defaultTreeCapturePrivacy = TreeCapturePrivacy( + textAndInputPrivacyLevel: TextAndInputPrivacyLevel.maskAll, + imagePrivacyLevel: ImagePrivacyLevel.maskNonAssetsOnly, + ); + + final tree = MaterialApp( + home: SimpleTestCapture( + key: const Key('key'), + recorder: recorder, + child: const Stack( + children: [ + Positioned( + top: 12, + left: 12, + width: 24, + height: 24, + child: Icon(Icons.check_circle, size: 24), + ), + ], + ), + ), + ); + await tester.pumpWidget(tree); + + CaptureResult? capture; + await tester.runAsync(() async { + capture = await recorder.performCapture(); + }); + + expect(capture!.viewTreeSnapshot.nodes.single, isA()); + verify(() => platform.saveImageForProcessing(any(), any(), any(), any())) + .called(1); + }); + + testWidgets( + 'Icon subtree is ignored so inner RichText is not captured as text', + (tester) async { + final tree = MaterialApp( + home: SimpleTestCapture( + key: const Key('key'), + recorder: recorder, + child: const Stack( + children: [ + Positioned( + top: 8, + left: 8, + width: 48, + height: 48, + child: Icon(Icons.ac_unit, size: 48), + ), + ], + ), + ), + ); + await tester.pumpWidget(tree); + + CaptureResult? capture; + await tester.runAsync(() async { + capture = await recorder.performCapture(); + }); + + expect(capture, isNotNull); + expect(capture!.viewTreeSnapshot.nodes.length, 1); + expect(capture!.viewTreeSnapshot.nodes.single, isA()); + expect( + capture!.viewTreeSnapshot.nodes.whereType(), + isEmpty, + ); + }); +} diff --git a/packages/datadog_session_replay/test/datadog_session_replay_test.dart b/packages/datadog_session_replay/test/datadog_session_replay_test.dart index e2080415..7afedbf3 100644 --- a/packages/datadog_session_replay/test/datadog_session_replay_test.dart +++ b/packages/datadog_session_replay/test/datadog_session_replay_test.dart @@ -32,6 +32,12 @@ void main() { expect(c.fontFamilyTransform.rules, isEmpty); }); + test('DatadogSessionReplayConfiguration default iconRasterLogicalSize is 20', + () { + final c = DatadogSessionReplayConfiguration(replaySampleRate: 100.0); + expect(c.iconRasterLogicalSize, 20.0); + }); + group('DatadogSessionReplay', () { final mockPlatform = MockDatadogSessionReplayPlatform(); final mockInternalLogger = MockInternalLogger();