gecko-dev/toolkit/crashreporter/CrashSubmit.sys.mjs

641 lines
18 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/. */
const SUCCESS = "success";
const FAILED = "failed";
const SUBMITTING = "submitting";
const UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const SUBMISSION_REGEX =
/^bp-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function getDir(name) {
let uAppDataPath = Services.dirsvc.get("UAppData", Ci.nsIFile).path;
return PathUtils.join(uAppDataPath, "Crash Reports", name);
}
async function writeFileAsync(dirName, fileName, data) {
let dirPath = getDir(dirName);
let filePath = PathUtils.join(dirPath, fileName);
await IOUtils.makeDirectory(dirPath, { permissions: 0o700 });
await IOUtils.writeUTF8(filePath, data);
}
function getPendingMinidump(id) {
let pendingDir = getDir("pending");
return [".dmp", ".extra", ".memory.json.gz"].map(suffix => {
return PathUtils.join(pendingDir, `${id}${suffix}`);
});
}
async function writeSubmittedReportAsync(crashID, viewURL) {
// Since we're basically re-implementing (with async) part of the
// crashreporter client here, we'll use the strings we need from the
// crashreporter fluent file.
const l10n = new Localization(["crashreporter/crashreporter.ftl"]);
let data = await l10n.formatValue("crashreporter-crash-identifier", {
id: crashID,
});
if (viewURL) {
data +=
"\n" +
(await l10n.formatValue("crashreporter-crash-details", { url: viewURL }));
}
await writeFileAsync("submitted", `${crashID}.txt`, data);
}
// the Submitter class represents an individual submission.
function Submitter(id, recordSubmission, noThrottle, extraExtraKeyVals) {
this.id = id;
this.recordSubmission = recordSubmission;
this.noThrottle = noThrottle;
this.additionalDumps = [];
this.extraKeyVals = extraExtraKeyVals;
// mimic deferred Promise behavior
this.submitStatusPromise = new Promise((resolve, reject) => {
this.resolveSubmitStatusPromise = resolve;
this.rejectSubmitStatusPromise = reject;
});
}
Submitter.prototype = {
submitSuccess: async function Submitter_submitSuccess(ret) {
// Write out the details file to submitted
await writeSubmittedReportAsync(ret.CrashID, ret.ViewURL);
try {
let toDelete = [this.dump, this.extra];
if (this.memory) {
toDelete.push(this.memory);
}
for (let entry of this.additionalDumps) {
toDelete.push(entry.dump);
}
await Promise.all(
toDelete.map(path => {
return IOUtils.remove(path, { ignoreAbsent: true });
})
);
} catch (ex) {
console.error(ex);
}
this.notifyStatus(SUCCESS, ret);
this.cleanup();
},
cleanup: function Submitter_cleanup() {
// drop some references just to be nice
this.iframe = null;
this.dump = null;
this.extra = null;
this.memory = null;
this.additionalDumps = null;
// remove this object from the list of active submissions
let idx = CrashSubmit._activeSubmissions.indexOf(this);
if (idx != -1) {
CrashSubmit._activeSubmissions.splice(idx, 1);
}
},
parseResponse: function Submitter_parseResponse(response) {
let parsedResponse = {};
for (let line of response.split("\n")) {
let data = line.split("=");
if (
(data.length == 2 &&
data[0] == "CrashID" &&
SUBMISSION_REGEX.test(data[1])) ||
data[0] == "ViewURL"
) {
parsedResponse[data[0]] = data[1];
}
}
return parsedResponse;
},
submitForm: function Submitter_submitForm() {
if (!("ServerURL" in this.extraKeyVals)) {
return false;
}
let serverURL = this.extraKeyVals.ServerURL;
delete this.extraKeyVals.ServerURL;
// Override the submission URL from the environment
let envOverride = Services.env.get("MOZ_CRASHREPORTER_URL");
if (envOverride != "") {
serverURL = envOverride;
}
let xhr = new XMLHttpRequest();
xhr.open("POST", serverURL, true);
let formData = new FormData();
// tell the server not to throttle this if requested
this.extraKeyVals.Throttleable = this.noThrottle ? "0" : "1";
// add the data
let payload = Object.assign({}, this.extraKeyVals);
let json = new Blob([JSON.stringify(payload)], {
type: "application/json",
});
formData.append("extra", json);
// add the minidumps
let promises = [
File.createFromFileName(this.dump, {
type: "application/octet-stream",
}).then(file => {
formData.append("upload_file_minidump", file);
}),
];
if (this.memory) {
promises.push(
File.createFromFileName(this.memory, {
type: "application/gzip",
}).then(file => {
formData.append("memory_report", file);
})
);
}
if (this.additionalDumps.length) {
let names = [];
for (let i of this.additionalDumps) {
names.push(i.name);
promises.push(
File.createFromFileName(i.dump, {
type: "application/octet-stream",
}).then(file => {
formData.append("upload_file_minidump_" + i.name, file);
})
);
}
}
let manager = Services.crashmanager;
let submissionID = manager.generateSubmissionID();
xhr.addEventListener("readystatechange", () => {
if (xhr.readyState == 4) {
let ret =
xhr.status === 200 ? this.parseResponse(xhr.responseText) : {};
let failmsg;
if (xhr.status !== 200) {
const xhrStatus = code => {
switch (code) {
case 400:
return xhr.responseText;
case 413:
return "Discarded=post_body_too_large";
default:
return "Discarded=unknown_error";
}
};
let err = xhrStatus(xhr.status);
if (err.length && err.startsWith("Discarded=")) {
// Place the error code after, otherwise JS will complain we start
// with a number when dealing with the telemetry value on JS side
const errMsg = `${err.split("Discarded=")[1]}_${xhr.status}`;
Glean.crashSubmission.collectorErrors[errMsg].add();
failmsg = `received bad response: ${xhr.status} ${err}`;
}
if (xhr.status === 0) {
Glean.crashSubmission.channelStatus[xhr.channel.status].add();
}
}
let submitted = !!ret.CrashID;
let p = Promise.resolve();
if (this.recordSubmission) {
let result = submitted
? manager.SUBMISSION_RESULT_OK
: manager.SUBMISSION_RESULT_FAILED;
p = manager.addSubmissionResult(
this.id,
submissionID,
new Date(),
result
);
if (submitted) {
manager.setRemoteCrashID(this.id, ret.CrashID);
}
}
p.then(() => {
if (submitted) {
this.submitSuccess(ret);
} else {
this.notifyStatus(
FAILED,
failmsg || "did not receive a crash ID in server response"
);
this.cleanup();
}
});
}
});
let p = Promise.all(promises);
let id = this.id;
if (this.recordSubmission) {
p = p.then(() => {
return manager.addSubmissionAttempt(id, submissionID, new Date());
});
}
p.then(() => {
xhr.send(formData);
});
return true;
},
// `ret` is determined based on `status`:
// * `SUCCESS` - `ret` should be an object with submission details.
// * `FAILED` - `ret` should be a string with additional information about
// the failure.
notifyStatus: function Submitter_notify(status, ret) {
let propBag = Cc["@mozilla.org/hash-property-bag;1"].createInstance(
Ci.nsIWritablePropertyBag2
);
propBag.setPropertyAsAString("minidumpID", this.id);
if (status == SUCCESS) {
propBag.setPropertyAsAString("serverCrashID", ret.CrashID);
}
let extraKeyValsBag = Cc["@mozilla.org/hash-property-bag;1"].createInstance(
Ci.nsIWritablePropertyBag2
);
for (let key in this.extraKeyVals) {
extraKeyValsBag.setPropertyAsAString(key, this.extraKeyVals[key]);
}
propBag.setPropertyAsInterface("extra", extraKeyValsBag);
Services.obs.notifyObservers(propBag, "crash-report-status", status);
switch (status) {
case SUCCESS:
this.resolveSubmitStatusPromise(ret.CrashID);
Glean.crashSubmission.success.add(1);
break;
case FAILED:
this.rejectSubmitStatusPromise(`${FAILED}: ${ret}`);
Glean.crashSubmission.failure.add(1);
break;
default:
// no callbacks invoked.
}
},
readAnnotations: async function Submitter_readAnnotations(extra) {
// These annotations are used only by the crash reporter client and should
// not be submitted to Socorro.
const strippedAnnotations = [
"StackTraces",
"TelemetryClientId",
"TelemetryProfileGroupId",
"TelemetrySessionId",
"TelemetryServerURL",
];
let extraKeyVals = await IOUtils.readJSON(extra);
this.extraKeyVals = { ...extraKeyVals, ...this.extraKeyVals };
strippedAnnotations.forEach(key => delete this.extraKeyVals[key]);
},
submit: async function Submitter_submit() {
if (this.recordSubmission) {
await Services.crashmanager.ensureCrashIsPresent(this.id);
}
let [dump, extra, memory] = getPendingMinidump(this.id);
let [dumpExists, extraExists, memoryExists] = await Promise.all([
IOUtils.exists(dump),
IOUtils.exists(extra),
IOUtils.exists(memory),
]);
if (!dumpExists || !extraExists) {
this.notifyStatus(
FAILED,
`missing ${!dumpExists ? "dump" : "extra"} file`
);
this.cleanup();
return this.submitStatusPromise;
}
this.dump = dump;
this.extra = extra;
this.memory = memoryExists ? memory : null;
await this.readAnnotations(extra);
let additionalDumps = [];
if ("additional_minidumps" in this.extraKeyVals) {
let dumpsExistsPromises = [];
let names = this.extraKeyVals.additional_minidumps.split(",");
for (let name of names) {
let [dump /* , extra, memory */] = getPendingMinidump(
this.id + "-" + name
);
dumpsExistsPromises.push(IOUtils.exists(dump));
additionalDumps.push({ name, dump });
}
let dumpsExist = await Promise.all(dumpsExistsPromises);
let allDumpsExist = dumpsExist.every(exists => exists);
if (!allDumpsExist) {
this.notifyStatus(
FAILED,
"one or more additional minidumps are missing"
);
this.cleanup();
return this.submitStatusPromise;
}
}
this.notifyStatus(SUBMITTING);
this.additionalDumps = additionalDumps;
if (!(await this.submitForm())) {
this.notifyStatus(
FAILED,
"no url available to which to send crash reports"
);
this.cleanup();
}
return this.submitStatusPromise;
},
};
// ===================================
// External API goes here
export var CrashSubmit = {
// A set of strings representing how a user submitted a given crash
SUBMITTED_FROM_AUTO: "Auto",
SUBMITTED_FROM_INFOBAR: "Infobar",
SUBMITTED_FROM_ABOUT_CRASHES: "AboutCrashes",
SUBMITTED_FROM_CRASH_TAB: "CrashedTab",
/**
* Submit the crash report named id.dmp from the "pending" directory.
*
* @param id
* Filename (minus .dmp extension) of the minidump to submit.
* @param submittedFrom
* One of the SUBMITTED_FROM_* constants representing how the
* user submitted this crash.
* @param params
* An object containing any of the following optional parameters:
* - recordSubmission
* If true, a submission event is recorded in CrashManager.
* - noThrottle
* If true, this crash report should be submitted with
* the Throttleable annotation set to "0" indicating that
* it should be processed right away. This should be set
* when the report is being submitted and the user expects
* to see the results immediately. Defaults to false.
* - extraExtraKeyVals
* An object whose key-value pairs will be merged with the data from
* the ".extra" file submitted with the report. The properties of
* this object will override properties of the same name in the
* .extra file.
*
* @return a Promise that is fulfilled with the server crash ID when the
* submission succeeds and rejected otherwise.
*/
submit: function CrashSubmit_submit(id, submittedFrom, params) {
params = params || {};
let recordSubmission = false;
let noThrottle = false;
let extraExtraKeyVals = {};
if ("recordSubmission" in params) {
recordSubmission = params.recordSubmission;
}
if ("noThrottle" in params) {
noThrottle = params.noThrottle;
}
if ("extraExtraKeyVals" in params) {
extraExtraKeyVals = params.extraExtraKeyVals;
}
extraExtraKeyVals.SubmittedFrom = submittedFrom;
let submitter = new Submitter(
id,
recordSubmission,
noThrottle,
extraExtraKeyVals
);
CrashSubmit._activeSubmissions.push(submitter);
return submitter.submit();
},
/**
* Delete the minidup from the "pending" directory.
*
* @param id
* Filename (minus .dmp extension) of the minidump to delete.
*
* @return a Promise that is fulfilled when the minidump is deleted and
* rejected otherwise
*/
delete: async function CrashSubmit_delete(id) {
await Promise.all(
getPendingMinidump(id).map(path => {
return IOUtils.remove(path);
})
);
},
/**
* Add a .dmg.ignore file along side the .dmp file to indicate that the user
* shouldn't be prompted to submit this crash report again.
*
* @param id
* Filename (minus .dmp extension) of the report to ignore
*
* @return a Promise that is fulfilled when (if) the .dmg.ignore is created
* and rejected otherwise.
*/
ignore: async function CrashSubmit_ignore(id) {
let [dump /* , extra, memory */] = getPendingMinidump(id);
const ignorePath = `${dump}.ignore`;
await IOUtils.writeUTF8(ignorePath, "", { mode: "create" });
},
/**
* Get the list of pending crash IDs, excluding those marked to be ignored
* @param minFileDate
* A Date object. Any files last modified before that date will be ignored
*
* @return a Promise that is fulfilled with an array of string, each
* being an ID as expected to be passed to submit() or ignore()
*/
pendingIDs: async function CrashSubmit_pendingIDs(minFileDate) {
let ids = [];
let pendingDir = getDir("pending");
if (!(await IOUtils.exists(pendingDir))) {
return ids;
}
let children;
try {
children = await IOUtils.getChildren(pendingDir);
} catch (ex) {
console.error(ex);
throw ex;
}
try {
const entries = Object.create(null);
const ignored = Object.create(null);
for (const child of children) {
const info = await IOUtils.stat(child);
if (info.type !== "directory") {
const name = PathUtils.filename(child);
const matches = name.match(/(.+)\.dmp$/);
if (matches) {
const id = matches[1];
if (UUID_REGEX.test(id)) {
entries[id] = info;
}
} else {
// maybe it's a .ignore file
const matchesIgnore = name.match(/(.+)\.dmp.ignore$/);
if (matchesIgnore) {
const id = matchesIgnore[1];
if (UUID_REGEX.test(id)) {
ignored[id] = true;
}
}
}
}
}
for (const [id, info] of Object.entries(entries)) {
const accessDate = new Date(info.lastAccessed);
if (!(id in ignored) && accessDate > minFileDate) {
ids.push(id);
}
}
} catch (ex) {
console.error(ex);
throw ex;
}
return ids;
},
/**
* Prune the saved dumps.
*
* @return a Promise that is fulfilled when the daved dumps are deleted and
* rejected otherwise
*/
pruneSavedDumps: async function CrashSubmit_pruneSavedDumps() {
const KEEP = 10;
let dirEntries = [];
let pendingDir = getDir("pending");
let children;
try {
children = await IOUtils.getChildren(pendingDir);
} catch (ex) {
if (DOMException.isInstance(ex) && ex.name === "NotFoundError") {
return [];
}
throw ex;
}
for (const path of children) {
let infoPromise;
try {
infoPromise = IOUtils.stat(path);
} catch (ex) {
console.error(ex);
throw ex;
}
const name = PathUtils.filename(path);
if (name.match(/(.+)\.extra$/)) {
dirEntries.push({
name,
path,
infoPromise,
});
}
}
dirEntries.sort(async (a, b) => {
let dateA = (await a.infoPromise).lastModified;
let dateB = (await b.infoPromise).lastModified;
if (dateA < dateB) {
return -1;
}
if (dateB < dateA) {
return 1;
}
return 0;
});
if (dirEntries.length > KEEP) {
let toDelete = [];
for (let i = 0; i < dirEntries.length - KEEP; ++i) {
let extra = dirEntries[i];
let matches = extra.leafName.match(/(.+)\.extra$/);
if (matches) {
let pathComponents = PathUtils.split(extra.path);
pathComponents[pathComponents.length - 1] = matches[1];
let path = PathUtils.join(...pathComponents);
toDelete.push(extra.path, `${path}.dmp`, `${path}.memory.json.gz`);
}
}
await Promise.all(
toDelete.map(path => {
return IOUtils.remove(path, { ignoreAbsent: true });
})
);
}
},
// List of currently active submit objects
_activeSubmissions: [],
};