Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Optimize stack traces for analytics #8687

Merged
merged 3 commits into from
Jan 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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 @@ -673,7 +673,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 @@ -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));
}
Expand Down Expand Up @@ -950,25 +950,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
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
Loading