Bug 1943642 - NewTab JOG dynamic metrics registration r=home-newtab-reviewers,mconley

Differential Revision: https://phabricator.services.mozilla.com/D244846
This commit is contained in:
Punam Dahiya
2025-04-23 19:16:11 +00:00
parent f5d406b7ad
commit 08ad8335d4
5 changed files with 375 additions and 2 deletions

View File

@@ -0,0 +1,270 @@
/* 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/. */
const lazy = {};
ChromeUtils.defineLazyGetter(lazy, "logConsole", function () {
return console.createInstance({
prefix: "NewTabGleanUtils",
maxLogLevel: Services.prefs.getBoolPref(
"browser.newtab.builtin-addon.log",
false
)
? "Debug"
: "Warn",
});
});
/**
* Module for managing Glean telemetry metrics and pings in the New Tab page.
* This object provides functionality to:
* - Read and parse JSON configuration files containing metrics and ping definitions
* - Register metrics and pings at runtime
* - Convert between different naming conventions (dotted snake case, kebab case, camel case)
* - Handle metric and ping registration with proper error handling and logging
*/
export const NewTabGleanUtils = {
/**
* Fetches and parses a JSON file from a given resource URI.
*
* @param {string} resourceURI - The URI of the JSON file to fetch and parse
* @returns {Promise<Object>} A promise that resolves to the parsed JSON object
*/
async readJSON(resourceURI) {
let result = await fetch(resourceURI);
return result.json();
},
/**
* Processes and registers Glean metrics and pings from a JSON configuration file.
* This method performs two main operations:
* 1. Registers all pings defined in the configuration
* 2. Registers all metrics under their respective categories
* Example: await NewTabGleanUtils.registerMetricsAndPings("resource://path/to/metrics.json");
*
* @param {string} resourceURI - The URI of the JSON file containing metrics and pings definitions
* @returns {Promise<boolean>} A promise that resolves when all metrics and pings are registered
* If a metric or ping registration fails, all further registration halts and this Promise
* will still resolve (errors will be logged to the console).
*/
async registerMetricsAndPings(resourceURI) {
try {
const data = await this.readJSON(resourceURI);
// Check if data exists and has either metrics or pings to register
if (!data || (!data.metrics && !data.pings)) {
lazy.logConsole.log("No metrics or pings found in the JSON file");
return false;
}
// First register all pings from the JSON file
if (data.pings) {
for (const [pingName, pingConfig] of Object.entries(data.pings)) {
await this.registerPingIfNeeded({
name: pingName,
...pingConfig,
});
}
}
// Then register all metrics under their respective categories
if (data.metrics) {
for (const [category, metrics] of Object.entries(data.metrics)) {
for (const [name, config] of Object.entries(metrics)) {
await this.registerMetricIfNeeded({
...config,
category,
name,
});
}
}
}
lazy.logConsole.debug(
"Successfully registered metrics and pings found in the JSON file"
);
return true;
} catch (e) {
lazy.logConsole.error(
"Failed to complete registration of metrics and pings found in runtime metrics JSON:",
e
);
return false;
}
},
/**
* Registers a metric in Glean if it doesn't already exist.
* @param {Object} options - The metric configuration options
* @param {string} options.type - The type of metric (e.g., "text", "counter")
* @param {string} options.category - The category the metric belongs to
* @param {string} options.name - The name of the metric
* @param {string[]} options.pings - Array of ping names this metric belongs to
* @param {string} options.lifetime - The lifetime of the metric
* @param {boolean} [options.disabled] - Whether the metric is disabled
* @param {Object} [options.extraArgs] - Additional arguments for the metric
* @throws {Error} If a new metrics registration fails and error will be logged in console
*/
registerMetricIfNeeded(options) {
const { type, category, name, pings, lifetime, disabled, extraArgs } =
options;
try {
let categoryName = this.dottedSnakeToCamel(category);
let metricName = this.dottedSnakeToCamel(name);
if (categoryName in Glean && metricName in Glean[categoryName]) {
lazy.logConsole.warn(
`Fail to register metric ${name} in category ${category} as it already exists`
);
return;
}
// Convert extraArgs to JSON string for metrics type event
let extraArgsJson = null;
if (type === "event" && extraArgs && Object.keys(extraArgs).length) {
extraArgsJson = JSON.stringify(extraArgs);
}
// Metric doesn't exist, register it
lazy.logConsole.debug(`Registering metric ${name} at runtime`);
// Register the metric
Services.fog.testRegisterRuntimeMetric(
type,
category,
name,
pings,
`"${lifetime}"`,
disabled,
extraArgsJson
);
} catch (e) {
lazy.logConsole.error(`Error registering metric ${name}: ${e}`);
throw new Error(`Failure while registering metrics ${name} `);
}
},
/**
* Registers a ping in Glean if it doesn't already exist.
* @param {Object} options - The ping configuration options
* @param {string} options.name - The name of the ping
* @param {boolean} [options.includeClientId] - Whether to include client ID
* @param {boolean} [options.sendIfEmpty] - Whether to send ping if empty
* @param {boolean} [options.preciseTimestamps] - Whether to use precise timestamps
* @param {boolean} [options.includeInfoSections] - Whether to include info sections
* @param {boolean} [options.enabled] - Whether the ping is enabled
* @param {string[]} [options.schedulesPings] - Array of scheduled ping times
* @param {string[]} [options.reasonCodes] - Array of valid reason codes
* @param {boolean} [options.followsCollectionEnabled] - Whether ping follows collection enabled state
* @param {string[]} [options.uploaderCapabilities] - Array of uploader capabilities for this ping
* @throws {Error} If a new ping registration fails and error will be logged in console
*/
registerPingIfNeeded(options) {
const {
name,
includeClientId,
sendIfEmpty,
preciseTimestamps,
includeInfoSections,
enabled,
schedulesPings,
reasonCodes,
followsCollectionEnabled,
uploaderCapabilities,
} = options;
try {
let pingName = this.kebabToCamel(name);
if (pingName in GleanPings) {
lazy.logConsole.warn(
`Fail to register ping ${name} as it already exists`
);
return;
}
// Ping doesn't exist, register it
lazy.logConsole.debug(`Registering ping ${name} at runtime`);
Services.fog.testRegisterRuntimePing(
name,
includeClientId,
sendIfEmpty,
preciseTimestamps,
includeInfoSections,
enabled,
schedulesPings,
reasonCodes,
followsCollectionEnabled,
uploaderCapabilities
);
} catch (e) {
lazy.logConsole.error(`Error registering ping ${name}: ${e}`);
throw new Error(`Failure while registering ping ${name} `);
}
},
/**
* Converts a dotted snake case string to camel case.
* Example: "foo.bar_baz" becomes "fooBarBaz"
* @param {string} metricNameOrCategory - The string in dotted snake case format
* @returns {string} The converted camel case string
*/
dottedSnakeToCamel(metricNameOrCategory) {
if (!metricNameOrCategory) {
return "";
}
let camel = "";
// Split by underscore and then by dots
const segments = metricNameOrCategory.split("_");
for (const segment of segments) {
const parts = segment.split(".");
for (const part of parts) {
if (!camel) {
camel += part;
} else if (part.length) {
const firstChar = part.charAt(0);
if (firstChar >= "a" && firstChar <= "z") {
// Capitalize first letter and append rest of the string
camel += firstChar.toUpperCase() + part.slice(1);
} else {
// If first char is not a-z, append as is
camel += part;
}
}
}
}
return camel;
},
/**
* Converts a kebab case string to camel case.
* Example: "foo-bar-baz" becomes "fooBarBaz"
* @param {string} pingName - The string in kebab case format
* @returns {string} The converted camel case string
*/
kebabToCamel(pingName) {
if (!pingName) {
return "";
}
let camel = "";
// Split by hyphens
const segments = pingName.split("-");
for (const segment of segments) {
if (!camel) {
camel += segment;
} else if (segment.length) {
const firstChar = segment.charAt(0);
if (firstChar >= "a" && firstChar <= "z") {
// Capitalize first letter and append rest of the string
camel += firstChar.toUpperCase() + segment.slice(1);
} else {
// If first char is not a-z, append as is
camel += segment;
}
}
}
return camel;
},
};

