Skip to content

Commit

Permalink
Adjust ad click attribution pixel logic (#2588)
Browse files Browse the repository at this point in the history
We recently added "pixel" requests to the mechanism we rely on to
support ad click attribution in DuckDuckGo products. These help
DuckDuckGo validate that the mechanism's logic is working correctly.

A few further adjustments are required:

1. The 'm_ad_click_active' pixel should only fire (and the
   'm_pageloads_with_ad_attribution' counter incremented) when a request
   is actually allowed by the mechanism.
2. The browser name (e.g. "chrome", "edge", etc.) should be appended
   to the pixel names.
3. The extension's version number (e.g. "2024.7.10") should be
   included with some of the pixels.

Further reading:
 - f875594
 - https://duckduckgo.com/duckduckgo-help-pages/privacy/web-tracking-protections/#duckduckgo-private-search-ads
 - https://improving.duckduckgo.com/
  • Loading branch information
kzar authored Jul 23, 2024
1 parent 9482acc commit 7f0bfbc
Show file tree
Hide file tree
Showing 7 changed files with 330 additions and 94 deletions.
15 changes: 14 additions & 1 deletion integration-test/click-attribution.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ for (let i = 0; i < testCases.length; i++) {
}

test.describe('Ad click blocking', () => {
let extensionVersion
const backgroundPixels = []
let clearBackgroundPixels

test.beforeEach(async ({ context, backgroundNetworkContext }) => {
test.beforeEach(async ({ context, backgroundPage, backgroundNetworkContext }) => {
if (clearBackgroundPixels) {
clearBackgroundPixels()
}
Expand All @@ -34,6 +35,10 @@ test.describe('Ad click blocking', () => {
)

await backgroundWait.forExtensionLoaded(context)

extensionVersion = await backgroundPage.evaluate(
() => chrome.runtime.getManifest().version
)
})

/**
Expand Down Expand Up @@ -119,6 +124,14 @@ test.describe('Ad click blocking', () => {
expect(backgroundPixels.length, `${step.name} expects the right number of pixels to fire`)
.toEqual(step.expected.pixels.length)
for (let i = 0; i < step.expected.pixels.length; i++) {
// Integration tests only run on Chrome so far, so this is a
// safe assumption for now.
step.expected.pixels[i].name += '_extension_chrome'

if (step.expected.pixels[i]?.params?.appVersion === 'EXTENSION_VERSION') {
step.expected.pixels[i].params.appVersion = extensionVersion
}

expect(backgroundPixels[i], `${step.name} expects pixel "${step.expected.pixels[i].name}" to have fired correctly.`)
.toEqual(step.expected.pixels[i])
}
Expand Down
30 changes: 24 additions & 6 deletions integration-test/data/click-attribution-pixels.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,17 @@ exports.expectedPixels = [
{
name: 'm_ad_click_detected',
params: {
appVersion: 'EXTENSION_VERSION',
domainDetection: 'heuristic_only',
heuristicDetectionEnabled: '1',
domainDetectionEnabled: '1'
}
},
{
name: 'm_ad_click_active',
params: { }
params: {
appVersion: 'EXTENSION_VERSION'
}
},
// After 24 hours the aggregate pixel fires.
{
Expand All @@ -53,14 +56,17 @@ exports.expectedPixels = [
{
name: 'm_ad_click_detected',
params: {
appVersion: 'EXTENSION_VERSION',
domainDetection: 'heuristic_only',
heuristicDetectionEnabled: '1',
domainDetectionEnabled: '1'
}
},
{
name: 'm_ad_click_active',
params: { }
params: {
appVersion: 'EXTENSION_VERSION'
}
},
// After 24 hours the aggregate pixel fires.
{
Expand All @@ -83,14 +89,17 @@ exports.expectedPixels = [
{
name: 'm_ad_click_detected',
params: {
appVersion: 'EXTENSION_VERSION',
domainDetection: 'matched',
heuristicDetectionEnabled: '1',
domainDetectionEnabled: '1'
}
},
{
name: 'm_ad_click_active',
params: { }
params: {
appVersion: 'EXTENSION_VERSION'
}
},
// After 24 hours the aggregate pixel fires.
{
Expand All @@ -113,14 +122,17 @@ exports.expectedPixels = [
{
name: 'm_ad_click_detected',
params: {
appVersion: 'EXTENSION_VERSION',
domainDetection: 'matched',
heuristicDetectionEnabled: '1',
domainDetectionEnabled: '1'
}
},
{
name: 'm_ad_click_active',
params: { }
params: {
appVersion: 'EXTENSION_VERSION'
}
},
// After 24 hours the aggregate pixel fires.
{
Expand All @@ -142,14 +154,17 @@ exports.expectedPixels = [
{
name: 'm_ad_click_detected',
params: {
appVersion: 'EXTENSION_VERSION',
domainDetection: 'heuristic_only',
heuristicDetectionEnabled: '1',
domainDetectionEnabled: '1'
}
},
{
name: 'm_ad_click_active',
params: { }
params: {
appVersion: 'EXTENSION_VERSION'
}
}
],
// Navigated to another page, two more requests allowed.
Expand All @@ -174,14 +189,17 @@ exports.expectedPixels = [
{
name: 'm_ad_click_detected',
params: {
appVersion: 'EXTENSION_VERSION',
domainDetection: 'heuristic_only',
heuristicDetectionEnabled: '1',
domainDetectionEnabled: '1'
}
},
{
name: 'm_ad_click_active',
params: { }
params: {
appVersion: 'EXTENSION_VERSION'
}
}
],
// Navigated to another page, two more requests allowed.
Expand Down
46 changes: 26 additions & 20 deletions shared/js/background/classes/ad-click-attribution-policy.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const { getFeatureSettings, getBaseDomain } = require('../utils')
const browserWrapper = require('../wrapper')
const { getNextSessionRuleId } = require('../dnr-session-rule-id')

const appVersion = browserWrapper.getExtensionVersion()
const manifestVersion = browserWrapper.getManifestVersion()

/**
Expand Down Expand Up @@ -254,6 +255,7 @@ export class AdClick {
}

sendPixelRequest('m_ad_click_detected', {
appVersion,
domainDetection,
heuristicDetectionEnabled: this.heuristicDetectionEnabled ? '1' : '0',
domainDetectionEnabled: this.domainDetectionEnabled ? '1' : 0
Expand Down Expand Up @@ -293,33 +295,37 @@ export class AdClick {
}

/**
* For use of checking if a load should be permitted for a tab.
* Returns true if the policy hasn't expired and the ad domain matches the tab domain.
* Check if this AdClick is active for the tab and currently allowing
* requests. Returns true if it hasn't expired and the ad domain matches the
* tab domain.
* @param {Tab} tab
* @returns {boolean}
*/
allowAdAttribution (tab) {
if (tab.site.baseDomain !== this.adBaseDomain) return false
const allowed = this.hasNotExpired()

if (allowed) {
// If this is the first ad attribution request allowed for the tab,
// increment the count sent with the
// 'm_pageloads_with_ad_attribution' pixel.
if (!tab.firstAdAttributionAllowed) {
settings.incrementNumericSetting('m_pageloads_with_ad_attribution.count')
tab.firstAdAttributionAllowed = true
}
return tab.site.baseDomain === this.adBaseDomain && this.hasNotExpired()
}

// If this is the first ad attribution request allowed for this
// AdClick, send the 'm_ad_click_active' pixel.
if (!this.adClickActivePixelSent) {
sendPixelRequest('m_ad_click_active')
this.adClickActivePixelSent = true
}
/**
* Called when a request has been allowed by the AdClickAttributionPolicy
* (only happens when this AdClick is active for the tab). Takes care of
* some housekeeping for the ad_attribution pixels.
* @param {Tab} tab
*/
requestWasAllowed (tab) {
// If this is the first ad attribution request allowed for the tab,
// increment the count sent with the 'm_pageloads_with_ad_attribution'
// pixel.
if (!tab.firstAdAttributionAllowed) {
settings.incrementNumericSetting('m_pageloads_with_ad_attribution.count')
tab.firstAdAttributionAllowed = true
}

return allowed
// If this is the first ad attribution request allowed for this AdClick,
// send the 'm_ad_click_active' pixel.
if (!this.adClickActivePixelSent) {
sendPixelRequest('m_ad_click_active', { appVersion })
this.adClickActivePixelSent = true
}
}

getAdClickDNR (tabId) {
Expand Down
7 changes: 6 additions & 1 deletion shared/js/background/classes/tab.js
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,12 @@ class Tab {
allowAdAttribution (resourcePath) {
if (!this.site.isFeatureEnabled('adClickAttribution') || !this.adClick || !this.adClick.allowAdAttribution(this)) return false
const policy = this.getAdClickAttributionPolicy()
return policy.resourcePermitted(resourcePath)
const permitted = policy.resourcePermitted(resourcePath)
if (permitted) {
this.adClick.requestWasAllowed(this)
}

return permitted
}

updateSite (url) {
Expand Down
30 changes: 9 additions & 21 deletions shared/js/background/components/email-autofill.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { sendPixelRequest } from '../pixels'
import { registerMessageHandler } from '../message-handlers'
import { getDomain } from 'tldts'
import tdsStorage from '../storage/tds'
import { getBrowserName, isInstalledWithinDays, sendTabMessage } from '../utils'
import { isInstalledWithinDays, sendTabMessage } from '../utils'
import { getFromSessionStorage, setToSessionStorage, removeFromSessionStorage, createAlarm } from '../wrapper'

/**
Expand Down Expand Up @@ -168,25 +168,25 @@ export default class EmailAutofill {
const { pixelName } = options
switch (pixelName) {
case 'autofill_show':
this.fireAutofillPixel('email_tooltip_show_extension')
this.fireAutofillPixel('email_tooltip_show')
break
case 'autofill_private_address':
this.fireAutofillPixel('email_filled_random_extension', true)
this.fireAutofillPixel('email_filled_random', true)
break
case 'autofill_personal_address':
this.fireAutofillPixel('email_filled_main_extension', true)
this.fireAutofillPixel('email_filled_main', true)
break
case 'incontext_show':
fireIncontextSignupPixel('incontext_show_extension')
sendPixelRequest('incontext_show')
break
case 'incontext_primary_cta':
fireIncontextSignupPixel('incontext_primary_cta_extension')
sendPixelRequest('incontext_primary_cta')
break
case 'incontext_dismiss_persisted':
fireIncontextSignupPixel('incontext_dismiss_persisted_extension')
sendPixelRequest('incontext_dismiss_persisted')
break
case 'incontext_close_x':
fireIncontextSignupPixel('incontext_close_x_extension')
sendPixelRequest('incontext_close_x')
break
default:
getFromSessionStorage('dev').then(isDev => {
Expand All @@ -196,14 +196,12 @@ export default class EmailAutofill {
}

fireAutofillPixel (pixel, shouldUpdateLastUsed = false) {
const browserName = getBrowserName() ?? 'unknown'

const userData = this.settings.getSetting('userData')
if (!userData?.userName) return

const lastAddressUsedAt = this.settings.getSetting('lastAddressUsedAt') ?? ''

sendPixelRequest(getFullPixelName(pixel, browserName), { duck_address_last_used: lastAddressUsedAt, cohort: userData.cohort })
sendPixelRequest(pixel, { duck_address_last_used: lastAddressUsedAt, cohort: userData.cohort })
if (shouldUpdateLastUsed) {
this.settings.updateSetting('lastAddressUsedAt', currentDate())
}
Expand Down Expand Up @@ -296,16 +294,6 @@ function currentDate () {
})
}

const getFullPixelName = (name, browserName) => {
return `${name}_${browserName.toLowerCase()}`
}

const fireIncontextSignupPixel = (pixel, params) => {
const browserName = getBrowserName() ?? 'unknown'

sendPixelRequest(getFullPixelName(pixel, browserName), params)
}

/**
* Given a username, returns a valid email address with the duck domain
* @param {string} address
Expand Down
6 changes: 5 additions & 1 deletion shared/js/background/pixels.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* global BUILD_TARGET */
import load from './load'
import { getBrowserName } from './utils'

/**
*
Expand All @@ -20,8 +21,11 @@ export function sendPixelRequest (pixelName, params = {}) {
return
}

const browserName = getBrowserName() || 'unknown'

const randomNum = Math.ceil(Math.random() * 1e7)
const searchParams = new URLSearchParams(Object.entries(params))
const url = getURL(pixelName) + `?${randomNum}&${searchParams.toString()}`
const url = getURL(`${pixelName}_extension_${browserName}`) +
`?${randomNum}&${searchParams.toString()}`
return load.url(url)
}
Loading

0 comments on commit 7f0bfbc

Please sign in to comment.