Skip to content

Commit 5559fa6

Browse files
prebid: add pubProvidedId delivery of cached EIDs via user-id config
1 parent f7f30c6 commit 5559fa6

4 files changed

Lines changed: 342 additions & 0 deletions

File tree

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1285,6 +1285,20 @@ window.optable.cmd = new OptableCommands(window.optable.cmd || []);
12851285

12861286
For the page-side stub and behaviour details, see the [command queue addon README](lib/addons/commands.md).
12871287

1288+
## Prebid pubProvidedId delivery
1289+
1290+
The pubProvidedId module delivers cached EIDs to prebid through the `pubProvidedId` user-id submodule, for integrations that don't use the RTD module. EIDs from other providers are preserved, ours are replaced by source, and the work queues on the prebid global so it also runs before prebid has loaded.
1291+
1292+
```typescript
1293+
import { mergeIntoPubProvidedId } from "@optable/web-sdk/lib/dist/core/prebid/pubProvidedId";
1294+
1295+
mergeIntoPubProvidedId({ instances: ["pbjs"] });
1296+
```
1297+
1298+
On Prebid versions without the fix for [prebid/Prebid.js#15562](https://github.com/prebid/Prebid.js/pull/15562), the module's filtered ID refresh can drop other vendors (LiveIntent, ID5, …) from the page's first auction. Passing `refreshAll: true` works around it with a full ID refresh: the upside is that no vendor is dropped from the first auction; the downside is that every ID vendor re-requests on that pageview (relevant under per-request quotas) and the auction can start later. Leave it off on Prebid versions that include the fix.
1299+
1300+
For behavior details and options, see the [pubProvidedId README](lib/core/prebid/pubProvidedId.md).
1301+
12881302
## Demo Pages
12891303

12901304
The demo pages are working examples of both `identify` and `targeting` APIs, as well as an integration with the [Google Ad Manager 360](https://admanager.google.com/home/) ad server, enabling the targeting of ads served by GAM360 to audiences activated in the [Optable](https://optable.co/) DCN.

lib/core/prebid/pubProvidedId.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# pubProvidedId Delivery
2+
3+
Delivers cached EIDs to prebid through the [`pubProvidedId` user-id submodule](https://docs.prebid.org/dev-docs/modules/userid-submodules/pubprovided.html), for integrations that deliver EIDs via user-id config rather than the RTD module.
4+
5+
## Usage
6+
7+
```js
8+
import { mergeIntoPubProvidedId } from "@optable/web-sdk/lib/dist/core/prebid/pubProvidedId";
9+
10+
mergeIntoPubProvidedId({ instances: ["pbjs"] });
11+
```
12+
13+
Call it after each write to the rolling EID cache (targeting, tokenize, UID2 refresh). By default it reads EIDs from the `OPTABLE_RESOLVED` key in `localStorage`; pass `cacheKey` to read another key, or `eids` to merge an explicit list.
14+
15+
## Behavior
16+
17+
- Work is queued on each instance's `que` array, so it also runs when prebid hasn't loaded yet — the queue is created on the named global if needed.
18+
- EIDs from other providers already in `pubProvidedId` are preserved; ours are replaced by `source`.
19+
- Duplicate `pubProvidedId` entries in an already polluted config are collapsed back to a single entry; other user-id submodules and the rest of the `userSync` config are untouched.
20+
- Underscore-prefixed cache sidecars (`_ref` UID2 refresh material, `_id5` metadata) are stripped before EIDs reach prebid, so they never leak into bid requests.
21+
- After merging, `refreshUserIds({ submoduleNames: ["pubProvidedId"] })` propagates the change — or a full `refreshUserIds()` with `refreshAll: true` (see below).
22+
- Any decision to skip delivery (a split-test control group, for example) stays with the caller.
23+
24+
## Options
25+
26+
| Option | Default | Description |
27+
| ------------ | -------------------- | ----------------------------------------------------------------------------------- |
28+
| `instances` | `["pbjs"]` | Names of the prebid globals to merge into. |
29+
| `cacheKey` | `"OPTABLE_RESOLVED"` | localStorage key of the rolling EID cache. |
30+
| `eids` | read from the cache | Explicit EIDs to merge, bypassing the cache. |
31+
| `refreshAll` | `false` | Refresh every user-id submodule after merging, not just `pubProvidedId`. See below. |
32+
33+
## First-auction identity and `refreshAll`
34+
35+
Prebid versions without the fix for [prebid/Prebid.js#15562](https://github.com/prebid/Prebid.js/pull/15562) have a defect: a filtered `refreshUserIds({ submoduleNames })` issued while other ID vendors are still initializing abandons their in-flight work, so vendors like LiveIntent or ID5 are dropped from the page's first auction. The default filtered refresh this module issues at page load is exactly that trigger.
36+
37+
`refreshAll: true` works around it by issuing an unfiltered `refreshUserIds()` instead, which starts a full refresh cycle the auction waits for — every vendor completes, and the merged EIDs are included.
38+
39+
- Upside: no vendor is dropped from the first auction, and split-test uplift is no longer understated by treated users losing other vendors' IDs.
40+
- Downside: every configured ID vendor makes a fresh request on that pageview (relevant when a vendor applies per-request quotas), and the auction can start later since it waits for the slowest vendor.
41+
42+
Leave it off on Prebid versions that include the fix — the filtered refresh is then both correct and cheaper. Neither mode can rescue an auction that fired before the merge ran at all.
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { mergeIntoPubProvidedId } from "./pubProvidedId";
2+
3+
type FakePbjs = {
4+
que: Array<() => void>;
5+
getConfig: jest.Mock;
6+
setConfig: jest.Mock;
7+
refreshUserIds: jest.Mock;
8+
};
9+
10+
function makePbjs(userSync: Record<string, unknown> = {}): FakePbjs {
11+
return {
12+
que: [],
13+
getConfig: jest.fn(() => userSync),
14+
setConfig: jest.fn(),
15+
refreshUserIds: jest.fn(),
16+
};
17+
}
18+
19+
const w = window as unknown as Record<string, any>;
20+
21+
const EIDS = [
22+
{ source: "uidapi.com", uids: [{ atype: 3, id: "uid2-token" }] },
23+
{ source: "id5-sync.com", uids: [{ atype: 1, id: "id5-id" }] },
24+
];
25+
26+
function seedCache(eids: unknown[], key = "OPTABLE_RESOLVED") {
27+
localStorage.setItem(key, JSON.stringify({ ortb2: { user: { data: [], eids } } }));
28+
}
29+
30+
function drain(pbjs: FakePbjs) {
31+
pbjs.que.forEach((cmd) => cmd());
32+
}
33+
34+
beforeEach(() => {
35+
localStorage.clear();
36+
delete w.pbjs;
37+
delete w.owpbjs;
38+
});
39+
40+
describe("mergeIntoPubProvidedId", () => {
41+
it("merges cached EIDs into a single pubProvidedId entry and refreshes it", () => {
42+
seedCache(EIDS);
43+
const pbjs = makePbjs({});
44+
w.pbjs = pbjs;
45+
46+
mergeIntoPubProvidedId();
47+
drain(pbjs);
48+
49+
const config = pbjs.setConfig.mock.calls[0][0];
50+
expect(config.userSync.userIds).toEqual([{ name: "pubProvidedId", params: { eids: EIDS } }]);
51+
expect(pbjs.refreshUserIds).toHaveBeenCalledWith({ submoduleNames: ["pubProvidedId"] });
52+
});
53+
54+
it("queues onto a stub global when prebid has not loaded yet", () => {
55+
seedCache(EIDS);
56+
mergeIntoPubProvidedId();
57+
58+
expect(w.pbjs.que).toHaveLength(1);
59+
expect(() => w.pbjs.que.forEach((cmd: () => void) => cmd())).not.toThrow();
60+
});
61+
62+
it("preserves other providers' EIDs and replaces ours by source", () => {
63+
seedCache(EIDS);
64+
const pbjs = makePbjs({
65+
userIds: [
66+
{
67+
name: "pubProvidedId",
68+
params: {
69+
eids: [
70+
{ source: "uidapi.com", uids: [{ id: "old-uid2" }] },
71+
{ source: "publisher.com", uids: [{ id: "pub-own" }] },
72+
],
73+
},
74+
},
75+
],
76+
});
77+
w.pbjs = pbjs;
78+
79+
mergeIntoPubProvidedId();
80+
drain(pbjs);
81+
82+
const eids = pbjs.setConfig.mock.calls[0][0].userSync.userIds[0].params.eids;
83+
expect(eids.map((e: any) => e.source)).toEqual(["publisher.com", "uidapi.com", "id5-sync.com"]);
84+
expect(eids.find((e: any) => e.source === "uidapi.com").uids[0].id).toBe("uid2-token");
85+
});
86+
87+
it("collapses duplicate pubProvidedId entries and keeps other submodules", () => {
88+
seedCache(EIDS);
89+
const pbjs = makePbjs({
90+
syncDelay: 5000,
91+
userIds: [
92+
{ name: "sharedId" },
93+
{ name: "pubProvidedId", params: { eids: [{ source: "a.com", uids: [{ id: "a" }] }] } },
94+
{ name: "pubProvidedId", params: { eids: [{ source: "b.com", uids: [{ id: "b" }] }] } },
95+
],
96+
});
97+
w.pbjs = pbjs;
98+
99+
mergeIntoPubProvidedId();
100+
drain(pbjs);
101+
102+
const config = pbjs.setConfig.mock.calls[0][0];
103+
expect(config.userSync.syncDelay).toBe(5000);
104+
const names = config.userSync.userIds.map((u: any) => u.name);
105+
expect(names).toEqual(["sharedId", "pubProvidedId"]);
106+
const eids = config.userSync.userIds[1].params.eids;
107+
expect(eids.map((e: any) => e.source)).toEqual(["a.com", "b.com", "uidapi.com", "id5-sync.com"]);
108+
});
109+
110+
it("strips underscore-prefixed cache sidecars before handing EIDs to prebid", () => {
111+
seedCache([{ source: "uidapi.com", uids: [{ id: "x" }], _ref: { refresh_token: "rt" }, _id5: { t: 1 } }]);
112+
const pbjs = makePbjs({});
113+
w.pbjs = pbjs;
114+
115+
mergeIntoPubProvidedId();
116+
drain(pbjs);
117+
118+
const eid = pbjs.setConfig.mock.calls[0][0].userSync.userIds[0].params.eids[0];
119+
expect(eid).toEqual({ source: "uidapi.com", uids: [{ id: "x" }] });
120+
});
121+
122+
it("does nothing when the cache has no EIDs", () => {
123+
const pbjs = makePbjs({});
124+
w.pbjs = pbjs;
125+
126+
mergeIntoPubProvidedId();
127+
128+
expect(pbjs.que).toHaveLength(0);
129+
});
130+
131+
it("merges into every configured instance", () => {
132+
seedCache(EIDS);
133+
const a = makePbjs({});
134+
const b = makePbjs({});
135+
w.pbjs = a;
136+
w.owpbjs = b;
137+
138+
mergeIntoPubProvidedId({ instances: ["pbjs", "owpbjs"] });
139+
drain(a);
140+
drain(b);
141+
142+
expect(a.setConfig).toHaveBeenCalled();
143+
expect(b.setConfig).toHaveBeenCalled();
144+
});
145+
146+
it("accepts explicit eids and a custom cacheKey", () => {
147+
seedCache(EIDS, "MY_CACHE");
148+
const pbjs = makePbjs({});
149+
w.pbjs = pbjs;
150+
151+
mergeIntoPubProvidedId({ cacheKey: "MY_CACHE" });
152+
drain(pbjs);
153+
expect(pbjs.setConfig.mock.calls[0][0].userSync.userIds[0].params.eids).toEqual(EIDS);
154+
155+
const direct = makePbjs({});
156+
w.pbjs = direct;
157+
mergeIntoPubProvidedId({ eids: [{ source: "direct.com", uids: [{ id: "d" }] }] });
158+
drain(direct);
159+
expect(direct.setConfig.mock.calls[0][0].userSync.userIds[0].params.eids).toEqual([
160+
{ source: "direct.com", uids: [{ id: "d" }] },
161+
]);
162+
});
163+
164+
it("refreshAll refreshes every user-id submodule instead of only pubProvidedId", () => {
165+
seedCache(EIDS);
166+
const pbjs = makePbjs({});
167+
w.pbjs = pbjs;
168+
169+
mergeIntoPubProvidedId({ refreshAll: true });
170+
drain(pbjs);
171+
172+
expect(pbjs.refreshUserIds).toHaveBeenCalledWith();
173+
});
174+
175+
it("a throwing prebid config call does not break the queue", () => {
176+
seedCache(EIDS);
177+
const pbjs = makePbjs({});
178+
pbjs.getConfig.mockImplementation(() => {
179+
throw new Error("boom");
180+
});
181+
w.pbjs = pbjs;
182+
183+
mergeIntoPubProvidedId();
184+
expect(() => drain(pbjs)).not.toThrow();
185+
expect(pbjs.setConfig).not.toHaveBeenCalled();
186+
});
187+
});

lib/core/prebid/pubProvidedId.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { debugLog } from "../log";
2+
3+
// Delivers cached EIDs to prebid through the pubProvidedId user-id submodule,
4+
// for integrations that don't use the RTD module.
5+
6+
type Eid = {
7+
source: string;
8+
uids?: unknown[];
9+
};
10+
11+
type PubProvidedIdOptions = {
12+
// Prebid global names to merge into. Defaults to ["pbjs"].
13+
instances?: readonly string[];
14+
// localStorage key of the EID cache. Defaults to OPTABLE_RESOLVED.
15+
cacheKey?: string;
16+
// EIDs to merge, bypassing the cache read.
17+
eids?: Eid[];
18+
// Refresh every user-id submodule after merging, not just pubProvidedId.
19+
// Workaround for prebid/Prebid.js#15562 — see pubProvidedId.md.
20+
refreshAll?: boolean;
21+
};
22+
23+
const DEFAULT_CACHE_KEY = "OPTABLE_RESOLVED";
24+
25+
function cachedEids(cacheKey: string): Eid[] {
26+
try {
27+
const resolved = JSON.parse(localStorage.getItem(cacheKey) || "null");
28+
return resolved?.ortb2?.user?.eids || [];
29+
} catch {
30+
return [];
31+
}
32+
}
33+
34+
// Cache sidecars like _ref (UID2 refresh material) must not reach bid requests.
35+
function stripSidecars(eid: Eid): Eid {
36+
const clean: Record<string, unknown> = {};
37+
for (const key of Object.keys(eid)) {
38+
if (!key.startsWith("_")) {
39+
clean[key] = (eid as Record<string, unknown>)[key];
40+
}
41+
}
42+
return clean as Eid;
43+
}
44+
45+
export function mergeIntoPubProvidedId(options: PubProvidedIdOptions = {}): void {
46+
const instances = options.instances ?? ["pbjs"];
47+
const ourEids = (options.eids ?? cachedEids(options.cacheKey ?? DEFAULT_CACHE_KEY)).map(stripSidecars);
48+
49+
instances.forEach((instanceName) => {
50+
if (!ourEids.length) {
51+
debugLog("log", `(${instanceName}) PPID: no EIDs to merge`);
52+
return;
53+
}
54+
55+
// Queue on the named global so this also works before prebid has loaded.
56+
const w = window as unknown as Record<string, { que?: Array<() => void> } & Record<string, any>>;
57+
w[instanceName] = w[instanceName] || {};
58+
const pbjs = w[instanceName];
59+
pbjs.que = pbjs.que || [];
60+
pbjs.que.push(() => {
61+
try {
62+
const ourSources = new Set(ourEids.map((e) => e.source));
63+
64+
// Collapse every pubProvidedId entry found, not just the first, so a
65+
// config already polluted with duplicates heals back down to one.
66+
const currentUserSync = pbjs.getConfig?.("userSync") || {};
67+
const currentUserIds: Array<{ name?: string; params?: { eids?: Eid[] } }> = currentUserSync.userIds || [];
68+
const existingEids = currentUserIds
69+
.filter((u) => u.name === "pubProvidedId")
70+
.flatMap((u) => u.params?.eids || []);
71+
72+
// Keep EIDs from other providers, replace ours by source.
73+
const preserved = existingEids.filter((e) => !ourSources.has(e.source));
74+
const mergedEids = [...preserved, ...ourEids];
75+
76+
const updatedUserIds = currentUserIds.filter((u) => u.name !== "pubProvidedId");
77+
updatedUserIds.push({
78+
name: "pubProvidedId",
79+
params: { eids: mergedEids },
80+
});
81+
82+
pbjs.setConfig?.({ userSync: { ...currentUserSync, userIds: updatedUserIds } });
83+
if (options.refreshAll) {
84+
pbjs.refreshUserIds?.();
85+
} else {
86+
pbjs.refreshUserIds?.({ submoduleNames: ["pubProvidedId"] });
87+
}
88+
debugLog(
89+
"log",
90+
`(${instanceName}) PPID: merged ${ourEids.length} EIDs (${preserved.length} preserved from others)`
91+
);
92+
} catch (err) {
93+
debugLog("error", `(${instanceName}) PPID: merge error`, err);
94+
}
95+
});
96+
});
97+
}
98+
99+
export type { Eid, PubProvidedIdOptions };

0 commit comments

Comments
 (0)