/* 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"; // 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", ]); /** * 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(fetchMethod = this._defaultFetch, chromeOnly = true) { // Test arguments for mocks and changing behavior this.fetch = fetchMethod; this.chromeOnly = chromeOnly; // Values that don't change between error reports. this.requestBodyTemplate = { logger: "javascript", platform: "javascript", release: Services.appinfo.version, environment: UpdateUtils.getUpdateChannel(false), 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), ); } /** * 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) { Services.console.registerListener(this); // Processing already-logged messages in case any errors occurred before // startup. for (const message of Services.console.getMessageArray()) { this.observe(message); } } } uninit() { try { Services.console.unregisterListener(this); } catch (err) {} // It probably wasn't registered. } handleEnabledPrefChanged(prefName, previousValue, newValue) { if (newValue) { Services.console.registerListener(this); } else { try { Services.console.unregisterListener(this); } catch (err) {} // It probably wasn't registered. } } 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; } // Sample the amount of errors we send out const sampleRate = Number.parseFloat(Services.prefs.getCharPref(PREF_SAMPLE_RATE)); if (!Number.isFinite(sampleRate) || (Math.random() >= sampleRate)) { return; } 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) { 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}/`; }); } // 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(); } const frames = []; let frame = message.stack; // Avoid an infinite loop by limiting traces to 100 frames. while (frame && frames.length < 100) { const normalizedFrame = await this.normalizeStackFrame(frame); normalizedFrame.module = mangleExtURL(normalizedFrame.module, false); frames.push(normalizedFrame); frame = frame.parent; } // Frames are sent in order from oldest to newest. frames.reverse(); const requestBody = Object.assign({}, this.requestBodyTemplate, { timestamp: new Date().toISOString().slice(0, -1), // Remove trailing "Z" project: Services.prefs.getCharPref(PREF_PROJECT_ID), exception: { values: [ { type: errorName, value: mangleExtURL(errorMessage), module: message.sourceName, stacktrace: { frames, } }, ], }, culprit: message.sourceName, }); 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) }); this.logger.debug("Sent error successfully."); } catch (error) { this.logger.warn(`Failed to send error: ${error}`); } } async normalizeStackFrame(frame) { 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. } return normalizedFrame; } async _defaultFetch(...args) { // Do not make network requests while running in automation if (Cu.isInAutomation) { return null; } return fetch(...args); } }