Differential Revision: https://phabricator.services.mozilla.com/D297875
1134 lines
33 KiB
JavaScript
1134 lines
33 KiB
JavaScript
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
"use strict";
|
|
|
|
// Docs: https://devdocs.io/d3~3/
|
|
Services.scriptloader.loadSubScript(
|
|
"chrome://global/content/third_party/d3/d3.js"
|
|
);
|
|
const d3 = this.d3;
|
|
|
|
const { AppConstants } = ChromeUtils.importESModule(
|
|
"resource://gre/modules/AppConstants.sys.mjs"
|
|
);
|
|
|
|
const METRIC_DATA = {};
|
|
let MAPPED_METRIC_DATA = [];
|
|
let FILTERED_METRIC_DATA = [];
|
|
let LIMITED_METRIC_DATA = [];
|
|
let LIMIT_OFFSET = 0;
|
|
let LIMIT_COUNT = 200;
|
|
let METRIC_DATA_INITIALIZED = false;
|
|
const INVALID_VALUE_REASONS = {
|
|
DUAL_LABELED_METRIC: 0,
|
|
UNKNOWN_METRIC: 1,
|
|
};
|
|
const SIMPLE_TYPES = {
|
|
Boolean: "Boolean",
|
|
String: "String",
|
|
StringList: "StringList",
|
|
Text: "Text",
|
|
Counter: "Counter",
|
|
};
|
|
const SELECTED_METRICS = [];
|
|
let DOCUMENT_BODY_SEL = undefined;
|
|
|
|
function updatePrefsAndDefines() {
|
|
let upload = Services.prefs.getBoolPref(
|
|
"datareporting.healthreport.uploadEnabled"
|
|
);
|
|
document.l10n.setAttributes(
|
|
document.querySelector("[data-l10n-id='about-glean-data-upload']"),
|
|
"about-glean-data-upload",
|
|
{
|
|
"data-upload-pref-value": upload,
|
|
}
|
|
);
|
|
let port = Services.prefs.getIntPref("telemetry.fog.test.localhost_port");
|
|
document.l10n.setAttributes(
|
|
document.querySelector("[data-l10n-id='about-glean-local-port']"),
|
|
"about-glean-local-port",
|
|
{
|
|
"local-port-pref-value": port,
|
|
}
|
|
);
|
|
document.l10n.setAttributes(
|
|
document.querySelector("[data-l10n-id='about-glean-glean-android']"),
|
|
"about-glean-glean-android",
|
|
{ "glean-android-define-value": AppConstants.MOZ_GLEAN_ANDROID }
|
|
);
|
|
document.l10n.setAttributes(
|
|
document.querySelector("[data-l10n-id='about-glean-moz-official']"),
|
|
"about-glean-moz-official",
|
|
{ "moz-official-define-value": AppConstants.MOZILLA_OFFICIAL }
|
|
);
|
|
|
|
// Knowing what we know, and copying logic from viaduct_uploader.rs,
|
|
// (which is documented in Preferences and Defines),
|
|
// tell the fine user whether and why upload is disabled.
|
|
let uploadMessageEl = document.getElementById("upload-status");
|
|
let uploadL10nId = "about-glean-upload-enabled";
|
|
if (!upload) {
|
|
uploadL10nId = "about-glean-upload-disabled";
|
|
} else if (port < 0 || (port == 0 && !AppConstants.MOZILLA_OFFICIAL)) {
|
|
uploadL10nId = "about-glean-upload-fake-enabled";
|
|
// This message has a link to the Glean Debug Ping Viewer in it.
|
|
// We must add the anchor element now so that Fluent can match it.
|
|
let a = document.createElement("a");
|
|
a.href = "https://debug-ping-preview.firebaseapp.com/";
|
|
a.setAttribute("data-l10n-name", "glean-debug-ping-viewer");
|
|
uploadMessageEl.appendChild(a);
|
|
} else if (port > 0) {
|
|
uploadL10nId = "about-glean-upload-enabled-local";
|
|
}
|
|
document.l10n.setAttributes(uploadMessageEl, uploadL10nId);
|
|
}
|
|
|
|
function camelToKebab(str) {
|
|
let out = "";
|
|
for (let i = 0; i < str.length; i++) {
|
|
let c = str.charAt(i);
|
|
if (c == c.toUpperCase()) {
|
|
out += "-";
|
|
c = c.toLowerCase();
|
|
}
|
|
out += c;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// I'm consciously omitting "deletion-request" until someone can come up with
|
|
// a use-case for sending it via about:glean.
|
|
const GLEAN_BUILTIN_PINGS = ["metrics", "events", "baseline"];
|
|
const NO_PING = "(don't submit any ping)";
|
|
|
|
function refillPingNames() {
|
|
const builtInGroup = document.getElementById("builtin-pings");
|
|
const customGroup = document.getElementById("custom-pings");
|
|
|
|
// Add built-in ping options to the dropdown.
|
|
GLEAN_BUILTIN_PINGS.forEach(id => {
|
|
const option = document.createElement("option");
|
|
option.value = id;
|
|
option.textContent = camelToKebab(id);
|
|
builtInGroup.appendChild(option);
|
|
});
|
|
|
|
// Add "(don't submit any ping)" as last built-in option in the dropdown.
|
|
const noPingOption = document.createElement("option");
|
|
noPingOption.value = NO_PING;
|
|
document.l10n.setAttributes(noPingOption, "about-glean-no-ping-label");
|
|
builtInGroup.appendChild(noPingOption);
|
|
|
|
// Add alpha sorted custom ping options to the dropdown.
|
|
Object.keys(GleanPings)
|
|
.map(id => ({ id, label: camelToKebab(id) }))
|
|
.sort((a, b) => a.label.localeCompare(b.label))
|
|
.forEach(({ label }) => {
|
|
const option = document.createElement("option");
|
|
option.textContent = label;
|
|
customGroup.appendChild(option);
|
|
});
|
|
}
|
|
|
|
// If there's been a previous tag, use it.
|
|
// If not, be _slightly_ clever and derive a default one from the profile dir.
|
|
function fillDebugTag() {
|
|
const DEBUG_TAG_PREF = "telemetry.fog.aboutGlean.debugTag";
|
|
let debugTag;
|
|
if (Services.prefs.prefHasUserValue(DEBUG_TAG_PREF)) {
|
|
debugTag = Services.prefs.getStringPref(DEBUG_TAG_PREF);
|
|
} else {
|
|
const debugTagPrefix = "about-glean-";
|
|
const profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile).path;
|
|
let charSum = Array.from(profileDir).reduce(
|
|
(prev, cur) => prev + cur.charCodeAt(0),
|
|
0
|
|
);
|
|
|
|
debugTag = debugTagPrefix + (charSum % 1000);
|
|
}
|
|
|
|
let tagInput = document.getElementById("tag-pings");
|
|
tagInput.value = debugTag;
|
|
const updateDebugTagValues = () => {
|
|
document.l10n.setAttributes(
|
|
document.querySelector(
|
|
"[data-l10n-id='about-glean-label-for-controls-submit']"
|
|
),
|
|
"about-glean-label-for-controls-submit",
|
|
{ "debug-tag": tagInput.value }
|
|
);
|
|
const GDPV_ROOT = "https://debug-ping-preview.firebaseapp.com/pings/";
|
|
let gdpvLink = document.querySelector(
|
|
"[data-l10n-name='gdpv-tagged-pings-link']"
|
|
);
|
|
gdpvLink.href = GDPV_ROOT + tagInput.value;
|
|
};
|
|
tagInput.addEventListener("change", () => {
|
|
Services.prefs.setStringPref(DEBUG_TAG_PREF, tagInput.value);
|
|
updateDebugTagValues();
|
|
});
|
|
updateDebugTagValues();
|
|
}
|
|
|
|
function showTab(button) {
|
|
let current_tab = document.querySelector(".active");
|
|
let category = button.getAttribute("id").substring("category-".length);
|
|
let content = document.getElementById(category);
|
|
if (current_tab == content) {
|
|
return;
|
|
}
|
|
current_tab.classList.remove("active");
|
|
current_tab.hidden = true;
|
|
content.classList.add("active");
|
|
content.hidden = false;
|
|
let current_button = document.querySelector("[selected=true]");
|
|
current_button.removeAttribute("selected");
|
|
button.setAttribute("selected", "true");
|
|
|
|
if (category == "metrics-table") {
|
|
// Init base level metric data
|
|
initializeMetricData();
|
|
|
|
const table = document.getElementById("metrics-table-instance");
|
|
table.removeAttribute("hidden");
|
|
|
|
// Map the metric data into a better defined type structure
|
|
MAPPED_METRIC_DATA = Object.entries(METRIC_DATA).flatMap(
|
|
([category, metrics]) =>
|
|
Object.entries(metrics).map(([name, metric]) => ({
|
|
category,
|
|
name,
|
|
fullName: `${category}.${name}`,
|
|
...metric,
|
|
}))
|
|
);
|
|
updateFilteredMetricData(
|
|
document.getElementById("filter-metrics").value.toLowerCase()
|
|
);
|
|
updateTable();
|
|
}
|
|
}
|
|
|
|
function handleRedesign() {
|
|
const categories = document.getElementById("categories");
|
|
const div = document.createElement("div");
|
|
div.id = "category-metrics-table";
|
|
div.className = "category";
|
|
div.setAttribute("role", "menuitem");
|
|
div.setAttribute("tabindex", 0);
|
|
const span = document.createElement("span");
|
|
span.className = "category-name";
|
|
span.setAttribute("data-l10n-id", "about-glean-category-metrics-table");
|
|
div.appendChild(span);
|
|
categories.appendChild(div);
|
|
|
|
/**
|
|
* Handle metric filter input.
|
|
*
|
|
* This uses a timeout to debounce the events down to 200ms.
|
|
* Instead of updating the DOM every time the input changes, it'll only update when the input hasn't changed in the last 200ms since it last changed.
|
|
*/
|
|
let inputTimeout = undefined;
|
|
document.getElementById("filter-metrics").addEventListener("input", e => {
|
|
clearTimeout(inputTimeout);
|
|
inputTimeout = setTimeout(() => {
|
|
updateFilteredMetricData(e.target.value.toLowerCase() ?? "");
|
|
}, 200);
|
|
});
|
|
|
|
// Handle loading all metric data
|
|
document.getElementById("load-all").addEventListener("click", () => {
|
|
MAPPED_METRIC_DATA.forEach(datum => {
|
|
updateDatum(datum);
|
|
});
|
|
updateTable();
|
|
});
|
|
}
|
|
|
|
function onLoad() {
|
|
let menu = document.getElementById("categories");
|
|
menu.addEventListener("click", function click(e) {
|
|
if (e.target && e.target.parentNode == menu) {
|
|
showTab(e.target);
|
|
}
|
|
});
|
|
menu.addEventListener("keydown", function keypress(e) {
|
|
if (
|
|
e.target &&
|
|
e.target.parentNode == menu &&
|
|
(e.key == " " || e.key == "Enter")
|
|
) {
|
|
showTab(e.target);
|
|
}
|
|
});
|
|
showTab(document.getElementById("category-about-glean"));
|
|
|
|
updatePrefsAndDefines();
|
|
refillPingNames();
|
|
fillDebugTag();
|
|
document.getElementById("controls-submit").addEventListener("click", () => {
|
|
let tag = document.getElementById("tag-pings").value;
|
|
let log = document.getElementById("log-pings").checked;
|
|
let ping = document.getElementById("ping-names").value;
|
|
let feedbackToast = document.getElementById("feedback");
|
|
|
|
Services.fog.setLogPings(log);
|
|
Services.fog.setTagPings(tag);
|
|
|
|
if (ping != NO_PING) {
|
|
Services.fog.sendPing(ping);
|
|
feedbackToast.setAttribute(
|
|
"data-l10n-id",
|
|
"about-glean-feedback-settings-and-ping"
|
|
);
|
|
} else {
|
|
feedbackToast.setAttribute(
|
|
"data-l10n-id",
|
|
"about-glean-feedback-settings-only"
|
|
);
|
|
}
|
|
|
|
feedbackToast.style.visibility = "visible";
|
|
setTimeout(() => {
|
|
feedbackToast.style.visibility = "hidden";
|
|
}, 3000);
|
|
});
|
|
|
|
handleRedesign();
|
|
|
|
DOCUMENT_BODY_SEL = d3.select(document.body);
|
|
|
|
document
|
|
.getElementById("metrics-table-settings-button")
|
|
.addEventListener("click", () => {
|
|
const settingsDiv = document.getElementById("metrics-table-settings");
|
|
if (settingsDiv.getAttribute("hidden")) {
|
|
settingsDiv.removeAttribute("hidden");
|
|
} else {
|
|
settingsDiv.setAttribute("hidden", true);
|
|
}
|
|
settingsChanged();
|
|
});
|
|
initMetricsSettings();
|
|
|
|
document.getElementById("export-data").addEventListener("click", () => {
|
|
exportData();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Initializes the base level metric data.
|
|
*
|
|
* Should only be able to be called once.
|
|
*/
|
|
function initializeMetricData() {
|
|
if (METRIC_DATA_INITIALIZED) {
|
|
return;
|
|
}
|
|
for (let [category, metrics] of Object.entries(Glean)) {
|
|
for (let [metricName, metric] of Object.entries(metrics)) {
|
|
// Trim "Glean" from the constructor names (e.g. "GleanBoolean" -> "Boolean").
|
|
let constructorName = metric.constructor.name.replace("Glean", "");
|
|
// For labeled metrics, get their submetrics' constructor names and append it
|
|
if (constructorName == "Labeled") {
|
|
constructorName += metric.__other__.constructor.name.replace(
|
|
"Glean",
|
|
""
|
|
);
|
|
}
|
|
if (!METRIC_DATA[category]) {
|
|
METRIC_DATA[category] = {};
|
|
}
|
|
METRIC_DATA[category][metricName] = {
|
|
type: constructorName,
|
|
value: undefined,
|
|
metric,
|
|
};
|
|
}
|
|
}
|
|
METRIC_DATA_INITIALIZED = true;
|
|
}
|
|
|
|
function updateButtonsSelection(selection) {
|
|
selection.attr("data-l10n-id", d =>
|
|
d.watching ? "about-glean-button-unwatch" : "about-glean-button-watch"
|
|
);
|
|
}
|
|
|
|
function createOrUpdateLabeledHistogram(selection, datum) {
|
|
const keysAndValues = Object.entries(datum.value || {}).map(
|
|
([key, value]) => [
|
|
key,
|
|
{
|
|
fullName: datum.fullName + `.${key}`,
|
|
value,
|
|
},
|
|
]
|
|
);
|
|
|
|
selection.selectAll("*").remove();
|
|
if (keysAndValues.length === 0) {
|
|
selection
|
|
.append("p")
|
|
.attr("data-l10n-id", "about-glean-no-data-to-display");
|
|
return;
|
|
}
|
|
for (let entry of keysAndValues.toSorted((a, b) =>
|
|
d3.ascending(a[1].fullName, b[1].fullName)
|
|
)) {
|
|
const div = selection
|
|
.append("div")
|
|
.classed({ "keyed-chart": true })
|
|
.attr("id", `${entry[0]}-container`)
|
|
.attr("data-d3-key", entry[0]);
|
|
div.append("h2").text(entry[0]);
|
|
const svgContainer = div.append("div");
|
|
createOrUpdateHistogram(svgContainer, entry[1]);
|
|
}
|
|
}
|
|
|
|
function createOrUpdateHistogram(selection, datum) {
|
|
const values = Object.entries(datum.value?.values || {}).map((d, i) => [
|
|
...d,
|
|
i,
|
|
]);
|
|
|
|
if (!values || values.length === 0) {
|
|
selection.select("p")?.remove();
|
|
selection
|
|
.append("p")
|
|
.attr("data-l10n-id", "about-glean-no-data-to-display");
|
|
selection.select("svg")?.remove();
|
|
return;
|
|
}
|
|
selection.select("p")?.remove();
|
|
|
|
const chartSettings = {
|
|
boxPadding: 5,
|
|
chartMax: 150,
|
|
leftPadding: 20,
|
|
chartPadding: 50,
|
|
scaledMax: 110,
|
|
...metricsTableSettings.histograms,
|
|
};
|
|
const max = values.map(d => d[1]).sort((a, b) => b - a)[0],
|
|
keyMax = values.map(d => d[0]).sort((a, b) => b - a)[0],
|
|
boxWidth = Math.max(`${Math.max(max, keyMax)}`.length * 10, 30),
|
|
denom = max / chartSettings.scaledMax;
|
|
|
|
let hist = selection.select(`svg[data-d3-datum='${datum.fullName}']`);
|
|
if (hist.empty()) {
|
|
hist = selection
|
|
.append("svg")
|
|
.classed({ histogram: true })
|
|
.attr("data-d3-datum", datum.fullName)
|
|
.attr(
|
|
"width",
|
|
values.length * (boxWidth + chartSettings.boxPadding) +
|
|
chartSettings.chartPadding +
|
|
chartSettings.leftPadding
|
|
)
|
|
.attr("height", chartSettings.chartMax + chartSettings.chartPadding);
|
|
}
|
|
|
|
let boxesContainer = hist.select("g.boxes");
|
|
if (boxesContainer.empty()) {
|
|
boxesContainer = hist.append("g").classed({ boxes: true });
|
|
}
|
|
|
|
const boxes = boxesContainer.selectAll("g").data(values, d => d);
|
|
|
|
const newBoxes = boxes.enter().append("g").attr("tabindex", 0);
|
|
newBoxes.append("rect").attr("width", boxWidth);
|
|
newBoxes.append("text").attr("data-d3-role", "y");
|
|
newBoxes.append("text").attr("data-d3-role", "x");
|
|
|
|
const xFn = index =>
|
|
boxWidth * index +
|
|
chartSettings.boxPadding * index +
|
|
chartSettings.leftPadding;
|
|
const yFn = yv =>
|
|
Math.abs(Math.max(yv / denom, 1) - chartSettings.scaledMax) +
|
|
(chartSettings.chartMax - chartSettings.scaledMax);
|
|
|
|
boxes
|
|
.selectAll("rect")
|
|
.attr("height", d => Math.max(d[1] / denom, 1))
|
|
.attr("x", (_, __, i) => xFn(i))
|
|
.attr("y", d => yFn(d[1]));
|
|
boxes
|
|
.selectAll("text[data-d3-role=y]")
|
|
.attr("x", (_, __, i) => xFn(i))
|
|
.attr("y", d => yFn(d[1]) - 5)
|
|
.text(d => d[1]);
|
|
boxes
|
|
.selectAll("text[data-d3-role=x]")
|
|
.attr("x", d => xFn(d[2]))
|
|
.attr("y", chartSettings.chartMax + 20)
|
|
.text(d => d[0]);
|
|
|
|
function focusStart(_) {
|
|
this.classList.add("hovered");
|
|
}
|
|
|
|
function focusEnd(_) {
|
|
this.classList.remove("hovered");
|
|
}
|
|
|
|
boxes
|
|
.attr("data-d3-box", d => d[0])
|
|
.on("focusin", focusStart)
|
|
.on("mouseover", focusStart)
|
|
.on("focusout", focusEnd)
|
|
.on("mouseout", focusEnd);
|
|
|
|
boxes.exit().remove();
|
|
}
|
|
|
|
function createOrUpdateEventChart(selection, datum) {
|
|
const values = (datum.value || []).map((d, i) => ({
|
|
...d,
|
|
index: i,
|
|
fullName: datum.fullName,
|
|
}));
|
|
|
|
if (!values || values.length === 0) {
|
|
selection.select("p")?.remove();
|
|
selection
|
|
.append("p")
|
|
.attr("data-l10n-id", "about-glean-no-data-to-display");
|
|
selection.select("svg")?.remove();
|
|
return;
|
|
}
|
|
selection.select("p")?.remove();
|
|
|
|
const chartSettings = {
|
|
height: 75,
|
|
width: 500,
|
|
chartPadding: 50,
|
|
circleRadius: 6,
|
|
verticalLineXOffset: 10,
|
|
verticalLineYOffset: 10,
|
|
...metricsTableSettings.timelines,
|
|
};
|
|
const max = values.map(d => d.timestamp).sort((a, b) => b - a)[0],
|
|
min = values.map(d => d.timestamp).sort((a, b) => a - b)[0];
|
|
|
|
let diagram = selection.select(`svg[data-d3-datum='${datum.fullName}']`);
|
|
if (diagram.empty()) {
|
|
diagram = selection
|
|
.append("svg")
|
|
.attr("data-d3-datum", datum.fullName)
|
|
.classed({ timeline: true });
|
|
}
|
|
diagram
|
|
.attr("width", chartSettings.width)
|
|
.attr("height", chartSettings.height);
|
|
|
|
let lineAcross = diagram.select("line[data-d3-role='across']");
|
|
if (lineAcross.empty()) {
|
|
lineAcross = diagram.append("line").attr("data-d3-role", "across");
|
|
}
|
|
lineAcross
|
|
.attr("x1", chartSettings.chartPadding)
|
|
.attr("y1", chartSettings.height / 2)
|
|
.attr("x2", chartSettings.width - chartSettings.chartPadding)
|
|
.attr("y2", chartSettings.height / 2);
|
|
|
|
let leftLineThrough = diagram.select("line[data-d3-role='left-through']");
|
|
if (leftLineThrough.empty()) {
|
|
leftLineThrough = diagram
|
|
.append("line")
|
|
.attr("data-d3-role", "left-through");
|
|
}
|
|
leftLineThrough
|
|
.attr("x1", chartSettings.chartPadding + chartSettings.verticalLineXOffset)
|
|
.attr("y1", chartSettings.height / 2 - chartSettings.verticalLineYOffset)
|
|
.attr("x2", chartSettings.chartPadding + chartSettings.verticalLineXOffset)
|
|
.attr("y2", chartSettings.height / 2 + chartSettings.verticalLineYOffset);
|
|
|
|
let rightLineThrough = diagram.select("line[data-d3-role='right-through']");
|
|
if (rightLineThrough.empty()) {
|
|
rightLineThrough = diagram
|
|
.append("line")
|
|
.attr("data-d3-role", "right-through");
|
|
}
|
|
rightLineThrough
|
|
.attr(
|
|
"x1",
|
|
chartSettings.width -
|
|
chartSettings.chartPadding -
|
|
chartSettings.verticalLineXOffset
|
|
)
|
|
.attr("y1", chartSettings.height / 2 - chartSettings.verticalLineYOffset)
|
|
.attr(
|
|
"x2",
|
|
chartSettings.width -
|
|
chartSettings.chartPadding -
|
|
chartSettings.verticalLineXOffset
|
|
)
|
|
.attr("y2", chartSettings.height / 2 + chartSettings.verticalLineYOffset);
|
|
|
|
let code = selection.select("pre");
|
|
if (code.empty()) {
|
|
code = selection.append("pre").classed({ withChart: true }).append("code");
|
|
} else {
|
|
code = code.select("code");
|
|
}
|
|
|
|
const xFn = d3.scale
|
|
.linear()
|
|
.domain([min, max])
|
|
.range([
|
|
chartSettings.verticalLineXOffset + chartSettings.chartPadding,
|
|
chartSettings.width -
|
|
chartSettings.chartPadding -
|
|
chartSettings.verticalLineXOffset,
|
|
]);
|
|
|
|
let eventsContainer = diagram.select("g.events");
|
|
if (eventsContainer.empty()) {
|
|
eventsContainer = diagram.append("g").classed({ events: true });
|
|
}
|
|
|
|
const events = eventsContainer
|
|
.selectAll("g.event")
|
|
.data(values, d => `${d.fullName}-${d.index}-${d.timestamp}`);
|
|
|
|
const newEvents = events
|
|
.enter()
|
|
.append("g")
|
|
.classed({ event: true })
|
|
.attr("tabindex", 0);
|
|
|
|
newEvents.append("circle");
|
|
|
|
function focusStart(_) {
|
|
this.classList.add("hovered");
|
|
}
|
|
|
|
function focusEnd(_) {
|
|
this.classList.remove("hovered");
|
|
}
|
|
|
|
function select(_) {
|
|
const dataPoint = this.__data__;
|
|
code.text(prettyPrint(dataPoint.extra));
|
|
|
|
document.querySelectorAll("g.event.selected").forEach(el => {
|
|
el.classList.remove("selected");
|
|
el.querySelector("text")?.remove();
|
|
});
|
|
this.classList.add("selected");
|
|
|
|
const text = this.appendChild(
|
|
document.createElementNS("http://www.w3.org/2000/svg", "text")
|
|
);
|
|
text.setAttribute("y", chartSettings.height / 2 + 25);
|
|
text.setAttribute(
|
|
"x",
|
|
xFn(dataPoint.timestamp) - `${dataPoint.timestamp}`.length * 4.5
|
|
);
|
|
text.textContent = dataPoint.timestamp;
|
|
}
|
|
|
|
events.attr("data-d3-datum", d => `${d.fullName}-${d.index}-${d.timestamp}`);
|
|
|
|
events
|
|
.selectAll("circle")
|
|
.attr("cy", chartSettings.height / 2)
|
|
.attr("cx", d => xFn(d.timestamp))
|
|
.attr("r", chartSettings.circleRadius);
|
|
|
|
events
|
|
.on("focusin", select)
|
|
.on("mouseover", focusStart)
|
|
.on("focusout", focusEnd)
|
|
.on("mouseout", focusEnd);
|
|
|
|
events.exit().remove();
|
|
}
|
|
|
|
const METRICS_TABLE_SETTINGS_KEY = "about-glean-metrics-table-settings";
|
|
/**
|
|
* When adding new fields to the metrics table settings,
|
|
* a corresponding element matching the query selector
|
|
* `[data-form-control=<key>]` must be present in the DOM.
|
|
*/
|
|
const metricsTableSettings = {
|
|
hideEmptyValueRows: false,
|
|
histograms: {
|
|
boxPadding: 5,
|
|
chartMax: 150,
|
|
leftPadding: 20,
|
|
chartPadding: 50,
|
|
scaledMax: 110,
|
|
},
|
|
timelines: {
|
|
height: 75,
|
|
width: 500,
|
|
chartPadding: 50,
|
|
circleRadius: 6,
|
|
verticalLineXOffset: 10,
|
|
verticalLineYOffset: 10,
|
|
},
|
|
};
|
|
|
|
function initMetricsSettings() {
|
|
const handleSetting = (obj, key, parent) => {
|
|
let element = parent.querySelector(`[data-form-control='${key}']`);
|
|
let valueFn = e => e.target.value;
|
|
if (!element && typeof obj[key] !== "object") {
|
|
console.error(
|
|
new Error(
|
|
`Unable to find form control with key '${key}' in the parent element`
|
|
),
|
|
parent
|
|
);
|
|
return;
|
|
}
|
|
switch (typeof obj[key]) {
|
|
case "boolean":
|
|
valueFn = e => e.target.checked;
|
|
obj[key] = valueFn({ target: element });
|
|
break;
|
|
case "object":
|
|
element = parent.querySelector(`[data-form-group='${key}']`);
|
|
if (!element) {
|
|
console.error(
|
|
new Error(
|
|
`Unable to find form control with key '${key}' in the parent element`
|
|
),
|
|
parent
|
|
);
|
|
return;
|
|
}
|
|
for (const subKey of Object.keys(obj[key])) {
|
|
handleSetting(obj[key], subKey, element);
|
|
}
|
|
break;
|
|
case "number":
|
|
valueFn = e => parseInt(e.target.value);
|
|
// eslint-disable-next-line no-fallthrough
|
|
default:
|
|
if (element.type !== typeof obj[key]) {
|
|
console.warn(
|
|
new Error(
|
|
`Form control input type does not match JavaScript value type ${typeof obj[key]}`
|
|
)
|
|
);
|
|
}
|
|
if (valueFn({ target: element })) {
|
|
obj[key] = valueFn({ target: element });
|
|
} else {
|
|
element.value = obj[key];
|
|
}
|
|
}
|
|
element.addEventListener("input", handleSettingChange(obj, valueFn));
|
|
};
|
|
|
|
for (const key of Object.keys(metricsTableSettings)) {
|
|
handleSetting(
|
|
metricsTableSettings,
|
|
key,
|
|
document.getElementById("metrics-table-settings")
|
|
);
|
|
}
|
|
}
|
|
|
|
function handleSettingChange(obj, valueFn) {
|
|
return e => {
|
|
obj[e.target.getAttribute("data-form-control")] = valueFn(e);
|
|
settingsChanged();
|
|
};
|
|
}
|
|
|
|
function settingsChanged() {
|
|
createOrUpdateHistogram(
|
|
d3.select("#metrics-table-settings-histogram-example"),
|
|
{
|
|
fullName: "histogram-example",
|
|
value: {
|
|
values: {
|
|
0: 1,
|
|
1: 5,
|
|
2: 4,
|
|
3: 0,
|
|
},
|
|
},
|
|
}
|
|
);
|
|
|
|
createOrUpdateEventChart(
|
|
d3.select("#metrics-table-settings-timeline-example"),
|
|
{
|
|
fullName: "timeline-example",
|
|
value: [
|
|
{
|
|
timestamp: 0,
|
|
extra: {
|
|
value: 1,
|
|
},
|
|
},
|
|
{
|
|
timestamp: 1,
|
|
extra: {
|
|
value: 2,
|
|
},
|
|
},
|
|
{
|
|
timestamp: 4,
|
|
extra: {
|
|
value: 3,
|
|
},
|
|
},
|
|
{
|
|
timestamp: 8,
|
|
extra: {
|
|
value: 4,
|
|
},
|
|
},
|
|
],
|
|
}
|
|
);
|
|
|
|
updateTable();
|
|
}
|
|
|
|
function updateValueSelection(selection) {
|
|
// Set the `data-l10n-id` attribute to the appropriate warning if the value is invalid, otherwise
|
|
// unset it by returning `null`.
|
|
selection
|
|
?.attr("data-l10n-id", d => {
|
|
switch (d.invalidValue) {
|
|
case INVALID_VALUE_REASONS.DUAL_LABELED_METRIC:
|
|
return "about-glean-dual-labeled-metric-warning";
|
|
case INVALID_VALUE_REASONS.UNKNOWN_METRIC:
|
|
return "about-glean-unknown-metric-type-warning";
|
|
default:
|
|
return null;
|
|
}
|
|
})
|
|
?.each(function (datum) {
|
|
if (datum.loaded) {
|
|
let codeSelection = d3.select(this).select("pre>code");
|
|
switch (datum.type) {
|
|
case "Event":
|
|
createOrUpdateEventChart(d3.select(this), datum);
|
|
break;
|
|
case "CustomDistribution":
|
|
case "MemoryDistribution":
|
|
case "TimingDistribution":
|
|
createOrUpdateHistogram(d3.select(this), datum);
|
|
break;
|
|
case "LabeledCustomDistribution":
|
|
case "LabeledMemoryDistribution":
|
|
case "LabeledTimingDistribution":
|
|
createOrUpdateLabeledHistogram(d3.select(this), datum);
|
|
break;
|
|
default:
|
|
if (codeSelection.empty()) {
|
|
codeSelection = d3.select(this).append("pre").append("code");
|
|
}
|
|
codeSelection.text(prettyPrint(datum.value));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Updates a datum object with its value from `testGetValue`.
|
|
*
|
|
* @param {*} datum the datum object to update
|
|
* @param {*} update update the table after updating the datum (defaults to `true`)
|
|
*/
|
|
function updateDatum(datum) {
|
|
if (typeof datum.metric.testGetValue == "function") {
|
|
try {
|
|
datum.value = datum.metric.testGetValue();
|
|
datum.error = undefined;
|
|
} catch (e) {
|
|
datum.error = e;
|
|
}
|
|
datum.loaded = true;
|
|
datum.invalidValue = undefined;
|
|
} else if (datum.type.includes("DualLabeled")) {
|
|
datum.invalidValue = INVALID_VALUE_REASONS.DUAL_LABELED_METRIC;
|
|
} else {
|
|
datum.invalidValue = INVALID_VALUE_REASONS.UNKNOWN_METRIC;
|
|
}
|
|
|
|
updateValueSelection(
|
|
DOCUMENT_BODY_SEL.select(
|
|
`tr[data-d3-row="${datum.fullName}"]>td[data-d3-cell=value]`
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Prettifies a JSON value to make it render more nicely in the table.
|
|
*
|
|
* @param {*} jsonValue the JSON value to prettify
|
|
* @returns a string containing the prettified JSON value in a pre+code
|
|
*/
|
|
function prettyPrint(jsonValue) {
|
|
if (typeof jsonValue == "object") {
|
|
jsonValue = Object.keys(jsonValue ?? {})
|
|
.sort()
|
|
.reduce((obj, key) => {
|
|
obj[key] = jsonValue[key];
|
|
return obj;
|
|
}, {});
|
|
}
|
|
// from devtools/client/jsonview/json-viewer.mjs
|
|
const pretty = JSON.stringify(
|
|
jsonValue,
|
|
(key, value) => {
|
|
if (value?.type === Symbol("JSON_NUMBER")) {
|
|
return JSON.rawJSON(value.source);
|
|
}
|
|
|
|
// By default, -0 will be stringified as `0`, so we need to handle it
|
|
if (Object.is(value, -0)) {
|
|
return JSON.rawJSON("-0");
|
|
}
|
|
|
|
return value;
|
|
},
|
|
" "
|
|
);
|
|
return pretty;
|
|
}
|
|
|
|
function getDocsURL(datum) {
|
|
const upperRegExp = /[A-Z]/;
|
|
const app = "firefox_desktop";
|
|
let category = datum.category;
|
|
let index = category.search(upperRegExp);
|
|
while (index != -1) {
|
|
category = category.replace(
|
|
upperRegExp,
|
|
"_" + category[index].toLowerCase()
|
|
);
|
|
index = category.search(upperRegExp);
|
|
}
|
|
|
|
let name = datum.name;
|
|
index = name.search(upperRegExp);
|
|
while (index != -1) {
|
|
name = name.replace(upperRegExp, "_" + name[index].toLowerCase());
|
|
index = name.search(upperRegExp);
|
|
}
|
|
return `https://dictionary.telemetry.mozilla.org/apps/${app}/metrics/${category}_${name}`;
|
|
}
|
|
|
|
/**
|
|
* Updates the `about:glean` metrics table body based on the data points in FILTERED_METRIC_DATA.
|
|
*/
|
|
function updateTable() {
|
|
LIMITED_METRIC_DATA = FILTERED_METRIC_DATA.toSorted((a, b) =>
|
|
d3.ascending(a.fullName, b.fullName)
|
|
)
|
|
// Filter out rows whose datum elements have either a) been loaded and have a value, or b) have not yet been loaded.
|
|
.filter(d =>
|
|
metricsTableSettings.hideEmptyValueRows
|
|
? (d.value !== undefined && d.value !== null) || !d.loaded
|
|
: true
|
|
)
|
|
// Filter down to only the datum elements whose indexes fall in the limit offset+count.
|
|
.filter((_, i) => i >= LIMIT_OFFSET && i < LIMIT_COUNT + LIMIT_OFFSET);
|
|
|
|
// Let's talk about d3.js
|
|
// `d3.select` is a rough equivalent to `document.querySelector`, but the resulting object(s) are things d3 knows how to manipulate.
|
|
const tbody = DOCUMENT_BODY_SEL.select("#metrics-table-body");
|
|
// Select all the `tr` elements within the previously selected `tbody` element.
|
|
const rows = tbody
|
|
.selectAll("tr")
|
|
// Set the data for the `tr` elements to be the FILTERED_METRIC_DATA, keyed off the data element's full name
|
|
.data(LIMITED_METRIC_DATA, d => d.fullName);
|
|
|
|
// `.enter()` means this section determines how we handle new data elements in the array.
|
|
// We class them and insert the appropriate data cells
|
|
let newRows = rows
|
|
.enter()
|
|
.append("tr")
|
|
.attr("data-d3-row", d => d.fullName)
|
|
.classed({ "metric-row": true });
|
|
|
|
const actions = newRows
|
|
.append("td")
|
|
.attr("data-d3-cell", "actions")
|
|
.append("div");
|
|
// Set the HTML content for the `category` and `name` cells, and store the name cells in-scope so we can
|
|
// append our buttons to them.
|
|
newRows
|
|
.append("td")
|
|
.attr("data-d3-cell", "category")
|
|
.append("pre")
|
|
.text(d => d.category);
|
|
newRows
|
|
.append("td")
|
|
.attr("data-d3-cell", "name")
|
|
.append("pre")
|
|
.text(d => d.name);
|
|
// Handle displaying the metric type.
|
|
newRows
|
|
.append("td")
|
|
.attr("data-d3-cell", "type")
|
|
.text(d => d.type);
|
|
newRows.append("td").attr("data-d3-cell", "value");
|
|
|
|
actions
|
|
.append("button")
|
|
.attr("data-l10n-id", "about-glean-button-load-value")
|
|
.on("click", datum => updateDatum(datum));
|
|
actions
|
|
.append("button")
|
|
.attr("data-l10n-id", "about-glean-button-dictionary-link")
|
|
.classed({ primary: true })
|
|
// On click, rewrite the metric category+name to snake-case, so we can link to the Glean dictionary.
|
|
// TODO: add canonical_name field to metrics https://bugzilla.mozilla.org/show_bug.cgi?id=1983630
|
|
.on("click", datum => {
|
|
const url = getDocsURL(datum);
|
|
window.open(url, "_blank").focus();
|
|
});
|
|
|
|
// Since `.enter` has been called on `rows` and we've handled new data points, everything
|
|
// that touches `rows` from here on out will affect ALL elements, old and new.
|
|
|
|
updateButtonsSelection(
|
|
rows.selectAll("td[data-d3-cell=actions] button[data-d3-button=watch]")
|
|
);
|
|
// Handle the metric's value.
|
|
updateValueSelection(rows.selectAll("td[data-d3-cell=value]"));
|
|
|
|
// Sort the `tr` elements by full metric category+name.
|
|
rows.sort((a, b) => d3.ascending(a.fullName, b.fullName));
|
|
|
|
// Handle exiting data points by removing their elements.
|
|
rows.exit().remove();
|
|
|
|
// Manually trigger translation on the table, as DOM updates after the first application of the `data-l10n-id` will not translate.
|
|
document.l10n.translateFragment(
|
|
document.querySelector("#metrics-table-body")
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Updates the FILTERED_METRIC_DATA value based on the provided `searchString`.
|
|
*
|
|
* @param {*} searchString the string by which the metric data will be filtered
|
|
*/
|
|
function updateFilteredMetricData(searchString) {
|
|
if (!searchString) {
|
|
FILTERED_METRIC_DATA = MAPPED_METRIC_DATA;
|
|
} else {
|
|
const simpleTypeValueSearch = datum => {
|
|
if (!Object.values(SIMPLE_TYPES).includes(datum.type)) {
|
|
return false;
|
|
}
|
|
switch (datum.type) {
|
|
case SIMPLE_TYPES.Boolean:
|
|
if (searchString == "true") {
|
|
return datum.value === true;
|
|
} else if (searchString == "false") {
|
|
return datum.value === false;
|
|
}
|
|
return false;
|
|
default:
|
|
return false;
|
|
}
|
|
};
|
|
FILTERED_METRIC_DATA = MAPPED_METRIC_DATA.filter(
|
|
datum =>
|
|
datum.category.toLowerCase().includes(searchString) ||
|
|
datum.name.toLowerCase().includes(searchString) ||
|
|
datum.type.toLowerCase().includes(searchString) ||
|
|
simpleTypeValueSearch(datum)
|
|
);
|
|
}
|
|
|
|
if (FILTERED_METRIC_DATA.length > LIMIT_COUNT + LIMIT_OFFSET) {
|
|
const table = document.getElementById("metrics-table-instance");
|
|
let scrollTimeout,
|
|
scrollTimeoutIsCleared = true;
|
|
table.addEventListener("scroll", el => {
|
|
if (scrollTimeoutIsCleared) {
|
|
scrollTimeout = setTimeout(
|
|
({ target }) => {
|
|
clearTimeout(scrollTimeout);
|
|
scrollTimeoutIsCleared = true;
|
|
let changes = false;
|
|
if (target.scrollTop < 1500) {
|
|
if (LIMIT_COUNT >= 500 && LIMIT_OFFSET > 0) {
|
|
LIMIT_OFFSET = LIMIT_OFFSET - 100 < 0 ? 0 : LIMIT_OFFSET - 100;
|
|
changes = true;
|
|
}
|
|
} else if (target.scrollHeight - target.scrollTop < 1500) {
|
|
if (LIMIT_COUNT >= 500) {
|
|
if (
|
|
LIMIT_OFFSET + LIMIT_COUNT + 100 >
|
|
FILTERED_METRIC_DATA.length
|
|
) {
|
|
LIMIT_OFFSET = FILTERED_METRIC_DATA.length - LIMIT_COUNT;
|
|
} else if (
|
|
LIMIT_OFFSET + LIMIT_COUNT <
|
|
FILTERED_METRIC_DATA.length - 100
|
|
) {
|
|
LIMIT_OFFSET += 100;
|
|
}
|
|
} else {
|
|
LIMIT_COUNT += 100;
|
|
}
|
|
changes = true;
|
|
}
|
|
if (changes) {
|
|
updateTable();
|
|
}
|
|
},
|
|
10,
|
|
el
|
|
);
|
|
scrollTimeoutIsCleared = false;
|
|
}
|
|
});
|
|
} else {
|
|
LIMIT_COUNT = 200;
|
|
LIMIT_OFFSET = 0;
|
|
}
|
|
|
|
updateTable();
|
|
}
|
|
|
|
function exportData() {
|
|
const data = MAPPED_METRIC_DATA.toSorted((a, b) =>
|
|
d3.ascending(a.fullName, b.fullName)
|
|
).reduce(
|
|
(accumulator, datum) => [
|
|
...accumulator,
|
|
{
|
|
name: `${datum.fullName}`,
|
|
docs: getDocsURL(datum),
|
|
value: datum.value,
|
|
},
|
|
],
|
|
[]
|
|
);
|
|
const href = `data:text/json;charset=utf-8,${encodeURIComponent(JSON.stringify(data))}`;
|
|
const anchor = document.createElement("a");
|
|
anchor.setAttribute("href", href);
|
|
anchor.setAttribute(
|
|
"download",
|
|
`about-glean-export-${new Date().toJSON()}.json`
|
|
);
|
|
document.body.appendChild(anchor);
|
|
anchor.click();
|
|
anchor.remove();
|
|
}
|
|
|
|
window.addEventListener("load", onLoad);
|