summaryrefslogtreecommitdiffstats
path: root/devtools/client/performance/modules
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/performance/modules')
-rw-r--r--devtools/client/performance/modules/categories.js87
-rw-r--r--devtools/client/performance/modules/constants.js11
-rw-r--r--devtools/client/performance/modules/global.js36
-rw-r--r--devtools/client/performance/modules/io.js173
-rw-r--r--devtools/client/performance/modules/logic/frame-utils.js510
-rw-r--r--devtools/client/performance/modules/logic/jit.js350
-rw-r--r--devtools/client/performance/modules/logic/moz.build12
-rw-r--r--devtools/client/performance/modules/logic/telemetry.js106
-rw-r--r--devtools/client/performance/modules/logic/tree-model.js589
-rw-r--r--devtools/client/performance/modules/logic/waterfall-utils.js171
-rw-r--r--devtools/client/performance/modules/marker-blueprint-utils.js110
-rw-r--r--devtools/client/performance/modules/marker-dom-utils.js275
-rw-r--r--devtools/client/performance/modules/marker-formatters.js204
-rw-r--r--devtools/client/performance/modules/markers.js180
-rw-r--r--devtools/client/performance/modules/moz.build22
-rw-r--r--devtools/client/performance/modules/utils.js24
-rw-r--r--devtools/client/performance/modules/waterfall-ticks.js98
-rw-r--r--devtools/client/performance/modules/widgets/graphs.js527
-rw-r--r--devtools/client/performance/modules/widgets/marker-details.js177
-rw-r--r--devtools/client/performance/modules/widgets/markers-overview.js256
-rw-r--r--devtools/client/performance/modules/widgets/moz.build11
-rw-r--r--devtools/client/performance/modules/widgets/tree-view.js461
22 files changed, 4390 insertions, 0 deletions
diff --git a/devtools/client/performance/modules/categories.js b/devtools/client/performance/modules/categories.js
new file mode 100644
index 0000000000..f6ef7073ef
--- /dev/null
+++ b/devtools/client/performance/modules/categories.js
@@ -0,0 +1,87 @@
+/* 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/. */
+"use strict";
+
+const { L10N } = require("devtools/client/performance/modules/global");
+
+/**
+ * Details about each label stack frame category.
+ * To be kept in sync with the JS::ProfilingCategory enum in ProfilingCategory.h
+ */
+const CATEGORIES = [
+ {
+ color: "#d99b28",
+ abbrev: "idle",
+ label: L10N.getStr("category.idle"),
+ },
+ {
+ color: "#5e88b0",
+ abbrev: "other",
+ label: L10N.getStr("category.other"),
+ },
+ {
+ color: "#46afe3",
+ abbrev: "layout",
+ label: L10N.getStr("category.layout"),
+ },
+ {
+ color: "#d96629",
+ abbrev: "js",
+ label: L10N.getStr("category.js"),
+ },
+ {
+ color: "#eb5368",
+ abbrev: "gc",
+ label: L10N.getStr("category.gc"),
+ },
+ {
+ color: "#df80ff",
+ abbrev: "network",
+ label: L10N.getStr("category.network"),
+ },
+ {
+ color: "#70bf53",
+ abbrev: "graphics",
+ label: L10N.getStr("category.graphics"),
+ },
+ {
+ color: "#8fa1b2",
+ abbrev: "dom",
+ label: L10N.getStr("category.dom"),
+ },
+ {
+ // The devtools-only "tools" category which gets computed by
+ // computeIsContentAndCategory and is not part of the category list in
+ // ProfilingStack.h.
+ color: "#8fa1b2",
+ abbrev: "tools",
+ label: L10N.getStr("category.tools"),
+ },
+];
+
+/**
+ * Get the numeric index for the given category abbreviation.
+ * See `CATEGORIES` above.
+ */
+const CATEGORY_INDEX = (() => {
+ const indexForCategory = {};
+ for (
+ let categoryIndex = 0;
+ categoryIndex < CATEGORIES.length;
+ categoryIndex++
+ ) {
+ const category = CATEGORIES[categoryIndex];
+ indexForCategory[category.abbrev] = categoryIndex;
+ }
+
+ return function(name) {
+ if (!(name in indexForCategory)) {
+ throw new Error(`Category abbreviation "${name}" does not exist.`);
+ }
+ return indexForCategory[name];
+ };
+})();
+
+exports.CATEGORIES = CATEGORIES;
+exports.CATEGORY_INDEX = CATEGORY_INDEX;
diff --git a/devtools/client/performance/modules/constants.js b/devtools/client/performance/modules/constants.js
new file mode 100644
index 0000000000..a0adaf5961
--- /dev/null
+++ b/devtools/client/performance/modules/constants.js
@@ -0,0 +1,11 @@
+/* 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/. */
+"use strict";
+
+exports.Constants = {
+ // ms
+ FRAMERATE_GRAPH_LOW_RES_INTERVAL: 100,
+ // ms
+ FRAMERATE_GRAPH_HIGH_RES_INTERVAL: 16,
+};
diff --git a/devtools/client/performance/modules/global.js b/devtools/client/performance/modules/global.js
new file mode 100644
index 0000000000..7816becc70
--- /dev/null
+++ b/devtools/client/performance/modules/global.js
@@ -0,0 +1,36 @@
+/* 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/. */
+"use strict";
+
+const { MultiLocalizationHelper } = require("devtools/shared/l10n");
+const { PrefsHelper } = require("devtools/client/shared/prefs");
+
+/**
+ * Localization convenience methods.
+ */
+exports.L10N = new MultiLocalizationHelper(
+ "devtools/client/locales/markers.properties",
+ "devtools/client/locales/performance.properties"
+);
+
+/**
+ * A list of preferences for this tool. The values automatically update
+ * if somebody edits edits about:config or the prefs change somewhere else.
+ *
+ * This needs to be registered and unregistered when used for the auto-update
+ * functionality to work. The PerformanceController handles this, but if you
+ * just use this module in a test independently, ensure you call
+ * `registerObserver()` and `unregisterUnobserver()`.
+ */
+exports.PREFS = new PrefsHelper("devtools.performance", {
+ "show-triggers-for-gc-types": ["Char", "ui.show-triggers-for-gc-types"],
+ "show-platform-data": ["Bool", "ui.show-platform-data"],
+ "hidden-markers": ["Json", "timeline.hidden-markers"],
+ "memory-sample-probability": ["Float", "memory.sample-probability"],
+ "memory-max-log-length": ["Int", "memory.max-log-length"],
+ "profiler-buffer-size": ["Int", "profiler.buffer-size"],
+ "profiler-sample-frequency": ["Int", "profiler.sample-frequency-hz"],
+ // TODO: re-enable once we flame charts via bug 1148663.
+ "enable-memory-flame": ["Bool", "ui.enable-memory-flame"],
+});
diff --git a/devtools/client/performance/modules/io.js b/devtools/client/performance/modules/io.js
new file mode 100644
index 0000000000..97fb16dc41
--- /dev/null
+++ b/devtools/client/performance/modules/io.js
@@ -0,0 +1,173 @@
+/* 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/. */
+"use strict";
+
+const { Cc, Ci } = require("chrome");
+
+const RecordingUtils = require("devtools/shared/performance/recording-utils");
+const { FileUtils } = require("resource://gre/modules/FileUtils.jsm");
+const { NetUtil } = require("resource://gre/modules/NetUtil.jsm");
+
+// This identifier string is used to tentatively ascertain whether or not
+// a JSON loaded from disk is actually something generated by this tool.
+// It isn't, of course, a definitive verification, but a Good Enough™
+// approximation before continuing the import. Don't localize this.
+const PERF_TOOL_SERIALIZER_IDENTIFIER = "Recorded Performance Data";
+const PERF_TOOL_SERIALIZER_LEGACY_VERSION = 1;
+const PERF_TOOL_SERIALIZER_CURRENT_VERSION = 2;
+
+/**
+ * Helpers for importing/exporting JSON.
+ */
+
+/**
+ * Gets a nsIScriptableUnicodeConverter instance with a default UTF-8 charset.
+ * @return object
+ */
+function getUnicodeConverter() {
+ const cname = "@mozilla.org/intl/scriptableunicodeconverter";
+ const converter = Cc[cname].createInstance(Ci.nsIScriptableUnicodeConverter);
+ converter.charset = "UTF-8";
+ return converter;
+}
+
+/**
+ * Saves a recording as JSON to a file. The provided data is assumed to be
+ * acyclical, so that it can be properly serialized.
+ *
+ * @param object recordingData
+ * The recording data to stream as JSON.
+ * @param nsIFile file
+ * The file to stream the data into.
+ * @return object
+ * A promise that is resolved once streaming finishes, or rejected
+ * if there was an error.
+ */
+function saveRecordingToFile(recordingData, file) {
+ recordingData.fileType = PERF_TOOL_SERIALIZER_IDENTIFIER;
+ recordingData.version = PERF_TOOL_SERIALIZER_CURRENT_VERSION;
+
+ const string = JSON.stringify(recordingData);
+ const inputStream = getUnicodeConverter().convertToInputStream(string);
+ const outputStream = FileUtils.openSafeFileOutputStream(file);
+
+ return new Promise(resolve => {
+ NetUtil.asyncCopy(inputStream, outputStream, resolve);
+ });
+}
+
+/**
+ * Loads a recording stored as JSON from a file.
+ *
+ * @param nsIFile file
+ * The file to import the data from.
+ * @return object
+ * A promise that is resolved once importing finishes, or rejected
+ * if there was an error.
+ */
+function loadRecordingFromFile(file) {
+ const channel = NetUtil.newChannel({
+ uri: NetUtil.newURI(file),
+ loadUsingSystemPrincipal: true,
+ });
+
+ channel.contentType = "text/plain";
+
+ return new Promise((resolve, reject) => {
+ NetUtil.asyncFetch(channel, inputStream => {
+ let recordingData;
+
+ try {
+ const string = NetUtil.readInputStreamToString(
+ inputStream,
+ inputStream.available()
+ );
+ recordingData = JSON.parse(string);
+ } catch (e) {
+ reject(new Error("Could not read recording data file."));
+ return;
+ }
+
+ if (recordingData.fileType != PERF_TOOL_SERIALIZER_IDENTIFIER) {
+ reject(new Error("Unrecognized recording data file."));
+ return;
+ }
+
+ if (!isValidSerializerVersion(recordingData.version)) {
+ reject(new Error("Unsupported recording data file version."));
+ return;
+ }
+
+ if (recordingData.version === PERF_TOOL_SERIALIZER_LEGACY_VERSION) {
+ recordingData = convertLegacyData(recordingData);
+ }
+
+ if (recordingData.profile.meta.version === 2) {
+ RecordingUtils.deflateProfile(recordingData.profile);
+ }
+
+ // If the recording has no label, set it to be the
+ // filename without its extension.
+ if (!recordingData.label) {
+ recordingData.label = file.leafName.replace(/\.[^.]+$/, "");
+ }
+
+ resolve(recordingData);
+ });
+ });
+}
+
+/**
+ * Returns a boolean indicating whether or not the passed in `version`
+ * is supported by this serializer.
+ *
+ * @param number version
+ * @return boolean
+ */
+function isValidSerializerVersion(version) {
+ return !!~[
+ PERF_TOOL_SERIALIZER_LEGACY_VERSION,
+ PERF_TOOL_SERIALIZER_CURRENT_VERSION,
+ ].indexOf(version);
+}
+
+/**
+ * Takes recording data (with version `1`, from the original profiler tool),
+ * and massages the data to be line with the current performance tool's
+ * property names and values.
+ *
+ * @param object legacyData
+ * @return object
+ */
+function convertLegacyData(legacyData) {
+ const { profilerData, ticksData, recordingDuration } = legacyData;
+
+ // The `profilerData` and `ticksData` stay, but the previously unrecorded
+ // fields just are empty arrays or objects.
+ const data = {
+ label: profilerData.profilerLabel,
+ duration: recordingDuration,
+ markers: [],
+ frames: [],
+ memory: [],
+ ticks: ticksData,
+ allocations: { sites: [], timestamps: [], frames: [], sizes: [] },
+ profile: profilerData.profile,
+ // Fake a configuration object here if there's tick data,
+ // so that it can be rendered.
+ configuration: {
+ withTicks: !!ticksData.length,
+ withMarkers: false,
+ withMemory: false,
+ withAllocations: false,
+ },
+ systemHost: {},
+ systemClient: {},
+ };
+
+ return data;
+}
+
+exports.saveRecordingToFile = saveRecordingToFile;
+exports.loadRecordingFromFile = loadRecordingFromFile;
diff --git a/devtools/client/performance/modules/logic/frame-utils.js b/devtools/client/performance/modules/logic/frame-utils.js
new file mode 100644
index 0000000000..a799381274
--- /dev/null
+++ b/devtools/client/performance/modules/logic/frame-utils.js
@@ -0,0 +1,510 @@
+/* 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/. */
+"use strict";
+
+const global = require("devtools/client/performance/modules/global");
+const demangle = require("devtools/client/shared/demangle");
+const { assert } = require("devtools/shared/DevToolsUtils");
+const {
+ isChromeScheme,
+ isContentScheme,
+ isWASM,
+ parseURL,
+} = require("devtools/client/shared/source-utils");
+
+const {
+ CATEGORY_INDEX,
+ CATEGORIES,
+} = require("devtools/client/performance/modules/categories");
+
+// Character codes used in various parsing helper functions.
+const CHAR_CODE_R = "r".charCodeAt(0);
+const CHAR_CODE_0 = "0".charCodeAt(0);
+const CHAR_CODE_9 = "9".charCodeAt(0);
+const CHAR_CODE_CAP_Z = "Z".charCodeAt(0);
+
+const CHAR_CODE_LPAREN = "(".charCodeAt(0);
+const CHAR_CODE_RPAREN = ")".charCodeAt(0);
+const CHAR_CODE_COLON = ":".charCodeAt(0);
+const CHAR_CODE_SPACE = " ".charCodeAt(0);
+const CHAR_CODE_UNDERSCORE = "_".charCodeAt(0);
+
+const EVAL_TOKEN = "%20%3E%20eval";
+
+// The cache used to store inflated frames.
+const gInflatedFrameStore = new WeakMap();
+
+// The cache used to store frame data from `getInfo`.
+const gFrameData = new WeakMap();
+
+/**
+ * Parses the raw location of this function call to retrieve the actual
+ * function name, source url, host name, line and column.
+ */
+// eslint-disable-next-line complexity
+function parseLocation(location, fallbackLine, fallbackColumn) {
+ // Parse the `location` for the function name, source url, line, column etc.
+
+ let line, column, url;
+
+ // These two indices are used to extract the resource substring, which is
+ // location[parenIndex + 1 .. lineAndColumnIndex].
+ //
+ // There are 3 variants of location strings in the profiler (with optional
+ // column numbers):
+ // 1) "name (resource:line)"
+ // 2) "resource:line"
+ // 3) "resource"
+ //
+ // For example for (1), take "foo (bar.js:1)".
+ // ^ ^
+ // | |
+ // | |
+ // | |
+ // parenIndex will point to ------+ |
+ // |
+ // lineAndColumnIndex will point to -----+
+ //
+ // For an example without parentheses, take "bar.js:2".
+ // ^ ^
+ // | |
+ // parenIndex will point to ----------------+ |
+ // |
+ // lineAndColumIndex will point to ----------------+
+ //
+ // To parse, we look for the last occurrence of the string ' ('.
+ //
+ // For 1), all occurrences of space ' ' characters in the resource string
+ // are urlencoded, so the last occurrence of ' (' is the separator between
+ // the function name and the resource.
+ //
+ // For 2) and 3), there can be no occurences of ' (' since ' ' characters
+ // are urlencoded in the resource string.
+ //
+ // XXX: Note that 3) is ambiguous with Gecko Profiler marker locations like
+ // "EnterJIT". We can't distinguish the two, so we treat 3) like a function
+ // name.
+ let parenIndex = -1;
+ let lineAndColumnIndex = -1;
+
+ const lastCharCode = location.charCodeAt(location.length - 1);
+ let i;
+ if (lastCharCode === CHAR_CODE_RPAREN) {
+ // Case 1)
+ i = location.length - 2;
+ } else if (isNumeric(lastCharCode)) {
+ // Case 2)
+ i = location.length - 1;
+ } else {
+ // Case 3)
+ i = 0;
+ }
+
+ if (i !== 0) {
+ // Look for a :number.
+ let end = i;
+ while (isNumeric(location.charCodeAt(i))) {
+ i--;
+ }
+ if (location.charCodeAt(i) === CHAR_CODE_COLON) {
+ column = location.substr(i + 1, end - i);
+ i--;
+ }
+
+ // Look for a preceding :number.
+ end = i;
+ while (isNumeric(location.charCodeAt(i))) {
+ i--;
+ }
+
+ // If two were found, the first is the line and the second is the
+ // column. If only a single :number was found, then it is the line number.
+ if (location.charCodeAt(i) === CHAR_CODE_COLON) {
+ line = location.substr(i + 1, end - i);
+ lineAndColumnIndex = i;
+ i--;
+ } else {
+ lineAndColumnIndex = i + 1;
+ line = column;
+ column = undefined;
+ }
+ }
+
+ // Look for the last occurrence of ' (' in case 1).
+ if (lastCharCode === CHAR_CODE_RPAREN) {
+ for (; i >= 0; i--) {
+ if (
+ location.charCodeAt(i) === CHAR_CODE_LPAREN &&
+ i > 0 &&
+ location.charCodeAt(i - 1) === CHAR_CODE_SPACE
+ ) {
+ parenIndex = i;
+ break;
+ }
+ }
+ }
+
+ let parsedUrl;
+ if (lineAndColumnIndex > 0) {
+ const resource = location.substring(parenIndex + 1, lineAndColumnIndex);
+ url = resource.split(" -> ").pop();
+ if (url) {
+ parsedUrl = parseURL(url);
+ }
+ }
+
+ let functionName, fileName, port, host;
+ line = line || fallbackLine;
+ column = column || fallbackColumn;
+
+ // If the URL digged out from the `location` is valid, this is a JS frame.
+ if (parsedUrl) {
+ functionName = location.substring(0, parenIndex - 1);
+ fileName = parsedUrl.fileName;
+ port = parsedUrl.port;
+ host = parsedUrl.host;
+
+ // Check for the case of the filename containing eval
+ // e.g. "file.js%20line%2065%20%3E%20eval"
+ const evalIndex = fileName.indexOf(EVAL_TOKEN);
+ if (evalIndex !== -1 && evalIndex === fileName.length - EVAL_TOKEN.length) {
+ // Match the filename
+ const evalLine = line;
+ const [, _fileName, , _line] =
+ fileName.match(/(.+)(%20line%20(\d+)%20%3E%20eval)/) || [];
+ fileName = `${_fileName} (eval:${evalLine})`;
+ line = _line;
+ assert(
+ _fileName !== undefined,
+ "Filename could not be found from an eval location site"
+ );
+ assert(
+ _line !== undefined,
+ "Line could not be found from an eval location site"
+ );
+
+ // Match the url as well
+ [, url] = url.match(/(.+)( line (\d+) > eval)/) || [];
+ assert(
+ url !== undefined,
+ "The URL could not be parsed correctly from an eval location site"
+ );
+ }
+ } else {
+ functionName = location;
+ url = null;
+ }
+
+ return { functionName, fileName, host, port, url, line, column };
+}
+
+/**
+ * Sets the properties of `isContent` and `category` on a frame.
+ *
+ * @param {InflatedFrame} frame
+ */
+function computeIsContentAndCategory(frame) {
+ const location = frame.location;
+
+ // There are 3 variants of location strings in the profiler (with optional
+ // column numbers):
+ // 1) "name (resource:line)"
+ // 2) "resource:line"
+ // 3) "resource"
+ const lastCharCode = location.charCodeAt(location.length - 1);
+ let schemeStartIndex = -1;
+ if (lastCharCode === CHAR_CODE_RPAREN) {
+ // Case 1)
+ //
+ // Need to search for the last occurrence of ' (' to find the start of the
+ // resource string.
+ for (let i = location.length - 2; i >= 0; i--) {
+ if (
+ location.charCodeAt(i) === CHAR_CODE_LPAREN &&
+ i > 0 &&
+ location.charCodeAt(i - 1) === CHAR_CODE_SPACE
+ ) {
+ schemeStartIndex = i + 1;
+ break;
+ }
+ }
+ } else {
+ // Cases 2) and 3)
+ schemeStartIndex = 0;
+ }
+
+ // We can't know if WASM frames are content or not at the time of this writing, so label
+ // them all as content.
+ if (isContentScheme(location, schemeStartIndex) || isWASM(location)) {
+ frame.isContent = true;
+ return;
+ }
+
+ if (frame.category !== null && frame.category !== undefined) {
+ return;
+ }
+
+ if (schemeStartIndex !== 0) {
+ for (let j = schemeStartIndex; j < location.length; j++) {
+ if (
+ location.charCodeAt(j) === CHAR_CODE_R &&
+ isChromeScheme(location, j) &&
+ (location.includes("resource://devtools") ||
+ location.includes("resource://devtools"))
+ ) {
+ frame.category = CATEGORY_INDEX("tools");
+ return;
+ }
+ }
+ }
+
+ if (location === "EnterJIT") {
+ frame.category = CATEGORY_INDEX("js");
+ return;
+ }
+
+ frame.category = CATEGORY_INDEX("other");
+}
+
+/**
+ * Get caches to cache inflated frames and computed frame keys of a frame
+ * table.
+ *
+ * @param object framesTable
+ * @return object
+ */
+function getInflatedFrameCache(frameTable) {
+ let inflatedCache = gInflatedFrameStore.get(frameTable);
+ if (inflatedCache !== undefined) {
+ return inflatedCache;
+ }
+
+ // Fill with nulls to ensure no holes.
+ inflatedCache = Array.from({ length: frameTable.data.length }, () => null);
+ gInflatedFrameStore.set(frameTable, inflatedCache);
+ return inflatedCache;
+}
+
+/**
+ * Get or add an inflated frame to a cache.
+ *
+ * @param object cache
+ * @param number index
+ * @param object frameTable
+ * @param object stringTable
+ */
+function getOrAddInflatedFrame(cache, index, frameTable, stringTable) {
+ let inflatedFrame = cache[index];
+ if (inflatedFrame === null) {
+ inflatedFrame = cache[index] = new InflatedFrame(
+ index,
+ frameTable,
+ stringTable
+ );
+ }
+ return inflatedFrame;
+}
+
+/**
+ * An intermediate data structured used to hold inflated frames.
+ *
+ * @param number index
+ * @param object frameTable
+ * @param object stringTable
+ */
+function InflatedFrame(index, frameTable, stringTable) {
+ const LOCATION_SLOT = frameTable.schema.location;
+ const IMPLEMENTATION_SLOT = frameTable.schema.implementation;
+ const OPTIMIZATIONS_SLOT = frameTable.schema.optimizations;
+ const LINE_SLOT = frameTable.schema.line;
+ const CATEGORY_SLOT = frameTable.schema.category;
+
+ const frame = frameTable.data[index];
+ const category = frame[CATEGORY_SLOT];
+ this.location = stringTable[frame[LOCATION_SLOT]];
+ this.implementation = frame[IMPLEMENTATION_SLOT];
+ this.optimizations = frame[OPTIMIZATIONS_SLOT];
+ this.line = frame[LINE_SLOT];
+ this.column = undefined;
+ this.category = category;
+ this.isContent = false;
+
+ // Attempt to compute if this frame is a content frame, and if not,
+ // its category.
+ //
+ // Since only C++ stack frames have associated category information,
+ // attempt to generate a useful category, fallback to the one provided
+ // by the profiling data, or fallback to an unknown category.
+ computeIsContentAndCategory(this);
+}
+
+/**
+ * Gets the frame key (i.e., equivalence group) according to options. Content
+ * frames are always identified by location. Chrome frames are identified by
+ * location if content-only filtering is off. If content-filtering is on, they
+ * are identified by their category.
+ *
+ * @param object options
+ * @return string
+ */
+InflatedFrame.prototype.getFrameKey = function getFrameKey(options) {
+ if (this.isContent || !options.contentOnly || options.isRoot) {
+ options.isMetaCategoryOut = false;
+ return this.location;
+ }
+
+ if (options.isLeaf) {
+ // We only care about leaf platform frames if we are displaying content
+ // only. If no category is present, give the default category of "other".
+ //
+ // 1. The leaf is where time is _actually_ being spent, so we _need_ to
+ // show it to developers in some way to give them accurate profiling
+ // data. We decide to split the platform into various category buckets
+ // and just show time spent in each bucket.
+ //
+ // 2. The calls leading to the leaf _aren't_ where we are spending time,
+ // but _do_ give the developer context for how they got to the leaf
+ // where they _are_ spending time. For non-platform hackers, the
+ // non-leaf platform frames don't give any meaningful context, and so we
+ // can safely filter them out.
+ options.isMetaCategoryOut = true;
+ return this.category;
+ }
+
+ // Return an empty string denoting that this frame should be skipped.
+ return "";
+};
+
+function isNumeric(c) {
+ return c >= CHAR_CODE_0 && c <= CHAR_CODE_9;
+}
+
+function shouldDemangle(name) {
+ return (
+ name?.charCodeAt &&
+ name.charCodeAt(0) === CHAR_CODE_UNDERSCORE &&
+ name.charCodeAt(1) === CHAR_CODE_UNDERSCORE &&
+ name.charCodeAt(2) === CHAR_CODE_CAP_Z
+ );
+}
+
+/**
+ * Calculates the relative costs of this frame compared to a root,
+ * and generates allocations information if specified. Uses caching
+ * if possible.
+ *
+ * @param {ThreadNode|FrameNode} node
+ * The node we are calculating.
+ * @param {ThreadNode} options.root
+ * The root thread node to calculate relative costs.
+ * Generates [self|total] [duration|percentage] values.
+ * @param {boolean} options.allocations
+ * Generates `totalAllocations` and `selfAllocations`.
+ *
+ * @return {object}
+ */
+function getFrameInfo(node, options) {
+ let data = gFrameData.get(node);
+
+ if (!data) {
+ if (node.nodeType === "Thread") {
+ data = Object.create(null);
+ data.functionName = global.L10N.getStr("table.root");
+ } else {
+ data = parseLocation(node.location, node.line, node.column);
+ data.hasOptimizations = node.hasOptimizations();
+ data.isContent = node.isContent;
+ data.isMetaCategory = node.isMetaCategory;
+ }
+ data.samples = node.youngestFrameSamples;
+ const hasCategory = node.category !== null && node.category !== undefined;
+ data.categoryData = hasCategory
+ ? CATEGORIES[node.category] || CATEGORIES[CATEGORY_INDEX("other")]
+ : {};
+ data.nodeType = node.nodeType;
+
+ // Frame name (function location or some meta information)
+ if (data.isMetaCategory) {
+ data.name = data.categoryData.label;
+ } else if (shouldDemangle(data.functionName)) {
+ data.name = demangle(data.functionName);
+ } else {
+ data.name = data.functionName;
+ }
+
+ data.tooltiptext = data.isMetaCategory
+ ? data.categoryData.label
+ : node.location || "";
+
+ gFrameData.set(node, data);
+ }
+
+ // If no options specified, we can't calculate relative values, abort here
+ if (!options) {
+ return data;
+ }
+
+ // If a root specified, calculate the relative costs in the context of
+ // this call tree. The cached store may already have this, but generate
+ // if it does not.
+ const totalSamples = options.root.samples;
+ const totalDuration = options.root.duration;
+ if (options?.root && !data.COSTS_CALCULATED) {
+ data.selfDuration =
+ (node.youngestFrameSamples / totalSamples) * totalDuration;
+ data.selfPercentage = (node.youngestFrameSamples / totalSamples) * 100;
+ data.totalDuration = (node.samples / totalSamples) * totalDuration;
+ data.totalPercentage = (node.samples / totalSamples) * 100;
+ data.COSTS_CALCULATED = true;
+ }
+
+ if (options?.allocations && !data.ALLOCATION_DATA_CALCULATED) {
+ const totalBytes = options.root.byteSize;
+ data.selfCount = node.youngestFrameSamples;
+ data.totalCount = node.samples;
+ data.selfCountPercentage = (node.youngestFrameSamples / totalSamples) * 100;
+ data.totalCountPercentage = (node.samples / totalSamples) * 100;
+ data.selfSize = node.youngestFrameByteSize;
+ data.totalSize = node.byteSize;
+ data.selfSizePercentage = (node.youngestFrameByteSize / totalBytes) * 100;
+ data.totalSizePercentage = (node.byteSize / totalBytes) * 100;
+ data.ALLOCATION_DATA_CALCULATED = true;
+ }
+
+ return data;
+}
+
+exports.getFrameInfo = getFrameInfo;
+
+/**
+ * Takes an inverted ThreadNode and searches its youngest frames for
+ * a FrameNode with matching location.
+ *
+ * @param {ThreadNode} threadNode
+ * @param {string} location
+ * @return {?FrameNode}
+ */
+function findFrameByLocation(threadNode, location) {
+ if (!threadNode.inverted) {
+ throw new Error(
+ "FrameUtils.findFrameByLocation only supports leaf nodes in an inverted tree."
+ );
+ }
+
+ const calls = threadNode.calls;
+ for (let i = 0; i < calls.length; i++) {
+ if (calls[i].location === location) {
+ return calls[i];
+ }
+ }
+ return null;
+}
+
+exports.findFrameByLocation = findFrameByLocation;
+exports.computeIsContentAndCategory = computeIsContentAndCategory;
+exports.parseLocation = parseLocation;
+exports.getInflatedFrameCache = getInflatedFrameCache;
+exports.getOrAddInflatedFrame = getOrAddInflatedFrame;
+exports.InflatedFrame = InflatedFrame;
+exports.shouldDemangle = shouldDemangle;
diff --git a/devtools/client/performance/modules/logic/jit.js b/devtools/client/performance/modules/logic/jit.js
new file mode 100644
index 0000000000..5063dd4a8b
--- /dev/null
+++ b/devtools/client/performance/modules/logic/jit.js
@@ -0,0 +1,350 @@
+/* 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/. */
+"use strict";
+
+// An outcome of an OptimizationAttempt that is considered successful.
+const SUCCESSFUL_OUTCOMES = [
+ "GenericSuccess",
+ "Inlined",
+ "DOM",
+ "Monomorphic",
+ "Polymorphic",
+];
+
+/**
+ * Model representing JIT optimization sites from the profiler
+ * for a frame (represented by a FrameNode). Requires optimization data from
+ * a profile, which is an array of RawOptimizationSites.
+ *
+ * When the ThreadNode for the profile iterates over the samples' frames, each
+ * frame's optimizations are accumulated in their respective FrameNodes. Each
+ * FrameNode may contain many different optimization sites. One sample may
+ * pick up optimization X on line Y in the frame, with the next sample
+ * containing optimization Z on line W in the same frame, as each frame is
+ * only function.
+ *
+ * An OptimizationSite contains a record of how many times the
+ * RawOptimizationSite was sampled, as well as the unique id based off of the
+ * original profiler array, and the RawOptimizationSite itself as a reference.
+ * @see devtools/client/performance/modules/logic/tree-model.js
+ *
+ * @struct RawOptimizationSite
+ * A structure describing a location in a script that was attempted to be optimized.
+ * Contains all the IonTypes observed, and the sequence of OptimizationAttempts that
+ * were attempted, and the line and column in the script. This is retrieved from the
+ * profiler after a recording, and our base data structure. Should always be referenced,
+ * and unmodified.
+ *
+ * Note that propertyName is an index into a string table, which needs to be
+ * provided in order for the raw optimization site to be inflated.
+ *
+ * @type {Array<IonType>} types
+ * @type {Array<OptimizationAttempt>} attempts
+ * @type {?number} propertyName
+ * @type {number} line
+ * @type {number} column
+ *
+ *
+ * @struct IonType
+ * IonMonkey attempts to classify each value in an optimization site by some type.
+ * Based off of the observed types for a value (like a variable that could be a
+ * string or an instance of an object), it determines what kind of type it should be
+ * classified as. Each IonType here contains an array of all ObservedTypes under `types`,
+ * the Ion type that IonMonkey decided this value should be (Int32, Object, etc.) as
+ * `mirType`, and the component of this optimization type that this value refers to --
+ * like a "getter" optimization, `a[b]`, has site `a` (the "Receiver") and `b`
+ * (the "Index").
+ *
+ * Generally the more ObservedTypes, the more deoptimized this OptimizationSite is.
+ * There could be no ObservedTypes, in which case `typeset` is undefined.
+ *
+ * @type {?Array<ObservedType>} typeset
+ * @type {string} site
+ * @type {string} mirType
+ *
+ *
+ * @struct ObservedType
+ * When IonMonkey attempts to determine what type a value is, it checks on each sample.
+ * The ObservedType can be thought of in more of JavaScripty-terms, rather than C++.
+ * The `keyedBy` property is a high level description of the type, like "primitive",
+ * "constructor", "function", "singleton", "alloc-site" (that one is a bit more weird).
+ * If the `keyedBy` type is a function or constructor, the ObservedType should have a
+ * `name` property, referring to the function or constructor name from the JS source.
+ * If IonMonkey can determine the origin of this type (like where the constructor is
+ * defined), the ObservedType will also have `location` and `line` properties, but
+ * `location` can sometimes be non-URL strings like "self-hosted" or a memory location
+ * like "102ca7880", or no location at all, and maybe `line` is 0 or undefined.
+ *
+ * @type {string} keyedBy
+ * @type {?string} name
+ * @type {?string} location
+ * @type {?string} line
+ *
+ *
+ * @struct OptimizationAttempt
+ * Each RawOptimizationSite contains an array of OptimizationAttempts. Generally,
+ * IonMonkey goes through a series of strategies for each kind of optimization, starting
+ * from most-niche and optimized, to the less-optimized, but more general strategies --
+ * for example, a getter opt may first try to optimize for the scenario of a getter on an
+ * `arguments` object -- that will fail most of the time, as most objects are not
+ * arguments objects, but it will attempt several strategies in order until it finds a
+ * strategy that works, or fails. Even in the best scenarios, some attempts will fail
+ * (like the arguments getter example), which is OK, as long as some attempt succeeds
+ * (with the earlier attempts preferred, as those are more optimized). In an
+ * OptimizationAttempt structure, we store just the `strategy` name and `outcome` name,
+ * both from enums in js/public/TrackedOptimizationInfo.h as TRACKED_STRATEGY_LIST and
+ * TRACKED_OUTCOME_LIST, respectively. An array of successful outcome strings are above
+ * in SUCCESSFUL_OUTCOMES.
+ *
+ * @see js/public/TrackedOptimizationInfo.h
+ *
+ * @type {string} strategy
+ * @type {string} outcome
+ */
+
+/*
+ * A wrapper around RawOptimizationSite to record sample count and ID (referring to the
+ * index of where this is in the initially seeded optimizations data), so we don't mutate
+ * the original data from the profiler. Provides methods to access the underlying
+ * optimization data easily, so understanding the semantics of JIT data isn't necessary.
+ *
+ * @constructor
+ *
+ * @param {Array<RawOptimizationSite>} optimizations
+ * @param {number} optsIndex
+ *
+ * @type {RawOptimizationSite} data
+ * @type {number} samples
+ * @type {number} id
+ */
+
+const OptimizationSite = function(id, opts) {
+ this.id = id;
+ this.data = opts;
+ this.samples = 1;
+};
+
+/**
+ * Constructor for JITOptimizations. A collection of OptimizationSites for a frame.
+ *
+ * @constructor
+ * @param {Array<RawOptimizationSite>} rawSites
+ * Array of raw optimization sites.
+ * @param {Array<string>} stringTable
+ * Array of strings from the profiler used to inflate
+ * JIT optimizations. Do not modify this!
+ */
+
+const JITOptimizations = function(rawSites, stringTable) {
+ // Build a histogram of optimization sites.
+ const sites = [];
+
+ for (const rawSite of rawSites) {
+ const existingSite = sites.find(site => site.data === rawSite);
+ if (existingSite) {
+ existingSite.samples++;
+ } else {
+ sites.push(new OptimizationSite(sites.length, rawSite));
+ }
+ }
+
+ // Inflate the optimization information.
+ for (const site of sites) {
+ const data = site.data;
+ const STRATEGY_SLOT = data.attempts.schema.strategy;
+ const OUTCOME_SLOT = data.attempts.schema.outcome;
+ const attempts = data.attempts.data.map(a => {
+ return {
+ id: site.id,
+ strategy: stringTable[a[STRATEGY_SLOT]],
+ outcome: stringTable[a[OUTCOME_SLOT]],
+ };
+ });
+ const types = data.types.map(t => {
+ const typeset = maybeTypeset(t.typeset, stringTable);
+ if (typeset) {
+ typeset.forEach(ts => {
+ ts.id = site.id;
+ });
+ }
+
+ return {
+ id: site.id,
+ typeset,
+ site: stringTable[t.site],
+ mirType: stringTable[t.mirType],
+ };
+ });
+ // Add IDs to to all children objects, so we can correllate sites when
+ // just looking at a specific type, attempt, etc..
+ attempts.id = types.id = site.id;
+
+ site.data = {
+ attempts,
+ types,
+ propertyName: maybeString(stringTable, data.propertyName),
+ line: data.line,
+ column: data.column,
+ };
+ }
+
+ this.optimizationSites = sites.sort((a, b) => b.samples - a.samples);
+};
+
+/**
+ * Make JITOptimizations iterable.
+ */
+JITOptimizations.prototype = {
+ [Symbol.iterator]: function*() {
+ yield* this.optimizationSites;
+ },
+
+ get length() {
+ return this.optimizationSites.length;
+ },
+};
+
+/**
+ * Takes an "outcome" string from an OptimizationAttempt and returns
+ * a boolean indicating whether or not its a successful outcome.
+ *
+ * @return {boolean}
+ */
+
+function isSuccessfulOutcome(outcome) {
+ return !!~SUCCESSFUL_OUTCOMES.indexOf(outcome);
+}
+
+/**
+ * Takes an OptimizationSite. Returns a boolean indicating if the passed
+ * in OptimizationSite has a "good" outcome at the end of its attempted strategies.
+ *
+ * @param {OptimizationSite} optimizationSite
+ * @return {boolean}
+ */
+
+function hasSuccessfulOutcome(optimizationSite) {
+ const attempts = optimizationSite.data.attempts;
+ const lastOutcome = attempts[attempts.length - 1].outcome;
+ return isSuccessfulOutcome(lastOutcome);
+}
+
+function maybeString(stringTable, index) {
+ return index ? stringTable[index] : undefined;
+}
+
+function maybeTypeset(typeset, stringTable) {
+ if (!typeset) {
+ return undefined;
+ }
+ return typeset.map(ty => {
+ return {
+ keyedBy: maybeString(stringTable, ty.keyedBy),
+ name: maybeString(stringTable, ty.name),
+ location: maybeString(stringTable, ty.location),
+ line: ty.line,
+ };
+ });
+}
+
+// Map of optimization implementation names to an enum.
+const IMPLEMENTATION_MAP = {
+ interpreter: 0,
+ baseline: 1,
+ ion: 2,
+};
+const IMPLEMENTATION_NAMES = Object.keys(IMPLEMENTATION_MAP);
+
+/**
+ * Takes data from a FrameNode and computes rendering positions for
+ * a stacked mountain graph, to visualize JIT optimization tiers over time.
+ *
+ * @param {FrameNode} frameNode
+ * The FrameNode who's optimizations we're iterating.
+ * @param {Array<number>} sampleTimes
+ * An array of every sample time within the range we're counting.
+ * From a ThreadNode's `sampleTimes` property.
+ * @param {number} bucketSize
+ * Size of each bucket in milliseconds.
+ * `duration / resolution = bucketSize` in OptimizationsGraph.
+ * @return {?Array<object>}
+ */
+function createTierGraphDataFromFrameNode(frameNode, sampleTimes, bucketSize) {
+ const tierData = frameNode.getTierData();
+ const stringTable = frameNode._stringTable;
+ const output = [];
+ let implEnum;
+
+ let tierDataIndex = 0;
+ let nextOptSample = tierData[tierDataIndex];
+
+ // Bucket data
+ let samplesInCurrentBucket = 0;
+ let currentBucketStartTime = sampleTimes[0];
+ let bucket = [];
+
+ // Store previous data point so we can have straight vertical lines
+ let previousValues;
+
+ // Iterate one after the samples, so we can finalize the last bucket
+ for (let i = 0; i <= sampleTimes.length; i++) {
+ const sampleTime = sampleTimes[i];
+
+ // If this sample is in the next bucket, or we're done
+ // checking sampleTimes and on the last iteration, finalize previous bucket
+ if (
+ sampleTime >= currentBucketStartTime + bucketSize ||
+ i >= sampleTimes.length
+ ) {
+ const dataPoint = {};
+ dataPoint.values = [];
+ dataPoint.delta = currentBucketStartTime;
+
+ // Map the opt site counts as a normalized percentage (0-1)
+ // of its count in context of total samples this bucket
+ for (let j = 0; j < IMPLEMENTATION_NAMES.length; j++) {
+ dataPoint.values[j] = (bucket[j] || 0) / (samplesInCurrentBucket || 1);
+ }
+
+ // Push the values from the previous bucket to the same time
+ // as the current bucket so we get a straight vertical line.
+ if (previousValues) {
+ const data = Object.create(null);
+ data.values = previousValues;
+ data.delta = currentBucketStartTime;
+ output.push(data);
+ }
+
+ output.push(dataPoint);
+
+ // Set the new start time of this bucket and reset its count
+ currentBucketStartTime += bucketSize;
+ samplesInCurrentBucket = 0;
+ previousValues = dataPoint.values;
+ bucket = [];
+ }
+
+ // If this sample observed an optimization in this frame, record it
+ if (nextOptSample && nextOptSample.time === sampleTime) {
+ // If no implementation defined, it was the "interpreter".
+ implEnum =
+ IMPLEMENTATION_MAP[
+ stringTable[nextOptSample.implementation] || "interpreter"
+ ];
+ bucket[implEnum] = (bucket[implEnum] || 0) + 1;
+ nextOptSample = tierData[++tierDataIndex];
+ }
+
+ samplesInCurrentBucket++;
+ }
+
+ return output;
+}
+
+exports.createTierGraphDataFromFrameNode = createTierGraphDataFromFrameNode;
+exports.OptimizationSite = OptimizationSite;
+exports.JITOptimizations = JITOptimizations;
+exports.hasSuccessfulOutcome = hasSuccessfulOutcome;
+exports.isSuccessfulOutcome = isSuccessfulOutcome;
+exports.SUCCESSFUL_OUTCOMES = SUCCESSFUL_OUTCOMES;
diff --git a/devtools/client/performance/modules/logic/moz.build b/devtools/client/performance/modules/logic/moz.build
new file mode 100644
index 0000000000..01f77231d7
--- /dev/null
+++ b/devtools/client/performance/modules/logic/moz.build
@@ -0,0 +1,12 @@
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules(
+ "frame-utils.js",
+ "jit.js",
+ "telemetry.js",
+ "tree-model.js",
+ "waterfall-utils.js",
+)
diff --git a/devtools/client/performance/modules/logic/telemetry.js b/devtools/client/performance/modules/logic/telemetry.js
new file mode 100644
index 0000000000..4ea267d747
--- /dev/null
+++ b/devtools/client/performance/modules/logic/telemetry.js
@@ -0,0 +1,106 @@
+/* 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/. */
+
+"use strict";
+
+const Telemetry = require("devtools/client/shared/telemetry");
+const EVENTS = require("devtools/client/performance/events");
+
+const EVENT_MAP_FLAGS = new Map([
+ [EVENTS.RECORDING_IMPORTED, "DEVTOOLS_PERFTOOLS_RECORDING_IMPORT_FLAG"],
+ [EVENTS.RECORDING_EXPORTED, "DEVTOOLS_PERFTOOLS_RECORDING_EXPORT_FLAG"],
+]);
+
+const RECORDING_FEATURES = [
+ "withMarkers",
+ "withTicks",
+ "withMemory",
+ "withAllocations",
+];
+
+const SELECTED_VIEW_HISTOGRAM_NAME = "DEVTOOLS_PERFTOOLS_SELECTED_VIEW_MS";
+
+function PerformanceTelemetry(emitter) {
+ this._emitter = emitter;
+ this._telemetry = new Telemetry();
+ this.onFlagEvent = this.onFlagEvent.bind(this);
+ this.onRecordingStateChange = this.onRecordingStateChange.bind(this);
+ this.onViewSelected = this.onViewSelected.bind(this);
+
+ for (const [event] of EVENT_MAP_FLAGS) {
+ this._emitter.on(event, this.onFlagEvent.bind(this, event));
+ }
+
+ this._emitter.on(EVENTS.RECORDING_STATE_CHANGE, this.onRecordingStateChange);
+ this._emitter.on(EVENTS.UI_DETAILS_VIEW_SELECTED, this.onViewSelected);
+}
+
+PerformanceTelemetry.prototype.destroy = function() {
+ if (this._previousView) {
+ this._telemetry.finishKeyed(
+ SELECTED_VIEW_HISTOGRAM_NAME,
+ this._previousView,
+ this,
+ false
+ );
+ }
+
+ for (const [event] of EVENT_MAP_FLAGS) {
+ this._emitter.off(event, this.onFlagEvent);
+ }
+ this._emitter.off(EVENTS.RECORDING_STATE_CHANGE, this.onRecordingStateChange);
+ this._emitter.off(EVENTS.UI_DETAILS_VIEW_SELECTED, this.onViewSelected);
+ this._emitter = null;
+};
+
+PerformanceTelemetry.prototype.onFlagEvent = function(eventName, ...data) {
+ this._telemetry.getHistogramById(EVENT_MAP_FLAGS.get(eventName)).add(true);
+};
+
+PerformanceTelemetry.prototype.onRecordingStateChange = function(
+ status,
+ model
+) {
+ if (status != "recording-stopped") {
+ return;
+ }
+
+ if (model.isConsole()) {
+ this._telemetry
+ .getHistogramById("DEVTOOLS_PERFTOOLS_CONSOLE_RECORDING_COUNT")
+ .add(true);
+ } else {
+ this._telemetry
+ .getHistogramById("DEVTOOLS_PERFTOOLS_RECORDING_COUNT")
+ .add(true);
+ }
+
+ this._telemetry
+ .getHistogramById("DEVTOOLS_PERFTOOLS_RECORDING_DURATION_MS")
+ .add(model.getDuration());
+
+ const config = model.getConfiguration();
+ for (const k in config) {
+ if (RECORDING_FEATURES.includes(k)) {
+ this._telemetry
+ .getKeyedHistogramById("DEVTOOLS_PERFTOOLS_RECORDING_FEATURES_USED")
+ .add(k, config[k]);
+ }
+ }
+};
+
+PerformanceTelemetry.prototype.onViewSelected = function(viewName) {
+ if (this._previousView) {
+ this._telemetry.finishKeyed(
+ SELECTED_VIEW_HISTOGRAM_NAME,
+ this._previousView,
+ this,
+ false
+ );
+ }
+ this._previousView = viewName;
+ this._telemetry.startKeyed(SELECTED_VIEW_HISTOGRAM_NAME, viewName, this);
+};
+
+exports.PerformanceTelemetry = PerformanceTelemetry;
diff --git a/devtools/client/performance/modules/logic/tree-model.js b/devtools/client/performance/modules/logic/tree-model.js
new file mode 100644
index 0000000000..518b4838a2
--- /dev/null
+++ b/devtools/client/performance/modules/logic/tree-model.js
@@ -0,0 +1,589 @@
+/* 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/. */
+"use strict";
+
+const {
+ JITOptimizations,
+} = require("devtools/client/performance/modules/logic/jit");
+const FrameUtils = require("devtools/client/performance/modules/logic/frame-utils");
+
+/**
+ * A call tree for a thread. This is essentially a linkage between all frames
+ * of all samples into a single tree structure, with additional information
+ * on each node, like the time spent (in milliseconds) and samples count.
+ *
+ * @param object thread
+ * The raw thread object received from the backend. Contains samples,
+ * stackTable, frameTable, and stringTable.
+ * @param object options
+ * Additional supported options
+ * - number startTime
+ * - number endTime
+ * - boolean contentOnly [optional]
+ * - boolean invertTree [optional]
+ * - boolean flattenRecursion [optional]
+ */
+function ThreadNode(thread, options = {}) {
+ if (options.endTime == void 0 || options.startTime == void 0) {
+ throw new Error("ThreadNode requires both `startTime` and `endTime`.");
+ }
+ this.samples = 0;
+ this.sampleTimes = [];
+ this.youngestFrameSamples = 0;
+ this.calls = [];
+ this.duration = options.endTime - options.startTime;
+ this.nodeType = "Thread";
+ this.inverted = options.invertTree;
+
+ // Total bytesize of all allocations if enabled
+ this.byteSize = 0;
+ this.youngestFrameByteSize = 0;
+
+ const { samples, stackTable, frameTable, stringTable } = thread;
+
+ // Nothing to do if there are no samples.
+ if (samples.data.length === 0) {
+ return;
+ }
+
+ this._buildInverted(samples, stackTable, frameTable, stringTable, options);
+ if (!options.invertTree) {
+ this._uninvert();
+ }
+}
+
+ThreadNode.prototype = {
+ /**
+ * Build an inverted call tree from profile samples. The format of the
+ * samples is described in tools/profiler/ProfileEntry.h, under the heading
+ * "Thread profile JSON Format".
+ *
+ * The profile data is naturally presented inverted. Inverting the call tree
+ * is also the default in the Performance tool.
+ *
+ * @param object samples
+ * The raw samples array received from the backend.
+ * @param object stackTable
+ * The table of deduplicated stacks from the backend.
+ * @param object frameTable
+ * The table of deduplicated frames from the backend.
+ * @param object stringTable
+ * The table of deduplicated strings from the backend.
+ * @param object options
+ * Additional supported options
+ * - number startTime
+ * - number endTime
+ * - boolean contentOnly [optional]
+ * - boolean invertTree [optional]
+ */
+ _buildInverted: function buildInverted(
+ samples,
+ stackTable,
+ frameTable,
+ stringTable,
+ options
+ ) {
+ function getOrAddFrameNode(
+ calls,
+ isLeaf,
+ frameKey,
+ inflatedFrame,
+ isMetaCategory,
+ leafTable
+ ) {
+ // Insert the inflated frame into the call tree at the current level.
+ let frameNode;
+
+ // Leaf nodes have fan out much greater than non-leaf nodes, thus the
+ // use of a hash table. Otherwise, do linear search.
+ //
+ // Note that this method is very hot, thus the manual looping over
+ // Array.prototype.find.
+ if (isLeaf) {
+ frameNode = leafTable[frameKey];
+ } else {
+ for (let i = 0; i < calls.length; i++) {
+ if (calls[i].key === frameKey) {
+ frameNode = calls[i];
+ break;
+ }
+ }
+ }
+
+ if (!frameNode) {
+ frameNode = new FrameNode(frameKey, inflatedFrame, isMetaCategory);
+ if (isLeaf) {
+ leafTable[frameKey] = frameNode;
+ }
+ calls.push(frameNode);
+ }
+
+ return frameNode;
+ }
+
+ const SAMPLE_STACK_SLOT = samples.schema.stack;
+ const SAMPLE_TIME_SLOT = samples.schema.time;
+ const SAMPLE_BYTESIZE_SLOT = samples.schema.size;
+
+ const STACK_PREFIX_SLOT = stackTable.schema.prefix;
+ const STACK_FRAME_SLOT = stackTable.schema.frame;
+
+ const getOrAddInflatedFrame = FrameUtils.getOrAddInflatedFrame;
+
+ const samplesData = samples.data;
+ const stacksData = stackTable.data;
+
+ // Caches.
+ const inflatedFrameCache = FrameUtils.getInflatedFrameCache(frameTable);
+ const leafTable = Object.create(null);
+
+ const startTime = options.startTime;
+ const endTime = options.endTime;
+ const flattenRecursion = options.flattenRecursion;
+
+ // Reused options object passed to InflatedFrame.prototype.getFrameKey.
+ const mutableFrameKeyOptions = {
+ contentOnly: options.contentOnly,
+ isRoot: false,
+ isLeaf: false,
+ isMetaCategoryOut: false,
+ };
+
+ let byteSize = 0;
+ for (let i = 0; i < samplesData.length; i++) {
+ const sample = samplesData[i];
+ const sampleTime = sample[SAMPLE_TIME_SLOT];
+
+ if (SAMPLE_BYTESIZE_SLOT !== void 0) {
+ byteSize = sample[SAMPLE_BYTESIZE_SLOT];
+ }
+
+ // A sample's end time is considered to be its time of sampling. Its
+ // start time is the sampling time of the previous sample.
+ //
+ // Thus, we compare sampleTime <= start instead of < to filter out
+ // samples that end exactly at the start time.
+ if (!sampleTime || sampleTime <= startTime || sampleTime > endTime) {
+ continue;
+ }
+
+ let stackIndex = sample[SAMPLE_STACK_SLOT];
+ let calls = this.calls;
+ let prevCalls = this.calls;
+ let prevFrameKey;
+ let isLeaf = (mutableFrameKeyOptions.isLeaf = true);
+ const skipRoot = options.invertTree;
+
+ // Inflate the stack and build the FrameNode call tree directly.
+ //
+ // In the profiler data, each frame's stack is referenced by an index
+ // into stackTable.
+ //
+ // Each entry in stackTable is a pair [ prefixIndex, frameIndex ]. The
+ // prefixIndex is itself an index into stackTable, referencing the
+ // prefix of the current stack (that is, the younger frames). In other
+ // words, the stackTable is encoded as a trie of the inverted
+ // callstack. The frameIndex is an index into frameTable, describing the
+ // frame at the current depth.
+ //
+ // This algorithm inflates each frame in the frame table while walking
+ // the stack trie as described above.
+ //
+ // The frame key is then computed from the inflated frame /and/ the
+ // current depth in the FrameNode call tree. That is, the frame key is
+ // not wholly determinable from just the inflated frame.
+ //
+ // For content frames, the frame key is just its location. For chrome
+ // frames, the key may be a metacategory or its location, depending on
+ // rendering options and its position in the FrameNode call tree.
+ //
+ // The frame key is then used to build up the inverted FrameNode call
+ // tree.
+ //
+ // Note that various filtering functions, such as filtering for content
+ // frames or flattening recursion, are inlined into the stack inflation
+ // loop. This is important for performance as it avoids intermediate
+ // structures and multiple passes.
+ while (stackIndex !== null) {
+ const stackEntry = stacksData[stackIndex];
+ const frameIndex = stackEntry[STACK_FRAME_SLOT];
+
+ // Fetch the stack prefix (i.e. older frames) index.
+ stackIndex = stackEntry[STACK_PREFIX_SLOT];
+
+ // Do not include the (root) node in this sample, as the costs of each frame
+ // will make it clear to differentiate (root)->B vs (root)->A->B
+ // when a tree is inverted, a revert of bug 1147604
+ if (stackIndex === null && skipRoot) {
+ break;
+ }
+
+ // Inflate the frame.
+ const inflatedFrame = getOrAddInflatedFrame(
+ inflatedFrameCache,
+ frameIndex,
+ frameTable,
+ stringTable
+ );
+
+ // Compute the frame key.
+ mutableFrameKeyOptions.isRoot = stackIndex === null;
+ const frameKey = inflatedFrame.getFrameKey(mutableFrameKeyOptions);
+
+ // An empty frame key means this frame should be skipped.
+ if (frameKey === "") {
+ continue;
+ }
+
+ // If we shouldn't flatten the current frame into the previous one, advance a
+ // level in the call tree.
+ const shouldFlatten = flattenRecursion && frameKey === prevFrameKey;
+ if (!shouldFlatten) {
+ calls = prevCalls;
+ }
+
+ const frameNode = getOrAddFrameNode(
+ calls,
+ isLeaf,
+ frameKey,
+ inflatedFrame,
+ mutableFrameKeyOptions.isMetaCategoryOut,
+ leafTable
+ );
+ if (isLeaf) {
+ frameNode.youngestFrameSamples++;
+ frameNode._addOptimizations(
+ inflatedFrame.optimizations,
+ inflatedFrame.implementation,
+ sampleTime,
+ stringTable
+ );
+
+ if (byteSize) {
+ frameNode.youngestFrameByteSize += byteSize;
+ }
+ }
+
+ // Don't overcount flattened recursive frames.
+ if (!shouldFlatten) {
+ frameNode.samples++;
+ if (byteSize) {
+ frameNode.byteSize += byteSize;
+ }
+ }
+
+ prevFrameKey = frameKey;
+ prevCalls = frameNode.calls;
+ isLeaf = mutableFrameKeyOptions.isLeaf = false;
+ }
+
+ this.samples++;
+ this.sampleTimes.push(sampleTime);
+ if (byteSize) {
+ this.byteSize += byteSize;
+ }
+ }
+ },
+
+ /**
+ * Uninverts the call tree after its having been built.
+ */
+ _uninvert: function uninvert() {
+ function mergeOrAddFrameNode(calls, node, samples, size) {
+ // Unlike the inverted call tree, we don't use a root table for the top
+ // level, as in general, there are many fewer entry points than
+ // leaves. Instead, linear search is used regardless of level.
+ for (let i = 0; i < calls.length; i++) {
+ if (calls[i].key === node.key) {
+ const foundNode = calls[i];
+ foundNode._merge(node, samples, size);
+ return foundNode.calls;
+ }
+ }
+ const copy = node._clone(samples, size);
+ calls.push(copy);
+ return copy.calls;
+ }
+
+ const workstack = [{ node: this, level: 0 }];
+ const spine = [];
+ let entry;
+
+ // The new root.
+ const rootCalls = [];
+
+ // Walk depth-first and keep the current spine (e.g., callstack).
+ do {
+ entry = workstack.pop();
+ if (entry) {
+ spine[entry.level] = entry;
+
+ const node = entry.node;
+ const calls = node.calls;
+ let callSamples = 0;
+ let callByteSize = 0;
+
+ // Continue the depth-first walk.
+ for (let i = 0; i < calls.length; i++) {
+ workstack.push({ node: calls[i], level: entry.level + 1 });
+ callSamples += calls[i].samples;
+ callByteSize += calls[i].byteSize;
+ }
+
+ // The sample delta is used to distinguish stacks.
+ //
+ // Suppose we have the following stack samples:
+ //
+ // A -> B
+ // A -> C
+ // A
+ //
+ // The inverted tree is:
+ //
+ // A
+ // / \
+ // B C
+ //
+ // with A.samples = 3, B.samples = 1, C.samples = 1.
+ //
+ // A is distinguished as being its own stack because
+ // A.samples - (B.samples + C.samples) > 0.
+ //
+ // Note that bottoming out is a degenerate where callSamples = 0.
+
+ const samplesDelta = node.samples - callSamples;
+ const byteSizeDelta = node.byteSize - callByteSize;
+ if (samplesDelta > 0) {
+ // Reverse the spine and add them to the uninverted call tree.
+ let uninvertedCalls = rootCalls;
+ for (let level = entry.level; level > 0; level--) {
+ const callee = spine[level];
+ uninvertedCalls = mergeOrAddFrameNode(
+ uninvertedCalls,
+ callee.node,
+ samplesDelta,
+ byteSizeDelta
+ );
+ }
+ }
+ }
+ } while (entry);
+
+ // Replace the toplevel calls with rootCalls, which now contains the
+ // uninverted roots.
+ this.calls = rootCalls;
+ },
+
+ /**
+ * Gets additional details about this node.
+ * @see FrameNode.prototype.getInfo for more information.
+ *
+ * @return object
+ */
+ getInfo: function(options) {
+ return FrameUtils.getFrameInfo(this, options);
+ },
+
+ /**
+ * Mimicks the interface of FrameNode, and a ThreadNode can never have
+ * optimization data (at the moment, anyway), so provide a function
+ * to return null so we don't need to check if a frame node is a thread
+ * or not everytime we fetch optimization data.
+ *
+ * @return {null}
+ */
+
+ hasOptimizations: function() {
+ return null;
+ },
+};
+
+/**
+ * A function call node in a tree. Represents a function call with a unique context,
+ * resulting in each FrameNode having its own row in the corresponding tree view.
+ * Take samples:
+ * A()->B()->C()
+ * A()->B()
+ * Q()->B()
+ *
+ * In inverted tree, A()->B()->C() would have one frame node, and A()->B() and
+ * Q()->B() would share a frame node.
+ * In an uninverted tree, A()->B()->C() and A()->B() would share a frame node,
+ * with Q()->B() having its own.
+ *
+ * In all cases, all the frame nodes originated from the same InflatedFrame.
+ *
+ * @param string frameKey
+ * The key associated with this frame. The key determines identity of
+ * the node.
+ * @param string location
+ * The location of this function call. Note that this isn't sanitized,
+ * so it may very well (not?) include the function name, url, etc.
+ * @param number line
+ * The line number inside the source containing this function call.
+ * @param number category
+ * The category type of this function call ("js", "graphics" etc.).
+ * @param number allocations
+ * The number of memory allocations performed in this frame.
+ * @param number isContent
+ * Whether this frame is content.
+ * @param boolean isMetaCategory
+ * Whether or not this is a platform node that should appear as a
+ * generalized meta category or not.
+ */
+function FrameNode(
+ frameKey,
+ { location, line, category, isContent },
+ isMetaCategory
+) {
+ this.key = frameKey;
+ this.location = location;
+ this.line = line;
+ this.youngestFrameSamples = 0;
+ this.samples = 0;
+ this.calls = [];
+ this.isContent = !!isContent;
+ this._optimizations = null;
+ this._tierData = [];
+ this._stringTable = null;
+ this.isMetaCategory = !!isMetaCategory;
+ this.category = category;
+ this.nodeType = "Frame";
+ this.byteSize = 0;
+ this.youngestFrameByteSize = 0;
+}
+
+FrameNode.prototype = {
+ /**
+ * Take optimization data observed for this frame.
+ *
+ * @param object optimizationSite
+ * Any JIT optimization information attached to the current
+ * sample. Lazily inflated via stringTable.
+ * @param number implementation
+ * JIT implementation used for this observed frame (baseline, ion);
+ * can be null indicating "interpreter"
+ * @param number time
+ * The time this optimization occurred.
+ * @param object stringTable
+ * The string table used to inflate the optimizationSite.
+ */
+ _addOptimizations: function(site, implementation, time, stringTable) {
+ // Simply accumulate optimization sites for now. Processing is done lazily
+ // by JITOptimizations, if optimization information is actually displayed.
+ if (site) {
+ let opts = this._optimizations;
+ if (opts === null) {
+ opts = this._optimizations = [];
+ }
+ opts.push(site);
+ }
+
+ if (!this._stringTable) {
+ this._stringTable = stringTable;
+ }
+
+ // Record type of implementation used and the sample time
+ this._tierData.push({ implementation, time });
+ },
+
+ _clone: function(samples, size) {
+ const newNode = new FrameNode(this.key, this, this.isMetaCategory);
+ newNode._merge(this, samples, size);
+ return newNode;
+ },
+
+ _merge: function(otherNode, samples, size) {
+ if (this === otherNode) {
+ return;
+ }
+
+ this.samples += samples;
+ this.byteSize += size;
+ if (otherNode.youngestFrameSamples > 0) {
+ this.youngestFrameSamples += samples;
+ }
+
+ if (otherNode.youngestFrameByteSize > 0) {
+ this.youngestFrameByteSize += otherNode.youngestFrameByteSize;
+ }
+
+ if (this._stringTable === null) {
+ this._stringTable = otherNode._stringTable;
+ }
+
+ if (otherNode._optimizations) {
+ if (!this._optimizations) {
+ this._optimizations = [];
+ }
+ const opts = this._optimizations;
+ const otherOpts = otherNode._optimizations;
+ for (let i = 0; i < otherOpts.length; i++) {
+ opts.push(otherOpts[i]);
+ }
+ }
+
+ if (otherNode._tierData.length) {
+ const tierData = this._tierData;
+ const otherTierData = otherNode._tierData;
+ for (let i = 0; i < otherTierData.length; i++) {
+ tierData.push(otherTierData[i]);
+ }
+ tierData.sort((a, b) => a.time - b.time);
+ }
+ },
+
+ /**
+ * Returns the parsed location and additional data describing
+ * this frame. Uses cached data if possible. Takes the following
+ * options:
+ *
+ * @param {ThreadNode} options.root
+ * The root thread node to calculate relative costs.
+ * Generates [self|total] [duration|percentage] values.
+ * @param {boolean} options.allocations
+ * Generates `totalAllocations` and `selfAllocations`.
+ *
+ * @return object
+ * The computed { name, file, url, line } properties for this
+ * function call, as well as additional params if options specified.
+ */
+ getInfo: function(options) {
+ return FrameUtils.getFrameInfo(this, options);
+ },
+
+ /**
+ * Returns whether or not the frame node has an JITOptimizations model.
+ *
+ * @return {Boolean}
+ */
+ hasOptimizations: function() {
+ return !this.isMetaCategory && !!this._optimizations;
+ },
+
+ /**
+ * Returns the underlying JITOptimizations model representing
+ * the optimization attempts occuring in this frame.
+ *
+ * @return {JITOptimizations|null}
+ */
+ getOptimizations: function() {
+ if (!this._optimizations) {
+ return null;
+ }
+ return new JITOptimizations(this._optimizations, this._stringTable);
+ },
+
+ /**
+ * Returns the tiers used overtime.
+ *
+ * @return {Array<object>}
+ */
+ getTierData: function() {
+ return this._tierData;
+ },
+};
+
+exports.ThreadNode = ThreadNode;
+exports.FrameNode = FrameNode;
diff --git a/devtools/client/performance/modules/logic/waterfall-utils.js b/devtools/client/performance/modules/logic/waterfall-utils.js
new file mode 100644
index 0000000000..5fc7e768e1
--- /dev/null
+++ b/devtools/client/performance/modules/logic/waterfall-utils.js
@@ -0,0 +1,171 @@
+/* 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/. */
+"use strict";
+
+/**
+ * Utility functions for collapsing markers into a waterfall.
+ */
+
+const {
+ MarkerBlueprintUtils,
+} = require("devtools/client/performance/modules/marker-blueprint-utils");
+
+/**
+ * Creates a parent marker, which functions like a regular marker,
+ * but is able to hold additional child markers.
+ *
+ * The marker is seeded with values from `marker`.
+ * @param object marker
+ * @return object
+ */
+function createParentNode(marker) {
+ return Object.assign({}, marker, { submarkers: [] });
+}
+
+/**
+ * Collapses markers into a tree-like structure.
+ * @param object rootNode
+ * @param array markersList
+ * @param array filter
+ */
+function collapseMarkersIntoNode({ rootNode, markersList, filter }) {
+ const {
+ getCurrentParentNode,
+ pushNode,
+ popParentNode,
+ } = createParentNodeFactory(rootNode);
+
+ for (let i = 0, len = markersList.length; i < len; i++) {
+ const curr = markersList[i];
+
+ // If this marker type should not be displayed, just skip
+ if (!MarkerBlueprintUtils.shouldDisplayMarker(curr, filter)) {
+ continue;
+ }
+
+ let parentNode = getCurrentParentNode();
+ const blueprint = MarkerBlueprintUtils.getBlueprintFor(curr);
+
+ const nestable = "nestable" in blueprint ? blueprint.nestable : true;
+ const collapsible =
+ "collapsible" in blueprint ? blueprint.collapsible : true;
+
+ let finalized = false;
+
+ // Extend the marker with extra properties needed in the marker tree
+ const extendedProps = { index: i };
+ if (collapsible) {
+ extendedProps.submarkers = [];
+ }
+ Object.assign(curr, extendedProps);
+
+ // If not nestible, just push it inside the root node. Additionally,
+ // markers originating outside the main thread are considered to be
+ // "never collapsible", to avoid confusion.
+ // A beter solution would be to collapse every marker with its siblings
+ // from the same thread, but that would require a thread id attached
+ // to all markers, which is potentially expensive and rather useless at
+ // the moment, since we don't really have that many OTMT markers.
+ if (!nestable || curr.isOffMainThread) {
+ pushNode(rootNode, curr);
+ continue;
+ }
+
+ // First off, if any parent nodes exist, finish them off
+ // recursively upwards if this marker is outside their ranges and nestable.
+ while (!finalized && parentNode) {
+ // If this marker is eclipsed by the current parent marker,
+ // make it a child of the current parent and stop going upwards.
+ // If the markers aren't from the same process, attach them to the root
+ // node as well. Every process has its own main thread.
+ if (
+ nestable &&
+ curr.start >= parentNode.start &&
+ curr.end <= parentNode.end &&
+ curr.processType == parentNode.processType
+ ) {
+ pushNode(parentNode, curr);
+ finalized = true;
+ break;
+ }
+
+ // If this marker is still nestable, but outside of the range
+ // of the current parent, iterate upwards on the next parent
+ // and finalize the current parent.
+ if (nestable) {
+ popParentNode();
+ parentNode = getCurrentParentNode();
+ continue;
+ }
+ }
+
+ if (!finalized) {
+ pushNode(rootNode, curr);
+ }
+ }
+}
+
+/**
+ * Takes a root marker node and creates a hash of functions used
+ * to manage the creation and nesting of additional parent markers.
+ *
+ * @param {object} root
+ * @return {object}
+ */
+function createParentNodeFactory(root) {
+ const parentMarkers = [];
+ const factory = {
+ /**
+ * Pops the most recent parent node off the stack, finalizing it.
+ * Sets the `end` time based on the most recent child if not defined.
+ */
+ popParentNode: () => {
+ if (parentMarkers.length === 0) {
+ throw new Error("Cannot pop parent markers when none exist.");
+ }
+
+ const lastParent = parentMarkers.pop();
+
+ // If this finished parent marker doesn't have an end time,
+ // so probably a synthesized marker, use the last marker's end time.
+ if (lastParent.end == void 0) {
+ lastParent.end =
+ lastParent.submarkers[lastParent.submarkers.length - 1].end;
+ }
+
+ // If no children were ever pushed into this parent node,
+ // remove its submarkers so it behaves like a non collapsible
+ // node.
+ if (!lastParent.submarkers.length) {
+ delete lastParent.submarkers;
+ }
+
+ return lastParent;
+ },
+
+ /**
+ * Returns the most recent parent node.
+ */
+ getCurrentParentNode: () =>
+ parentMarkers.length ? parentMarkers[parentMarkers.length - 1] : null,
+
+ /**
+ * Push this marker into the most recent parent node.
+ */
+ pushNode: (parent, marker) => {
+ parent.submarkers.push(marker);
+
+ // If pushing a parent marker, track it as the top of
+ // the parent stack.
+ if (marker.submarkers) {
+ parentMarkers.push(marker);
+ }
+ },
+ };
+
+ return factory;
+}
+
+exports.createParentNode = createParentNode;
+exports.collapseMarkersIntoNode = collapseMarkersIntoNode;
diff --git a/devtools/client/performance/modules/marker-blueprint-utils.js b/devtools/client/performance/modules/marker-blueprint-utils.js
new file mode 100644
index 0000000000..f249c0cb7b
--- /dev/null
+++ b/devtools/client/performance/modules/marker-blueprint-utils.js
@@ -0,0 +1,110 @@
+/* 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/. */
+"use strict";
+
+const {
+ TIMELINE_BLUEPRINT,
+} = require("devtools/client/performance/modules/markers");
+
+/**
+ * This file contains utilities for parsing out the markers blueprint
+ * to generate strings to be displayed in the UI.
+ */
+
+exports.MarkerBlueprintUtils = {
+ /**
+ * Takes a marker and a list of marker names that should be hidden, and
+ * determines if this marker should be filtered or not.
+ *
+ * @param object marker
+ * @return boolean
+ */
+ shouldDisplayMarker: function(marker, hiddenMarkerNames) {
+ if (!hiddenMarkerNames || hiddenMarkerNames.length == 0) {
+ return true;
+ }
+
+ // If this marker isn't yet defined in the blueprint, simply check if the
+ // entire category of "UNKNOWN" markers are supposed to be visible or not.
+ const isUnknown = !(marker.name in TIMELINE_BLUEPRINT);
+ if (isUnknown) {
+ return !hiddenMarkerNames.includes("UNKNOWN");
+ }
+
+ return !hiddenMarkerNames.includes(marker.name);
+ },
+
+ /**
+ * Takes a marker and returns the blueprint definition for that marker type,
+ * falling back to the UNKNOWN blueprint definition if undefined.
+ *
+ * @param object marker
+ * @return object
+ */
+ getBlueprintFor: function(marker) {
+ return TIMELINE_BLUEPRINT[marker.name] || TIMELINE_BLUEPRINT.UNKNOWN;
+ },
+
+ /**
+ * Returns the label to display for a marker, based off the blueprints.
+ *
+ * @param object marker
+ * @return string
+ */
+ getMarkerLabel: function(marker) {
+ const blueprint = this.getBlueprintFor(marker);
+ const dynamic = typeof blueprint.label === "function";
+ const label = dynamic ? blueprint.label(marker) : blueprint.label;
+ return label;
+ },
+
+ /**
+ * Returns the generic label to display for a marker name.
+ * (e.g. "Function Call" for JS markers, rather than "setTimeout", etc.)
+ *
+ * @param string type
+ * @return string
+ */
+ getMarkerGenericName: function(markerName) {
+ const blueprint = this.getBlueprintFor({ name: markerName });
+ const dynamic = typeof blueprint.label === "function";
+ const generic = dynamic ? blueprint.label() : blueprint.label;
+
+ // If no class name found, attempt to throw a descriptive error as to
+ // how the marker implementor can fix this.
+ if (!generic) {
+ let message = `Could not find marker generic name for "${markerName}".`;
+ if (typeof blueprint.label === "function") {
+ message +=
+ ` The following function must return a generic name string when no` +
+ ` marker passed: ${blueprint.label}`;
+ } else {
+ message += ` ${markerName}.label must be defined in the marker blueprint.`;
+ }
+ throw new Error(message);
+ }
+
+ return generic;
+ },
+
+ /**
+ * Returns an array of objects with key/value pairs of what should be rendered
+ * in the marker details view.
+ *
+ * @param object marker
+ * @return array<object>
+ */
+ getMarkerFields: function(marker) {
+ const blueprint = this.getBlueprintFor(marker);
+ const dynamic = typeof blueprint.fields === "function";
+ const fields = dynamic ? blueprint.fields(marker) : blueprint.fields;
+
+ return Object.entries(fields || {})
+ .filter(([_, value]) => (dynamic ? true : value in marker))
+ .map(([label, value]) => ({
+ label,
+ value: dynamic ? value : marker[value],
+ }));
+ },
+};
diff --git a/devtools/client/performance/modules/marker-dom-utils.js b/devtools/client/performance/modules/marker-dom-utils.js
new file mode 100644
index 0000000000..556f940da9
--- /dev/null
+++ b/devtools/client/performance/modules/marker-dom-utils.js
@@ -0,0 +1,275 @@
+/* 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/. */
+"use strict";
+
+/**
+ * This file contains utilities for creating DOM nodes for markers
+ * to be displayed in the UI.
+ */
+
+const { L10N, PREFS } = require("devtools/client/performance/modules/global");
+const {
+ MarkerBlueprintUtils,
+} = require("devtools/client/performance/modules/marker-blueprint-utils");
+const { getSourceNames } = require("devtools/client/shared/source-utils");
+
+/**
+ * Utilites for creating elements for markers.
+ */
+exports.MarkerDOMUtils = {
+ /**
+ * Builds all the fields possible for the given marker. Returns an
+ * array of elements to be appended to a parent element.
+ *
+ * @param document doc
+ * @param object marker
+ * @return array<Node>
+ */
+ buildFields: function(doc, marker) {
+ const fields = MarkerBlueprintUtils.getMarkerFields(marker);
+ return fields.map(({ label, value }) =>
+ this.buildNameValueLabel(doc, label, value)
+ );
+ },
+
+ /**
+ * Builds the label representing the marker's type.
+ *
+ * @param document doc
+ * @param object marker
+ * @return Node
+ */
+ buildTitle: function(doc, marker) {
+ const blueprint = MarkerBlueprintUtils.getBlueprintFor(marker);
+
+ const hbox = doc.createXULElement("hbox");
+ hbox.setAttribute("align", "center");
+
+ const bullet = doc.createXULElement("hbox");
+ bullet.className = `marker-details-bullet marker-color-${blueprint.colorName}`;
+
+ const title = MarkerBlueprintUtils.getMarkerLabel(marker);
+ const label = doc.createXULElement("label");
+ label.className = "marker-details-type";
+ label.setAttribute("value", title);
+
+ hbox.appendChild(bullet);
+ hbox.appendChild(label);
+
+ return hbox;
+ },
+
+ /**
+ * Builds the label representing the marker's duration.
+ *
+ * @param document doc
+ * @param object marker
+ * @return Node
+ */
+ buildDuration: function(doc, marker) {
+ const label = L10N.getStr("marker.field.duration");
+ const start = L10N.getFormatStrWithNumbers("timeline.tick", marker.start);
+ const end = L10N.getFormatStrWithNumbers("timeline.tick", marker.end);
+ const duration = L10N.getFormatStrWithNumbers(
+ "timeline.tick",
+ marker.end - marker.start
+ );
+
+ const el = this.buildNameValueLabel(doc, label, duration);
+ el.classList.add("marker-details-duration");
+ el.setAttribute("tooltiptext", `${start} → ${end}`);
+
+ return el;
+ },
+
+ /**
+ * Builds labels for name:value pairs.
+ * E.g. "Start: 100ms", "Duration: 200ms", ...
+ *
+ * @param document doc
+ * @param string field
+ * @param string value
+ * @return Node
+ */
+ buildNameValueLabel: function(doc, field, value) {
+ const hbox = doc.createXULElement("hbox");
+ hbox.className = "marker-details-labelcontainer";
+
+ const nameLabel = doc.createXULElement("label");
+ nameLabel.className = "plain marker-details-name-label";
+ nameLabel.setAttribute("value", field);
+ hbox.appendChild(nameLabel);
+
+ const valueLabel = doc.createXULElement("label");
+ valueLabel.className = "plain marker-details-value-label";
+ valueLabel.setAttribute("value", value);
+ hbox.appendChild(valueLabel);
+
+ return hbox;
+ },
+
+ /**
+ * Builds a stack trace in an element.
+ *
+ * @param document doc
+ * @param object params
+ * An options object with the following members:
+ * - string type: string identifier for type of stack ("stack", "startStack"
+ or "endStack"
+ * - number frameIndex: the index of the topmost stack frame
+ * - array frames: array of stack frames
+ */
+ buildStackTrace: function(doc, { type, frameIndex, frames }) {
+ const container = doc.createXULElement("vbox");
+ container.className = "marker-details-stack";
+ container.setAttribute("type", type);
+
+ const nameLabel = doc.createXULElement("label");
+ nameLabel.className = "plain marker-details-name-label";
+ nameLabel.setAttribute("value", L10N.getStr(`marker.field.${type}`));
+ container.appendChild(nameLabel);
+
+ // Workaround for profiles that have looping stack traces. See
+ // bug 1246555.
+ let wasAsyncParent = false;
+ const seen = new Set();
+
+ while (frameIndex > 0) {
+ if (seen.has(frameIndex)) {
+ break;
+ }
+ seen.add(frameIndex);
+
+ const frame = frames[frameIndex];
+ const url = frame.source;
+ const displayName = frame.functionDisplayName;
+ const line = frame.line;
+
+ // If the previous frame had an async parent, then the async
+ // cause is in this frame and should be displayed.
+ if (wasAsyncParent) {
+ const asyncStr = L10N.getFormatStr(
+ "marker.field.asyncStack",
+ frame.asyncCause
+ );
+ const asyncBox = doc.createXULElement("hbox");
+ const asyncLabel = doc.createXULElement("label");
+ asyncLabel.className = "devtools-monospace";
+ asyncLabel.setAttribute("value", asyncStr);
+ asyncBox.appendChild(asyncLabel);
+ container.appendChild(asyncBox);
+ wasAsyncParent = false;
+ }
+
+ const hbox = doc.createXULElement("hbox");
+
+ if (displayName) {
+ const functionLabel = doc.createXULElement("label");
+ functionLabel.className = "devtools-monospace";
+ functionLabel.setAttribute("value", displayName);
+ hbox.appendChild(functionLabel);
+ }
+
+ if (url) {
+ const linkNode = doc.createXULElement("a");
+ linkNode.className = "waterfall-marker-location devtools-source-link";
+ linkNode.href = url;
+ linkNode.draggable = false;
+ linkNode.setAttribute("title", url);
+
+ const urlLabel = doc.createXULElement("label");
+ urlLabel.className = "filename";
+ urlLabel.setAttribute("value", getSourceNames(url).short);
+ linkNode.appendChild(urlLabel);
+
+ const lineLabel = doc.createXULElement("label");
+ lineLabel.className = "line-number";
+ lineLabel.setAttribute("value", `:${line}`);
+ linkNode.appendChild(lineLabel);
+
+ hbox.appendChild(linkNode);
+
+ // Clicking here will bubble up to the parent,
+ // which handles the view source.
+ linkNode.setAttribute(
+ "data-action",
+ JSON.stringify({
+ url: url,
+ line: line,
+ action: "view-source",
+ })
+ );
+ }
+
+ if (!displayName && !url) {
+ const unknownLabel = doc.createXULElement("label");
+ unknownLabel.setAttribute(
+ "value",
+ L10N.getStr("marker.value.unknownFrame")
+ );
+ hbox.appendChild(unknownLabel);
+ }
+
+ container.appendChild(hbox);
+
+ if (frame.asyncParent) {
+ frameIndex = frame.asyncParent;
+ wasAsyncParent = true;
+ } else {
+ frameIndex = frame.parent;
+ }
+ }
+
+ return container;
+ },
+
+ /**
+ * Builds any custom fields specific to the marker.
+ *
+ * @param document doc
+ * @param object marker
+ * @param object options
+ * @return array<Node>
+ */
+ buildCustom: function(doc, marker, options) {
+ const elements = [];
+
+ if (options.allocations && shouldShowAllocationsTrigger(marker)) {
+ const hbox = doc.createXULElement("hbox");
+ hbox.className = "marker-details-customcontainer";
+
+ const label = doc.createXULElement("label");
+ label.className = "custom-button";
+ label.setAttribute("value", "Show allocation triggers");
+ label.setAttribute("type", "show-allocations");
+ label.setAttribute(
+ "data-action",
+ JSON.stringify({
+ endTime: marker.start,
+ action: "show-allocations",
+ })
+ );
+
+ hbox.appendChild(label);
+ elements.push(hbox);
+ }
+
+ return elements;
+ },
+};
+
+/**
+ * Takes a marker and determines if this marker should display
+ * the allocations trigger button.
+ *
+ * @param object marker
+ * @return boolean
+ */
+function shouldShowAllocationsTrigger(marker) {
+ if (marker.name == "GarbageCollection") {
+ const showTriggers = PREFS["show-triggers-for-gc-types"];
+ return showTriggers.split(" ").includes(marker.causeName);
+ }
+ return false;
+}
diff --git a/devtools/client/performance/modules/marker-formatters.js b/devtools/client/performance/modules/marker-formatters.js
new file mode 100644
index 0000000000..b4b9472259
--- /dev/null
+++ b/devtools/client/performance/modules/marker-formatters.js
@@ -0,0 +1,204 @@
+/* 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/. */
+"use strict";
+
+/**
+ * This file contains utilities for creating elements for markers to be displayed,
+ * and parsing out the blueprint to generate correct values for markers.
+ */
+const { L10N, PREFS } = require("devtools/client/performance/modules/global");
+
+// String used to fill in platform data when it should be hidden.
+const GECKO_SYMBOL = "(Gecko)";
+
+/**
+ * Mapping of JS marker causes to a friendlier form. Only
+ * markers that are considered "from content" should be labeled here.
+ */
+const JS_MARKER_MAP = {
+ "<script> element": L10N.getStr("marker.label.javascript.scriptElement"),
+ "promise callback": L10N.getStr("marker.label.javascript.promiseCallback"),
+ "promise initializer": L10N.getStr("marker.label.javascript.promiseInit"),
+ "Worker runnable": L10N.getStr("marker.label.javascript.workerRunnable"),
+ "javascript: URI": L10N.getStr("marker.label.javascript.jsURI"),
+ // The difference between these two event handler markers are differences
+ // in their WebIDL implementation, so distinguishing them is not necessary.
+ EventHandlerNonNull: L10N.getStr("marker.label.javascript.eventHandler"),
+ "EventListener.handleEvent": L10N.getStr(
+ "marker.label.javascript.eventHandler"
+ ),
+ // These markers do not get L10N'd because they're JS names.
+ "setInterval handler": "setInterval",
+ "setTimeout handler": "setTimeout",
+ FrameRequestCallback: "requestAnimationFrame",
+};
+
+/**
+ * A series of formatters used by the blueprint.
+ */
+exports.Formatters = {
+ /**
+ * Uses the marker name as the label for markers that do not have
+ * a blueprint entry. Uses "Other" in the marker filter menu.
+ */
+ UnknownLabel: function(marker = {}) {
+ return marker.name || L10N.getStr("marker.label.unknown");
+ },
+
+ /* Group 0 - Reflow and Rendering pipeline */
+
+ StylesFields: function(marker) {
+ if ("isAnimationOnly" in marker) {
+ return {
+ [L10N.getStr("marker.field.isAnimationOnly")]: marker.isAnimationOnly,
+ };
+ }
+ return null;
+ },
+
+ /* Group 1 - JS */
+
+ DOMEventFields: function(marker) {
+ const fields = Object.create(null);
+
+ if ("type" in marker) {
+ fields[L10N.getStr("marker.field.DOMEventType")] = marker.type;
+ }
+
+ if ("eventPhase" in marker) {
+ let label;
+ switch (marker.eventPhase) {
+ case Event.AT_TARGET:
+ label = L10N.getStr("marker.value.DOMEventTargetPhase");
+ break;
+ case Event.CAPTURING_PHASE:
+ label = L10N.getStr("marker.value.DOMEventCapturingPhase");
+ break;
+ case Event.BUBBLING_PHASE:
+ label = L10N.getStr("marker.value.DOMEventBubblingPhase");
+ break;
+ }
+ fields[L10N.getStr("marker.field.DOMEventPhase")] = label;
+ }
+
+ return fields;
+ },
+
+ JSLabel: function(marker = {}) {
+ const generic = L10N.getStr("marker.label.javascript");
+ if ("causeName" in marker) {
+ return JS_MARKER_MAP[marker.causeName] || generic;
+ }
+ return generic;
+ },
+
+ JSFields: function(marker) {
+ if ("causeName" in marker && !JS_MARKER_MAP[marker.causeName]) {
+ const label = PREFS["show-platform-data"]
+ ? marker.causeName
+ : GECKO_SYMBOL;
+ return {
+ [L10N.getStr("marker.field.causeName")]: label,
+ };
+ }
+ return null;
+ },
+
+ GCLabel: function(marker) {
+ if (!marker) {
+ return L10N.getStr("marker.label.garbageCollection2");
+ }
+ // Only if a `nonincrementalReason` exists, do we want to label
+ // this as a non incremental GC event.
+ if ("nonincrementalReason" in marker) {
+ return L10N.getStr("marker.label.garbageCollection.nonIncremental");
+ }
+ return L10N.getStr("marker.label.garbageCollection.incremental");
+ },
+
+ GCFields: function(marker) {
+ const fields = Object.create(null);
+
+ if ("causeName" in marker) {
+ const cause = marker.causeName;
+ const label = L10N.getStr(`marker.gcreason.label.${cause}`) || cause;
+ fields[L10N.getStr("marker.field.causeName")] = label;
+ }
+
+ if ("nonincrementalReason" in marker) {
+ const label = marker.nonincrementalReason;
+ fields[L10N.getStr("marker.field.nonIncrementalCause")] = label;
+ }
+
+ return fields;
+ },
+
+ MinorGCFields: function(marker) {
+ const fields = Object.create(null);
+
+ if ("causeName" in marker) {
+ const cause = marker.causeName;
+ const label = L10N.getStr(`marker.gcreason.label.${cause}`) || cause;
+ fields[L10N.getStr("marker.field.causeName")] = label;
+ }
+
+ fields[L10N.getStr("marker.field.type")] = L10N.getStr(
+ "marker.nurseryCollection"
+ );
+
+ return fields;
+ },
+
+ CycleCollectionFields: function(marker) {
+ const label = marker.name.replace(/nsCycleCollector::/g, "");
+ return {
+ [L10N.getStr("marker.field.type")]: label,
+ };
+ },
+
+ WorkerFields: function(marker) {
+ if ("workerOperation" in marker) {
+ const label = L10N.getStr(`marker.worker.${marker.workerOperation}`);
+ return {
+ [L10N.getStr("marker.field.type")]: label,
+ };
+ }
+ return null;
+ },
+
+ MessagePortFields: function(marker) {
+ if ("messagePortOperation" in marker) {
+ const label = L10N.getStr(
+ `marker.messagePort.${marker.messagePortOperation}`
+ );
+ return {
+ [L10N.getStr("marker.field.type")]: label,
+ };
+ }
+ return null;
+ },
+
+ /* Group 2 - User Controlled */
+
+ ConsoleTimeFields: {
+ [L10N.getStr("marker.field.consoleTimerName")]: "causeName",
+ },
+
+ TimeStampFields: {
+ [L10N.getStr("marker.field.label")]: "causeName",
+ },
+};
+
+/**
+ * Takes a main label (e.g. "Timestamp") and a property name (e.g. "causeName"),
+ * and returns a string that represents that property value for a marker if it
+ * exists (e.g. "Timestamp (rendering)"), or just the main label if it does not.
+ *
+ * @param string mainLabel
+ * @param string propName
+ */
+exports.Formatters.labelForProperty = function(mainLabel, propName) {
+ return (marker = {}) =>
+ marker[propName] ? `${mainLabel} (${marker[propName]})` : mainLabel;
+};
diff --git a/devtools/client/performance/modules/markers.js b/devtools/client/performance/modules/markers.js
new file mode 100644
index 0000000000..dbcd661205
--- /dev/null
+++ b/devtools/client/performance/modules/markers.js
@@ -0,0 +1,180 @@
+/* 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/. */
+"use strict";
+
+const { L10N } = require("devtools/client/performance/modules/global");
+const {
+ Formatters,
+} = require("devtools/client/performance/modules/marker-formatters");
+
+/**
+ * A simple schema for mapping markers to the timeline UI. The keys correspond
+ * to marker names, while the values are objects with the following format:
+ *
+ * - group: The row index in the overview graph; multiple markers
+ * can be added on the same row. @see <overview.js/buildGraphImage>
+ * - label: The label used in the waterfall to identify the marker. Can be a
+ * string or just a function that accepts the marker and returns a
+ * string (if you want to use a dynamic property for the main label).
+ * If you use a function for a label, it *must* handle the case where
+ * no marker is provided, to get a generic label used to describe
+ * all markers of this type.
+ * - fields: The fields used in the marker details view to display more
+ * information about a currently selected marker. Can either be an
+ * object of fields, or simply a function that accepts the marker and
+ * returns such an object (if you want to use properties dynamically).
+ * For example, a field in the object such as { "Cause": "causeName" }
+ * would render something like `Cause: ${marker.causeName}` in the UI.
+ * - colorName: The label of the DevTools color used for this marker. If
+ * adding a new color, be sure to check that there's an entry
+ * for `.marker-color-graphs-{COLORNAME}` for the equivilent
+ * entry in "./devtools/client/themes/performance.css"
+ * - collapsible: Whether or not this marker can contain other markers it
+ * eclipses, and becomes collapsible to reveal its nestable
+ * children. Defaults to true.
+ * - nestable: Whether or not this marker can be nested inside an eclipsing
+ * collapsible marker. Defaults to true.
+ */
+const TIMELINE_BLUEPRINT = {
+ /* Default definition used for markers that occur but are not defined here.
+ * Should ultimately be defined, but this gives us room to work on the
+ * front end separately from the platform. */
+ UNKNOWN: {
+ group: 2,
+ colorName: "graphs-grey",
+ label: Formatters.UnknownLabel,
+ },
+
+ /* Group 0 - Reflow and Rendering pipeline */
+
+ Styles: {
+ group: 0,
+ colorName: "graphs-purple",
+ label: L10N.getStr("marker.label.styles"),
+ fields: Formatters.StylesFields,
+ },
+ StylesApplyChanges: {
+ group: 0,
+ colorName: "graphs-purple",
+ label: L10N.getStr("marker.label.stylesApplyChanges"),
+ },
+ Reflow: {
+ group: 0,
+ colorName: "graphs-purple",
+ label: L10N.getStr("marker.label.reflow"),
+ },
+ Paint: {
+ group: 0,
+ colorName: "graphs-green",
+ label: L10N.getStr("marker.label.paint"),
+ },
+ Composite: {
+ group: 0,
+ colorName: "graphs-green",
+ label: L10N.getStr("marker.label.composite"),
+ },
+ CompositeForwardTransaction: {
+ group: 0,
+ colorName: "graphs-bluegrey",
+ label: L10N.getStr("marker.label.compositeForwardTransaction"),
+ },
+
+ /* Group 1 - JS */
+
+ DOMEvent: {
+ group: 1,
+ colorName: "graphs-yellow",
+ label: L10N.getStr("marker.label.domevent"),
+ fields: Formatters.DOMEventFields,
+ },
+ "document::DOMContentLoaded": {
+ group: 1,
+ colorName: "graphs-full-red",
+ label: "DOMContentLoaded",
+ },
+ "document::Load": {
+ group: 1,
+ colorName: "graphs-full-blue",
+ label: "Load",
+ },
+ Javascript: {
+ group: 1,
+ colorName: "graphs-yellow",
+ label: Formatters.JSLabel,
+ fields: Formatters.JSFields,
+ },
+ "Parse HTML": {
+ group: 1,
+ colorName: "graphs-yellow",
+ label: L10N.getStr("marker.label.parseHTML"),
+ },
+ "Parse XML": {
+ group: 1,
+ colorName: "graphs-yellow",
+ label: L10N.getStr("marker.label.parseXML"),
+ },
+ GarbageCollection: {
+ group: 1,
+ colorName: "graphs-red",
+ label: Formatters.GCLabel,
+ fields: Formatters.GCFields,
+ },
+ MinorGC: {
+ group: 1,
+ colorName: "graphs-red",
+ label: L10N.getStr("marker.label.minorGC"),
+ fields: Formatters.MinorGCFields,
+ },
+ "nsCycleCollector::Collect": {
+ group: 1,
+ colorName: "graphs-red",
+ label: L10N.getStr("marker.label.cycleCollection"),
+ fields: Formatters.CycleCollectionFields,
+ },
+ "nsCycleCollector::ForgetSkippable": {
+ group: 1,
+ colorName: "graphs-red",
+ label: L10N.getStr("marker.label.cycleCollection.forgetSkippable"),
+ fields: Formatters.CycleCollectionFields,
+ },
+ Worker: {
+ group: 1,
+ colorName: "graphs-orange",
+ label: L10N.getStr("marker.label.worker"),
+ fields: Formatters.WorkerFields,
+ },
+ MessagePort: {
+ group: 1,
+ colorName: "graphs-orange",
+ label: L10N.getStr("marker.label.messagePort"),
+ fields: Formatters.MessagePortFields,
+ },
+
+ /* Group 2 - User Controlled */
+
+ ConsoleTime: {
+ group: 2,
+ colorName: "graphs-blue",
+ label: Formatters.labelForProperty(
+ L10N.getStr("marker.label.consoleTime"),
+ "causeName"
+ ),
+ fields: Formatters.ConsoleTimeFields,
+ nestable: false,
+ collapsible: false,
+ },
+ TimeStamp: {
+ group: 2,
+ colorName: "graphs-blue",
+ label: Formatters.labelForProperty(
+ L10N.getStr("marker.label.timestamp"),
+ "causeName"
+ ),
+ fields: Formatters.TimeStampFields,
+ collapsible: false,
+ },
+};
+
+// Exported symbols.
+exports.TIMELINE_BLUEPRINT = TIMELINE_BLUEPRINT;
diff --git a/devtools/client/performance/modules/moz.build b/devtools/client/performance/modules/moz.build
new file mode 100644
index 0000000000..c054669022
--- /dev/null
+++ b/devtools/client/performance/modules/moz.build
@@ -0,0 +1,22 @@
+# vim: set filetype=python:
+# 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/.
+
+DIRS += [
+ "logic",
+ "widgets",
+]
+
+DevToolsModules(
+ "categories.js",
+ "constants.js",
+ "global.js",
+ "io.js",
+ "marker-blueprint-utils.js",
+ "marker-dom-utils.js",
+ "marker-formatters.js",
+ "markers.js",
+ "utils.js",
+ "waterfall-ticks.js",
+)
diff --git a/devtools/client/performance/modules/utils.js b/devtools/client/performance/modules/utils.js
new file mode 100644
index 0000000000..ebd0ba2ccb
--- /dev/null
+++ b/devtools/client/performance/modules/utils.js
@@ -0,0 +1,24 @@
+/* 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/. */
+"use strict";
+
+/* globals document */
+
+/**
+ * React components grab the namespace of the element they are mounting to. This function
+ * takes a XUL element, and makes sure to create a properly namespaced HTML element to
+ * avoid React creating XUL elements.
+ *
+ * {XULElement} xulElement
+ * return {HTMLElement} div
+ */
+
+exports.createHtmlMount = function(xulElement) {
+ const htmlElement = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "div"
+ );
+ xulElement.appendChild(htmlElement);
+ return htmlElement;
+};
diff --git a/devtools/client/performance/modules/waterfall-ticks.js b/devtools/client/performance/modules/waterfall-ticks.js
new file mode 100644
index 0000000000..efc88001f3
--- /dev/null
+++ b/devtools/client/performance/modules/waterfall-ticks.js
@@ -0,0 +1,98 @@
+/* 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/. */
+"use strict";
+
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+
+const WATERFALL_BACKGROUND_TICKS_MULTIPLE = 5; // ms
+const WATERFALL_BACKGROUND_TICKS_SCALES = 3;
+const WATERFALL_BACKGROUND_TICKS_SPACING_MIN = 10; // px
+const WATERFALL_BACKGROUND_TICKS_COLOR_RGB = [128, 136, 144];
+const WATERFALL_BACKGROUND_TICKS_OPACITY_MIN = 32; // byte
+const WATERFALL_BACKGROUND_TICKS_OPACITY_ADD = 32; // byte
+
+const FIND_OPTIMAL_TICK_INTERVAL_MAX_ITERS = 100;
+
+/**
+ * Creates the background displayed on the marker's waterfall.
+ */
+function drawWaterfallBackground(doc, dataScale, waterfallWidth) {
+ const canvas = doc.createElementNS(HTML_NS, "canvas");
+ const ctx = canvas.getContext("2d");
+
+ // Nuke the context.
+ const canvasWidth = (canvas.width = Math.max(waterfallWidth, 1));
+ // Awww yeah, 1px, repeats on Y axis.
+ const canvasHeight = (canvas.height = 1);
+
+ // Start over.
+ const imageData = ctx.createImageData(canvasWidth, canvasHeight);
+ const pixelArray = imageData.data;
+
+ const buf = new ArrayBuffer(pixelArray.length);
+ const view8bit = new Uint8ClampedArray(buf);
+ const view32bit = new Uint32Array(buf);
+
+ // Build new millisecond tick lines...
+ const [r, g, b] = WATERFALL_BACKGROUND_TICKS_COLOR_RGB;
+ let alphaComponent = WATERFALL_BACKGROUND_TICKS_OPACITY_MIN;
+ const tickInterval = findOptimalTickInterval({
+ ticksMultiple: WATERFALL_BACKGROUND_TICKS_MULTIPLE,
+ ticksSpacingMin: WATERFALL_BACKGROUND_TICKS_SPACING_MIN,
+ dataScale: dataScale,
+ });
+
+ // Insert one pixel for each division on each scale.
+ for (let i = 1; i <= WATERFALL_BACKGROUND_TICKS_SCALES; i++) {
+ const increment = tickInterval * Math.pow(2, i);
+ for (let x = 0; x < canvasWidth; x += increment) {
+ const position = x | 0;
+ view32bit[position] = (alphaComponent << 24) | (b << 16) | (g << 8) | r;
+ }
+ alphaComponent += WATERFALL_BACKGROUND_TICKS_OPACITY_ADD;
+ }
+
+ // Flush the image data and cache the waterfall background.
+ pixelArray.set(view8bit);
+ ctx.putImageData(imageData, 0, 0);
+ doc.mozSetImageElement("waterfall-background", canvas);
+
+ return canvas;
+}
+
+/**
+ * Finds the optimal tick interval between time markers in this timeline.
+ *
+ * @param number ticksMultiple
+ * @param number ticksSpacingMin
+ * @param number dataScale
+ * @return number
+ */
+function findOptimalTickInterval({
+ ticksMultiple,
+ ticksSpacingMin,
+ dataScale,
+}) {
+ let timingStep = ticksMultiple;
+ const maxIters = FIND_OPTIMAL_TICK_INTERVAL_MAX_ITERS;
+ let numIters = 0;
+
+ if (dataScale > ticksSpacingMin) {
+ return dataScale;
+ }
+
+ while (true) {
+ const scaledStep = dataScale * timingStep;
+ if (++numIters > maxIters) {
+ return scaledStep;
+ }
+ if (scaledStep < ticksSpacingMin) {
+ timingStep <<= 1;
+ continue;
+ }
+ return scaledStep;
+ }
+}
+
+exports.TickUtils = { findOptimalTickInterval, drawWaterfallBackground };
diff --git a/devtools/client/performance/modules/widgets/graphs.js b/devtools/client/performance/modules/widgets/graphs.js
new file mode 100644
index 0000000000..926fb43cf6
--- /dev/null
+++ b/devtools/client/performance/modules/widgets/graphs.js
@@ -0,0 +1,527 @@
+/* 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/. */
+"use strict";
+
+/**
+ * This file contains the base line graph that all Performance line graphs use.
+ */
+
+const { extend } = require("devtools/shared/extend");
+const LineGraphWidget = require("devtools/client/shared/widgets/LineGraphWidget");
+const MountainGraphWidget = require("devtools/client/shared/widgets/MountainGraphWidget");
+const { CanvasGraphUtils } = require("devtools/client/shared/widgets/Graphs");
+
+const EventEmitter = require("devtools/shared/event-emitter");
+
+const { colorUtils } = require("devtools/shared/css/color");
+const { getColor } = require("devtools/client/shared/theme");
+const ProfilerGlobal = require("devtools/client/performance/modules/global");
+const {
+ MarkersOverview,
+} = require("devtools/client/performance/modules/widgets/markers-overview");
+const {
+ createTierGraphDataFromFrameNode,
+} = require("devtools/client/performance/modules/logic/jit");
+
+/**
+ * For line graphs
+ */
+const HEIGHT = 35; // px
+const STROKE_WIDTH = 1; // px
+const DAMPEN_VALUES = 0.95;
+const CLIPHEAD_LINE_COLOR = "#666";
+const SELECTION_LINE_COLOR = "#555";
+const SELECTION_BACKGROUND_COLOR_NAME = "graphs-blue";
+const FRAMERATE_GRAPH_COLOR_NAME = "graphs-green";
+const MEMORY_GRAPH_COLOR_NAME = "graphs-blue";
+
+/**
+ * For timeline overview
+ */
+const MARKERS_GRAPH_HEADER_HEIGHT = 14; // px
+const MARKERS_GRAPH_ROW_HEIGHT = 10; // px
+const MARKERS_GROUP_VERTICAL_PADDING = 4; // px
+
+/**
+ * For optimization graph
+ */
+const OPTIMIZATIONS_GRAPH_RESOLUTION = 100;
+
+/**
+ * A base class for performance graphs to inherit from.
+ *
+ * @param Node parent
+ * The parent node holding the overview.
+ * @param string metric
+ * The unit of measurement for this graph.
+ */
+function PerformanceGraph(parent, metric) {
+ LineGraphWidget.call(this, parent, { metric });
+ this.setTheme();
+}
+
+PerformanceGraph.prototype = extend(LineGraphWidget.prototype, {
+ strokeWidth: STROKE_WIDTH,
+ dampenValuesFactor: DAMPEN_VALUES,
+ fixedHeight: HEIGHT,
+ clipheadLineColor: CLIPHEAD_LINE_COLOR,
+ selectionLineColor: SELECTION_LINE_COLOR,
+ withTooltipArrows: false,
+ withFixedTooltipPositions: true,
+
+ /**
+ * Disables selection and empties this graph.
+ */
+ clearView: function() {
+ this.selectionEnabled = false;
+ this.dropSelection();
+ this.setData([]);
+ },
+
+ /**
+ * Sets the theme via `theme` to either "light" or "dark",
+ * and updates the internal styling to match. Requires a redraw
+ * to see the effects.
+ */
+ setTheme: function(theme) {
+ theme = theme || "light";
+ const mainColor = getColor(this.mainColor || "graphs-blue", theme);
+ this.backgroundColor = getColor("body-background", theme);
+ this.strokeColor = mainColor;
+ this.backgroundGradientStart = colorUtils.setAlpha(mainColor, 0.2);
+ this.backgroundGradientEnd = colorUtils.setAlpha(mainColor, 0.2);
+ this.selectionBackgroundColor = colorUtils.setAlpha(
+ getColor(SELECTION_BACKGROUND_COLOR_NAME, theme),
+ 0.25
+ );
+ this.selectionStripesColor = "rgba(255, 255, 255, 0.1)";
+ this.maximumLineColor = colorUtils.setAlpha(mainColor, 0.4);
+ this.averageLineColor = colorUtils.setAlpha(mainColor, 0.7);
+ this.minimumLineColor = colorUtils.setAlpha(mainColor, 0.9);
+ },
+});
+
+/**
+ * Constructor for the framerate graph. Inherits from PerformanceGraph.
+ *
+ * @param Node parent
+ * The parent node holding the overview.
+ */
+function FramerateGraph(parent) {
+ PerformanceGraph.call(this, parent, ProfilerGlobal.L10N.getStr("graphs.fps"));
+}
+
+FramerateGraph.prototype = extend(PerformanceGraph.prototype, {
+ mainColor: FRAMERATE_GRAPH_COLOR_NAME,
+ setPerformanceData: function({ duration, ticks }, resolution) {
+ this.dataDuration = duration;
+ return this.setDataFromTimestamps(ticks, resolution, duration);
+ },
+});
+
+/**
+ * Constructor for the memory graph. Inherits from PerformanceGraph.
+ *
+ * @param Node parent
+ * The parent node holding the overview.
+ */
+function MemoryGraph(parent) {
+ PerformanceGraph.call(
+ this,
+ parent,
+ ProfilerGlobal.L10N.getStr("graphs.memory")
+ );
+}
+
+MemoryGraph.prototype = extend(PerformanceGraph.prototype, {
+ mainColor: MEMORY_GRAPH_COLOR_NAME,
+ setPerformanceData: function({ duration, memory }) {
+ this.dataDuration = duration;
+ return this.setData(memory);
+ },
+});
+
+function TimelineGraph(parent, filter) {
+ MarkersOverview.call(this, parent, filter);
+}
+
+TimelineGraph.prototype = extend(MarkersOverview.prototype, {
+ headerHeight: MARKERS_GRAPH_HEADER_HEIGHT,
+ rowHeight: MARKERS_GRAPH_ROW_HEIGHT,
+ groupPadding: MARKERS_GROUP_VERTICAL_PADDING,
+ setPerformanceData: MarkersOverview.prototype.setData,
+});
+
+/**
+ * Definitions file for GraphsController, indicating the constructor,
+ * selector and other meta for each of the graphs controller by
+ * GraphsController.
+ */
+const GRAPH_DEFINITIONS = {
+ memory: {
+ constructor: MemoryGraph,
+ selector: "#memory-overview",
+ },
+ framerate: {
+ constructor: FramerateGraph,
+ selector: "#time-framerate",
+ },
+ timeline: {
+ constructor: TimelineGraph,
+ selector: "#markers-overview",
+ primaryLink: true,
+ },
+};
+
+/**
+ * A controller for orchestrating the performance's tool overview graphs. Constructs,
+ * syncs, toggles displays and defines the memory, framerate and timeline view.
+ *
+ * @param {object} definition
+ * @param {DOMElement} root
+ * @param {function} getFilter
+ * @param {function} getTheme
+ */
+function GraphsController({ definition, root, getFilter, getTheme }) {
+ this._graphs = {};
+ this._enabled = new Set();
+ this._definition = definition || GRAPH_DEFINITIONS;
+ this._root = root;
+ this._getFilter = getFilter;
+ this._getTheme = getTheme;
+ this._primaryLink = Object.keys(this._definition).filter(
+ name => this._definition[name].primaryLink
+ )[0];
+ this.$ = root.ownerDocument.querySelector.bind(root.ownerDocument);
+
+ EventEmitter.decorate(this);
+ this._onSelecting = this._onSelecting.bind(this);
+}
+
+GraphsController.prototype = {
+ /**
+ * Returns the corresponding graph by `graphName`.
+ */
+ get: function(graphName) {
+ return this._graphs[graphName];
+ },
+
+ /**
+ * Iterates through all graphs and renders the data
+ * from a RecordingModel. Takes a resolution value used in
+ * some graphs.
+ * Saves rendering progress as a promise to be consumed by `destroy`,
+ * to wait for cleaning up rendering during destruction.
+ */
+ async render(recordingData, resolution) {
+ // Get the previous render promise so we don't start rendering
+ // until the previous render cycle completes, which can occur
+ // especially when a recording is finished, and triggers a
+ // fresh rendering at a higher rate
+ await this._rendering;
+
+ // Check after yielding to ensure we're not tearing down,
+ // as this can create a race condition in tests
+ if (this._destroyed) {
+ return;
+ }
+
+ this._rendering = (async () => {
+ for (const graph of await this._getEnabled()) {
+ await graph.setPerformanceData(recordingData, resolution);
+ this.emit("rendered", graph.graphName);
+ }
+ })();
+ await this._rendering;
+ },
+
+ /**
+ * Destroys the underlying graphs.
+ */
+ async destroy() {
+ const primary = this._getPrimaryLink();
+
+ this._destroyed = true;
+
+ if (primary) {
+ primary.off("selecting", this._onSelecting);
+ }
+
+ // If there was rendering, wait until the most recent render cycle
+ // has finished
+ if (this._rendering) {
+ await this._rendering;
+ }
+
+ for (const graph of this.getWidgets()) {
+ await graph.destroy();
+ }
+ },
+
+ /**
+ * Applies the theme to the underlying graphs. Optionally takes
+ * a `redraw` boolean in the options to force redraw.
+ */
+ setTheme: function(options = {}) {
+ const theme = options.theme || this._getTheme();
+ for (const graph of this.getWidgets()) {
+ graph.setTheme(theme);
+ graph.refresh({ force: options.redraw });
+ }
+ },
+
+ /**
+ * Sets up the graph, if needed. Returns a promise resolving
+ * to the graph if it is enabled once it's ready, or otherwise returns
+ * null if disabled.
+ */
+ async isAvailable(graphName) {
+ if (!this._enabled.has(graphName)) {
+ return null;
+ }
+
+ let graph = this.get(graphName);
+
+ if (!graph) {
+ graph = await this._construct(graphName);
+ }
+
+ await graph.ready();
+ return graph;
+ },
+
+ /**
+ * Enable or disable a subgraph controlled by GraphsController.
+ * This determines what graphs are visible and get rendered.
+ */
+ enable: function(graphName, isEnabled) {
+ const el = this.$(this._definition[graphName].selector);
+ el.classList[isEnabled ? "remove" : "add"]("hidden");
+
+ // If no status change, just return
+ if (this._enabled.has(graphName) === isEnabled) {
+ return;
+ }
+ if (isEnabled) {
+ this._enabled.add(graphName);
+ } else {
+ this._enabled.delete(graphName);
+ }
+
+ // Invalidate our cache of ready-to-go graphs
+ this._enabledGraphs = null;
+ },
+
+ /**
+ * Disables all graphs controller by the GraphsController, and
+ * also hides the root element. This is a one way switch, and used
+ * when older platforms do not have any timeline data.
+ */
+ disableAll: function() {
+ this._root.classList.add("hidden");
+ // Hide all the subelements
+ Object.keys(this._definition).forEach(graphName =>
+ this.enable(graphName, false)
+ );
+ },
+
+ /**
+ * Sets a mapped selection on the graph that is the main controller
+ * for keeping the graphs' selections in sync.
+ */
+ setMappedSelection: function(selection, { mapStart, mapEnd }) {
+ return this._getPrimaryLink().setMappedSelection(selection, {
+ mapStart,
+ mapEnd,
+ });
+ },
+
+ /**
+ * Fetches the currently mapped selection. If graphs are not yet rendered,
+ * (which throws in Graphs.js), return null.
+ */
+ getMappedSelection: function({ mapStart, mapEnd }) {
+ const primary = this._getPrimaryLink();
+ if (primary && primary.hasData()) {
+ return primary.getMappedSelection({ mapStart, mapEnd });
+ }
+ return null;
+ },
+
+ /**
+ * Returns an array of graphs that have been created, not necessarily
+ * enabled currently.
+ */
+ getWidgets: function() {
+ return Object.keys(this._graphs).map(name => this._graphs[name]);
+ },
+
+ /**
+ * Drops the selection.
+ */
+ dropSelection: function() {
+ if (this._getPrimaryLink()) {
+ return this._getPrimaryLink().dropSelection();
+ }
+ return null;
+ },
+
+ /**
+ * Makes sure the selection is enabled or disabled in all the graphs.
+ */
+ async selectionEnabled(enabled) {
+ for (const graph of await this._getEnabled()) {
+ graph.selectionEnabled = enabled;
+ }
+ },
+
+ /**
+ * Creates the graph `graphName` and initializes it.
+ */
+ async _construct(graphName) {
+ const def = this._definition[graphName];
+ const el = this.$(def.selector);
+ const filter = this._getFilter();
+ const graph = (this._graphs[graphName] = new def.constructor(el, filter));
+ graph.graphName = graphName;
+
+ await graph.ready();
+
+ // Sync the graphs' animations and selections together
+ if (def.primaryLink) {
+ graph.on("selecting", this._onSelecting);
+ } else {
+ CanvasGraphUtils.linkAnimation(this._getPrimaryLink(), graph);
+ CanvasGraphUtils.linkSelection(this._getPrimaryLink(), graph);
+ }
+
+ // Sets the container element's visibility based off of enabled status
+ el.classList[this._enabled.has(graphName) ? "remove" : "add"]("hidden");
+
+ this.setTheme();
+ return graph;
+ },
+
+ /**
+ * Returns the main graph for this collection, that all graphs
+ * are bound to for syncing and selection.
+ */
+ _getPrimaryLink: function() {
+ return this.get(this._primaryLink);
+ },
+
+ /**
+ * Emitted when a selection occurs.
+ */
+ _onSelecting: function() {
+ this.emit("selecting");
+ },
+
+ /**
+ * Resolves to an array with all graphs that are enabled, and
+ * creates them if needed. Different than just iterating over `this._graphs`,
+ * as those could be enabled. Uses caching, as rendering happens many times per second,
+ * compared to how often which graphs/features are changed (rarely).
+ */
+ async _getEnabled() {
+ if (this._enabledGraphs) {
+ return this._enabledGraphs;
+ }
+ const enabled = [];
+ for (const graphName of this._enabled) {
+ const graph = await this.isAvailable(graphName);
+ if (graph) {
+ enabled.push(graph);
+ }
+ }
+ this._enabledGraphs = enabled;
+ return this._enabledGraphs;
+ },
+};
+
+/**
+ * A base class for performance graphs to inherit from.
+ *
+ * @param Node parent
+ * The parent node holding the overview.
+ * @param string metric
+ * The unit of measurement for this graph.
+ */
+function OptimizationsGraph(parent) {
+ MountainGraphWidget.call(this, parent);
+ this.setTheme();
+}
+
+OptimizationsGraph.prototype = extend(MountainGraphWidget.prototype, {
+ async render(threadNode, frameNode) {
+ // Regardless if we draw or clear the graph, wait
+ // until it's ready.
+ await this.ready();
+
+ if (!threadNode || !frameNode) {
+ this.setData([]);
+ return;
+ }
+
+ const { sampleTimes } = threadNode;
+
+ if (!sampleTimes.length) {
+ this.setData([]);
+ return;
+ }
+
+ // Take startTime/endTime from samples recorded, rather than
+ // using duration directly from threadNode, as the first sample that
+ // equals the startTime does not get recorded.
+ const startTime = sampleTimes[0];
+ const endTime = sampleTimes[sampleTimes.length - 1];
+
+ const bucketSize = (endTime - startTime) / OPTIMIZATIONS_GRAPH_RESOLUTION;
+ const data = createTierGraphDataFromFrameNode(
+ frameNode,
+ sampleTimes,
+ bucketSize
+ );
+
+ // If for some reason we don't have data (like the frameNode doesn't
+ // have optimizations, but it shouldn't be at this point if it doesn't),
+ // log an error.
+ if (!data) {
+ console.error(
+ `FrameNode#${frameNode.location} does not have optimizations data to render.`
+ );
+ return;
+ }
+
+ this.dataOffsetX = startTime;
+ await this.setData(data);
+ },
+
+ /**
+ * Sets the theme via `theme` to either "light" or "dark",
+ * and updates the internal styling to match. Requires a redraw
+ * to see the effects.
+ */
+ setTheme: function(theme) {
+ theme = theme || "light";
+
+ const interpreterColor = getColor("graphs-red", theme);
+ const baselineColor = getColor("graphs-blue", theme);
+ const ionColor = getColor("graphs-green", theme);
+
+ this.format = [
+ { color: interpreterColor },
+ { color: baselineColor },
+ { color: ionColor },
+ ];
+
+ this.backgroundColor = getColor("sidebar-background", theme);
+ },
+});
+
+exports.OptimizationsGraph = OptimizationsGraph;
+exports.FramerateGraph = FramerateGraph;
+exports.MemoryGraph = MemoryGraph;
+exports.TimelineGraph = TimelineGraph;
+exports.GraphsController = GraphsController;
diff --git a/devtools/client/performance/modules/widgets/marker-details.js b/devtools/client/performance/modules/widgets/marker-details.js
new file mode 100644
index 0000000000..2a8ce17ce4
--- /dev/null
+++ b/devtools/client/performance/modules/widgets/marker-details.js
@@ -0,0 +1,177 @@
+/* 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/. */
+"use strict";
+
+/**
+ * This file contains the rendering code for the marker sidebar.
+ */
+
+const EventEmitter = require("devtools/shared/event-emitter");
+const {
+ MarkerDOMUtils,
+} = require("devtools/client/performance/modules/marker-dom-utils");
+
+/**
+ * A detailed view for one single marker.
+ *
+ * @param Node parent
+ * The parent node holding the view.
+ * @param Node splitter
+ * The splitter node that the resize event is bound to.
+ */
+function MarkerDetails(parent, splitter) {
+ EventEmitter.decorate(this);
+
+ this._document = parent.ownerDocument;
+ this._parent = parent;
+ this._splitter = splitter;
+
+ this._onClick = this._onClick.bind(this);
+ this._onSplitterMouseUp = this._onSplitterMouseUp.bind(this);
+
+ this._parent.addEventListener("click", this._onClick);
+ this._splitter.addEventListener("mouseup", this._onSplitterMouseUp);
+
+ this.hidden = true;
+}
+
+MarkerDetails.prototype = {
+ /**
+ * Sets this view's width.
+ * @param number
+ */
+ set width(value) {
+ this._parent.setAttribute("width", value);
+ },
+
+ /**
+ * Sets this view's width.
+ * @return number
+ */
+ get width() {
+ return +this._parent.getAttribute("width");
+ },
+
+ /**
+ * Sets this view's visibility.
+ * @param boolean
+ */
+ set hidden(value) {
+ if (this._parent.hidden != value) {
+ this._parent.hidden = value;
+ this.emit("resize");
+ }
+ },
+
+ /**
+ * Gets this view's visibility.
+ * @param boolean
+ */
+ get hidden() {
+ return this._parent.hidden;
+ },
+
+ /**
+ * Clears the marker details from this view.
+ */
+ empty: function() {
+ this._parent.innerHTML = "";
+ },
+
+ /**
+ * Populates view with marker's details.
+ *
+ * @param object params
+ * An options object holding:
+ * - marker: The marker to display.
+ * - frames: Array of stack frame information; see stack.js.
+ * - allocations: Whether or not allocations were enabled for this
+ * recording. [optional]
+ */
+ render: function(options) {
+ const { marker, frames } = options;
+ this.empty();
+
+ const elements = [];
+ elements.push(MarkerDOMUtils.buildTitle(this._document, marker));
+ elements.push(MarkerDOMUtils.buildDuration(this._document, marker));
+ MarkerDOMUtils.buildFields(this._document, marker).forEach(f =>
+ elements.push(f)
+ );
+ MarkerDOMUtils.buildCustom(this._document, marker, options).forEach(f =>
+ elements.push(f)
+ );
+
+ // Build a stack element -- and use the "startStack" label if
+ // we have both a startStack and endStack.
+ if (marker.stack) {
+ const type = marker.endStack ? "startStack" : "stack";
+ elements.push(
+ MarkerDOMUtils.buildStackTrace(this._document, {
+ frameIndex: marker.stack,
+ frames,
+ type,
+ })
+ );
+ }
+ if (marker.endStack) {
+ const type = "endStack";
+ elements.push(
+ MarkerDOMUtils.buildStackTrace(this._document, {
+ frameIndex: marker.endStack,
+ frames,
+ type,
+ })
+ );
+ }
+
+ elements.forEach(el => this._parent.appendChild(el));
+ },
+
+ /**
+ * Handles click in the marker details view. Based on the target,
+ * can handle different actions -- only supporting view source links
+ * for the moment.
+ */
+ _onClick: function(e) {
+ const data = findActionFromEvent(e.target, this._parent);
+ if (!data) {
+ return;
+ }
+
+ this.emit(data.action, data);
+ },
+
+ /**
+ * Handles the "mouseup" event on the marker details view splitter.
+ */
+ _onSplitterMouseUp: function() {
+ this.emit("resize");
+ },
+};
+
+/**
+ * Take an element from an event `target`, and ascend through
+ * the DOM, looking for an element with a `data-action` attribute. Return
+ * the parsed `data-action` value found, or null if none found before
+ * reaching the parent `container`.
+ *
+ * @param {Element} target
+ * @param {Element} container
+ * @return {?object}
+ */
+function findActionFromEvent(target, container) {
+ let el = target;
+ let action;
+ while (el !== container) {
+ action = el.getAttribute("data-action");
+ if (action) {
+ return JSON.parse(action);
+ }
+ el = el.parentNode;
+ }
+ return null;
+}
+
+exports.MarkerDetails = MarkerDetails;
diff --git a/devtools/client/performance/modules/widgets/markers-overview.js b/devtools/client/performance/modules/widgets/markers-overview.js
new file mode 100644
index 0000000000..ea762a371d
--- /dev/null
+++ b/devtools/client/performance/modules/widgets/markers-overview.js
@@ -0,0 +1,256 @@
+/* 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/. */
+"use strict";
+
+/**
+ * This file contains the "markers overview" graph, which is a minimap of all
+ * the timeline data. Regions inside it may be selected, determining which
+ * markers are visible in the "waterfall".
+ */
+
+const { extend } = require("devtools/shared/extend");
+const {
+ AbstractCanvasGraph,
+} = require("devtools/client/shared/widgets/Graphs");
+
+const { colorUtils } = require("devtools/shared/css/color");
+const { getColor } = require("devtools/client/shared/theme");
+const ProfilerGlobal = require("devtools/client/performance/modules/global");
+const {
+ MarkerBlueprintUtils,
+} = require("devtools/client/performance/modules/marker-blueprint-utils");
+const {
+ TickUtils,
+} = require("devtools/client/performance/modules/waterfall-ticks");
+const {
+ TIMELINE_BLUEPRINT,
+} = require("devtools/client/performance/modules/markers");
+
+const OVERVIEW_HEADER_HEIGHT = 14; // px
+const OVERVIEW_ROW_HEIGHT = 11; // px
+
+const OVERVIEW_SELECTION_LINE_COLOR = "#666";
+const OVERVIEW_CLIPHEAD_LINE_COLOR = "#555";
+
+const OVERVIEW_HEADER_TICKS_MULTIPLE = 100; // ms
+const OVERVIEW_HEADER_TICKS_SPACING_MIN = 75; // px
+const OVERVIEW_HEADER_TEXT_FONT_SIZE = 9; // px
+const OVERVIEW_HEADER_TEXT_FONT_FAMILY = "sans-serif";
+const OVERVIEW_HEADER_TEXT_PADDING_LEFT = 6; // px
+const OVERVIEW_HEADER_TEXT_PADDING_TOP = 1; // px
+const OVERVIEW_MARKER_WIDTH_MIN = 4; // px
+const OVERVIEW_GROUP_VERTICAL_PADDING = 5; // px
+
+/**
+ * An overview for the markers data.
+ *
+ * @param Node parent
+ * The parent node holding the overview.
+ * @param Array<String> filter
+ * List of names of marker types that should not be shown.
+ */
+function MarkersOverview(parent, filter = [], ...args) {
+ AbstractCanvasGraph.apply(this, [parent, "markers-overview", ...args]);
+ this.setTheme();
+ this.setFilter(filter);
+}
+
+MarkersOverview.prototype = extend(AbstractCanvasGraph.prototype, {
+ clipheadLineColor: OVERVIEW_CLIPHEAD_LINE_COLOR,
+ selectionLineColor: OVERVIEW_SELECTION_LINE_COLOR,
+ headerHeight: OVERVIEW_HEADER_HEIGHT,
+ rowHeight: OVERVIEW_ROW_HEIGHT,
+ groupPadding: OVERVIEW_GROUP_VERTICAL_PADDING,
+
+ /**
+ * Compute the height of the overview.
+ */
+ get fixedHeight() {
+ return this.headerHeight + this.rowHeight * this._numberOfGroups;
+ },
+
+ /**
+ * List of marker types that should not be shown in the graph.
+ */
+ setFilter: function(filter) {
+ this._paintBatches = new Map();
+ this._filter = filter;
+ this._groupMap = Object.create(null);
+
+ const observedGroups = new Set();
+
+ for (const type in TIMELINE_BLUEPRINT) {
+ if (filter.includes(type)) {
+ continue;
+ }
+ this._paintBatches.set(type, {
+ definition: TIMELINE_BLUEPRINT[type],
+ batch: [],
+ });
+ observedGroups.add(TIMELINE_BLUEPRINT[type].group);
+ }
+
+ // Take our set of observed groups and order them and map
+ // the group numbers to fill in the holes via `_groupMap`.
+ // This normalizes our rows by removing rows that aren't used
+ // if filters are enabled.
+ let actualPosition = 0;
+ for (const groupNumber of Array.from(observedGroups).sort()) {
+ this._groupMap[groupNumber] = actualPosition++;
+ }
+ this._numberOfGroups = Object.keys(this._groupMap).length;
+ },
+
+ /**
+ * Disables selection and empties this graph.
+ */
+ clearView: function() {
+ this.selectionEnabled = false;
+ this.dropSelection();
+ this.setData({ duration: 0, markers: [] });
+ },
+
+ /**
+ * Renders the graph's data source.
+ * @see AbstractCanvasGraph.prototype.buildGraphImage
+ */
+ buildGraphImage: function() {
+ const { markers, duration } = this._data;
+
+ const { canvas, ctx } = this._getNamedCanvas("markers-overview-data");
+ const canvasWidth = this._width;
+ const canvasHeight = this._height;
+
+ // Group markers into separate paint batches. This is necessary to
+ // draw all markers sharing the same style at once.
+ for (const marker of markers) {
+ // Again skip over markers that we're filtering -- we don't want them
+ // to be labeled as "Unknown"
+ if (!MarkerBlueprintUtils.shouldDisplayMarker(marker, this._filter)) {
+ continue;
+ }
+
+ const markerType =
+ this._paintBatches.get(marker.name) ||
+ this._paintBatches.get("UNKNOWN");
+ markerType.batch.push(marker);
+ }
+
+ // Calculate each row's height, and the time-based scaling.
+
+ const groupHeight = this.rowHeight * this._pixelRatio;
+ const groupPadding = this.groupPadding * this._pixelRatio;
+ const headerHeight = this.headerHeight * this._pixelRatio;
+ const dataScale = (this.dataScaleX = canvasWidth / duration);
+
+ // Draw the header and overview background.
+
+ ctx.fillStyle = this.headerBackgroundColor;
+ ctx.fillRect(0, 0, canvasWidth, headerHeight);
+
+ ctx.fillStyle = this.backgroundColor;
+ ctx.fillRect(0, headerHeight, canvasWidth, canvasHeight);
+
+ // Draw the alternating odd/even group backgrounds.
+
+ ctx.fillStyle = this.alternatingBackgroundColor;
+ ctx.beginPath();
+
+ for (let i = 0; i < this._numberOfGroups; i += 2) {
+ const top = headerHeight + i * groupHeight;
+ ctx.rect(0, top, canvasWidth, groupHeight);
+ }
+
+ ctx.fill();
+
+ // Draw the timeline header ticks.
+
+ const fontSize = OVERVIEW_HEADER_TEXT_FONT_SIZE * this._pixelRatio;
+ const fontFamily = OVERVIEW_HEADER_TEXT_FONT_FAMILY;
+ const textPaddingLeft =
+ OVERVIEW_HEADER_TEXT_PADDING_LEFT * this._pixelRatio;
+ const textPaddingTop = OVERVIEW_HEADER_TEXT_PADDING_TOP * this._pixelRatio;
+
+ const tickInterval = TickUtils.findOptimalTickInterval({
+ ticksMultiple: OVERVIEW_HEADER_TICKS_MULTIPLE,
+ ticksSpacingMin: OVERVIEW_HEADER_TICKS_SPACING_MIN,
+ dataScale: dataScale,
+ });
+
+ ctx.textBaseline = "middle";
+ ctx.font = fontSize + "px " + fontFamily;
+ ctx.fillStyle = this.headerTextColor;
+ ctx.strokeStyle = this.headerTimelineStrokeColor;
+ ctx.beginPath();
+
+ for (let x = 0; x < canvasWidth; x += tickInterval) {
+ const lineLeft = x;
+ const textLeft = lineLeft + textPaddingLeft;
+ const time = Math.round(x / dataScale);
+ const label = ProfilerGlobal.L10N.getFormatStr("timeline.tick", time);
+ ctx.fillText(label, textLeft, headerHeight / 2 + textPaddingTop);
+ ctx.moveTo(lineLeft, 0);
+ ctx.lineTo(lineLeft, canvasHeight);
+ }
+
+ ctx.stroke();
+
+ // Draw the timeline markers.
+
+ for (const [, { definition, batch }] of this._paintBatches) {
+ const group = this._groupMap[definition.group];
+ const top = headerHeight + group * groupHeight + groupPadding / 2;
+ const height = groupHeight - groupPadding;
+
+ const color = getColor(definition.colorName, this.theme);
+ ctx.fillStyle = color;
+ ctx.beginPath();
+
+ for (const { start, end } of batch) {
+ const left = start * dataScale;
+ const width = Math.max(
+ (end - start) * dataScale,
+ OVERVIEW_MARKER_WIDTH_MIN
+ );
+ ctx.rect(left, top, width, height);
+ }
+
+ ctx.fill();
+
+ // Since all the markers in this batch (thus sharing the same style) have
+ // been drawn, empty it. The next time new markers will be available,
+ // they will be sorted and drawn again.
+ batch.length = 0;
+ }
+
+ return canvas;
+ },
+
+ /**
+ * Sets the theme via `theme` to either "light" or "dark",
+ * and updates the internal styling to match. Requires a redraw
+ * to see the effects.
+ */
+ setTheme: function(theme) {
+ this.theme = theme = theme || "light";
+ this.backgroundColor = getColor("body-background", theme);
+ this.selectionBackgroundColor = colorUtils.setAlpha(
+ getColor("selection-background", theme),
+ 0.25
+ );
+ this.selectionStripesColor = colorUtils.setAlpha("#fff", 0.1);
+ this.headerBackgroundColor = getColor("body-background", theme);
+ this.headerTextColor = getColor("body-color", theme);
+ this.headerTimelineStrokeColor = colorUtils.setAlpha(
+ getColor("text-color-alt", theme),
+ 0.25
+ );
+ this.alternatingBackgroundColor = colorUtils.setAlpha(
+ getColor("body-color", theme),
+ 0.05
+ );
+ },
+});
+
+exports.MarkersOverview = MarkersOverview;
diff --git a/devtools/client/performance/modules/widgets/moz.build b/devtools/client/performance/modules/widgets/moz.build
new file mode 100644
index 0000000000..d04890425c
--- /dev/null
+++ b/devtools/client/performance/modules/widgets/moz.build
@@ -0,0 +1,11 @@
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules(
+ "graphs.js",
+ "marker-details.js",
+ "markers-overview.js",
+ "tree-view.js",
+)
diff --git a/devtools/client/performance/modules/widgets/tree-view.js b/devtools/client/performance/modules/widgets/tree-view.js
new file mode 100644
index 0000000000..79ad8229ff
--- /dev/null
+++ b/devtools/client/performance/modules/widgets/tree-view.js
@@ -0,0 +1,461 @@
+/* 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/. */
+"use strict";
+
+/**
+ * This file contains the tree view, displaying all the samples and frames
+ * received from the proviler in a tree-like structure.
+ */
+
+const { L10N } = require("devtools/client/performance/modules/global");
+const { extend } = require("devtools/shared/extend");
+const {
+ AbstractTreeItem,
+} = require("resource://devtools/client/shared/widgets/AbstractTreeItem.jsm");
+
+const URL_LABEL_TOOLTIP = L10N.getStr("table.url.tooltiptext");
+const VIEW_OPTIMIZATIONS_TOOLTIP = L10N.getStr(
+ "table.view-optimizations.tooltiptext2"
+);
+
+const CALL_TREE_INDENTATION = 16; // px
+
+// Used for rendering values in cells
+const FORMATTERS = {
+ TIME: value =>
+ L10N.getFormatStr("table.ms2", L10N.numberWithDecimals(value, 2)),
+ PERCENT: value =>
+ L10N.getFormatStr("table.percentage3", L10N.numberWithDecimals(value, 2)),
+ NUMBER: value => value || 0,
+ BYTESIZE: value => L10N.getFormatStr("table.bytes", value || 0),
+};
+
+/**
+ * Definitions for rendering cells. Triads of class name, property name from
+ * `frame.getInfo()`, and a formatter function.
+ */
+const CELLS = {
+ duration: ["duration", "totalDuration", FORMATTERS.TIME],
+ percentage: ["percentage", "totalPercentage", FORMATTERS.PERCENT],
+ selfDuration: ["self-duration", "selfDuration", FORMATTERS.TIME],
+ selfPercentage: ["self-percentage", "selfPercentage", FORMATTERS.PERCENT],
+ samples: ["samples", "samples", FORMATTERS.NUMBER],
+
+ selfSize: ["self-size", "selfSize", FORMATTERS.BYTESIZE],
+ selfSizePercentage: [
+ "self-size-percentage",
+ "selfSizePercentage",
+ FORMATTERS.PERCENT,
+ ],
+ selfCount: ["self-count", "selfCount", FORMATTERS.NUMBER],
+ selfCountPercentage: [
+ "self-count-percentage",
+ "selfCountPercentage",
+ FORMATTERS.PERCENT,
+ ],
+ size: ["size", "totalSize", FORMATTERS.BYTESIZE],
+ sizePercentage: [
+ "size-percentage",
+ "totalSizePercentage",
+ FORMATTERS.PERCENT,
+ ],
+ count: ["count", "totalCount", FORMATTERS.NUMBER],
+ countPercentage: [
+ "count-percentage",
+ "totalCountPercentage",
+ FORMATTERS.PERCENT,
+ ],
+};
+const CELL_TYPES = Object.keys(CELLS);
+
+const DEFAULT_SORTING_PREDICATE = (frameA, frameB) => {
+ const dataA = frameA.getDisplayedData();
+ const dataB = frameB.getDisplayedData();
+ const isAllocations = "totalSize" in dataA;
+
+ if (isAllocations) {
+ if (this.inverted && dataA.selfSize !== dataB.selfSize) {
+ return dataA.selfSize < dataB.selfSize ? 1 : -1;
+ }
+ return dataA.totalSize < dataB.totalSize ? 1 : -1;
+ }
+
+ if (this.inverted && dataA.selfPercentage !== dataB.selfPercentage) {
+ return dataA.selfPercentage < dataB.selfPercentage ? 1 : -1;
+ }
+ return dataA.totalPercentage < dataB.totalPercentage ? 1 : -1;
+};
+
+// depth
+const DEFAULT_AUTO_EXPAND_DEPTH = 3;
+const DEFAULT_VISIBLE_CELLS = {
+ duration: true,
+ percentage: true,
+ selfDuration: true,
+ selfPercentage: true,
+ samples: true,
+ function: true,
+
+ // allocation columns
+ count: false,
+ selfCount: false,
+ size: false,
+ selfSize: false,
+ countPercentage: false,
+ selfCountPercentage: false,
+ sizePercentage: false,
+ selfSizePercentage: false,
+};
+
+/**
+ * An item in a call tree view, which looks like this:
+ *
+ * Time (ms) | Cost | Calls | Function
+ * ============================================================================
+ * 1,000.00 | 100.00% | | ▼ (root)
+ * 500.12 | 50.01% | 300 | ▼ foo Categ. 1
+ * 300.34 | 30.03% | 1500 | ▼ bar Categ. 2
+ * 10.56 | 0.01% | 42 | ▶ call_with_children Categ. 3
+ * 90.78 | 0.09% | 25 | call_without_children Categ. 4
+ *
+ * Every instance of a `CallView` represents a row in the call tree. The same
+ * parent node is used for all rows.
+ *
+ * @param CallView caller
+ * The CallView considered the "caller" frame. This newly created
+ * instance will be represent the "callee". Should be null for root nodes.
+ * @param ThreadNode | FrameNode frame
+ * Details about this function, like { samples, duration, calls } etc.
+ * @param number level [optional]
+ * The indentation level in the call tree. The root node is at level 0.
+ * @param boolean hidden [optional]
+ * Whether this node should be hidden and not contribute to depth/level
+ * calculations. Defaults to false.
+ * @param boolean inverted [optional]
+ * Whether the call tree has been inverted (bottom up, rather than
+ * top-down). Defaults to false.
+ * @param function sortingPredicate [optional]
+ * The predicate used to sort the tree items when created. Defaults to
+ * the caller's `sortingPredicate` if a caller exists, otherwise defaults
+ * to DEFAULT_SORTING_PREDICATE. The two passed arguments are FrameNodes.
+ * @param number autoExpandDepth [optional]
+ * The depth to which the tree should automatically expand. Defualts to
+ * the caller's `autoExpandDepth` if a caller exists, otherwise defaults
+ * to DEFAULT_AUTO_EXPAND_DEPTH.
+ * @param object visibleCells
+ * An object specifying which cells are visible in the tree. Defaults to
+ * the caller's `visibleCells` if a caller exists, otherwise defaults
+ * to DEFAULT_VISIBLE_CELLS.
+ * @param boolean showOptimizationHint [optional]
+ * Whether or not to show an icon indicating if the frame has optimization
+ * data.
+ */
+function CallView({
+ caller,
+ frame,
+ level,
+ hidden,
+ inverted,
+ sortingPredicate,
+ autoExpandDepth,
+ visibleCells,
+ showOptimizationHint,
+}) {
+ AbstractTreeItem.call(this, {
+ parent: caller,
+ level: level | (0 - (hidden ? 1 : 0)),
+ });
+
+ if (sortingPredicate != null) {
+ this.sortingPredicate = sortingPredicate;
+ } else if (caller) {
+ this.sortingPredicate = caller.sortingPredicate;
+ } else {
+ this.sortingPredicate = DEFAULT_SORTING_PREDICATE;
+ }
+
+ if (autoExpandDepth != null) {
+ this.autoExpandDepth = autoExpandDepth;
+ } else if (caller) {
+ this.autoExpandDepth = caller.autoExpandDepth;
+ } else {
+ this.autoExpandDepth = DEFAULT_AUTO_EXPAND_DEPTH;
+ }
+
+ if (visibleCells != null) {
+ this.visibleCells = visibleCells;
+ } else if (caller) {
+ this.visibleCells = caller.visibleCells;
+ } else {
+ this.visibleCells = Object.create(DEFAULT_VISIBLE_CELLS);
+ }
+
+ this.caller = caller;
+ this.frame = frame;
+ this.hidden = hidden;
+ this.inverted = inverted;
+ this.showOptimizationHint = showOptimizationHint;
+
+ this._onUrlClick = this._onUrlClick.bind(this);
+}
+
+CallView.prototype = extend(AbstractTreeItem.prototype, {
+ /**
+ * Creates the view for this tree node.
+ * @param Node document
+ * @param Node arrowNode
+ * @return Node
+ */
+ _displaySelf: function(document, arrowNode) {
+ const frameInfo = this.getDisplayedData();
+ const cells = [];
+
+ for (const type of CELL_TYPES) {
+ if (this.visibleCells[type]) {
+ // Inline for speed, but pass in the formatted value via
+ // cell definition, as well as the element type.
+ cells.push(
+ this._createCell(
+ document,
+ CELLS[type][2](frameInfo[CELLS[type][1]]),
+ CELLS[type][0]
+ )
+ );
+ }
+ }
+
+ if (this.visibleCells.function) {
+ cells.push(
+ this._createFunctionCell(
+ document,
+ arrowNode,
+ frameInfo.name,
+ frameInfo,
+ this.level
+ )
+ );
+ }
+
+ const targetNode = document.createXULElement("hbox");
+ targetNode.className = "call-tree-item";
+ targetNode.setAttribute(
+ "origin",
+ frameInfo.isContent ? "content" : "chrome"
+ );
+ targetNode.setAttribute("category", frameInfo.categoryData.abbrev || "");
+ targetNode.setAttribute("tooltiptext", frameInfo.tooltiptext);
+
+ if (this.hidden) {
+ targetNode.style.display = "none";
+ }
+
+ for (let i = 0; i < cells.length; i++) {
+ targetNode.appendChild(cells[i]);
+ }
+
+ return targetNode;
+ },
+
+ /**
+ * Populates this node in the call tree with the corresponding "callees".
+ * These are defined in the `frame` data source for this call view.
+ * @param array:AbstractTreeItem children
+ */
+ _populateSelf: function(children) {
+ const newLevel = this.level + 1;
+
+ for (const newFrame of this.frame.calls) {
+ children.push(
+ new CallView({
+ caller: this,
+ frame: newFrame,
+ level: newLevel,
+ inverted: this.inverted,
+ })
+ );
+ }
+
+ // Sort the "callees" asc. by samples, before inserting them in the tree,
+ // if no other sorting predicate was specified on this on the root item.
+ children.sort(this.sortingPredicate.bind(this));
+ },
+
+ /**
+ * Functions creating each cell in this call view.
+ * Invoked by `_displaySelf`.
+ */
+ _createCell: function(doc, value, type) {
+ const cell = doc.createXULElement("description");
+ cell.className = "plain call-tree-cell";
+ cell.setAttribute("type", type);
+ cell.setAttribute("crop", "end");
+ // Add a tabulation to the cell text in case it's is selected and copied.
+ cell.textContent = value + "\t";
+ return cell;
+ },
+
+ _createFunctionCell: function(
+ doc,
+ arrowNode,
+ frameName,
+ frameInfo,
+ frameLevel
+ ) {
+ const cell = doc.createXULElement("hbox");
+ cell.className = "call-tree-cell";
+ cell.style.marginInlineStart = frameLevel * CALL_TREE_INDENTATION + "px";
+ cell.setAttribute("type", "function");
+ cell.appendChild(arrowNode);
+
+ // Render optimization hint if this frame has opt data.
+ if (
+ this.root.showOptimizationHint &&
+ frameInfo.hasOptimizations &&
+ !frameInfo.isMetaCategory
+ ) {
+ const icon = doc.createXULElement("description");
+ icon.setAttribute("tooltiptext", VIEW_OPTIMIZATIONS_TOOLTIP);
+ icon.className = "opt-icon";
+ cell.appendChild(icon);
+ }
+
+ // Don't render a name label node if there's no function name. A different
+ // location label node will be rendered instead.
+ if (frameName) {
+ const nameNode = doc.createXULElement("description");
+ nameNode.className = "plain call-tree-name";
+ nameNode.textContent = frameName;
+ cell.appendChild(nameNode);
+ }
+
+ // Don't render detailed labels for meta category frames
+ if (!frameInfo.isMetaCategory) {
+ this._appendFunctionDetailsCells(doc, cell, frameInfo);
+ }
+
+ // Don't render an expando-arrow for leaf nodes.
+ const hasDescendants = Object.keys(this.frame.calls).length > 0;
+ if (!hasDescendants) {
+ arrowNode.setAttribute("invisible", "");
+ }
+
+ // Add a line break to the last description of the row in case it's selected
+ // and copied.
+ const lastDescription = cell.querySelector("description:last-of-type");
+ lastDescription.textContent = lastDescription.textContent + "\n";
+
+ // Add spaces as frameLevel indicators in case the row is selected and
+ // copied. These spaces won't be displayed in the cell content.
+ const firstDescription = cell.querySelector("description:first-of-type");
+ const levelIndicator = frameLevel > 0 ? " ".repeat(frameLevel) : "";
+ firstDescription.textContent =
+ levelIndicator + firstDescription.textContent;
+
+ return cell;
+ },
+
+ _appendFunctionDetailsCells: function(doc, cell, frameInfo) {
+ if (frameInfo.fileName) {
+ const urlNode = doc.createXULElement("description");
+ urlNode.className = "plain call-tree-url";
+ urlNode.textContent = frameInfo.fileName;
+ urlNode.setAttribute(
+ "tooltiptext",
+ URL_LABEL_TOOLTIP + " → " + frameInfo.url
+ );
+ urlNode.addEventListener("mousedown", this._onUrlClick);
+ cell.appendChild(urlNode);
+ }
+
+ if (frameInfo.line) {
+ const lineNode = doc.createXULElement("description");
+ lineNode.className = "plain call-tree-line";
+ lineNode.textContent = ":" + frameInfo.line;
+ cell.appendChild(lineNode);
+ }
+
+ if (frameInfo.column) {
+ const columnNode = doc.createXULElement("description");
+ columnNode.className = "plain call-tree-column";
+ columnNode.textContent = ":" + frameInfo.column;
+ cell.appendChild(columnNode);
+ }
+
+ if (frameInfo.host) {
+ const hostNode = doc.createXULElement("description");
+ hostNode.className = "plain call-tree-host";
+ hostNode.textContent = frameInfo.host;
+ cell.appendChild(hostNode);
+ }
+
+ if (frameInfo.categoryData.label) {
+ const categoryNode = doc.createXULElement("description");
+ categoryNode.className = "plain call-tree-category";
+ categoryNode.style.color = frameInfo.categoryData.color;
+ categoryNode.textContent = frameInfo.categoryData.label;
+ cell.appendChild(categoryNode);
+ }
+ },
+
+ /**
+ * Gets the data displayed about this tree item, based on the FrameNode
+ * model associated with this view.
+ *
+ * @return object
+ */
+ getDisplayedData: function() {
+ if (this._cachedDisplayedData) {
+ return this._cachedDisplayedData;
+ }
+
+ this._cachedDisplayedData = this.frame.getInfo({
+ root: this.root.frame,
+ allocations: this.visibleCells.count || this.visibleCells.selfCount,
+ });
+
+ return this._cachedDisplayedData;
+
+ /**
+ * When inverting call tree, the costs and times are dependent on position
+ * in the tree. We must only count leaf nodes with self cost, and total costs
+ * dependent on how many times the leaf node was found with a full stack path.
+ *
+ * Total | Self | Calls | Function
+ * ============================================================================
+ * 100% | 100% | 100 | ▼ C
+ * 50% | 0% | 50 | ▼ B
+ * 50% | 0% | 50 | ▼ A
+ * 50% | 0% | 50 | ▼ B
+ *
+ * Every instance of a `CallView` represents a row in the call tree. The same
+ * container node is used for all rows.
+ */
+ },
+
+ /**
+ * Toggles the category information hidden or visible.
+ * @param boolean visible
+ */
+ toggleCategories: function(visible) {
+ if (!visible) {
+ this.container.setAttribute("categories-hidden", "");
+ } else {
+ this.container.removeAttribute("categories-hidden");
+ }
+ },
+
+ /**
+ * Handler for the "click" event on the url node of this call view.
+ */
+ _onUrlClick: function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ // Only emit for left click events
+ if (e.button === 0) {
+ this.root.emit("link", this);
+ }
+ },
+});
+
+exports.CallView = CallView;