Skip to content

Commit

Permalink
Optimize stack traces for analytics (#8687)
Browse files Browse the repository at this point in the history
  • Loading branch information
kenzieschmoll authored Jan 10, 2025
1 parent 81c9bad commit e511362
Show file tree
Hide file tree
Showing 7 changed files with 255 additions and 34 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -94,7 +95,7 @@ void impression(

void reportError(
String errorMessage, {
List<String> stackTraceSubstrings = const <String>[],
stack_trace.Trace? stackTrace,
bool fatal = false,
}) {}

Expand Down
24 changes: 8 additions & 16 deletions packages/devtools_app/lib/src/shared/analytics/_analytics_web.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -681,7 +681,7 @@ String? _lastGaError;
/// 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.
Expand All @@ -690,14 +690,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));
}
Expand Down Expand Up @@ -958,25 +958,17 @@ ua.Event _uaEventFromGtagEvent(GtagEventDevTools gtagEvent) {

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,
Expand Down
134 changes: 134 additions & 0 deletions packages/devtools_app/lib/src/shared/analytics/analytics_common.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand 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<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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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');
Expand Down Expand Up @@ -95,22 +93,12 @@ Future<void> _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) {
Expand Down Expand Up @@ -147,7 +135,7 @@ Future<SingleMapping?> _initializeSourceMapping() async {
}
}

Future<stack_trace.Trace?> _mapAndTersify(StackTrace? stack) async {
Future<stack_trace.Trace?> _sourceMapStackTrace(StackTrace? stack) async {
final originalStackTrace = stack;
if (originalStackTrace == null) return null;

Expand Down
7 changes: 6 additions & 1 deletion packages/devtools_app/lib/src/shared/primitives/utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -633,10 +633,15 @@ String toStringAsFixed(double num, [int fractionDigit = 1]) {
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> {
Expand Down
95 changes: 95 additions & 0 deletions packages/devtools_app/test/shared/analytics/analytics_test.dart
Original file line number Diff line number Diff line change
@@ -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();
}
Loading

0 comments on commit e511362

Please sign in to comment.