telemetry.js (16698B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 /** 6 * This is the telemetry module to report metrics for tools. 7 * 8 * Comprehensive documentation is in docs/frontend/telemetry.md 9 */ 10 11 "use strict"; 12 13 const { 14 getNthPathExcluding, 15 } = require("resource://devtools/shared/platform/stack.js"); 16 const { TelemetryEnvironment } = ChromeUtils.importESModule( 17 "resource://gre/modules/TelemetryEnvironment.sys.mjs" 18 ); 19 const WeakMapMap = require("resource://devtools/client/shared/WeakMapMap.js"); 20 21 // Object to be shared among all instances. 22 const PENDING_EVENT_PROPERTIES = new WeakMapMap(); 23 const PENDING_EVENTS = new WeakMapMap(); 24 25 /** 26 * Instantiate a new Telemetry helper class. 27 * 28 * @param {object} options [optional] 29 * @param {boolean} options.useSessionId [optional] 30 * If true, this instance will automatically generate a unique "sessionId" 31 * and use it to aggregate all records against this unique session. 32 * This helps aggregate all data coming from a single toolbox instance for ex. 33 */ 34 class Telemetry { 35 constructor({ useSessionId = false } = {}) { 36 // Note that native telemetry APIs expect a string 37 this.sessionId = String( 38 useSessionId ? parseInt(this.msSinceProcessStart(), 10) : -1 39 ); 40 41 // Bind pretty much all functions so that callers do not need to. 42 this.msSystemNow = this.msSystemNow.bind(this); 43 this.recordEvent = this.recordEvent.bind(this); 44 this.preparePendingEvent = this.preparePendingEvent.bind(this); 45 this.addEventProperty = this.addEventProperty.bind(this); 46 this.addEventProperties = this.addEventProperties.bind(this); 47 this.toolOpened = this.toolOpened.bind(this); 48 this.toolClosed = this.toolClosed.bind(this); 49 } 50 51 get osNameAndVersion() { 52 const osInfo = TelemetryEnvironment.currentEnvironment.system.os; 53 54 if (!osInfo) { 55 return "Unknown OS"; 56 } 57 58 let osVersion = `${osInfo.name} ${osInfo.version}`; 59 60 if (osInfo.windowsBuildNumber) { 61 osVersion += `.${osInfo.windowsBuildNumber}`; 62 } 63 64 return osVersion; 65 } 66 67 /** 68 * Time since the system wide epoch. This is not a monotonic timer but 69 * can be used across process boundaries. 70 */ 71 msSystemNow() { 72 return Services.telemetry.msSystemNow(); 73 } 74 75 /** 76 * The number of milliseconds since process start using monotonic 77 * timestamps (unaffected by system clock changes). 78 */ 79 msSinceProcessStart() { 80 return Services.telemetry.msSinceProcessStart(); 81 } 82 83 /** 84 * Telemetry events often need to make use of a number of properties from 85 * completely different codepaths. To make this possible we create a 86 * "pending event" along with an array of property names that we need to wait 87 * for before sending the event. 88 * 89 * As each property is received via addEventProperty() we check if all 90 * properties have been received. Once they have all been received we send the 91 * telemetry event. 92 * 93 * @param {object} obj 94 * The telemetry event or ping is associated with this object, meaning 95 * that multiple events or pings for the same histogram may be run 96 * concurrently, as long as they are associated with different objects. 97 * @param {string} method 98 * The telemetry event method (describes the type of event that 99 * occurred e.g. "open") 100 * @param {string} object 101 * The telemetry event object name (the name of the object the event 102 * occurred on) e.g. "tools" or "setting" 103 * @param {string | null} value 104 * The telemetry event value (a user defined value, providing context 105 * for the event) e.g. "console" 106 * @param {Array} expected 107 * An array of the properties needed before sending the telemetry 108 * event e.g. 109 * [ 110 * "host", 111 * "width" 112 * ] 113 */ 114 preparePendingEvent(obj, method, object, value, expected = []) { 115 const sig = `${method},${object},${value}`; 116 117 if (expected.length === 0) { 118 throw new Error( 119 `preparePendingEvent() was called without any expected ` + 120 `properties.\n` + 121 `CALLER: ${getCaller()}` 122 ); 123 } 124 125 const data = { 126 extra: {}, 127 expected: new Set(expected), 128 }; 129 130 PENDING_EVENTS.set(obj, sig, data); 131 132 const props = PENDING_EVENT_PROPERTIES.get(obj, sig); 133 if (props) { 134 for (const [name, val] of Object.entries(props)) { 135 this.addEventProperty(obj, method, object, value, name, val); 136 } 137 PENDING_EVENT_PROPERTIES.delete(obj, sig); 138 } 139 } 140 141 /** 142 * Adds an expected property for either a current or future pending event. 143 * This means that if preparePendingEvent() is called before or after sending 144 * the event properties they will automatically added to the event. 145 * 146 * @param {object} obj 147 * The telemetry event or ping is associated with this object, meaning 148 * that multiple events or pings for the same histogram may be run 149 * concurrently, as long as they are associated with different objects. 150 * @param {string} method 151 * The telemetry event method (describes the type of event that 152 * occurred e.g. "open") 153 * @param {string} object 154 * The telemetry event object name (the name of the object the event 155 * occurred on) e.g. "tools" or "setting" 156 * @param {string | null} value 157 * The telemetry event value (a user defined value, providing context 158 * for the event) e.g. "console" 159 * @param {string} pendingPropName 160 * The pending property name 161 * @param {string} pendingPropValue 162 * The pending property value 163 */ 164 addEventProperty( 165 obj, 166 method, 167 object, 168 value, 169 pendingPropName, 170 pendingPropValue 171 ) { 172 const sig = `${method},${object},${value}`; 173 const events = PENDING_EVENTS.get(obj, sig); 174 175 // If the pending event has not been created add the property to the pending 176 // list. 177 if (!events) { 178 const props = PENDING_EVENT_PROPERTIES.get(obj, sig); 179 180 if (props) { 181 props[pendingPropName] = pendingPropValue; 182 } else { 183 PENDING_EVENT_PROPERTIES.set(obj, sig, { 184 [pendingPropName]: pendingPropValue, 185 }); 186 } 187 return; 188 } 189 190 const { expected, extra } = events; 191 192 if (expected.has(pendingPropName)) { 193 extra[pendingPropName] = pendingPropValue; 194 195 if (expected.size === Object.keys(extra).length) { 196 this._sendPendingEvent(obj, method, object, value); 197 } 198 } else { 199 // The property was not expected, warn and bail. 200 throw new Error( 201 `An attempt was made to add the unexpected property ` + 202 `"${pendingPropName}" to a telemetry event with the ` + 203 `signature "${sig}"\n` + 204 `CALLER: ${getCaller()}` 205 ); 206 } 207 } 208 209 /** 210 * Adds expected properties for either a current or future pending event. 211 * This means that if preparePendingEvent() is called before or after sending 212 * the event properties they will automatically added to the event. 213 * 214 * @param {object} obj 215 * The telemetry event or ping is associated with this object, meaning 216 * that multiple events or pings for the same histogram may be run 217 * concurrently, as long as they are associated with different objects. 218 * @param {string} method 219 * The telemetry event method (describes the type of event that 220 * occurred e.g. "open") 221 * @param {string} object 222 * The telemetry event object name (the name of the object the event 223 * occurred on) e.g. "tools" or "setting" 224 * @param {string | null} value 225 * The telemetry event value (a user defined value, providing context 226 * for the event) e.g. "console" 227 * @param {string} pendingObject 228 * An object containing key, value pairs that should be added to the 229 * event as properties. 230 */ 231 addEventProperties(obj, method, object, value, pendingObject) { 232 for (const [key, val] of Object.entries(pendingObject)) { 233 this.addEventProperty(obj, method, object, value, key, val); 234 } 235 } 236 237 /** 238 * A private method that is not to be used externally. This method is used to 239 * prepare a pending telemetry event for sending and then send it via 240 * recordEvent(). 241 * 242 * @param {object} obj 243 * The telemetry event or ping is associated with this object, meaning 244 * that multiple events or pings for the same histogram may be run 245 * concurrently, as long as they are associated with different objects. 246 * @param {string} method 247 * The telemetry event method (describes the type of event that 248 * occurred e.g. "open") 249 * @param {string} object 250 * The telemetry event object name (the name of the object the event 251 * occurred on) e.g. "tools" or "setting" 252 * @param {string | null} value 253 * The telemetry event value (a user defined value, providing context 254 * for the event) e.g. "console" 255 */ 256 _sendPendingEvent(obj, method, object, value) { 257 const sig = `${method},${object},${value}`; 258 const { extra } = PENDING_EVENTS.get(obj, sig); 259 260 PENDING_EVENTS.delete(obj, sig); 261 PENDING_EVENT_PROPERTIES.delete(obj, sig); 262 this.recordEvent(method, object, value, extra); 263 } 264 265 /** 266 * Send a telemetry event. 267 * 268 * @param {string} method 269 * The telemetry event method (describes the type of event that 270 * occurred e.g. "open") 271 * @param {string} object 272 * The telemetry event object name (the name of the object the event 273 * occurred on) e.g. "tools" or "setting" 274 * @param {string | null} [value] 275 * Optional telemetry event value (a user defined value, providing 276 * context for the event) e.g. "console" 277 * @param {object} [extra] 278 * Optional telemetry event extra object containing the properties that 279 * will be sent with the event e.g. 280 * { 281 * host: "bottom", 282 * width: "1024" 283 * } 284 */ 285 recordEvent(method, object, value = null, extra = null) { 286 // Only string values are allowed so cast all values to strings. 287 if (extra) { 288 for (let [name, val] of Object.entries(extra)) { 289 val = val + ""; 290 291 if (val.length > 80) { 292 const sig = `${method},${object},${value}`; 293 294 dump( 295 `Warning: The property "${name}" was added to a telemetry ` + 296 `event with the signature ${sig} but it's value "${val}" is ` + 297 `longer than the maximum allowed length of 80 characters.\n` + 298 `The property value has been trimmed to 80 characters before ` + 299 `sending.\nCALLER: ${getCaller()}` 300 ); 301 302 val = val.substring(0, 80); 303 } 304 305 extra[name] = val; 306 } 307 } 308 // Automatically flag the record with the session ID 309 // if the current Telemetry instance relates to a toolbox 310 // so that data can be aggregated per toolbox instance. 311 // Note that we also aggregate data per about:debugging instance. 312 if (!extra) { 313 extra = {}; 314 } 315 extra.session_id = this.sessionId; 316 if (value !== null) { 317 extra.value = value; 318 } 319 320 // Using the Glean API directly insteade of doing string manipulations 321 // would be better. See bug 1921793. 322 const eventName = `${method}_${object}`.replace(/(_[a-z])/g, c => 323 c[1].toUpperCase() 324 ); 325 Glean.devtoolsMain[eventName]?.record(extra); 326 } 327 328 /** 329 * Sends telemetry pings to indicate that a tool has been opened. 330 * 331 * @param {string} id 332 * The ID of the tool opened. 333 * @param {object} obj 334 * The telemetry event or ping is associated with this object, meaning 335 * that multiple events or pings for the same histogram may be run 336 * concurrently, as long as they are associated with different objects. 337 * 338 * NOTE: This method is designed for tools that send multiple probes on open, 339 * one of those probes being a counter and the other a timer. If you 340 * only have one probe you should be using another method. 341 */ 342 toolOpened(id, obj) { 343 const charts = getChartsFromToolId(id); 344 345 if (!charts) { 346 return; 347 } 348 349 if (charts.useTimedEvent) { 350 this.preparePendingEvent(obj, "tool_timer", id, null, [ 351 "os", 352 "time_open", 353 ]); 354 this.addEventProperty( 355 obj, 356 "tool_timer", 357 id, 358 null, 359 "time_open", 360 this.msSystemNow() 361 ); 362 } 363 if (charts.gleanTimingDist) { 364 if (!obj._timerIDs) { 365 obj._timerIDs = new Map(); 366 } 367 if (!obj._timerIDs.has(id)) { 368 obj._timerIDs.set(id, charts.gleanTimingDist.start()); 369 } 370 } 371 if (charts.gleanCounter) { 372 charts.gleanCounter.add(1); 373 } 374 } 375 376 /** 377 * Sends telemetry pings to indicate that a tool has been closed. 378 * 379 * @param {string} id 380 * The ID of the tool opened. 381 * @param {object} obj 382 * The telemetry event or ping is associated with this object, meaning 383 * that multiple events or pings for the same histogram may be run 384 * concurrently, as long as they are associated with different objects. 385 * 386 * NOTE: This method is designed for tools that send multiple probes on open, 387 * one of those probes being a counter and the other a timer. If you 388 * only have one probe you should be using another method. 389 */ 390 toolClosed(id, obj) { 391 const charts = getChartsFromToolId(id); 392 393 if (!charts) { 394 return; 395 } 396 397 if (charts.useTimedEvent) { 398 const sig = `tool_timer,${id},null`; 399 const event = PENDING_EVENTS.get(obj, sig); 400 const time = this.msSystemNow() - event.extra.time_open; 401 402 this.addEventProperties(obj, "tool_timer", id, null, { 403 time_open: time, 404 os: this.osNameAndVersion, 405 }); 406 } 407 408 if (charts.gleanTimingDist && obj._timerIDs) { 409 const timerID = obj._timerIDs.get(id); 410 if (timerID) { 411 charts.gleanTimingDist.stopAndAccumulate(timerID); 412 obj._timerIDs.delete(id); 413 } 414 } 415 } 416 } 417 418 /** 419 * Returns the telemetry charts for a specific tool. 420 * 421 * @param {string} id 422 * The ID of the tool that has been opened. 423 */ 424 // eslint-disable-next-line complexity 425 function getChartsFromToolId(id) { 426 if (!id) { 427 return null; 428 } 429 430 let useTimedEvent = null; 431 let gleanCounter = null; 432 let gleanTimingDist = null; 433 434 if (id === "performance") { 435 id = "jsprofiler"; 436 } 437 438 switch (id) { 439 case "aboutdebugging": 440 case "browserconsole": 441 case "dom": 442 case "inspector": 443 case "jsbrowserdebugger": 444 case "jsdebugger": 445 case "jsprofiler": 446 case "memory": 447 case "netmonitor": 448 case "options": 449 case "responsive": 450 case "storage": 451 case "styleeditor": 452 case "toolbox": 453 case "webconsole": 454 gleanTimingDist = Glean.devtools[`${id}TimeActive`]; 455 gleanCounter = Glean.devtools[`${id}OpenedCount`]; 456 break; 457 case "accessibility": 458 gleanTimingDist = Glean.devtools.accessibilityTimeActive; 459 gleanCounter = Glean.devtoolsAccessibility.openedCount; 460 break; 461 case "accessibility_picker": 462 gleanTimingDist = Glean.devtools.accessibilityPickerTimeActive; 463 gleanCounter = Glean.devtoolsAccessibility.pickerUsedCount; 464 break; 465 case "changesview": 466 gleanTimingDist = Glean.devtools.changesviewTimeActive; 467 gleanCounter = Glean.devtoolsChangesview.openedCount; 468 break; 469 case "animationinspector": 470 case "compatibilityview": 471 case "computedview": 472 case "fontinspector": 473 case "layoutview": 474 case "ruleview": 475 useTimedEvent = true; 476 gleanTimingDist = Glean.devtools[`${id}TimeActive`]; 477 gleanCounter = Glean.devtools[`${id}OpenedCount`]; 478 break; 479 case "flexbox_highlighter": 480 gleanTimingDist = Glean.devtools.flexboxHighlighterTimeActive; 481 break; 482 case "grid_highlighter": 483 gleanTimingDist = Glean.devtools.gridHighlighterTimeActive; 484 break; 485 default: 486 gleanTimingDist = Glean.devtools.customTimeActive; 487 gleanCounter = Glean.devtools.customOpenedCount; 488 } 489 490 return { 491 useTimedEvent, 492 gleanCounter, 493 gleanTimingDist, 494 }; 495 } 496 497 /** 498 * Displays the first caller and calling line outside of this file in the 499 * event of an error. This is the line that made the call that produced the 500 * error. 501 */ 502 function getCaller() { 503 return getNthPathExcluding(0, "/telemetry.js"); 504 } 505 506 module.exports = Telemetry;