NewTabGleanUtils.sys.mjs (11132B)
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 const lazy = {}; 6 7 ChromeUtils.defineLazyGetter(lazy, "logConsole", function () { 8 return console.createInstance({ 9 prefix: "NewTabGleanUtils", 10 maxLogLevel: Services.prefs.getBoolPref( 11 "browser.newtabpage.glean-utils.log", 12 false 13 ) 14 ? "Debug" 15 : "Warn", 16 }); 17 }); 18 19 const EXTRA_ARGS_TYPES_ALLOWLIST = [ 20 "event", 21 "memory_distribution", 22 "timing_distribution", 23 ]; 24 25 /** 26 * Module for managing Glean telemetry metrics and pings in the New Tab page. 27 * This object provides functionality to: 28 * - Read and parse JSON configuration files containing metrics and ping definitions 29 * - Register metrics and pings at runtime 30 * - Convert between different naming conventions (dotted snake case, kebab case, camel case) 31 * - Handle metric and ping registration with proper error handling and logging 32 */ 33 export const NewTabGleanUtils = { 34 /** 35 * Internal Promise.withResolvers() object for tracking metrics and pings 36 * registration completion. Contains resolve, reject functions and a promise 37 * that resolves when registerMetricsAndPings completes. 38 * 39 * @type {{promise: Promise<void>, resolve: Function, reject: Function}} 40 * @private 41 */ 42 _registrationDone: Promise.withResolvers(), 43 44 /** 45 * Gets the promise that resolves when metrics and pings registration is 46 * complete. This allows external code to wait for registration to finish 47 * before using registered metrics. 48 * 49 * @returns {Promise<void>} A promise that resolves when 50 * registerMetricsAndPings completes 51 */ 52 get registrationDone() { 53 return this._registrationDone.promise; 54 }, 55 56 /** 57 * Fetches and parses a JSON file from a given resource URI. 58 * 59 * @param {string} resourceURI - The URI of the JSON file to fetch and parse 60 * @returns {Promise<object>} A promise that resolves to the parsed JSON object 61 */ 62 async readJSON(resourceURI) { 63 let result = await fetch(resourceURI); 64 return result.json(); 65 }, 66 /** 67 * Processes and registers Glean metrics and pings from a JSON configuration file. 68 * This method performs two main operations: 69 * 1. Registers all pings defined in the configuration 70 * 2. Registers all metrics under their respective categories 71 * Example: await NewTabGleanUtils.registerMetricsAndPings("resource://path/to/metrics.json"); 72 * 73 * @param {string} resourceURI - The URI of the JSON file containing metrics and pings definitions 74 * @returns {Promise<boolean>} A promise that resolves when all metrics and pings are registered 75 * If a metric or ping registration fails, all further registration halts and this Promise 76 * will still resolve (errors will be logged to the console). 77 */ 78 async registerMetricsAndPings(resourceURI) { 79 try { 80 const data = await this.readJSON(resourceURI); 81 82 // Check if data exists and has either metrics or pings to register 83 if (!data || (!data.metrics && !data.pings)) { 84 lazy.logConsole.log("No metrics or pings found in the JSON file"); 85 return false; 86 } 87 88 // First register all pings from the JSON file 89 if (data.pings) { 90 for (const [pingName, pingConfig] of Object.entries(data.pings)) { 91 await this.registerPingIfNeeded({ 92 name: pingName, 93 ...this.convertToCamelCase(pingConfig), 94 }); 95 } 96 } 97 98 // Then register all metrics under their respective categories 99 if (data.metrics) { 100 for (const [category, metrics] of Object.entries(data.metrics)) { 101 for (const [name, config] of Object.entries(metrics)) { 102 await this.registerMetricIfNeeded({ 103 ...config, 104 category, 105 name, 106 }); 107 } 108 } 109 } 110 lazy.logConsole.debug( 111 "Successfully registered metrics and pings found in the JSON file" 112 ); 113 this._registrationDone.resolve(); 114 return true; 115 } catch (e) { 116 lazy.logConsole.error( 117 "Failed to complete registration of metrics and pings found in runtime metrics JSON:", 118 e 119 ); 120 this._registrationDone.resolve(); 121 return false; 122 } 123 }, 124 125 /** 126 * Registers a metric in Glean if it doesn't already exist. 127 * 128 * @param {object} options - The metric configuration options 129 * @param {string} options.type - The type of metric (e.g., "text", "counter") 130 * @param {string} options.category - The category the metric belongs to 131 * @param {string} options.name - The name of the metric 132 * @param {string[]} options.pings - Array of ping names this metric belongs to 133 * @param {string} options.lifetime - The lifetime of the metric 134 * @param {boolean} [options.disabled] - Whether the metric is disabled 135 * @param {object} [options.extraArgs] - Additional arguments for the metric 136 * @throws {Error} If a new metrics registration fails and error will be logged in console 137 */ 138 registerMetricIfNeeded(options) { 139 const { type, category, name, pings, lifetime, disabled, extraArgs } = 140 options; 141 142 // Glean metric to record the success of metric registration for telemetry purposes. 143 let gleanSuccessMetric = Glean.newtab.metricRegistered[name]; 144 145 try { 146 let categoryName = this.dottedSnakeToCamel(category); 147 let metricName = this.dottedSnakeToCamel(name); 148 149 if (categoryName in Glean && metricName in Glean[categoryName]) { 150 lazy.logConsole.warn( 151 `Fail to register metric ${name} in category ${category} as it already exists` 152 ); 153 return; 154 } 155 156 // Convert extraArgs to JSON string for metrics types in allowlist 157 let extraArgsJson = null; 158 if ( 159 EXTRA_ARGS_TYPES_ALLOWLIST.includes(type) && 160 extraArgs && 161 Object.keys(extraArgs).length 162 ) { 163 extraArgsJson = JSON.stringify(extraArgs); 164 } 165 166 // Metric doesn't exist, register it 167 lazy.logConsole.debug(`Registering metric ${name} at runtime`); 168 169 // Register the metric 170 Services.fog.registerRuntimeMetric( 171 type, 172 category, 173 name, 174 pings, 175 `"${lifetime}"`, 176 disabled, 177 extraArgsJson 178 ); 179 gleanSuccessMetric.set(true); 180 } catch (e) { 181 gleanSuccessMetric.set(false); 182 lazy.logConsole.error(`Error registering metric ${name}: ${e}`); 183 throw new Error(`Failure while registering metrics ${name} `); 184 } 185 }, 186 187 /** 188 * Registers a ping in Glean if it doesn't already exist. 189 * 190 * @param {object} options - The ping configuration options 191 * @param {string} options.name - The name of the ping 192 * @param {boolean} [options.includeClientId] - Whether to include client ID 193 * @param {boolean} [options.sendIfEmpty] - Whether to send ping if empty 194 * @param {boolean} [options.preciseTimestamps] - Whether to use precise timestamps 195 * @param {boolean} [options.includeInfoSections] - Whether to include info sections 196 * @param {boolean} [options.enabled] - Whether the ping is enabled 197 * @param {string[]} [options.schedulesPings] - Array of scheduled ping times 198 * @param {string[]} [options.reasonCodes] - Array of valid reason codes 199 * @param {boolean} [options.followsCollectionEnabled] - Whether ping follows collection enabled state 200 * @param {string[]} [options.uploaderCapabilities] - Array of uploader capabilities for this ping 201 * @throws {Error} If a new ping registration fails and error will be logged in console 202 */ 203 registerPingIfNeeded(options) { 204 const { 205 name, 206 includeClientId, 207 sendIfEmpty, 208 preciseTimestamps, 209 includeInfoSections, 210 enabled, 211 schedulesPings, 212 reasonCodes, 213 followsCollectionEnabled, 214 uploaderCapabilities, 215 } = options; 216 217 // Glean metric to record the success of ping registration for telemetry purposes. 218 let gleanSuccessPing = Glean.newtab.pingRegistered[name]; 219 try { 220 let pingName = this.kebabToCamel(name); 221 if (pingName in GleanPings) { 222 lazy.logConsole.warn( 223 `Fail to register ping ${name} as it already exists` 224 ); 225 return; 226 } 227 228 // Ping doesn't exist, register it 229 lazy.logConsole.debug(`Registering ping ${name} at runtime`); 230 231 Services.fog.registerRuntimePing( 232 name, 233 includeClientId, 234 sendIfEmpty, 235 preciseTimestamps, 236 includeInfoSections, 237 enabled, 238 schedulesPings, 239 reasonCodes, 240 followsCollectionEnabled, 241 uploaderCapabilities 242 ); 243 gleanSuccessPing.set(true); 244 } catch (e) { 245 gleanSuccessPing.set(false); 246 lazy.logConsole.error(`Error registering ping ${name}: ${e}`); 247 throw new Error(`Failure while registering ping ${name} `); 248 } 249 }, 250 251 /** 252 * Converts a dotted snake case string to camel case. 253 * Example: "foo.bar_baz" becomes "fooBarBaz" 254 * 255 * @param {string} metricNameOrCategory - The string in dotted snake case format 256 * @returns {string} The converted camel case string 257 */ 258 dottedSnakeToCamel(metricNameOrCategory) { 259 if (!metricNameOrCategory) { 260 return ""; 261 } 262 263 let camel = ""; 264 // Split by underscore and then by dots 265 const segments = metricNameOrCategory.split("_"); 266 for (const segment of segments) { 267 const parts = segment.split("."); 268 for (const part of parts) { 269 if (!camel) { 270 camel += part; 271 } else if (part.length) { 272 const firstChar = part.charAt(0); 273 if (firstChar >= "a" && firstChar <= "z") { 274 // Capitalize first letter and append rest of the string 275 camel += firstChar.toUpperCase() + part.slice(1); 276 } else { 277 // If first char is not a-z, append as is 278 camel += part; 279 } 280 } 281 } 282 } 283 return camel; 284 }, 285 286 /** 287 * Converts a kebab case string to camel case. 288 * Example: "foo-bar-baz" becomes "fooBarBaz" 289 * 290 * @param {string} pingName - The string in kebab case format 291 * @returns {string} The converted camel case string 292 */ 293 kebabToCamel(pingName) { 294 if (!pingName) { 295 return ""; 296 } 297 298 let camel = ""; 299 // Split by hyphens 300 const segments = pingName.split("-"); 301 for (const segment of segments) { 302 if (!camel) { 303 camel += segment; 304 } else if (segment.length) { 305 const firstChar = segment.charAt(0); 306 if (firstChar >= "a" && firstChar <= "z") { 307 // Capitalize first letter and append rest of the string 308 camel += firstChar.toUpperCase() + segment.slice(1); 309 } else { 310 // If first char is not a-z, append as is 311 camel += segment; 312 } 313 } 314 } 315 return camel; 316 }, 317 318 // Convert all properties in an object from snake_case to camelCase 319 convertToCamelCase(obj) { 320 const result = {}; 321 for (const [key, value] of Object.entries(obj)) { 322 result[this.dottedSnakeToCamel(key)] = value; 323 } 324 return result; 325 }, 326 };