browser/modules/BrowserErrorReporter.jsm
author L10n Bumper Bot <release+l10nbumper@mozilla.com>
Mon, 14 Jan 2019 05:00:14 -0800
changeset 506681 ac40678709f6f3af82e30819cea10260b6e497fc
parent 489310 d7fcfbc15cfe5e33cce5a12ff009e9b6aec07811
permissions -rw-r--r--
no bug - Bumping Fennec l10n changesets r=release a=l10n-bump DONTBUILD en-CA -> 1df3b5370c30 es-CL -> ff997f81eae6 es-MX -> 7d161fdf265c eu -> 506f20acd46d hi-IN -> 504d2fde3fc7 hu -> 6477fd6ddf64 mai -> d524e539eefb nn-NO -> 656adee29da6 sq -> f7c2ccfb7710 ta -> 047b3e205cca ur -> 03708a03f1b8 zh-CN -> b9c275f71f13 zh-TW -> f2b2ea4cd960

/* 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");

XPCOMUtils.defineLazyGlobalGetters(this, ["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 RECENT_BUILD_AGE = 1000 * 60 * 60 * 24 * 7; // 7 days
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|devtools)/,
  /^chrome:\/\/(?:global|browser|devtools)/,
]);

// Mapping of regexes to sample rates; if the regex matches the module an error
// is thrown from, the matching sample rate is used instead of the default.
// In case of a conflict, the first matching rate by insertion order is used.
const MODULE_SAMPLE_RATES = new Map([
  [/^(?:chrome|resource):\/\/devtools/, 1],
  [/^moz-extension:\/\//, 0],
]);

/**
 * 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 {
  /**
   * Generate a Date object corresponding to the date in the appBuildId.
   */
  static getAppBuildIdDate() {
    const appBuildId = Services.appinfo.appBuildID;
    const buildYear = Number.parseInt(appBuildId.slice(0, 4));
    // Date constructor uses 0-indexed months
    const buildMonth = Number.parseInt(appBuildId.slice(4, 6)) - 1;
    const buildDay = Number.parseInt(appBuildId.slice(6, 8));
    return new Date(buildYear, buildMonth, buildDay);
  }

  constructor(options = {}) {
    // Test arguments for mocks and changing behavior
    const defaultOptions = {
      fetch: defaultFetch,
      now: null,
      chromeOnly: true,
      sampleRates: MODULE_SAMPLE_RATES,
      registerListener: () => Services.console.registerListener(this),
      unregisterListener: () => Services.console.unregisterListener(this),
    };
    for (const [key, defaultValue] of Object.entries(defaultOptions)) {
      this[key] = key in options ? options[key] : defaultValue;
    }

    XPCOMUtils.defineLazyGetter(this, "appBuildIdDate", BrowserErrorReporter.getAppBuildIdDate);

    // Values that don't change between error reports.
    this.requestBodyTemplate = {
      logger: "javascript",
      platform: "javascript",
      release: Services.appinfo.appBuildID,
      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: {
        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),
    );

    // Prefix mappings for the mangleFilePaths transform.
    this.manglePrefixes = options.manglePrefixes || {
      greDir: Services.dirsvc.get("GreD", Ci.nsIFile),
      profileDir: Services.dirsvc.get("ProfD", Ci.nsIFile),
    };
    // File paths are encoded by nsIURI, so let's do the same for the prefixes
    // we're comparing them to.
    for (const [name, prefixFile] of Object.entries(this.manglePrefixes)) {
      let filePath = Services.io.newFileURI(prefixFile).filePath;

      // filePath might not have a trailing slash in some cases
      if (!filePath.endsWith("/")) {
        filePath += "/";
      }

      this.manglePrefixes[name] = filePath;
    }
  }

  /**
   * 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);
  }

  errorCollectedFilenameKey(filename) {
    for (const pattern of TELEMETRY_REPORTED_PATTERNS) {
      if (filename.match(pattern)) {
        return filename;
      }
    }

    // WebExtensions get grouped separately from other errors
    if (filename.startsWith("moz-extension://")) {
        return "MOZEXTENSION";
    }

    return "FILTERED";
  }

  isRecentBuild() {
    // The local clock is not reliable, but this method doesn't need to be
    // perfect.
    const now = this.now || new Date();
    return (now - this.appBuildIdDate) <= RECENT_BUILD_AGE;
  }

  observe(message) {
    if (message instanceof Ci.nsIScriptError) {
      ChromeUtils.idleDispatch(() => this.handleMessage(message));
    }
  }

  async handleMessage(message) {
    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) {
      const key = this.errorCollectedFilenameKey(message.sourceName);
      Services.telemetry.keyedScalarAdd(TELEMETRY_ERROR_COLLECTED_FILENAME, key.slice(0, 69), 1);
    }

    // We do not collect errors on non-Nightly channels, just telemetry.
    // Also, old builds should not send errors to Sentry
    if (!AppConstants.NIGHTLY_BUILD || !this.isRecentBuild()) {
      return;
    }

    // Sample the amount of errors we send out
    let sampleRate = Number.parseFloat(this.sampleRatePref);
    for (const [regex, rate] of this.sampleRates) {
      if (message.sourceName.match(regex)) {
        sampleRate = rate;
        break;
      }
    }
    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],
      },
    };

    const transforms = [
      addErrorMessage,
      addStacktrace,
      addModule,
      mangleExtensionUrls,
      this.mangleFilePaths.bind(this),
      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 "${message.errorMessage}" successfully.`);
    } catch (error) {
      Services.telemetry.scalarAdd(TELEMETRY_ERROR_REPORTED_FAIL, 1);
      this.logger.warn(`Failed to send error "${message.errorMessage}": ${error}`);
    }
  }

  /**
   * Alters file: and jar: paths to remove leading file paths that may contain
   * user-identifying or platform-specific paths.
   *
   * prefixes is a mapping of replacementName -> filePath, where filePath is a
   * path on the filesystem that should be replaced, and replacementName is the
   * text that will replace it.
   */
  mangleFilePaths(message, exceptionValue) {
    exceptionValue.module = this._transformFilePath(exceptionValue.module);
    for (const frame of exceptionValue.stacktrace.frames) {
      frame.module = this._transformFilePath(frame.module);
    }
  }

  _transformFilePath(path) {
    try {
      const uri = Services.io.newURI(path);
      if (uri.schemeIs("jar")) {
        return uri.filePath;
      }
      if (uri.schemeIs("file")) {
        for (const [name, prefix] of Object.entries(this.manglePrefixes)) {
          if (uri.filePath.startsWith(prefix)) {
            return uri.filePath.replace(prefix, `[${name}]/`);
          }
        }

        return "[UNKNOWN_LOCAL_FILEPATH]";
      }
    } catch (err) {}

    return path;
  }
}

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;
    }

    const re = new RegExp(`${anchored ? "^" : ""}moz-extension://([^/]+)/`, "g");
    return string.replace(re, (m0, m1) => {
      const 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) {
  requestBody.tags.isExtensionError = !!(
      exceptionValue.module && exceptionValue.module.startsWith("moz-extension://")
  );
}