Files
tubestation/toolkit/components/nimbus/lib/TargetingContextRecorder.sys.mjs
Beth Rennie 5b3c07d2d2 Bug 1950237 - Expose ExperimentManager as ExperimentAPI.manager r=nimbus-reviewers,relud,nalexander
Since we're removing the export of the global ExperimentManager from
ExperimentManager.sys.mjs we need to expose it somewhere else. Ideally
it would be an implementation detail internal to the ExperimentAPI, but
we presently have too many consumers relying on it.

Differential Revision: https://phabricator.services.mozilla.com/D248069
2025-05-09 20:35:21 +00:00

404 lines
14 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/. */
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
ASRouterTargeting:
// eslint-disable-next-line mozilla/no-browser-refs-in-toolkit
"resource:///modules/asrouter/ASRouterTargeting.sys.mjs",
ClientID: "resource://gre/modules/ClientID.sys.mjs",
ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs",
NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
TargetingContext: "resource://messaging-system/targeting/Targeting.sys.mjs",
});
const { PREF_INVALID, PREF_STRING, PREF_INT, PREF_BOOL } = Ci.nsIPrefBranch;
const PREF_TYPES = Object.freeze({
[PREF_STRING]: "Ci.nsIPrefBranch.PREF_STRING",
[PREF_INT]: "Ci.nsIPrefBranch.PREF_INT",
[PREF_BOOL]: "Ci.nsIPrefBranch.PREF_BOOL",
});
/**
* Return a function that returns specific keys of an object.
*
* All values will be awaited, so objects containing promises will be flattened
* into objects.
*
* Any exceptions encountered will not prevent the key from being recorded in
* the metric.
*
* @param {string[]} keys - The keys to include.
* @returns The function.
*/
function pick(...keys) {
const identity = x => x;
return pickWith(Object.fromEntries(keys.map(key => [key, identity])));
}
/**
* Return a function that returns a specific keys of an object, with transforms.
*
* All values will be awaited, as will their transform functions, so objects
* containing promises will be flattened into objects.
*
* Any exceptions encountered will not prevent the key from being recorded in
* the metric.
*
* @param {Record<string, () => any>} shape
* A mapping of keys to transformation functions.
*
* @returns The function.
*/
function pickWith(shape) {
return async function (object) {
const transformed = {};
if (typeof object !== "undefined" && object !== null) {
for (const [key, transform] of Object.entries(shape)) {
try {
transformed[key] = await transform(await object[key]);
} catch (ex) {}
}
}
return transformed;
};
}
/**
* Assert that the attribute matches the given type (via typeof).
*
* @param {string} expectedType
* The expected type.
* If the attribute is not of this type, this function will throw.
* @param {any} attribute
* The value whose type is to be checked.
*
* @returns The attribute.
*/
function assertType(expectedType, attribute) {
const type = typeof attribute;
if (type !== expectedType) {
throw new Error(`Expected ${expectedType} but got ${type} instead`);
}
return attribute;
}
/**
* Transforms that assert that the type of the attribute matches an expected
* type.
*/
const typeAssertions = {
integer: attribute =>
assertType("number", attribute) && Number.isSafeInteger(attribute),
string: attribute => assertType("string", attribute),
boolean: attribute => assertType("boolean", attribute),
quantity: attribute => Math.floor(assertType("number", attribute)),
array: attribute => {
if (!Array.isArray(attribute)) {
throw new Error(`Expected Array but got ${typeof attribute} instead`);
}
return attribute;
},
// NB: Date methods will throw if called on a non-Date object. We can't simply
// use `attribute instanceof Date` because the Date constructor might be from
// a different context (and thus the expression would evaluate to false).
date: attribute => Date.prototype.toUTCString.call(attribute),
};
/**
* This contains the set of all top-level targeting attributes in the Nimbus
* Targeting context and optional transforms functions that will be applied
* before the value is recorded.
*/
export const ATTRIBUTE_TRANSFORMS = Object.freeze({
activeExperiments: typeAssertions.array,
activeRollouts: typeAssertions.array,
addonsInfo: addonsInfo => ({
addons: Object.keys(addonsInfo?.addons ?? {}).sort(),
hasInstalledAddons: !!addonsInfo?.hasInstalledAddons,
}),
addressesSaved: typeAssertions.quantity,
archBits: typeAssertions.quantity,
attributionData: pick("medium", "source", "ua"),
browserSettings: pickWith({
update: pick("channel"),
}),
buildId: typeAssertions.integer,
currentDate: typeAssertions.date,
defaultPDFHandler: pick("knownBrowser", "registered"),
distributionId: typeAssertions.string,
doesAppNeedPin: typeAssertions.boolean,
enrollmentsMap: enrollmentsMap =>
Object.entries(enrollmentsMap).map(([experimentSlug, branchSlug]) => ({
experimentSlug,
branchSlug,
})),
firefoxVersion: typeAssertions.quantity,
hasActiveEnterprisePolicies: typeAssertions.boolean,
homePageSettings: pick("isCustomUrl", "isDefault", "isLocked", "isWebExt"),
isDefaultHandler: pick("html", "pdf"),
isDefaultBrowser: typeAssertions.boolean,
isFirstStartup: typeAssertions.boolean,
isFxAEnabled: typeAssertions.boolean,
isFxASignedIn: typeAssertions.boolean,
isMSIX: typeAssertions.boolean,
locale: typeAssertions.string,
memoryMB: typeAssertions.quantity,
os: pick(
"isLinux",
"isMac",
"isWindow",
"windowsBuildNumber",
"windowsVersion"
),
primaryResolution: pick("height", "width"),
profileAgeCreated: typeAssertions.quantity,
region: typeAssertions.string,
totalBookmarksCount: typeAssertions.quantity,
userMonthlyActivity: userMonthlyActivity =>
userMonthlyActivity.map(([numberOfURLsVisited, date]) => ({
numberOfURLsVisited,
date,
})),
// userPrefersReducedMotion can only be false in xpcshell tests because it
// uses a stubbed nsIXULAppInfo (/testing/modules/AppInfo.sys.mjs).
userPrefersReducedMotion: userPrefersReducedMotion =>
userPrefersReducedMotion ?? false,
usesFirefoxSync: typeAssertions.boolean,
version: typeAssertions.string,
});
/**
* Transform a targeting context attribute name to the name that Glean expects
* for the corresponding metric.
*
* Glean metrics are defined in `snake_case` and are translated to `camelCase`
* for JavaScript. Most of our targeting attributes and their Glean metric
* equivalent have names that line up cleanly, but this falls apart when the
* targeting attribute has a name with an all-uppercase acronym.
*
* For example, the metric corresponding to the `defaultPDFHandler` attribute
* has the name `default_pdf_handler` in the metrics.yaml which would become
* `defaultPdfhandler` in JavaScript.
*
* @param {string} The attribute name.
* @returns {string} The metric name.
*/
export function normalizeAttributeName(attr) {
switch (attr) {
case "isFxAEnabled": // Would transform to `isFxAenabled`.
case "isFxASignedIn": // Would transform to `isFxAsignedIn`.
return attr;
case "defaultPDFHandler":
// Would transform to `defaultPdfhandler`.
return "defaultPdfHandler";
default:
return attr.replaceAll(/[A-Z]+/g, substr => {
return `${substr[0]}${substr.slice(1).toLowerCase()}`;
});
}
}
/**
* These are the prefs that can be used in evaluation of a JEXL expression by
* Nimbus via the `getPrefValue` filter.
*/
export const PREFS = Object.freeze({
"browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons": PREF_BOOL,
"browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features":
PREF_BOOL,
"browser.newtabpage.activity-stream.feeds.section.highlights": PREF_BOOL,
"browser.newtabpage.activity-stream.feeds.section.topstories": PREF_BOOL,
"browser.newtabpage.activity-stream.feeds.topsites": PREF_BOOL,
"browser.newtabpage.activity-stream.showSearch": PREF_BOOL,
"browser.newtabpage.activity-stream.showSponsoredTopSites": PREF_BOOL,
"browser.newtabpage.enabled": PREF_BOOL,
"browser.shopping.experience2023.autoActivateCount": PREF_INT,
"browser.shopping.experience2023.optedIn": PREF_INT,
"browser.toolbars.bookmarks.visibility": PREF_STRING,
"browser.urlbar.quicksuggest.dataCollection.enabled": PREF_BOOL,
"browser.urlbar.showSearchSuggestionsFirst": PREF_BOOL,
"browser.urlbar.suggest.quicksuggest.sponsored": PREF_BOOL,
"media.videocontrols.picture-in-picture.enabled": PREF_BOOL,
"media.videocontrols.picture-in-picture.video-toggle.enabled": PREF_BOOL,
"media.videocontrols.picture-in-picture.video-toggle.has-used": PREF_BOOL,
"messaging-system-action.testday": PREF_STRING,
"network.trr.mode": PREF_INT,
"nimbus.qa.pref-1": PREF_STRING,
"nimbus.qa.pref-2": PREF_STRING,
"security.sandbox.content.level": PREF_INT,
"trailhead.firstrun.didSeeAboutWelcome": PREF_BOOL,
});
/**
* Transform a pref name to its key in the targeting context metric.
*
* Using dashes and periods in the object metric type would make the resulting
* data harder to query, so we replace them with single and double underscores,
* respectively.
*
* @param {string} The pref name.
* @returns {string} The normalized pref name.
*/
export function normalizePrefName(pref) {
return pref.replaceAll(/-/g, "_").replaceAll(/\./g, "__");
}
/**
* Get the list of all prefs that Nimbus cares about and determine whether or
* not they have user branch values.
*
* This will walk the Feature Manifest, collecting every setPref entry.
*
* This does not return any errors because prefHasUserValue cannot throw.
*
* @returns {string[]} The array of prefs.
*/
function recordUserSetPrefs() {
const prefs = Object.values(lazy.NimbusFeatures)
.filter(feature => feature.manifest)
.flatMap(feature => feature.manifest.variables)
.flatMap(Object.values)
.filter(variable => variable.setPref)
.map(variable => variable.setPref.pref)
.filter(pref => Services.prefs.prefHasUserValue(pref));
Glean.nimbusTargetingEnvironment.userSetPrefs.set(prefs);
}
/**
* Record pref values to the nimbus_targeting_environment.pref_values metric.
*
* The prefs queried are determined by `PREFS`.
*
* Any type errors will encountered will be recorded in the
* `nimbus_targeting_environment.pref_type_errors` metric.
*/
function recordPrefValues() {
const prefValues = {};
for (const [pref, expectedType] of Object.entries(PREFS)) {
const key = normalizePrefName(pref);
const prefType = Services.prefs.getPrefType(pref);
if (prefType === PREF_INVALID) {
// The pref doesn't have a value on either branch. This is not an actual
// error.
continue;
}
if (prefType !== expectedType) {
// We cannot record this value since the pref has the wrong type.
Glean.nimbusTargetingEnvironment.prefTypeErrors[pref].add();
console.error(
`TargetingContextRecorder: Pref "${pref}" has the wrong type. Expected ${PREF_TYPES[expectedType]} but found ${PREF_TYPES[prefType]}`
);
continue;
}
try {
switch (expectedType) {
case PREF_STRING:
prefValues[key] = Services.prefs.getStringPref(pref);
break;
case PREF_INT:
prefValues[key] = Services.prefs.getIntPref(pref);
break;
case PREF_BOOL:
prefValues[key] = Services.prefs.getBoolPref(pref);
break;
}
} catch (ex) {
// `nsIPrefBranch::Get{String,Int,Bool}Pref` only fails for three reasons:
// - you request a pref that does not exist
// - you request a pref with the wrongly-typed method (e.g., you try to
// get the value of an int pref with `GetStringPref`)
// - the pref service is not available (likely because we are shutting down).
//
// The first two cases are covered before we attempt to read the pref
// value and the last case is not worth recording telemetry about.
console.error(
`TargetingContextRecorder: Could not get value of pref "${pref}; are we shutting down?"`,
ex
);
}
}
Glean.nimbusTargetingEnvironment.prefValues.set(prefValues);
}
/**
* Evaluate the values of the `nimbus_targeting_context` category metrics and
* record them.
*
* Any errors encountered during evaluation will be recorded in the
* `nimbus_targeting_environment.attr_eval_errors` metric.
*
* The entire targeting context will be recorded inside the
* `nimbus_targeting_environment.targeting_context_value` metric as stringified
* JSON. The metric is disabled by default, but can be enabled via the
* `nimbusTelemetry` feature to debug evaluation failures.
*/
async function recordTargetingContextAttributes() {
const context = new lazy.TargetingContext(
lazy.TargetingContext.combineContexts(
lazy.ExperimentAPI.manager.createTargetingContext(),
lazy.ASRouterTargeting.Environment
)
).ctx;
const recordAttrs =
lazy.NimbusFeatures.nimbusTelemetry.getVariable(
"nimbusTargetingEnvironment"
)?.recordAttrs ?? null;
const values = {};
for (const [attr, transform] of Object.entries(ATTRIBUTE_TRANSFORMS)) {
const metric = normalizeAttributeName(attr);
try {
const value = await transform(await context[attr]);
if (recordAttrs === null || recordAttrs.includes(attr)) {
values[metric] = value;
}
Glean.nimbusTargetingContext[metric].set(value);
} catch (ex) {
Glean.nimbusTargetingEnvironment.attrEvalErrors[metric].add();
console.error(`TargetingContextRecorder: Could not get "${attr}"`, ex);
}
}
let stringifiedCtx;
try {
stringifiedCtx = JSON.stringify(values);
} catch (ex) {
stringifiedCtx = "(JSON.stringify error)";
}
Glean.nimbusTargetingEnvironment.targetingContextValue.set(stringifiedCtx);
}
/**
* Record the metrics for the nimbus-targeting-context ping and submit it.
*/
export async function recordTargetingContext() {
recordPrefValues();
recordUserSetPrefs();
await recordTargetingContextAttributes();
// This will ensure that the profile group ID metric has been set.
await lazy.ClientID.getProfileGroupID();
GleanPings.nimbusTargetingContext.submit();
}