Optimize stack traces for analytics (#8687)

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 6392dd7..fe8256d 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 reportError(
   String errorMessage, {
-  List<String> stackTraceSubstrings = const <String>[],
+  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 2154cba..fe9f059 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 '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';
@@ -681,7 +681,7 @@
 /// chunks to GA4 through unified_analytics.
 void reportError(
   String errorMessage, {
-  List<String> stackTraceSubstrings = const <String>[],
+  stack_trace.Trace? stackTrace,
   bool fatal = false,
 }) {
   // Don't keep recording same last error.
@@ -690,14 +690,14 @@
 
   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));
 }
@@ -958,25 +958,17 @@
 
 ua.Event _uaEventFromGtagException(
   GtagExceptionDevTools gtagException, {
-  List<String> stackTraceSubstrings = const <String>[],
+  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 60724bb..af0569f 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 @@
 
 /// 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<String, String?> 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 = '<modified to include DevTools frames>\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<String> 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<String> 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 616e07a..b45a7b9 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: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 @@
   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<stack_trace.Trace?> _mapAndTersify(StackTrace? stack) async {
+Future<stack_trace.Trace?> _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 50d3ca2..536bc73 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 @@
   return num.toStringAsFixed(fractionDigit);
 }
 
-extension SafeAccessList<T> on List<T> {
+extension SafeListOperations<T> on List<T> {
   T? safeGet(int index) => index < 0 || index >= length ? null : this[index];
 
   T? safeRemoveLast() => isNotEmpty ? removeLast() : null;
+
+  List<T> safeSublist(int start, [int? end]) {
+    if (start >= length || start >= (end ?? length)) return <T>[];
+    return sublist(max(start, 0), min(length, end ?? length));
+  }
 }
 
 extension SafeAccess<T> on Iterable<T> {
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 0000000..85587db
--- /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
+<modified to include DevTools frames>
+/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, String?> {
+  String get display => values.whereType<String>().join();
+}
diff --git a/packages/devtools_app/test/shared/primitives/utils_test.dart b/packages/devtools_app/test/shared/primitives/utils_test.dart
index 31fa5ac..97f3448 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 @@
       });
     });
 
-    group('SafeAccess', () {
+    group('SafeListOperations', () {
       test('safeFirst', () {
         final list = <int?>[];
         final iterable = list;
@@ -727,6 +727,12 @@
         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), []);
+      });
     });
   });