NewTabAttributionService.sys.mjs (15765B)
1 /* vim: set ts=2 sw=2 sts=2 et tw=80: */ 2 /* This Source Code Form is subject to the terms of the Mozilla Public 3 * License, v. 2.0. If a copy of the MPL was not distributed with this 4 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 5 6 const lazy = {}; 7 8 ChromeUtils.defineESModuleGetters(lazy, { 9 IndexedDB: "resource://gre/modules/IndexedDB.sys.mjs", 10 DAPSender: "resource://gre/modules/DAPSender.sys.mjs", 11 ObliviousHTTP: "resource://gre/modules/ObliviousHTTP.sys.mjs", 12 HPKEConfigManager: "resource://gre/modules/HPKEConfigManager.sys.mjs", 13 AboutNewTab: "resource:///modules/AboutNewTab.sys.mjs", 14 }); 15 16 const MAX_CONVERSIONS = 2; 17 const MAX_LOOKBACK_DAYS = 30; 18 const DAY_IN_MILLI = 1000 * 60 * 60 * 24; 19 const CONVERSION_RESET_MILLI = 7 * DAY_IN_MILLI; 20 21 const DAP_HPKE_PREF = "dap.ohttp.hpke"; 22 const DAP_RELAY_PREF = "dap.ohttp.relayURL"; 23 const MARS_ENDPOINT_PREF = 24 "browser.newtabpage.activity-stream.unifiedAds.endpoint"; 25 const PREF_MARS_OHTTP_CONFIG = 26 "browser.newtabpage.activity-stream.discoverystream.ohttp.configURL"; 27 const PREF_MARS_OHTTP_RELAY = 28 "browser.newtabpage.activity-stream.discoverystream.ohttp.relayURL"; 29 30 /** 31 * 32 */ 33 class NewTabAttributionService { 34 /** 35 * @typedef { 'view' | 'click' | 'default' } matchType - Available matching methodologies for conversion events. 36 * 37 * @typedef { 'view' | 'click' } eventType - A subset of matchType values that Newtab will register events. 38 * 39 * @typedef {object} task - DAP task settings. 40 * @property {string} id - task id. 41 * @property {string} vdaf - vdaf type. 42 * @property {number} length - number of buckets. 43 * @property {number} time_precision - time precision. 44 * 45 * @typedef {object} allocatedTask 46 * @property {task} task - DAP task settings. 47 * @property {number} defaultMeasurement - Measurement value used if budget is exceeded. 48 * @property {number} index - Measurement value used if budget is not exceeded. 49 * 50 * @typedef {object} impression - stored event. 51 * @property {allocatedTask} conversion - DAP task settings for conversion attribution. 52 * @property {number} lastImpression - Timestamp in milliseconds for last touch matching. 53 * @property {number} lastView - Timestamp in milliseconds for last view matching. 54 * @property {number} lastClick - Timestamp in milliseconds for last click matching. 55 * 56 * @typedef {object} budget - stored budget. 57 * @property {number} conversions - Number of conversions that have occurred in the budget period. 58 * @property {number} nextReset - Timestamp in milliseconds for the end of the period this budget applies to. 59 */ 60 #dapSenderInternal; 61 #dateProvider; 62 // eslint-disable-next-line no-unused-private-class-members 63 #testDapOptions; 64 65 constructor({ dapSender, dateProvider, testDapOptions } = {}) { 66 this.#dapSenderInternal = dapSender; 67 this.#dateProvider = dateProvider ?? Date; 68 this.#testDapOptions = testDapOptions; 69 70 this.dbName = "NewTabAttribution"; 71 this.impressionStoreName = "impressions"; 72 this.budgetStoreName = "budgets"; 73 this.storeNames = [this.impressionStoreName, this.budgetStoreName]; 74 this.dbVersion = 1; 75 this.models = { 76 default: "lastImpression", 77 view: "lastView", 78 click: "lastClick", 79 }; 80 } 81 82 get #dapSender() { 83 return this.#dapSenderInternal || lazy.DAPSender; 84 } 85 86 #now() { 87 return this.#dateProvider.now(); 88 } 89 90 #getTrainhopConfig() { 91 return ( 92 lazy.AboutNewTab.activityStream?.store.getState().Prefs.values 93 .trainhopConfig ?? {} 94 ); 95 } 96 97 /** 98 * onAttributionEvent stores an event locally for an attributable interaction on Newtab. 99 * 100 * @param {eventType} type - The type of event. 101 * @param {*} params - Attribution task details & partner, to enable attribution matching 102 * with this event and submission to DAP. 103 */ 104 async onAttributionEvent(type, params) { 105 try { 106 const now = this.#now(); 107 108 if ( 109 !params || 110 !params.partner_id || 111 params.index === undefined || 112 params.index === null 113 ) { 114 return; 115 } 116 117 const impression = await this.#getImpression(params.partner_id, { 118 conversion: { 119 index: params.index, 120 }, 121 }); 122 123 const prop = this.#getModelProp(type); 124 impression.lastImpression = now; 125 impression[prop] = now; 126 127 await this.#updateImpression(params.partner_id, impression); 128 } catch (e) { 129 console.error(e); 130 } 131 } 132 133 /** 134 * Resets all partner budgets and clears stored impressions, 135 * preparing for a new attribution conversion cycle. 136 */ 137 async onAttributionReset() { 138 try { 139 const now = this.#now(); 140 141 // Clear impressions so future conversions won't match outdated impressions 142 const impressionStore = await this.#getImpressionStore(); 143 await impressionStore.clear(); 144 145 // Reset budgets 146 const budgetStore = await this.#getBudgetStore(); 147 const partnerIds = await budgetStore.getAllKeys(); 148 149 for (const partnerId of partnerIds) { 150 const budget = await budgetStore.get(partnerId); 151 // Currently clobbers the budget, but will work if any future data is added to DB 152 const updatedBudget = { 153 ...budget, 154 conversions: 0, 155 nextReset: now + CONVERSION_RESET_MILLI, 156 }; 157 158 await budgetStore.put(updatedBudget, partnerId); 159 } 160 } catch (e) { 161 console.error(e); 162 } 163 } 164 165 /** 166 * onAttributionConversion checks for eligible Newtab events and submits 167 * a DAP report. 168 * 169 * @param {string} partnerId - The partner that the conversion occured for. Compared against 170 * local events to see if any of them are eligible. 171 * @param {number} lookbackDays - The number of days prior to now that an event can be for it 172 * to be eligible. 173 * @param {matchType} impressionType - How the matching of events is determined. 174 * 'view': attributes the most recent eligible view event. 175 * 'click': attributes the most recent eligible click event. 176 * 'default': attributes the most recent eligible event of any type. 177 */ 178 async onAttributionConversion(partnerId, lookbackDays, impressionType) { 179 try { 180 const trainhopConfig = this.#getTrainhopConfig(); 181 const attributionConfig = trainhopConfig.attribution || {}; 182 183 const maxLookbackDays = 184 attributionConfig.maxLookbackDays ?? MAX_LOOKBACK_DAYS; 185 const maxConversions = 186 attributionConfig.maxConversions ?? MAX_CONVERSIONS; 187 188 if (lookbackDays > maxLookbackDays) { 189 return; 190 } 191 // we don't want to request the gateway key at time of conversion to avoid an IP address leak 192 const dapHpke = Services.prefs.getCharPref( 193 DAP_HPKE_PREF, 194 "gAAgJSO22Y3HKzRSese15JtQVuuFfOIcTrZ56lQ5kDQwS0oABAABAAE" 195 ); 196 const ohttpRelayURL = Services.prefs.getCharPref( 197 DAP_RELAY_PREF, 198 "https://mozilla-ohttp-dap.mozilla.fastly-edge.com/" 199 ); 200 const now = this.#now(); 201 202 const budget = await this.#getBudget(partnerId, now); 203 const impression = await this.#findImpression( 204 partnerId, 205 lookbackDays, 206 impressionType, 207 now 208 ); 209 210 const receivedTaskConfig = await this.#getTaskConfig(partnerId); 211 212 if (!receivedTaskConfig) { 213 return; 214 } 215 216 // Need to rename task_id to id for DAP report submission. 217 const taskConfig = { 218 ...receivedTaskConfig, 219 id: receivedTaskConfig.task_id, 220 }; 221 222 let measurement = receivedTaskConfig.default_measurement; 223 let budgetSpend = 0; 224 if (budget.conversions < maxConversions && impression) { 225 budgetSpend = 1; 226 const conversionIndex = impression.conversion.index; 227 if ( 228 receivedTaskConfig.length > conversionIndex && 229 conversionIndex !== undefined 230 ) { 231 measurement = conversionIndex; 232 } 233 } 234 235 await this.#updateBudget(budget, budgetSpend, partnerId); 236 237 const options = {}; 238 if (dapHpke) { 239 options.ohttp_hpke = lazy.HPKEConfigManager.decodeKey(dapHpke); 240 } 241 242 if (ohttpRelayURL) { 243 options.ohttp_relay = ohttpRelayURL; 244 } 245 246 await this.#dapSender.sendDAPMeasurement( 247 taskConfig, 248 measurement, 249 options 250 ); 251 } catch (e) { 252 console.error(e); 253 } 254 } 255 256 /** 257 * findImpression queries the local events to find an attributable event. 258 * 259 * @param {string} partnerId - Partner the event must be associated with. 260 * @param {number} lookbackDays - Maximum number of days ago that the event occurred for it to 261 * be eligible. 262 * @param {matchType} impressionType - How the matching of events is determined. Determines what 263 * timestamp property to compare against. 264 * @param {number} now - Timestamp in milliseconds when the conversion event was triggered 265 * @returns {Promise<impression|undefined>} - The impression that most recently occurred matching the 266 * search criteria. 267 */ 268 async #findImpression(partnerId, lookbackDays, impressionType, now) { 269 // Get impressions for the partner 270 const impressionStore = await this.#getImpressionStore(); 271 const impressions = await this.#getPartnerImpressions( 272 impressionStore, 273 partnerId 274 ); 275 276 // Determine what timestamp to compare against for the matching methodology 277 const prop = this.#getModelProp(impressionType); 278 279 // Find the most relevant impression 280 const lookbackWindow = now - lookbackDays * DAY_IN_MILLI; 281 return ( 282 impressions 283 // Filter by lookback days 284 .filter(impression => impression[prop] >= lookbackWindow) 285 // Get the impression with the most recent interaction 286 .reduce( 287 (cur, impression) => 288 !cur || impression[prop] > cur[prop] ? impression : cur, 289 null 290 ) 291 ); 292 } 293 294 /** 295 * getImpression searches existing events for the partner and retuns the event 296 * if it is found, defaulting to the passed in impression if there are none. This 297 * enables timestamp fields of the stored event to be updated or carried forward. 298 * 299 * @param {string} partnerId - partner this event is associated with. 300 * @param {impression} defaultImpression - event to use if it has not been seen previously. 301 * @returns {Promise<impression>} 302 */ 303 async #getImpression(partnerId, defaultImpression) { 304 const impressionStore = await this.#getImpressionStore(); 305 const impressions = await this.#getPartnerImpressions( 306 impressionStore, 307 partnerId 308 ); 309 const impression = impressions.find(r => 310 this.#compareImpression(r, defaultImpression) 311 ); 312 313 return impression ?? defaultImpression; 314 } 315 316 async #getTaskConfig(partnerId) { 317 const baseUrl = Services.prefs.getCharPref(MARS_ENDPOINT_PREF, ""); 318 const endpoint = `${baseUrl}/v1/attribution?partner_id=${encodeURIComponent( 319 partnerId 320 )}`; 321 const ohttpConfigURL = Services.prefs.getCharPref( 322 PREF_MARS_OHTTP_CONFIG, 323 "" 324 ); 325 const ohttpRelayURL = Services.prefs.getCharPref(PREF_MARS_OHTTP_RELAY, ""); 326 327 if (!partnerId || !endpoint || !ohttpRelayURL || !ohttpConfigURL) { 328 return null; 329 } 330 const controller = new AbortController(); 331 const { signal } = controller; 332 let config = await lazy.ObliviousHTTP.getOHTTPConfig(ohttpConfigURL); 333 if (!config) { 334 console.error( 335 new Error( 336 `OHTTP was configured for ${endpoint} but we couldn't fetch a valid config` 337 ) 338 ); 339 return null; 340 } 341 try { 342 const response = await lazy.ObliviousHTTP.ohttpRequest( 343 ohttpRelayURL, 344 config, 345 endpoint, 346 { 347 headers: {}, 348 signal, 349 } 350 ); 351 return response.json(); 352 } catch (error) { 353 console.error( 354 `Failed to make OHTTP request for unattributed task: ${error.message}`, 355 error 356 ); 357 return null; 358 } 359 } 360 361 /** 362 * updateImpression stores the passed event, either updating the record 363 * if this event was already seen, or appending to the list of events if it is new. 364 * 365 * @param {string} partnerId - partner this event is associated with. 366 * @param {impression} impression - event to update. 367 */ 368 async #updateImpression(partnerId, impression) { 369 const impressionStore = await this.#getImpressionStore(); 370 let impressions = await this.#getPartnerImpressions( 371 impressionStore, 372 partnerId 373 ); 374 375 const i = impressions.findIndex(r => 376 this.#compareImpression(r, impression) 377 ); 378 if (i < 0) { 379 impressions.push(impression); 380 } else { 381 impressions[i] = impression; 382 } 383 384 await impressionStore.put(impressions, partnerId); 385 } 386 387 /** 388 * @param {impression} cur 389 * @param {impression} impression 390 * @returns {boolean} true if cur and impression have the same index 391 */ 392 #compareImpression(cur, impression) { 393 return cur.conversion.index === impression.conversion.index; 394 } 395 396 /** 397 * getBudget returns the current budget available for the partner. 398 * 399 * @param {string} partnerId - partner to look up budget for. 400 * @param {number} now - Timestamp in milliseconds. 401 * @returns {Promise<budget>} the current budget for the partner. 402 */ 403 async #getBudget(partnerId, now) { 404 const budgetStore = await this.#getBudgetStore(); 405 const budget = await budgetStore.get(partnerId); 406 407 if (!budget || now > budget.nextReset) { 408 return { 409 conversions: 0, 410 nextReset: now + CONVERSION_RESET_MILLI, 411 }; 412 } 413 414 return budget; 415 } 416 417 /** 418 * updateBudget updates the stored budget to indicate some has been used. 419 * 420 * @param {budget} budget - current budget to be modified. 421 * @param {number} value - amount of budget that has been used. 422 * @param {string} partnerId - partner this budget is for. 423 */ 424 async #updateBudget(budget, value, partnerId) { 425 const budgetStore = await this.#getBudgetStore(); 426 budget.conversions += value; 427 await budgetStore.put(budget, partnerId); 428 } 429 430 /** 431 * @param {ObjectStore} impressionStore - Promise-based wrapped IDBObjectStore. 432 * @param {string} partnerId - partner to look up impressions for. 433 * @returns {Promise<Array<impression>>} impressions associated with the partner. 434 */ 435 async #getPartnerImpressions(impressionStore, partnerId) { 436 const impressions = (await impressionStore.get(partnerId)) ?? []; 437 return impressions; 438 } 439 440 async #getImpressionStore() { 441 return await this.#getStore(this.impressionStoreName); 442 } 443 444 async #getBudgetStore() { 445 return await this.#getStore(this.budgetStoreName); 446 } 447 448 async #getStore(storeName) { 449 return (await this.#db).objectStore(storeName, "readwrite"); 450 } 451 452 get #db() { 453 return this._db || (this._db = this.#createOrOpenDb()); 454 } 455 456 async #createOrOpenDb() { 457 try { 458 return await this.#openDatabase(); 459 } catch { 460 await lazy.IndexedDB.deleteDatabase(this.dbName); 461 return this.#openDatabase(); 462 } 463 } 464 465 async #openDatabase() { 466 return await lazy.IndexedDB.open(this.dbName, this.dbVersion, db => { 467 this.storeNames.forEach(store => { 468 if (!db.objectStoreNames.contains(store)) { 469 db.createObjectStore(store); 470 } 471 }); 472 }); 473 } 474 475 /** 476 * getModelProp returns the property name associated with a given matching 477 * methodology. 478 * 479 * @param {matchType} type 480 * @returns {string} The name of the timestamp property to check against. 481 */ 482 #getModelProp(type) { 483 return this.models[type] ?? this.models.default; 484 } 485 } 486 487 const newTabAttributionService = new NewTabAttributionService(); 488 489 export { 490 newTabAttributionService, 491 NewTabAttributionService as NewTabAttributionServiceClass, 492 };