├── impl
├── .gitignore
├── README.md
├── eslint.config.mjs
├── e2e-tests
│ ├── no-matching-impression.json
│ ├── CONFIG.json
│ ├── basic.json
│ ├── credit-longer-than-impressions.json
│ ├── multi-touch-divides-evenly.json
│ ├── multi-touch-same-histogram-index.json
│ ├── multi-touch-divides-evenly-unordered-credit.json
│ ├── priority.json
│ ├── clear-site-state.json
│ ├── simulate-multiple-buckets.json
│ ├── forget-one-site-conversions.json
│ ├── match-values.json
│ ├── conversion-sites.json
│ ├── lookback.json
│ ├── impression-sites.json
│ ├── expiry.json
│ ├── save-impression-errors.json
│ ├── impression-callers.json
│ ├── conversion-callers.json
│ ├── measure-conversion-errors.json
│ └── clear-site-data.json
├── tsconfig.json
├── webpack.config.js
├── package.json
├── src
│ ├── fixture.ts
│ ├── index.ts
│ ├── http.ts
│ ├── allocate.test.ts
│ ├── e2e.test.ts
│ ├── clear.test.ts
│ ├── http.test.ts
│ ├── simulator.ts
│ └── backend.ts
├── e2e.schema.json
└── index.html
├── .gitignore
├── .pr-preview.json
├── w3c.json
├── examples
└── examples.md
├── .github
└── workflows
│ ├── check.yml
│ ├── auto-publish.yml
│ ├── test-typescript.yml
│ └── deploy.yml
├── README.md
├── images
├── histogram.svg
├── value.svg
├── budget.svg
└── overview.svg
├── Makefile
└── process.md
/impl/.gitignore:
--------------------------------------------------------------------------------
1 | dist
2 | node_modules
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /build/
2 | /venv/
3 | *~
4 | *.bak
5 | .*.sw?
6 |
--------------------------------------------------------------------------------
/.pr-preview.json:
--------------------------------------------------------------------------------
1 | {
2 | "src_file": "api.bs",
3 | "type": "bikeshed"
4 | }
5 |
--------------------------------------------------------------------------------
/w3c.json:
--------------------------------------------------------------------------------
1 | {
2 | "group": "wg/pat",
3 | "contacts": ["seanturner", "aramzs"],
4 | "repo-type": "rec-track"
5 | }
6 |
--------------------------------------------------------------------------------
/impl/README.md:
--------------------------------------------------------------------------------
1 | Usage:
2 |
3 | ```sh
4 | npm install && npm run pack && npm run serve-local
5 | ```
6 |
7 | A live version of the simulator can be found at
8 | https://w3c.github.io/attribution/simulator.html.
9 |
--------------------------------------------------------------------------------
/examples/examples.md:
--------------------------------------------------------------------------------
1 | # Examples Folders
2 |
3 | We have been considering a variety of uses for Attribution, including more complicated use cases like [partial conversion values](https://github.com/w3c/attribution/issues/16) and multi-touch attribution. Please add examples here via PRs the group can discuss.
4 |
--------------------------------------------------------------------------------
/impl/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import eslint from "@eslint/js";
2 | import tseslint from "typescript-eslint";
3 |
4 | export default tseslint.config(...tseslint.configs.recommendedTypeChecked, {
5 | languageOptions: {
6 | parserOptions: {
7 | project: true,
8 | tsconfigRootDir: import.meta.dirname,
9 | },
10 | },
11 | });
12 |
--------------------------------------------------------------------------------
/impl/e2e-tests/no-matching-impression.json:
--------------------------------------------------------------------------------
1 | {
2 | "events": [
3 | {
4 | "seconds": 1,
5 | "site": "advertiser.example",
6 | "event": "measureConversion",
7 | "options": {
8 | "aggregationService": "https://agg-service.example",
9 | "histogramSize": 3
10 | },
11 | "expected": [0, 0, 0]
12 | }
13 | ]
14 | }
15 |
--------------------------------------------------------------------------------
/impl/e2e-tests/CONFIG.json:
--------------------------------------------------------------------------------
1 | {
2 | "aggregationServices": {
3 | "https://agg-service.example": "dap-15-histogram"
4 | },
5 | "epochStart": 0.5,
6 | "fairlyAllocateCreditFraction": 0.5,
7 | "maxConversionSitesPerImpression": 3,
8 | "maxConversionCallersPerImpression": 3,
9 | "maxCreditSize": 10,
10 | "maxLookbackDays": 30,
11 | "maxHistogramSize": 5,
12 | "privacyBudgetMicroEpsilons": 1000000,
13 | "privacyBudgetEpochDays": 7
14 | }
15 |
--------------------------------------------------------------------------------
/.github/workflows/check.yml:
--------------------------------------------------------------------------------
1 | name: "Check API"
2 |
3 | on:
4 | push:
5 | branches: [ "main" ]
6 | paths: [ "Makefile", "api.bs", "images/**", ".github/workflows/check.yml" ]
7 | pull_request:
8 | branches: [ "main" ]
9 | paths: [ "Makefile", "api.bs", "images/**", ".github/workflows/check.yml" ]
10 |
11 | jobs:
12 | build:
13 | name: "Build HTML"
14 | runs-on: ubuntu-latest
15 | steps:
16 | - uses: actions/checkout@v4
17 | - uses: actions/setup-python@v5
18 | with:
19 | python-version: '3.12'
20 | - uses: actions/cache@v4
21 | with:
22 | path: venv
23 | key: venv2-${{ hashFiles('Makefile') }}
24 | - run: make
25 |
--------------------------------------------------------------------------------
/impl/e2e-tests/basic.json:
--------------------------------------------------------------------------------
1 | {
2 | "events": [
3 | {
4 | "seconds": 1,
5 | "site": "publisher.example",
6 | "event": "saveImpression",
7 | "options": { "histogramIndex": 0 }
8 | },
9 | {
10 | "seconds": 2,
11 | "site": "publisher.example",
12 | "event": "saveImpression",
13 | "options": { "histogramIndex": 1 }
14 | },
15 | {
16 | "seconds": 3,
17 | "site": "advertiser.example",
18 | "event": "measureConversion",
19 | "options": {
20 | "aggregationService": "https://agg-service.example",
21 | "histogramSize": 3,
22 | "value": 5,
23 | "maxValue": 10
24 | },
25 | "expected": [0, 5, 0]
26 | }
27 | ]
28 | }
29 |
--------------------------------------------------------------------------------
/impl/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowUnreachableCode": false,
4 | "allowUnusedLabels": false,
5 | "esModuleInterop": true,
6 | "exactOptionalPropertyTypes": true,
7 | "forceConsistentCasingInFileNames": true,
8 | "lib": ["ES2024", "dom"],
9 | "module": "nodenext",
10 | "noFallthroughCasesInSwitch": true,
11 | "noImplicitOverride": true,
12 | "noImplicitReturns": true,
13 | "noPropertyAccessFromIndexSignature": true,
14 | "noUncheckedIndexedAccess": true,
15 | "noUnusedLocals": true,
16 | "noUnusedParameters": true,
17 | "outDir": "dist",
18 | "resolveJsonModule": true,
19 | "skipLibCheck": true,
20 | "strict": true,
21 | "target": "ES2022"
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/.github/workflows/auto-publish.yml:
--------------------------------------------------------------------------------
1 | # .github/workflows/auto-publish.yml
2 | name: CI
3 | on:
4 | pull_request: {}
5 | push:
6 | branches: [main]
7 |
8 | jobs:
9 | main:
10 | name: Build, Validate and Deploy
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v4
14 | - uses: w3c/spec-prod@v2
15 | with:
16 | # testing without github pages or figures dir on first pass
17 | # GH_PAGES_BRANCH: gh-pages
18 | W3C_ECHIDNA_TOKEN: ${{ secrets.ECHIDNA_TOKEN }}
19 | W3C_WG_DECISION_URL: https://github.com/w3c/patwg/issues/19
20 | TOOLCHAIN: bikeshed
21 | SOURCE: api.bs
22 | W3C_BUILD_OVERRIDE: |
23 | status: WD
24 | shortName: attribution
25 |
--------------------------------------------------------------------------------
/impl/webpack.config.js:
--------------------------------------------------------------------------------
1 | const HtmlWebpackPlugin = require("html-webpack-plugin");
2 | const path = require("path");
3 |
4 | module.exports = {
5 | entry: {
6 | simulator: "./src/simulator.ts",
7 | },
8 | module: {
9 | rules: [
10 | {
11 | test: /\.ts?$/,
12 | use: [
13 | {
14 | loader: "ts-loader",
15 | options: { onlyCompileBundledFiles: true },
16 | },
17 | ],
18 | },
19 | ],
20 | },
21 | resolve: {
22 | extensions: [".ts", ".js"],
23 | },
24 | plugins: [
25 | new HtmlWebpackPlugin({
26 | template: "./index.html",
27 | }),
28 | ],
29 | output: {
30 | filename: "[name].js",
31 | path: path.resolve(__dirname, "dist"),
32 | clean: true,
33 | },
34 | };
35 |
--------------------------------------------------------------------------------
/impl/e2e-tests/credit-longer-than-impressions.json:
--------------------------------------------------------------------------------
1 | {
2 | "events": [
3 | {
4 | "seconds": 1,
5 | "site": "publisher.example",
6 | "event": "saveImpression",
7 | "options": { "histogramIndex": 0 }
8 | },
9 | {
10 | "seconds": 2,
11 | "site": "publisher.example",
12 | "event": "saveImpression",
13 | "options": { "histogramIndex": 1 }
14 | },
15 | {
16 | "seconds": 3,
17 | "site": "advertiser.example",
18 | "event": "measureConversion",
19 | "options": {
20 | "aggregationService": "https://agg-service.example",
21 | "histogramSize": 4,
22 | "value": 12,
23 | "maxValue": 12,
24 | "credit": [6, 3, 3]
25 | },
26 | "expected": [4, 8, 0, 0]
27 | }
28 | ]
29 | }
30 |
--------------------------------------------------------------------------------
/.github/workflows/test-typescript.yml:
--------------------------------------------------------------------------------
1 | name: TypeScript Tests
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | paths:
7 | - '.github/workflow/test-typescript.yml'
8 | - 'impl/**'
9 | pull_request:
10 | branches: [ main ]
11 | paths:
12 | - '.github/workflow/test-typescript.yml'
13 | - 'impl/**'
14 |
15 | jobs:
16 | build:
17 | runs-on: ubuntu-latest
18 | defaults:
19 | run:
20 | working-directory: 'impl'
21 |
22 | steps:
23 | - uses: actions/checkout@v4
24 | - uses: actions/setup-node@v4
25 | with:
26 | node-version: '>=22'
27 | - run: npm ci
28 | - run: npm run build --if-present
29 | - run: npm test
30 | - name: Check Format
31 | run: |
32 | npm run pretty
33 | git diff --exit-code
34 | - run: npm run lint
35 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Attribution
2 |
3 | _A [Deliverable](https://www.w3.org/2024/11/wg-pat-charter.html#private-attribution) of the [Private Advertising Technology Working Group](https://www.w3.org/groups/wg/pat/) of [W3C](https://www.w3.org/)._
4 |
5 | Attribution is the name given to the measurement system used in advertising. It is called attribution because it seeks to attribute value from an outcome (like someone buying stuff) to advertisements.
6 |
7 | This repository contains [a specification](https://w3c.github.io/attribution/) that describes an API that would be presented by a browser to websites.
8 |
9 | The specification contains all of the details. There is no [explainer](https://tag.w3.org/explainers/). All the explanation can be found up front, before it gets into the gory details.
10 |
11 | The WG will use the process outlined [here](process.md) to develop this specification. Nothing contained therein is intended to override other W3C process and procedures.
12 |
--------------------------------------------------------------------------------
/impl/e2e-tests/multi-touch-divides-evenly.json:
--------------------------------------------------------------------------------
1 | {
2 | "events": [
3 | {
4 | "seconds": 1,
5 | "site": "publisher.example",
6 | "event": "saveImpression",
7 | "options": { "histogramIndex": 0 }
8 | },
9 | {
10 | "seconds": 2,
11 | "site": "publisher.example",
12 | "event": "saveImpression",
13 | "options": { "histogramIndex": 1 }
14 | },
15 | {
16 | "seconds": 3,
17 | "site": "publisher.example",
18 | "event": "saveImpression",
19 | "options": { "histogramIndex": 2 }
20 | },
21 | {
22 | "seconds": 4,
23 | "site": "advertiser.example",
24 | "event": "measureConversion",
25 | "options": {
26 | "aggregationService": "https://agg-service.example",
27 | "histogramSize": 4,
28 | "value": 8,
29 | "maxValue": 10,
30 | "credit": [2, 1, 1]
31 | },
32 | "expected": [2, 2, 4, 0]
33 | }
34 | ]
35 | }
36 |
--------------------------------------------------------------------------------
/impl/e2e-tests/multi-touch-same-histogram-index.json:
--------------------------------------------------------------------------------
1 | {
2 | "events": [
3 | {
4 | "seconds": 1,
5 | "site": "publisher.example",
6 | "event": "saveImpression",
7 | "options": { "histogramIndex": 1 }
8 | },
9 | {
10 | "seconds": 2,
11 | "site": "publisher.example",
12 | "event": "saveImpression",
13 | "options": { "histogramIndex": 0 }
14 | },
15 | {
16 | "seconds": 3,
17 | "site": "publisher.example",
18 | "event": "saveImpression",
19 | "options": { "histogramIndex": 0 }
20 | },
21 | {
22 | "seconds": 4,
23 | "site": "advertiser.example",
24 | "event": "measureConversion",
25 | "options": {
26 | "aggregationService": "https://agg-service.example",
27 | "histogramSize": 3,
28 | "value": 12,
29 | "maxValue": 12,
30 | "credit": [8, 3, 1]
31 | },
32 | "expected": [11, 1, 0]
33 | }
34 | ]
35 | }
36 |
--------------------------------------------------------------------------------
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | name: "Deploy to GitHub Pages"
2 |
3 | on:
4 | push:
5 | branches: [ "main" ]
6 | paths: [ "Makefile", "api.bs", "images/**", ".github/workflows/deploy.yml", "impl/**" ]
7 |
8 | jobs:
9 | build:
10 | name: "Build HTML"
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v4
14 | - uses: actions/setup-python@v5
15 | with:
16 | python-version: '3.12'
17 | - uses: actions/setup-node@v4
18 | with:
19 | node-version: '>=22'
20 | - run: make
21 | - uses: actions/upload-pages-artifact@v3
22 | with:
23 | path: build
24 |
25 |
26 | publish:
27 | name: "Publish HTML"
28 | permissions:
29 | contents: read
30 | pages: write
31 | id-token: write
32 | runs-on: ubuntu-latest
33 | environment:
34 | name: github-pages
35 | url: $
36 | needs: build
37 | steps:
38 | - name: "Publish to GitHub Pages"
39 | uses: actions/deploy-pages@v4
40 |
--------------------------------------------------------------------------------
/impl/e2e-tests/multi-touch-divides-evenly-unordered-credit.json:
--------------------------------------------------------------------------------
1 | {
2 | "events": [
3 | {
4 | "seconds": 1,
5 | "site": "publisher.example",
6 | "event": "saveImpression",
7 | "options": { "histogramIndex": 0 }
8 | },
9 | {
10 | "seconds": 2,
11 | "site": "publisher.example",
12 | "event": "saveImpression",
13 | "options": { "histogramIndex": 1 }
14 | },
15 | {
16 | "seconds": 3,
17 | "site": "publisher.example",
18 | "event": "saveImpression",
19 | "options": { "histogramIndex": 2 }
20 | },
21 | {
22 | "seconds": 4,
23 | "site": "advertiser.example",
24 | "event": "measureConversion",
25 | "options": {
26 | "aggregationService": "https://agg-service.example",
27 | "histogramSize": 4,
28 | "value": 8,
29 | "maxValue": 10,
30 | "credit": [1, 2, 1]
31 | },
32 | "$comment": "not necessarily useful, but demonstrates the ability to prefer the second-to-last over the last",
33 | "expected": [2, 4, 2, 0]
34 | }
35 | ]
36 | }
37 |
--------------------------------------------------------------------------------
/impl/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "scripts": {
4 | "build": "tsc",
5 | "pack": "webpack --mode production",
6 | "test": "node --test dist/**/*.test.js",
7 | "pretty": "prettier . --write",
8 | "pretty:check": "prettier . --check",
9 | "lint": "eslint src",
10 | "serve": "http-server -c-1 dist",
11 | "serve-local": "http-server -c-1 -a 127.0.0.1 dist",
12 | "validate-e2e": "ajv validate --strict -s e2e.schema.json -c ajv-formats ./e2e-tests/*.json"
13 | },
14 | "devDependencies": {
15 | "@jirutka/ajv-cli": "^6.0.0",
16 | "@types/node": "^24.2.0",
17 | "@types/psl": "^1.1.3",
18 | "ajv-formats": "^3.0.1",
19 | "eslint": "^9.32.0",
20 | "html-webpack-plugin": "^5.6.3",
21 | "http-server": "^14.1.1",
22 | "prettier": "^3.6.2",
23 | "simple-statistics": "^7.8.8",
24 | "ts-loader": "^9.5.2",
25 | "typescript": "^5.9.2",
26 | "typescript-eslint": "^8.39.0",
27 | "webpack": "^5.101.0",
28 | "webpack-cli": "^6.0.1"
29 | },
30 | "dependencies": {
31 | "psl": "^1.15.0",
32 | "structured-headers": "^2.0.2",
33 | "temporal-polyfill": "^0.3.0"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/impl/e2e-tests/priority.json:
--------------------------------------------------------------------------------
1 | {
2 | "events": [
3 | {
4 | "seconds": 1,
5 | "site": "publisher.example",
6 | "event": "saveImpression",
7 | "options": {
8 | "histogramIndex": 0,
9 | "priority": 200
10 | }
11 | },
12 | {
13 | "seconds": 2,
14 | "site": "publisher.example",
15 | "event": "saveImpression",
16 | "options": {
17 | "histogramIndex": 1,
18 | "priority": 300
19 | }
20 | },
21 | {
22 | "seconds": 3,
23 | "site": "publisher.example",
24 | "event": "saveImpression",
25 | "options": {
26 | "histogramIndex": 2,
27 | "priority": 200
28 | }
29 | },
30 | {
31 | "seconds": 4,
32 | "site": "advertiser.example",
33 | "event": "measureConversion",
34 | "options": {
35 | "aggregationService": "https://agg-service.example",
36 | "histogramSize": 4,
37 | "value": 8,
38 | "maxValue": 10,
39 | "credit": [6, 2]
40 | },
41 | "$comment": "index 1 has the highest priority; the others have equal priority but index 2 is most recent",
42 | "expected": [0, 6, 2, 0]
43 | }
44 | ]
45 | }
46 |
--------------------------------------------------------------------------------
/images/histogram.svg:
--------------------------------------------------------------------------------
1 |
32 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: all venv clean images simulator check
2 | .SUFFIXES: .bs .html
3 |
4 | IMAGES := $(wildcard images/*.svg)
5 |
6 | all: build/index.html simulator
7 |
8 | clean:
9 | -rm -rf build venv impl/dist
10 |
11 | venv-marker := venv/.make
12 | bikeshed := venv/bin/bikeshed
13 | venv: $(venv-marker)
14 |
15 | $(venv-marker): Makefile
16 | python3 -m venv venv
17 | @touch $@
18 |
19 | $(bikeshed): $(venv-marker) Makefile
20 | venv/bin/pip install $(notdir $@)
21 | @touch $@
22 |
23 | build:
24 | mkdir -p $@
25 |
26 | build/index.html: api.bs $(IMAGES) build $(bikeshed)
27 | $(bikeshed) --die-on=warning spec $< $@
28 |
29 | images:
30 | @echo "Regenerating images"
31 | for i in $(IMAGES); do \
32 | tmp="$$(mktemp)"; \
33 | npx aasvg --extract --embed <"$$i" >"$$tmp" && mv "$$tmp" "$$i"; \
34 | done
35 |
36 | simulator: build/simulator.html build/simulator.js
37 |
38 | build/simulator.html: impl/dist/index.html build
39 | cp $< $@
40 |
41 | build/simulator.js: impl/dist/simulator.js build
42 | cp $< $@
43 |
44 | impl/dist/index.html impl/dist/simulator.js: impl/index.html impl/package-lock.json impl/package.json impl/tsconfig.json impl/webpack.config.js impl/src/*.ts
45 | @ npm ci --prefix ./impl
46 | @ npm run pack --prefix ./impl
47 |
48 | check:
49 | @ npm run --prefix ./impl pretty:check
50 | @ npm run --prefix ./impl build
51 | @ npm run --prefix ./impl lint
52 | @ npm run --prefix ./impl validate-e2e
53 | @ npm run --prefix ./impl test
54 |
--------------------------------------------------------------------------------
/impl/e2e-tests/clear-site-state.json:
--------------------------------------------------------------------------------
1 | {
2 | "events": [
3 | {
4 | "seconds": 1,
5 | "site": "a.example",
6 | "event": "saveImpression",
7 | "options": { "histogramIndex": 0 }
8 | },
9 | {
10 | "seconds": 2,
11 | "site": "advertiser-1.example",
12 | "event": "measureConversion",
13 | "options": {
14 | "aggregationService": "https://agg-service.example",
15 | "epsilon": 0.1,
16 | "histogramSize": 1
17 | },
18 | "expected": [1]
19 | },
20 | {
21 | "seconds": 3,
22 | "event": "clearBrowsingHistoryForAttribution",
23 | "sites": ["advertiser-1.example"],
24 | "forgetVisits": false
25 | },
26 | {
27 | "seconds": 4,
28 | "site": "advertiser-1.example",
29 | "event": "measureConversion",
30 | "$comment": "re-run query, expecting budget to have been zeroed",
31 | "options": {
32 | "aggregationService": "https://agg-service.example",
33 | "epsilon": 0.1,
34 | "histogramSize": 1
35 | },
36 | "expected": [0]
37 | },
38 | {
39 | "seconds": 5,
40 | "site": "advertiser-2.example",
41 | "$comment": "re-run query with different conversion site, expecting separate budget",
42 | "event": "measureConversion",
43 | "options": {
44 | "aggregationService": "https://agg-service.example",
45 | "epsilon": 0.1,
46 | "histogramSize": 1
47 | },
48 | "expected": [1]
49 | }
50 | ]
51 | }
52 |
--------------------------------------------------------------------------------
/impl/e2e-tests/simulate-multiple-buckets.json:
--------------------------------------------------------------------------------
1 | {
2 | "events": [
3 | {
4 | "seconds": 1,
5 | "site": "publisher.example",
6 | "event": "saveImpression",
7 | "$comment": "100 will be used for campaign queries",
8 | "options": { "histogramIndex": 0, "matchValue": 100 }
9 | },
10 | {
11 | "seconds": 2,
12 | "site": "publisher.example",
13 | "event": "saveImpression",
14 | "$comment": "200 will be used for geo queries",
15 | "options": { "histogramIndex": 1, "matchValue": 200 }
16 | },
17 | {
18 | "seconds": 3,
19 | "site": "publisher.example",
20 | "event": "saveImpression",
21 | "options": { "histogramIndex": 2, "matchValue": 100 }
22 | },
23 | {
24 | "seconds": 4,
25 | "site": "publisher.example",
26 | "event": "saveImpression",
27 | "options": { "histogramIndex": 3, "matchValue": 200 }
28 | },
29 | {
30 | "seconds": 5,
31 | "site": "advertiser.example",
32 | "event": "measureConversion",
33 | "options": {
34 | "aggregationService": "https://agg-service.example",
35 | "histogramSize": 4,
36 | "matchValues": [100],
37 | "epsilon": 0.5
38 | },
39 | "expected": [0, 0, 1, 0]
40 | },
41 | {
42 | "seconds": 6,
43 | "site": "advertiser.example",
44 | "event": "measureConversion",
45 | "options": {
46 | "aggregationService": "https://agg-service.example",
47 | "histogramSize": 5,
48 | "matchValues": [200],
49 | "epsilon": 0.5
50 | },
51 | "expected": [0, 0, 0, 1, 0]
52 | }
53 | ]
54 | }
55 |
--------------------------------------------------------------------------------
/impl/src/fixture.ts:
--------------------------------------------------------------------------------
1 | import { AttributionProtocol } from "./index";
2 | import { Backend, days } from "./backend";
3 | import { Temporal } from "temporal-polyfill";
4 | import e2eConfig from "../e2e-tests/CONFIG.json";
5 |
6 | export const defaultConfig = e2eConfig as Readonly;
7 |
8 | export interface TestConfig {
9 | now?: Temporal.Instant;
10 | aggregationServices: Record;
11 | maxConversionSitesPerImpression: number;
12 | maxConversionCallersPerImpression: number;
13 | maxCreditSize: number;
14 | maxLookbackDays: number;
15 | maxHistogramSize: number;
16 | privacyBudgetMicroEpsilons: number;
17 | privacyBudgetEpochDays: number;
18 | epochStart: number;
19 | fairlyAllocateCreditFraction: number;
20 | }
21 |
22 | export function makeBackend(
23 | config: Readonly = defaultConfig,
24 | ): Backend {
25 | const now = config.now ?? new Temporal.Instant(0n);
26 |
27 | return new Backend({
28 | aggregationServices: new Map(
29 | Object.entries(config.aggregationServices).map(([url, protocol]) => [
30 | url,
31 | { protocol },
32 | ]),
33 | ),
34 | includeUnencryptedHistogram: true,
35 |
36 | maxConversionSitesPerImpression: config.maxConversionSitesPerImpression,
37 | maxConversionCallersPerImpression: config.maxConversionCallersPerImpression,
38 | maxCreditSize: config.maxCreditSize,
39 | maxLookbackDays: config.maxLookbackDays,
40 | maxHistogramSize: config.maxHistogramSize,
41 | privacyBudgetMicroEpsilons: config.privacyBudgetMicroEpsilons,
42 | privacyBudgetEpoch: days(config.privacyBudgetEpochDays),
43 |
44 | now: () => now,
45 | fairlyAllocateCreditFraction: () => config.fairlyAllocateCreditFraction,
46 | epochStart: () => config.epochStart,
47 | });
48 | }
49 |
--------------------------------------------------------------------------------
/impl/e2e-tests/forget-one-site-conversions.json:
--------------------------------------------------------------------------------
1 | {
2 | "$comment": "TODO: Add coverage for other epochs",
3 | "events": [
4 | {
5 | "seconds": 1,
6 | "site": "a.example",
7 | "event": "saveImpression",
8 | "options": { "histogramIndex": 0 }
9 | },
10 | {
11 | "seconds": 2,
12 | "site": "advertiser-1.example",
13 | "event": "measureConversion",
14 | "options": {
15 | "aggregationService": "https://agg-service.example",
16 | "epsilon": 0.1,
17 | "histogramSize": 1
18 | },
19 | "expected": [1]
20 | },
21 | {
22 | "seconds": 3,
23 | "event": "clearBrowsingHistoryForAttribution",
24 | "sites": ["advertiser-1.example"],
25 | "forgetVisits": true
26 | },
27 | {
28 | "seconds": 4,
29 | "site": "a.example",
30 | "event": "saveImpression",
31 | "$comment": "add an impression to be sure that budget is affected below, rather than impressions",
32 | "options": { "histogramIndex": 0 }
33 | },
34 | {
35 | "seconds": 5,
36 | "site": "advertiser-1.example",
37 | "event": "measureConversion",
38 | "$comment": "re-run query, expecting budget to have been zeroed",
39 | "options": {
40 | "aggregationService": "https://agg-service.example",
41 | "epsilon": 0.1,
42 | "histogramSize": 1
43 | },
44 | "expected": [0]
45 | },
46 | {
47 | "seconds": 6,
48 | "site": "advertiser-2.example",
49 | "$comment": "re-run query with different conversion site, expecting epoch to be off-limits",
50 | "event": "measureConversion",
51 | "options": {
52 | "aggregationService": "https://agg-service.example",
53 | "epsilon": 0.1,
54 | "histogramSize": 1
55 | },
56 | "expected": [0]
57 | }
58 | ]
59 | }
60 |
--------------------------------------------------------------------------------
/impl/e2e-tests/match-values.json:
--------------------------------------------------------------------------------
1 | {
2 | "$comment": "use different advertiser sites to avoid depending on budgeting",
3 | "events": [
4 | {
5 | "seconds": 1,
6 | "site": "publisher.example",
7 | "event": "saveImpression",
8 | "$comment": "matchValue defaults to 0",
9 | "options": { "histogramIndex": 1 }
10 | },
11 | {
12 | "seconds": 2,
13 | "site": "publisher.example",
14 | "event": "saveImpression",
15 | "options": {
16 | "histogramIndex": 2,
17 | "matchValue": 100
18 | }
19 | },
20 | {
21 | "seconds": 3,
22 | "site": "advertiser-1.example",
23 | "event": "measureConversion",
24 | "$comment": "try to select 2 impressions to avoid depending on ordering",
25 | "options": {
26 | "aggregationService": "https://agg-service.example",
27 | "histogramSize": 3,
28 | "matchValues": [0],
29 | "value": 2,
30 | "maxValue": 2,
31 | "credit": [0.5, 0.5]
32 | },
33 | "expected": [0, 2, 0]
34 | },
35 | {
36 | "seconds": 4,
37 | "site": "advertiser-2.example",
38 | "event": "measureConversion",
39 | "$comment": "try to select 2 impressions to avoid depending on ordering",
40 | "options": {
41 | "aggregationService": "https://agg-service.example",
42 | "histogramSize": 3,
43 | "matchValues": [100],
44 | "value": 2,
45 | "maxValue": 2,
46 | "credit": [0.5, 0.5]
47 | },
48 | "expected": [0, 0, 2]
49 | },
50 | {
51 | "seconds": 5,
52 | "site": "advertiser-3.example",
53 | "event": "measureConversion",
54 | "options": {
55 | "aggregationService": "https://agg-service.example",
56 | "histogramSize": 3,
57 | "matchValues": [0, 100],
58 | "value": 2,
59 | "maxValue": 2,
60 | "credit": [0.5, 0.5]
61 | },
62 | "expected": [0, 1, 1]
63 | }
64 | ]
65 | }
66 |
--------------------------------------------------------------------------------
/impl/e2e-tests/conversion-sites.json:
--------------------------------------------------------------------------------
1 | {
2 | "$comment": "use different advertiser sites to avoid depending on budgeting",
3 | "events": [
4 | {
5 | "seconds": 1,
6 | "site": "publisher.example",
7 | "event": "saveImpression",
8 | "options": {
9 | "histogramIndex": 1,
10 | "conversionSites": ["advertiser-1.example"]
11 | }
12 | },
13 | {
14 | "seconds": 2,
15 | "site": "publisher.example",
16 | "event": "saveImpression",
17 | "$comment": "first conversion site should be parsed as advertiser-2.example",
18 | "options": {
19 | "histogramIndex": 2,
20 | "conversionSites": ["foo.advertiser-2.example", "advertiser-3.example"]
21 | }
22 | },
23 | {
24 | "seconds": 3,
25 | "site": "advertiser-1.example",
26 | "event": "measureConversion",
27 | "$comment": "try to select 2 impressions to avoid depending on ordering",
28 | "options": {
29 | "aggregationService": "https://agg-service.example",
30 | "histogramSize": 3,
31 | "value": 2,
32 | "maxValue": 2,
33 | "credit": [0.5, 0.5]
34 | },
35 | "expected": [0, 2, 0]
36 | },
37 | {
38 | "seconds": 4,
39 | "site": "advertiser-2.example",
40 | "event": "measureConversion",
41 | "$comment": "try to select 2 impressions to avoid depending on ordering",
42 | "options": {
43 | "aggregationService": "https://agg-service.example",
44 | "histogramSize": 3,
45 | "value": 2,
46 | "maxValue": 2,
47 | "credit": [0.5, 0.5]
48 | },
49 | "expected": [0, 0, 2]
50 | },
51 | {
52 | "seconds": 5,
53 | "site": "adtech.example",
54 | "intermediarySite": "advertiser-3.example",
55 | "event": "measureConversion",
56 | "options": {
57 | "aggregationService": "https://agg-service.example",
58 | "histogramSize": 3,
59 | "value": 2,
60 | "maxValue": 2,
61 | "credit": [0.5, 0.5]
62 | },
63 | "$comment": "advertiser-3.example is not a top-level site",
64 | "expected": [0, 0, 0]
65 | }
66 | ]
67 | }
68 |
--------------------------------------------------------------------------------
/impl/src/index.ts:
--------------------------------------------------------------------------------
1 | export type AttributionProtocol = "dap-15-histogram";
2 |
3 | export interface AttributionAggregationService {
4 | protocol: AttributionProtocol;
5 | }
6 |
7 | export type AttributionAggregationServices = ReadonlyMap<
8 | string,
9 | Readonly
10 | >;
11 |
12 | export const DEFAULT_IMPRESSION_LIFETIME_DAYS = 30;
13 | export const DEFAULT_IMPRESSION_MATCH_VALUE = 0;
14 | export const DEFAULT_IMPRESSION_PRIORITY = 0;
15 |
16 | export interface AttributionImpressionOptions {
17 | histogramIndex: number;
18 | matchValue?: number | undefined; // = DEFAULT_IMPRESSION_PRIORITY
19 | conversionSites?: string[] | undefined; // = []
20 | conversionCallers?: string[] | undefined; // = []
21 | lifetimeDays?: number | undefined; // = DEFAULT_IMPRESSION_LIFETIME_DAYS
22 | priority?: number | undefined; // = DEFAULT_IMPRESSION_PRIORITY
23 | }
24 |
25 | export type AttributionImpressionResult = object;
26 |
27 | export const DEFAULT_CONVERSION_EPSILON = 1.0;
28 | export const DEFAULT_CONVERSION_VALUE = 1;
29 | export const DEFAULT_CONVERSION_MAX_VALUE = 1;
30 |
31 | export const MAX_CONVERSION_EPSILON = 4294;
32 |
33 | export interface AttributionConversionOptions {
34 | aggregationService: string;
35 | epsilon?: number | undefined; // = DEFAULT_CONVERSION_EPSILON
36 |
37 | histogramSize: number;
38 |
39 | lookbackDays?: number | undefined;
40 | matchValues?: number[] | undefined; // = []
41 | impressionSites?: string[] | undefined; // = []
42 | impressionCallers?: string[] | undefined; // = []
43 |
44 | credit?: number[] | undefined;
45 | value?: number | undefined; // = DEFAULT_CONVERSION_VALUE
46 | maxValue?: number | undefined; // = DEFAULT_CONVERSION_MAX_VALUE
47 | }
48 |
49 | export interface AttributionConversionResult {
50 | report: Uint8Array;
51 |
52 | // Added to facilitate testing and local debugging. Will be absent in "real"
53 | // API usage.
54 | unencryptedHistogram?: number[];
55 | }
56 |
57 | export interface Attribution {
58 | readonly aggregationServices: AttributionAggregationServices;
59 |
60 | saveImpression(
61 | options: AttributionImpressionOptions,
62 | ): Promise;
63 |
64 | measureConversion(
65 | options: AttributionConversionOptions,
66 | ): Promise;
67 | }
68 |
--------------------------------------------------------------------------------
/impl/e2e-tests/lookback.json:
--------------------------------------------------------------------------------
1 | {
2 | "$comment": "use different advertiser sites to avoid depending on budgeting",
3 | "$comment": "86400 seconds in a day",
4 | "events": [
5 | {
6 | "seconds": 1,
7 | "site": "publisher.example",
8 | "event": "saveImpression",
9 | "options": { "histogramIndex": 1 }
10 | },
11 | {
12 | "seconds": 2,
13 | "site": "publisher.example",
14 | "intermediarySite": "adtech.example",
15 | "event": "saveImpression",
16 | "options": { "histogramIndex": 2 }
17 | },
18 | {
19 | "seconds": 86402,
20 | "site": "advertiser-1.example",
21 | "event": "measureConversion",
22 | "$comment": "try to select 2 impressions to avoid depending on ordering",
23 | "options": {
24 | "aggregationService": "https://agg-service.example",
25 | "histogramSize": 3,
26 | "lookbackDays": 1,
27 | "value": 2,
28 | "maxValue": 2,
29 | "credit": [0.5, 0.5]
30 | },
31 | "expected": [0, 0, 2]
32 | },
33 | {
34 | "seconds": 86403,
35 | "site": "advertiser-2.example",
36 | "event": "measureConversion",
37 | "$comment": "try to select 2 impressions to avoid depending on ordering",
38 | "options": {
39 | "aggregationService": "https://agg-service.example",
40 | "histogramSize": 3,
41 | "lookbackDays": 1,
42 | "value": 2,
43 | "maxValue": 2,
44 | "credit": [0.5, 0.5]
45 | },
46 | "expected": [0, 0, 0]
47 | },
48 | {
49 | "seconds": 172802,
50 | "site": "advertiser-3.example",
51 | "event": "measureConversion",
52 | "$comment": "try to select 2 impressions to avoid depending on ordering",
53 | "options": {
54 | "aggregationService": "https://agg-service.example",
55 | "histogramSize": 3,
56 | "lookbackDays": 1,
57 | "value": 2,
58 | "maxValue": 2,
59 | "credit": [0.5, 0.5]
60 | },
61 | "expected": [0, 0, 0]
62 | },
63 | {
64 | "seconds": 172803,
65 | "site": "advertiser-4.example",
66 | "event": "measureConversion",
67 | "$comment": "try to select 2 impressions to avoid depending on ordering",
68 | "options": {
69 | "aggregationService": "https://agg-service.example",
70 | "histogramSize": 3,
71 | "lookbackDays": 3,
72 | "value": 2,
73 | "maxValue": 2,
74 | "credit": [0.5, 0.5]
75 | },
76 | "expected": [0, 1, 1]
77 | }
78 | ]
79 | }
80 |
--------------------------------------------------------------------------------
/impl/e2e-tests/impression-sites.json:
--------------------------------------------------------------------------------
1 | {
2 | "$comment": "use different advertiser sites to avoid depending on budgeting",
3 | "events": [
4 | {
5 | "seconds": 1,
6 | "site": "publisher-1.example",
7 | "event": "saveImpression",
8 | "options": { "histogramIndex": 1 }
9 | },
10 | {
11 | "seconds": 2,
12 | "site": "publisher-2.example",
13 | "intermediarySite": "adtech.example",
14 | "event": "saveImpression",
15 | "options": { "histogramIndex": 2 }
16 | },
17 | {
18 | "seconds": 3,
19 | "site": "advertiser-1.example",
20 | "event": "measureConversion",
21 | "$comment": "try to select 2 impressions to avoid depending on ordering",
22 | "options": {
23 | "aggregationService": "https://agg-service.example",
24 | "histogramSize": 3,
25 | "impressionSites": ["publisher-1.example"],
26 | "value": 2,
27 | "maxValue": 2,
28 | "credit": [0.5, 0.5]
29 | },
30 | "expected": [0, 2, 0]
31 | },
32 | {
33 | "seconds": 4,
34 | "site": "advertiser-2.example",
35 | "event": "measureConversion",
36 | "$comment": "try to select 2 impressions to avoid depending on ordering",
37 | "options": {
38 | "aggregationService": "https://agg-service.example",
39 | "histogramSize": 3,
40 | "impressionSites": ["publisher-2.example"],
41 | "value": 2,
42 | "maxValue": 2,
43 | "credit": [0.5, 0.5]
44 | },
45 | "expected": [0, 0, 2]
46 | },
47 | {
48 | "seconds": 5,
49 | "site": "advertiser-3.example",
50 | "event": "measureConversion",
51 | "$comment": "first impression site should be parsed as publisher-1.example",
52 | "options": {
53 | "aggregationService": "https://agg-service.example",
54 | "histogramSize": 3,
55 | "impressionSites": ["foo.publisher-1.example", "publisher-2.example"],
56 | "value": 2,
57 | "maxValue": 2,
58 | "credit": [0.5, 0.5]
59 | },
60 | "expected": [0, 1, 1]
61 | },
62 | {
63 | "seconds": 6,
64 | "site": "advertiser-4.example",
65 | "event": "measureConversion",
66 | "options": {
67 | "aggregationService": "https://agg-service.example",
68 | "histogramSize": 3,
69 | "impressionSites": ["adtech.example"],
70 | "value": 2,
71 | "maxValue": 2,
72 | "credit": [0.5, 0.5]
73 | },
74 | "$comment": "adtech.example is not a top-level site",
75 | "expected": [0, 0, 0]
76 | }
77 | ]
78 | }
79 |
--------------------------------------------------------------------------------
/impl/e2e-tests/expiry.json:
--------------------------------------------------------------------------------
1 | {
2 | "$comment": "use different advertiser sites to avoid depending on budgeting",
3 | "events": [
4 | {
5 | "seconds": 1,
6 | "site": "publisher.example",
7 | "event": "saveImpression",
8 | "options": {
9 | "histogramIndex": 1,
10 | "lifetimeDays": 2
11 | }
12 | },
13 | {
14 | "seconds": 2,
15 | "site": "publisher.example",
16 | "event": "saveImpression",
17 | "$comment": "lifetimeDays defaults to 30",
18 | "options": { "histogramIndex": 2 }
19 | },
20 | {
21 | "seconds": 172801,
22 | "site": "advertiser-1.example",
23 | "event": "measureConversion",
24 | "$comment": "try to select 2 impressions to avoid depending on ordering",
25 | "options": {
26 | "aggregationService": "https://agg-service.example",
27 | "histogramSize": 3,
28 | "value": 2,
29 | "maxValue": 2,
30 | "credit": [0.5, 0.5]
31 | },
32 | "$comment": "neither impression has expired",
33 | "expected": [0, 1, 1]
34 | },
35 | {
36 | "seconds": 172802,
37 | "site": "advertiser-2.example",
38 | "event": "measureConversion",
39 | "$comment": "try to select 2 impressions to avoid depending on ordering",
40 | "options": {
41 | "aggregationService": "https://agg-service.example",
42 | "histogramSize": 3,
43 | "value": 2,
44 | "maxValue": 2,
45 | "credit": [0.5, 0.5]
46 | },
47 | "$comment": "the first impression has expired",
48 | "expected": [0, 0, 2]
49 | },
50 | {
51 | "seconds": 2592002,
52 | "site": "advertiser-3.example",
53 | "event": "measureConversion",
54 | "$comment": "try to select 2 impressions to avoid depending on ordering",
55 | "options": {
56 | "aggregationService": "https://agg-service.example",
57 | "histogramSize": 3,
58 | "value": 2,
59 | "maxValue": 2,
60 | "credit": [0.5, 0.5]
61 | },
62 | "$comment": "the second impression hasn't expired yet",
63 | "expected": [0, 0, 2]
64 | },
65 | {
66 | "seconds": 2592003,
67 | "site": "advertiser-4.example",
68 | "event": "measureConversion",
69 | "$comment": "try to select 2 impressions to avoid depending on ordering",
70 | "options": {
71 | "aggregationService": "https://agg-service.example",
72 | "histogramSize": 3,
73 | "value": 2,
74 | "maxValue": 2,
75 | "credit": [0.5, 0.5]
76 | },
77 | "$comment": "the second impression has expired",
78 | "expected": [0, 0, 0]
79 | }
80 | ]
81 | }
82 |
--------------------------------------------------------------------------------
/impl/e2e-tests/save-impression-errors.json:
--------------------------------------------------------------------------------
1 | {
2 | "events": [
3 | {
4 | "seconds": 1,
5 | "site": "publisher.example",
6 | "event": "saveImpression",
7 | "options": { "histogramIndex": -1 },
8 | "expectedError": "RangeError"
9 | },
10 | {
11 | "seconds": 2,
12 | "site": "publisher.example",
13 | "event": "saveImpression",
14 | "$comment": "Equal to CONFIG.maxHistogramSize",
15 | "options": { "histogramIndex": 5 },
16 | "expectedError": "RangeError"
17 | },
18 | {
19 | "seconds": 3,
20 | "site": "publisher.example",
21 | "event": "saveImpression",
22 | "options": {
23 | "histogramIndex": 0,
24 | "lifetimeDays": 0
25 | },
26 | "expectedError": "RangeError"
27 | },
28 | {
29 | "seconds": 4,
30 | "site": "publisher.example",
31 | "event": "saveImpression",
32 | "options": {
33 | "histogramIndex": 0,
34 | "conversionSites": [":"]
35 | },
36 | "expectedError": {
37 | "error": "DOMException",
38 | "name": "SyntaxError"
39 | }
40 | },
41 | {
42 | "seconds": 5,
43 | "site": "publisher.example",
44 | "event": "saveImpression",
45 | "$comment": "Greater than CONFIG.maxConversionSitesPerImpression",
46 | "options": {
47 | "histogramIndex": 0,
48 | "conversionSites": ["a.example", "a.example", "a.example", "a.example"]
49 | },
50 | "expectedError": "RangeError"
51 | },
52 | {
53 | "seconds": 6,
54 | "site": "publisher.example",
55 | "event": "saveImpression",
56 | "options": {
57 | "histogramIndex": 0,
58 | "conversionCallers": [":"]
59 | },
60 | "expectedError": {
61 | "error": "DOMException",
62 | "name": "SyntaxError"
63 | }
64 | },
65 | {
66 | "seconds": 7,
67 | "site": "publisher.example",
68 | "event": "saveImpression",
69 | "$comment": "Greater than CONFIG.maxConversionCallersPerImpression",
70 | "options": {
71 | "histogramIndex": 0,
72 | "conversionCallers": [
73 | "a.example",
74 | "a.example",
75 | "a.example",
76 | "a.example"
77 | ]
78 | },
79 | "expectedError": "RangeError"
80 | },
81 | {
82 | "seconds": 8,
83 | "site": "advertiser.example",
84 | "event": "measureConversion",
85 | "options": {
86 | "aggregationService": "https://agg-service.example",
87 | "histogramSize": 5
88 | },
89 | "$comment": "check that no impression was stored",
90 | "expected": [0, 0, 0, 0, 0]
91 | }
92 | ]
93 | }
94 |
--------------------------------------------------------------------------------
/impl/src/http.ts:
--------------------------------------------------------------------------------
1 | import type { AttributionImpressionOptions } from "./index";
2 |
3 | import type { BareItem, Dictionary, Item } from "structured-headers";
4 |
5 | import { parseDictionary } from "structured-headers";
6 |
7 | const MAX_UINT32: number = 4294967295;
8 |
9 | const MIN_INT32: number = -2147483648;
10 | const MAX_INT32: number = 2147483647;
11 |
12 | function get(dict: Dictionary, key: string): BareItem | Item[] | undefined {
13 | const [value] = dict.get(key) ?? [undefined];
14 | return value;
15 | }
16 |
17 | function getInteger(dict: Dictionary, key: string): number | undefined {
18 | const value = get(dict, key);
19 | if (value === undefined) {
20 | return value;
21 | }
22 |
23 | if (typeof value !== "number" || !Number.isInteger(value)) {
24 | throw new TypeError(`${key} must be an integer`);
25 | }
26 |
27 | return value;
28 | }
29 |
30 | function get32BitUnsignedInteger(
31 | dict: Dictionary,
32 | key: string,
33 | ): number | undefined {
34 | const value = getInteger(dict, key);
35 | if (value === undefined) {
36 | return value;
37 | }
38 |
39 | if (value < 0 || value > MAX_UINT32) {
40 | throw new RangeError(`${key} must be in the 32-bit unsigned range`);
41 | }
42 |
43 | return value;
44 | }
45 |
46 | function parseInnerListOfSites(
47 | dict: Dictionary,
48 | key: string,
49 | ): string[] | undefined {
50 | const values = get(dict, key);
51 | if (values === undefined) {
52 | return values;
53 | }
54 |
55 | if (!Array.isArray(values)) {
56 | throw new TypeError(`${key} must be an inner list`);
57 | }
58 |
59 | const sites = [];
60 | for (const [i, [value]] of values.entries()) {
61 | if (typeof value !== "string") {
62 | throw new TypeError(`${key}[${i}] must be a string`);
63 | }
64 | sites.push(value);
65 | }
66 | return sites;
67 | }
68 |
69 | export function parseSaveImpressionHeader(
70 | input: string,
71 | ): AttributionImpressionOptions {
72 | const dict = parseDictionary(input);
73 |
74 | const histogramIndex = get32BitUnsignedInteger(dict, "histogram-index");
75 | if (histogramIndex === undefined) {
76 | throw new TypeError("histogram-index is required");
77 | }
78 |
79 | const opts: AttributionImpressionOptions = { histogramIndex };
80 |
81 | opts.conversionSites = parseInnerListOfSites(dict, "conversion-sites");
82 | opts.conversionCallers = parseInnerListOfSites(dict, "conversion-callers");
83 |
84 | opts.matchValue = get32BitUnsignedInteger(dict, "match-value");
85 |
86 | opts.lifetimeDays = getInteger(dict, "lifetime-days");
87 | if (opts.lifetimeDays !== undefined && opts.lifetimeDays <= 0) {
88 | throw new RangeError("lifetime-days must be positive");
89 | }
90 |
91 | opts.priority = getInteger(dict, "priority");
92 | if (
93 | opts.priority !== undefined &&
94 | (opts.priority < MIN_INT32 || opts.priority > MAX_INT32)
95 | ) {
96 | throw new RangeError("priority must be in the 32-bit signed range");
97 | }
98 |
99 | return opts;
100 | }
101 |
--------------------------------------------------------------------------------
/impl/e2e-tests/impression-callers.json:
--------------------------------------------------------------------------------
1 | {
2 | "$comment": "use different advertiser sites to avoid depending on budgeting",
3 | "events": [
4 | {
5 | "seconds": 1,
6 | "site": "publisher-1.example",
7 | "event": "saveImpression",
8 | "options": { "histogramIndex": 1 }
9 | },
10 | {
11 | "seconds": 2,
12 | "site": "publisher-2.example",
13 | "event": "saveImpression",
14 | "options": { "histogramIndex": 2 }
15 | },
16 | {
17 | "seconds": 3,
18 | "site": "publisher-3.example",
19 | "intermediarySite": "adtech-1.example",
20 | "event": "saveImpression",
21 | "options": { "histogramIndex": 3 }
22 | },
23 | {
24 | "seconds": 4,
25 | "site": "advertiser-1.example",
26 | "event": "measureConversion",
27 | "$comment": "try to select 2 impressions to avoid depending on ordering",
28 | "options": {
29 | "aggregationService": "https://agg-service.example",
30 | "histogramSize": 4,
31 | "impressionCallers": ["publisher-1.example"],
32 | "value": 2,
33 | "maxValue": 2,
34 | "credit": [0.5, 0.5]
35 | },
36 | "expected": [0, 2, 0, 0]
37 | },
38 | {
39 | "seconds": 5,
40 | "site": "advertiser-2.example",
41 | "event": "measureConversion",
42 | "$comment": "try to select 2 impressions to avoid depending on ordering",
43 | "options": {
44 | "aggregationService": "https://agg-service.example",
45 | "histogramSize": 4,
46 | "impressionCallers": ["publisher-2.example"],
47 | "value": 2,
48 | "maxValue": 2,
49 | "credit": [0.5, 0.5]
50 | },
51 | "expected": [0, 0, 2, 0]
52 | },
53 | {
54 | "seconds": 6,
55 | "site": "advertiser-3.example",
56 | "event": "measureConversion",
57 | "$comment": "first impression caller should be parsed as publisher-1.example",
58 | "options": {
59 | "aggregationService": "https://agg-service.example",
60 | "histogramSize": 4,
61 | "impressionCallers": ["foo.publisher-1.example", "publisher-2.example"],
62 | "value": 2,
63 | "maxValue": 2,
64 | "credit": [0.5, 0.5]
65 | },
66 | "expected": [0, 1, 1, 0]
67 | },
68 | {
69 | "seconds": 7,
70 | "site": "advertiser-4.example",
71 | "event": "measureConversion",
72 | "options": {
73 | "aggregationService": "https://agg-service.example",
74 | "histogramSize": 4,
75 | "impressionCallers": ["publisher-3.example"],
76 | "value": 2,
77 | "maxValue": 2,
78 | "credit": [0.5, 0.5]
79 | },
80 | "$comment": "empty because publisher-3.example's impression had an intermediary site",
81 | "expected": [0, 0, 0, 0]
82 | },
83 | {
84 | "seconds": 8,
85 | "site": "advertiser-4.example",
86 | "event": "measureConversion",
87 | "options": {
88 | "aggregationService": "https://agg-service.example",
89 | "histogramSize": 4,
90 | "impressionCallers": ["adtech-1.example"],
91 | "value": 2,
92 | "maxValue": 2,
93 | "credit": [0.5, 0.5]
94 | },
95 | "expected": [0, 0, 0, 2]
96 | }
97 | ]
98 | }
99 |
--------------------------------------------------------------------------------
/impl/e2e-tests/conversion-callers.json:
--------------------------------------------------------------------------------
1 | {
2 | "$comment": "use different advertiser sites to avoid depending on budgeting",
3 | "events": [
4 | {
5 | "seconds": 1,
6 | "site": "publisher.example",
7 | "event": "saveImpression",
8 | "options": {
9 | "histogramIndex": 1,
10 | "conversionCallers": ["advertiser-1.example"]
11 | }
12 | },
13 | {
14 | "seconds": 2,
15 | "site": "publisher.example",
16 | "event": "saveImpression",
17 | "options": {
18 | "histogramIndex": 2,
19 | "conversionCallers": ["advertiser-2.example"]
20 | }
21 | },
22 | {
23 | "seconds": 3,
24 | "site": "publisher.example",
25 | "event": "saveImpression",
26 | "$comment": "first conversion caller should be parsed as advertiser-3.example",
27 | "options": {
28 | "histogramIndex": 3,
29 | "conversionCallers": ["foo.advertiser-3.example", "adtech-2.example"]
30 | }
31 | },
32 | {
33 | "seconds": 4,
34 | "site": "advertiser-1.example",
35 | "event": "measureConversion",
36 | "$comment": "try to select 2 impressions to avoid depending on ordering",
37 | "options": {
38 | "aggregationService": "https://agg-service.example",
39 | "histogramSize": 4,
40 | "value": 2,
41 | "maxValue": 2,
42 | "credit": [0.5, 0.5]
43 | },
44 | "expected": [0, 2, 0, 0]
45 | },
46 | {
47 | "seconds": 5,
48 | "site": "advertiser-2.example",
49 | "event": "measureConversion",
50 | "$comment": "try to select 2 impressions to avoid depending on ordering",
51 | "options": {
52 | "aggregationService": "https://agg-service.example",
53 | "histogramSize": 4,
54 | "value": 2,
55 | "maxValue": 2,
56 | "credit": [0.5, 0.5]
57 | },
58 | "expected": [0, 0, 2, 0]
59 | },
60 | {
61 | "seconds": 6,
62 | "site": "advertiser-3.example",
63 | "intermediarySite": "adtech-1.example",
64 | "event": "measureConversion",
65 | "options": {
66 | "aggregationService": "https://agg-service.example",
67 | "histogramSize": 4,
68 | "value": 2,
69 | "maxValue": 2,
70 | "credit": [0.5, 0.5]
71 | },
72 | "$comment": "empty because of the intermediary site",
73 | "expected": [0, 0, 0, 0]
74 | },
75 | {
76 | "seconds": 7,
77 | "site": "advertiser-3.example",
78 | "event": "measureConversion",
79 | "options": {
80 | "aggregationService": "https://agg-service.example",
81 | "histogramSize": 4,
82 | "value": 2,
83 | "maxValue": 2,
84 | "credit": [0.5, 0.5]
85 | },
86 | "expected": [0, 0, 0, 2]
87 | },
88 | {
89 | "seconds": 8,
90 | "site": "advertiser-4.example",
91 | "intermediarySite": "adtech-2.example",
92 | "event": "measureConversion",
93 | "options": {
94 | "aggregationService": "https://agg-service.example",
95 | "histogramSize": 4,
96 | "value": 2,
97 | "maxValue": 2,
98 | "credit": [0.5, 0.5]
99 | },
100 | "expected": [0, 0, 0, 2]
101 | }
102 | ]
103 | }
104 |
--------------------------------------------------------------------------------
/images/value.svg:
--------------------------------------------------------------------------------
1 |
69 |
--------------------------------------------------------------------------------
/impl/e2e-tests/measure-conversion-errors.json:
--------------------------------------------------------------------------------
1 | {
2 | "events": [
3 | {
4 | "seconds": 1,
5 | "site": "advertiser.example",
6 | "event": "measureConversion",
7 | "options": {
8 | "aggregationService": "https://invalid.example",
9 | "histogramSize": 1
10 | },
11 | "expected": "ReferenceError"
12 | },
13 | {
14 | "seconds": 2,
15 | "site": "advertiser.example",
16 | "event": "measureConversion",
17 | "options": {
18 | "aggregationService": "https://agg-service.example",
19 | "epsilon": 0,
20 | "histogramSize": 1
21 | },
22 | "expected": "RangeError"
23 | },
24 | {
25 | "seconds": 3,
26 | "site": "advertiser.example",
27 | "event": "measureConversion",
28 | "options": {
29 | "aggregationService": "https://agg-service.example",
30 | "epsilon": -1,
31 | "histogramSize": 1
32 | },
33 | "expected": "RangeError"
34 | },
35 | {
36 | "seconds": 4,
37 | "site": "advertiser.example",
38 | "event": "measureConversion",
39 | "options": {
40 | "aggregationService": "https://agg-service.example",
41 | "histogramSize": 0
42 | },
43 | "expected": "RangeError"
44 | },
45 | {
46 | "seconds": 5,
47 | "site": "advertiser.example",
48 | "event": "measureConversion",
49 | "$comment": "Greater than CONFIG.maxHistogramSize",
50 | "options": {
51 | "aggregationService": "https://agg-service.example",
52 | "histogramSize": 6
53 | },
54 | "expected": "RangeError"
55 | },
56 | {
57 | "seconds": 6,
58 | "site": "advertiser.example",
59 | "event": "measureConversion",
60 | "options": {
61 | "aggregationService": "https://agg-service.example",
62 | "histogramSize": 1,
63 | "value": 0
64 | },
65 | "expected": "RangeError"
66 | },
67 | {
68 | "seconds": 7,
69 | "site": "advertiser.example",
70 | "event": "measureConversion",
71 | "options": {
72 | "aggregationService": "https://agg-service.example",
73 | "histogramSize": 1,
74 | "value": 2,
75 | "maxValue": 1
76 | },
77 | "expected": "RangeError"
78 | },
79 | {
80 | "seconds": 8,
81 | "site": "advertiser.example",
82 | "event": "measureConversion",
83 | "options": {
84 | "aggregationService": "https://agg-service.example",
85 | "histogramSize": 1,
86 | "credit": []
87 | },
88 | "expected": "RangeError"
89 | },
90 | {
91 | "seconds": 9,
92 | "site": "advertiser.example",
93 | "event": "measureConversion",
94 | "options": {
95 | "aggregationService": "https://agg-service.example",
96 | "histogramSize": 1,
97 | "credit": [0]
98 | },
99 | "expected": "RangeError"
100 | },
101 | {
102 | "seconds": 10,
103 | "site": "advertiser.example",
104 | "event": "measureConversion",
105 | "$comment": "Greater than CONFIG.maxCreditSize",
106 | "options": {
107 | "aggregationService": "https://agg-service.example",
108 | "histogramSize": 1,
109 | "credit": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
110 | },
111 | "expected": "RangeError"
112 | },
113 | {
114 | "seconds": 11,
115 | "site": "advertiser.example",
116 | "event": "measureConversion",
117 | "options": {
118 | "aggregationService": "https://agg-service.example",
119 | "histogramSize": 1,
120 | "lookbackDays": 0
121 | },
122 | "expected": "RangeError"
123 | },
124 | {
125 | "seconds": 12,
126 | "site": "advertiser.example",
127 | "event": "measureConversion",
128 | "options": {
129 | "aggregationService": "https://agg-service.example",
130 | "histogramSize": 1,
131 | "impressionSites": [":"]
132 | },
133 | "expected": {
134 | "error": "DOMException",
135 | "name": "SyntaxError"
136 | }
137 | },
138 | {
139 | "seconds": 13,
140 | "site": "advertiser.example",
141 | "event": "measureConversion",
142 | "options": {
143 | "aggregationService": "https://agg-service.example",
144 | "histogramSize": 1,
145 | "impressionCallers": [":"]
146 | },
147 | "expected": {
148 | "error": "DOMException",
149 | "name": "SyntaxError"
150 | }
151 | }
152 | ]
153 | }
154 |
--------------------------------------------------------------------------------
/process.md:
--------------------------------------------------------------------------------
1 | The PAT working group will use a process similar to others that have employed a GitHub-centric mode of work. The process summary is as follows, and we should note that this is subject to change as we get closer to a final specification:
2 |
3 | * To access GitHub, you choose an interface, e.g., [command line interface](https://docs.github.com/en/github-cli), [GitHub mobile](https://docs.github.com/en/get-started/using-github/github-mobile), [GitHub desktop](https://docs.github.com/en/desktop), [VSCode](https://code.visualstudio.com/docs/sourcecontrol/github), or Browser.
4 | * To start a discussion, you generate an [Issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/creating-an-issue). Most of the discussion happens on/in an Issue.
5 | * To suggest a change, you generate a [Pull Request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request). “Discussions” about specific wording changes for a Pull Request occur on/in the PR.
6 | * To make a change, editors and, in rare circumstances, Chairs “merge” the Pull Request; issues related to PRs will be autoclosed with [link](https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue).
7 | * To publish a new version of the specification, GitHub plus some [W3C Tooling](https://github.com/w3c/echidna?tab=readme-ov-file) takes care of this [within minutes](https://www.w3.org/2021/03/18-echidna/?full#1) of the PR being merged.
8 | * To track what has happened, you can consult the GitHub logs; GitHub logs everything -- all issues, discussions about issues, labels, label changes, and specification changes, etc.
9 | * To stay informed, you can either “watch” the [Attribution repo](https://github.com/w3c/attribution) or check your email: the repo’s notifications are copied to the [PAT working group mailing list](https://lists.w3.org/Archives/Public/public-patwg/).
10 |
11 | What follows is a more detailed explanation of the process.
12 |
13 | Not all Issues are created equal. Some Issues are editorial; some are noncontroversial, non-editorial; and some are controversial. More process is required for controversial Issues than for noncontroversial Issues.
14 |
15 | While nobody wants their Issue to be controversial and most do not start out that way, it happens and when it does the chairs get involved to (if necessary) mediate the discussion and to (chair only function) judge consensus on the way forward. We will use [Labels](https://docs.github.com/en/issues/using-labels-and-milestones-to-track-work/managing-labels), `discuss`, `needs consensus`, `call-for-consensus`, and `has consensus`, to move through the process.
16 |
17 | * `discuss` is notice that the Issue needs to be discussed; can be applied by anyone.
18 | * `needs consensus` is a request for the chairs step in; can be applied by anyone.
19 | * `call-for-consensus` indicates that the PR or Issue is ready for working group participants to reach consensus; can only be applied by the chairs.
20 | * `has consensus` is an indication to the editors that the Issue has consensus and work on the Pull Request can proceed or that the Pull Request can be merged; can only be applied by the chairs.
21 |
22 | If there is no consensus to merge the Issue/PR will be closed; GitHub also supports reopening Issues and PRs if there is new information.
23 |
24 | If you apply a Label, please add a comment to explain your understanding of the current state of the Issue and why you are applying the Label, e.g., "discussed during interim meeting and the analysis in \[link to comment\] was supported, marking as `has consensus`".
25 |
26 | Editorial issues need to be dispatched quickly, and the process is as follows:
27 |
28 | * You submit a Pull Request to make the change; as noted earlier, editorial issues do not require that an Issue be filed.
29 | * You apply the `editorial` Label.
30 | * Editors will merge the Pull Request when appropriate
31 | * If the editors or chairs disagree with the `editorial` label, they will apply the `discuss` Label.
32 |
33 | For noncontroversial, non-editorial issues:
34 | * You file an Issue.
35 | * You add one or more Assignee(s) or one or more Assignee(s) will be selected during the discussion.
36 | * If at some point during the discussion, the Issue ceases to be noncontroversial, non-editorial the `discuss` Label will be applied.
37 | * The Assignee(s) submit(s) a Pull Request to address the issue; [link](https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue) to the Issue to allow for autoclose.
38 | * Editors will judge when the Pull Request is ready and when it is they will merge the Pull Request.
39 |
--------------------------------------------------------------------------------
/images/budget.svg:
--------------------------------------------------------------------------------
1 |
74 |
--------------------------------------------------------------------------------
/impl/src/allocate.test.ts:
--------------------------------------------------------------------------------
1 | import { fairlyAllocateCredit } from "./backend";
2 |
3 | import { strict as assert } from "node:assert";
4 | import test from "node:test";
5 | import { inverseErrorFunction } from "simple-statistics";
6 |
7 | interface FairlyAllocateCreditTestCase {
8 | name: string;
9 | credit: number[];
10 | value: number;
11 | needsRand?: boolean;
12 | }
13 |
14 | function noRand(): number {
15 | throw new Error("no rand expected");
16 | }
17 |
18 | type Interval = [min: number, max: number];
19 |
20 | // https://en.wikipedia.org/wiki/Probit
21 | function normalPpf(q: number, stdDev: number): number {
22 | return stdDev * Math.sqrt(2) * inverseErrorFunction(2 * q - 1);
23 | }
24 |
25 | const minNForIntervalApprox = 1000;
26 |
27 | function getIntervalApprox(n: number, p: number, alpha: number): Interval {
28 | if (n < minNForIntervalApprox) {
29 | throw new RangeError(`n must be >= ${minNForIntervalApprox}`);
30 | }
31 |
32 | // Approximates a binomial distribution with a normal distribution which is a bit
33 | // simpler as it is symmetric.
34 | const mean = n * p;
35 | const variance = mean * (1 - p);
36 | const diff = normalPpf(1 - alpha / 2, Math.sqrt(variance));
37 | return [mean - diff, mean + diff];
38 | }
39 |
40 | function getAllIntervals(
41 | n: number,
42 | creditFractions: readonly number[],
43 | alphaTotal: number,
44 | ): Interval[] {
45 | // We are testing one hypothesis per dimension, so divide `alphaTotal` by
46 | // the number of dimensions: https://en.wikipedia.org/wiki/Bonferroni_correction
47 | const alpha = alphaTotal / creditFractions.length;
48 | return creditFractions.map((cf) => getIntervalApprox(n, cf, alpha));
49 | }
50 |
51 | function runFairlyAllocateCreditTest(
52 | tc: Readonly,
53 | ): void {
54 | // TODO: replace with precise sum
55 | const sumCredit = tc.credit.reduce((a, b) => a + b, 0);
56 | const normalizedFloatCredit = tc.credit.map((item) => item / sumCredit);
57 |
58 | const [rand, k] = tc.needsRand ? [Math.random, 1000] : [noRand, 1];
59 |
60 | const totals = new Array(tc.credit.length).fill(0);
61 |
62 | for (let n = 0; n < k; ++n) {
63 | const actualCredit = fairlyAllocateCredit(tc.credit, tc.value, rand);
64 |
65 | assert.equal(actualCredit.length, tc.credit.length);
66 |
67 | for (const [j, actual] of actualCredit.entries()) {
68 | assert.ok(Number.isInteger(actual));
69 |
70 | const normalized = normalizedFloatCredit[j]! * tc.value;
71 | const diff = Math.abs(actual - normalized);
72 | assert.ok(
73 | diff < 1,
74 | `credit error >= 1: actual=${actual}, normalized=${normalized}`,
75 | );
76 |
77 | totals[j]! += actual / tc.value;
78 | }
79 |
80 | assert.equal(
81 | // TODO: replace with precise sum
82 | actualCredit.reduce((a, b) => a + b, 0),
83 | tc.value,
84 | `actual credit does not sum to value: ${actualCredit.join(", ")}`,
85 | );
86 | }
87 |
88 | const alpha = 0.00001; // Probability of test failing at random.
89 |
90 | const intervals: Interval[] =
91 | k > 1
92 | ? getAllIntervals(
93 | k,
94 | normalizedFloatCredit.map((c) => c - Math.floor(c)),
95 | alpha,
96 | )
97 | : normalizedFloatCredit.map((c) => [c, c]);
98 |
99 | for (const [j, total] of totals.entries()) {
100 | const [min, max] = intervals[j]!;
101 | assert.ok(
102 | total >= min && total <= max,
103 | `total for credit[${j}] ${total} not in ${1 - alpha} confidence interval [${min}, ${max}]`,
104 | );
105 | }
106 | }
107 |
108 | const testCases: FairlyAllocateCreditTestCase[] = [
109 | {
110 | name: "credit-equal-to-value",
111 | credit: [1],
112 | value: 1,
113 | needsRand: false,
114 | },
115 | {
116 | name: "credit-less-than-value",
117 | credit: [2],
118 | value: 3,
119 | needsRand: false,
120 | },
121 | {
122 | name: "credit-less-than-1",
123 | credit: [0.25],
124 | value: 4,
125 | needsRand: false,
126 | },
127 | {
128 | name: "2-credit-divides-value-evenly",
129 | credit: [3, 1],
130 | value: 8,
131 | needsRand: false,
132 | },
133 | {
134 | name: "3-credit-divides-value-evenly",
135 | credit: [2, 1, 1],
136 | value: 8,
137 | needsRand: false,
138 | },
139 | {
140 | name: "2-credit-divides-value-unevenly",
141 | credit: [1, 1],
142 | value: 5,
143 | needsRand: true,
144 | },
145 | {
146 | name: "3-credit-divides-value-unevenly",
147 | credit: [2, 1, 1],
148 | value: 5,
149 | needsRand: true,
150 | },
151 | ];
152 |
153 | void test("fairly-allocate-credit", async (t) => {
154 | await Promise.all(
155 | testCases.map((tc) =>
156 | t.test(tc.name, () => runFairlyAllocateCreditTest(tc)),
157 | ),
158 | );
159 | });
160 |
--------------------------------------------------------------------------------
/impl/src/e2e.test.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | AttributionImpressionOptions,
3 | AttributionConversionOptions,
4 | } from "./index";
5 |
6 | import type { TestContext } from "node:test";
7 |
8 | import { Backend, days } from "./backend";
9 | import type { TestConfig } from "./fixture";
10 |
11 | import { strict as assert } from "assert";
12 | import { glob, readFile } from "node:fs/promises";
13 | import * as path from "node:path";
14 | import test from "node:test";
15 | import { Temporal } from "temporal-polyfill";
16 |
17 | interface TestCase {
18 | config?: TestConfig;
19 | events: Event[];
20 | }
21 |
22 | type Event =
23 | | SaveImpression
24 | | MeasureConversion
25 | | ClearImpressionsForSite
26 | | ClearBrowsingHistoryForAttribution;
27 |
28 | type ExpectedError =
29 | | "RangeError"
30 | | "ReferenceError"
31 | | {
32 | error: "DOMException";
33 | name: string;
34 | };
35 |
36 | interface SaveImpression {
37 | event: "saveImpression";
38 | seconds: number;
39 | site: string;
40 | intermediarySite?: string | undefined;
41 | options: AttributionImpressionOptions;
42 | expectedError?: ExpectedError;
43 | }
44 |
45 | interface MeasureConversion {
46 | event: "measureConversion";
47 | seconds: number;
48 | site: string;
49 | intermediarySite?: string | undefined;
50 | options: AttributionConversionOptions;
51 | expected: number[] | ExpectedError;
52 | }
53 |
54 | interface ClearImpressionsForSite {
55 | event: "clearImpressionsForSite";
56 | seconds: number;
57 | site: string;
58 | }
59 |
60 | interface ClearBrowsingHistoryForAttribution {
61 | event: "clearBrowsingHistoryForAttribution";
62 | seconds: number;
63 | sites: string[];
64 | forgetVisits: boolean;
65 | }
66 |
67 | function assertThrows(
68 | call: () => unknown,
69 | expectedError: ExpectedError,
70 | seconds: number,
71 | ): void {
72 | const check =
73 | typeof expectedError === "string"
74 | ? { name: expectedError }
75 | : (err: unknown) => {
76 | assert.ok(err instanceof DOMException);
77 | assert.equal(err.name, expectedError.name);
78 | return true;
79 | };
80 |
81 | assert.throws(call, check, `seconds: ${seconds}`);
82 | }
83 |
84 | function runTest(
85 | defaultConfig: Readonly,
86 | tc: Readonly,
87 | ): void {
88 | const config = tc.config ?? defaultConfig;
89 |
90 | let now = new Temporal.Instant(0n);
91 |
92 | const backend = new Backend({
93 | aggregationServices: new Map(
94 | Object.entries(config.aggregationServices).map(([url, protocol]) => [
95 | url,
96 | { protocol },
97 | ]),
98 | ),
99 | includeUnencryptedHistogram: true,
100 |
101 | maxConversionSitesPerImpression: config.maxConversionSitesPerImpression,
102 | maxConversionCallersPerImpression: config.maxConversionCallersPerImpression,
103 | maxCreditSize: config.maxCreditSize,
104 | maxLookbackDays: config.maxLookbackDays,
105 | maxHistogramSize: config.maxHistogramSize,
106 | privacyBudgetMicroEpsilons: config.privacyBudgetMicroEpsilons,
107 | privacyBudgetEpoch: days(config.privacyBudgetEpochDays),
108 |
109 | now: () => now,
110 | fairlyAllocateCreditFraction: () => config.fairlyAllocateCreditFraction,
111 | epochStart: () => config.epochStart,
112 | });
113 |
114 | for (const event of tc.events) {
115 | const newNow = Temporal.Instant.fromEpochMilliseconds(event.seconds * 1e3);
116 | if (Temporal.Instant.compare(newNow, now) <= 0) {
117 | throw new RangeError(
118 | "events must have strictly increasing seconds fields",
119 | );
120 | }
121 | now = newNow;
122 |
123 | switch (event.event) {
124 | case "saveImpression": {
125 | const call = () =>
126 | backend.saveImpression(
127 | event.site,
128 | event.intermediarySite,
129 | event.options,
130 | );
131 |
132 | if (event.expectedError === undefined) {
133 | call();
134 | } else {
135 | assertThrows(call, event.expectedError, event.seconds);
136 | }
137 |
138 | break;
139 | }
140 | case "measureConversion": {
141 | const call = () =>
142 | backend.measureConversion(
143 | event.site,
144 | event.intermediarySite,
145 | event.options,
146 | );
147 |
148 | if (Array.isArray(event.expected)) {
149 | assert.deepEqual(
150 | call().unencryptedHistogram,
151 | event.expected,
152 | `seconds: ${event.seconds}`,
153 | );
154 | } else {
155 | assertThrows(call, event.expected, event.seconds);
156 | }
157 |
158 | break;
159 | }
160 | case "clearImpressionsForSite":
161 | backend.clearImpressionsForSite(event.site);
162 | break;
163 | case "clearBrowsingHistoryForAttribution":
164 | backend.clearState(event.sites, event.forgetVisits);
165 | break;
166 | }
167 | }
168 | }
169 |
170 | const configName = "CONFIG.json";
171 |
172 | async function runTestsInDir(t: TestContext, dir: string): Promise {
173 | const configJson = await readFile(path.join(dir, configName), "utf8");
174 | const defaultConfig = JSON.parse(configJson) as TestConfig;
175 |
176 | const promises = [];
177 |
178 | for await (const entry of glob(path.join(dir, "*.json"))) {
179 | if (path.basename(entry) === configName) {
180 | continue;
181 | }
182 |
183 | const promise = t.test(entry, async () => {
184 | const json = await readFile(entry, "utf8");
185 | const tc = JSON.parse(json) as TestCase;
186 | runTest(defaultConfig, tc);
187 | });
188 |
189 | promises.push(promise);
190 | }
191 |
192 | await Promise.all(promises);
193 | }
194 |
195 | void test("e2e", async (t) => runTestsInDir(t, "e2e-tests"));
196 |
--------------------------------------------------------------------------------
/impl/src/clear.test.ts:
--------------------------------------------------------------------------------
1 | import { Temporal } from "temporal-polyfill";
2 | import { Backend } from "./backend";
3 | import { defaultConfig, makeBackend, TestConfig } from "./fixture";
4 |
5 | import { strict as assert } from "node:assert";
6 | import test from "node:test";
7 |
8 | // For this test, we only care about the sites that are involved.
9 | interface SiteTableEntry {
10 | impression: string;
11 | conversion: string[];
12 | }
13 |
14 | const siteTable: readonly SiteTableEntry[] = [
15 | {
16 | impression: "imp-one.example",
17 | conversion: ["conv-one.example"],
18 | },
19 | {
20 | impression: "imp-two.example",
21 | conversion: ["conv-two.example"],
22 | },
23 | {
24 | impression: "imp-three.example",
25 | conversion: ["conv-three.example", "conv-three-plus.example"],
26 | },
27 | ];
28 |
29 | async function setupImpressions(config?: TestConfig): Promise {
30 | const backend = makeBackend(config);
31 | await Promise.all(
32 | siteTable.map(({ impression, conversion: conversionSites }) =>
33 | backend.saveImpression(impression, undefined, {
34 | histogramIndex: 1,
35 | conversionSites,
36 | }),
37 | ),
38 | );
39 | return backend;
40 | }
41 |
42 | // Clearing state for a given site only affects available privacy budget.
43 | void test("clear-site-state", async () => {
44 | const backend = await setupImpressions();
45 |
46 | // Check that this rejects correctly.
47 | assert.throws(() => backend.clearState([], false));
48 |
49 | // Run one query with the affected site.
50 | const before = backend.measureConversion("conv-one.example", undefined, {
51 | aggregationService: Object.keys(defaultConfig.aggregationServices)[0]!,
52 | histogramSize: defaultConfig.maxHistogramSize,
53 | epsilon: defaultConfig.privacyBudgetMicroEpsilons / 1e6 / 10,
54 | });
55 | assert.ok(before.unencryptedHistogram!.some((v) => v > 0));
56 |
57 | backend.clearState(["conv-one.example"], false);
58 |
59 | assert.equal(
60 | backend.impressions.length,
61 | siteTable.length,
62 | "All the impressions remain unaffected",
63 | );
64 | assert.equal(
65 | backend.lastBrowsingHistoryClear,
66 | null,
67 | "The last clear time is unaffected",
68 | );
69 |
70 | // Re-run a query and it should return an all zero result.
71 | const after = backend.measureConversion("conv-one.example", undefined, {
72 | aggregationService: Object.keys(defaultConfig.aggregationServices)[0]!,
73 | histogramSize: defaultConfig.maxHistogramSize,
74 | epsilon: defaultConfig.privacyBudgetMicroEpsilons / 1e6 / 10,
75 | });
76 | assert.ok(after.unencryptedHistogram!.every((v) => v === 0));
77 |
78 | // And all entries in the privacy budget table are for the cleared site.
79 | for (const entry of backend.privacyBudgetEntries) {
80 | assert.equal(entry.site, "conv-one.example");
81 | assert.equal(entry.value, 0);
82 | }
83 | });
84 |
85 | // Forgetting all sites resets the entire thing, except the last reset time.
86 | void test("forget-all-sites", async () => {
87 | const now = Temporal.Instant.from("2025-01-01T00:00Z");
88 | const backend = await setupImpressions({ now, ...defaultConfig });
89 | backend.clearState([], true);
90 |
91 | assert.deepEqual(backend.impressions, []);
92 | assert.deepEqual(backend.privacyBudgetEntries, []);
93 | assert.deepEqual(backend.epochStarts, new Map());
94 | assert.deepEqual(backend.lastBrowsingHistoryClear, now);
95 | });
96 |
97 | // Forgetting a site with impressions removes impressions.
98 | void test("forget-one-site-impressions", async () => {
99 | const now = Temporal.Instant.from("2025-01-01T00:00Z");
100 | const backend = await setupImpressions({ now, ...defaultConfig });
101 | backend.clearState(["imp-one.example"], true);
102 |
103 | assert.deepEqual(
104 | backend.impressions.map((i) => i.impressionSite),
105 | siteTable.map((i) => i.impression).filter((i) => i !== "imp-one.example"),
106 | "Impressions for the affected site are removed",
107 | );
108 | assert.deepEqual(backend.privacyBudgetEntries, []);
109 | assert.deepEqual(backend.epochStarts, new Map());
110 | assert.deepEqual(backend.lastBrowsingHistoryClear, now);
111 | });
112 |
113 | // Forgetting a site with conversion state removes those.
114 | void test("forget-one-site-conversions", async () => {
115 | const now = Temporal.Instant.from("2025-01-01T00:00Z");
116 | const backend = await setupImpressions({ now, ...defaultConfig });
117 |
118 | const before = backend.measureConversion("conv-one.example", undefined, {
119 | aggregationService: Object.keys(defaultConfig.aggregationServices)[0]!,
120 | histogramSize: defaultConfig.maxHistogramSize,
121 | epsilon: defaultConfig.privacyBudgetMicroEpsilons / 1e6 / 10,
122 | });
123 | assert.ok(before.unencryptedHistogram!.some((v) => v > 0));
124 |
125 | assert.ok(backend.privacyBudgetEntries.length > 0);
126 | assert.equal(backend.epochStarts.size, 1);
127 |
128 | backend.clearState(["conv-one.example"], true);
129 |
130 | // Impressions are unaffected, and conversion state is gone.
131 | assert.equal(backend.impressions.length, siteTable.length);
132 | assert.deepEqual(backend.privacyBudgetEntries, []);
133 | assert.deepEqual(backend.epochStarts, new Map());
134 | assert.deepEqual(backend.lastBrowsingHistoryClear, now);
135 |
136 | // Re-run a query and it should return an all zero result.
137 | const after = backend.measureConversion("conv-one.example", undefined, {
138 | aggregationService: Object.keys(defaultConfig.aggregationServices)[0]!,
139 | histogramSize: defaultConfig.maxHistogramSize,
140 | epsilon: defaultConfig.privacyBudgetMicroEpsilons / 1e6 / 10,
141 | });
142 | assert.ok(after.unencryptedHistogram!.every((v) => v === 0));
143 |
144 | // Privacy budget entries aren't added; this epoch is off-limits.
145 | assert.deepEqual(backend.privacyBudgetEntries, []);
146 | // The epoch start will be initialized.
147 | assert.equal(backend.epochStarts.size, 1);
148 | });
149 |
--------------------------------------------------------------------------------
/impl/src/http.test.ts:
--------------------------------------------------------------------------------
1 | import type { AttributionImpressionOptions } from "./index";
2 |
3 | import { parseSaveImpressionHeader } from "./http";
4 |
5 | import { strict as assert } from "assert";
6 | import test from "node:test";
7 |
8 | interface TestCase {
9 | name: string;
10 | input: string;
11 | expected?: AttributionImpressionOptions;
12 | }
13 |
14 | function runTests(cases: readonly TestCase[]): void {
15 | void test("parseSaveImpression", async (t) => {
16 | await Promise.all(
17 | cases.map((tc) =>
18 | t.test(tc.name, () => {
19 | if (tc.expected) {
20 | const actual = parseSaveImpressionHeader(tc.input);
21 | assert.deepEqual(actual, tc.expected);
22 | } else {
23 | assert.throws(() => parseSaveImpressionHeader(tc.input));
24 | }
25 | }),
26 | ),
27 | );
28 | });
29 | }
30 |
31 | runTests([
32 | { name: "invalid-structured-header-syntax", input: "!" },
33 | { name: "not-structured-header-dictionary", input: "histogram-index" },
34 | { name: "a-different-type", input: "10" },
35 |
36 | {
37 | name: "valid-minimal",
38 | input: "histogram-index=123",
39 | expected: {
40 | histogramIndex: 123,
41 | matchValue: undefined,
42 | conversionSites: undefined,
43 | conversionCallers: undefined,
44 | lifetimeDays: undefined,
45 | priority: undefined,
46 | },
47 | },
48 |
49 | {
50 | name: "valid-maximal",
51 | input: `histogram-index=123;x, match-value=4, conversion-sites=("b" "a";y);z, conversion-callers=("c"), lifetime-days=5, priority=-6, octopus=?1`,
52 | expected: {
53 | histogramIndex: 123,
54 | matchValue: 4,
55 | conversionSites: ["b", "a"],
56 | conversionCallers: ["c"],
57 | lifetimeDays: 5,
58 | priority: -6,
59 | },
60 | },
61 |
62 | {
63 | name: "valid-empty-sites",
64 | input: "histogram-index=1, conversion-sites=(), conversion-callers=()",
65 | expected: {
66 | histogramIndex: 1,
67 | matchValue: undefined,
68 | conversionSites: [],
69 | conversionCallers: [],
70 | lifetimeDays: undefined,
71 | priority: undefined,
72 | },
73 | },
74 |
75 | { name: "histogram-index-missing", input: "" },
76 | { name: "histogram-index-wrong-type", input: "histogram-index=a" },
77 | { name: "histogram-index-negative", input: "histogram-index=-1" },
78 | { name: "histogram-index-not-integer", input: "histogram-index=1.2" },
79 |
80 | {
81 | name: "valid-histogram-index-eq-32-bit-max",
82 | input: "histogram-index=4294967295",
83 | expected: {
84 | histogramIndex: 4294967295,
85 | matchValue: undefined,
86 | conversionSites: undefined,
87 | conversionCallers: undefined,
88 | lifetimeDays: undefined,
89 | priority: undefined,
90 | },
91 | },
92 | {
93 | name: "histogram-index-gt-32-bit-max",
94 | input: "histogram-index=4294967296",
95 | },
96 |
97 | {
98 | name: "conversion-sites-wrong-type",
99 | input: "conversion-sites=a, histogram-index=1",
100 | },
101 | {
102 | name: "conversion-sites-item-wrong-type",
103 | input: "conversion-sites=(a), histogram-index=1",
104 | },
105 |
106 | {
107 | name: "conversion-callers-wrong-type",
108 | input: "conversion-callers=a, histogram-index=1",
109 | },
110 | {
111 | name: "conversion-callers-item-wrong-type",
112 | input: "conversion-callers=(a), histogram-index=1",
113 | },
114 |
115 | { name: "match-value-wrong-type", input: "match-value=a, histogram-index=1" },
116 | { name: "match-value-negative", input: "match-value=-1, histogram-index=1" },
117 | {
118 | name: "match-value-not-integer",
119 | input: "match-value=1.2, histogram-index=1",
120 | },
121 | {
122 | name: "valid-match-value-eq-32-bit-max",
123 | input: "match-value=4294967295, histogram-index=1",
124 | expected: {
125 | histogramIndex: 1,
126 | matchValue: 4294967295,
127 | conversionSites: undefined,
128 | conversionCallers: undefined,
129 | lifetimeDays: undefined,
130 | priority: undefined,
131 | },
132 | },
133 | {
134 | name: "match-value-gt-32-bit-max",
135 | input: "match-value=4294967296, histogram-index=1",
136 | },
137 |
138 | {
139 | name: "lifetime-days-wrong-type",
140 | input: "lifetime-days=a, histogram-index=1",
141 | },
142 | {
143 | name: "lifetime-days-negative",
144 | input: "lifetime-days=-1, histogram-index=1",
145 | },
146 | {
147 | name: "lifetime-days-not-integer",
148 | input: "lifetime-days=1.2, histogram-index=1",
149 | },
150 | { name: "lifetime-days-zero", input: "lifetime-days=0, histogram-index=1" },
151 | {
152 | name: "valid-lifetime-days-maximal",
153 | input: "lifetime-days=999999999999999, histogram-index=1",
154 | expected: {
155 | histogramIndex: 1,
156 | matchValue: undefined,
157 | conversionSites: undefined,
158 | conversionCallers: undefined,
159 | lifetimeDays: 999999999999999,
160 | priority: undefined,
161 | },
162 | },
163 |
164 | { name: "priority-wrong-type", input: "priority=a, histogram-index=1" },
165 | { name: "priority-not-integer", input: "priority=1.2, histogram-index=1" },
166 | {
167 | name: "valid-priority-eq-32-bit-max",
168 | input: "priority=2147483647, histogram-index=1",
169 | expected: {
170 | histogramIndex: 1,
171 | matchValue: undefined,
172 | conversionSites: undefined,
173 | conversionCallers: undefined,
174 | lifetimeDays: undefined,
175 | priority: 2147483647,
176 | },
177 | },
178 | {
179 | name: "valid-priority-eq-32-bit-min",
180 | input: "priority=-2147483648, histogram-index=1",
181 | expected: {
182 | histogramIndex: 1,
183 | matchValue: undefined,
184 | conversionSites: undefined,
185 | conversionCallers: undefined,
186 | lifetimeDays: undefined,
187 | priority: -2147483648,
188 | },
189 | },
190 | {
191 | name: "priority-gt-32-bit-max",
192 | input: "priority=2147483648, histogram-index=1",
193 | },
194 | {
195 | name: "priority-lt-32-bit-min",
196 | input: "priority=-2147483649, histogram-index=1",
197 | },
198 | ]);
199 |
--------------------------------------------------------------------------------
/images/overview.svg:
--------------------------------------------------------------------------------
1 |
121 |
--------------------------------------------------------------------------------
/impl/e2e.schema.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json-schema.org/draft/2020-12/schema",
3 | "type": "object",
4 | "unevaluatedProperties": false,
5 | "oneOf": [
6 | {
7 | "properties": {
8 | "aggregationServices": {
9 | "type": "object",
10 | "additionalProperties": {
11 | "enum": ["dap-15-histogram"]
12 | },
13 | "propertyNames": {
14 | "format": "uri"
15 | }
16 | },
17 | "epochStart": {
18 | "type": "number",
19 | "minimum": 0,
20 | "exclusiveMaximum": 1
21 | },
22 | "fairlyAllocateCreditFraction": {
23 | "type": "number",
24 | "minimum": 0,
25 | "exclusiveMaximum": 1
26 | },
27 | "maxConversionCallersPerImpression": {
28 | "type": "integer",
29 | "minimum": 0
30 | },
31 | "maxConversionSitesPerImpression": {
32 | "type": "integer",
33 | "minimum": 0
34 | },
35 | "maxCreditSize": {
36 | "type": "integer",
37 | "minimum": 1
38 | },
39 | "maxHistogramSize": {
40 | "type": "integer",
41 | "minimum": 1
42 | },
43 | "maxLookbackDays": {
44 | "type": "integer",
45 | "minimum": 1
46 | },
47 | "privacyBudgetEpochDays": {
48 | "type": "integer",
49 | "minimum": 1
50 | },
51 | "privacyBudgetMicroEpsilons": {
52 | "type": "integer",
53 | "minimum": 1
54 | }
55 | },
56 | "required": [
57 | "aggregationServices",
58 | "maxConversionCallersPerImpression",
59 | "maxConversionSitesPerImpression",
60 | "maxCreditSize",
61 | "maxHistogramSize",
62 | "privacyBudgetEpochDays",
63 | "privacyBudgetMicroEpsilons"
64 | ]
65 | },
66 | {
67 | "properties": {
68 | "$comment": {
69 | "type": "string"
70 | },
71 | "events": {
72 | "type": "array",
73 | "items": {
74 | "type": "object",
75 | "oneOf": [
76 | {
77 | "$ref": "#/$defs/siteEvent",
78 | "properties": {
79 | "event": {
80 | "const": "saveImpression"
81 | },
82 | "expectedError": {
83 | "$ref": "#/$defs/expectedError"
84 | },
85 | "intermediarySite": {
86 | "type": "string"
87 | },
88 | "options": {
89 | "$ref": "#/$defs/AttributionImpressionOptions"
90 | }
91 | },
92 | "required": ["options"],
93 | "unevaluatedProperties": false
94 | },
95 | {
96 | "$ref": "#/$defs/siteEvent",
97 | "properties": {
98 | "event": {
99 | "const": "measureConversion"
100 | },
101 | "expected": {
102 | "oneOf": [
103 | {
104 | "type": "array",
105 | "items": {
106 | "type": "integer",
107 | "minimum": 0
108 | }
109 | },
110 | {
111 | "$ref": "#/$defs/expectedError"
112 | }
113 | ]
114 | },
115 | "intermediarySite": {
116 | "type": "string"
117 | },
118 | "options": {
119 | "$ref": "#/$defs/AttributionConversionOptions"
120 | }
121 | },
122 | "required": ["expected", "options"],
123 | "unevaluatedProperties": false
124 | },
125 | {
126 | "$ref": "#/$defs/siteEvent",
127 | "properties": {
128 | "event": {
129 | "const": "clearImpressionsForSite"
130 | }
131 | },
132 | "unevaluatedProperties": false
133 | },
134 | {
135 | "$ref": "#/$defs/commonEvent",
136 | "properties": {
137 | "event": {
138 | "const": "clearBrowsingHistoryForAttribution"
139 | },
140 | "forgetVisits": {
141 | "type": "boolean"
142 | },
143 | "sites": {
144 | "$ref": "#/$defs/siteList"
145 | }
146 | },
147 | "required": ["forgetVisits", "sites"],
148 | "unevaluatedProperties": false
149 | }
150 | ]
151 | }
152 | }
153 | },
154 | "required": ["events"]
155 | }
156 | ],
157 | "$defs": {
158 | "AttributionConversionOptions": {
159 | "type": "object",
160 | "properties": {
161 | "aggregationService": {
162 | "type": "string"
163 | },
164 | "credit": {
165 | "type": "array",
166 | "items": {
167 | "type": "number"
168 | }
169 | },
170 | "epsilon": {
171 | "type": "number"
172 | },
173 | "histogramSize": {
174 | "type": "integer"
175 | },
176 | "impressionCallers": {
177 | "$ref": "#/$defs/siteList"
178 | },
179 | "impressionSites": {
180 | "$ref": "#/$defs/siteList"
181 | },
182 | "lookbackDays": {
183 | "type": "integer"
184 | },
185 | "matchValues": {
186 | "type": "array",
187 | "items": {
188 | "type": "integer"
189 | }
190 | },
191 | "maxValue": {
192 | "type": "integer"
193 | },
194 | "value": {
195 | "type": "integer"
196 | }
197 | },
198 | "required": ["aggregationService", "histogramSize"],
199 | "unevaluatedProperties": false
200 | },
201 | "AttributionImpressionOptions": {
202 | "type": "object",
203 | "properties": {
204 | "conversionCallers": {
205 | "$ref": "#/$defs/siteList"
206 | },
207 | "conversionSites": {
208 | "$ref": "#/$defs/siteList"
209 | },
210 | "histogramIndex": {
211 | "type": "integer"
212 | },
213 | "lifetimeDays": {
214 | "type": "integer"
215 | },
216 | "matchValue": {
217 | "type": "integer"
218 | },
219 | "priority": {
220 | "type": "integer"
221 | }
222 | },
223 | "required": ["histogramIndex"],
224 | "unevaluatedProperties": false
225 | },
226 | "commonEvent": {
227 | "type": "object",
228 | "properties": {
229 | "$comment": {
230 | "type": "string"
231 | },
232 | "seconds": {
233 | "type": "integer"
234 | }
235 | },
236 | "required": ["seconds"]
237 | },
238 | "expectedError": {
239 | "oneOf": [
240 | {
241 | "type": "string"
242 | },
243 | {
244 | "type": "object",
245 | "properties": {
246 | "error": {
247 | "type": "string"
248 | },
249 | "name": {
250 | "type": "string"
251 | }
252 | },
253 | "required": ["error", "name"],
254 | "unevaluatedProperties": false
255 | }
256 | ]
257 | },
258 | "siteEvent": {
259 | "$ref": "#/$defs/commonEvent",
260 | "type": "object",
261 | "properties": {
262 | "site": {
263 | "type": "string"
264 | }
265 | },
266 | "required": ["site"]
267 | },
268 | "siteList": {
269 | "type": "array",
270 | "items": {
271 | "type": "string"
272 | }
273 | }
274 | }
275 | }
276 |
--------------------------------------------------------------------------------
/impl/e2e-tests/clear-site-data.json:
--------------------------------------------------------------------------------
1 | {
2 | "$comment": "use different advertiser sites to avoid depending on budgeting",
3 | "events": [
4 | {
5 | "seconds": 1,
6 | "site": "a.example",
7 | "event": "saveImpression",
8 | "options": { "histogramIndex": 0 }
9 | },
10 | {
11 | "seconds": 2,
12 | "site": "b.example",
13 | "event": "saveImpression",
14 | "options": {
15 | "histogramIndex": 1,
16 | "conversionSites": [
17 | "advertiser-1.example",
18 | "advertiser-2.example",
19 | "advertiser-3.example"
20 | ]
21 | }
22 | },
23 | {
24 | "seconds": 3,
25 | "site": "c.example",
26 | "intermediarySite": "d.example",
27 | "event": "saveImpression",
28 | "options": { "histogramIndex": 2 }
29 | },
30 | {
31 | "seconds": 4,
32 | "site": "advertiser-1.example",
33 | "event": "measureConversion",
34 | "$comment": "try to select 3 impressions to avoid depending on ordering",
35 | "options": {
36 | "aggregationService": "https://agg-service.example",
37 | "epsilon": 0.5,
38 | "histogramSize": 3,
39 | "value": 6,
40 | "maxValue": 6,
41 | "credit": [1, 1, 1]
42 | },
43 | "expected": [2, 2, 2]
44 | },
45 | {
46 | "seconds": 5,
47 | "site": "c.example",
48 | "event": "clearImpressionsForSite",
49 | "$comment": "should remove no impressions, as although there is an impression site for c.example, it has a different intermediary site"
50 | },
51 | {
52 | "seconds": 6,
53 | "site": "advertiser-2.example",
54 | "event": "measureConversion",
55 | "$comment": "try to select 3 impressions to avoid depending on ordering",
56 | "options": {
57 | "aggregationService": "https://agg-service.example",
58 | "epsilon": 0.5,
59 | "histogramSize": 3,
60 | "value": 6,
61 | "maxValue": 6,
62 | "credit": [1, 1, 1]
63 | },
64 | "expected": [2, 2, 2]
65 | },
66 | {
67 | "seconds": 7,
68 | "site": "d.example",
69 | "event": "clearImpressionsForSite",
70 | "$comment": "should remove only the impression with histogramIndex 2"
71 | },
72 | {
73 | "seconds": 8,
74 | "site": "advertiser-3.example",
75 | "event": "measureConversion",
76 | "$comment": "try to select 3 impressions to avoid depending on ordering",
77 | "options": {
78 | "aggregationService": "https://agg-service.example",
79 | "epsilon": 0.5,
80 | "histogramSize": 3,
81 | "value": 6,
82 | "maxValue": 6,
83 | "credit": [1, 1, 1]
84 | },
85 | "expected": [3, 3, 0]
86 | },
87 | {
88 | "seconds": 9,
89 | "site": "advertiser-1.example",
90 | "event": "clearImpressionsForSite",
91 | "$comment": "should remove no impressions, but remove this site from the conversionSites of the impression with histogramIndex 1"
92 | },
93 | {
94 | "seconds": 10,
95 | "site": "advertiser-1.example",
96 | "event": "measureConversion",
97 | "$comment": "try to select 3 impressions to avoid depending on ordering",
98 | "options": {
99 | "aggregationService": "https://agg-service.example",
100 | "epsilon": 0.5,
101 | "histogramSize": 3,
102 | "value": 6,
103 | "maxValue": 6,
104 | "credit": [1, 1, 1]
105 | },
106 | "expected": [6, 0, 0]
107 | },
108 | {
109 | "seconds": 11,
110 | "site": "advertiser-2.example",
111 | "event": "clearImpressionsForSite",
112 | "$comment": "should remove no impressions, but remove this site from the conversionSites of the impression with histogramIndex 1"
113 | },
114 | {
115 | "seconds": 12,
116 | "site": "advertiser-2.example",
117 | "event": "measureConversion",
118 | "$comment": "try to select 3 impressions to avoid depending on ordering",
119 | "options": {
120 | "aggregationService": "https://agg-service.example",
121 | "epsilon": 0.5,
122 | "histogramSize": 3,
123 | "value": 6,
124 | "maxValue": 6,
125 | "credit": [1, 1, 1]
126 | },
127 | "expected": [6, 0, 0]
128 | },
129 | {
130 | "seconds": 13,
131 | "site": "advertiser-3.example",
132 | "event": "clearImpressionsForSite",
133 | "$comment": "should remove the impression with histogramIndex 1, as its conversion sites set should become empty"
134 | },
135 | {
136 | "seconds": 14,
137 | "site": "advertiser-3.example",
138 | "event": "measureConversion",
139 | "$comment": "try to select 3 impressions to avoid depending on ordering",
140 | "options": {
141 | "aggregationService": "https://agg-service.example",
142 | "epsilon": 0.5,
143 | "histogramSize": 3,
144 | "value": 6,
145 | "maxValue": 6,
146 | "credit": [1, 1, 1]
147 | },
148 | "expected": [6, 0, 0]
149 | },
150 | {
151 | "seconds": 15,
152 | "site": "a.example",
153 | "event": "clearImpressionsForSite",
154 | "$comment": "should remove the impression with histogramIndex 0"
155 | },
156 | {
157 | "seconds": 16,
158 | "site": "advertiser-4.example",
159 | "event": "measureConversion",
160 | "$comment": "try to select 3 impressions to avoid depending on ordering",
161 | "options": {
162 | "aggregationService": "https://agg-service.example",
163 | "epsilon": 0.5,
164 | "histogramSize": 3,
165 | "value": 6,
166 | "maxValue": 6,
167 | "credit": [1, 1, 1]
168 | },
169 | "expected": [0, 0, 0]
170 | },
171 | {
172 | "seconds": 17,
173 | "site": "e.example",
174 | "event": "saveImpression",
175 | "options": {
176 | "histogramIndex": 3,
177 | "conversionSites": ["advertiser-5.example"],
178 | "conversionCallers": [
179 | "intermediary-1.example",
180 | "intermediary-2.example"
181 | ]
182 | }
183 | },
184 | {
185 | "seconds": 18,
186 | "site": "intermediary-1.example",
187 | "event": "clearImpressionsForSite",
188 | "$comment": "should remove no impressions, but remove this site from the conversionCallers of the impression with histogramIndex 3"
189 | },
190 | {
191 | "seconds": 19,
192 | "site": "advertiser-5.example",
193 | "intermediarySite": "intermediary-1.example",
194 | "event": "measureConversion",
195 | "$comment": "try to select 4 impressions to avoid depending on ordering",
196 | "options": {
197 | "aggregationService": "https://agg-service.example",
198 | "epsilon": 0.5,
199 | "histogramSize": 4,
200 | "value": 8,
201 | "maxValue": 8,
202 | "credit": [1, 1, 1, 1]
203 | },
204 | "expected": [0, 0, 0, 0]
205 | },
206 | {
207 | "seconds": 20,
208 | "site": "advertiser-5.example",
209 | "intermediarySite": "intermediary-2.example",
210 | "event": "measureConversion",
211 | "$comment": "try to select 4 impressions to avoid depending on ordering",
212 | "options": {
213 | "aggregationService": "https://agg-service.example",
214 | "epsilon": 0.5,
215 | "histogramSize": 4,
216 | "value": 8,
217 | "maxValue": 8,
218 | "credit": [1, 1, 1, 1]
219 | },
220 | "expected": [0, 0, 0, 8]
221 | },
222 | {
223 | "seconds": 21,
224 | "site": "intermediary-2.example",
225 | "event": "clearImpressionsForSite",
226 | "$comment": "should remove the impression with histogramIndex 3, as its conversion callers set should become empty"
227 | },
228 | {
229 | "seconds": 22,
230 | "site": "advertiser-5.example",
231 | "intermediarySite": "intermediary-2.example",
232 | "event": "measureConversion",
233 | "$comment": "try to select 4 impressions to avoid depending on ordering",
234 | "options": {
235 | "aggregationService": "https://agg-service.example",
236 | "epsilon": 0.5,
237 | "histogramSize": 4,
238 | "value": 8,
239 | "maxValue": 8,
240 | "credit": [1, 1, 1, 1]
241 | },
242 | "expected": [0, 0, 0, 0]
243 | }
244 | ]
245 | }
246 |
--------------------------------------------------------------------------------
/impl/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Attribution Simulator
6 |
107 |
108 |
109 |
110 |
173 |
255 |
256 | Impression Database
257 |
258 |
259 |
260 |
261 | | Timestamp |
262 | Impression Site |
263 | Intermediary Site |
264 | Histogram Index |
265 | Match Value |
266 | Lifetime Days |
267 | Priority |
268 | Conversion Sites |
269 | Conversion Callers |
270 |
271 |
272 |
273 |
274 |
275 |
276 |
277 |
278 |
--------------------------------------------------------------------------------
/impl/src/simulator.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | AttributionImpressionOptions,
3 | AttributionConversionOptions,
4 | } from "./index";
5 |
6 | import * as index from "./index";
7 |
8 | import { Backend, days } from "./backend";
9 |
10 | import { Temporal } from "temporal-polyfill";
11 |
12 | let now = new Temporal.Instant(0n);
13 |
14 | const backend = new Backend({
15 | aggregationServices: new Map([["", { protocol: "dap-15-histogram" }]]),
16 | includeUnencryptedHistogram: true,
17 |
18 | // TODO: Allow these values to be configured in the UI.
19 | maxConversionSitesPerImpression: 10,
20 | maxConversionCallersPerImpression: 10,
21 | maxCreditSize: Infinity,
22 | maxLookbackDays: 30,
23 | maxHistogramSize: 100,
24 | privacyBudgetMicroEpsilons: 1000000,
25 | privacyBudgetEpoch: days(7),
26 |
27 | now: () => now,
28 | fairlyAllocateCreditFraction: () => 0.5,
29 | epochStart: () => 0.5,
30 | });
31 |
32 | function numberOrUndefined(input: HTMLInputElement): number | undefined {
33 | const val = input.valueAsNumber;
34 | return Number.isNaN(val) ? undefined : val;
35 | }
36 |
37 | function spaceSeparated(input: HTMLInputElement): string[] {
38 | return input.value
39 | .trim()
40 | .split(/\s+/)
41 | .filter((v) => v.length > 0);
42 | }
43 |
44 | function reportValidity(this: HTMLFormElement) {
45 | this.reportValidity();
46 | }
47 |
48 | function sites(
49 | site: HTMLInputElement,
50 | intermediary: HTMLInputElement,
51 | ): [string, string | undefined] {
52 | return [
53 | site.value,
54 | intermediary.value.length === 0 ? undefined : intermediary.value,
55 | ];
56 | }
57 |
58 | function listCell(tr: HTMLTableRowElement, vs: Iterable): void {
59 | const td = tr.insertCell();
60 |
61 | let ul;
62 |
63 | for (const v of vs) {
64 | if (!ul) {
65 | ul = document.createElement("ul");
66 | }
67 |
68 | const li = document.createElement("li");
69 | li.innerText = v;
70 | ul.append(li);
71 | }
72 |
73 | if (ul) {
74 | td.append(ul);
75 | }
76 | }
77 |
78 | const impressionDb = document.querySelector("#db")!;
79 | const impressionTable = impressionDb.querySelector("tbody")!;
80 |
81 | function updateImpressionsTable() {
82 | impressionTable.replaceChildren();
83 | for (const i of backend.impressions) {
84 | const tr = document.createElement("tr");
85 |
86 | tr.insertCell().innerText = i.timestamp.toString();
87 | tr.insertCell().innerText = i.impressionSite;
88 | tr.insertCell().innerText = i.intermediarySite ?? "";
89 | tr.insertCell().innerText = i.histogramIndex.toString();
90 | tr.insertCell().innerText = i.matchValue.toString();
91 | tr.insertCell().innerText = (i.lifetime.hours / 24).toString();
92 | tr.insertCell().innerText = i.priority.toString();
93 | listCell(tr, i.conversionSites);
94 | listCell(tr, i.conversionCallers);
95 |
96 | impressionTable.append(tr);
97 | impressionDb.scroll(0, impressionTable.scrollHeight);
98 | }
99 | }
100 |
101 | function updateBudgetAndEpochTables() {
102 | const epochStarts = document.querySelector("#epochStarts")!;
103 | epochStarts.replaceChildren();
104 | for (const [site, start] of backend.epochStarts) {
105 | const dt = document.createElement("dt");
106 | dt.innerText = site;
107 | const dd = document.createElement("dd");
108 | dd.innerText = start.toString();
109 | epochStarts.append(dt, dd);
110 | }
111 |
112 | const privacyBudgetEntries = document.querySelector(
113 | "#privacyBudgetEntries",
114 | )!;
115 | privacyBudgetEntries.replaceChildren();
116 | for (const entry of backend.privacyBudgetEntries) {
117 | const dt = document.createElement("dt");
118 | dt.innerText = `${entry.site} @ epoch ${entry.epoch}`;
119 | const dd = document.createElement("dd");
120 | dd.innerText = entry.value.toString();
121 | privacyBudgetEntries.append(dt, dd);
122 | }
123 | }
124 |
125 | {
126 | const form = document.querySelector("#time")!;
127 |
128 | const time = document.querySelector("time")!;
129 | time.innerText = now.toString();
130 |
131 | const daysInput = form.elements.namedItem("days") as HTMLInputElement;
132 |
133 | form.addEventListener("input", reportValidity);
134 |
135 | form.addEventListener("submit", function (this: HTMLFormElement, e) {
136 | e.preventDefault();
137 |
138 | if (!this.reportValidity()) {
139 | return;
140 | }
141 |
142 | now = now.add(days(daysInput.valueAsNumber));
143 | time.innerText = now.toString();
144 | backend.clearExpiredImpressions();
145 | updateImpressionsTable();
146 | });
147 | }
148 |
149 | {
150 | function updateLastClear() {
151 | updateImpressionsTable();
152 | updateBudgetAndEpochTables();
153 |
154 | const container = document.querySelector("#last-clear")!;
155 | if (backend.lastBrowsingHistoryClear !== null) {
156 | container.style.display = "block";
157 | const lastClear = container.querySelector("time")!;
158 | lastClear.innerText = backend.lastBrowsingHistoryClear.toString();
159 | }
160 | }
161 |
162 | document
163 | .querySelector("#clear-as-user")!
164 | .addEventListener("submit", function (this: HTMLFormElement, e) {
165 | e.preventDefault();
166 |
167 | const sites = this.elements.namedItem("sites") as HTMLInputElement;
168 | const forgetVisits = this.elements.namedItem(
169 | "forget-visits",
170 | ) as HTMLInputElement;
171 | backend.clearState(spaceSeparated(sites), forgetVisits.checked);
172 | updateLastClear();
173 | });
174 |
175 | document
176 | .querySelector("#clear-as-site")!
177 | .addEventListener("submit", function (this: HTMLFormElement, e) {
178 | e.preventDefault();
179 |
180 | const site = this.elements.namedItem("site") as HTMLInputElement;
181 | backend.clearImpressionsForSite(site.value.trim());
182 | updateImpressionsTable();
183 | });
184 | }
185 |
186 | {
187 | const form = document.querySelector("#saveImpression")!;
188 |
189 | const site = form.elements.namedItem("impressionSite") as HTMLInputElement;
190 |
191 | const intermediary = form.elements.namedItem(
192 | "impressionIntermediary",
193 | ) as HTMLInputElement;
194 |
195 | const histogramIndex = form.elements.namedItem(
196 | "histogramIndex",
197 | ) as HTMLInputElement;
198 | histogramIndex.min = "0";
199 | histogramIndex.value = "0";
200 |
201 | const matchValue = form.elements.namedItem("matchValue") as HTMLInputElement;
202 | matchValue.min = "0";
203 | matchValue.valueAsNumber = index.DEFAULT_IMPRESSION_MATCH_VALUE;
204 |
205 | const lifetimeDays = form.elements.namedItem(
206 | "lifetimeDays",
207 | ) as HTMLInputElement;
208 | lifetimeDays.min = "1";
209 | lifetimeDays.valueAsNumber = index.DEFAULT_IMPRESSION_LIFETIME_DAYS;
210 |
211 | const priority = form.elements.namedItem("priority") as HTMLInputElement;
212 | priority.valueAsNumber = index.DEFAULT_IMPRESSION_PRIORITY;
213 |
214 | const conversionSites = form.elements.namedItem(
215 | "conversionSites",
216 | ) as HTMLInputElement;
217 |
218 | const conversionCallers = form.elements.namedItem(
219 | "conversionCallers",
220 | ) as HTMLInputElement;
221 |
222 | form.addEventListener("input", reportValidity);
223 |
224 | form.addEventListener("submit", function (this: HTMLFormElement, e) {
225 | e.preventDefault();
226 |
227 | if (!this.reportValidity()) {
228 | return;
229 | }
230 |
231 | const opts: AttributionImpressionOptions = {
232 | histogramIndex: histogramIndex.valueAsNumber,
233 | lifetimeDays: numberOrUndefined(lifetimeDays),
234 | matchValue: numberOrUndefined(matchValue),
235 | priority: numberOrUndefined(priority),
236 | conversionSites: spaceSeparated(conversionSites),
237 | conversionCallers: spaceSeparated(conversionCallers),
238 | };
239 |
240 | const li = document.createElement("li");
241 |
242 | try {
243 | backend.saveImpression(...sites(site, intermediary), opts);
244 | li.innerText = "Success";
245 | } catch (e) {
246 | // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
247 | li.innerText = `Error: ${e}`;
248 | }
249 |
250 | const output = form.querySelector("output")!;
251 | const results = output.querySelector("ol")!;
252 | results.append(li);
253 | output.scroll(0, output.scrollHeight);
254 | updateImpressionsTable();
255 | });
256 | }
257 |
258 | {
259 | const form = document.querySelector("#measureConversion")!;
260 |
261 | const site = form.elements.namedItem("conversionSite") as HTMLInputElement;
262 |
263 | const intermediary = form.elements.namedItem(
264 | "conversionIntermediary",
265 | ) as HTMLInputElement;
266 |
267 | const histogramSize = form.elements.namedItem(
268 | "histogramSize",
269 | ) as HTMLInputElement;
270 | histogramSize.min = "1";
271 | histogramSize.value = "1";
272 |
273 | const epsilon = form.elements.namedItem("epsilon") as HTMLInputElement;
274 | epsilon.min = "0.01";
275 | epsilon.step = "0.01";
276 | epsilon.max = index.MAX_CONVERSION_EPSILON.toString();
277 | epsilon.valueAsNumber = index.DEFAULT_CONVERSION_EPSILON;
278 |
279 | const value = form.elements.namedItem("value") as HTMLInputElement;
280 | value.min = "1";
281 | value.valueAsNumber = index.DEFAULT_CONVERSION_VALUE;
282 |
283 | const maxValue = form.elements.namedItem("maxValue") as HTMLInputElement;
284 | maxValue.min = "1";
285 | maxValue.valueAsNumber = index.DEFAULT_CONVERSION_MAX_VALUE;
286 |
287 | const lookbackDays = form.elements.namedItem(
288 | "lookbackDays",
289 | ) as HTMLInputElement;
290 | lookbackDays.min = "1";
291 |
292 | const credit = form.elements.namedItem("credit") as HTMLInputElement;
293 |
294 | const matchValues = form.elements.namedItem(
295 | "matchValues",
296 | ) as HTMLInputElement;
297 |
298 | const impressionSites = form.elements.namedItem(
299 | "impressionSites",
300 | ) as HTMLInputElement;
301 |
302 | const impressionCallers = form.elements.namedItem(
303 | "impressionCallers",
304 | ) as HTMLInputElement;
305 |
306 | const output = form.querySelector("output")!;
307 |
308 | form.addEventListener("input", reportValidity);
309 |
310 | form.addEventListener("submit", function (this: HTMLFormElement, e) {
311 | e.preventDefault();
312 |
313 | if (!this.reportValidity()) {
314 | return;
315 | }
316 |
317 | const opts: AttributionConversionOptions = {
318 | aggregationService: "",
319 | epsilon: numberOrUndefined(epsilon),
320 | histogramSize: histogramSize.valueAsNumber,
321 | matchValues: spaceSeparated(matchValues).map((v) =>
322 | Number.parseInt(v, 10),
323 | ),
324 | credit: spaceSeparated(credit).map(Number.parseFloat),
325 | lookbackDays: numberOrUndefined(lookbackDays),
326 | maxValue: numberOrUndefined(maxValue),
327 | value: numberOrUndefined(value),
328 | impressionSites: spaceSeparated(impressionSites),
329 | impressionCallers: spaceSeparated(impressionCallers),
330 | };
331 |
332 | const li = document.createElement("li");
333 | try {
334 | const result = backend.measureConversion(
335 | ...sites(site, intermediary),
336 | opts,
337 | );
338 |
339 | const dl = document.createElement("dl");
340 | let zeroes = 0;
341 | for (const [i, v] of result.unencryptedHistogram!.entries()) {
342 | if (v === 0) {
343 | zeroes++;
344 | continue;
345 | }
346 |
347 | const dt = document.createElement("dt");
348 | dt.innerText = i.toString();
349 |
350 | const dd = document.createElement("dd");
351 | dd.innerText = v.toString();
352 |
353 | dl.append(dt, dd);
354 | }
355 |
356 | li.innerText = `Histogram: ${zeroes} zeroes`;
357 | if (zeroes !== result.unencryptedHistogram!.length) {
358 | li.append(" and…", dl);
359 | }
360 | } catch (e) {
361 | // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
362 | li.innerText = `Error: ${e}`;
363 | }
364 |
365 | const results = output.querySelector("ol")!;
366 | results.append(li);
367 |
368 | updateBudgetAndEpochTables();
369 |
370 | output.scroll(0, output.scrollHeight);
371 | });
372 | }
373 |
--------------------------------------------------------------------------------
/impl/src/backend.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | AttributionAggregationService,
3 | AttributionAggregationServices,
4 | AttributionConversionOptions,
5 | AttributionConversionResult,
6 | AttributionImpressionOptions,
7 | AttributionImpressionResult,
8 | } from "./index";
9 |
10 | import * as index from "./index";
11 |
12 | import { Temporal } from "temporal-polyfill";
13 |
14 | import * as psl from "psl";
15 |
16 | interface Impression {
17 | matchValue: number;
18 | impressionSite: string;
19 | intermediarySite: string | undefined;
20 | conversionSites: Set;
21 | conversionCallers: Set;
22 | timestamp: Temporal.Instant;
23 | lifetime: Temporal.Duration;
24 | histogramIndex: number;
25 | priority: number;
26 | }
27 |
28 | interface PrivacyBudgetKey {
29 | epoch: number;
30 | site: string;
31 | }
32 |
33 | interface PrivacyBudgetStoreEntry extends Readonly {
34 | value: number;
35 | }
36 |
37 | interface ValidatedConversionOptions {
38 | aggregationService: Readonly;
39 | epsilon: number;
40 | histogramSize: number;
41 | lookback: Temporal.Duration;
42 | matchValues: ReadonlySet;
43 | impressionSites: ReadonlySet;
44 | impressionCallers: ReadonlySet;
45 | credit: readonly number[];
46 | value: number;
47 | maxValue: number;
48 | }
49 |
50 | export function days(days: number): Temporal.Duration {
51 | // We use `hours: X` here instead of `days` because days are considered to be
52 | // "calendar" units, making them incapable of being used in calculations
53 | // without a reference point.
54 | return Temporal.Duration.from({ hours: days * 24 });
55 | }
56 |
57 | function parseSite(input: string): string {
58 | const site = psl.get(input);
59 | if (site === null) {
60 | throw new DOMException(`invalid site ${input}`, "SyntaxError");
61 | }
62 | return site;
63 | }
64 |
65 | function validateSite(input: string): void {
66 | if (parseSite(input) !== input) {
67 | throw new TypeError("input must already be a valid site");
68 | }
69 | }
70 |
71 | function parseSites(input: readonly string[]): Set {
72 | const parsed = new Set();
73 | for (const site of input) {
74 | parsed.add(parseSite(site));
75 | }
76 | return parsed;
77 | }
78 |
79 | export interface Delegate {
80 | readonly aggregationServices: AttributionAggregationServices;
81 | readonly includeUnencryptedHistogram?: boolean;
82 |
83 | readonly maxConversionSitesPerImpression: number;
84 | readonly maxConversionCallersPerImpression: number;
85 | readonly maxCreditSize: number;
86 | readonly maxLookbackDays: number;
87 | readonly maxHistogramSize: number;
88 | readonly privacyBudgetMicroEpsilons: number;
89 | readonly privacyBudgetEpoch: Temporal.Duration;
90 |
91 | now(): Temporal.Instant;
92 | fairlyAllocateCreditFraction(): number;
93 | epochStart(): number;
94 | }
95 |
96 | function allZeroHistogram(size: number): number[] {
97 | return new Array(size).fill(0);
98 | }
99 |
100 | export class Backend {
101 | enabled: boolean = true;
102 |
103 | readonly #delegate: Delegate;
104 | #impressions: Readonly[] = [];
105 | readonly #epochStartStore: Map = new Map();
106 | #privacyBudgetStore: PrivacyBudgetStoreEntry[] = [];
107 |
108 | #lastBrowsingHistoryClear: Temporal.Instant | null = null;
109 |
110 | constructor(delegate: Delegate) {
111 | this.#delegate = delegate;
112 | }
113 |
114 | get epochStarts(): ReadonlyMap {
115 | return this.#epochStartStore;
116 | }
117 |
118 | get privacyBudgetEntries(): ReadonlyArray> {
119 | return this.#privacyBudgetStore;
120 | }
121 |
122 | get impressions(): ReadonlyArray> {
123 | return this.#impressions;
124 | }
125 |
126 | get aggregationServices(): AttributionAggregationServices {
127 | return this.#delegate.aggregationServices;
128 | }
129 |
130 | get lastBrowsingHistoryClear(): Temporal.Instant | null {
131 | return this.#lastBrowsingHistoryClear;
132 | }
133 |
134 | saveImpression(
135 | impressionSite: string,
136 | intermediarySite: string | undefined,
137 | {
138 | histogramIndex,
139 | matchValue = index.DEFAULT_IMPRESSION_MATCH_VALUE,
140 | conversionSites = [],
141 | conversionCallers = [],
142 | lifetimeDays = index.DEFAULT_IMPRESSION_LIFETIME_DAYS,
143 | priority = index.DEFAULT_IMPRESSION_PRIORITY,
144 | }: AttributionImpressionOptions,
145 | ): AttributionImpressionResult {
146 | validateSite(impressionSite);
147 | if (intermediarySite !== undefined) {
148 | validateSite(intermediarySite);
149 | }
150 |
151 | const timestamp = this.#delegate.now();
152 |
153 | if (
154 | histogramIndex < 0 ||
155 | histogramIndex >= this.#delegate.maxHistogramSize ||
156 | !Number.isInteger(histogramIndex)
157 | ) {
158 | throw new RangeError("histogramIndex must be a non-negative integer");
159 | }
160 |
161 | if (lifetimeDays <= 0 || !Number.isInteger(lifetimeDays)) {
162 | throw new RangeError("lifetimeDays must be a positive integer");
163 | }
164 | lifetimeDays = Math.min(lifetimeDays, this.#delegate.maxLookbackDays);
165 |
166 | const maxConversionSitesPerImpression =
167 | this.#delegate.maxConversionSitesPerImpression;
168 | if (conversionSites.length > maxConversionSitesPerImpression) {
169 | throw new RangeError(
170 | `conversionSites.length must be <= ${maxConversionSitesPerImpression}`,
171 | );
172 | }
173 | const parsedConversionSites = parseSites(conversionSites);
174 |
175 | const maxConversionCallersPerImpression =
176 | this.#delegate.maxConversionCallersPerImpression;
177 | if (conversionCallers.length > maxConversionCallersPerImpression) {
178 | throw new RangeError(
179 | `conversionCallers.length must be <= ${maxConversionCallersPerImpression}`,
180 | );
181 | }
182 | const parsedConversionCallers = parseSites(conversionCallers);
183 |
184 | if (matchValue < 0 || !Number.isInteger(matchValue)) {
185 | throw new RangeError("matchValue must be a non-negative integer");
186 | }
187 |
188 | if (!Number.isInteger(priority)) {
189 | throw new RangeError("priority must be an integer");
190 | }
191 |
192 | if (!this.enabled) {
193 | return {};
194 | }
195 |
196 | this.#impressions.push({
197 | matchValue,
198 | impressionSite,
199 | intermediarySite,
200 | conversionSites: parsedConversionSites,
201 | conversionCallers: parsedConversionCallers,
202 | timestamp,
203 | lifetime: days(lifetimeDays),
204 | histogramIndex,
205 | priority,
206 | });
207 |
208 | return {};
209 | }
210 |
211 | #validateConversionOptions({
212 | aggregationService,
213 | epsilon = index.DEFAULT_CONVERSION_EPSILON,
214 | histogramSize,
215 | impressionSites = [],
216 | impressionCallers = [],
217 | lookbackDays = this.#delegate.maxLookbackDays,
218 | credit = [1],
219 | maxValue = index.DEFAULT_CONVERSION_MAX_VALUE,
220 | matchValues = [],
221 | value = index.DEFAULT_CONVERSION_VALUE,
222 | }: AttributionConversionOptions): ValidatedConversionOptions {
223 | const aggregationServiceEntry =
224 | this.aggregationServices.get(aggregationService);
225 | if (aggregationServiceEntry === undefined) {
226 | throw new ReferenceError("unknown aggregation service");
227 | }
228 |
229 | if (epsilon <= 0 || epsilon > index.MAX_CONVERSION_EPSILON) {
230 | throw new RangeError(
231 | `epsilon must be in the range (0, ${index.MAX_CONVERSION_EPSILON}]`,
232 | );
233 | }
234 |
235 | const maxHistogramSize = this.#delegate.maxHistogramSize;
236 | if (
237 | histogramSize < 1 ||
238 | histogramSize > maxHistogramSize ||
239 | !Number.isInteger(histogramSize)
240 | ) {
241 | throw new RangeError(
242 | `histogramSize must be an integer in the range [1, ${maxHistogramSize}]`,
243 | );
244 | }
245 |
246 | if (value <= 0 || !Number.isInteger(value)) {
247 | throw new RangeError("value must be a positive integer");
248 | }
249 | if (maxValue <= 0 || !Number.isInteger(value)) {
250 | throw new RangeError("maxValue must be a positive integer");
251 | }
252 | if (value > maxValue) {
253 | throw new RangeError("value must be <= maxValue");
254 | }
255 |
256 | const maxCreditSize = this.#delegate.maxCreditSize;
257 | if (credit.length === 0 || credit.length > maxCreditSize) {
258 | throw new RangeError(
259 | `credit size must be in the range [1, ${maxCreditSize}]`,
260 | );
261 | }
262 | for (const c of credit) {
263 | if (c <= 0 || !Number.isFinite(value)) {
264 | throw new RangeError("credit must be positive and finite");
265 | }
266 | }
267 |
268 | if (lookbackDays <= 0 || !Number.isInteger(lookbackDays)) {
269 | throw new RangeError("lookbackDays must be a positive integer");
270 | }
271 | lookbackDays = Math.min(lookbackDays, this.#delegate.maxLookbackDays);
272 |
273 | const matchValueSet = new Set();
274 | for (const value of matchValues) {
275 | if (value < 0 || !Number.isInteger(value)) {
276 | throw new RangeError("match value must be a non-negative integer");
277 | }
278 | matchValueSet.add(value);
279 | }
280 |
281 | return {
282 | aggregationService: aggregationServiceEntry,
283 | epsilon,
284 | histogramSize,
285 | lookback: days(lookbackDays),
286 | matchValues: matchValueSet,
287 | impressionSites: parseSites(impressionSites),
288 | impressionCallers: parseSites(impressionCallers),
289 | credit,
290 | value,
291 | maxValue,
292 | };
293 | }
294 |
295 | measureConversion(
296 | topLevelSite: string,
297 | intermediarySite: string | undefined,
298 | options: AttributionConversionOptions,
299 | ): AttributionConversionResult {
300 | validateSite(topLevelSite);
301 | if (intermediarySite !== undefined) {
302 | validateSite(intermediarySite);
303 | }
304 |
305 | const now = this.#delegate.now();
306 |
307 | const validatedOptions = this.#validateConversionOptions(options);
308 |
309 | const report = this.enabled
310 | ? this.#doAttributionAndFillHistogram(
311 | topLevelSite,
312 | intermediarySite,
313 | now,
314 | validatedOptions,
315 | )
316 | : allZeroHistogram(validatedOptions.histogramSize);
317 |
318 | const result: AttributionConversionResult = {
319 | report: this.#encryptReport(report),
320 | };
321 | if (this.#delegate.includeUnencryptedHistogram) {
322 | result.unencryptedHistogram = report;
323 | }
324 | return result;
325 | }
326 |
327 | #commonMatchingLogic(
328 | topLevelSite: string,
329 | intermediarySite: string | undefined,
330 | epoch: number,
331 | now: Temporal.Instant,
332 | {
333 | lookback,
334 | impressionSites,
335 | impressionCallers,
336 | matchValues,
337 | }: ValidatedConversionOptions,
338 | ): Set {
339 | const matching = new Set();
340 |
341 | for (const impression of this.#impressions) {
342 | const impressionEpoch = this.#getCurrentEpoch(
343 | topLevelSite,
344 | impression.timestamp,
345 | );
346 | if (impressionEpoch !== epoch) {
347 | continue;
348 | }
349 | if (
350 | Temporal.Instant.compare(
351 | now,
352 | impression.timestamp.add(impression.lifetime),
353 | ) > 0
354 | ) {
355 | continue;
356 | }
357 | if (
358 | Temporal.Instant.compare(now, impression.timestamp.add(lookback)) > 0
359 | ) {
360 | continue;
361 | }
362 | if (
363 | impression.conversionSites.size > 0 &&
364 | !impression.conversionSites.has(topLevelSite)
365 | ) {
366 | continue;
367 | }
368 | const conversionCaller = intermediarySite ?? topLevelSite;
369 | if (
370 | impression.conversionCallers.size > 0 &&
371 | !impression.conversionCallers.has(conversionCaller)
372 | ) {
373 | continue;
374 | }
375 | if (matchValues.size > 0 && !matchValues.has(impression.matchValue)) {
376 | continue;
377 | }
378 | if (
379 | impressionSites.size > 0 &&
380 | !impressionSites.has(impression.impressionSite)
381 | ) {
382 | continue;
383 | }
384 | const impressionCaller =
385 | impression.intermediarySite ?? impression.impressionSite;
386 | if (
387 | impressionCallers.size > 0 &&
388 | !impressionCallers.has(impressionCaller)
389 | ) {
390 | continue;
391 | }
392 | matching.add(impression);
393 | }
394 |
395 | return matching;
396 | }
397 |
398 | #doAttributionAndFillHistogram(
399 | topLevelSite: string,
400 | intermediarySite: string | undefined,
401 | now: Temporal.Instant,
402 | options: ValidatedConversionOptions,
403 | ): number[] {
404 | let matchedImpressions;
405 | const currentEpoch = this.#getCurrentEpoch(topLevelSite, now);
406 | const startEpoch = this.#getStartEpoch(topLevelSite, now);
407 | const earliestEpoch = this.#getCurrentEpoch(
408 | topLevelSite,
409 | now.subtract(options.lookback),
410 | );
411 | const singleEpoch = currentEpoch === earliestEpoch;
412 |
413 | if (singleEpoch) {
414 | matchedImpressions = this.#commonMatchingLogic(
415 | topLevelSite,
416 | intermediarySite,
417 | currentEpoch,
418 | now,
419 | options,
420 | );
421 | } else {
422 | matchedImpressions = new Set();
423 | for (let epoch = startEpoch; epoch <= currentEpoch; ++epoch) {
424 | const impressions = this.#commonMatchingLogic(
425 | topLevelSite,
426 | intermediarySite,
427 | epoch,
428 | now,
429 | options,
430 | );
431 | if (impressions.size > 0) {
432 | const key = { epoch, site: topLevelSite };
433 | const budgetOk = this.#deductPrivacyBudget(
434 | key,
435 | options.epsilon,
436 | options.value,
437 | options.maxValue,
438 | /*l1Norm=*/ null,
439 | );
440 | if (budgetOk) {
441 | for (const i of impressions) {
442 | matchedImpressions.add(i);
443 | }
444 | }
445 | }
446 | }
447 | }
448 |
449 | if (matchedImpressions.size === 0) {
450 | return allZeroHistogram(options.histogramSize);
451 | }
452 |
453 | let histogram = this.#fillHistogramWithLastNTouchAttribution(
454 | matchedImpressions,
455 | options.histogramSize,
456 | options.value,
457 | options.credit,
458 | );
459 |
460 | if (singleEpoch) {
461 | const l1Norm = histogram.reduce((a, b) => a + b);
462 | if (l1Norm > options.value) {
463 | throw new DOMException(
464 | "l1Norm must be less than or equal to options.value",
465 | "InvalidStateError",
466 | );
467 | }
468 |
469 | const key = {
470 | site: topLevelSite,
471 | epoch: currentEpoch,
472 | };
473 |
474 | const budgetOk = this.#deductPrivacyBudget(
475 | key,
476 | options.epsilon,
477 | options.value,
478 | options.maxValue,
479 | l1Norm,
480 | );
481 |
482 | if (!budgetOk) {
483 | histogram = allZeroHistogram(options.histogramSize);
484 | }
485 | }
486 |
487 | return histogram;
488 | }
489 |
490 | #deductPrivacyBudget(
491 | key: PrivacyBudgetKey,
492 | epsilon: number,
493 | value: number,
494 | maxValue: number,
495 | l1Norm: number | null,
496 | ): boolean {
497 | let entry = this.#privacyBudgetStore.find(
498 | (e) => e.epoch === key.epoch && e.site === key.site,
499 | );
500 | if (entry === undefined) {
501 | entry = {
502 | value: this.#delegate.privacyBudgetMicroEpsilons + 1000,
503 | ...key,
504 | };
505 | this.#privacyBudgetStore.push(entry);
506 | }
507 | const sensitivity = l1Norm ?? 2 * value;
508 | const noiseScale = (2 * maxValue) / epsilon;
509 | const deductionFp = sensitivity / noiseScale;
510 | if (deductionFp < 0 || deductionFp > index.MAX_CONVERSION_EPSILON) {
511 | entry.value = 0;
512 | return false;
513 | }
514 | const deduction = Math.ceil(deductionFp * 1000000);
515 | if (deduction > entry.value) {
516 | entry.value = 0;
517 | return false;
518 | }
519 | entry.value -= deduction;
520 | return true;
521 | }
522 |
523 | #fillHistogramWithLastNTouchAttribution(
524 | matchedImpressions: ReadonlySet,
525 | histogramSize: number,
526 | value: number,
527 | credit: readonly number[],
528 | ): number[] {
529 | if (matchedImpressions.size === 0) {
530 | throw new DOMException(
531 | "matchedImpressions must not be empty",
532 | "InvalidStateError",
533 | );
534 | }
535 |
536 | const sortedImpressions = Array.from(matchedImpressions).toSorted(
537 | (a, b) => {
538 | if (a.priority < b.priority) {
539 | return 1;
540 | }
541 | if (a.priority > b.priority) {
542 | return -1;
543 | }
544 | return Temporal.Instant.compare(b.timestamp, a.timestamp);
545 | },
546 | );
547 |
548 | const N = Math.min(credit.length, sortedImpressions.length);
549 |
550 | const lastNImpressions = sortedImpressions.slice(0, N);
551 |
552 | credit = credit.slice(0, N);
553 |
554 | const normalizedCredit = fairlyAllocateCredit(credit, value, () =>
555 | this.#delegate.fairlyAllocateCreditFraction(),
556 | );
557 |
558 | const histogram = allZeroHistogram(histogramSize);
559 |
560 | for (const [i, impression] of lastNImpressions.entries()) {
561 | const value = normalizedCredit[i];
562 | const index = impression.histogramIndex;
563 | if (index < histogram.length) {
564 | histogram[index]! += value!;
565 | }
566 | }
567 | return histogram;
568 | }
569 |
570 | #encryptReport(report: readonly number[]): Uint8Array {
571 | void report;
572 | return new Uint8Array(0); // TODO
573 | }
574 |
575 | #getCurrentEpoch(site: string, t: Temporal.Instant): number {
576 | const period = this.#delegate.privacyBudgetEpoch.total("seconds");
577 | let start = this.#epochStartStore.get(site);
578 | if (start === undefined) {
579 | const p = checkRandom(this.#delegate.epochStart());
580 | const dur = Temporal.Duration.from({
581 | seconds: p * period,
582 | });
583 | start = t.subtract(dur);
584 | this.#epochStartStore.set(site, start);
585 | }
586 | const elapsed = t.since(start).total("seconds") / period;
587 | return Math.floor(elapsed);
588 | }
589 |
590 | #getStartEpoch(site: string, now: Temporal.Instant): number {
591 | const earliestEpochIndex = this.#getCurrentEpoch(
592 | site,
593 | now.subtract(days(this.#delegate.maxLookbackDays)),
594 | );
595 | const startEpoch = earliestEpochIndex;
596 | if (this.#lastBrowsingHistoryClear) {
597 | let clearEpoch = this.#getCurrentEpoch(
598 | site,
599 | this.#lastBrowsingHistoryClear,
600 | );
601 | clearEpoch += 2;
602 | if (clearEpoch > startEpoch) {
603 | return clearEpoch;
604 | }
605 | }
606 | return startEpoch;
607 | }
608 |
609 | clearImpressionsForSite(site: string): void {
610 | function shouldRemoveImpression(i: Impression): boolean {
611 | if (i.intermediarySite === undefined && i.impressionSite === site) {
612 | return true;
613 | }
614 | if (i.intermediarySite === site) {
615 | return true;
616 | }
617 | if (i.conversionSites.has(site)) {
618 | i.conversionSites.delete(site);
619 | if (i.conversionSites.size === 0) {
620 | return true;
621 | }
622 | }
623 | if (i.conversionCallers.has(site)) {
624 | i.conversionCallers.delete(site);
625 | if (i.conversionCallers.size === 0) {
626 | return true;
627 | }
628 | }
629 | return false;
630 | }
631 |
632 | this.#impressions = this.#impressions.filter(
633 | (i) => !shouldRemoveImpression(i),
634 | );
635 | }
636 |
637 | #zeroBudgetForSites(sites: ReadonlySet): void {
638 | if (sites.size === 0) {
639 | throw new RangeError("need to specify at least one site when forgetting");
640 | }
641 |
642 | const now = this.#delegate.now();
643 |
644 | for (const site of sites) {
645 | const startEpoch = this.#getStartEpoch(site, now);
646 | const currentEpoch = this.#getCurrentEpoch(site, now);
647 | for (let epoch = startEpoch; epoch <= currentEpoch; ++epoch) {
648 | const entry = this.#privacyBudgetStore.find(
649 | (e) => e.epoch === epoch && e.site === site,
650 | );
651 | if (entry === undefined) {
652 | this.#privacyBudgetStore.push({
653 | site,
654 | epoch,
655 | value: 0,
656 | });
657 | } else {
658 | entry.value = 0;
659 | }
660 | }
661 | }
662 | }
663 |
664 | clearState(sites: readonly string[], forgetVisits: boolean): void {
665 | const parsedSites = parseSites(sites);
666 | if (!forgetVisits) {
667 | this.#zeroBudgetForSites(parsedSites);
668 | return;
669 | }
670 |
671 | if (parsedSites.size === 0) {
672 | this.#impressions = [];
673 | this.#privacyBudgetStore = [];
674 | this.#epochStartStore.clear();
675 | } else {
676 | this.#impressions = this.#impressions.filter((e) => {
677 | return !parsedSites.has(e.impressionSite);
678 | });
679 | this.#privacyBudgetStore = this.#privacyBudgetStore.filter((e) => {
680 | return !parsedSites.has(e.site);
681 | });
682 | for (const site of parsedSites) {
683 | this.#epochStartStore.delete(site);
684 | }
685 | }
686 |
687 | this.#lastBrowsingHistoryClear = this.#delegate.now();
688 | }
689 |
690 | clearExpiredImpressions(): void {
691 | const now = this.#delegate.now();
692 |
693 | this.#impressions = this.#impressions.filter((impression) => {
694 | return (
695 | Temporal.Instant.compare(
696 | now,
697 | impression.timestamp.add(impression.lifetime),
698 | ) < 0
699 | );
700 | });
701 | }
702 | }
703 |
704 | function checkRandom(p: number): number {
705 | if (!(p >= 0 && p < 1)) {
706 | throw new RangeError("random must be in the range [0, 1)");
707 | }
708 | return p;
709 | }
710 |
711 | export function fairlyAllocateCredit(
712 | credit: readonly number[],
713 | value: number,
714 | rand: () => number,
715 | ): number[] {
716 | // TODO: replace with precise sum
717 | const sumCredit = credit.reduce((a, b) => a + b, 0);
718 |
719 | const roundedCredit = credit.map((item) => (value * item) / sumCredit);
720 |
721 | let idx1 = 0;
722 |
723 | for (let n = 1; n < roundedCredit.length; ++n) {
724 | let idx2 = n;
725 |
726 | const frac1 = roundedCredit[idx1]! - Math.floor(roundedCredit[idx1]!);
727 | const frac2 = roundedCredit[idx2]! - Math.floor(roundedCredit[idx2]!);
728 | if (frac1 === 0 && frac2 === 0) {
729 | continue;
730 | }
731 |
732 | const [incr1, incr2] =
733 | frac1 + frac2 > 1 ? [1 - frac1, 1 - frac2] : [-frac1, -frac2];
734 |
735 | const p1 = incr2 / (incr1 + incr2);
736 |
737 | const r = checkRandom(rand());
738 |
739 | let incr;
740 | if (r < p1) {
741 | incr = incr1;
742 | [idx1, idx2] = [idx2, idx1];
743 | } else {
744 | incr = incr2;
745 | }
746 |
747 | roundedCredit[idx2]! += incr;
748 | roundedCredit[idx1]! -= incr;
749 | }
750 |
751 | return roundedCredit.map((item) => Math.round(item));
752 | }
753 |
--------------------------------------------------------------------------------