Skip to content

Commit 7916231

Browse files
committed
feat(flutter): implement layout snapshots
1 parent 362bb35 commit 7916231

File tree

97 files changed

+5110
-1026
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

97 files changed

+5110
-1026
lines changed

android/measure/src/main/java/sh/measure/android/events/AttachmentType.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package sh.measure.android.events
33
internal object AttachmentType {
44
const val SCREENSHOT = "screenshot"
55
const val LAYOUT_SNAPSHOT = "layout_snapshot"
6+
const val LAYOUT_SNAPSHOT_JSON = "layout_snapshot_json"
67

7-
val VALID_TYPES = listOf(SCREENSHOT, LAYOUT_SNAPSHOT)
8+
val VALID_TYPES = listOf(SCREENSHOT, LAYOUT_SNAPSHOT, LAYOUT_SNAPSHOT_JSON)
89
}

android/measure/src/main/java/sh/measure/android/events/InternalSignalCollector.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ internal class InternalSignalCollector(
117117
type = eventType,
118118
attributes = attributes,
119119
userDefinedAttributes = userDefinedAttrs,
120+
attachments = eventAttachments,
120121
userTriggered = userTriggered,
121122
)
122123
}
@@ -129,6 +130,7 @@ internal class InternalSignalCollector(
129130
type = eventType,
130131
attributes = attributes,
131132
userDefinedAttributes = userDefinedAttrs,
133+
attachments = eventAttachments,
132134
userTriggered = userTriggered,
133135
)
134136
}

