diff options
Diffstat (limited to 'toolkit/content/aboutTelemetry.js')
-rw-r--r-- | toolkit/content/aboutTelemetry.js | 2622 |
1 files changed, 2622 insertions, 0 deletions
diff --git a/toolkit/content/aboutTelemetry.js b/toolkit/content/aboutTelemetry.js new file mode 100644 index 0000000000..a9a3b01fed --- /dev/null +++ b/toolkit/content/aboutTelemetry.js @@ -0,0 +1,2622 @@ +/* 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 { BrowserUtils } = ChromeUtils.importESModule( + "resource://gre/modules/BrowserUtils.sys.mjs" +); +const { TelemetryTimestamps } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetryTimestamps.sys.mjs" +); +const { TelemetryController } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetryController.sys.mjs" +); +const { TelemetryArchive } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetryArchive.sys.mjs" +); +const { TelemetrySend } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetrySend.sys.mjs" +); + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +ChromeUtils.defineESModuleGetters(this, { + Preferences: "resource://gre/modules/Preferences.sys.mjs", +}); +ChromeUtils.defineModuleGetter( + this, + "ObjectUtils", + "resource://gre/modules/ObjectUtils.jsm" +); + +const Telemetry = Services.telemetry; + +// Maximum height of a histogram bar (in em for html, in chars for text) +const MAX_BAR_HEIGHT = 8; +const MAX_BAR_CHARS = 25; +const PREF_TELEMETRY_SERVER_OWNER = "toolkit.telemetry.server_owner"; +const PREF_TELEMETRY_ENABLED = "toolkit.telemetry.enabled"; +const PREF_DEBUG_SLOW_SQL = "toolkit.telemetry.debugSlowSql"; +const PREF_SYMBOL_SERVER_URI = "profiler.symbolicationUrl"; +const DEFAULT_SYMBOL_SERVER_URI = + "https://symbolication.services.mozilla.com/symbolicate/v4"; +const PREF_FHR_UPLOAD_ENABLED = "datareporting.healthreport.uploadEnabled"; + +// ms idle before applying the filter (allow uninterrupted typing) +const FILTER_IDLE_TIMEOUT = 500; + +const isWindows = Services.appinfo.OS == "WINNT"; +const EOL = isWindows ? "\r\n" : "\n"; + +// This is the ping object currently displayed in the page. +var gPingData = null; + +// Cached value of document's RTL mode +var documentRTLMode = ""; + +/** + * Helper function for determining whether the document direction is RTL. + * Caches result of check on first invocation. + */ +function isRTL() { + if (!documentRTLMode) { + documentRTLMode = window.getComputedStyle(document.body).direction; + } + return documentRTLMode == "rtl"; +} + +function isFlatArray(obj) { + if (!Array.isArray(obj)) { + return false; + } + return !obj.some(e => typeof e == "object"); +} + +/** + * This is a helper function for explodeObject. + */ +function flattenObject(obj, map, path, array) { + for (let k of Object.keys(obj)) { + let newPath = [...path, array ? "[" + k + "]" : k]; + let v = obj[k]; + if (!v || typeof v != "object") { + map.set(newPath.join("."), v); + } else if (isFlatArray(v)) { + map.set(newPath.join("."), "[" + v.join(", ") + "]"); + } else { + flattenObject(v, map, newPath, Array.isArray(v)); + } + } +} + +/** + * This turns a JSON object into a "flat" stringified form. + * + * For an object like {a: "1", b: {c: "2", d: "3"}} it returns a Map of the + * form Map(["a","1"], ["b.c", "2"], ["b.d", "3"]). + */ +function explodeObject(obj) { + let map = new Map(); + flattenObject(obj, map, []); + return map; +} + +function filterObject(obj, filterOut) { + let ret = {}; + for (let k of Object.keys(obj)) { + if (!filterOut.includes(k)) { + ret[k] = obj[k]; + } + } + return ret; +} + +/** + * This turns a JSON object into a "flat" stringified form, separated into top-level sections. + * + * For an object like: + * { + * a: {b: "1"}, + * c: {d: "2", e: {f: "3"}} + * } + * it returns a Map of the form: + * Map([ + * ["a", Map(["b","1"])], + * ["c", Map([["d", "2"], ["e.f", "3"]])] + * ]) + */ +function sectionalizeObject(obj) { + let map = new Map(); + for (let k of Object.keys(obj)) { + map.set(k, explodeObject(obj[k])); + } + return map; +} + +/** + * Obtain the main DOMWindow for the current context. + */ +function getMainWindow() { + return window.browsingContext.topChromeWindow; +} + +/** + * Obtain the DOMWindow that can open a preferences pane. + * + * This is essentially "get the browser chrome window" with the added check + * that the supposed browser chrome window is capable of opening a preferences + * pane. + * + * This may return null if we can't find the browser chrome window. + */ +function getMainWindowWithPreferencesPane() { + let mainWindow = getMainWindow(); + if (mainWindow && "openPreferences" in mainWindow) { + return mainWindow; + } + return null; +} + +/** + * Remove all child nodes of a document node. + */ +function removeAllChildNodes(node) { + while (node.hasChildNodes()) { + node.removeChild(node.lastChild); + } +} + +var Settings = { + attachObservers() { + let elements = document.getElementsByClassName("change-data-choices-link"); + for (let el of elements) { + el.parentElement.addEventListener("click", function (event) { + if (event.target.localName === "a") { + if (AppConstants.platform == "android") { + var { EventDispatcher } = ChromeUtils.importESModule( + "resource://gre/modules/Messaging.sys.mjs" + ); + EventDispatcher.instance.sendRequest({ + type: "Settings:Show", + resource: "preferences_privacy", + }); + } else { + // Show the data choices preferences on desktop. + let mainWindow = getMainWindowWithPreferencesPane(); + mainWindow.openPreferences("privacy-reports"); + } + } + }); + } + }, + + /** + * Updates the button & text at the top of the page to reflect Telemetry state. + */ + render() { + let settingsExplanation = document.getElementById("settings-explanation"); + let extendedEnabled = Services.telemetry.canRecordExtended; + + let channel = extendedEnabled ? "prerelease" : "release"; + let uploadcase = TelemetrySend.sendingEnabled() ? "enabled" : "disabled"; + + document.l10n.setAttributes( + settingsExplanation, + "about-telemetry-settings-explanation", + { channel, uploadcase } + ); + + this.attachObservers(); + }, +}; + +var PingPicker = { + viewCurrentPingData: null, + _archivedPings: null, + TYPE_ALL: "all", + + attachObservers() { + let pingSourceElements = document.getElementsByName("choose-ping-source"); + for (let el of pingSourceElements) { + el.addEventListener("change", () => this.onPingSourceChanged()); + } + + let displays = document.getElementsByName("choose-ping-display"); + for (let el of displays) { + el.addEventListener("change", () => this.onPingDisplayChanged()); + } + + document + .getElementById("show-subsession-data") + .addEventListener("change", () => { + this._updateCurrentPingData(); + }); + + document.getElementById("choose-ping-id").addEventListener("change", () => { + this._updateArchivedPingData(); + }); + document + .getElementById("choose-ping-type") + .addEventListener("change", () => { + this.filterDisplayedPings(); + }); + + document + .getElementById("newer-ping") + .addEventListener("click", () => this._movePingIndex(-1)); + document + .getElementById("older-ping") + .addEventListener("click", () => this._movePingIndex(1)); + + let pingPickerNeedHide = false; + let pingPicker = document.getElementById("ping-picker"); + pingPicker.addEventListener( + "mouseenter", + () => (pingPickerNeedHide = false) + ); + pingPicker.addEventListener( + "mouseleave", + () => (pingPickerNeedHide = true) + ); + document.addEventListener("click", ev => { + if (pingPickerNeedHide) { + pingPicker.classList.add("hidden"); + } + }); + document + .getElementById("stores") + .addEventListener("change", () => displayPingData(gPingData)); + Array.from(document.querySelectorAll(".change-ping")).forEach(el => { + el.addEventListener("click", event => { + if (!pingPicker.classList.contains("hidden")) { + pingPicker.classList.add("hidden"); + } else { + pingPicker.classList.remove("hidden"); + event.stopPropagation(); + } + }); + }); + }, + + onPingSourceChanged() { + this.update(); + }, + + onPingDisplayChanged() { + this.update(); + }, + + render() { + // Display the type and controls if the ping is not current + let pingDate = document.getElementById("ping-date"); + let pingType = document.getElementById("ping-type"); + let controls = document.getElementById("controls"); + let pingExplanation = document.getElementById("ping-explanation"); + + if (!this.viewCurrentPingData) { + let pingName = this._getSelectedPingName(); + // Change sidebar heading text. + pingDate.textContent = pingName; + pingDate.setAttribute("title", pingName); + let pingTypeText = this._getSelectedPingType(); + controls.classList.remove("hidden"); + pingType.textContent = pingTypeText; + document.l10n.setAttributes( + pingExplanation, + "about-telemetry-ping-details", + { timestamp: pingTypeText, name: pingName } + ); + } else { + // Change sidebar heading text. + controls.classList.add("hidden"); + document.l10n.setAttributes( + pingType, + "about-telemetry-current-data-sidebar" + ); + // Change home page text. + document.l10n.setAttributes( + pingExplanation, + "about-telemetry-data-details-current" + ); + } + + GenericSubsection.deleteAllSubSections(); + }, + + async update() { + let viewCurrent = document.getElementById("ping-source-current").checked; + let currentChanged = viewCurrent !== this.viewCurrentPingData; + this.viewCurrentPingData = viewCurrent; + + // If we have no archived pings, disable the ping archive selection. + // This can happen on new profiles or if the ping archive is disabled. + let archivedPingList = await TelemetryArchive.promiseArchivedPingList(); + let sourceArchived = document.getElementById("ping-source-archive"); + let sourceArchivedContainer = document.getElementById( + "ping-source-archive-container" + ); + let archivedDisabled = !archivedPingList.length; + sourceArchived.disabled = archivedDisabled; + sourceArchivedContainer.classList.toggle("disabled", archivedDisabled); + + if (currentChanged) { + if (this.viewCurrentPingData) { + document.getElementById("current-ping-picker").hidden = false; + document.getElementById("archived-ping-picker").hidden = true; + this._updateCurrentPingData(); + } else { + document.getElementById("current-ping-picker").hidden = true; + await this._updateArchivedPingList(archivedPingList); + document.getElementById("archived-ping-picker").hidden = false; + } + } + }, + + _updateCurrentPingData() { + const subsession = document.getElementById("show-subsession-data").checked; + let ping = TelemetryController.getCurrentPingData(subsession); + if (!ping) { + return; + } + + let stores = Telemetry.getAllStores(); + let getData = { + histograms: Telemetry.getSnapshotForHistograms, + keyedHistograms: Telemetry.getSnapshotForKeyedHistograms, + scalars: Telemetry.getSnapshotForScalars, + keyedScalars: Telemetry.getSnapshotForKeyedScalars, + }; + + let data = {}; + for (const [name, fn] of Object.entries(getData)) { + for (const store of stores) { + if (!data[store]) { + data[store] = {}; + } + let measurement = fn(store, /* clear */ false, /* filterTest */ true); + let processes = Object.keys(measurement); + + for (const process of processes) { + if (!data[store][process]) { + data[store][process] = {}; + } + + data[store][process][name] = measurement[process]; + } + } + } + ping.payload.stores = data; + + // Delete the unused data from the payload of the current ping. + // It's included in the above `stores` attribute. + for (const data of Object.values(ping.payload.processes)) { + delete data.scalars; + delete data.keyedScalars; + delete data.histograms; + delete data.keyedHistograms; + } + delete ping.payload.histograms; + delete ping.payload.keyedHistograms; + + // augment ping payload with event telemetry + let eventSnapshot = Telemetry.snapshotEvents( + Telemetry.DATASET_PRERELEASE_CHANNELS, + false + ); + for (let process of Object.keys(eventSnapshot)) { + if (process in ping.payload.processes) { + ping.payload.processes[process].events = eventSnapshot[process].filter( + e => !e[1].startsWith("telemetry.test") + ); + } + } + + displayPingData(ping, true); + }, + + _updateArchivedPingData() { + let id = this._getSelectedPingId(); + let res = Promise.resolve(); + if (id) { + res = TelemetryArchive.promiseArchivedPingById(id).then(ping => + displayPingData(ping, true) + ); + } + return res; + }, + + async _updateArchivedPingList(pingList) { + // The archived ping list is sorted in ascending timestamp order, + // but descending is more practical for the operations we do here. + pingList.reverse(); + this._archivedPings = pingList; + // Render the archive data. + this._renderPingList(); + // Update the displayed ping. + await this._updateArchivedPingData(); + }, + + _renderPingList() { + let pingSelector = document.getElementById("choose-ping-id"); + Array.from(pingSelector.children).forEach(child => + removeAllChildNodes(child) + ); + + let pingTypes = new Set(); + pingTypes.add(this.TYPE_ALL); + + const today = new Date(); + today.setHours(0, 0, 0, 0); + const yesterday = new Date(today); + yesterday.setDate(today.getDate() - 1); + + for (let p of this._archivedPings) { + pingTypes.add(p.type); + const pingDate = new Date(p.timestampCreated); + const datetimeText = new Services.intl.DateTimeFormat(undefined, { + dateStyle: "short", + timeStyle: "medium", + }).format(pingDate); + const pingName = `${datetimeText}, ${p.type}`; + + let option = document.createElement("option"); + let content = document.createTextNode(pingName); + option.appendChild(content); + option.setAttribute("value", p.id); + option.dataset.type = p.type; + option.dataset.date = datetimeText; + + pingDate.setHours(0, 0, 0, 0); + if (pingDate.getTime() === today.getTime()) { + pingSelector.children[0].appendChild(option); + } else if (pingDate.getTime() === yesterday.getTime()) { + pingSelector.children[1].appendChild(option); + } else { + pingSelector.children[2].appendChild(option); + } + } + this._renderPingTypes(pingTypes); + }, + + _renderPingTypes(pingTypes) { + let pingTypeSelector = document.getElementById("choose-ping-type"); + removeAllChildNodes(pingTypeSelector); + pingTypes.forEach(type => { + let option = document.createElement("option"); + option.appendChild(document.createTextNode(type)); + option.setAttribute("value", type); + pingTypeSelector.appendChild(option); + }); + }, + + _movePingIndex(offset) { + if (this.viewCurrentPingData) { + return; + } + let typeSelector = document.getElementById("choose-ping-type"); + let type = typeSelector.selectedOptions.item(0).value; + + let id = this._getSelectedPingId(); + let index = this._archivedPings.findIndex(p => p.id == id); + let newIndex = Math.min( + Math.max(0, index + offset), + this._archivedPings.length - 1 + ); + + let pingList; + if (offset > 0) { + pingList = this._archivedPings.slice(newIndex); + } else { + pingList = this._archivedPings.slice(0, newIndex); + pingList.reverse(); + } + + let ping = pingList.find(p => { + return type == this.TYPE_ALL || p.type == type; + }); + + if (ping) { + this.selectPing(ping); + this._updateArchivedPingData(); + } + }, + + selectPing(ping) { + let pingSelector = document.getElementById("choose-ping-id"); + // Use some() to break if we find the ping. + Array.from(pingSelector.children).some(group => { + return Array.from(group.children).some(option => { + if (option.value == ping.id) { + option.selected = true; + return true; + } + return false; + }); + }); + }, + + filterDisplayedPings() { + let pingSelector = document.getElementById("choose-ping-id"); + let typeSelector = document.getElementById("choose-ping-type"); + let type = typeSelector.selectedOptions.item(0).value; + let first = true; + Array.from(pingSelector.children).forEach(group => { + Array.from(group.children).forEach(option => { + if (first && option.dataset.type == type) { + option.selected = true; + first = false; + } + option.hidden = type != this.TYPE_ALL && option.dataset.type != type; + // Arrow keys should only iterate over visible options + option.disabled = option.hidden; + }); + }); + this._updateArchivedPingData(); + }, + + _getSelectedPingName() { + let pingSelector = document.getElementById("choose-ping-id"); + let selected = pingSelector.selectedOptions.item(0); + return selected.dataset.date; + }, + + _getSelectedPingType() { + let pingSelector = document.getElementById("choose-ping-id"); + let selected = pingSelector.selectedOptions.item(0); + return selected.dataset.type; + }, + + _getSelectedPingId() { + let pingSelector = document.getElementById("choose-ping-id"); + let selected = pingSelector.selectedOptions.item(0); + return selected.getAttribute("value"); + }, + + _showRawPingData() { + show(document.getElementById("category-raw")); + }, + + _showStructuredPingData() { + show(document.getElementById("category-home")); + }, +}; + +var GeneralData = { + /** + * Renders the general data + */ + render(aPing) { + setHasData("general-data-section", true); + let generalDataSection = document.getElementById("general-data"); + removeAllChildNodes(generalDataSection); + + const headings = [ + "about-telemetry-names-header", + "about-telemetry-values-header", + ]; + + // The payload & environment parts are handled by other renderers. + let ignoreSections = ["payload", "environment"]; + let data = explodeObject(filterObject(aPing, ignoreSections)); + + const table = GenericTable.render(data, headings); + generalDataSection.appendChild(table); + }, +}; + +var EnvironmentData = { + /** + * Renders the environment data + */ + render(ping) { + let dataDiv = document.getElementById("environment-data"); + removeAllChildNodes(dataDiv); + const hasData = !!ping.environment; + setHasData("environment-data-section", hasData); + if (!hasData) { + return; + } + + let ignore = ["addons"]; + let env = filterObject(ping.environment, ignore); + let sections = sectionalizeObject(env); + GenericSubsection.render(sections, dataDiv, "environment-data-section"); + + // We use specialized rendering here to make the addon and plugin listings + // more readable. + this.createAddonSection(dataDiv, ping); + }, + + renderAddonsObject(addonObj, addonSection, sectionTitle) { + let table = document.createElement("table"); + table.setAttribute("id", sectionTitle); + this.appendAddonSubsectionTitle(sectionTitle, table); + + for (let id of Object.keys(addonObj)) { + let addon = addonObj[id]; + this.appendHeadingName(table, addon.name || id); + this.appendAddonID(table, id); + let data = explodeObject(addon); + + for (let [key, value] of data) { + this.appendRow(table, key, value); + } + } + + addonSection.appendChild(table); + }, + + renderKeyValueObject(addonObj, addonSection, sectionTitle) { + let data = explodeObject(addonObj); + let table = GenericTable.render(data); + table.setAttribute("class", sectionTitle); + this.appendAddonSubsectionTitle(sectionTitle, table); + addonSection.appendChild(table); + }, + + appendAddonID(table, addonID) { + this.appendRow(table, "id", addonID); + }, + + appendHeadingName(table, name) { + let headings = document.createElement("tr"); + this.appendColumn(headings, "th", name); + headings.cells[0].colSpan = 2; + table.appendChild(headings); + }, + + appendAddonSubsectionTitle(section, table) { + let caption = document.createElement("caption"); + caption.appendChild(document.createTextNode(section)); + table.appendChild(caption); + }, + + createAddonSection(dataDiv, ping) { + if (!ping || !("environment" in ping) || !("addons" in ping.environment)) { + return; + } + let addonSection = document.createElement("div"); + addonSection.setAttribute("class", "subsection-data subdata"); + let addons = ping.environment.addons; + this.renderAddonsObject(addons.activeAddons, addonSection, "activeAddons"); + this.renderKeyValueObject(addons.theme, addonSection, "theme"); + this.renderAddonsObject( + addons.activeGMPlugins, + addonSection, + "activeGMPlugins" + ); + + let hasAddonData = !!Object.keys(ping.environment.addons).length; + let s = GenericSubsection.renderSubsectionHeader( + "addons", + hasAddonData, + "environment-data-section" + ); + s.appendChild(addonSection); + dataDiv.appendChild(s); + }, + + appendRow(table, id, value) { + let row = document.createElement("tr"); + row.id = id; + this.appendColumn(row, "td", id); + this.appendColumn(row, "td", value); + table.appendChild(row); + }, + /** + * Helper function for appending a column to the data table. + * + * @param aRowElement Parent row element + * @param aColType Column's tag name + * @param aColText Column contents + */ + appendColumn(aRowElement, aColType, aColText) { + let colElement = document.createElement(aColType); + let colTextElement = document.createTextNode(aColText); + colElement.appendChild(colTextElement); + aRowElement.appendChild(colElement); + }, +}; + +var SlowSQL = { + /** + * Render slow SQL statistics + */ + render: function SlowSQL_render(aPing) { + // We can add the debug SQL data to the current ping later. + // However, we need to be careful to never send that debug data + // out due to privacy concerns. + // We want to show the actual ping data for archived pings, + // so skip this there. + + let debugSlowSql = + PingPicker.viewCurrentPingData && + Preferences.get(PREF_DEBUG_SLOW_SQL, false); + let slowSql = debugSlowSql ? Telemetry.debugSlowSQL : aPing.payload.slowSQL; + if (!slowSql) { + setHasData("slow-sql-section", false); + return; + } + + let { mainThread, otherThreads } = debugSlowSql + ? Telemetry.debugSlowSQL + : aPing.payload.slowSQL; + + let mainThreadCount = Object.keys(mainThread).length; + let otherThreadCount = Object.keys(otherThreads).length; + if (mainThreadCount == 0 && otherThreadCount == 0) { + setHasData("slow-sql-section", false); + return; + } + + setHasData("slow-sql-section", true); + if (debugSlowSql) { + document.getElementById("sql-warning").hidden = false; + } + + let slowSqlDiv = document.getElementById("slow-sql-tables"); + removeAllChildNodes(slowSqlDiv); + + // Main thread + if (mainThreadCount > 0) { + let table = document.createElement("table"); + this.renderTableHeader(table, "main"); + this.renderTable(table, mainThread); + slowSqlDiv.appendChild(table); + } + + // Other threads + if (otherThreadCount > 0) { + let table = document.createElement("table"); + this.renderTableHeader(table, "other"); + this.renderTable(table, otherThreads); + slowSqlDiv.appendChild(table); + } + }, + + /** + * Creates a header row for a Slow SQL table + * Tabs & newlines added to cells to make it easier to copy-paste. + * + * @param aTable Parent table element + * @param aTitle Table's title + */ + renderTableHeader: function SlowSQL_renderTableHeader(aTable, threadType) { + let caption = document.createElement("caption"); + if (threadType == "main") { + document.l10n.setAttributes(caption, "about-telemetry-slow-sql-main"); + } + + if (threadType == "other") { + document.l10n.setAttributes(caption, "about-telemetry-slow-sql-other"); + } + aTable.appendChild(caption); + + let headings = document.createElement("tr"); + document.l10n.setAttributes( + this.appendColumn(headings, "th"), + "about-telemetry-slow-sql-hits" + ); + document.l10n.setAttributes( + this.appendColumn(headings, "th"), + "about-telemetry-slow-sql-average" + ); + document.l10n.setAttributes( + this.appendColumn(headings, "th"), + "about-telemetry-slow-sql-statement" + ); + aTable.appendChild(headings); + }, + + /** + * Fills out the table body + * Tabs & newlines added to cells to make it easier to copy-paste. + * + * @param aTable Parent table element + * @param aSql SQL stats object + */ + renderTable: function SlowSQL_renderTable(aTable, aSql) { + for (let [sql, [hitCount, totalTime]] of Object.entries(aSql)) { + let averageTime = totalTime / hitCount; + + let sqlRow = document.createElement("tr"); + + this.appendColumn(sqlRow, "td", hitCount + "\t"); + this.appendColumn(sqlRow, "td", averageTime.toFixed(0) + "\t"); + this.appendColumn(sqlRow, "td", sql + "\n"); + + aTable.appendChild(sqlRow); + } + }, + + /** + * Helper function for appending a column to a Slow SQL table. + * + * @param aRowElement Parent row element + * @param aColType Column's tag name + * @param aColText Column contents + */ + appendColumn: function SlowSQL_appendColumn( + aRowElement, + aColType, + aColText = "" + ) { + let colElement = document.createElement(aColType); + if (aColText) { + let colTextElement = document.createTextNode(aColText); + colElement.appendChild(colTextElement); + } + aRowElement.appendChild(colElement); + return colElement; + }, +}; + +var StackRenderer = { + /** + * Outputs the memory map associated with this hang report + * + * @param aDiv Output div + */ + renderMemoryMap: async function StackRenderer_renderMemoryMap( + aDiv, + memoryMap + ) { + let memoryMapTitleElement = document.createElement("span"); + document.l10n.setAttributes( + memoryMapTitleElement, + "about-telemetry-memory-map-title" + ); + aDiv.appendChild(memoryMapTitleElement); + aDiv.appendChild(document.createElement("br")); + + for (let currentModule of memoryMap) { + aDiv.appendChild(document.createTextNode(currentModule.join(" "))); + aDiv.appendChild(document.createElement("br")); + } + + aDiv.appendChild(document.createElement("br")); + }, + + /** + * Outputs the raw PCs from the hang's stack + * + * @param aDiv Output div + * @param aStack Array of PCs from the hang stack + */ + renderStack: function StackRenderer_renderStack(aDiv, aStack) { + let stackTitleElement = document.createElement("span"); + document.l10n.setAttributes( + stackTitleElement, + "about-telemetry-stack-title" + ); + aDiv.appendChild(stackTitleElement); + let stackText = " " + aStack.join(" "); + aDiv.appendChild(document.createTextNode(stackText)); + + aDiv.appendChild(document.createElement("br")); + aDiv.appendChild(document.createElement("br")); + }, + renderStacks: function StackRenderer_renderStacks( + aPrefix, + aStacks, + aMemoryMap, + aRenderHeader + ) { + let div = document.getElementById(aPrefix); + removeAllChildNodes(div); + + let fetchE = document.getElementById(aPrefix + "-fetch-symbols"); + if (fetchE) { + fetchE.hidden = false; + } + let hideE = document.getElementById(aPrefix + "-hide-symbols"); + if (hideE) { + hideE.hidden = true; + } + + if (!aStacks.length) { + return; + } + + setHasData(aPrefix + "-section", true); + + this.renderMemoryMap(div, aMemoryMap); + + for (let i = 0; i < aStacks.length; ++i) { + let stack = aStacks[i]; + aRenderHeader(i); + this.renderStack(div, stack); + } + }, + + /** + * Renders the title of the stack: e.g. "Late Write #1" or + * "Hang Report #1 (6 seconds)". + * + * @param aDivId The id of the div to append the header to. + * @param aL10nId The l10n id of the message to use for the title. + * @param aL10nArgs The l10n args for the provided message id. + */ + renderHeader: function StackRenderer_renderHeader( + aDivId, + aL10nId, + aL10nArgs + ) { + let div = document.getElementById(aDivId); + + let titleElement = document.createElement("span"); + titleElement.className = "stack-title"; + + document.l10n.setAttributes(titleElement, aL10nId, aL10nArgs); + + div.appendChild(titleElement); + div.appendChild(document.createElement("br")); + }, +}; + +var RawPayloadData = { + /** + * Renders the raw pyaload. + */ + render(aPing) { + setHasData("raw-payload-section", true); + let pre = document.getElementById("raw-payload-data"); + pre.textContent = JSON.stringify(aPing.payload, null, 2); + }, + + attachObservers() { + document + .getElementById("payload-json-viewer") + .addEventListener("click", e => { + openJsonInFirefoxJsonViewer(JSON.stringify(gPingData.payload, null, 2)); + }); + }, +}; + +function SymbolicationRequest( + aPrefix, + aRenderHeader, + aMemoryMap, + aStacks, + aDurations = null +) { + this.prefix = aPrefix; + this.renderHeader = aRenderHeader; + this.memoryMap = aMemoryMap; + this.stacks = aStacks; + this.durations = aDurations; +} +/** + * A callback for onreadystatechange. It replaces the numeric stack with + * the symbolicated one returned by the symbolication server. + */ +SymbolicationRequest.prototype.handleSymbolResponse = + async function SymbolicationRequest_handleSymbolResponse() { + if (this.symbolRequest.readyState != 4) { + return; + } + + let fetchElement = document.getElementById(this.prefix + "-fetch-symbols"); + fetchElement.hidden = true; + let hideElement = document.getElementById(this.prefix + "-hide-symbols"); + hideElement.hidden = false; + let div = document.getElementById(this.prefix); + removeAllChildNodes(div); + let errorMessage = await document.l10n.formatValue( + "about-telemetry-error-fetching-symbols" + ); + + if (this.symbolRequest.status != 200) { + div.appendChild(document.createTextNode(errorMessage)); + return; + } + + let jsonResponse = {}; + try { + jsonResponse = JSON.parse(this.symbolRequest.responseText); + } catch (e) { + div.appendChild(document.createTextNode(errorMessage)); + return; + } + + for (let i = 0; i < jsonResponse.length; ++i) { + let stack = jsonResponse[i]; + this.renderHeader(i, this.durations); + + for (let symbol of stack) { + div.appendChild(document.createTextNode(symbol)); + div.appendChild(document.createElement("br")); + } + div.appendChild(document.createElement("br")); + } + }; +/** + * Send a request to the symbolication server to symbolicate this stack. + */ +SymbolicationRequest.prototype.fetchSymbols = + function SymbolicationRequest_fetchSymbols() { + let symbolServerURI = Preferences.get( + PREF_SYMBOL_SERVER_URI, + DEFAULT_SYMBOL_SERVER_URI + ); + let request = { + memoryMap: this.memoryMap, + stacks: this.stacks, + version: 3, + }; + let requestJSON = JSON.stringify(request); + + this.symbolRequest = new XMLHttpRequest(); + this.symbolRequest.open("POST", symbolServerURI, true); + this.symbolRequest.setRequestHeader("Content-type", "application/json"); + this.symbolRequest.setRequestHeader("Content-length", requestJSON.length); + this.symbolRequest.setRequestHeader("Connection", "close"); + this.symbolRequest.onreadystatechange = + this.handleSymbolResponse.bind(this); + this.symbolRequest.send(requestJSON); + }; + +var Histogram = { + /** + * Renders a single Telemetry histogram + * + * @param aParent Parent element + * @param aName Histogram name + * @param aHgram Histogram information + * @param aOptions Object with render options + * * exponential: bars follow logarithmic scale + */ + render: function Histogram_render(aParent, aName, aHgram, aOptions) { + let options = aOptions || {}; + let hgram = this.processHistogram(aHgram, aName); + + let outerDiv = document.createElement("div"); + outerDiv.className = "histogram"; + outerDiv.id = aName; + + let divTitle = document.createElement("div"); + divTitle.classList.add("histogram-title"); + divTitle.appendChild(document.createTextNode(aName)); + outerDiv.appendChild(divTitle); + + let divStats = document.createElement("div"); + divStats.classList.add("histogram-stats"); + + let histogramStatsArgs = { + sampleCount: hgram.sample_count, + prettyAverage: hgram.pretty_average, + sum: hgram.sum, + }; + + document.l10n.setAttributes( + divStats, + "about-telemetry-histogram-stats", + histogramStatsArgs + ); + + if (isRTL()) { + hgram.values.reverse(); + } + + let textData = this.renderValues(outerDiv, hgram, options); + + // The 'Copy' button contains the textual data, copied to clipboard on click + let copyButton = document.createElement("button"); + copyButton.className = "copy-node"; + document.l10n.setAttributes(copyButton, "about-telemetry-histogram-copy"); + + copyButton.addEventListener("click", async function () { + let divStatsString = await document.l10n.formatValue( + "about-telemetry-histogram-stats", + histogramStatsArgs + ); + copyButton.histogramText = + aName + EOL + divStatsString + EOL + EOL + textData; + Cc["@mozilla.org/widget/clipboardhelper;1"] + .getService(Ci.nsIClipboardHelper) + .copyString(this.histogramText); + }); + outerDiv.appendChild(copyButton); + + aParent.appendChild(outerDiv); + return outerDiv; + }, + + processHistogram(aHgram, aName) { + const values = Object.keys(aHgram.values).map(k => aHgram.values[k]); + if (!values.length) { + // If we have no values collected for this histogram, just return + // zero values so we still render it. + return { + values: [], + pretty_average: 0, + max: 0, + sample_count: 0, + sum: 0, + }; + } + + const sample_count = values.reduceRight((a, b) => a + b); + const average = Math.round((aHgram.sum * 10) / sample_count) / 10; + const max_value = Math.max(...values); + + const labelledValues = Object.keys(aHgram.values).map(k => [ + Number(k), + aHgram.values[k], + ]); + + let result = { + values: labelledValues, + pretty_average: average, + max: max_value, + sample_count, + sum: aHgram.sum, + }; + + return result; + }, + + /** + * Return a non-negative, logarithmic representation of a non-negative number. + * e.g. 0 => 0, 1 => 1, 10 => 2, 100 => 3 + * + * @param aNumber Non-negative number + */ + getLogValue(aNumber) { + return Math.max(0, Math.log10(aNumber) + 1); + }, + + /** + * Create histogram HTML bars, also returns a textual representation + * Both aMaxValue and aSumValues must be positive. + * Values are assumed to use 0 as baseline. + * + * @param aDiv Outer parent div + * @param aHgram The histogram data + * @param aOptions Object with render options (@see #render) + */ + renderValues: function Histogram_renderValues(aDiv, aHgram, aOptions) { + let text = ""; + // If the last label is not the longest string, alignment will break a little + let labelPadTo = 0; + if (aHgram.values.length) { + labelPadTo = String(aHgram.values[aHgram.values.length - 1][0]).length; + } + let maxBarValue = aOptions.exponential + ? this.getLogValue(aHgram.max) + : aHgram.max; + + for (let [label, value] of aHgram.values) { + label = String(label); + let barValue = aOptions.exponential ? this.getLogValue(value) : value; + + // Create a text representation: <right-aligned-label> |<bar-of-#><value> <percentage> + text += + EOL + + " ".repeat(Math.max(0, labelPadTo - label.length)) + + label + // Right-aligned label + " |" + + "#".repeat(Math.round((MAX_BAR_CHARS * barValue) / maxBarValue)) + // Bar + " " + + value + // Value + " " + + Math.round((100 * value) / aHgram.sample_count) + + "%"; // Percentage + + // Construct the HTML labels + bars + let belowEm = + Math.round(MAX_BAR_HEIGHT * (barValue / maxBarValue) * 10) / 10; + let aboveEm = MAX_BAR_HEIGHT - belowEm; + + let barDiv = document.createElement("div"); + barDiv.className = "bar"; + barDiv.style.paddingTop = aboveEm + "em"; + + // Add value label or an nbsp if no value + barDiv.appendChild(document.createTextNode(value ? value : "\u00A0")); + + // Create the blue bar + let bar = document.createElement("div"); + bar.className = "bar-inner"; + bar.style.height = belowEm + "em"; + barDiv.appendChild(bar); + + // Add a special class to move the text down to prevent text overlap + if (label.length > 3) { + bar.classList.add("long-label"); + } + // Add bucket label + barDiv.appendChild(document.createTextNode(label)); + + aDiv.appendChild(barDiv); + } + + return text.substr(EOL.length); // Trim the EOL before the first line + }, +}; + +var Search = { + HASH_SEARCH: "search=", + + // A list of ids of sections that do not support search. + blacklist: ["late-writes-section", "raw-payload-section"], + + // Pass if: all non-empty array items match (case-sensitive) + isPassText(subject, filter) { + for (let item of filter) { + if (item.length && !subject.includes(item)) { + return false; // mismatch and not a spurious space + } + } + return true; + }, + + isPassRegex(subject, filter) { + return filter.test(subject); + }, + + chooseFilter(filterText) { + let filter = filterText.toString(); + // Setup normalized filter string (trimmed, lower cased and split on spaces if not RegEx) + let isPassFunc; // filter function, set once, then applied to all elements + filter = filter.trim(); + if (filter[0] != "/") { + // Plain text: case insensitive, AND if multi-string + isPassFunc = this.isPassText; + filter = filter.toLowerCase().split(" "); + } else { + isPassFunc = this.isPassRegex; + var r = filter.match(/^\/(.*)\/(i?)$/); + try { + filter = RegExp(r[1], r[2]); + } catch (e) { + // Incomplete or bad RegExp - always no match + isPassFunc = function () { + return false; + }; + } + } + return [isPassFunc, filter]; + }, + + filterTextRows(table, filterText) { + let [isPassFunc, filter] = this.chooseFilter(filterText); + let allElementHidden = true; + + let needLowerCase = isPassFunc === this.isPassText; + let elements = table.rows; + for (let element of elements) { + if (element.firstChild.nodeName == "th") { + continue; + } + for (let cell of element.children) { + let subject = needLowerCase + ? cell.textContent.toLowerCase() + : cell.textContent; + element.hidden = !isPassFunc(subject, filter); + if (!element.hidden) { + if (allElementHidden) { + allElementHidden = false; + } + // Don't need to check the rest of this row. + break; + } + } + } + // Unhide the first row: + if (!allElementHidden) { + table.rows[0].hidden = false; + } + return allElementHidden; + }, + + filterElements(elements, filterText) { + let [isPassFunc, filter] = this.chooseFilter(filterText); + let allElementHidden = true; + + let needLowerCase = isPassFunc === this.isPassText; + for (let element of elements) { + let subject = needLowerCase ? element.id.toLowerCase() : element.id; + element.hidden = !isPassFunc(subject, filter); + if (allElementHidden && !element.hidden) { + allElementHidden = false; + } + } + return allElementHidden; + }, + + filterKeyedElements(keyedElements, filterText) { + let [isPassFunc, filter] = this.chooseFilter(filterText); + let allElementsHidden = true; + + let needLowerCase = isPassFunc === this.isPassText; + keyedElements.forEach(keyedElement => { + let subject = needLowerCase + ? keyedElement.key.id.toLowerCase() + : keyedElement.key.id; + if (!isPassFunc(subject, filter)) { + // If the keyedHistogram's name is not matched + let allKeyedElementsHidden = true; + for (let element of keyedElement.datas) { + let subject = needLowerCase ? element.id.toLowerCase() : element.id; + let match = isPassFunc(subject, filter); + element.hidden = !match; + if (match) { + allKeyedElementsHidden = false; + } + } + if (allElementsHidden && !allKeyedElementsHidden) { + allElementsHidden = false; + } + keyedElement.key.hidden = allKeyedElementsHidden; + } else { + // If the keyedHistogram's name is matched + allElementsHidden = false; + keyedElement.key.hidden = false; + for (let element of keyedElement.datas) { + element.hidden = false; + } + } + }); + return allElementsHidden; + }, + + searchHandler(e) { + if (this.idleTimeout) { + clearTimeout(this.idleTimeout); + } + this.idleTimeout = setTimeout( + () => Search.search(e.target.value), + FILTER_IDLE_TIMEOUT + ); + }, + + search(text, sectionParam = null) { + let section = sectionParam; + if (!section) { + let sectionId = document + .querySelector(".category.selected") + .getAttribute("value"); + section = document.getElementById(sectionId); + } + if (Search.blacklist.includes(section.id)) { + return false; + } + let noSearchResults = true; + // In the home section, we search all other sections: + if (section.id === "home-section") { + return this.homeSearch(text); + } + + if (section.id === "histograms-section") { + let histograms = section.getElementsByClassName("histogram"); + noSearchResults = this.filterElements(histograms, text); + } else if (section.id === "keyed-histograms-section") { + let keyedElements = []; + let keyedHistograms = section.getElementsByClassName("keyed-histogram"); + for (let key of keyedHistograms) { + let datas = key.getElementsByClassName("histogram"); + keyedElements.push({ key, datas }); + } + noSearchResults = this.filterKeyedElements(keyedElements, text); + } else if (section.id === "keyed-scalars-section") { + let keyedElements = []; + let keyedScalars = section.getElementsByClassName("keyed-scalar"); + for (let key of keyedScalars) { + let datas = key.querySelector("table").rows; + keyedElements.push({ key, datas }); + } + noSearchResults = this.filterKeyedElements(keyedElements, text); + } else if (section.matches(".text-search")) { + let tables = section.querySelectorAll("table"); + for (let table of tables) { + // If we unhide anything, flip noSearchResults to + // false so we don't show the "no results" bits. + if (!this.filterTextRows(table, text)) { + noSearchResults = false; + } + } + } else if (section.querySelector(".sub-section")) { + let keyedSubSections = []; + let subsections = section.querySelectorAll(".sub-section"); + for (let section of subsections) { + let datas = section.querySelector("table").rows; + keyedSubSections.push({ key: section, datas }); + } + noSearchResults = this.filterKeyedElements(keyedSubSections, text); + } else { + let tables = section.querySelectorAll("table"); + for (let table of tables) { + noSearchResults = this.filterElements(table.rows, text); + if (table.caption) { + table.caption.hidden = noSearchResults; + } + } + } + + changeUrlSearch(text); + + if (!sectionParam) { + // If we are not searching in all section. + this.updateNoResults(text, noSearchResults); + } + return noSearchResults; + }, + + updateNoResults(text, noSearchResults) { + document + .getElementById("no-search-results") + .classList.toggle("hidden", !noSearchResults); + if (noSearchResults) { + let section = document.querySelector(".category.selected > span"); + let searchResultsText = document.getElementById("no-search-results-text"); + if (section.parentElement.id === "category-home") { + document.l10n.setAttributes( + searchResultsText, + "about-telemetry-no-search-results-all", + { searchTerms: text } + ); + } else { + let sectionName = section.textContent.trim(); + text === "" + ? document.l10n.setAttributes( + searchResultsText, + "about-telemetry-no-data-to-display", + { sectionName } + ) + : document.l10n.setAttributes( + searchResultsText, + "about-telemetry-no-search-results", + { sectionName, currentSearchText: text } + ); + } + } + }, + + resetHome() { + document.getElementById("main").classList.remove("search"); + document.getElementById("no-search-results").classList.add("hidden"); + adjustHeaderState(); + Array.from(document.querySelectorAll("section")).forEach(section => { + section.classList.toggle("active", section.id == "home-section"); + }); + }, + + homeSearch(text) { + changeUrlSearch(text); + removeSearchSectionTitles(); + if (text === "") { + this.resetHome(); + return; + } + document.getElementById("main").classList.add("search"); + adjustHeaderState(text); + let noSearchResults = true; + Array.from(document.querySelectorAll("section")).forEach(section => { + if (section.id == "home-section" || section.id == "raw-payload-section") { + section.classList.remove("active"); + return; + } + section.classList.add("active"); + let sectionHidden = this.search(text, section); + if (!sectionHidden) { + let sectionTitle = document.querySelector( + `.category[value="${section.id}"] .category-name` + ).textContent; + let sectionDataDiv = document.querySelector( + `#${section.id}.has-data.active .data` + ); + let titleDiv = document.createElement("h1"); + titleDiv.classList.add("data", "search-section-title"); + titleDiv.textContent = sectionTitle; + section.insertBefore(titleDiv, sectionDataDiv); + noSearchResults = false; + } else { + // Hide all subsections if the section is hidden + let subsections = section.querySelectorAll(".sub-section"); + for (let subsection of subsections) { + subsection.hidden = true; + } + } + }); + this.updateNoResults(text, noSearchResults); + }, +}; + +/* + * Helper function to render JS objects with white space between top level elements + * so that they look better in the browser + * @param aObject JavaScript object or array to render + * @return String + */ +function RenderObject(aObject) { + let output = ""; + if (Array.isArray(aObject)) { + if (!aObject.length) { + return "[]"; + } + output = "[" + JSON.stringify(aObject[0]); + for (let i = 1; i < aObject.length; i++) { + output += ", " + JSON.stringify(aObject[i]); + } + return output + "]"; + } + let keys = Object.keys(aObject); + if (!keys.length) { + return "{}"; + } + output = '{"' + keys[0] + '":\u00A0' + JSON.stringify(aObject[keys[0]]); + for (let i = 1; i < keys.length; i++) { + output += ', "' + keys[i] + '":\u00A0' + JSON.stringify(aObject[keys[i]]); + } + return output + "}"; +} + +var GenericSubsection = { + addSubSectionToSidebar(id, title) { + let category = document.querySelector("#categories > [value=" + id + "]"); + category.classList.add("has-subsection"); + let subCategory = document.createElement("div"); + subCategory.classList.add("category-subsection"); + subCategory.setAttribute("value", id + "-" + title); + subCategory.addEventListener("click", ev => { + let section = ev.target; + showSubSection(section); + }); + subCategory.appendChild(document.createTextNode(title)); + category.appendChild(subCategory); + }, + + render(data, dataDiv, sectionID) { + for (let [title, sectionData] of data) { + let hasData = sectionData.size > 0; + let s = this.renderSubsectionHeader(title, hasData, sectionID); + s.appendChild(this.renderSubsectionData(title, sectionData)); + dataDiv.appendChild(s); + } + }, + + renderSubsectionHeader(title, hasData, sectionID) { + this.addSubSectionToSidebar(sectionID, title); + let section = document.createElement("div"); + section.setAttribute("id", sectionID + "-" + title); + section.classList.add("sub-section"); + if (hasData) { + section.classList.add("has-subdata"); + } + return section; + }, + + renderSubsectionData(title, data) { + // Create data container + let dataDiv = document.createElement("div"); + dataDiv.setAttribute("class", "subsection-data subdata"); + // Instanciate the data + let table = GenericTable.render(data); + let caption = document.createElement("caption"); + caption.textContent = title; + table.appendChild(caption); + dataDiv.appendChild(table); + + return dataDiv; + }, + + deleteAllSubSections() { + let subsections = document.querySelectorAll(".category-subsection"); + subsections.forEach(el => { + el.parentElement.removeChild(el); + }); + }, +}; + +var GenericTable = { + // Returns a table with key and value headers + defaultHeadings() { + return ["about-telemetry-keys-header", "about-telemetry-values-header"]; + }, + + /** + * Returns a n-column table. + * @param rows An array of arrays, each containing data to render + * for one row. + * @param headings The column header strings. + */ + render(rows, headings = this.defaultHeadings()) { + let table = document.createElement("table"); + this.renderHeader(table, headings); + this.renderBody(table, rows); + return table; + }, + + /** + * Create the table header. + * Tabs & newlines added to cells to make it easier to copy-paste. + * + * @param table Table element + * @param headings Array of column header strings. + */ + renderHeader(table, headings) { + let headerRow = document.createElement("tr"); + table.appendChild(headerRow); + + for (let i = 0; i < headings.length; ++i) { + let column = document.createElement("th"); + document.l10n.setAttributes(column, headings[i]); + headerRow.appendChild(column); + } + }, + + /** + * Create the table body + * Tabs & newlines added to cells to make it easier to copy-paste. + * + * @param table Table element + * @param rows An array of arrays, each containing data to render + * for one row. + */ + renderBody(table, rows) { + for (let row of rows) { + row = row.map(value => { + // use .valueOf() to unbox Number, String, etc. objects + if ( + value && + typeof value == "object" && + typeof value.valueOf() == "object" + ) { + return RenderObject(value); + } + return value; + }); + + let newRow = document.createElement("tr"); + newRow.id = row[0]; + table.appendChild(newRow); + + for (let i = 0; i < row.length; ++i) { + let suffix = i == row.length - 1 ? "\n" : "\t"; + let field = document.createElement("td"); + field.appendChild(document.createTextNode(row[i] + suffix)); + newRow.appendChild(field); + } + } + }, +}; + +var KeyedHistogram = { + render(parent, id, keyedHistogram) { + let outerDiv = document.createElement("div"); + outerDiv.className = "keyed-histogram"; + outerDiv.id = id; + + let divTitle = document.createElement("div"); + divTitle.classList.add("keyed-title"); + divTitle.appendChild(document.createTextNode(id)); + outerDiv.appendChild(divTitle); + + for (let [name, hgram] of Object.entries(keyedHistogram)) { + Histogram.render(outerDiv, name, hgram); + } + + parent.appendChild(outerDiv); + return outerDiv; + }, +}; + +var AddonDetails = { + /** + * Render the addon details section as a series of headers followed by key/value tables + * @param aPing A ping object to render the data from. + */ + render(aPing) { + let addonSection = document.getElementById("addon-details"); + removeAllChildNodes(addonSection); + let addonDetails = aPing.payload.addonDetails; + const hasData = addonDetails && !!Object.keys(addonDetails).length; + setHasData("addon-details-section", hasData); + if (!hasData) { + return; + } + + for (let provider in addonDetails) { + let providerSection = document.createElement("caption"); + document.l10n.setAttributes( + providerSection, + "about-telemetry-addon-provider", + { addonProvider: provider } + ); + let headingStrings = [ + "about-telemetry-addon-table-id", + "about-telemetry-addon-table-details", + ]; + let table = GenericTable.render( + explodeObject(addonDetails[provider]), + headingStrings + ); + table.appendChild(providerSection); + addonSection.appendChild(table); + } + }, +}; + +class Section { + static renderContent(data, process, div, section) { + if (data && Object.keys(data).length) { + let s = GenericSubsection.renderSubsectionHeader(process, true, section); + let heading = document.createElement("h2"); + document.l10n.setAttributes(heading, "about-telemetry-process", { + process, + }); + s.appendChild(heading); + + this.renderData(data, s); + + div.appendChild(s); + let separator = document.createElement("div"); + separator.classList.add("clearfix"); + div.appendChild(separator); + } + } + + /** + * Make parent process the first one, content process the second + * then sort processes alphabetically + */ + static processesComparator(a, b) { + if (a === "parent" || (a === "content" && b !== "parent")) { + return -1; + } else if (b === "parent" || b === "content") { + return 1; + } else if (a < b) { + return -1; + } else if (a > b) { + return 1; + } + return 0; + } + + /** + * Render sections + */ + static renderSection(divName, section, aPayload) { + let div = document.getElementById(divName); + removeAllChildNodes(div); + + let data = {}; + let hasData = false; + let selectedStore = getSelectedStore(); + + let payload = aPayload.stores; + + let isCurrentPayload = !!payload; + + // Sort processes + let sortedProcesses = isCurrentPayload + ? Object.keys(payload[selectedStore]).sort(this.processesComparator) + : Object.keys(aPayload.processes).sort(this.processesComparator); + + // Render content by process + for (const process of sortedProcesses) { + data = isCurrentPayload + ? this.dataFiltering(payload, selectedStore, process) + : this.archivePingDataFiltering(aPayload, process); + hasData = hasData || !ObjectUtils.isEmpty(data); + this.renderContent(data, process, div, section, this.renderData); + } + setHasData(section, hasData); + } +} + +class Scalars extends Section { + /** + * Return data from the current ping + */ + static dataFiltering(payload, selectedStore, process) { + return payload[selectedStore][process].scalars; + } + + /** + * Return data from an archived ping + */ + static archivePingDataFiltering(payload, process) { + return payload.processes[process].scalars; + } + + static renderData(data, div) { + const scalarsHeadings = [ + "about-telemetry-names-header", + "about-telemetry-values-header", + ]; + let scalarsTable = GenericTable.render( + explodeObject(data), + scalarsHeadings + ); + div.appendChild(scalarsTable); + } + + /** + * Render the scalar data - if present - from the payload in a simple key-value table. + * @param aPayload A payload object to render the data from. + */ + static render(aPayload) { + const divName = "scalars"; + const section = "scalars-section"; + this.renderSection(divName, section, aPayload); + } +} + +class KeyedScalars extends Section { + /** + * Return data from the current ping + */ + static dataFiltering(payload, selectedStore, process) { + return payload[selectedStore][process].keyedScalars; + } + + /** + * Return data from an archived ping + */ + static archivePingDataFiltering(payload, process) { + return payload.processes[process].keyedScalars; + } + + static renderData(data, div) { + const scalarsHeadings = [ + "about-telemetry-names-header", + "about-telemetry-values-header", + ]; + for (let scalarId in data) { + // Add the name of the scalar. + let container = document.createElement("div"); + container.classList.add("keyed-scalar"); + container.id = scalarId; + let scalarNameSection = document.createElement("p"); + scalarNameSection.classList.add("keyed-title"); + scalarNameSection.appendChild(document.createTextNode(scalarId)); + container.appendChild(scalarNameSection); + // Populate the section with the key-value pairs from the scalar. + const table = GenericTable.render( + explodeObject(data[scalarId]), + scalarsHeadings + ); + container.appendChild(table); + div.appendChild(container); + } + } + + /** + * Render the keyed scalar data - if present - from the payload in a simple key-value table. + * @param aPayload A payload object to render the data from. + */ + static render(aPayload) { + const divName = "keyed-scalars"; + const section = "keyed-scalars-section"; + this.renderSection(divName, section, aPayload); + } +} + +var Events = { + /** + * Render the event data - if present - from the payload in a simple table. + * @param aPayload A payload object to render the data from. + */ + render(aPayload) { + let eventsDiv = document.getElementById("events"); + removeAllChildNodes(eventsDiv); + const headings = [ + "about-telemetry-time-stamp-header", + "about-telemetry-category-header", + "about-telemetry-method-header", + "about-telemetry-object-header", + "about-telemetry-values-header", + "about-telemetry-extra-header", + ]; + let payload = aPayload.processes; + let hasData = false; + if (payload) { + for (const process of Object.keys(aPayload.processes)) { + let data = aPayload.processes[process].events; + if (data && Object.keys(data).length) { + hasData = true; + let s = GenericSubsection.renderSubsectionHeader( + process, + true, + "events-section" + ); + let heading = document.createElement("h2"); + heading.textContent = process; + s.appendChild(heading); + const table = GenericTable.render(data, headings); + s.appendChild(table); + eventsDiv.appendChild(s); + let separator = document.createElement("div"); + separator.classList.add("clearfix"); + eventsDiv.appendChild(separator); + } + } + } else { + // handle archived ping + for (const process of Object.keys(aPayload.events)) { + let data = process; + if (data && Object.keys(data).length) { + hasData = true; + let s = GenericSubsection.renderSubsectionHeader( + process, + true, + "events-section" + ); + let heading = document.createElement("h2"); + heading.textContent = process; + s.appendChild(heading); + const table = GenericTable.render(data, headings); + eventsDiv.appendChild(table); + let separator = document.createElement("div"); + separator.classList.add("clearfix"); + eventsDiv.appendChild(separator); + } + } + } + setHasData("events-section", hasData); + }, +}; + +/** + * Helper function for showing either the toggle element or "No data collected" message for a section + * + * @param aSectionID ID of the section element that needs to be changed + * @param aHasData true (default) indicates that toggle should be displayed + */ +function setHasData(aSectionID, aHasData) { + let sectionElement = document.getElementById(aSectionID); + sectionElement.classList[aHasData ? "add" : "remove"]("has-data"); + + // Display or Hide the section in the sidebar + let sectionCategory = document.querySelector( + ".category[value=" + aSectionID + "]" + ); + sectionCategory.classList[aHasData ? "add" : "remove"]("has-data"); +} + +/** + * Sets l10n attributes based on the Telemetry Server Owner pref. + */ +function setupServerOwnerBranding() { + let serverOwner = Preferences.get(PREF_TELEMETRY_SERVER_OWNER, "Mozilla"); + const elements = [ + [document.getElementById("page-subtitle"), "about-telemetry-page-subtitle"], + ]; + for (const [elt, l10nName] of elements) { + document.l10n.setAttributes(elt, l10nName, { + telemetryServerOwner: serverOwner, + }); + } +} + +/** + * Display the store selector if we are on one + * of the whitelisted sections + */ +function displayStoresSelector(selectedSection) { + let whitelist = [ + "scalars-section", + "keyed-scalars-section", + "histograms-section", + "keyed-histograms-section", + ]; + let stores = document.getElementById("stores"); + stores.hidden = !whitelist.includes(selectedSection); + let storesLabel = document.getElementById("storesLabel"); + storesLabel.hidden = !whitelist.includes(selectedSection); +} + +function refreshSearch() { + removeSearchSectionTitles(); + let selectedSection = document + .querySelector(".category.selected") + .getAttribute("value"); + let search = document.getElementById("search"); + if (!Search.blacklist.includes(selectedSection)) { + Search.search(search.value); + } +} + +function adjustSearchState() { + removeSearchSectionTitles(); + let selectedSection = document + .querySelector(".category.selected") + .getAttribute("value"); + let search = document.getElementById("search"); + search.value = ""; + search.hidden = Search.blacklist.includes(selectedSection); + document.getElementById("no-search-results").classList.add("hidden"); + Search.search(""); // reinitialize search state. +} + +function removeSearchSectionTitles() { + for (let sectionTitleDiv of Array.from( + document.getElementsByClassName("search-section-title") + )) { + sectionTitleDiv.remove(); + } +} + +function adjustSection() { + let selectedCategory = document.querySelector(".category.selected"); + if (!selectedCategory.classList.contains("has-data")) { + PingPicker._showStructuredPingData(); + } +} + +function adjustHeaderState(title = null) { + let selected = document.querySelector(".category.selected .category-name"); + let selectedTitle = selected.textContent.trim(); + let sectionTitle = document.getElementById("sectionTitle"); + if (title !== null) { + document.l10n.setAttributes( + sectionTitle, + "about-telemetry-results-for-search", + { searchTerms: title } + ); + } else { + sectionTitle.textContent = selectedTitle; + } + let search = document.getElementById("search"); + if (selected.parentElement.id === "category-home") { + document.l10n.setAttributes( + search, + "about-telemetry-filter-all-placeholder" + ); + } else { + document.l10n.setAttributes(search, "about-telemetry-filter-placeholder", { + selectedTitle, + }); + } +} + +/** + * Change the url according to the current section displayed + * e.g about:telemetry#general-data + */ +function changeUrlPath(selectedSection, subSection) { + if (subSection) { + let hash = window.location.hash.split("_")[0] + "_" + selectedSection; + window.location.hash = hash; + } else { + window.location.hash = selectedSection.replace("-section", "-tab"); + } +} + +/** + * Change the url according to the current search text + */ +function changeUrlSearch(searchText) { + let currentHash = window.location.hash; + let hashWithoutSearch = currentHash.split(Search.HASH_SEARCH)[0]; + let hash = ""; + + if (!currentHash && !searchText) { + return; + } + if (!currentHash.includes(Search.HASH_SEARCH) && hashWithoutSearch) { + hashWithoutSearch += "_"; + } + if (searchText) { + hash = + hashWithoutSearch + Search.HASH_SEARCH + searchText.replace(/ /g, "+"); + } else if (hashWithoutSearch) { + hash = hashWithoutSearch.slice(0, hashWithoutSearch.length - 1); + } + + window.location.hash = hash; +} + +/** + * Change the section displayed + */ +function show(selected) { + let selectedValue = selected.getAttribute("value"); + if (selectedValue === "raw-json-viewer") { + openJsonInFirefoxJsonViewer(JSON.stringify(gPingData, null, 2)); + return; + } + + let selected_section = document.getElementById(selectedValue); + let subsections = selected_section.querySelectorAll(".sub-section"); + if (selected.classList.contains("has-subsection")) { + for (let subsection of selected.children) { + subsection.classList.remove("selected"); + } + } + if (subsections) { + for (let subsection of subsections) { + subsection.hidden = false; + } + } + + let current_button = document.querySelector(".category.selected"); + if (current_button == selected) { + return; + } + current_button.classList.remove("selected"); + selected.classList.add("selected"); + + document.querySelectorAll("section").forEach(section => { + section.classList.remove("active"); + }); + selected_section.classList.add("active"); + + adjustHeaderState(); + displayStoresSelector(selectedValue); + adjustSearchState(); + changeUrlPath(selectedValue); +} + +function showSubSection(selected) { + if (!selected) { + return; + } + let current_selection = document.querySelector( + ".category-subsection.selected" + ); + if (current_selection) { + current_selection.classList.remove("selected"); + } + selected.classList.add("selected"); + + let section = document.getElementById(selected.getAttribute("value")); + section.parentElement.childNodes.forEach(element => { + element.hidden = true; + }); + section.hidden = false; + + let title = + selected.parentElement.querySelector(".category-name").textContent; + let subsection = selected.textContent; + document.getElementById("sectionTitle").textContent = + title + " - " + subsection; + changeUrlPath(subsection, true); +} + +/** + * Initializes load/unload, pref change and mouse-click listeners + */ +function setupListeners() { + Settings.attachObservers(); + PingPicker.attachObservers(); + RawPayloadData.attachObservers(); + + let menu = document.getElementById("categories"); + menu.addEventListener("click", e => { + if (e.target && e.target.parentNode == menu) { + show(e.target); + } + }); + + let search = document.getElementById("search"); + search.addEventListener("input", Search.searchHandler); + + document + .getElementById("late-writes-fetch-symbols") + .addEventListener("click", function () { + if (!gPingData) { + return; + } + + let lateWrites = gPingData.payload.lateWrites; + let req = new SymbolicationRequest( + "late-writes", + LateWritesSingleton.renderHeader, + lateWrites.memoryMap, + lateWrites.stacks + ); + req.fetchSymbols(); + }); + + document + .getElementById("late-writes-hide-symbols") + .addEventListener("click", function () { + if (!gPingData) { + return; + } + + LateWritesSingleton.renderLateWrites(gPingData.payload.lateWrites); + }); +} + +// Restores the sections states +function urlSectionRestore(hash) { + if (hash) { + let section = hash.replace("-tab", "-section"); + let subsection = section.split("_")[1]; + section = section.split("_")[0]; + let category = document.querySelector(".category[value=" + section + "]"); + if (category) { + show(category); + if (subsection) { + let selector = + ".category-subsection[value=" + section + "-" + subsection + "]"; + let subcategory = document.querySelector(selector); + showSubSection(subcategory); + } + } + } +} + +// Restore sections states and search terms +function urlStateRestore() { + let hash = window.location.hash; + let searchQuery = ""; + if (hash) { + hash = hash.slice(1); + if (hash.includes(Search.HASH_SEARCH)) { + searchQuery = hash.split(Search.HASH_SEARCH)[1].replace(/[+]/g, " "); + hash = hash.split(Search.HASH_SEARCH)[0]; + } + urlSectionRestore(hash); + } + if (searchQuery) { + let search = document.getElementById("search"); + search.value = searchQuery; + } +} + +function openJsonInFirefoxJsonViewer(json) { + json = unescape(encodeURIComponent(json)); + try { + window.open("data:application/json;base64," + btoa(json)); + } catch (e) { + show(document.querySelector(".category[value=raw-payload-section]")); + } +} + +function onLoad() { + window.removeEventListener("load", onLoad); + // Set the text in the page header and elsewhere that needs the server owner. + setupServerOwnerBranding(); + + // Set up event listeners + setupListeners(); + + // Render settings. + Settings.render(); + + adjustHeaderState(); + + urlStateRestore(); + + // Update ping data when async Telemetry init is finished. + Telemetry.asyncFetchTelemetryData(async () => { + await PingPicker.update(); + }); +} + +var LateWritesSingleton = { + renderHeader: function LateWritesSingleton_renderHeader(aIndex) { + StackRenderer.renderHeader( + "late-writes", + "about-telemetry-late-writes-title", + { lateWriteCount: aIndex + 1 } + ); + }, + + renderLateWrites: function LateWritesSingleton_renderLateWrites(lateWrites) { + let hasData = !!( + lateWrites && + lateWrites.stacks && + lateWrites.stacks.length + ); + setHasData("late-writes-section", hasData); + if (!hasData) { + return; + } + + let stacks = lateWrites.stacks; + let memoryMap = lateWrites.memoryMap; + StackRenderer.renderStacks( + "late-writes", + stacks, + memoryMap, + LateWritesSingleton.renderHeader + ); + }, +}; + +class HistogramSection extends Section { + /** + * Return data from the current ping + */ + static dataFiltering(payload, selectedStore, process) { + return payload[selectedStore][process].histograms; + } + + /** + * Return data from an archived ping + */ + static archivePingDataFiltering(payload, process) { + if (process === "parent") { + return payload.histograms; + } + return payload.processes[process].histograms; + } + + static renderData(data, div) { + for (let [hName, hgram] of Object.entries(data)) { + Histogram.render(div, hName, hgram, { unpacked: true }); + } + } + + static render(aPayload) { + const divName = "histograms"; + const section = "histograms-section"; + this.renderSection(divName, section, aPayload); + } +} + +class KeyedHistogramSection extends Section { + /** + * Return data from the current ping + */ + static dataFiltering(payload, selectedStore, process) { + return payload[selectedStore][process].keyedHistograms; + } + + /** + * Return data from an archived ping + */ + static archivePingDataFiltering(payload, process) { + if (process === "parent") { + return payload.keyedHistograms; + } + return payload.processes[process].keyedHistograms; + } + + static renderData(data, div) { + for (let [id, keyed] of Object.entries(data)) { + KeyedHistogram.render(div, id, keyed, { unpacked: true }); + } + } + + static render(aPayload) { + const divName = "keyed-histograms"; + const section = "keyed-histograms-section"; + this.renderSection(divName, section, aPayload); + } +} + +var SessionInformation = { + render(aPayload) { + let infoSection = document.getElementById("session-info"); + removeAllChildNodes(infoSection); + + let hasData = !!Object.keys(aPayload.info).length; + setHasData("session-info-section", hasData); + + if (hasData) { + const table = GenericTable.render(explodeObject(aPayload.info)); + infoSection.appendChild(table); + } + }, +}; + +var SimpleMeasurements = { + render(aPayload) { + let simpleSection = document.getElementById("simple-measurements"); + removeAllChildNodes(simpleSection); + + let simpleMeasurements = this.sortStartupMilestones( + aPayload.simpleMeasurements + ); + let hasData = !!Object.keys(simpleMeasurements).length; + setHasData("simple-measurements-section", hasData); + + if (hasData) { + const table = GenericTable.render(explodeObject(simpleMeasurements)); + simpleSection.appendChild(table); + } + }, + + /** + * Helper function for sorting the startup milestones in the Simple Measurements + * section into temporal order. + * + * @param aSimpleMeasurements Telemetry ping's "Simple Measurements" data + * @return Sorted measurements + */ + sortStartupMilestones(aSimpleMeasurements) { + const telemetryTimestamps = TelemetryTimestamps.get(); + let startupEvents = Services.startup.getStartupInfo(); + delete startupEvents.process; + + function keyIsMilestone(k) { + return k in startupEvents || k in telemetryTimestamps; + } + + let sortedKeys = Object.keys(aSimpleMeasurements); + + // Sort the measurements, with startup milestones at the front + ordered by time + sortedKeys.sort(function keyCompare(keyA, keyB) { + let isKeyAMilestone = keyIsMilestone(keyA); + let isKeyBMilestone = keyIsMilestone(keyB); + + // First order by startup vs non-startup measurement + if (isKeyAMilestone && !isKeyBMilestone) { + return -1; + } + if (!isKeyAMilestone && isKeyBMilestone) { + return 1; + } + // Don't change order of non-startup measurements + if (!isKeyAMilestone && !isKeyBMilestone) { + return 0; + } + + // If both keys are startup measurements, order them by value + return aSimpleMeasurements[keyA] - aSimpleMeasurements[keyB]; + }); + + // Insert measurements into a result object in sort-order + let result = {}; + for (let key of sortedKeys) { + result[key] = aSimpleMeasurements[key]; + } + + return result; + }, +}; + +/** + * Render stores options + */ +function renderStoreList(payload) { + let storeSelect = document.getElementById("stores"); + let storesLabel = document.getElementById("storesLabel"); + removeAllChildNodes(storeSelect); + + if (!("stores" in payload)) { + storeSelect.classList.add("hidden"); + storesLabel.classList.add("hidden"); + return; + } + + storeSelect.classList.remove("hidden"); + storesLabel.classList.remove("hidden"); + storeSelect.disabled = false; + + for (let store of Object.keys(payload.stores)) { + let option = document.createElement("option"); + option.appendChild(document.createTextNode(store)); + option.setAttribute("value", store); + // Select main store by default + if (store === "main") { + option.selected = true; + } + storeSelect.appendChild(option); + } +} + +/** + * Return the selected store + */ +function getSelectedStore() { + let storeSelect = document.getElementById("stores"); + let storeSelectedOption = storeSelect.selectedOptions.item(0); + let selectedStore = + storeSelectedOption !== null + ? storeSelectedOption.getAttribute("value") + : undefined; + return selectedStore; +} + +function togglePingSections(isMainPing) { + // We always show the sections that are "common" to all pings. + let commonSections = new Set([ + "heading", + "home-section", + "general-data-section", + "environment-data-section", + "raw-json-viewer", + ]); + + let elements = document.querySelectorAll(".category"); + for (let section of elements) { + if (commonSections.has(section.getAttribute("value"))) { + continue; + } + // Only show the raw payload for non main ping. + if (section.getAttribute("value") == "raw-payload-section") { + section.classList.toggle("has-data", !isMainPing); + } else { + section.classList.toggle("has-data", isMainPing); + } + } +} + +function displayPingData(ping, updatePayloadList = false) { + gPingData = ping; + try { + PingPicker.render(); + displayRichPingData(ping, updatePayloadList); + adjustSection(); + refreshSearch(); + } catch (err) { + console.log(err); + PingPicker._showRawPingData(); + } +} + +function displayRichPingData(ping, updatePayloadList) { + // Update the payload list and store lists + if (updatePayloadList) { + renderStoreList(ping.payload); + } + + // Show general data. + GeneralData.render(ping); + + // Show environment data. + EnvironmentData.render(ping); + + RawPayloadData.render(ping); + + // We have special rendering code for the payloads from "main" and "event" pings. + // For any other pings we just render the raw JSON payload. + let isMainPing = ping.type == "main" || ping.type == "saved-session"; + let isEventPing = ping.type == "event"; + togglePingSections(isMainPing); + + if (isEventPing) { + // Copy the payload, so we don't modify the raw representation + // Ensure we always have at least the parent process. + let payload = { processes: { parent: {} } }; + for (let process of Object.keys(ping.payload.events)) { + payload.processes[process] = { + events: ping.payload.events[process], + }; + } + + // We transformed the actual payload, let's reload the store list if necessary. + if (updatePayloadList) { + renderStoreList(payload); + } + + // Show event data. + Events.render(payload); + return; + } + + if (!isMainPing) { + return; + } + + // Show slow SQL stats + SlowSQL.render(ping); + + // Render Addon details. + AddonDetails.render(ping); + + let payload = ping.payload; + // Show basic session info gathered + SessionInformation.render(payload); + + // Show scalar data. + Scalars.render(payload); + KeyedScalars.render(payload); + + // Show histogram data + HistogramSection.render(payload); + + // Show keyed histogram data + KeyedHistogramSection.render(payload); + + // Show event data. + Events.render(payload); + + LateWritesSingleton.renderLateWrites(payload.lateWrites); + + // Show simple measurements + SimpleMeasurements.render(payload); +} + +window.addEventListener("load", onLoad); |