Include stack traces with unified_analytics exception events. (#8510)

diff --git a/packages/devtools_app/lib/src/framework/app_error_handling.dart b/packages/devtools_app/lib/src/framework/app_error_handling.dart
index ed2ceea..46dca87 100644
--- a/packages/devtools_app/lib/src/framework/app_error_handling.dart
+++ b/packages/devtools_app/lib/src/framework/app_error_handling.dart
@@ -5,6 +5,7 @@
 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';
@@ -14,6 +15,7 @@
 import 'package:stack_trace/stack_trace.dart' as stack_trace;
 
 import '../shared/analytics/analytics.dart' as ga;
+import '../shared/analytics/analytics_common.dart';
 import '../shared/globals.dart';
 
 final _log = Logger('app_error_handling');
@@ -93,17 +95,27 @@
   bool notifyUser = false,
   StackTrace? stack,
 }) async {
-  final terseStackTrace = await _mapAndTersify(stack);
-  final errorMessage = '$error\n$terseStackTrace';
+  final stackTrace = await _mapAndTersify(stack);
+  final terseStackTrace = stackTrace?.terse;
+  final errorMessageWithTerseStackTrace = '$error\n${terseStackTrace ?? ''}';
+  _log.severe('[$errorType]: $errorMessageWithTerseStackTrace', error, stack);
 
-  _log.severe('[$errorType]: $errorMessage', error, stack);
-  ga.reportError(errorMessage);
+  // 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);
 
   // Show error message in a notification pop-up:
   if (notifyUser) {
     notificationService.pushError(
       error.toString(),
-      stackTrace: terseStackTrace,
+      stackTrace: terseStackTrace?.toString(),
     );
   }
 }
@@ -135,16 +147,16 @@
   }
 }
 
