Source code
Revision control
Copy as Markdown
Other Tools
/* 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
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
ASRouterTargeting:
// eslint-disable-next-line mozilla/no-browser-refs-in-toolkit
"resource:///modules/asrouter/ASRouterTargeting.sys.mjs",
ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs",
ClientEnvironmentBase:
"resource://gre/modules/components-utils/ClientEnvironment.sys.mjs",
FilterExpressions:
"resource://gre/modules/components-utils/FilterExpressions.sys.mjs",
TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs",
clearTimeout: "resource://gre/modules/Timer.sys.mjs",
setTimeout: "resource://gre/modules/Timer.sys.mjs",
});
const DEFAULT_TIMEOUT = 5000;
const ERROR_TYPES = {
ATTRIBUTE_ERROR: "AttributeError",
TIMEOUT: "AttributeTimeout",
};
const TargetingEnvironment = {
get locale() {
return lazy.ASRouterTargeting.Environment.locale;
},
get localeLanguageCode() {
return lazy.ASRouterTargeting.Environment.localeLanguageCode;
},
get region() {
return lazy.ASRouterTargeting.Environment.region;
},
get userId() {
return lazy.ClientEnvironment.userId;
},
get version() {
return AppConstants.MOZ_APP_VERSION_DISPLAY;
},
get channel() {
const { settings } = lazy.TelemetryEnvironment.currentEnvironment;
return settings.update.channel;
},
get platform() {
return AppConstants.platform;
},
get os() {
return lazy.ClientEnvironmentBase.os;
},
};
export class TargetingContext {
#telemetrySource = null;
constructor(customContext, options = { source: null }) {
if (customContext) {
this.ctx = new Proxy(customContext, {
get: (customCtx, prop) => {
if (prop in TargetingEnvironment) {
return TargetingEnvironment[prop];
}
return customCtx[prop];
},
});
} else {
this.ctx = TargetingEnvironment;
}
// Used in telemetry to report where the targeting expression is coming from
this.#telemetrySource = options.source;
}
setTelemetrySource(source) {
if (source) {
this.#telemetrySource = source;
}
}
_sendUndesiredEvent({ event, value }) {
let extra = { value };
if (this.#telemetrySource) {
extra.source = this.#telemetrySource;
}
Glean.messagingExperiments["targeting" + event].record(extra);
}
/**
* Wrap each property of context[key] with a Proxy that captures errors and
* timeouts
*
* @param {Object.<string, TargetingGetters> | TargetingGetters} context
* @param {string} key Namespace value found in `context` param
* @returns {TargetingGetters} Wrapped context where getter report errors and timeouts
*/
createContextWithTimeout(context, key = null) {
const timeoutDuration = key ? context[key].timeout : context.timeout;
const logUndesiredEvent = (event, key, prop) => {
const value = key ? `${key}.${prop}` : prop;
this._sendUndesiredEvent({ event, value });
console.error(`${event}: ${value}`);
};
return new Proxy(context, {
get(target, prop) {
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve, reject) => {
// Create timeout cb to record attribute resolution taking too long.
let timeout = lazy.setTimeout(() => {
logUndesiredEvent(ERROR_TYPES.TIMEOUT, key, prop);
reject(
new Error(
`${prop} targeting getter timed out after ${
timeoutDuration || DEFAULT_TIMEOUT
}ms`
)
);
}, timeoutDuration || DEFAULT_TIMEOUT);
try {
resolve(await (key ? target[key][prop] : target[prop]));
} catch (error) {
logUndesiredEvent(ERROR_TYPES.ATTRIBUTE_ERROR, key, prop);
reject(error);
console.error(error);
} finally {
lazy.clearTimeout(timeout);
}
});
},
});
}
/**
* Merge all evaluation contexts and wrap the getters with timeouts
*
* @param {Object.<string, TargetingGetters>[]} contexts
* @returns {Object.<string, TargetingGetters>} Object that follows the pattern of `namespace: getters`
*/
mergeEvaluationContexts(contexts) {
let context = {};
for (let c of contexts) {
for (let envNamespace of Object.keys(c)) {
// Take the provided context apart, replace it with a proxy
context[envNamespace] = this.createContextWithTimeout(c, envNamespace);
}
}
return context;
}
/**
* Merge multiple TargetingGetters objects without accidentally evaluating
*
* @param {TargetingGetters[]} ...contexts
* @returns {Proxy<TargetingGetters>}
*/
static combineContexts(...contexts) {
return new Proxy(
{},
{
get(target, prop) {
for (let context of contexts) {
if (prop in context) {
return context[prop];
}
}
return null;
},
}
);
}
/**
* Evaluate JEXL expressions with default `TargetingEnvironment` and custom
* provided targeting contexts
*
* @example
* eval(
* "ctx.locale == 'en-US' && customCtx.foo == 42",
* { customCtx: { foo: 42 } }
* ); // true
*
* @param {string} expression JEXL expression
* @param {Object.<string, TargetingGetters>[]} ...contexts Additional custom context
* objects where the keys act as namespaces for the different getters
*
* @returns {promise} Evaluation result
*/
eval(expression, ...contexts) {
return lazy.FilterExpressions.eval(
expression,
this.mergeEvaluationContexts([{ ctx: this.ctx }, ...contexts])
);
}
/**
* Evaluate JEXL expressions with default provided targeting context
*
* @example
* new TargetingContext({ bar: 42 });
* evalWithDefault(
* "bar == 42",
* ); // true
*
* @param {string} expression JEXL expression
* @returns {promise} Evaluation result
*/
evalWithDefault(expression) {
return lazy.FilterExpressions.eval(
expression,
this.createContextWithTimeout(this.ctx)
);
}
}