--- a/browser/modules/BrowserErrorReporter.jsm
+++ b/browser/modules/BrowserErrorReporter.jsm
@@ -50,20 +50,26 @@ const REPORTED_CATEGORIES = new Set([
* 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) {
+ constructor(options = {}) {
// Test arguments for mocks and changing behavior
- this.fetch = fetchMethod;
- this.chromeOnly = chromeOnly;
+ 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),
tags: {
@@ -94,38 +100,38 @@ class BrowserErrorReporter {
logger.manageLevelFromPref(PREF_LOG_LEVEL);
Object.defineProperty(this, "logger", {value: logger});
return this.logger;
}
init() {
if (this.collectionEnabled) {
- Services.console.registerListener(this);
+ 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 {
- Services.console.unregisterListener(this);
+ this.unregisterListener();
} catch (err) {} // It probably wasn't registered.
}
handleEnabledPrefChanged(prefName, previousValue, newValue) {
if (newValue) {
- Services.console.registerListener(this);
+ this.registerListener();
} else {
try {
- Services.console.unregisterListener(this);
+ this.unregisterListener();
} catch (err) {} // It probably wasn't registered.
}
}
async observe(message) {
try {
message.QueryInterface(Ci.nsIScriptError);
} catch (err) {
@@ -139,70 +145,35 @@ class BrowserErrorReporter {
}
// 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}/`;
- });
+ const exceptionValue = {};
+ const transforms = [
+ addErrorMessage,
+ addStacktrace,
+ addModule,
+ mangleExtensionUrls,
+ ];
+ for (const transform of transforms) {
+ await transform(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();
- }
-
- 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, {
+ const requestBody = {
+ ...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,
- }
- },
- ],
+ values: [exceptionValue],
},
- 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, {
@@ -215,18 +186,46 @@ class BrowserErrorReporter {
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) {
+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 {
@@ -251,20 +250,48 @@ class BrowserErrorReporter {
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;
+ 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);
}
- async _defaultFetch(...args) {
- // Do not make network requests while running in automation
- if (Cu.isInAutomation) {
- return null;
+ // 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;
}
- return fetch(...args);
+ 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);
}
}
--- a/browser/modules/test/browser/browser_BrowserErrorReporter.js
+++ b/browser/modules/test/browser/browser_BrowserErrorReporter.js
@@ -11,69 +11,48 @@ registerCleanupFunction(function() {
delete window.sinon;
});
const PREF_ENABLED = "browser.chrome.errorReporter.enabled";
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 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";
function createScriptError(options = {}) {
const scriptError = Cc["@mozilla.org/scripterror;1"].createInstance(Ci.nsIScriptError);
scriptError.init(
options.message || "",
options.sourceName || null,
options.sourceLine || null,
options.lineNumber || null,
options.columnNumber || null,
options.flags || Ci.nsIScriptError.errorFlag,
options.category || "chrome javascript",
);
return scriptError;
}
-// Wrapper around Services.console.logMessage that waits for the message to be
-// logged before resolving, since messages are logged asynchronously.
-function logMessage(message) {
- return new Promise(resolve => {
- Services.console.registerListener({
- observe(loggedMessage) {
- if (loggedMessage.message === message.message) {
- Services.console.unregisterListener(this);
- resolve();
- }
- },
- });
- Services.console.logMessage(message);
- });
+function noop() {
+ // Do nothing
}
// Clears the console of any previous messages. Should be called at the end of
// each test that logs to the console.
function resetConsole() {
Services.console.logStringMessage("");
Services.console.reset();
}
-// Wrapper similar to logMessage, but for logStringMessage.
-function logStringMessage(message) {
- return new Promise(resolve => {
- Services.console.registerListener({
- observe(loggedMessage) {
- if (loggedMessage.message === message) {
- Services.console.unregisterListener(this);
- resolve();
- }
- },
- });
- Services.console.logStringMessage(message);
- });
-}
-
// Finds the fetch spy call for an error with a matching message.
function fetchCallForMessage(fetchSpy, message) {
for (const call of fetchSpy.getCalls()) {
const body = JSON.parse(call.args[1].body);
if (body.exception.values[0].value.includes(message)) {
return call;
}
}
@@ -83,256 +62,235 @@ function fetchCallForMessage(fetchSpy, m
// Helper to test if a fetch spy was called with the given error message.
// Used in tests where unrelated JS errors from other code are logged.
function fetchPassedError(fetchSpy, message) {
return fetchCallForMessage(fetchSpy, message) !== null;
}
add_task(async function testInitPrefDisabled() {
- const fetchSpy = sinon.spy();
- const reporter = new BrowserErrorReporter(fetchSpy);
+ let listening = false;
+ const reporter = new BrowserErrorReporter({
+ registerListener() {
+ listening = true;
+ },
+ });
await SpecialPowers.pushPrefEnv({set: [
[PREF_ENABLED, false],
- [PREF_SAMPLE_RATE, "1.0"],
]});
reporter.init();
- await logMessage(createScriptError({message: "Logged while disabled"}));
- ok(
- !fetchPassedError(fetchSpy, "Logged while disabled"),
- "Reporter does not listen for errors if the enabled pref is false.",
- );
- reporter.uninit();
- resetConsole();
+ ok(!listening, "Reporter does not listen for errors if the enabled pref is false.");
});
add_task(async function testInitUninitPrefEnabled() {
- const fetchSpy = sinon.spy();
- const reporter = new BrowserErrorReporter(fetchSpy);
+ let listening = false;
+ const reporter = new BrowserErrorReporter({
+ registerListener() {
+ listening = true;
+ },
+ unregisterListener() {
+ listening = false;
+ },
+ });
await SpecialPowers.pushPrefEnv({set: [
[PREF_ENABLED, true],
- [PREF_SAMPLE_RATE, "1.0"],
]});
reporter.init();
- await logMessage(createScriptError({message: "Logged after init"}));
- ok(
- fetchPassedError(fetchSpy, "Logged after init"),
- "Reporter listens for errors if the enabled pref is true.",
- );
+ ok(listening, "Reporter listens for errors if the enabled pref is true.");
- fetchSpy.reset();
- ok(!fetchSpy.called, "Fetch spy was reset.");
reporter.uninit();
- await logMessage(createScriptError({message: "Logged after uninit"}));
- ok(
- !fetchPassedError(fetchSpy, "Logged after uninit"),
- "Reporter does not listen for errors after uninit.",
- );
-
- resetConsole();
+ ok(!listening, "Reporter does not listen for errors after uninit.");
});
add_task(async function testInitPastMessages() {
const fetchSpy = sinon.spy();
- const reporter = new BrowserErrorReporter(fetchSpy);
+ const reporter = new BrowserErrorReporter({
+ fetch: fetchSpy,
+ registerListener: noop,
+ unregisterListener: noop,
+ });
await SpecialPowers.pushPrefEnv({set: [
[PREF_ENABLED, true],
[PREF_SAMPLE_RATE, "1.0"],
]});
- await logMessage(createScriptError({message: "Logged before init"}));
+ resetConsole();
+ Services.console.logMessage(createScriptError({message: "Logged before init"}));
reporter.init();
- ok(
- fetchPassedError(fetchSpy, "Logged before init"),
- "Reporter collects errors logged before initialization.",
+
+ // Include ok() to satisfy mochitest warning for test without any assertions
+ const errorWasLogged = await TestUtils.waitForCondition(
+ () => fetchPassedError(fetchSpy, "Logged before init"),
+ "Waiting for message to be logged",
);
- reporter.uninit();
- resetConsole();
+ ok(errorWasLogged, "Reporter collects errors logged before initialization.");
+
});
add_task(async function testEnabledPrefWatcher() {
- const fetchSpy = sinon.spy();
- const reporter = new BrowserErrorReporter(fetchSpy);
+ let listening = false;
+ const reporter = new BrowserErrorReporter({
+ registerListener() {
+ listening = true;
+ },
+ unregisterListener() {
+ listening = false;
+ },
+ });
await SpecialPowers.pushPrefEnv({set: [
[PREF_ENABLED, false],
- [PREF_SAMPLE_RATE, "1.0"],
]});
reporter.init();
- await logMessage(createScriptError({message: "Shouldn't report"}));
- ok(
- !fetchPassedError(fetchSpy, "Shouldn't report"),
- "Reporter does not collect errors if the enable pref is false.",
- );
+ ok(!listening, "Reporter does not collect errors if the enable pref is false.");
+ Services.console.logMessage(createScriptError({message: "Shouldn't report"}));
await SpecialPowers.pushPrefEnv({set: [
[PREF_ENABLED, true],
]});
- ok(
- !fetchPassedError(fetchSpy, "Shouldn't report"),
- "Reporter does not collect past-logged errors if it is enabled mid-run.",
- );
- await logMessage(createScriptError({message: "Should report"}));
- ok(
- fetchPassedError(fetchSpy, "Should report"),
- "Reporter collects errors logged after the enabled pref is turned on mid-run",
- );
-
- reporter.uninit();
- resetConsole();
+ ok(listening, "Reporter collects errors if the enabled pref switches to true.");
});
add_task(async function testNonErrorLogs() {
const fetchSpy = sinon.spy();
- const reporter = new BrowserErrorReporter(fetchSpy);
+ const reporter = new BrowserErrorReporter({fetch: fetchSpy});
await SpecialPowers.pushPrefEnv({set: [
[PREF_ENABLED, true],
[PREF_SAMPLE_RATE, "1.0"],
]});
- reporter.init();
-
- await logStringMessage("Not a scripterror instance.");
+ reporter.observe({message: "Not a scripterror instance."});
ok(
!fetchPassedError(fetchSpy, "Not a scripterror instance."),
"Reporter does not collect normal log messages or warnings.",
);
- await logMessage(createScriptError({
+ await reporter.observe(createScriptError({
message: "Warning message",
flags: Ci.nsIScriptError.warningFlag,
}));
ok(
!fetchPassedError(fetchSpy, "Warning message"),
"Reporter does not collect normal log messages or warnings.",
);
- await logMessage(createScriptError({
+ await reporter.observe(createScriptError({
message: "Non-chrome category",
category: "totally from a website",
}));
ok(
!fetchPassedError(fetchSpy, "Non-chrome category"),
"Reporter does not collect normal log messages or warnings.",
);
- await logMessage(createScriptError({message: "Is error"}));
+ await reporter.observe(createScriptError({message: "Is error"}));
ok(
fetchPassedError(fetchSpy, "Is error"),
"Reporter collects error messages.",
);
-
- reporter.uninit();
- resetConsole();
});
add_task(async function testSampling() {
const fetchSpy = sinon.spy();
- const reporter = new BrowserErrorReporter(fetchSpy);
+ const reporter = new BrowserErrorReporter({fetch: fetchSpy});
await SpecialPowers.pushPrefEnv({set: [
[PREF_ENABLED, true],
[PREF_SAMPLE_RATE, "1.0"],
]});
- reporter.init();
- await logMessage(createScriptError({message: "Should log"}));
+ await reporter.observe(createScriptError({message: "Should log"}));
ok(
fetchPassedError(fetchSpy, "Should log"),
"A 1.0 sample rate will cause the reporter to always collect errors.",
);
await SpecialPowers.pushPrefEnv({set: [
[PREF_SAMPLE_RATE, "0.0"],
]});
- await logMessage(createScriptError({message: "Shouldn't log"}));
+ await reporter.observe(createScriptError({message: "Shouldn't log"}));
ok(
!fetchPassedError(fetchSpy, "Shouldn't log"),
"A 0.0 sample rate will cause the reporter to never collect errors.",
);
await SpecialPowers.pushPrefEnv({set: [
[PREF_SAMPLE_RATE, ")fasdf"],
]});
- await logMessage(createScriptError({message: "Also shouldn't log"}));
+ await reporter.observe(createScriptError({message: "Also shouldn't log"}));
ok(
!fetchPassedError(fetchSpy, "Also shouldn't log"),
"An invalid sample rate will cause the reporter to never collect errors.",
);
-
- reporter.uninit();
- resetConsole();
});
add_task(async function testNameMessage() {
const fetchSpy = sinon.spy();
- const reporter = new BrowserErrorReporter(fetchSpy);
+ const reporter = new BrowserErrorReporter({fetch: fetchSpy});
await SpecialPowers.pushPrefEnv({set: [
[PREF_ENABLED, true],
[PREF_SAMPLE_RATE, "1.0"],
]});
- reporter.init();
- await logMessage(createScriptError({message: "No name"}));
+ await reporter.observe(createScriptError({message: "No name"}));
let call = fetchCallForMessage(fetchSpy, "No name");
let body = JSON.parse(call.args[1].body);
is(
body.exception.values[0].type,
"Error",
"Reporter uses a generic type when no name is in the message.",
);
is(
body.exception.values[0].value,
"No name",
"Reporter uses error message as the exception value.",
);
- await logMessage(createScriptError({message: "FooError: Has name"}));
+ await reporter.observe(createScriptError({message: "FooError: Has name"}));
call = fetchCallForMessage(fetchSpy, "Has name");
body = JSON.parse(call.args[1].body);
is(
body.exception.values[0].type,
"FooError",
"Reporter uses the error type from the message.",
);
is(
body.exception.values[0].value,
"Has name",
"Reporter uses error message as the value parameter.",
);
- await logMessage(createScriptError({message: "FooError: Has :extra: colons"}));
+ await reporter.observe(createScriptError({message: "FooError: Has :extra: colons"}));
call = fetchCallForMessage(fetchSpy, "Has :extra: colons");
body = JSON.parse(call.args[1].body);
is(
body.exception.values[0].type,
"FooError",
"Reporter uses the error type from the message.",
);
is(
body.exception.values[0].value,
"Has :extra: colons",
"Reporter uses error message as the value parameter.",
);
- reporter.uninit();
- resetConsole();
});
add_task(async function testFetchArguments() {
const fetchSpy = sinon.spy();
- const reporter = new BrowserErrorReporter(fetchSpy);
+ const reporter = new BrowserErrorReporter({fetch: fetchSpy});
await SpecialPowers.pushPrefEnv({set: [
[PREF_ENABLED, true],
[PREF_SAMPLE_RATE, "1.0"],
[PREF_PROJECT_ID, "123"],
[PREF_PUBLIC_KEY, "foobar"],
[PREF_SUBMIT_URL, "https://errors.example.com/api/123/store/"],
]});
+ resetConsole();
reporter.init();
const testPageUrl = (
"chrome://mochitests/content/browser/browser/modules/test/browser/" +
"browser_BrowserErrorReporter.html"
);
SimpleTest.expectUncaughtException();
await BrowserTestUtils.withNewTab(testPageUrl, async () => {
@@ -400,28 +358,28 @@ add_task(async function testFetchArgumen
},
],
},
"Reporter builds stack trace from scriptError correctly.",
);
});
reporter.uninit();
- resetConsole();
});
add_task(async function testAddonIDMangle() {
const fetchSpy = sinon.spy();
// Passing false here disables category checks on errors, which would
// otherwise block errors directly from extensions.
- const reporter = new BrowserErrorReporter(fetchSpy, false);
+ const reporter = new BrowserErrorReporter({fetch: fetchSpy, chromeOnly: false});
await SpecialPowers.pushPrefEnv({set: [
[PREF_ENABLED, true],
[PREF_SAMPLE_RATE, "1.0"],
]});
+ resetConsole();
reporter.init();
// Create and install test add-on
const id = "browsererrorcollection@example.com";
const extension = ExtensionTestUtils.loadExtension({
manifest: {
applications: {
gecko: { id },
@@ -442,10 +400,9 @@ add_task(async function testAddonIDMangl
const stackFrame = body.exception.values[0].stacktrace.frames[0];
ok(
stackFrame.module.startsWith(`moz-extension://${id}/`),
"Stack frame filenames use the proper add-on ID instead of internal UUIDs.",
);
await extension.unload();
reporter.uninit();
- resetConsole();
});