-Future<String> _mapAndTersify(StackTrace? stack) async {
+Future<stack_trace.Trace?> _mapAndTersify(StackTrace? stack) async {
   final originalStackTrace = stack;
-  if (originalStackTrace == null) return '';
+  if (originalStackTrace == null) return null;
 
   final mappedStackTrace = await _maybeMapStackTrace(originalStackTrace);
   // If mapping fails, revert back to the original stack trace:
   final stackTrace = mappedStackTrace.toString().isEmpty
       ? originalStackTrace
       : mappedStackTrace;
-  return stack_trace.Trace.from(stackTrace).terse.toString();
+  return stack_trace.Trace.from(stackTrace);
 }
 
 Future<StackTrace> _maybeMapStackTrace(StackTrace stack) async {
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 f1e0f4a..a6f5d1d 100644
--- a/packages/devtools_app/lib/src/shared/analytics/_analytics_stub.dart
+++ b/packages/devtools_app/lib/src/shared/analytics/_analytics_stub.dart
@@ -97,6 +97,7 @@
 
 void reportError(
   String errorMessage, {
+  List<String> stackTraceSubstrings = const <String>[],
   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 4e67114..6e31105 100644
--- a/packages/devtools_app/lib/src/shared/analytics/_analytics_web.dart
+++ b/packages/devtools_app/lib/src/shared/analytics/_analytics_web.dart
@@ -18,6 +18,7 @@
 
 import '../dtd_manager_extensions.dart';
 import '../globals.dart';
+import '../primitives/utils.dart';
 import '../query_parameters.dart';
 import '../server/server.dart' as server;
 import '../utils.dart';
@@ -631,21 +632,32 @@
 
 String? _lastGaError;
 
+/// Reports an error to analytics.
+///
+/// [errorMessage] is the description of the error.
+/// [stackTraceSubstrings] is the stack trace broken up into substrings of
+/// size [ga4ParamValueCharacterLimit] so that we can send the stack trace in
+/// chunks to GA4 through unified_analytics.
 void reportError(
   String errorMessage, {
+  List<String> stackTraceSubstrings = const <String>[],
   bool fatal = false,
 }) {
   // Don't keep recording same last error.
   if (_lastGaError == errorMessage) return;
   _lastGaError = errorMessage;
 
-  final gTagException = GtagExceptionDevTools._create(
-    errorMessage,
+  final gTagExceptionWithStackTrace = GtagExceptionDevTools._create(
+    // Include the stack trace in the message for legacy analytics.
+    '$errorMessage\n${stackTraceSubstrings.join()}',
     fatal: fatal,
   );
-  GTag.exception(gaExceptionProvider: () => gTagException);
+  GTag.exception(gaExceptionProvider: () => gTagExceptionWithStackTrace);
 
-  final uaEvent = _uaEventFromGtagException(gTagException);
+  final uaEvent = _uaEventFromGtagException(
+    GtagExceptionDevTools._create(errorMessage, fatal: fatal),
+    stackTraceSubstrings: stackTraceSubstrings,
+  );
   unawaited(dtdManager.sendAnalyticsEvent(uaEvent));
 }
 
@@ -877,6 +889,8 @@
 }
 
 ua.Event _uaEventFromGtagEvent(GtagEventDevTools gtagEvent) {
+  // Any dimensions or metrics that have a null value will be removed from
+  // the event data in the [ua.Event.devtoolsEvent] constructor.
   return ua.Event.devtoolsEvent(
     eventCategory: gtagEvent.event_category!,
     label: gtagEvent.event_label!,
@@ -889,11 +903,15 @@
     devtoolsChrome: gtagEvent.devtools_chrome,
     devtoolsVersion: gtagEvent.devtools_version,
     ideLaunched: gtagEvent.ide_launched,
+    ideLaunchedFeature: gtagEvent.ide_launched_feature,
     isExternalBuild: gtagEvent.is_external_build,
     isEmbedded: gtagEvent.is_embedded,
-    ideLaunchedFeature: gtagEvent.ide_launched_feature,
     isWasm: gtagEvent.is_wasm,
     g3Username: gtagEvent.g3_username,
+    // Only 25 entries are permitted for GA4 event parameters, but since not
+    // all of the below metrics will be non-null at the same time, it is okay to
+    // include all the metrics here. The [ua.Event.devtoolsEvent] constructor
+    // will remove any entries with a null value from the sent event parameters.
     uiDurationMicros: gtagEvent.ui_duration_micros,
     rasterDurationMicros: gtagEvent.raster_duration_micros,
     shaderCompilationDurationMicros:
@@ -912,11 +930,27 @@
   );
 }
 
-ua.Event _uaEventFromGtagException(GtagExceptionDevTools gtagException) {
+ua.Event _uaEventFromGtagException(
+  GtagExceptionDevTools gtagException, {
+  List<String> stackTraceSubstrings = const <String>[],
+}) {
+  // 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),
       'userApp': gtagException.user_app,
       'userBuild': gtagException.user_build,
       'userPlatform': gtagException.user_platform,
@@ -924,24 +958,14 @@
       'devtoolsChrome': gtagException.devtools_chrome,
       'devtoolsVersion': gtagException.devtools_version,
       'ideLaunched': gtagException.ide_launched,
+      'ideLaunchedFeature': gtagException.ide_launched_feature,
       'isExternalBuild': gtagException.is_external_build,
       'isEmbedded': gtagException.is_embedded,
-      'ideLaunchedFeature': gtagException.ide_launched_feature,
       'isWasm': gtagException.is_wasm,
       'g3Username': gtagException.g3_username,
-      'uiDurationMicros': gtagException.ui_duration_micros,
-      'rasterDurationMicros': gtagException.raster_duration_micros,
-      'shaderCompilationDurationMicros':
-          gtagException.shader_compilation_duration_micros,
-      'traceEventCount': gtagException.trace_event_count,
-      'cpuSampleCount': gtagException.cpu_sample_count,
-      'cpuStackDepth': gtagException.cpu_stack_depth,
-      'heapDiffObjectsBefore': gtagException.heap_diff_objects_before,
-      'heapDiffObjectsAfter': gtagException.heap_diff_objects_after,
-      'heapObjectsTotal': gtagException.heap_objects_total,
-      'rootSetCount': gtagException.root_set_count,
-      'rowCount': gtagException.row_count,
-      'inspectorTreeControllerId': gtagException.inspector_tree_controller_id,
+      // Do not include metrics in exceptions because GA4 event parameter are
+      // limited to 25 entries, and we need to reserve entries for the stack
+      // trace chunks.
     },
   );
 }
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 bdabb36..60724bb 100644
--- a/packages/devtools_app/lib/src/shared/analytics/analytics_common.dart
+++ b/packages/devtools_app/lib/src/shared/analytics/analytics_common.dart
@@ -23,3 +23,6 @@
 /// Then, add your fields to the [GtagEventDevTools] factory constructor and add
 /// a corresponding getter in the class.
 abstract class ScreenAnalyticsMetrics {}
+
+/// The character limit for each event parameter value sent to GA4.
+const ga4ParamValueCharacterLimit = 100;
diff --git a/packages/devtools_app/pubspec.yaml b/packages/devtools_app/pubspec.yaml
index d94c6d2..ca3bf5f 100644
--- a/packages/devtools_app/pubspec.yaml
+++ b/packages/devtools_app/pubspec.yaml
@@ -53,7 +53,7 @@
   stack_trace: ^1.12.0
   stream_channel: ^2.1.1
   string_scanner: ^1.1.0
-  unified_analytics: ^6.1.4
+  unified_analytics: ^6.1.5
   vm_service: ^14.2.5
   vm_service_protos: ^1.0.0
   vm_snapshot_analysis: ^0.7.6