PrivateAttributionService.sys.mjs (8447B)
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 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 9 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; 10 11 ChromeUtils.defineESModuleGetters(lazy, { 12 IndexedDB: "resource://gre/modules/IndexedDB.sys.mjs", 13 DAPTelemetrySender: "resource://gre/modules/DAPTelemetrySender.sys.mjs", 14 HPKEConfigManager: "resource://gre/modules/HPKEConfigManager.sys.mjs", 15 setTimeout: "resource://gre/modules/Timer.sys.mjs", 16 }); 17 18 XPCOMUtils.defineLazyPreferenceGetter( 19 lazy, 20 "gIsTelemetrySendingEnabled", 21 "datareporting.healthreport.uploadEnabled", 22 true 23 ); 24 25 XPCOMUtils.defineLazyPreferenceGetter( 26 lazy, 27 "gIsPPAEnabled", 28 "dom.private-attribution.submission.enabled", 29 true 30 ); 31 32 XPCOMUtils.defineLazyPreferenceGetter( 33 lazy, 34 "gOhttpRelayUrl", 35 "toolkit.shopping.ohttpRelayURL" 36 ); 37 XPCOMUtils.defineLazyPreferenceGetter( 38 lazy, 39 "gOhttpGatewayKeyUrl", 40 "toolkit.shopping.ohttpConfigURL" 41 ); 42 43 const MAX_CONVERSIONS = 2; 44 const DAY_IN_MILLI = 1000 * 60 * 60 * 24; 45 const CONVERSION_RESET_MILLI = 7 * DAY_IN_MILLI; 46 const DAP_TIMEOUT_MILLI = 30000; 47 48 /** 49 * 50 */ 51 export class PrivateAttributionService { 52 constructor({ 53 dapTelemetrySender, 54 dateProvider, 55 testForceEnabled, 56 testDapOptions, 57 } = {}) { 58 this._dapTelemetrySender = dapTelemetrySender; 59 this._dateProvider = dateProvider ?? Date; 60 this._testForceEnabled = testForceEnabled; 61 this._testDapOptions = testDapOptions; 62 63 this.dbName = "PrivateAttribution"; 64 this.impressionStoreName = "impressions"; 65 this.budgetStoreName = "budgets"; 66 this.storeNames = [this.impressionStoreName, this.budgetStoreName]; 67 this.dbVersion = 1; 68 this.models = { 69 default: "lastImpression", 70 view: "lastView", 71 click: "lastClick", 72 }; 73 } 74 75 get dapTelemetrySender() { 76 return this._dapTelemetrySender || lazy.DAPTelemetrySender; 77 } 78 79 now() { 80 return this._dateProvider.now(); 81 } 82 83 async onAttributionEvent(sourceHost, type, index, ad, targetHost) { 84 if (!this.isEnabled()) { 85 return; 86 } 87 88 const now = this.now(); 89 90 try { 91 const impressionStore = await this.getImpressionStore(); 92 93 const impression = await this.getImpression(impressionStore, ad, { 94 index, 95 target: targetHost, 96 source: sourceHost, 97 }); 98 99 const prop = this.getModelProp(type); 100 impression.index = index; 101 impression.lastImpression = now; 102 impression[prop] = now; 103 104 await this.updateImpression(impressionStore, ad, impression); 105 } catch (e) { 106 console.error(e); 107 } 108 } 109 110 async onAttributionConversion( 111 targetHost, 112 task, 113 histogramSize, 114 lookbackDays, 115 impressionType, 116 ads, 117 sourceHosts 118 ) { 119 if (!this.isEnabled()) { 120 return; 121 } 122 123 const now = this.now(); 124 125 try { 126 const budget = await this.getBudget(targetHost, now); 127 const impression = await this.findImpression( 128 ads, 129 targetHost, 130 sourceHosts, 131 impressionType, 132 lookbackDays, 133 histogramSize, 134 now 135 ); 136 137 let index = 0; 138 let value = 0; 139 if (budget.conversions < MAX_CONVERSIONS && impression) { 140 index = impression.index; 141 value = 1; 142 } 143 144 await this.updateBudget(budget, value, targetHost); 145 await this.sendDapReport(task, index, histogramSize, value); 146 } catch (e) { 147 console.error(e); 148 } 149 } 150 151 async findImpression(ads, target, sources, model, days, histogramSize, now) { 152 let impressions = []; 153 154 const impressionStore = await this.getImpressionStore(); 155 156 // Get matching ad impressions 157 if (ads && ads.length) { 158 for (var i = 0; i < ads.length; i++) { 159 impressions = impressions.concat( 160 (await impressionStore.get(ads[i])) ?? [] 161 ); 162 } 163 } else { 164 impressions = (await impressionStore.getAll()).flat(1); 165 } 166 167 // Set attribution model properties 168 const prop = this.getModelProp(model); 169 170 // Find the most relevant impression 171 const lookbackWindow = now - days * DAY_IN_MILLI; 172 return ( 173 impressions 174 // Filter by target, sources, and lookback days 175 .filter( 176 impression => 177 impression.target === target && 178 (!sources || sources.includes(impression.source)) && 179 impression[prop] >= lookbackWindow && 180 impression.index < histogramSize 181 ) 182 // Get the impression with the most recent interaction 183 .reduce( 184 (cur, impression) => 185 !cur || impression[prop] > cur[prop] ? impression : cur, 186 null 187 ) 188 ); 189 } 190 191 async getImpression(impressionStore, ad, defaultImpression) { 192 const impressions = (await impressionStore.get(ad)) ?? []; 193 const impression = impressions.find(r => 194 this.compareImpression(r, defaultImpression) 195 ); 196 197 return impression ?? defaultImpression; 198 } 199 200 async updateImpression(impressionStore, key, impression) { 201 let impressions = (await impressionStore.get(key)) ?? []; 202 203 const i = impressions.findIndex(r => this.compareImpression(r, impression)); 204 if (i < 0) { 205 impressions.push(impression); 206 } else { 207 impressions[i] = impression; 208 } 209 210 await impressionStore.put(impressions, key); 211 } 212 213 compareImpression(cur, impression) { 214 return cur.source === impression.source && cur.target === impression.target; 215 } 216 217 async getBudget(target, now) { 218 const budgetStore = await this.getBudgetStore(); 219 const budget = await budgetStore.get(target); 220 221 if (!budget || now > budget.nextReset) { 222 return { 223 conversions: 0, 224 nextReset: now + CONVERSION_RESET_MILLI, 225 }; 226 } 227 228 return budget; 229 } 230 231 async updateBudget(budget, value, target) { 232 const budgetStore = await this.getBudgetStore(); 233 budget.conversions += value; 234 await budgetStore.put(budget, target); 235 } 236 237 async getImpressionStore() { 238 return await this.getStore(this.impressionStoreName); 239 } 240 241 async getBudgetStore() { 242 return await this.getStore(this.budgetStoreName); 243 } 244 245 async getStore(storeName) { 246 return (await this.db).objectStore(storeName, "readwrite"); 247 } 248 249 get db() { 250 return this._db || (this._db = this.createOrOpenDb()); 251 } 252 253 async createOrOpenDb() { 254 try { 255 return await this.openDatabase(); 256 } catch { 257 await lazy.IndexedDB.deleteDatabase(this.dbName); 258 return this.openDatabase(); 259 } 260 } 261 262 async openDatabase() { 263 return await lazy.IndexedDB.open(this.dbName, this.dbVersion, db => { 264 this.storeNames.forEach(store => { 265 if (!db.objectStoreNames.contains(store)) { 266 db.createObjectStore(store); 267 } 268 }); 269 }); 270 } 271 272 async sendDapReport(id, index, size, value) { 273 const task = { 274 id, 275 vdaf: "sumvec", 276 bits: 8, 277 length: size, 278 time_precision: 60, 279 }; 280 281 const measurement = new Array(size).fill(0); 282 measurement[index] = value; 283 284 let options = { 285 timeout: DAP_TIMEOUT_MILLI, 286 ohttp_relay: lazy.gOhttpRelayUrl, 287 ...this._testDapOptions, 288 }; 289 290 if (options.ohttp_relay) { 291 // Fetch the OHTTP-Gateway-HPKE key if not provided yet. 292 if (!options.ohttp_hpke) { 293 const controller = new AbortController(); 294 lazy.setTimeout(() => controller.abort(), DAP_TIMEOUT_MILLI); 295 296 options.ohttp_hpke = await lazy.HPKEConfigManager.get( 297 lazy.gOhttpGatewayKeyUrl, 298 { 299 maxAge: DAY_IN_MILLI, 300 abortSignal: controller.signal, 301 } 302 ); 303 } 304 } else if (!this._testForceEnabled) { 305 // Except for testing, do no allow PPA to bypass OHTTP. 306 throw new Error("PPA requires an OHTTP relay for submission"); 307 } 308 309 await this.dapTelemetrySender.sendDAPMeasurement( 310 task, 311 measurement, 312 options 313 ); 314 } 315 316 getModelProp(type) { 317 return this.models[type ? type : "default"]; 318 } 319 320 isEnabled() { 321 return ( 322 this._testForceEnabled || 323 (lazy.gIsTelemetrySendingEnabled && 324 AppConstants.MOZ_TELEMETRY_REPORTING && 325 lazy.gIsPPAEnabled) 326 ); 327 } 328 329 QueryInterface = ChromeUtils.generateQI([Ci.nsIPrivateAttributionService]); 330 }