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