Skip to content

Commit e511362

Browse files
Optimize stack traces for analytics (#8687)
1 parent 81c9bad commit e511362

File tree

7 files changed

+255
-34
lines changed

7 files changed

+255
-34
lines changed

packages/devtools_app/lib/src/shared/analytics/_analytics_stub.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import 'dart:async';
1010

1111
import 'package:logging/logging.dart';
12+
import 'package:stack_trace/stack_trace.dart' as stack_trace;
1213

1314
import 'analytics_common.dart';
1415

@@ -94,7 +95,7 @@ void impression(
9495

9596
void reportError(
9697
String errorMessage, {
97-
List<String> stackTraceSubstrings = const <String>[],
98+
stack_trace.Trace? stackTrace,
9899
bool fatal = false,
99100
}) {}
100101

packages/devtools_app/lib/src/shared/analytics/_analytics_web.dart

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@ import 'dart:js_interop';
1313
import 'package:devtools_app_shared/ui.dart';
1414
import 'package:flutter/foundation.dart';
1515
import 'package:logging/logging.dart';
16+
import 'package:stack_trace/stack_trace.dart' as stack_trace;
1617
import 'package:unified_analytics/unified_analytics.dart' as ua;
1718
import 'package:web/web.dart';
1819

1920
import '../globals.dart';
2021
import '../managers/dtd_manager_extensions.dart';
2122
import '../primitives/query_parameters.dart';
22-
import '../primitives/utils.dart';
2323
import '../server/server.dart' as server;
2424
import '../utils/utils.dart';
2525
import 'analytics_common.dart';
@@ -681,7 +681,7 @@ String? _lastGaError;
681681
/// chunks to GA4 through unified_analytics.
682682
void reportError(
683683
String errorMessage, {
684-
List<String> stackTraceSubstrings = const <String>[],
684+
stack_trace.Trace? stackTrace,
685685
bool fatal = false,
686686
}) {
687687
// Don't keep recording same last error.
@@ -690,14 +690,14 @@ void reportError(
690690

691691
final gTagExceptionWithStackTrace = GtagExceptionDevTools._create(
692692
// Include the stack trace in the message for legacy analytics.
693-
'$errorMessage\n${stackTraceSubstrings.join()}',
693+
'$errorMessage\n${stackTrace?.toString() ?? ''}',
694694
fatal: fatal,
695695
);
696696
GTag.exception(gaExceptionProvider: () => gTagExceptionWithStackTrace);
697697

698698
final uaEvent = _uaEventFromGtagException(
699699
GtagExceptionDevTools._create(errorMessage, fatal: fatal),
700-
stackTraceSubstrings: stackTraceSubstrings,
700+
stackTrace: stackTrace,
701701
);
702702
unawaited(dtdManager.sendAnalyticsEvent(uaEvent));
703703
}
@@ -958,25 +958,17 @@ ua.Event _uaEventFromGtagEvent(GtagEventDevTools gtagEvent) {
958958

959959
ua.Event _uaEventFromGtagException(
960960
GtagExceptionDevTools gtagException, {
961-
List<String> stackTraceSubstrings = const <String>[],
961+
stack_trace.Trace? stackTrace,
962962
}) {
963+
final stackTraceAsMap = createStackTraceForAnalytics(stackTrace);
964+
963965
// Any data entries that have a null value will be removed from the event data
964966
// in the [ua.Event.exception] constructor.
965967
return ua.Event.exception(
966968
exception: gtagException.description ?? 'unknown exception',
967969
data: {
968970
'fatal': gtagException.fatal,
969-
// Each stack trace substring of length [ga4ParamValueCharacterLimit]
970-
// contains information for ~1 stack frame, so including 8 chunks should
971-
// give us enough information to understand the source of the exception.
972-
'stackTraceChunk0': stackTraceSubstrings.safeGet(0),
973-
'stackTraceChunk1': stackTraceSubstrings.safeGet(1),
974-
'stackTraceChunk2': stackTraceSubstrings.safeGet(2),
975-
'stackTraceChunk3': stackTraceSubstrings.safeGet(3),
976-
'stackTraceChunk4': stackTraceSubstrings.safeGet(4),
977-
'stackTraceChunk5': stackTraceSubstrings.safeGet(5),
978-
'stackTraceChunk6': stackTraceSubstrings.safeGet(6),
979-
'stackTraceChunk7': stackTraceSubstrings.safeGet(7),
971+
...stackTraceAsMap,
980972
'userApp': gtagException.user_app,
981973
'userBuild': gtagException.user_build,
982974
'userPlatform': gtagException.user_platform,

packages/devtools_app/lib/src/shared/analytics/analytics_common.dart

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@
55
// Code in this file should be able to be imported by both web and dart:io
66
// dependent libraries.
77

8+
import 'package:collection/collection.dart';
9+
import 'package:flutter/widgets.dart';
10+
import 'package:stack_trace/stack_trace.dart' as stack_trace;
11+
12+
import '../primitives/utils.dart';
13+
814
/// Base class for all screen metrics classes.
915
///
1016
/// Create a subclass of this class to store custom metrics for a screen. All
@@ -26,3 +32,131 @@ abstract class ScreenAnalyticsMetrics {}
2632

2733
/// The character limit for each event parameter value sent to GA4.
2834
const ga4ParamValueCharacterLimit = 100;
35+
36+
/// Returns a stack trace as a [Map] for consumption by GA4 analytics.
37+
///
38+
/// The returned [Map] is indexed into [stackTraceChunksLimit] chunks, where
39+
/// each chunk is a substring of length [ga4ParamValueCharacterLimit]. Each
40+
/// substring contains information for ~1 stack frame, so including
41+
/// [stackTraceChunksLimit] chunks should give us enough information to
42+
/// understand the source of the exception.
43+
///
44+
/// This method uses a heuristic to attempt to include a minimal amount of
45+
/// DevTools-related information in each stack trace. However, there is no
46+
/// guarantee that the returned stack trace will contain any DevTools
47+
/// information. For example, this may happen if all stack frames in the stack
48+
/// trace are from the Flutter framework or from some other package.
49+
Map<String, String?> createStackTraceForAnalytics(
50+
stack_trace.Trace? stackTrace,
51+
) {
52+
if (stackTrace == null) return {};
53+
54+
// Consider a stack frame that contains the 'devtools' String to be from one
55+
// of the DevTools packages (devtools_app, devtools_shared, etc.).
56+
const devToolsIdentifier = 'devtools';
57+
const stackTraceChunksLimit = 10;
58+
const maxCharacterLimit = stackTraceChunksLimit * ga4ParamValueCharacterLimit;
59+
60+
// Reduce whitespace characters to optimize available space.
61+
final trimmedStackFrames =
62+
stackTrace.frames.map((f) => '${f.location} | ${f.member}\n').toList();
63+
final stackTraceAsString = trimmedStackFrames.join();
64+
65+
var stackTraceChunksForGa = chunkForGa(
66+
stackTraceAsString,
67+
chunkCountLimit: stackTraceChunksLimit,
68+
);
69+
70+
// Count the number of stack frames that fully fit within [maxCharacterLimit].
71+
final framesThatFitCount = countFullFramesThatFit(
72+
trimmedStackFrames,
73+
maxCharacterLimit: maxCharacterLimit,
74+
);
75+
final framesThatFit = trimmedStackFrames.sublist(0, framesThatFitCount);
76+
77+
final containsDevToolsFrame = framesThatFit.join().contains(
78+
devToolsIdentifier,
79+
);
80+
// If the complete stack frames in [stackTraceChunksForGa] do not contain any
81+
// DevTools data, modify the stack trace to add DevTools information that may
82+
// help with debugging the exception.
83+
if (!containsDevToolsFrame) {
84+
final devToolsFrames = trimmedStackFrames
85+
.where((entry) => entry.contains(devToolsIdentifier))
86+
.toList()
87+
.safeSublist(0, 3);
88+
if (devToolsFrames.isNotEmpty) {
89+
const modifierLine = '<modified to include DevTools frames>\n';
90+
final devToolsFramesCharacterLength = devToolsFrames.fold(
91+
0,
92+
(sum, frame) => sum += frame.length,
93+
);
94+
final originalStackTraceCharLimit =
95+
maxCharacterLimit -
96+
devToolsFramesCharacterLength -
97+
modifierLine.length;
98+
final originalFramesThatFitCount = countFullFramesThatFit(
99+
trimmedStackFrames,
100+
maxCharacterLimit: originalStackTraceCharLimit,
101+
);
102+
103+
final modifiedStackFrames = [
104+
...trimmedStackFrames.sublist(0, originalFramesThatFitCount),
105+
modifierLine,
106+
...devToolsFrames,
107+
];
108+
stackTraceChunksForGa = chunkForGa(
109+
modifiedStackFrames.join(),
110+
chunkCountLimit: stackTraceChunksLimit,
111+
);
112+
}
113+
}
114+
115+
final stackTraceChunks = {
116+
'stackTraceChunk0': stackTraceChunksForGa.safeGet(0),
117+
'stackTraceChunk1': stackTraceChunksForGa.safeGet(1),
118+
'stackTraceChunk2': stackTraceChunksForGa.safeGet(2),
119+
'stackTraceChunk3': stackTraceChunksForGa.safeGet(3),
120+
'stackTraceChunk4': stackTraceChunksForGa.safeGet(4),
121+
'stackTraceChunk5': stackTraceChunksForGa.safeGet(5),
122+
'stackTraceChunk6': stackTraceChunksForGa.safeGet(6),
123+
'stackTraceChunk7': stackTraceChunksForGa.safeGet(7),
124+
'stackTraceChunk8': stackTraceChunksForGa.safeGet(8),
125+
'stackTraceChunk9': stackTraceChunksForGa.safeGet(9),
126+
};
127+
assert(stackTraceChunks.length == stackTraceChunksLimit);
128+
return stackTraceChunks;
129+
}
130+
131+
/// Returns the number of stack frames from [stackFrameStrings] that fit within
132+
/// [maxCharacterLimit].
133+
int countFullFramesThatFit(
134+
List<String> stackFrameStrings, {
135+
required int maxCharacterLimit,
136+
}) {
137+
var count = 0;
138+
var characterCount = 0;
139+
for (final stackFrameAsString in stackFrameStrings) {
140+
characterCount += stackFrameAsString.length;
141+
if (characterCount < maxCharacterLimit) {
142+
count++;
143+
} else {
144+
break;
145+
}
146+
}
147+
return count;
148+
}
149+
150+
/// Splits [value] up into substrings of size [ga4ParamValueCharacterLimit] so
151+
/// that the data can be set to GA4 through unified_analytics.
152+
///
153+
/// This will return a [List] up to size [chunkCountLimit] at a maximum.
154+
List<String> chunkForGa(String value, {required int chunkCountLimit}) {
155+
return value
156+
.trim()
157+
.characters
158+
.slices(ga4ParamValueCharacterLimit)
159+
.map((slice) => slice.join())
160+
.toList()
161+
.safeSublist(0, chunkCountLimit);
162+
}

packages/devtools_app/lib/src/shared/framework/app_error_handling.dart

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import 'dart:async';
66
import 'dart:convert';
77

8-
import 'package:collection/collection.dart';
98
import 'package:flutter/foundation.dart';
109
import 'package:flutter/widgets.dart';
1110
import 'package:http/http.dart';
@@ -15,7 +14,6 @@ import 'package:source_maps/source_maps.dart';
1514
import 'package:stack_trace/stack_trace.dart' as stack_trace;
1615

1716
import '../analytics/analytics.dart' as ga;
18-
import '../analytics/analytics_common.dart';
1917
import '../globals.dart';
2018

2119
final _log = Logger('app_error_handling');
@@ -95,22 +93,12 @@ Future<void> _reportError(
9593
bool notifyUser = false,
9694
StackTrace? stack,
9795
}) async {
98-
final stackTrace = await _mapAndTersify(stack);
96+
final stackTrace = await _sourceMapStackTrace(stack);
9997
final terseStackTrace = stackTrace?.terse;
10098
final errorMessageWithTerseStackTrace = '$error\n${terseStackTrace ?? ''}';
10199
_log.severe('[$errorType]: $errorMessageWithTerseStackTrace', error, stack);
102100

103-
// Split the stack trace up into substrings of size
104-
// [ga4ParamValueCharacterLimit] so that we can send the stack trace in chunks
105-
// to GA4 through unified_analytics.
106-
final stackTraceSubstrings =
107-
stackTrace
108-
.toString()
109-
.characters
110-
.slices(ga4ParamValueCharacterLimit)
111-
.map((slice) => slice.join())
112-
.toList();
113-
ga.reportError('$error', stackTraceSubstrings: stackTraceSubstrings);
101+
ga.reportError('$error', stackTrace: stackTrace);
114102

115103
// Show error message in a notification pop-up:
116104
if (notifyUser) {
@@ -147,7 +135,7 @@ Future<SingleMapping?> _initializeSourceMapping() async {
147135
}
148136
}
149137

150-
Future<stack_trace.Trace?> _mapAndTersify(StackTrace? stack) async {
138+
Future<stack_trace.Trace?> _sourceMapStackTrace(StackTrace? stack) async {
151139
final originalStackTrace = stack;
152140
if (originalStackTrace == null) return null;
153141

packages/devtools_app/lib/src/shared/primitives/utils.dart

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -633,10 +633,15 @@ String toStringAsFixed(double num, [int fractionDigit = 1]) {
633633
return num.toStringAsFixed(fractionDigit);
634634
}
635635

636-
extension SafeAccessList<T> on List<T> {
636+
extension SafeListOperations<T> on List<T> {
637637
T? safeGet(int index) => index < 0 || index >= length ? null : this[index];
638638

639639
T? safeRemoveLast() => isNotEmpty ? removeLast() : null;
640+
641+
List<T> safeSublist(int start, [int? end]) {
642+
if (start >= length || start >= (end ?? length)) return <T>[];
643+
return sublist(max(start, 0), min(length, end ?? length));
644+
}
640645
}
641646

642647
extension SafeAccess<T> on Iterable<T> {
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Copyright 2025 The Chromium Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:devtools_app/src/shared/analytics/analytics_common.dart';
6+
import 'package:stack_trace/stack_trace.dart' as stack_trace;
7+
import 'package:test/test.dart';
8+
9+
void main() {
10+
group('createStackTraceForAnalytics for stack trace', () {
11+
test('with DevTools stack frames near the top', () {
12+
final stackTrace = stack_trace.Trace.parse(
13+
'''file:///b/s/w/ir/x/w/rc/tmp6qd7qvz0/hosted/pub.dev/vm_service-14.3.0/lib/src/vm_service.dart 95:18 28240
14+
file:///b/s/w/ir/x/w/rc/tmp6qd7qvz0/hosted/pub.dev/vm_service-14.3.0/lib/src/dart_io_extensions.dart 63:12 28301
15+
file:///b/s/w/ir/x/w/rc/tmp6qd7qvz0/hosted/pub.dev/vm_service-14.3.0/lib/src/dart_io_extensions.dart 61:25 28300
16+
file:///b/s/w/ir/x/w/devtools/packages/devtools_app/lib/src/screens/network/network_service.dart 104:34 28297
17+
file:///b/s/w/ir/x/w/devtools/packages/devtools_app_shared/lib/src/service/service_utils.dart 82:25 9696
18+
org-dartlang-sdk:///dart-sdk/lib/_internal/wasm/lib/async_patch.dart 103:30 2140''',
19+
);
20+
final stackTraceChunks = createStackTraceForAnalytics(stackTrace);
21+
expect(
22+
stackTraceChunks.display,
23+
'''/b/s/w/ir/x/w/rc/tmp6qd7qvz0/hosted/pub.dev/vm_service-14.3.0/lib/src/vm_service.dart 95:18 | 28240
24+
/b/s/w/ir/x/w/rc/tmp6qd7qvz0/hosted/pub.dev/vm_service-14.3.0/lib/src/dart_io_extensions.dart 63:12 | 28301
25+
/b/s/w/ir/x/w/rc/tmp6qd7qvz0/hosted/pub.dev/vm_service-14.3.0/lib/src/dart_io_extensions.dart 61:25 | 28300
26+
/b/s/w/ir/x/w/devtools/packages/devtools_app/lib/src/screens/network/network_service.dart 104:34 | 28297
27+
/b/s/w/ir/x/w/devtools/packages/devtools_app_shared/lib/src/service/service_utils.dart 82:25 | 9696
28+
org-dartlang-sdk:///dart-sdk/lib/_internal/wasm/lib/async_patch.dart 103:30 | 2140''',
29+
);
30+
});
31+
32+
test('with DevTools stack frames near the bottom', () {
33+
final stackTrace = stack_trace.Trace.parse(
34+
'''file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/box.dart 2212:22 size
35+
file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/proxy_box.dart 298:21 performLayout
36+
file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/object.dart 2627:7 layout
37+
file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/shifted_box.dart 239:12 performLayout
38+
file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/object.dart 2627:7 layout
39+
file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/proxy_box.dart 117:21 performLayout
40+
file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/object.dart 2627:7 layout
41+
file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/proxy_box.dart 117:21 performLayout
42+
file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/object.dart 2627:7 layout
43+
file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/object.dart 2627:7 layout
44+
file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/proxy_box.dart 117:21 performLayout
45+
file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/object.dart 2627:7 layout
46+
file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/proxy_box.dart 117:21 performLayout
47+
file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/object.dart 2627:7 layout
48+
file:///b/s/w/ir/x/w/devtools/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/timeline_events_controller.dart 210:29 24070
49+
file:///b/s/w/ir/x/w/devtools/packages/devtools_app/lib/src/shared/analytics/_analytics_web.dart 557:25 24080
50+
file:///b/s/w/ir/x/w/devtools/packages/devtools_app/lib/src/shared/analytics/_analytics_web.dart 549:14 24079
51+
file:///b/s/w/ir/x/w/devtools/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/timeline_events_controller.dart 207:20 24074''',
52+
);
53+
final stackTraceChunks = createStackTraceForAnalytics(stackTrace);
54+
expect(
55+
stackTraceChunks.display,
56+
'''/b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/box.dart 2212:22 | size
57+
/b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/proxy_box.dart 298:21 | performLayout
58+
/b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/object.dart 2627:7 | layout
59+
/b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/shifted_box.dart 239:12 | performLayout
60+
/b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/object.dart 2627:7 | layout
61+
/b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/proxy_box.dart 117:21 | performLayout
62+
<modified to include DevTools frames>
63+
/b/s/w/ir/x/w/devtools/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/timeline_events_controller.dart 210:29 | 24070
64+
/b/s/w/ir/x/w/devtools/packages/devtools_app/lib/src/shared/analytics/_analytics_web.dart 557:25 | 24080
65+
/b/s/w/ir/x/w/devtools/packages/devtools_app/lib/src/shared/analytics/_analytics_web.dart 549:14 | 24079''', // nullnullnull expected since the last 3 chunks do not exist
66+
);
67+
});
68+
69+
test('without DevTools stack frames', () {
70+
final stackTrace = stack_trace.Trace.parse(
71+
'''
72+
file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/box.dart 2212:22 size
73+
file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/proxy_box.dart 298:21 performLayout
74+
file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/object.dart 2627:7 layout
75+
file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/layout_helper.dart 61:11 layoutChild
76+
file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/stack.dart 601:43 _computeSize
77+
file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/stack.dart 628:12 performLayout''',
78+
);
79+
final stackTraceChunks = createStackTraceForAnalytics(stackTrace);
80+
expect(
81+
stackTraceChunks.display,
82+
'''/b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/box.dart 2212:22 | size
83+
/b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/proxy_box.dart 298:21 | performLayout
84+
/b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/object.dart 2627:7 | layout
85+
/b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/layout_helper.dart 61:11 | layoutChild
86+
/b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/stack.dart 601:43 | _computeSize
87+
/b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/stack.dart 628:12 | performLayout''',
88+
);
89+
});
90+
});
91+
}
92+
93+
extension on Map<String, String?> {
94+
String get display => values.whereType<String>().join();
95+
}

0 commit comments

Comments
 (0)