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 {{[key: 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 {{[key: string]: TargetingGetters}[]} contexts * @returns {{[key: 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} */ 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 {{[key: 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) ); } } PK