/* 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", DAPTelemetrySender: "resource://gre/modules/DAPTelemetrySender.sys.mjs", }); const MAX_CONVERSIONS = 5; const MAX_LOOKBACK_DAYS = 30; const DAY_IN_MILLI = 1000 * 60 * 60 * 24; const CONVERSION_RESET_MILLI = 7 * DAY_IN_MILLI; /** * */ export 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} bits - datatype size. * @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. */ #dapTelemetrySenderInternal; #dateProvider; // eslint-disable-next-line no-unused-private-class-members #testDapOptions; constructor({ dapTelemetrySender, dateProvider, testDapOptions } = {}) { this.#dapTelemetrySenderInternal = dapTelemetrySender; 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 #dapTelemetrySender() { return this.#dapTelemetrySenderInternal || lazy.DAPTelemetrySender; } #now() { return this.#dateProvider.now(); } /** * 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(); const impressionStore = await this.#getImpressionStore(); if (!params || !params.conversion) { return; } const impression = await this.#getImpression( impressionStore, params.partner_id, { conversion: { task: { id: params.conversion.task_id, vdaf: params.conversion.vdaf, bits: params.conversion.bits, length: params.conversion.length, time_precision: params.conversion.time_precision, }, defaultMeasurement: params.conversion.default_measurement, index: params.conversion.index, }, } ); const prop = this.#getModelProp(type); impression.lastImpression = now; impression[prop] = now; await this.#updateImpression( impressionStore, 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 { if (lookbackDays > MAX_LOOKBACK_DAYS) { return; } const now = this.#now(); const budget = await this.#getBudget(partnerId, now); const impression = await this.#findImpression( partnerId, lookbackDays, impressionType, now ); let conversion = impression?.conversion; if (!conversion) { // retreive "conversion" for conversions with no found impression // conversion = await this.#getUnattributedTask(partnerId); if (!conversion) { return; } } let measurement = conversion.defaultMeasurement; let budgetSpend = 0; if (budget.conversions < MAX_CONVERSIONS && conversion) { budgetSpend = 1; if (conversion.task && conversion.task.length > conversion.index) { measurement = conversion.index; } } await this.#updateBudget(budget, budgetSpend, partnerId); await this.#dapTelemetrySender.sendDAPMeasurement( conversion.task, measurement, {} ); } 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} - 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 {ObjectStore} impressionStore - Promise-based wrapped IDBObjectStore. * @param {string} partnerId - partner this event is associated with. * @param {impression} defaultImpression - event to use if it has not been seen previously. * @returns {Promise} */ async #getImpression(impressionStore, partnerId, defaultImpression) { const impressions = await this.#getPartnerImpressions( impressionStore, partnerId ); const impression = impressions.find(r => this.#compareImpression(r, defaultImpression) ); return impression ?? defaultImpression; } /** * 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 {ObjectStore} impressionStore - Promise-based wrapped IDBObjectStore. * @param {string} partnerId - partner this event is associated with. * @param {impression} impression - event to update. */ async #updateImpression(impressionStore, partnerId, impression) { 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 DAP allocation, else false. */ #compareImpression(cur, impression) { return ( cur.conversion.task.id === impression.conversion.task.id && 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} 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>} 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; } }