gecko-dev/browser/modules/BrowserErrorReporter.jsm
2018-03-20 20:31:57 -07:00

376 lines
12 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
ChromeUtils.import("resource://gre/modules/AppConstants.jsm");
ChromeUtils.import("resource://gre/modules/Services.jsm");
ChromeUtils.import("resource://gre/modules/Timer.jsm");
ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
ChromeUtils.defineModuleGetter(this, "Log", "resource://gre/modules/Log.jsm");
ChromeUtils.defineModuleGetter(this, "UpdateUtils", "resource://gre/modules/UpdateUtils.jsm");
Cu.importGlobalProperties(["fetch", "URL"]);
var EXPORTED_SYMBOLS = ["BrowserErrorReporter"];
const CONTEXT_LINES = 5;
const ERROR_PREFIX_RE = /^[^\W]+:/m;
const PREF_ENABLED = "browser.chrome.errorReporter.enabled";
const PREF_LOG_LEVEL = "browser.chrome.errorReporter.logLevel";
const PREF_PROJECT_ID = "browser.chrome.errorReporter.projectId";
const PREF_PUBLIC_KEY = "browser.chrome.errorReporter.publicKey";
const PREF_SAMPLE_RATE = "browser.chrome.errorReporter.sampleRate";
const PREF_SUBMIT_URL = "browser.chrome.errorReporter.submitUrl";
const SDK_NAME = "firefox-error-reporter";
const SDK_VERSION = "1.0.0";
const TELEMETRY_ERROR_COLLECTED = "browser.errors.collected_count";
const TELEMETRY_ERROR_COLLECTED_FILENAME = "browser.errors.collected_count_by_filename";
const TELEMETRY_ERROR_COLLECTED_STACK = "browser.errors.collected_with_stack_count";
const TELEMETRY_ERROR_REPORTED = "browser.errors.reported_success_count";
const TELEMETRY_ERROR_REPORTED_FAIL = "browser.errors.reported_failure_count";
const TELEMETRY_ERROR_SAMPLE_RATE = "browser.errors.sample_rate";
// https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIScriptError#Categories
const REPORTED_CATEGORIES = new Set([
"XPConnect JavaScript",
"component javascript",
"chrome javascript",
"chrome registration",
"XBL",
"XBL Prototype Handler",
"XBL Content Sink",
"xbl javascript",
"FrameConstructor",
]);
const PLATFORM_NAMES = {
linux: "Linux",
win: "Windows",
macosx: "macOS",
android: "Android",
};
// Filename URI regexes that we are okay with reporting to Telemetry. URIs not
// matching these patterns may contain local file paths.
const TELEMETRY_REPORTED_PATTERNS = new Set([
/^resource:\/\/(?:\/|gre)/,
/^chrome:\/\/(?:global|browser|devtools)/,
]);
/**
* Collects nsIScriptError messages logged to the browser console and reports
* them to a remotely-hosted error collection service.
*
* This is a PROTOTYPE; it will be removed in the future and potentially
* replaced with a more robust implementation. It is meant to only collect
* errors from Nightly (and local builds if enabled for development purposes)
* and has not been reviewed for use outside of Nightly.
*
* The outgoing requests are designed to be compatible with Sentry. See
* https://docs.sentry.io/clientdev/ for details on the data format that Sentry
* expects.
*
* Errors may contain PII, such as in messages or local file paths in stack
* traces; see bug 1426482 for privacy review and server-side mitigation.
*/
class BrowserErrorReporter {
constructor(options = {}) {
// Test arguments for mocks and changing behavior
this.fetch = options.fetch || defaultFetch;
this.chromeOnly = options.chromeOnly !== undefined ? options.chromeOnly : true;
this.registerListener = (
options.registerListener || (() => Services.console.registerListener(this))
);
this.unregisterListener = (
options.unregisterListener || (() => Services.console.unregisterListener(this))
);
// Values that don't change between error reports.
this.requestBodyTemplate = {
logger: "javascript",
platform: "javascript",
release: Services.appinfo.version,
environment: UpdateUtils.getUpdateChannel(false),
contexts: {
os: {
name: PLATFORM_NAMES[AppConstants.platform],
version: (
Cc["@mozilla.org/network/protocol;1?name=http"]
.getService(Ci.nsIHttpProtocolHandler)
.oscpu
),
},
browser: {
name: "Firefox",
version: Services.appinfo.version,
},
},
tags: {
appBuildID: Services.appinfo.appBuildID,
changeset: AppConstants.SOURCE_REVISION_URL,
},
sdk: {
name: SDK_NAME,
version: SDK_VERSION,
},
};
XPCOMUtils.defineLazyPreferenceGetter(
this,
"collectionEnabled",
PREF_ENABLED,
false,
this.handleEnabledPrefChanged.bind(this),
);
XPCOMUtils.defineLazyPreferenceGetter(
this,
"sampleRatePref",
PREF_SAMPLE_RATE,
"0.0",
this.handleSampleRatePrefChanged.bind(this),
);
}
/**
* Lazily-created logger
*/
get logger() {
const logger = Log.repository.getLogger("BrowserErrorReporter");
logger.addAppender(new Log.ConsoleAppender(new Log.BasicFormatter()));
logger.manageLevelFromPref(PREF_LOG_LEVEL);
Object.defineProperty(this, "logger", {value: logger});
return this.logger;
}
init() {
if (this.collectionEnabled) {
this.registerListener();
// Processing already-logged messages in case any errors occurred before
// startup.
for (const message of Services.console.getMessageArray()) {
this.observe(message);
}
}
}
uninit() {
try {
this.unregisterListener();
} catch (err) {} // It probably wasn't registered.
}
handleEnabledPrefChanged(prefName, previousValue, newValue) {
if (newValue) {
this.registerListener();
} else {
try {
this.unregisterListener();
} catch (err) {} // It probably wasn't registered.
}
}
handleSampleRatePrefChanged(prefName, previousValue, newValue) {
Services.telemetry.scalarSet(TELEMETRY_ERROR_SAMPLE_RATE, newValue);
}
shouldReportFilename(filename) {
for (const pattern of TELEMETRY_REPORTED_PATTERNS) {
if (filename.match(pattern)) {
return true;
}
}
return false;
}
async observe(message) {
try {
message.QueryInterface(Ci.nsIScriptError);
} catch (err) {
return; // Not an error
}
const isWarning = message.flags & message.warningFlag;
const isFromChrome = REPORTED_CATEGORIES.has(message.category);
if ((this.chromeOnly && !isFromChrome) || isWarning) {
return;
}
// Record that we collected an error prior to applying the sample rate
Services.telemetry.scalarAdd(TELEMETRY_ERROR_COLLECTED, 1);
if (message.stack) {
Services.telemetry.scalarAdd(TELEMETRY_ERROR_COLLECTED_STACK, 1);
}
if (message.sourceName) {
let filename = "FILTERED";
if (this.shouldReportFilename(message.sourceName)) {
filename = message.sourceName;
}
Services.telemetry.keyedScalarAdd(TELEMETRY_ERROR_COLLECTED_FILENAME, filename.slice(0, 69), 1);
}
// Sample the amount of errors we send out
const sampleRate = Number.parseFloat(this.sampleRatePref);
if (!Number.isFinite(sampleRate) || (Math.random() >= sampleRate)) {
return;
}
const exceptionValue = {};
const requestBody = {
...this.requestBodyTemplate,
timestamp: new Date().toISOString().slice(0, -1), // Remove trailing "Z"
project: Services.prefs.getCharPref(PREF_PROJECT_ID),
exception: {
values: [exceptionValue],
},
tags: {},
};
const transforms = [
addErrorMessage,
addStacktrace,
addModule,
mangleExtensionUrls,
tagExtensionErrors,
];
for (const transform of transforms) {
await transform(message, exceptionValue, requestBody);
}
const url = new URL(Services.prefs.getCharPref(PREF_SUBMIT_URL));
url.searchParams.set("sentry_client", `${SDK_NAME}/${SDK_VERSION}`);
url.searchParams.set("sentry_version", "7");
url.searchParams.set("sentry_key", Services.prefs.getCharPref(PREF_PUBLIC_KEY));
try {
await this.fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
},
// Sentry throws an auth error without a referrer specified.
referrer: "https://fake.mozilla.org",
body: JSON.stringify(requestBody)
});
Services.telemetry.scalarAdd(TELEMETRY_ERROR_REPORTED, 1);
this.logger.debug("Sent error successfully.");
} catch (error) {
Services.telemetry.scalarAdd(TELEMETRY_ERROR_REPORTED_FAIL, 1);
this.logger.warn(`Failed to send error: ${error}`);
}
}
}
function defaultFetch(...args) {
// Do not make network requests while running in automation
if (Cu.isInAutomation) {
return null;
}
return fetch(...args);
}
function addErrorMessage(message, exceptionValue) {
// Parse the error type from the message if present (e.g. "TypeError: Whoops").
let errorMessage = message.errorMessage;
let errorName = "Error";
if (message.errorMessage.match(ERROR_PREFIX_RE)) {
const parts = message.errorMessage.split(":");
errorName = parts[0];
errorMessage = parts.slice(1).join(":").trim();
}
exceptionValue.type = errorName;
exceptionValue.value = errorMessage;
}
async function addStacktrace(message, exceptionValue) {
const frames = [];
let frame = message.stack;
// Avoid an infinite loop by limiting traces to 100 frames.
while (frame && frames.length < 100) {
const normalizedFrame = {
function: frame.functionDisplayName,
module: frame.source,
lineno: frame.line,
colno: frame.column,
};
try {
const response = await fetch(frame.source);
const sourceCode = await response.text();
const sourceLines = sourceCode.split(/\r?\n/);
// HTML pages and some inline event handlers have 0 as their line number
let lineIndex = Math.max(frame.line - 1, 0);
// XBL line numbers are off by one, and pretty much every XML file with JS
// in it is an XBL file.
if (frame.source.endsWith(".xml") && lineIndex > 0) {
lineIndex--;
}
normalizedFrame.context_line = sourceLines[lineIndex];
normalizedFrame.pre_context = sourceLines.slice(
Math.max(lineIndex - CONTEXT_LINES, 0),
lineIndex,
);
normalizedFrame.post_context = sourceLines.slice(
lineIndex + 1,
Math.min(lineIndex + 1 + CONTEXT_LINES, sourceLines.length),
);
} catch (err) {
// Could be a fetch issue, could be a line index issue. Not much we can
// do to recover in either case.
}
frames.push(normalizedFrame);
frame = frame.parent;
}
// Frames are sent in order from oldest to newest.
frames.reverse();
exceptionValue.stacktrace = {frames};
}
function addModule(message, exceptionValue) {
exceptionValue.module = message.sourceName;
}
function mangleExtensionUrls(message, exceptionValue) {
const extensions = new Map();
for (let extension of WebExtensionPolicy.getActiveExtensions()) {
extensions.set(extension.mozExtensionHostname, extension);
}
// Replaces any instances of moz-extension:// URLs with internal UUIDs to use
// the add-on ID instead.
function mangleExtURL(string, anchored = true) {
if (!string) {
return string;
}
let re = new RegExp(`${anchored ? "^" : ""}moz-extension://([^/]+)/`, "g");
return string.replace(re, (m0, m1) => {
let id = extensions.has(m1) ? extensions.get(m1).id : m1;
return `moz-extension://${id}/`;
});
}
exceptionValue.value = mangleExtURL(exceptionValue.value, false);
exceptionValue.module = mangleExtURL(exceptionValue.module);
for (const frame of exceptionValue.stacktrace.frames) {
frame.module = mangleExtURL(frame.module);
}
}
function tagExtensionErrors(message, exceptionValue, requestBody) {
if (exceptionValue.module && exceptionValue.module.startsWith("moz-extension://")) {
requestBody.tags.isExtensionError = true;
}
}