1
0
Fork 0
firefox/devtools/client/shared/telemetry.js
Daniel Baumann 5e9a113729
Adding upstream version 140.0.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
2025-06-25 09:37:52 +02:00

535 lines
17 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/. */
/**
* This is the telemetry module to report metrics for tools.
*
* Comprehensive documentation is in docs/frontend/telemetry.md
*/
"use strict";
const {
getNthPathExcluding,
} = require("resource://devtools/shared/platform/stack.js");
const { TelemetryEnvironment } = ChromeUtils.importESModule(
"resource://gre/modules/TelemetryEnvironment.sys.mjs"
);
const WeakMapMap = require("resource://devtools/client/shared/WeakMapMap.js");
// Object to be shared among all instances.
const PENDING_EVENT_PROPERTIES = new WeakMapMap();
const PENDING_EVENTS = new WeakMapMap();
/**
* Instantiate a new Telemetry helper class.
*
* @param {Object} options [optional]
* @param {Boolean} options.useSessionId [optional]
* If true, this instance will automatically generate a unique "sessionId"
* and use it to aggregate all records against this unique session.
* This helps aggregate all data coming from a single toolbox instance for ex.
*/
class Telemetry {
constructor({ useSessionId = false } = {}) {
// Note that native telemetry APIs expect a string
this.sessionId = String(
useSessionId ? parseInt(this.msSinceProcessStart(), 10) : -1
);
// Bind pretty much all functions so that callers do not need to.
this.msSystemNow = this.msSystemNow.bind(this);
this.getKeyedHistogramById = this.getKeyedHistogramById.bind(this);
this.recordEvent = this.recordEvent.bind(this);
this.preparePendingEvent = this.preparePendingEvent.bind(this);
this.addEventProperty = this.addEventProperty.bind(this);
this.addEventProperties = this.addEventProperties.bind(this);
this.toolOpened = this.toolOpened.bind(this);
this.toolClosed = this.toolClosed.bind(this);
}
get osNameAndVersion() {
const osInfo = TelemetryEnvironment.currentEnvironment.system.os;
if (!osInfo) {
return "Unknown OS";
}
let osVersion = `${osInfo.name} ${osInfo.version}`;
if (osInfo.windowsBuildNumber) {
osVersion += `.${osInfo.windowsBuildNumber}`;
}
return osVersion;
}
/**
* Time since the system wide epoch. This is not a monotonic timer but
* can be used across process boundaries.
*/
msSystemNow() {
return Services.telemetry.msSystemNow();
}
/**
* The number of milliseconds since process start using monotonic
* timestamps (unaffected by system clock changes).
*/
msSinceProcessStart() {
return Services.telemetry.msSinceProcessStart();
}
/**
* Get a keyed histogram.
*
* @param {String} histogramId
* Histogram in which the data is to be stored.
*/
getKeyedHistogramById(histogramId) {
let histogram = null;
if (histogramId) {
try {
histogram = Services.telemetry.getKeyedHistogramById(histogramId);
} catch (e) {
dump(
`Warning: An attempt was made to write to the ${histogramId} ` +
`histogram, which is not defined in Histograms.json\n` +
`CALLER: ${getCaller()}`
);
}
}
return (
histogram || {
add: () => {},
}
);
}
/**
* Telemetry events often need to make use of a number of properties from
* completely different codepaths. To make this possible we create a
* "pending event" along with an array of property names that we need to wait
* for before sending the event.
*
* As each property is received via addEventProperty() we check if all
* properties have been received. Once they have all been received we send the
* telemetry event.
*
* @param {Object} obj
* The telemetry event or ping is associated with this object, meaning
* that multiple events or pings for the same histogram may be run
* concurrently, as long as they are associated with different objects.
* @param {String} method
* The telemetry event method (describes the type of event that
* occurred e.g. "open")
* @param {String} object
* The telemetry event object name (the name of the object the event
* occurred on) e.g. "tools" or "setting"
* @param {String|null} value
* The telemetry event value (a user defined value, providing context
* for the event) e.g. "console"
* @param {Array} expected
* An array of the properties needed before sending the telemetry
* event e.g.
* [
* "host",
* "width"
* ]
*/
preparePendingEvent(obj, method, object, value, expected = []) {
const sig = `${method},${object},${value}`;
if (expected.length === 0) {
throw new Error(
`preparePendingEvent() was called without any expected ` +
`properties.\n` +
`CALLER: ${getCaller()}`
);
}
const data = {
extra: {},
expected: new Set(expected),
};
PENDING_EVENTS.set(obj, sig, data);
const props = PENDING_EVENT_PROPERTIES.get(obj, sig);
if (props) {
for (const [name, val] of Object.entries(props)) {
this.addEventProperty(obj, method, object, value, name, val);
}
PENDING_EVENT_PROPERTIES.delete(obj, sig);
}
}
/**
* Adds an expected property for either a current or future pending event.
* This means that if preparePendingEvent() is called before or after sending
* the event properties they will automatically added to the event.
*
* @param {Object} obj
* The telemetry event or ping is associated with this object, meaning
* that multiple events or pings for the same histogram may be run
* concurrently, as long as they are associated with different objects.
* @param {String} method
* The telemetry event method (describes the type of event that
* occurred e.g. "open")
* @param {String} object
* The telemetry event object name (the name of the object the event
* occurred on) e.g. "tools" or "setting"
* @param {String|null} value
* The telemetry event value (a user defined value, providing context
* for the event) e.g. "console"
* @param {String} pendingPropName
* The pending property name
* @param {String} pendingPropValue
* The pending property value
*/
addEventProperty(
obj,
method,
object,
value,
pendingPropName,
pendingPropValue
) {
const sig = `${method},${object},${value}`;
const events = PENDING_EVENTS.get(obj, sig);
// If the pending event has not been created add the property to the pending
// list.
if (!events) {
const props = PENDING_EVENT_PROPERTIES.get(obj, sig);
if (props) {
props[pendingPropName] = pendingPropValue;
} else {
PENDING_EVENT_PROPERTIES.set(obj, sig, {
[pendingPropName]: pendingPropValue,
});
}
return;
}
const { expected, extra } = events;
if (expected.has(pendingPropName)) {
extra[pendingPropName] = pendingPropValue;
if (expected.size === Object.keys(extra).length) {
this._sendPendingEvent(obj, method, object, value);
}
} else {
// The property was not expected, warn and bail.
throw new Error(
`An attempt was made to add the unexpected property ` +
`"${pendingPropName}" to a telemetry event with the ` +
`signature "${sig}"\n` +
`CALLER: ${getCaller()}`
);
}
}
/**
* Adds expected properties for either a current or future pending event.
* This means that if preparePendingEvent() is called before or after sending
* the event properties they will automatically added to the event.
*
* @param {Object} obj
* The telemetry event or ping is associated with this object, meaning
* that multiple events or pings for the same histogram may be run
* concurrently, as long as they are associated with different objects.
* @param {String} method
* The telemetry event method (describes the type of event that
* occurred e.g. "open")
* @param {String} object
* The telemetry event object name (the name of the object the event
* occurred on) e.g. "tools" or "setting"
* @param {String|null} value
* The telemetry event value (a user defined value, providing context
* for the event) e.g. "console"
* @param {String} pendingObject
* An object containing key, value pairs that should be added to the
* event as properties.
*/
addEventProperties(obj, method, object, value, pendingObject) {
for (const [key, val] of Object.entries(pendingObject)) {
this.addEventProperty(obj, method, object, value, key, val);
}
}
/**
* A private method that is not to be used externally. This method is used to
* prepare a pending telemetry event for sending and then send it via
* recordEvent().
*
* @param {Object} obj
* The telemetry event or ping is associated with this object, meaning
* that multiple events or pings for the same histogram may be run
* concurrently, as long as they are associated with different objects.
* @param {String} method
* The telemetry event method (describes the type of event that
* occurred e.g. "open")
* @param {String} object
* The telemetry event object name (the name of the object the event
* occurred on) e.g. "tools" or "setting"
* @param {String|null} value
* The telemetry event value (a user defined value, providing context
* for the event) e.g. "console"
*/
_sendPendingEvent(obj, method, object, value) {
const sig = `${method},${object},${value}`;
const { extra } = PENDING_EVENTS.get(obj, sig);
PENDING_EVENTS.delete(obj, sig);
PENDING_EVENT_PROPERTIES.delete(obj, sig);
this.recordEvent(method, object, value, extra);
}
/**
* Send a telemetry event.
*
* @param {String} method
* The telemetry event method (describes the type of event that
* occurred e.g. "open")
* @param {String} object
* The telemetry event object name (the name of the object the event
* occurred on) e.g. "tools" or "setting"
* @param {String|null} [value]
* Optional telemetry event value (a user defined value, providing
* context for the event) e.g. "console"
* @param {Object} [extra]
* Optional telemetry event extra object containing the properties that
* will be sent with the event e.g.
* {
* host: "bottom",
* width: "1024"
* }
*/
recordEvent(method, object, value = null, extra = null) {
// Only string values are allowed so cast all values to strings.
if (extra) {
for (let [name, val] of Object.entries(extra)) {
val = val + "";
if (val.length > 80) {
const sig = `${method},${object},${value}`;
dump(
`Warning: The property "${name}" was added to a telemetry ` +
`event with the signature ${sig} but it's value "${val}" is ` +
`longer than the maximum allowed length of 80 characters.\n` +
`The property value has been trimmed to 80 characters before ` +
`sending.\nCALLER: ${getCaller()}`
);
val = val.substring(0, 80);
}
extra[name] = val;
}
}
// Automatically flag the record with the session ID
// if the current Telemetry instance relates to a toolbox
// so that data can be aggregated per toolbox instance.
// Note that we also aggregate data per about:debugging instance.
if (!extra) {
extra = {};
}
extra.session_id = this.sessionId;
if (value !== null) {
extra.value = value;
}
// Using the Glean API directly insteade of doing string manipulations
// would be better. See bug 1921793.
const eventName = `${method}_${object}`.replace(/(_[a-z])/g, c =>
c[1].toUpperCase()
);
Glean.devtoolsMain[eventName]?.record(extra);
}
/**
* Sends telemetry pings to indicate that a tool has been opened.
*
* @param {String} id
* The ID of the tool opened.
* @param {Object} obj
* The telemetry event or ping is associated with this object, meaning
* that multiple events or pings for the same histogram may be run
* concurrently, as long as they are associated with different objects.
*
* NOTE: This method is designed for tools that send multiple probes on open,
* one of those probes being a counter and the other a timer. If you
* only have one probe you should be using another method.
*/
toolOpened(id, obj) {
const charts = getChartsFromToolId(id);
if (!charts) {
return;
}
if (charts.useTimedEvent) {
this.preparePendingEvent(obj, "tool_timer", id, null, [
"os",
"time_open",
]);
this.addEventProperty(
obj,
"tool_timer",
id,
null,
"time_open",
this.msSystemNow()
);
}
if (charts.gleanTimingDist) {
if (!obj._timerIDs) {
obj._timerIDs = new Map();
}
if (!obj._timerIDs.has(id)) {
obj._timerIDs.set(id, charts.gleanTimingDist.start());
}
}
if (charts.gleanCounter) {
charts.gleanCounter.add(1);
}
}
/**
* Sends telemetry pings to indicate that a tool has been closed.
*
* @param {String} id
* The ID of the tool opened.
* @param {Object} obj
* The telemetry event or ping is associated with this object, meaning
* that multiple events or pings for the same histogram may be run
* concurrently, as long as they are associated with different objects.
*
* NOTE: This method is designed for tools that send multiple probes on open,
* one of those probes being a counter and the other a timer. If you
* only have one probe you should be using another method.
*/
toolClosed(id, obj) {
const charts = getChartsFromToolId(id);
if (!charts) {
return;
}
if (charts.useTimedEvent) {
const sig = `tool_timer,${id},null`;
const event = PENDING_EVENTS.get(obj, sig);
const time = this.msSystemNow() - event.extra.time_open;
this.addEventProperties(obj, "tool_timer", id, null, {
time_open: time,
os: this.osNameAndVersion,
});
}
if (charts.gleanTimingDist && obj._timerIDs) {
const timerID = obj._timerIDs.get(id);
if (timerID) {
charts.gleanTimingDist.stopAndAccumulate(timerID);
obj._timerIDs.delete(id);
}
}
}
}
/**
* Returns the telemetry charts for a specific tool.
*
* @param {String} id
* The ID of the tool that has been opened.
*
*/
// eslint-disable-next-line complexity
function getChartsFromToolId(id) {
if (!id) {
return null;
}
let useTimedEvent = null;
let gleanCounter = null;
let gleanTimingDist = null;
if (id === "performance") {
id = "jsprofiler";
}
switch (id) {
case "aboutdebugging":
case "browserconsole":
case "dom":
case "inspector":
case "jsbrowserdebugger":
case "jsdebugger":
case "jsprofiler":
case "memory":
case "netmonitor":
case "options":
case "responsive":
case "storage":
case "styleeditor":
case "toolbox":
case "webconsole":
gleanTimingDist = Glean.devtools[`${id}TimeActive`];
gleanCounter = Glean.devtools[`${id}OpenedCount`];
break;
case "accessibility":
gleanTimingDist = Glean.devtools.accessibilityTimeActive;
gleanCounter = Glean.devtoolsAccessibility.openedCount;
break;
case "accessibility_picker":
gleanTimingDist = Glean.devtools.accessibilityPickerTimeActive;
gleanCounter = Glean.devtoolsAccessibility.pickerUsedCount;
break;
case "changesview":
gleanTimingDist = Glean.devtools.changesviewTimeActive;
gleanCounter = Glean.devtoolsChangesview.openedCount;
break;
case "animationinspector":
case "compatibilityview":
case "computedview":
case "fontinspector":
case "layoutview":
case "ruleview":
useTimedEvent = true;
gleanTimingDist = Glean.devtools[`${id}TimeActive`];
gleanCounter = Glean.devtools[`${id}OpenedCount`];
break;
case "flexbox_highlighter":
gleanTimingDist = Glean.devtools.flexboxHighlighterTimeActive;
break;
case "grid_highlighter":
gleanTimingDist = Glean.devtools.gridHighlighterTimeActive;
break;
default:
gleanTimingDist = Glean.devtools.customTimeActive;
gleanCounter = Glean.devtools.customOpenedCount;
}
return {
useTimedEvent,
gleanCounter,
gleanTimingDist,
};
}
/**
* Displays the first caller and calling line outside of this file in the
* event of an error. This is the line that made the call that produced the
* error.
*/
function getCaller() {
return getNthPathExcluding(0, "/telemetry.js");
}
module.exports = Telemetry;