commit f1beabcd7dab5a8b8fc20d3a7f4b8a1d33459f32
parent 6d67e830479391fd3e4f0de0fb1cb59852494e52
Author: Daniel Mueller <dmueller@mozilla.com>
Date: Mon, 6 Oct 2025 21:11:29 +0000
Bug 1974059 - Attribution service with specific to Newtab r=mconley,home-newtab-reviewers
Differential Revision: https://phabricator.services.mozilla.com/D255015
Diffstat:
3 files changed, 993 insertions(+), 0 deletions(-)
diff --git a/browser/extensions/newtab/lib/NewTabAttributionService.sys.mjs b/browser/extensions/newtab/lib/NewTabAttributionService.sys.mjs
@@ -0,0 +1,379 @@
+/* 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);
+ }
+ }
+
+ /**
+ * onAttributionClear
+ */
+ async onAttributionClear() {}
+
+ /**
+ * onAttributionReset
+ */
+ async onAttributionReset() {}
+
+ /**
+ * 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<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 {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<impression>}
+ */
+ 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<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;
+ }
+}
diff --git a/browser/extensions/newtab/test/xpcshell/test_NewTabAttributionService.js b/browser/extensions/newtab/test/xpcshell/test_NewTabAttributionService.js
@@ -0,0 +1,612 @@
+/* Any copyright is dedicated to the Public Domain.
+https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ NewTabAttributionService:
+ "resource://newtab/lib/NewTabAttributionService.sys.mjs",
+});
+
+const { HttpServer } = ChromeUtils.importESModule(
+ "resource://testing-common/httpd.sys.mjs"
+);
+
+const BinaryInputStream = Components.Constructor(
+ "@mozilla.org/binaryinputstream;1",
+ "nsIBinaryInputStream",
+ "setInputStream"
+);
+
+const PREF_LEADER = "toolkit.telemetry.dap.leader.url";
+const PREF_HELPER = "toolkit.telemetry.dap.helper.url";
+const TASK_ID = "DSZGMFh26hBYXNaKvhL_N4AHA3P5lDn19on1vFPBxJM";
+const MAX_CONVERSIONS = 5;
+const DAY_IN_MILLI = 1000 * 60 * 60 * 24;
+const LOOKBACK_DAYS = 1;
+const MAX_LOOKBACK_DAYS = 30;
+const HISTOGRAM_SIZE = 5;
+
+class MockDateProvider {
+ constructor() {
+ this._now = Date.now();
+ }
+
+ now() {
+ return this._now;
+ }
+
+ add(interval_ms) {
+ this._now += interval_ms;
+ }
+}
+
+class MockDAPTelemetrySender {
+ constructor() {
+ this.receivedMeasurements = [];
+ }
+
+ async sendDAPMeasurement(task, measurement, _options) {
+ this.receivedMeasurements.push({
+ task,
+ measurement,
+ });
+ }
+}
+
+class MockServer {
+ constructor() {
+ this.receivedReports = [];
+
+ const server = new HttpServer();
+
+ server.registerPrefixHandler(
+ "/leader_endpoint/tasks/",
+ this.uploadHandler.bind(this)
+ );
+
+ this._server = server;
+ }
+
+ start() {
+ this._server.start(-1);
+
+ this.orig_leader = Services.prefs.getStringPref(PREF_LEADER);
+ this.orig_helper = Services.prefs.getStringPref(PREF_HELPER);
+
+ const i = this._server.identity;
+ const serverAddr = `${i.primaryScheme}://${i.primaryHost}:${i.primaryPort}`;
+ Services.prefs.setStringPref(PREF_LEADER, `${serverAddr}/leader_endpoint`);
+ Services.prefs.setStringPref(PREF_HELPER, `${serverAddr}/helper_endpoint`);
+ }
+
+ async stop() {
+ Services.prefs.setStringPref(PREF_LEADER, this.orig_leader);
+ Services.prefs.setStringPref(PREF_HELPER, this.orig_helper);
+
+ await this._server.stop();
+ }
+
+ uploadHandler(request, response) {
+ let body = new BinaryInputStream(request.bodyInputStream);
+
+ this.receivedReports.push({
+ contentType: request.getHeader("Content-Type"),
+ size: body.available(),
+ });
+
+ response.setStatusLine(request.httpVersion, 200);
+ }
+}
+
+add_setup(async function () {
+ do_get_profile();
+});
+
+add_task(async function testSuccessfulConversion() {
+ const mockSender = new MockDAPTelemetrySender();
+ const privateAttribution = new NewTabAttributionService({
+ dapTelemetrySender: mockSender,
+ });
+
+ const partnerIdentifier = "partner_identifier";
+ const conversionSettings = {
+ task_id: TASK_ID,
+ vdaf: "histogram",
+ bits: 1,
+ length: HISTOGRAM_SIZE,
+ time_precision: 60,
+ default_measurement: 0,
+ index: 1,
+ };
+
+ await privateAttribution.onAttributionEvent("view", {
+ partner_id: partnerIdentifier,
+ conversion: conversionSettings,
+ });
+
+ await privateAttribution.onAttributionEvent("click", {
+ partner_id: partnerIdentifier,
+ conversion: conversionSettings,
+ });
+
+ await privateAttribution.onAttributionConversion(
+ partnerIdentifier,
+ LOOKBACK_DAYS,
+ "view"
+ );
+
+ const expectedMeasurement = {
+ task: {
+ id: conversionSettings.task_id,
+ vdaf: conversionSettings.vdaf,
+ bits: conversionSettings.bits,
+ length: conversionSettings.length,
+ time_precision: conversionSettings.time_precision,
+ },
+ measurement: conversionSettings.index,
+ };
+
+ const receivedMeasurement = mockSender.receivedMeasurements.pop();
+ Assert.deepEqual(receivedMeasurement, expectedMeasurement);
+ Assert.equal(mockSender.receivedMeasurements.length, 0);
+});
+
+add_task(async function testConversionWithoutImpression() {
+ const mockSender = new MockDAPTelemetrySender();
+ const privateAttribution = new NewTabAttributionService({
+ dapTelemetrySender: mockSender,
+ });
+
+ const partnerIdentifier = "partner_identifier_no_impression";
+
+ await privateAttribution.onAttributionConversion(
+ partnerIdentifier,
+ LOOKBACK_DAYS,
+ "view"
+ );
+
+ Assert.equal(mockSender.receivedMeasurements.length, 0);
+});
+
+add_task(async function testConversionWithInvalidLookbackDays() {
+ const mockSender = new MockDAPTelemetrySender();
+ const privateAttribution = new NewTabAttributionService({
+ dapTelemetrySender: mockSender,
+ });
+
+ const partnerIdentifier = "partner_identifier";
+ const conversionSettings = {
+ task_id: TASK_ID,
+ vdaf: "histogram",
+ bits: 1,
+ length: HISTOGRAM_SIZE,
+ time_precision: 60,
+ default_measurement: 0,
+ index: 1,
+ };
+
+ await privateAttribution.onAttributionEvent("view", {
+ partner_id: partnerIdentifier,
+ conversion: conversionSettings,
+ });
+
+ await privateAttribution.onAttributionConversion(
+ partnerIdentifier,
+ MAX_LOOKBACK_DAYS + 1,
+ "view"
+ );
+
+ Assert.equal(mockSender.receivedMeasurements.length, 0);
+});
+
+add_task(async function testSelectionByLastView() {
+ const mockSender = new MockDAPTelemetrySender();
+ const mockDateProvider = new MockDateProvider();
+ const privateAttribution = new NewTabAttributionService({
+ dapTelemetrySender: mockSender,
+ dateProvider: mockDateProvider,
+ });
+
+ const partnerIdentifier = "partner_identifier_last_view";
+ const conversionSettings = {
+ task_id: TASK_ID,
+ vdaf: "histogram",
+ bits: 1,
+ length: HISTOGRAM_SIZE,
+ time_precision: 60,
+ default_measurement: 0,
+ index: 1,
+ };
+ const selectedViewIndex = 1;
+ const ignoredViewIndex = 2;
+ const clickIndex = 3;
+
+ // View event that will be ignored, as a more recent view will exist
+ await privateAttribution.onAttributionEvent("view", {
+ partner_id: partnerIdentifier,
+ conversion: {
+ ...conversionSettings,
+ index: ignoredViewIndex,
+ },
+ });
+
+ // step forward time
+ mockDateProvider.add(10);
+
+ // View event that will be selected, as no more recent view exists
+ await privateAttribution.onAttributionEvent("view", {
+ partner_id: partnerIdentifier,
+ conversion: {
+ ...conversionSettings,
+ index: selectedViewIndex,
+ },
+ });
+
+ // step forward time
+ mockDateProvider.add(10);
+
+ // Click event that will be ignored because the match type is "view"
+ await privateAttribution.onAttributionEvent("click", {
+ partner_id: partnerIdentifier,
+ conversion: {
+ ...conversionSettings,
+ index: clickIndex,
+ },
+ });
+
+ // Conversion filtering for "view" finds the view event
+ await privateAttribution.onAttributionConversion(
+ partnerIdentifier,
+ LOOKBACK_DAYS,
+ "view"
+ );
+
+ let receivedMeasurement = mockSender.receivedMeasurements.pop();
+ Assert.deepEqual(receivedMeasurement.measurement, selectedViewIndex);
+ Assert.equal(mockSender.receivedMeasurements.length, 0);
+});
+
+add_task(async function testSelectionByLastClick() {
+ const mockSender = new MockDAPTelemetrySender();
+ const mockDateProvider = new MockDateProvider();
+ const privateAttribution = new NewTabAttributionService({
+ dapTelemetrySender: mockSender,
+ dateProvider: mockDateProvider,
+ });
+
+ const partnerIdentifier = "partner_identifier_last_click";
+ const conversionSettings = {
+ task_id: TASK_ID,
+ vdaf: "histogram",
+ bits: 1,
+ length: HISTOGRAM_SIZE,
+ time_precision: 60,
+ default_measurement: 0,
+ index: 1,
+ };
+ const viewIndex = 1;
+ const ignoredClickIndex = 2;
+ const selectedClickIndex = 3;
+
+ // Click event that will be ignored, as a more recent click will exist
+ await privateAttribution.onAttributionEvent("click", {
+ partner_id: partnerIdentifier,
+ conversion: {
+ ...conversionSettings,
+ index: ignoredClickIndex,
+ },
+ });
+
+ // step forward time
+ mockDateProvider.add(10);
+
+ // Click event that will be selected, as no more recent click exists
+ await privateAttribution.onAttributionEvent("click", {
+ partner_id: partnerIdentifier,
+ conversion: {
+ ...conversionSettings,
+ index: selectedClickIndex,
+ },
+ });
+
+ // step forward time
+ mockDateProvider.add(10);
+
+ // View event that will be ignored because the match type is "click"
+ await privateAttribution.onAttributionEvent("view", {
+ partner_id: partnerIdentifier,
+ conversion: {
+ ...conversionSettings,
+ index: viewIndex,
+ },
+ });
+
+ // Conversion filtering for "click" finds the click event
+ await privateAttribution.onAttributionConversion(
+ partnerIdentifier,
+ LOOKBACK_DAYS,
+ "click"
+ );
+
+ let receivedMeasurement = mockSender.receivedMeasurements.pop();
+ Assert.deepEqual(receivedMeasurement.measurement, selectedClickIndex);
+ Assert.equal(mockSender.receivedMeasurements.length, 0);
+});
+
+add_task(async function testSelectionByLastTouch() {
+ const mockSender = new MockDAPTelemetrySender();
+ const mockDateProvider = new MockDateProvider();
+ const privateAttribution = new NewTabAttributionService({
+ dapTelemetrySender: mockSender,
+ dateProvider: mockDateProvider,
+ });
+
+ const partnerIdentifier = "partner_identifier_last_touch";
+ const conversionSettings = {
+ task_id: TASK_ID,
+ vdaf: "histogram",
+ bits: 1,
+ length: HISTOGRAM_SIZE,
+ time_precision: 60,
+ default_measurement: 0,
+ index: 1,
+ };
+ const viewIndex = 1;
+ const clickIndex = 2;
+
+ // Click at clickIndex
+ await privateAttribution.onAttributionEvent("click", {
+ partner_id: partnerIdentifier,
+ conversion: {
+ ...conversionSettings,
+ index: clickIndex,
+ },
+ });
+
+ // step forward time so the view event occurs most recently
+ mockDateProvider.add(10);
+
+ // View at viewIndex
+ await privateAttribution.onAttributionEvent("view", {
+ partner_id: partnerIdentifier,
+ conversion: {
+ ...conversionSettings,
+ index: viewIndex,
+ },
+ });
+
+ // Conversion filtering for "default" finds the view event
+ await privateAttribution.onAttributionConversion(
+ partnerIdentifier,
+ LOOKBACK_DAYS,
+ "default"
+ );
+
+ let receivedMeasurement = mockSender.receivedMeasurements.pop();
+ Assert.deepEqual(receivedMeasurement.measurement, viewIndex);
+ Assert.equal(mockSender.receivedMeasurements.length, 0);
+});
+
+add_task(async function testSelectionByPartnerId() {
+ const mockSender = new MockDAPTelemetrySender();
+ const mockDateProvider = new MockDateProvider();
+ const privateAttribution = new NewTabAttributionService({
+ dapTelemetrySender: mockSender,
+ dateProvider: mockDateProvider,
+ });
+
+ const partnerIdentifier1 = "partner_identifier_1";
+ const partnerIdentifier2 = "partner_identifier_2";
+ const conversionSettings = {
+ task_id: TASK_ID,
+ vdaf: "histogram",
+ bits: 1,
+ length: HISTOGRAM_SIZE,
+ time_precision: 60,
+ default_measurement: 0,
+ index: 1,
+ };
+ const partner1Index = 1;
+ const partner2Index = 2;
+
+ // view event associated with partner 1
+ await privateAttribution.onAttributionEvent("view", {
+ partner_id: partnerIdentifier1,
+ conversion: {
+ ...conversionSettings,
+ index: partner1Index,
+ },
+ });
+
+ // step forward time so the partner 2 event occurs most recently
+ mockDateProvider.add(10);
+
+ // view event associated with partner 2
+ await privateAttribution.onAttributionEvent("view", {
+ partner_id: partnerIdentifier2,
+ conversion: {
+ ...conversionSettings,
+ index: partner2Index,
+ },
+ });
+
+ // Conversion filtering for "default" finds the correct view event
+ await privateAttribution.onAttributionConversion(
+ partnerIdentifier1,
+ LOOKBACK_DAYS,
+ "default"
+ );
+
+ let receivedMeasurement = mockSender.receivedMeasurements.pop();
+ Assert.deepEqual(receivedMeasurement.measurement, partner1Index);
+ Assert.equal(mockSender.receivedMeasurements.length, 0);
+});
+
+add_task(async function testExpiredImpressions() {
+ const mockSender = new MockDAPTelemetrySender();
+ const mockDateProvider = new MockDateProvider();
+ const privateAttribution = new NewTabAttributionService({
+ dapTelemetrySender: mockSender,
+ dateProvider: mockDateProvider,
+ });
+
+ const partnerIdentifier = "partner_identifier";
+ const conversionSettings = {
+ task_id: TASK_ID,
+ vdaf: "histogram",
+ bits: 1,
+ length: HISTOGRAM_SIZE,
+ time_precision: 60,
+ default_measurement: 0,
+ index: 1,
+ };
+
+ // Register impression
+ await privateAttribution.onAttributionEvent("view", {
+ partner_id: partnerIdentifier,
+ conversion: conversionSettings,
+ });
+
+ // Fast-forward time by LOOKBACK_DAYS days + 1 ms
+ mockDateProvider.add(LOOKBACK_DAYS * DAY_IN_MILLI + 1);
+
+ // Conversion doesn't match expired impression
+ await privateAttribution.onAttributionConversion(
+ partnerIdentifier,
+ LOOKBACK_DAYS,
+ "view"
+ );
+
+ Assert.equal(mockSender.receivedMeasurements.length, 0);
+});
+
+add_task(async function testConversionBudget() {
+ const mockSender = new MockDAPTelemetrySender();
+ const privateAttribution = new NewTabAttributionService({
+ dapTelemetrySender: mockSender,
+ });
+
+ const partnerIdentifier = "partner_identifier_budget";
+ const conversionSettings = {
+ task_id: TASK_ID,
+ vdaf: "histogram",
+ bits: 1,
+ length: HISTOGRAM_SIZE,
+ time_precision: 60,
+ default_measurement: 0,
+ index: 1,
+ };
+
+ await privateAttribution.onAttributionEvent("view", {
+ partner_id: partnerIdentifier,
+ conversion: conversionSettings,
+ });
+
+ // Measurements uploaded for conversions up to MAX_CONVERSIONS
+ for (let i = 0; i < MAX_CONVERSIONS; i++) {
+ await privateAttribution.onAttributionConversion(
+ partnerIdentifier,
+ LOOKBACK_DAYS,
+ "view"
+ );
+
+ const receivedMeasurement = mockSender.receivedMeasurements.pop();
+ Assert.deepEqual(receivedMeasurement.measurement, conversionSettings.index);
+ Assert.equal(mockSender.receivedMeasurements.length, 0);
+ }
+
+ // default report uploaded on subsequent conversions
+ await privateAttribution.onAttributionConversion(
+ partnerIdentifier,
+ LOOKBACK_DAYS,
+ "view"
+ );
+
+ const receivedMeasurement = mockSender.receivedMeasurements.pop();
+ Assert.deepEqual(
+ receivedMeasurement.measurement,
+ conversionSettings.default_measurement
+ );
+ Assert.equal(mockSender.receivedMeasurements.length, 0);
+});
+
+add_task(async function testHistogramSize() {
+ const mockSender = new MockDAPTelemetrySender();
+ const privateAttribution = new NewTabAttributionService({
+ dapTelemetrySender: mockSender,
+ });
+
+ const partnerIdentifier = "partner_identifier_bad_settings";
+ const conversionSettings = {
+ task_id: TASK_ID,
+ vdaf: "histogram",
+ bits: 1,
+ length: HISTOGRAM_SIZE,
+ time_precision: 60,
+ default_measurement: 0,
+ // Zero-based index equal to histogram size is out of bounds
+ index: HISTOGRAM_SIZE,
+ };
+
+ await privateAttribution.onAttributionEvent("view", {
+ partner_id: partnerIdentifier,
+ conversion: conversionSettings,
+ });
+
+ await privateAttribution.onAttributionConversion(
+ partnerIdentifier,
+ LOOKBACK_DAYS,
+ "view"
+ );
+
+ const receivedMeasurement = mockSender.receivedMeasurements.pop();
+ Assert.deepEqual(
+ receivedMeasurement.measurement,
+ conversionSettings.default_measurement
+ );
+ Assert.equal(mockSender.receivedMeasurements.length, 0);
+});
+
+add_task(async function testWithRealDAPSender() {
+ // Omit mocking DAP telemetry sender in this test to defend against mock
+ // sender getting out of sync
+ const mockServer = new MockServer();
+ mockServer.start();
+
+ const privateAttribution = new NewTabAttributionService({});
+
+ const partnerIdentifier = "partner_identifier_real_dap";
+ const conversionSettings = {
+ task_id: TASK_ID,
+ vdaf: "histogram",
+ bits: 1,
+ length: HISTOGRAM_SIZE,
+ time_precision: 60,
+ default_measurement: 0,
+ index: 1,
+ };
+
+ await privateAttribution.onAttributionEvent("view", {
+ partner_id: partnerIdentifier,
+ conversion: conversionSettings,
+ });
+
+ await privateAttribution.onAttributionConversion(
+ partnerIdentifier,
+ LOOKBACK_DAYS,
+ "view"
+ );
+
+ await mockServer.stop();
+
+ Assert.equal(mockServer.receivedReports.length, 1);
+
+ const expectedReport = {
+ contentType: "application/dap-report",
+ size: 502,
+ };
+
+ const receivedReport = mockServer.receivedReports.pop();
+ Assert.deepEqual(receivedReport, expectedReport);
+});
diff --git a/browser/extensions/newtab/test/xpcshell/xpcshell.toml b/browser/extensions/newtab/test/xpcshell/xpcshell.toml
@@ -23,6 +23,8 @@ support-files = ["topstories.json"]
["test_LocalInferredRanking.js"]
+["test_NewTabAttributionService.js"]
+
["test_NewTabContentPing.js"]
["test_NewTabGleanUtils.js"]