android/measure/src/main/java/sh/measure/android/exporter/AttachmentExporter.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ internal class DefaultAttachmentExporter(
134134
val response = httpClient.uploadFile(
135135
url = attachment.url,
136136
contentType = attachment.contentType,
137+
contentEncoding = attachment.contentEncoding,
137138
headers = attachment.headers,
138139
fileSize = file.length(),
139140
) { sink ->

android/measure/src/main/java/sh/measure/android/exporter/AttachmentPacket.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ internal data class AttachmentPacket(
1717
) {
1818
val contentType: String = when (type) {
1919
AttachmentType.LAYOUT_SNAPSHOT -> "image/svg+xml"
20+
AttachmentType.LAYOUT_SNAPSHOT_JSON -> "application/json"
2021
AttachmentType.SCREENSHOT -> {
2122
if (name.endsWith(".webp")) {
2223
"image/webp"
@@ -27,4 +28,9 @@ internal data class AttachmentPacket(
2728

2829
else -> "application/octet-stream"
2930
}
31+
32+
val contentEncoding: String? = when (type) {
33+
AttachmentType.LAYOUT_SNAPSHOT_JSON -> "gzip"
34+
else -> null
35+
}
3036
}

android/measure/src/main/java/sh/measure/android/exporter/HttpUrlConnectionClient.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ internal interface HttpClient {
2222
fun uploadFile(
2323
url: String,
2424
contentType: String,
25+
contentEncoding: String?,
2526
headers: Map<String, String>,
2627
fileSize: Long,
2728
fileWriter: (BufferedSink) -> Unit,
@@ -89,13 +90,15 @@ internal class HttpUrlConnectionClient(private val logger: Logger) : HttpClient
8990
override fun uploadFile(
9091
url: String,
9192
contentType: String,
93+
contentEncoding: String?,
9294
headers: Map<String, String>,
9395
fileSize: Long,
9496
fileWriter: (BufferedSink) -> Unit,
9597
): HttpResponse {
9698
var connection: HttpURLConnection? = null
9799
return try {
98-
connection = createFileUploadConnection(url, contentType, headers, fileSize)
100+
connection =
101+
createFileUploadConnection(url, contentType, contentEncoding, headers, fileSize)
99102
logger.log(LogLevel.Debug, "Uploading file to: $url")
100103
connection.outputStream.sink().buffer().use { sink ->
101104
fileWriter(sink)
@@ -144,6 +147,7 @@ internal class HttpUrlConnectionClient(private val logger: Logger) : HttpClient
144147
private fun createFileUploadConnection(
145148
url: String,
146149
contentType: String,
150+
contentEncoding: String?,
147151
headers: Map<String, String>,
148152
fileSize: Long,
149153
): HttpURLConnection = (URL(url).openConnection() as HttpURLConnection).apply {
@@ -156,6 +160,7 @@ internal class HttpUrlConnectionClient(private val logger: Logger) : HttpClient
156160
setChunkedStreamingMode(0)
157161
}
158162
setRequestProperty("Content-Type", contentType)
163+
contentEncoding?.let { setRequestProperty("Content-Encoding", contentEncoding) }
159164
headers.forEach { (key, value) ->
160165
setRequestProperty(key, value)
161166
}

android/measure/src/test/java/sh/measure/android/exporter/HttpUrlConnectionClientTest.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,7 @@ class HttpUrlConnectionClientTest {
306306
contentType = "image/png",
307307
fileSize = 12L,
308308
headers = mapOf(),
309+
contentEncoding = null,
309310
) { sink ->
310311
sink.writeUtf8("file-content")
311312
}
@@ -326,6 +327,7 @@ class HttpUrlConnectionClientTest {
326327
contentType = "text/plain",
327328
fileSize = 0L,
328329
headers = mapOf(),
330+
contentEncoding = null,
329331
) { sink ->
330332
sink.writeUtf8("test")
331333
}
@@ -342,6 +344,7 @@ class HttpUrlConnectionClientTest {
342344
contentType = "image/jpeg",
343345
fileSize = 10L,
344346
headers = mapOf(),
347+
contentEncoding = null,
345348
) { sink ->
346349
sink.writeUtf8("test-image")
347350
}
@@ -359,6 +362,7 @@ class HttpUrlConnectionClientTest {
359362
contentType = "application/pdf",
360363
fileSize = 4L,
361364
headers = mapOf(),
365+
contentEncoding = null,
362366
) { sink ->
363367
sink.writeUtf8("test")
364368
}

docs/features/feature-gesture-tracking.md

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22

33
* [**Overview**](#overview)
44
* [**Layout Snapshots**](#layout-snapshots)
5+
* [**Flutter**](#flutter)
56
* [**How it works**](#how-it-works)
67
* [**Android**](#android)
78
* [**iOS**](#ios)
8-
* [**Flutter**](#flutter)
9+
* [**Flutter**](#flutter-1)
910
* [**Benchmark Results**](#benchmark-results)
1011
* [**Android**](#android-1)
1112
* [**iOS**](#ios-1)
@@ -21,8 +22,8 @@ and the state of the UI at that moment.
2122
## Layout Snapshots
2223

2324
Layout snapshots provide a lightweight way to capture the structure of your UI at key user interactions.
24-
They are automatically collected during click events (with throttling) and store the layout hierarchy as SVG rather than
25-
full screenshots.
25+
They are automatically collected during click events (with throttling) and store the layout hierarchy as a wireframe
26+
rather than full screenshots.
2627
This approach gives valuable context about the UI state during user interactions while being significantly more
2728
efficient to capture and store than traditional screenshots.
2829

@@ -32,7 +33,46 @@ efficient to capture and store than traditional screenshots.
3233

3334
Layout snapshots are captured along with every gesture click event with throttling (750ms between consecutive
3435
snapshots). This ensures that you get a representative snapshot of the UI without overwhelming the system with too many
35-
images. The snapshots are stored in a lightweight SVG format, which is efficient for both storage and rendering.
36+
images. The snapshots are stored in a compressed lightweight format, which is efficient for both storage and rendering.
37+
38+
### Flutter
39+
40+
Layout snapshots are collected by default for Flutter applications. However, only the widgets shown in the table are
41+
used to build the layout snapshot. To make the layout snapshot more useful, you can use a build time script to also
42+
add any other widgets that are used in your application.
43+
See [measure_build](../../flutter/packages/measure_build/README.md) package for more details.
44+
45+
| Default Widget Types |
46+
|-------------------------|
47+
| `FilledButton` |
48+
| `OutlinedButton` |
49+
| `TextButton` |
50+
| `ElevatedButton` |
51+
| `CupertinoButton` |
52+
| `ButtonStyleButton` |
53+
| `MaterialButton` |
54+
| `IconButton` |
55+
| `FloatingActionButton` |
56+
| `ListTile` |
57+
| `PopupMenuButton` |
58+
| `PopupMenuItem` |
59+
| `DropdownButton` |
60+
| `DropdownMenuItem` |
61+
| `ExpansionTile` |
62+
| `Card` |
63+
| `Scaffold` |
64+
| `CupertinoPageScaffold` |
65+
| `MaterialApp` |
66+
| `CupertinoApp` |
67+
| `Container` |
68+
| `Row` |
69+
| `Column` |
70+
| `ListView` |
71+
| `PageView` |
72+
| `SingleChildScrollView` |
73+
| `ScrollView` |
74+
| `Text` |
75+
| `RichText` |
3676

3777
## How it works
3878

@@ -76,7 +116,9 @@ for click, long click and scroll respectively.
76116

77117
> [!NOTE]
78118
>
79-
> Compose currently reports the target_id in the collected data using [testTag](https://developer.android.com/reference/kotlin/androidx/compose/ui/semantics/package-summary#(androidx.compose.ui.semantics.SemanticsPropertyReceiver).testTag()),
119+
> Compose currently reports the target_id in the collected data
120+
>
121+
using [testTag](https://developer.android.com/reference/kotlin/androidx/compose/ui/semantics/package-summary#(androidx.compose.ui.semantics.SemanticsPropertyReceiver).testTag()),
80122
> if it is set. While the `target` is always reported as `AndroidComposeView`.
81123
82124
#### Gesture target detection
@@ -128,6 +170,7 @@ from [Listener](https://api.flutter.dev/flutter/widgets/Listener-class.html) wid
128170
added to the root widget of the app using `MeasureWidget`.
129171

130172
It processes touch events to classify them into different gesture types:
173+
131174
- **Click**: A touch event that lasts for less than 500 ms.
132175
- **Long Click**: A touch event that lasts for more than 500 ms.
133176
- **Scroll**: A touch movement exceeding 20 pixels in any direction.
@@ -185,6 +228,19 @@ widgets, while for scrolls, it looks for scrollable widgets.
185228
| `PageView` |
186229
| `SingleChildScrollView` |
187230

231+
### Layout snapshots
232+
233+
Layout snapshots capture your app's UI structure by traversing the widget tree from the root widget. The SDK collects
234+
key information about each widget—including its type, position, size, and hierarchy—to build a lightweight
235+
representation of your UI.
236+
237+
The entire layout snapshot is generated in a single pass through the widget tree using the `visitChildElements` method.
238+
Since a typical Flutter screen can contain thousands of widgets, the snapshot is optimized to include only relevant
239+
widget types to maintain performance and clarity.
240+
241+
To include custom widgets or additional widget types in your snapshots, use
242+
the [measure_build](../../flutter/packages/measure_build/README.md) package to generate a comprehensive list of all
243+
widget types used in your app.
188244

189245
## Benchmark results
190246

@@ -204,6 +260,13 @@ TLDR;
204260
- You can find the benchmark tests
205261
in [GestureTargetFinderTests](../../ios/Tests/MeasureSDKTests/Gestures/GestureTargetFinderTests.swift).
206262

263+
### Flutter
264+
265+
- On average, it takes **10ms** to generate a layout snapshot and identify the clicked widget in a widget tree with a
266+
depth of **100** widgets.
267+
- The time to generate the layout snapshot increases linearly with the depth of the widget tree.
268+
- The benchmark tests can be found in [](../../flutter/example/integration_test/layout_snapshot_performance_test.dart).
269+
207270
## Data collected
208271

209272
Check out the data collected by Measure in

docs/sdk-integration-guide.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,10 @@ navigation events.
411411

412412
See [Network Monitoring](features/feature-network-monitoring.md) for instructions on how to track HTTP requests.
413413

414+
### Layout snapshots
415+
416+
Layout snapshots are collected by default along with cli
417+
414418

415419
## 3. Verify Installation
416420

flutter/example/android/app/src/main/AndroidManifest.xml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,10 @@
2525

2626
<meta-data
2727
android:name="sh.measure.android.API_KEY"
28-
android:value="msrsh_" />
28+
android:value="msrsh_8456989a9cd452c7a4864d37a1f3cf9b4f8a45395203c19cb5b6d8252c6970fd_95a8744f" />
2929

3030
<meta-data android:name="sh.measure.android.API_URL"
31-
android:value="http://localhost:8080" />
31+
android:value="https://staging-ingest.measure.sh" />
3232

3333
<meta-data
3434
android:name="flutterEmbedding"
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
package sh.measure.android.flutter
22

33
import android.app.Application
4+
import sh.measure.android.Measure
5+
import sh.measure.android.config.MeasureConfig
46

57
class SampleApp : Application() {
68
override fun onCreate() {
79
super.onCreate()
10+
11+
Measure.init(this, MeasureConfig(enableLogging = true))
812
}
913
}

0 commit comments

Comments
 (0)