Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package sh.measure.android.events
internal object AttachmentType {
const val SCREENSHOT = "screenshot"
const val LAYOUT_SNAPSHOT = "layout_snapshot"
const val LAYOUT_SNAPSHOT_JSON = "layout_snapshot_json"

val VALID_TYPES = listOf(SCREENSHOT, LAYOUT_SNAPSHOT)
val VALID_TYPES = listOf(SCREENSHOT, LAYOUT_SNAPSHOT, LAYOUT_SNAPSHOT_JSON)
}
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ internal class InternalSignalCollector(
type = eventType,
attributes = attributes,
userDefinedAttributes = userDefinedAttrs,
attachments = eventAttachments,
userTriggered = userTriggered,
)
}
Expand All @@ -129,6 +130,7 @@ internal class InternalSignalCollector(
type = eventType,
attributes = attributes,
userDefinedAttributes = userDefinedAttrs,
attachments = eventAttachments,
userTriggered = userTriggered,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ internal class DefaultAttachmentExporter(
val response = httpClient.uploadFile(
url = attachment.url,
contentType = attachment.contentType,
contentEncoding = attachment.contentEncoding,
headers = attachment.headers,
fileSize = file.length(),
) { sink ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ internal data class AttachmentPacket(
) {
val contentType: String = when (type) {
AttachmentType.LAYOUT_SNAPSHOT -> "image/svg+xml"
AttachmentType.LAYOUT_SNAPSHOT_JSON -> "application/json"
AttachmentType.SCREENSHOT -> {
if (name.endsWith(".webp")) {
"image/webp"
Expand All @@ -27,4 +28,9 @@ internal data class AttachmentPacket(

else -> "application/octet-stream"
}

val contentEncoding: String? = when (type) {
AttachmentType.LAYOUT_SNAPSHOT_JSON -> "gzip"
else -> null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ internal interface HttpClient {
fun uploadFile(
url: String,
contentType: String,
contentEncoding: String?,
headers: Map<String, String>,
fileSize: Long,
fileWriter: (BufferedSink) -> Unit,
Expand Down Expand Up @@ -89,13 +90,15 @@ internal class HttpUrlConnectionClient(private val logger: Logger) : HttpClient
override fun uploadFile(
url: String,
contentType: String,
contentEncoding: String?,
headers: Map<String, String>,
fileSize: Long,
fileWriter: (BufferedSink) -> Unit,
): HttpResponse {
var connection: HttpURLConnection? = null
return try {
connection = createFileUploadConnection(url, contentType, headers, fileSize)
connection =
createFileUploadConnection(url, contentType, contentEncoding, headers, fileSize)
logger.log(LogLevel.Debug, "Uploading file to: $url")
connection.outputStream.sink().buffer().use { sink ->
fileWriter(sink)
Expand Down Expand Up @@ -144,6 +147,7 @@ internal class HttpUrlConnectionClient(private val logger: Logger) : HttpClient
private fun createFileUploadConnection(
url: String,
contentType: String,
contentEncoding: String?,
headers: Map<String, String>,
fileSize: Long,
): HttpURLConnection = (URL(url).openConnection() as HttpURLConnection).apply {
Expand All @@ -156,6 +160,7 @@ internal class HttpUrlConnectionClient(private val logger: Logger) : HttpClient
setChunkedStreamingMode(0)
}
setRequestProperty("Content-Type", contentType)
contentEncoding?.let { setRequestProperty("Content-Encoding", contentEncoding) }
headers.forEach { (key, value) ->
setRequestProperty(key, value)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ class HttpUrlConnectionClientTest {
contentType = "image/png",
fileSize = 12L,
headers = mapOf(),
contentEncoding = null,
) { sink ->
sink.writeUtf8("file-content")
}
Expand All @@ -326,6 +327,7 @@ class HttpUrlConnectionClientTest {
contentType = "text/plain",
fileSize = 0L,
headers = mapOf(),
contentEncoding = null,
) { sink ->
sink.writeUtf8("test")
}
Expand All @@ -342,6 +344,7 @@ class HttpUrlConnectionClientTest {
contentType = "image/jpeg",
fileSize = 10L,
headers = mapOf(),
contentEncoding = null,
) { sink ->
sink.writeUtf8("test-image")
}
Expand All @@ -359,6 +362,7 @@ class HttpUrlConnectionClientTest {
contentType = "application/pdf",
fileSize = 4L,
headers = mapOf(),
contentEncoding = null,
) { sink ->
sink.writeUtf8("test")
}
Expand Down
73 changes: 68 additions & 5 deletions docs/features/feature-gesture-tracking.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

* [**Overview**](#overview)
* [**Layout Snapshots**](#layout-snapshots)
* [**Flutter**](#flutter)
* [**How it works**](#how-it-works)
* [**Android**](#android)
* [**iOS**](#ios)
* [**Flutter**](#flutter)
* [**Flutter**](#flutter-1)
* [**Benchmark Results**](#benchmark-results)
* [**Android**](#android-1)
* [**iOS**](#ios-1)
Expand All @@ -21,8 +22,8 @@ and the state of the UI at that moment.
## Layout Snapshots

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

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

Layout snapshots are captured along with every gesture click event with throttling (750ms between consecutive
snapshots). This ensures that you get a representative snapshot of the UI without overwhelming the system with too many
images. The snapshots are stored in a lightweight SVG format, which is efficient for both storage and rendering.
images. The snapshots are stored in a compressed lightweight format, which is efficient for both storage and rendering.

### Flutter

Layout snapshots are collected by default for Flutter applications. However, only the widgets shown in the table are
used to build the layout snapshot. To make the layout snapshot more useful, you can use a build time script to also
add any other widgets that are used in your application.
See [measure_build](../../flutter/packages/measure_build/README.md) package for more details.

| Default Widget Types |
|-------------------------|
| `FilledButton` |
| `OutlinedButton` |
| `TextButton` |
| `ElevatedButton` |
| `CupertinoButton` |
| `ButtonStyleButton` |
| `MaterialButton` |
| `IconButton` |
| `FloatingActionButton` |
| `ListTile` |
| `PopupMenuButton` |
| `PopupMenuItem` |
| `DropdownButton` |
| `DropdownMenuItem` |
| `ExpansionTile` |
| `Card` |
| `Scaffold` |
| `CupertinoPageScaffold` |
| `MaterialApp` |
| `CupertinoApp` |
| `Container` |
| `Row` |
| `Column` |
| `ListView` |
| `PageView` |
| `SingleChildScrollView` |
| `ScrollView` |
| `Text` |
| `RichText` |

## How it works

Expand Down Expand Up @@ -76,7 +116,9 @@ for click, long click and scroll respectively.

> [!NOTE]
>
> 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()),
> 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()),
> if it is set. While the `target` is always reported as `AndroidComposeView`.

#### Gesture target detection
Expand Down Expand Up @@ -128,6 +170,7 @@ from [Listener](https://api.flutter.dev/flutter/widgets/Listener-class.html) wid
added to the root widget of the app using `MeasureWidget`.

It processes touch events to classify them into different gesture types:

- **Click**: A touch event that lasts for less than 500 ms.
- **Long Click**: A touch event that lasts for more than 500 ms.
- **Scroll**: A touch movement exceeding 20 pixels in any direction.
Expand Down Expand Up @@ -185,6 +228,19 @@ widgets, while for scrolls, it looks for scrollable widgets.
| `PageView` |
| `SingleChildScrollView` |

### Layout snapshots

Layout snapshots capture your app's UI structure by traversing the widget tree from the root widget. The SDK collects
key information about each widget—including its type, position, size, and hierarchy—to build a lightweight
representation of your UI.

The entire layout snapshot is generated in a single pass through the widget tree using the `visitChildElements` method.
Since a typical Flutter screen can contain thousands of widgets, the snapshot is optimized to include only relevant
widget types to maintain performance and clarity.

To include custom widgets or additional widget types in your snapshots, use
the [measure_build](../../flutter/packages/measure_build/README.md) package to generate a comprehensive list of all
widget types used in your app.

## Benchmark results

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

### Flutter

- On average, it takes **10ms** to generate a layout snapshot and identify the clicked widget in a widget tree with a
depth of **100** widgets.
- The time to generate the layout snapshot increases linearly with the depth of the widget tree.
- The benchmark tests can be found in [](../../flutter/example/integration_test/layout_snapshot_performance_test.dart).

## Data collected

Check out the data collected by Measure in
Expand Down
4 changes: 4 additions & 0 deletions docs/sdk-integration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,10 @@ navigation events.

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

### Layout snapshots

Layout snapshots are collected by default along with cli


## 3. Verify Installation

Expand Down
4 changes: 2 additions & 2 deletions flutter/example/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@

<meta-data
android:name="sh.measure.android.API_KEY"
android:value="msrsh_" />
android:value="msrsh_8456989a9cd452c7a4864d37a1f3cf9b4f8a45395203c19cb5b6d8252c6970fd_95a8744f" />

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

<meta-data
android:name="flutterEmbedding"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
package sh.measure.android.flutter

import android.app.Application
import sh.measure.android.Measure
import sh.measure.android.config.MeasureConfig

class SampleApp : Application() {
override fun onCreate() {
super.onCreate()

Measure.init(this, MeasureConfig(enableLogging = true))
}
}
100 changes: 100 additions & 0 deletions flutter/example/integration_test/layout_snapshot_performance_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:measure_flutter/measure_flutter.dart';

void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();

testWidgets('Layout snapshot performance test', (WidgetTester tester) async {
await Measure.instance.init(
() {},
config: const MeasureConfig(
enableLogging: false,
autoStart: true,
trackScreenshotOnCrash: false,
),
clientInfo: ClientInfo(
apiKey: "test_key",
apiUrl: "https://test.measure.sh",
),
);

await tester.pumpWidget(
MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('Perf Test')),
body: _buildDeeplyNestedButton(50),
),
),
);

await tester.pumpAndSettle();

final buttonFinder = find.byKey(const ValueKey('test_button'));
expect(buttonFinder, findsOneWidget);

await binding.traceAction(
() async {
for (int i = 0; i < 30; i++) {
await tester.tap(buttonFinder);
await tester.pump();
await Future.delayed(const Duration(milliseconds: 500));
}
},
reportKey: 'layout_snapshot',
);
});
}

Widget _buildDeeplyNestedButton(int depth) {
return Stack(
children: [
_buildDeeplyNestedContainer(depth),
Center(
child: ElevatedButton(
key: const ValueKey('test_button'),
onPressed: () {},
child: const Text('Tap Me'),
),
),
],
);
}

Widget _buildDeeplyNestedContainer(int depth) {
if (depth <= 0) {
return const SizedBox.shrink();
}

// Alternate between different container types for variety
if (depth % 4 == 0) {
return Container(
padding: const EdgeInsets.all(1),
child: _buildDeeplyNestedContainer(depth - 1),
);
} else if (depth % 4 == 1) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: _buildDeeplyNestedContainer(depth - 1),
),
],
);
} else if (depth % 4 == 2) {
return Container(
margin: const EdgeInsets.all(1),
child: _buildDeeplyNestedContainer(depth - 1),
);
} else {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: _buildDeeplyNestedContainer(depth - 1),
),
],
);
}
}
Loading
Loading