View File

@@ -22,6 +22,7 @@ const SUPPORTED_LOCALES =
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
AboutHomeStartupCache: "resource:///modules/AboutHomeStartupCache.sys.mjs",
NewTabGleanUtils: "resource://newtab/lib/NewTabGleanUtils.sys.mjs",
});
const ResourceSubstitution = "newtab";
@@ -29,6 +30,15 @@ const ResourceSubstitution = "newtab";
this.builtin_newtab = class extends ExtensionAPI {
#chromeHandle = null;
async registerMetricsFromJson() {
// The metrics we need to process were placed in webext-glue/metrics/runtime-metrics-<version>.json
// That file will be generated by build scipt getting implemented with Bug 1960111
const version = AppConstants.MOZ_APP_VERSION.match(/\d+/)[0];
const metricsPath = `webext-glue/metrics/runtime-metrics-${version}.json`;
lazy.NewTabGleanUtils.registerMetricsAndPings(`resource://newtab/${metricsPath}`);
}
onStartup() {
if (!AppConstants.BROWSER_NEWTAB_AS_ADDON) {
// If we're here, this must be the first launch of a profile where this
@@ -73,9 +83,9 @@ this.builtin_newtab = class extends ExtensionAPI {
);
L10nRegistry.getInstance().registerSources([newtabFileSource]);
// TODO: Dynamically register any Glean pings/metrics here.
// Dynamically register any Glean pings/metrics here.
this.registerMetricsFromJson();
}
redirector.builtInAddonInitialized();
}

View File

@@ -0,0 +1,45 @@
{
"metrics": {
"newtab": {
"wallpaper_click": {
"type": "text",
"description": "Number of clicks",
"lifetime": "ping",
"pings": ["newtab"],
"disabled": false
},
"load_time": {
"type": "event",
"description": "Time taken to load and render",
"lifetime": "ping",
"pings": ["spoc-1"],
"disabled": false,
"extraArgs": {
"allowed_extra_keys": ["is_page_followed", "newtab_visit_id"]
}
}
}
},
"pings": {
"messaging-system": {
"includeClientId": false,
"sendIfEmpty": false,
"preciseTimestamps": true,
"includeInfoSections": true,
"enabled": true,
"schedulesPings": [],
"reasonCodes": [],
"followsCollectionEnabled": true
},
"spoc-1": {
"includeClientId": false,
"sendIfEmpty": false,
"preciseTimestamps": true,
"includeInfoSections": true,
"enabled": true,
"schedulesPings": [],
"reasonCodes": ["click", "impression", "save"],
"followsCollectionEnabled": true
}
}
}

View File

@@ -0,0 +1,47 @@
{
"metrics": {
"newtab": {
"wallpaper_click": {
"type": "text",
"description": "Number of clicks",
"lifetime": "ping",
"pings": ["newtab"],
"disabled": false
},
"load_time": {
"type": "event",
"description": "Time taken to load and render",
"lifetime": "ping",
"pings": ["spoc-1"],
"disabled": false,
"extraArgs": {
"allowed_extra_keys": ["is_page_followed", "newtab_visit_id"]
}
}
}
},
"pings": {
"messaging-system": {
"includeClientId": false,
"sendIfEmpty": false,
"preciseTimestamps": true,
"includeInfoSections": true,
"enabled": true,
"schedulesPings": [],
"reasonCodes": [],
"followsCollectionEnabled": true,
"uploaderCapabilities": []
},
"spoc-1": {
"includeClientId": false,
"sendIfEmpty": false,
"preciseTimestamps": true,
"includeInfoSections": true,
"enabled": true,
"schedulesPings": [],
"reasonCodes": ["click", "impression", "save"],
"followsCollectionEnabled": true,
"uploaderCapabilities": []
}
}
}

View File

@@ -22,6 +22,7 @@ FINAL_TARGET_FILES += [
]
FINAL_TARGET_FILES["webext-glue"] += [
"background.js",
"metrics/**",
"schema.json",
]
FINAL_TARGET_PP_FILES["webext-glue"] += [