Merge "UI: improve crash reporting" into main
diff --git a/python/tools/check_imports.py b/python/tools/check_imports.py
index 3634419..a114982 100755
--- a/python/tools/check_imports.py
+++ b/python/tools/check_imports.py
@@ -353,10 +353,18 @@
return result
+def remove_prefix(s, prefix):
+ return s[len(prefix):] if s.startswith(prefix) else s
+
+
+def remove_suffix(s, suffix):
+ return s[:-len(suffix)] if s.endswith(suffix) else s
+
+
def find_imports(path):
src = path
- src = src.removeprefix(UI_SRC_DIR)
- src = src.removesuffix('.ts')
+ src = remove_prefix(src, UI_SRC_DIR)
+ src = remove_suffix(src, '.ts')
directory, _ = os.path.split(src)
with open(path) as f:
s = f.read()
diff --git a/ui/src/base/logging.ts b/ui/src/base/logging.ts
index 945d96a..8387783 100644
--- a/ui/src/base/logging.ts
+++ b/ui/src/base/logging.ts
@@ -12,11 +12,21 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-import {SCM_REVISION, VERSION} from '../gen/perfetto_version';
+import {VERSION} from '../gen/perfetto_version';
-export type ErrorHandler = (err: string) => void;
+export type ErrorType = 'ERROR'|'PROMISE_REJ'|'OTHER';
+export interface ErrorStackEntry {
+ name: string; // e.g. renderCanvas
+ location: string; // e.g. frontend_bundle.js:12:3
+}
+export interface ErrorDetails {
+ errType: ErrorType;
+ message: string; // Uncaught StoreError: No such subtree: tracks,1374,state
+ stack: ErrorStackEntry[];
+}
-let errorHandler: ErrorHandler = (_: string) => {};
+export type ErrorHandler = (err: ErrorDetails) => void;
+const errorHandlers: ErrorHandler[] = [];
export function assertExists<A>(value: A | null | undefined): A {
if (value === null || value === undefined) {
@@ -35,34 +45,86 @@
assertTrue(!value, optMsg);
}
-export function setErrorHandler(handler: ErrorHandler) {
- errorHandler = handler;
+export function addErrorHandler(handler: ErrorHandler) {
+ if (!errorHandlers.includes(handler)) {
+ errorHandlers.push(handler);
+ }
}
export function reportError(err: ErrorEvent|PromiseRejectionEvent|{}) {
- let errLog = '';
let errorObj = undefined;
+ let errMsg = '';
+ let errType: ErrorType;
+ const stack: ErrorStackEntry[] = [];
+ const baseUrl = `${location.protocol}//${location.host}`;
if (err instanceof ErrorEvent) {
- errLog = err.message;
+ errType = 'ERROR';
+ errMsg = err.message;
errorObj = err.error;
} else if (err instanceof PromiseRejectionEvent) {
- errLog = `${err.reason}`;
+ errType = 'PROMISE_REJ';
+ errMsg = `PromiseRejection: ${err.reason}`;
errorObj = err.reason;
} else {
- errLog = `${err}`;
+ errType = 'OTHER';
+ errMsg = `Err: ${err}`;
}
if (errorObj !== undefined && errorObj !== null) {
- const errStack = (errorObj as {stack?: string}).stack;
- errLog += '\n';
- errLog += errStack !== undefined ? errStack : JSON.stringify(errorObj);
- }
- errLog += '\n\n';
- errLog += `${VERSION} ${SCM_REVISION}\n`;
- errLog += `UA: ${navigator.userAgent}\n`;
+ const maybeStack = (errorObj as {stack?: string}).stack;
+ let errStack = maybeStack !== undefined ? `${maybeStack}` : '';
+ errStack = errStack.replaceAll(/\r/g, ''); // Strip Windows CR.
+ for (let line of errStack.split('\n')) {
+ if (errMsg.includes(line)) continue;
+ // Chrome, Firefox and safari don't agree on the stack format:
+ // Chrome: prefixes entries with a ' at ' and uses the format
+ // function(https://url:line:col), e.g.
+ // ' at FooBar (https://.../frontend_bundle.js:2073:15)'
+ // however, if the function name is not known, it prints just:
+ // ' at https://.../frontend_bundle.js:2073:15'
+ // or also:
+ // ' at <anonymous>:5:11'
+ // Firefox and Safari: don't have any prefix and use @ as a separator:
+ // redrawCanvas@https://.../frontend_bundle.js:468814:26
+ // @debugger eval code:1:32
- console.error(errLog, err);
- errorHandler(errLog);
+ // Here we first normalize Chrome into the Firefox/Safari format by
+ // removing the ' at ' prefix and replacing (xxx)$ into @xxx.
+ line = line.replace(/^\s*at\s*/, '');
+ line = line.replace(/\s*\(([^)]+)\)$/, '@$1');
+
+ // This leaves us still with two possible options here:
+ // 1. FooBar@https://ui.perfetto.dev/v123/frontend_bundle.js:2073:15
+ // 2. https://ui.perfetto.dev/v123/frontend_bundle.js:2073:15
+ const lastAt = line.lastIndexOf('@');
+ let entryName = '';
+ let entryLocation = '';
+ if (lastAt >= 0) {
+ entryLocation = line.substring(lastAt + 1);
+ entryName = line.substring(0, lastAt);
+ } else {
+ entryLocation = line;
+ }
+
+ // Remove redundant https://ui.perfetto.dev/v38.0-d6ed090ee/ as we have
+ // that information already and don't need to repeat it on each line.
+ if (entryLocation.includes(baseUrl)) {
+ entryLocation = entryLocation.replace(baseUrl, '');
+ entryLocation = entryLocation.replace(`/${VERSION}/`, '');
+ }
+ stack.push({name: entryName, location: entryLocation});
+ }
+ }
+ // Invoke all the handlers registered through addErrorHandler.
+ // There are usually two handlers registered, one for the UI (error_dialog.ts)
+ // and one for Analytics (analytics.ts).
+ for (const handler of errorHandlers) {
+ handler({
+ errType,
+ message: errMsg,
+ stack,
+ } as ErrorDetails);
+ }
}
// This function serves two purposes.
diff --git a/ui/src/frontend/analytics.ts b/ui/src/frontend/analytics.ts
index 74c42fc..25f8e96 100644
--- a/ui/src/frontend/analytics.ts
+++ b/ui/src/frontend/analytics.ts
@@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
+import {ErrorDetails} from '../base/logging';
import {getCurrentChannel} from '../common/channels';
import {VERSION} from '../gen/perfetto_version';
@@ -58,16 +59,16 @@
export interface Analytics {
initialize(): void;
updatePath(_: string): void;
- logEvent(_x: TraceCategories|null, _y: string): void;
- logError(_x: string, _y?: boolean): void;
+ logEvent(category: TraceCategories|null, event: string): void;
+ logError(err: ErrorDetails): void;
isEnabled(): boolean;
}
export class NullAnalytics implements Analytics {
initialize() {}
updatePath(_: string) {}
- logEvent(_x: TraceCategories|null, _y: string) {}
- logError(_x: string) {}
+ logEvent(_category: TraceCategories|null, _event: string) {}
+ logError(_err: ErrorDetails) {}
isEnabled(): boolean {
return false;
}
@@ -135,8 +136,24 @@
gtagGlobals.gtag('event', event, {event_category: category});
}
- logError(description: string, fatal = true) {
- gtagGlobals.gtag('event', 'exception', {description, fatal});
+ logError(err: ErrorDetails) {
+ let stack = '';
+ for (const entry of err.stack) {
+ const shortLocation = entry.location.replace('frontend_bundle.js', '$');
+ stack += `${entry.name}(${shortLocation}),`;
+ }
+ // Strip trailing ',' (works also for empty strings without extra checks).
+ stack = stack.substring(0, stack.length - 1);
+
+ gtagGlobals.gtag('event', 'exception', {
+ description: err.message,
+ error_type: err.errType,
+
+ // As per GA4 all field are restrictred to 100 chars.
+ // page_title is the only one restricted to 1000 chars and we use that for
+ // the full crash report.
+ page_location: `http://crash?/${encodeURI(stack)}`,
+ });
}
isEnabled(): boolean {
diff --git a/ui/src/frontend/error_dialog.ts b/ui/src/frontend/error_dialog.ts
index e94bfd7..008f1bc 100644
--- a/ui/src/frontend/error_dialog.ts
+++ b/ui/src/frontend/error_dialog.ts
@@ -14,30 +14,26 @@
import m from 'mithril';
-import {assertExists} from '../base/logging';
+import {assertExists, ErrorDetails} from '../base/logging';
import {EXTENSION_URL} from '../common/recordingV2/recording_utils';
import {TraceUrlSource} from '../common/state';
import {saveTrace} from '../common/upload_utils';
import {RECORDING_V2_FLAG} from '../core/feature_flags';
+import {VERSION} from '../gen/perfetto_version';
import {globals} from './globals';
import {showModal} from './modal';
import {isShareable} from './trace_attrs';
-// Never show more than one dialog per minute.
-const MIN_REPORT_PERIOD_MS = 60000;
+// Never show more than one dialog per 10s.
+const MIN_REPORT_PERIOD_MS = 10000;
let timeLastReport = 0;
-// Keeps the last ERR_QUEUE_MAX_LEN errors while the dialog is throttled.
-const queuedErrors = new Array<string>();
-const ERR_QUEUE_MAX_LEN = 10;
-
-export function maybeShowErrorDialog(errLog: string) {
- globals.logging.logError(errLog);
+export function maybeShowErrorDialog(err: ErrorDetails) {
const now = performance.now();
// Here we rely on the exception message from onCannotGrowMemory function
- if (errLog.includes('Cannot enlarge memory')) {
+ if (err.message.includes('Cannot enlarge memory')) {
showOutOfMemoryDialog();
// Refresh timeLastReport to prevent a different error showing a dialog
timeLastReport = now;
@@ -45,46 +41,37 @@
}
if (!RECORDING_V2_FLAG.get()) {
- if (errLog.includes('Unable to claim interface')) {
+ if (err.message.includes('Unable to claim interface')) {
showWebUSBError();
timeLastReport = now;
return;
}
- if (errLog.includes('A transfer error has occurred') ||
- errLog.includes('The device was disconnected') ||
- errLog.includes('The transfer was cancelled')) {
+ if (err.message.includes('A transfer error has occurred') ||
+ err.message.includes('The device was disconnected') ||
+ err.message.includes('The transfer was cancelled')) {
showConnectionLostError();
timeLastReport = now;
return;
}
}
- if (errLog.includes('(ERR:fmt)')) {
+ if (err.message.includes('(ERR:fmt)')) {
showUnknownFileError();
return;
}
- if (errLog.includes('(ERR:rpc_seq)')) {
+ if (err.message.includes('(ERR:rpc_seq)')) {
showRpcSequencingError();
return;
}
if (timeLastReport > 0 && now - timeLastReport <= MIN_REPORT_PERIOD_MS) {
- queuedErrors.unshift(errLog);
- if (queuedErrors.length > ERR_QUEUE_MAX_LEN) queuedErrors.pop();
console.log('Suppressing crash dialog, last error notified too soon.');
return;
}
timeLastReport = now;
-
- // Append queued errors.
- while (queuedErrors.length > 0) {
- const queuedErr = queuedErrors.shift();
- errLog += `\n\n---------------------------------------\n${queuedErr}`;
- }
-
- const errTitle = errLog.split('\n', 1)[0].substr(0, 80);
+ const errTitle = err.message.split('\n', 1)[0].substring(0, 80);
const userDescription = '';
let checked = false;
const engine = globals.getCurrentEngine();
@@ -98,13 +85,13 @@
checked = (ev.target as HTMLInputElement).checked;
if (checked && engine && engine.source.type === 'FILE') {
saveTrace(engine.source.file).then((url) => {
- const errMessage = createErrorMessage(errLog, checked, url);
+ const errMessage = createErrorMessage(err, checked, url);
renderModal(
errTitle, errMessage, userDescription, shareTraceSection);
return;
});
}
- const errMessage = createErrorMessage(errLog, checked);
+ const errMessage = createErrorMessage(err, checked);
renderModal(
errTitle, errMessage, userDescription, shareTraceSection);
},
@@ -118,7 +105,7 @@
}
renderModal(
errTitle,
- createErrorMessage(errLog, checked),
+ createErrorMessage(err, checked),
userDescription,
shareTraceSection);
}
@@ -171,20 +158,28 @@
engine.source.url !== undefined;
}
-function createErrorMessage(errLog: string, checked: boolean, url?: string) {
- let errMessage = '';
+function createErrorMessage(err: ErrorDetails, checked: boolean, url?: string) {
+ let msg = `UI: ${location.protocol}//${location.host}/${VERSION}\n\n`;
+
+ // Append the trace stack.
+ msg += `${err.message}\n`;
+ for (const entry of err.stack) {
+ msg += ` - ${entry.name} (${entry.location})\n`;
+ }
+ msg += '\n';
+
+ // Append the trace URL.
const engine = globals.getCurrentEngine();
if (checked && url !== undefined) {
- errMessage += `Trace: ${url}`;
+ msg += `Trace: ${url}\n`;
} else if (urlExists()) {
- errMessage +=
- `Trace: ${(assertExists(engine).source as TraceUrlSource).url}`;
+ msg += `Trace: ${(assertExists(engine).source as TraceUrlSource).url}\n`;
} else {
- errMessage += 'To assist with debugging please attach or link to the ' +
- 'trace you were viewing.';
+ msg += 'Trace: not available, please provide repro steps.\n';
}
- return errMessage + '\n\n' +
- 'Viewed on: ' + self.location.origin + '\n\n' + errLog;
+ msg += `UA: ${navigator.userAgent}\n`;
+ msg += `Referrer: ${document.referrer}\n`;
+ return msg;
}
function createLink(
diff --git a/ui/src/frontend/index.ts b/ui/src/frontend/index.ts
index 9ab3d09..581f61c 100644
--- a/ui/src/frontend/index.ts
+++ b/ui/src/frontend/index.ts
@@ -20,7 +20,7 @@
import m from 'mithril';
import {defer} from '../base/deferred';
-import {reportError, setErrorHandler} from '../base/logging';
+import {addErrorHandler, reportError} from '../base/logging';
import {Actions, DeferredAction, StateActions} from '../common/actions';
import {CommandManager} from '../common/commands';
import {createEmptyState} from '../common/empty_state';
@@ -205,8 +205,11 @@
document.head.append(script, css);
+ // Route errors to both the UI bugreport dialog and Analytics (if enabled).
+ addErrorHandler(maybeShowErrorDialog);
+ addErrorHandler((e) => globals.logging.logError(e));
+
// Add Error handlers for JS error and for uncaught exceptions in promises.
- setErrorHandler((err: string) => maybeShowErrorDialog(err));
window.addEventListener('error', (e) => reportError(e));
window.addEventListener('unhandledrejection', (e) => reportError(e));
diff --git a/ui/src/frontend/publish.ts b/ui/src/frontend/publish.ts
index d23be62..052cdea 100644
--- a/ui/src/frontend/publish.ts
+++ b/ui/src/frontend/publish.ts
@@ -153,7 +153,6 @@
export function publishMetricError(error: string) {
globals.setMetricError(error);
- globals.logging.logError(error, false);
globals.publishRedraw();
}
diff --git a/ui/src/frontend/trace_converter.ts b/ui/src/frontend/trace_converter.ts
index a08f622..fb0bc12 100644
--- a/ui/src/frontend/trace_converter.ts
+++ b/ui/src/frontend/trace_converter.ts
@@ -13,6 +13,7 @@
// limitations under the License.
import {download} from '../base/clipboard';
+import {ErrorDetails} from '../base/logging';
import {time} from '../base/time';
import {Actions} from '../common/actions';
import {
@@ -51,7 +52,7 @@
interface ErrorArgs {
kind: 'error';
- error: string;
+ error: ErrorDetails;
}
diff --git a/ui/src/traceconv/index.ts b/ui/src/traceconv/index.ts
index 627df91..d9c6aed 100644
--- a/ui/src/traceconv/index.ts
+++ b/ui/src/traceconv/index.ts
@@ -13,7 +13,12 @@
// limitations under the License.
import {defer} from '../base/deferred';
-import {assertExists, reportError, setErrorHandler} from '../base/logging';
+import {
+ addErrorHandler,
+ assertExists,
+ ErrorDetails,
+ reportError,
+} from '../base/logging';
import {time} from '../base/time';
import {
ConversionJobName,
@@ -60,7 +65,7 @@
});
}
-function forwardError(error: string) {
+function forwardError(error: ErrorDetails) {
selfWorker.postMessage({
kind: 'error',
error,
@@ -221,7 +226,7 @@
selfWorker.onmessage = (msg: MessageEvent) => {
self.addEventListener('error', (e) => reportError(e));
self.addEventListener('unhandledrejection', (e) => reportError(e));
- setErrorHandler((err: string) => forwardError(err));
+ addErrorHandler((error: ErrorDetails) => forwardError(error));
const args = msg.data as Args;
if (isConvertTraceAndDownload(args)) {
ConvertTraceAndDownload(args.trace, args.format, args.truncate);