diff --git a/packages/devtools_app/lib/src/shared/analytics/_analytics_stub.dart b/packages/devtools_app/lib/src/shared/analytics/_analytics_stub.dart index 6392dd76833..fe8256dfe5e 100644 --- a/packages/devtools_app/lib/src/shared/analytics/_analytics_stub.dart +++ b/packages/devtools_app/lib/src/shared/analytics/_analytics_stub.dart @@ -9,6 +9,7 @@ import 'dart:async'; import 'package:logging/logging.dart'; +import 'package:stack_trace/stack_trace.dart' as stack_trace; import 'analytics_common.dart'; @@ -94,7 +95,7 @@ void impression( void reportError( String errorMessage, { - List stackTraceSubstrings = const [], + stack_trace.Trace? stackTrace, bool fatal = false, }) {} diff --git a/packages/devtools_app/lib/src/shared/analytics/_analytics_web.dart b/packages/devtools_app/lib/src/shared/analytics/_analytics_web.dart index 178e0817816..c94419344b8 100644 --- a/packages/devtools_app/lib/src/shared/analytics/_analytics_web.dart +++ b/packages/devtools_app/lib/src/shared/analytics/_analytics_web.dart @@ -13,13 +13,13 @@ import 'dart:js_interop'; import 'package:devtools_app_shared/ui.dart'; import 'package:flutter/foundation.dart'; import 'package:logging/logging.dart'; +import 'package:stack_trace/stack_trace.dart' as stack_trace; import 'package:unified_analytics/unified_analytics.dart' as ua; import 'package:web/web.dart'; import '../globals.dart'; import '../managers/dtd_manager_extensions.dart'; import '../primitives/query_parameters.dart'; -import '../primitives/utils.dart'; import '../server/server.dart' as server; import '../utils/utils.dart'; import 'analytics_common.dart'; @@ -673,7 +673,7 @@ String? _lastGaError; /// chunks to GA4 through unified_analytics. void reportError( String errorMessage, { - List stackTraceSubstrings = const [], + stack_trace.Trace? stackTrace, bool fatal = false, }) { // Don't keep recording same last error. @@ -682,14 +682,14 @@ void reportError( final gTagExceptionWithStackTrace = GtagExceptionDevTools._create( // Include the stack trace in the message for legacy analytics. - '$errorMessage\n${stackTraceSubstrings.join()}', + '$errorMessage\n${stackTrace?.toString() ?? ''}', fatal: fatal, ); GTag.exception(gaExceptionProvider: () => gTagExceptionWithStackTrace); final uaEvent = _uaEventFromGtagException( GtagExceptionDevTools._create(errorMessage, fatal: fatal), - stackTraceSubstrings: stackTraceSubstrings, + stackTrace: stackTrace, ); unawaited(dtdManager.sendAnalyticsEvent(uaEvent)); } @@ -950,25 +950,17 @@ ua.Event _uaEventFromGtagEvent(GtagEventDevTools gtagEvent) { ua.Event _uaEventFromGtagException( GtagExceptionDevTools gtagException, { - List stackTraceSubstrings = const [], + stack_trace.Trace? stackTrace, }) { + final stackTraceAsMap = createStackTraceForAnalytics(stackTrace); + // Any data entries that have a null value will be removed from the event data // in the [ua.Event.exception] constructor. return ua.Event.exception( exception: gtagException.description ?? 'unknown exception', data: { 'fatal': gtagException.fatal, - // Each stack trace substring of length [ga4ParamValueCharacterLimit] - // contains information for ~1 stack frame, so including 8 chunks should - // give us enough information to understand the source of the exception. - 'stackTraceChunk0': stackTraceSubstrings.safeGet(0), - 'stackTraceChunk1': stackTraceSubstrings.safeGet(1), - 'stackTraceChunk2': stackTraceSubstrings.safeGet(2), - 'stackTraceChunk3': stackTraceSubstrings.safeGet(3), - 'stackTraceChunk4': stackTraceSubstrings.safeGet(4), - 'stackTraceChunk5': stackTraceSubstrings.safeGet(5), - 'stackTraceChunk6': stackTraceSubstrings.safeGet(6), - 'stackTraceChunk7': stackTraceSubstrings.safeGet(7), + ...stackTraceAsMap, 'userApp': gtagException.user_app, 'userBuild': gtagException.user_build, 'userPlatform': gtagException.user_platform, diff --git a/packages/devtools_app/lib/src/shared/analytics/analytics_common.dart b/packages/devtools_app/lib/src/shared/analytics/analytics_common.dart index 60724bb1387..af0569f9e2a 100644 --- a/packages/devtools_app/lib/src/shared/analytics/analytics_common.dart +++ b/packages/devtools_app/lib/src/shared/analytics/analytics_common.dart @@ -5,6 +5,12 @@ // Code in this file should be able to be imported by both web and dart:io // dependent libraries. +import 'package:collection/collection.dart'; +import 'package:flutter/widgets.dart'; +import 'package:stack_trace/stack_trace.dart' as stack_trace; + +import '../primitives/utils.dart'; + /// Base class for all screen metrics classes. /// /// Create a subclass of this class to store custom metrics for a screen. All @@ -26,3 +32,131 @@ abstract class ScreenAnalyticsMetrics {} /// The character limit for each event parameter value sent to GA4. const ga4ParamValueCharacterLimit = 100; + +/// Returns a stack trace as a [Map] for consumption by GA4 analytics. +/// +/// The returned [Map] is indexed into [stackTraceChunksLimit] chunks, where +/// each chunk is a substring of length [ga4ParamValueCharacterLimit]. Each +/// substring contains information for ~1 stack frame, so including +/// [stackTraceChunksLimit] chunks should give us enough information to +/// understand the source of the exception. +/// +/// This method uses a heuristic to attempt to include a minimal amount of +/// DevTools-related information in each stack trace. However, there is no +/// guarantee that the returned stack trace will contain any DevTools +/// information. For example, this may happen if all stack frames in the stack +/// trace are from the Flutter framework or from some other package. +Map createStackTraceForAnalytics( + stack_trace.Trace? stackTrace, +) { + if (stackTrace == null) return {}; + + // Consider a stack frame that contains the 'devtools' String to be from one + // of the DevTools packages (devtools_app, devtools_shared, etc.). + const devToolsIdentifier = 'devtools'; + const stackTraceChunksLimit = 10; + const maxCharacterLimit = stackTraceChunksLimit * ga4ParamValueCharacterLimit; + + // Reduce whitespace characters to optimize available space. + final trimmedStackFrames = + stackTrace.frames.map((f) => '${f.location} | ${f.member}\n').toList(); + final stackTraceAsString = trimmedStackFrames.join(); + + var stackTraceChunksForGa = chunkForGa( + stackTraceAsString, + chunkCountLimit: stackTraceChunksLimit, + ); + + // Count the number of stack frames that fully fit within [maxCharacterLimit]. + final framesThatFitCount = countFullFramesThatFit( + trimmedStackFrames, + maxCharacterLimit: maxCharacterLimit, + ); + final framesThatFit = trimmedStackFrames.sublist(0, framesThatFitCount); + + final containsDevToolsFrame = framesThatFit.join().contains( + devToolsIdentifier, + ); + // If the complete stack frames in [stackTraceChunksForGa] do not contain any + // DevTools data, modify the stack trace to add DevTools information that may + // help with debugging the exception. + if (!containsDevToolsFrame) { + final devToolsFrames = trimmedStackFrames + .where((entry) => entry.contains(devToolsIdentifier)) + .toList() + .safeSublist(0, 3); + if (devToolsFrames.isNotEmpty) { + const modifierLine = '\n'; + final devToolsFramesCharacterLength = devToolsFrames.fold( + 0, + (sum, frame) => sum += frame.length, + ); + final originalStackTraceCharLimit = + maxCharacterLimit - + devToolsFramesCharacterLength - + modifierLine.length; + final originalFramesThatFitCount = countFullFramesThatFit( + trimmedStackFrames, + maxCharacterLimit: originalStackTraceCharLimit, + ); + + final modifiedStackFrames = [ + ...trimmedStackFrames.sublist(0, originalFramesThatFitCount), + modifierLine, + ...devToolsFrames, + ]; + stackTraceChunksForGa = chunkForGa( + modifiedStackFrames.join(), + chunkCountLimit: stackTraceChunksLimit, + ); + } + } + + final stackTraceChunks = { + 'stackTraceChunk0': stackTraceChunksForGa.safeGet(0), + 'stackTraceChunk1': stackTraceChunksForGa.safeGet(1), + 'stackTraceChunk2': stackTraceChunksForGa.safeGet(2), + 'stackTraceChunk3': stackTraceChunksForGa.safeGet(3), + 'stackTraceChunk4': stackTraceChunksForGa.safeGet(4), + 'stackTraceChunk5': stackTraceChunksForGa.safeGet(5), + 'stackTraceChunk6': stackTraceChunksForGa.safeGet(6), + 'stackTraceChunk7': stackTraceChunksForGa.safeGet(7), + 'stackTraceChunk8': stackTraceChunksForGa.safeGet(8), + 'stackTraceChunk9': stackTraceChunksForGa.safeGet(9), + }; + assert(stackTraceChunks.length == stackTraceChunksLimit); + return stackTraceChunks; +} + +/// Returns the number of stack frames from [stackFrameStrings] that fit within +/// [maxCharacterLimit]. +int countFullFramesThatFit( + List stackFrameStrings, { + required int maxCharacterLimit, +}) { + var count = 0; + var characterCount = 0; + for (final stackFrameAsString in stackFrameStrings) { + characterCount += stackFrameAsString.length; + if (characterCount < maxCharacterLimit) { + count++; + } else { + break; + } + } + return count; +} + +/// Splits [value] up into substrings of size [ga4ParamValueCharacterLimit] so +/// that the data can be set to GA4 through unified_analytics. +/// +/// This will return a [List] up to size [chunkCountLimit] at a maximum. +List chunkForGa(String value, {required int chunkCountLimit}) { + return value + .trim() + .characters + .slices(ga4ParamValueCharacterLimit) + .map((slice) => slice.join()) + .toList() + .safeSublist(0, chunkCountLimit); +} diff --git a/packages/devtools_app/lib/src/shared/framework/app_error_handling.dart b/packages/devtools_app/lib/src/shared/framework/app_error_handling.dart index 616e07a95b7..b45a7b90c40 100644 --- a/packages/devtools_app/lib/src/shared/framework/app_error_handling.dart +++ b/packages/devtools_app/lib/src/shared/framework/app_error_handling.dart @@ -5,7 +5,6 @@ import 'dart:async'; import 'dart:convert'; -import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:http/http.dart'; @@ -15,7 +14,6 @@ import 'package:source_maps/source_maps.dart'; import 'package:stack_trace/stack_trace.dart' as stack_trace; import '../analytics/analytics.dart' as ga; -import '../analytics/analytics_common.dart'; import '../globals.dart'; final _log = Logger('app_error_handling'); @@ -95,22 +93,12 @@ Future _reportError( bool notifyUser = false, StackTrace? stack, }) async { - final stackTrace = await _mapAndTersify(stack); + final stackTrace = await _sourceMapStackTrace(stack); final terseStackTrace = stackTrace?.terse; final errorMessageWithTerseStackTrace = '$error\n${terseStackTrace ?? ''}'; _log.severe('[$errorType]: $errorMessageWithTerseStackTrace', error, stack); - // Split the stack trace up into substrings of size - // [ga4ParamValueCharacterLimit] so that we can send the stack trace in chunks - // to GA4 through unified_analytics. - final stackTraceSubstrings = - stackTrace - .toString() - .characters - .slices(ga4ParamValueCharacterLimit) - .map((slice) => slice.join()) - .toList(); - ga.reportError('$error', stackTraceSubstrings: stackTraceSubstrings); + ga.reportError('$error', stackTrace: stackTrace); // Show error message in a notification pop-up: if (notifyUser) { @@ -147,7 +135,7 @@ Future _initializeSourceMapping() async { } } -Future _mapAndTersify(StackTrace? stack) async { +Future _sourceMapStackTrace(StackTrace? stack) async { final originalStackTrace = stack; if (originalStackTrace == null) return null; diff --git a/packages/devtools_app/lib/src/shared/primitives/utils.dart b/packages/devtools_app/lib/src/shared/primitives/utils.dart index 50d3ca2caa6..536bc730ea4 100644 --- a/packages/devtools_app/lib/src/shared/primitives/utils.dart +++ b/packages/devtools_app/lib/src/shared/primitives/utils.dart @@ -633,10 +633,15 @@ String toStringAsFixed(double num, [int fractionDigit = 1]) { return num.toStringAsFixed(fractionDigit); } -extension SafeAccessList on List { +extension SafeListOperations on List { T? safeGet(int index) => index < 0 || index >= length ? null : this[index]; T? safeRemoveLast() => isNotEmpty ? removeLast() : null; + + List safeSublist(int start, [int? end]) { + if (start >= length || start >= (end ?? length)) return []; + return sublist(max(start, 0), min(length, end ?? length)); + } } extension SafeAccess on Iterable { diff --git a/packages/devtools_app/test/shared/analytics/analytics_test.dart b/packages/devtools_app/test/shared/analytics/analytics_test.dart new file mode 100644 index 00000000000..85587db88ab --- /dev/null +++ b/packages/devtools_app/test/shared/analytics/analytics_test.dart @@ -0,0 +1,95 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:devtools_app/src/shared/analytics/analytics_common.dart'; +import 'package:stack_trace/stack_trace.dart' as stack_trace; +import 'package:test/test.dart'; + +void main() { + group('createStackTraceForAnalytics for stack trace', () { + test('with DevTools stack frames near the top', () { + final stackTrace = stack_trace.Trace.parse( + '''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 +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 +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 +file:///b/s/w/ir/x/w/devtools/packages/devtools_app/lib/src/screens/network/network_service.dart 104:34 28297 +file:///b/s/w/ir/x/w/devtools/packages/devtools_app_shared/lib/src/service/service_utils.dart 82:25 9696 +org-dartlang-sdk:///dart-sdk/lib/_internal/wasm/lib/async_patch.dart 103:30 2140''', + ); + final stackTraceChunks = createStackTraceForAnalytics(stackTrace); + expect( + stackTraceChunks.display, + '''/b/s/w/ir/x/w/rc/tmp6qd7qvz0/hosted/pub.dev/vm_service-14.3.0/lib/src/vm_service.dart 95:18 | 28240 +/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 +/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 +/b/s/w/ir/x/w/devtools/packages/devtools_app/lib/src/screens/network/network_service.dart 104:34 | 28297 +/b/s/w/ir/x/w/devtools/packages/devtools_app_shared/lib/src/service/service_utils.dart 82:25 | 9696 +org-dartlang-sdk:///dart-sdk/lib/_internal/wasm/lib/async_patch.dart 103:30 | 2140''', + ); + }); + + test('with DevTools stack frames near the bottom', () { + final stackTrace = stack_trace.Trace.parse( + '''file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/box.dart 2212:22 size +file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/proxy_box.dart 298:21 performLayout +file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/object.dart 2627:7 layout +file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/shifted_box.dart 239:12 performLayout +file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/object.dart 2627:7 layout +file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/proxy_box.dart 117:21 performLayout +file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/object.dart 2627:7 layout +file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/proxy_box.dart 117:21 performLayout +file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/object.dart 2627:7 layout +file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/object.dart 2627:7 layout +file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/proxy_box.dart 117:21 performLayout +file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/object.dart 2627:7 layout +file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/proxy_box.dart 117:21 performLayout +file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/object.dart 2627:7 layout +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 +file:///b/s/w/ir/x/w/devtools/packages/devtools_app/lib/src/shared/analytics/_analytics_web.dart 557:25 24080 +file:///b/s/w/ir/x/w/devtools/packages/devtools_app/lib/src/shared/analytics/_analytics_web.dart 549:14 24079 +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''', + ); + final stackTraceChunks = createStackTraceForAnalytics(stackTrace); + expect( + stackTraceChunks.display, + '''/b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/box.dart 2212:22 | size +/b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/proxy_box.dart 298:21 | performLayout +/b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/object.dart 2627:7 | layout +/b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/shifted_box.dart 239:12 | performLayout +/b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/object.dart 2627:7 | layout +/b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/proxy_box.dart 117:21 | performLayout + +/b/s/w/ir/x/w/devtools/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/timeline_events_controller.dart 210:29 | 24070 +/b/s/w/ir/x/w/devtools/packages/devtools_app/lib/src/shared/analytics/_analytics_web.dart 557:25 | 24080 +/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 + ); + }); + + test('without DevTools stack frames', () { + final stackTrace = stack_trace.Trace.parse( + ''' +file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/box.dart 2212:22 size +file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/proxy_box.dart 298:21 performLayout +file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/object.dart 2627:7 layout +file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/layout_helper.dart 61:11 layoutChild +file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/stack.dart 601:43 _computeSize +file:///b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/stack.dart 628:12 performLayout''', + ); + final stackTraceChunks = createStackTraceForAnalytics(stackTrace); + expect( + stackTraceChunks.display, + '''/b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/box.dart 2212:22 | size +/b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/proxy_box.dart 298:21 | performLayout +/b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/object.dart 2627:7 | layout +/b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/layout_helper.dart 61:11 | layoutChild +/b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/stack.dart 601:43 | _computeSize +/b/s/w/ir/x/w/rc/flutter/packages/flutter/lib/src/rendering/stack.dart 628:12 | performLayout''', + ); + }); + }); +} + +extension on Map { + String get display => values.whereType().join(); +} diff --git a/packages/devtools_app/test/shared/primitives/utils_test.dart b/packages/devtools_app/test/shared/primitives/utils_test.dart index 31fa5acc968..97f3448227d 100644 --- a/packages/devtools_app/test/shared/primitives/utils_test.dart +++ b/packages/devtools_app/test/shared/primitives/utils_test.dart @@ -687,7 +687,7 @@ void main() { }); }); - group('SafeAccess', () { + group('SafeListOperations', () { test('safeFirst', () { final list = []; final iterable = list; @@ -727,6 +727,12 @@ void main() { expect(list.safeRemoveLast(), 1); expect(list.safeRemoveLast(), isNull); }); + + test('safeSublist', () { + expect([1, 2, 3].safeSublist(-1, 2), [1, 2]); + expect([1, 2, 3].safeSublist(0, 6), [1, 2, 3]); + expect([1, 2, 3].safeSublist(2, 1), []); + }); }); });