401 lines
14 KiB
JavaScript
401 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.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();
|
|
}
|