Source code

Revision control

Copy as Markdown

Other Tools

/* vim: set ts=2 sw=2 sts=2 et tw=80: */
/* 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, {
IndexedDB: "resource://gre/modules/IndexedDB.sys.mjs",
DAPSender: "resource://gre/modules/DAPSender.sys.mjs",
ObliviousHTTP: "resource://gre/modules/ObliviousHTTP.sys.mjs",
HPKEConfigManager: "resource://gre/modules/HPKEConfigManager.sys.mjs",
AboutNewTab: "resource:///modules/AboutNewTab.sys.mjs",
});
const MAX_CONVERSIONS = 2;
const MAX_LOOKBACK_DAYS = 30;
const DAY_IN_MILLI = 1000 * 60 * 60 * 24;
const CONVERSION_RESET_MILLI = 7 * DAY_IN_MILLI;
const DAP_HPKE_PREF = "dap.ohttp.hpke";
const DAP_RELAY_PREF = "dap.ohttp.relayURL";
const MARS_ENDPOINT_PREF =
"browser.newtabpage.activity-stream.unifiedAds.endpoint";
const PREF_MARS_OHTTP_CONFIG =
"browser.newtabpage.activity-stream.discoverystream.ohttp.configURL";
const PREF_MARS_OHTTP_RELAY =
"browser.newtabpage.activity-stream.discoverystream.ohttp.relayURL";
/**
*
*/
class NewTabAttributionService {
/**
* @typedef { 'view' | 'click' | 'default' } matchType - Available matching methodologies for conversion events.
*
* @typedef { 'view' | 'click' } eventType - A subset of matchType values that Newtab will register events.
*
* @typedef {object} task - DAP task settings.
* @property {string} id - task id.
* @property {string} vdaf - vdaf type.
* @property {number} length - number of buckets.
* @property {number} time_precision - time precision.
*
* @typedef {object} allocatedTask
* @property {task} task - DAP task settings.
* @property {number} defaultMeasurement - Measurement value used if budget is exceeded.
* @property {number} index - Measurement value used if budget is not exceeded.
*
* @typedef {object} impression - stored event.
* @property {allocatedTask} conversion - DAP task settings for conversion attribution.
* @property {number} lastImpression - Timestamp in milliseconds for last touch matching.
* @property {number} lastView - Timestamp in milliseconds for last view matching.
* @property {number} lastClick - Timestamp in milliseconds for last click matching.
*
* @typedef {object} budget - stored budget.
* @property {number} conversions - Number of conversions that have occurred in the budget period.
* @property {number} nextReset - Timestamp in milliseconds for the end of the period this budget applies to.
*/
#dapSenderInternal;
#dateProvider;
// eslint-disable-next-line no-unused-private-class-members
#testDapOptions;
constructor({ dapSender, dateProvider, testDapOptions } = {}) {
this.#dapSenderInternal = dapSender;
this.#dateProvider = dateProvider ?? Date;
this.#testDapOptions = testDapOptions;
this.dbName = "NewTabAttribution";
this.impressionStoreName = "impressions";
this.budgetStoreName = "budgets";
this.storeNames = [this.impressionStoreName, this.budgetStoreName];
this.dbVersion = 1;
this.models = {
default: "lastImpression",
view: "lastView",
click: "lastClick",
};
}
get #dapSender() {
return this.#dapSenderInternal || lazy.DAPSender;
}
#now() {
return this.#dateProvider.now();
}
#getTrainhopConfig() {
return (
lazy.AboutNewTab.activityStream?.store.getState().Prefs.values
.trainhopConfig ?? {}
);
}
/**
* onAttributionEvent stores an event locally for an attributable interaction on Newtab.
*
* @param {eventType} type - The type of event.
* @param {*} params - Attribution task details & partner, to enable attribution matching
* with this event and submission to DAP.
*/
async onAttributionEvent(type, params) {
try {
const now = this.#now();
if (
!params ||
!params.partner_id ||
params.index === undefined ||
params.index === null
) {
return;
}
const impression = await this.#getImpression(params.partner_id, {
conversion: {
index: params.index,
},
});
const prop = this.#getModelProp(type);
impression.lastImpression = now;
impression[prop] = now;
await this.#updateImpression(params.partner_id, impression);
} catch (e) {
console.error(e);
}
}
/**
* Resets all partner budgets and clears stored impressions,
* preparing for a new attribution conversion cycle.
*/
async onAttributionReset() {
try {
const now = this.#now();
// Clear impressions so future conversions won't match outdated impressions
const impressionStore = await this.#getImpressionStore();
await impressionStore.clear();
// Reset budgets
const budgetStore = await this.#getBudgetStore();
const partnerIds = await budgetStore.getAllKeys();
for (const partnerId of partnerIds) {
const budget = await budgetStore.get(partnerId);
// Currently clobbers the budget, but will work if any future data is added to DB
const updatedBudget = {
...budget,
conversions: 0,
nextReset: now + CONVERSION_RESET_MILLI,
};
await budgetStore.put(updatedBudget, partnerId);
}
} catch (e) {
console.error(e);
}
}
/**
* onAttributionConversion checks for eligible Newtab events and submits
* a DAP report.
*
* @param {string} partnerId - The partner that the conversion occured for. Compared against
* local events to see if any of them are eligible.
* @param {number} lookbackDays - The number of days prior to now that an event can be for it
* to be eligible.
* @param {matchType} impressionType - How the matching of events is determined.
* 'view': attributes the most recent eligible view event.
* 'click': attributes the most recent eligible click event.
* 'default': attributes the most recent eligible event of any type.
*/
async onAttributionConversion(partnerId, lookbackDays, impressionType) {
try {
const trainhopConfig = this.#getTrainhopConfig();
const attributionConfig = trainhopConfig.attribution || {};
const maxLookbackDays =
attributionConfig.maxLookbackDays ?? MAX_LOOKBACK_DAYS;
const maxConversions =
attributionConfig.maxConversions ?? MAX_CONVERSIONS;
if (lookbackDays > maxLookbackDays) {
return;
}
// we don't want to request the gateway key at time of conversion to avoid an IP address leak
const dapHpke = Services.prefs.getCharPref(
DAP_HPKE_PREF,
"gAAgJSO22Y3HKzRSese15JtQVuuFfOIcTrZ56lQ5kDQwS0oABAABAAE"
);
const ohttpRelayURL = Services.prefs.getCharPref(
DAP_RELAY_PREF,
);
const now = this.#now();
const budget = await this.#getBudget(partnerId, now);
const impression = await this.#findImpression(
partnerId,
lookbackDays,
impressionType,
now
);
const receivedTaskConfig = await this.#getTaskConfig(partnerId);
if (!receivedTaskConfig) {
return;
}
// Need to rename task_id to id for DAP report submission.
const taskConfig = {
...receivedTaskConfig,
id: receivedTaskConfig.task_id,
};
let measurement = receivedTaskConfig.default_measurement;
let budgetSpend = 0;
if (budget.conversions < maxConversions && impression) {
budgetSpend = 1;
const conversionIndex = impression.conversion.index;
if (
receivedTaskConfig.length > conversionIndex &&
conversionIndex !== undefined
) {
measurement = conversionIndex;
}
}
await this.#updateBudget(budget, budgetSpend, partnerId);
const options = {};
if (dapHpke) {
options.ohttp_hpke = lazy.HPKEConfigManager.decodeKey(dapHpke);
}
if (ohttpRelayURL) {
options.ohttp_relay = ohttpRelayURL;
}
await this.#dapSender.sendDAPMeasurement(
taskConfig,
measurement,
options
);
} catch (e) {
console.error(e);
}
}
/**
* findImpression queries the local events to find an attributable event.
*
* @param {string} partnerId - Partner the event must be associated with.
* @param {number} lookbackDays - Maximum number of days ago that the event occurred for it to
* be eligible.
* @param {matchType} impressionType - How the matching of events is determined. Determines what
* timestamp property to compare against.
* @param {number} now - Timestamp in milliseconds when the conversion event was triggered
* @returns {Promise<impression|undefined>} - The impression that most recently occurred matching the
* search criteria.
*/
async #findImpression(partnerId, lookbackDays, impressionType, now) {
// Get impressions for the partner
const impressionStore = await this.#getImpressionStore();
const impressions = await this.#getPartnerImpressions(
impressionStore,
partnerId
);
// Determine what timestamp to compare against for the matching methodology
const prop = this.#getModelProp(impressionType);
// Find the most relevant impression
const lookbackWindow = now - lookbackDays * DAY_IN_MILLI;
return (
impressions
// Filter by lookback days
.filter(impression => impression[prop] >= lookbackWindow)
// Get the impression with the most recent interaction
.reduce(
(cur, impression) =>
!cur || impression[prop] > cur[prop] ? impression : cur,
null
)
);
}
/**
* getImpression searches existing events for the partner and retuns the event
* if it is found, defaulting to the passed in impression if there are none. This
* enables timestamp fields of the stored event to be updated or carried forward.
*
* @param {string} partnerId - partner this event is associated with.
* @param {impression} defaultImpression - event to use if it has not been seen previously.
* @returns {Promise<impression>}
*/
async #getImpression(partnerId, defaultImpression) {
const impressionStore = await this.#getImpressionStore();
const impressions = await this.#getPartnerImpressions(
impressionStore,
partnerId
);
const impression = impressions.find(r =>
this.#compareImpression(r, defaultImpression)
);
return impression ?? defaultImpression;
}
async #getTaskConfig(partnerId) {
const baseUrl = Services.prefs.getCharPref(MARS_ENDPOINT_PREF, "");
const endpoint = `${baseUrl}/v1/attribution?partner_id=${encodeURIComponent(
partnerId
)}`;
const ohttpConfigURL = Services.prefs.getCharPref(
PREF_MARS_OHTTP_CONFIG,
""
);
const ohttpRelayURL = Services.prefs.getCharPref(PREF_MARS_OHTTP_RELAY, "");
if (!partnerId || !endpoint || !ohttpRelayURL || !ohttpConfigURL) {
return null;
}
const controller = new AbortController();
const { signal } = controller;
let config = await lazy.ObliviousHTTP.getOHTTPConfig(ohttpConfigURL);
if (!config) {
console.error(
new Error(
`OHTTP was configured for ${endpoint} but we couldn't fetch a valid config`
)
);
return null;
}
try {
const response = await lazy.ObliviousHTTP.ohttpRequest(
ohttpRelayURL,
config,
endpoint,
{
headers: {},
signal,
}
);
return response.json();
} catch (error) {
console.error(
`Failed to make OHTTP request for unattributed task: ${error.message}`,
error
);
return null;
}
}
/**
* updateImpression stores the passed event, either updating the record
* if this event was already seen, or appending to the list of events if it is new.
*
* @param {string} partnerId - partner this event is associated with.
* @param {impression} impression - event to update.
*/
async #updateImpression(partnerId, impression) {
const impressionStore = await this.#getImpressionStore();
let impressions = await this.#getPartnerImpressions(
impressionStore,
partnerId
);
const i = impressions.findIndex(r =>
this.#compareImpression(r, impression)
);
if (i < 0) {
impressions.push(impression);
} else {
impressions[i] = impression;
}
await impressionStore.put(impressions, partnerId);
}
/**
* @param {impression} cur
* @param {impression} impression
* @returns {boolean} true if cur and impression have the same index
*/
#compareImpression(cur, impression) {
return cur.conversion.index === impression.conversion.index;
}
/**
* getBudget returns the current budget available for the partner.
*
* @param {string} partnerId - partner to look up budget for.
* @param {number} now - Timestamp in milliseconds.
* @returns {Promise<budget>} the current budget for the partner.
*/
async #getBudget(partnerId, now) {
const budgetStore = await this.#getBudgetStore();
const budget = await budgetStore.get(partnerId);
if (!budget || now > budget.nextReset) {
return {
conversions: 0,
nextReset: now + CONVERSION_RESET_MILLI,
};
}
return budget;
}
/**
* updateBudget updates the stored budget to indicate some has been used.
*
* @param {budget} budget - current budget to be modified.
* @param {number} value - amount of budget that has been used.
* @param {string} partnerId - partner this budget is for.
*/
async #updateBudget(budget, value, partnerId) {
const budgetStore = await this.#getBudgetStore();
budget.conversions += value;
await budgetStore.put(budget, partnerId);
}
/**
* @param {ObjectStore} impressionStore - Promise-based wrapped IDBObjectStore.
* @param {string} partnerId - partner to look up impressions for.
* @returns {Promise<Array<impression>>} impressions associated with the partner.
*/
async #getPartnerImpressions(impressionStore, partnerId) {
const impressions = (await impressionStore.get(partnerId)) ?? [];
return impressions;
}
async #getImpressionStore() {
return await this.#getStore(this.impressionStoreName);
}
async #getBudgetStore() {
return await this.#getStore(this.budgetStoreName);
}
async #getStore(storeName) {
return (await this.#db).objectStore(storeName, "readwrite");
}
get #db() {
return this._db || (this._db = this.#createOrOpenDb());
}
async #createOrOpenDb() {
try {
return await this.#openDatabase();
} catch {
await lazy.IndexedDB.deleteDatabase(this.dbName);
return this.#openDatabase();
}
}
async #openDatabase() {
return await lazy.IndexedDB.open(this.dbName, this.dbVersion, db => {
this.storeNames.forEach(store => {
if (!db.objectStoreNames.contains(store)) {
db.createObjectStore(store);
}
});
});
}
/**
* getModelProp returns the property name associated with a given matching
* methodology.
*
* @param {matchType} type
* @returns {string} The name of the timestamp property to check against.
*/
#getModelProp(type) {
return this.models[type] ?? this.models.default;
}
}
const newTabAttributionService = new NewTabAttributionService();
export {
newTabAttributionService,
NewTabAttributionService as NewTabAttributionServiceClass,
};