Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions lib/addons/prebid/analytics.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,11 @@ Each sampled auction sends an `optable.prebid.auction` event with, among others:
`optableMatchers`, `optableSources`, `optableTargetingDone`, `bidWon`, `missed`,
`url`, `tenant`, `prebidjsVersion`, `sessionDepth`, `pageAuctionsCount`,
`originSlug`, and the parsed `userAgent`/`device`.

The first payload of each page also carries `page`:
`{ optableRTDInstalled, optableRTDConfigured, rtdDelay, udDelay }`.
`optableRTDInstalled` is `1` when `optableRtdProvider` is in `pbjs.installedModules`
(compiled into the Prebid build). `optableRTDConfigured` is `1` when
`realTimeData.dataProviders` contains a provider named `optable`. `rtdDelay` and
`udDelay` are `realTimeData.auctionDelay` and `userSync.auctionDelay`. Same shape
as the V1 add-on.
57 changes: 57 additions & 0 deletions lib/addons/prebid/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,63 @@ describe("OptablePrebidAnalytics", () => {
analytics = new OptablePrebidAnalytics(mockOptableInstance);
});

describe("page config", () => {
const emptyAuction = { auctionId: "page-auction", bidderRequests: [] };

it("is sent on the first payload of the page only", async () => {
(analytics as any).prebidInstance = {
installedModules: ["rtdModule", "optableRtdProvider"],
getConfig: (k: string) =>
(
({
realTimeData: { auctionDelay: 150, dataProviders: [{ name: "other" }, { name: "optable" }] },
userSync: { auctionDelay: 250 },
}) as Record<string, unknown>
)[k],
};

const first = await analytics.toWitness(emptyAuction, []);
expect(first.page).toEqual({ optableRTDInstalled: 1, optableRTDConfigured: 1, rtdDelay: 150, udDelay: 250 });

const second = await analytics.toWitness(emptyAuction, []);
expect(second.page).toBeUndefined();
});

it("reports the RTD module installed but not configured", async () => {
(analytics as any).prebidInstance = {
installedModules: ["optableRtdProvider"],
getConfig: () => undefined,
};

const result = await analytics.toWitness(emptyAuction, []);
expect(result.page).toMatchObject({ optableRTDInstalled: 1, optableRTDConfigured: 0 });
});

it("reports the RTD module not installed when installedModules is missing", async () => {
(analytics as any).prebidInstance = {
getConfig: (k: string) => (k === "realTimeData" ? { dataProviders: [{ name: "optable" }] } : undefined),
};

const result = await analytics.toWitness(emptyAuction, []);
expect(result.page).toMatchObject({ optableRTDInstalled: 0, optableRTDConfigured: 1 });
});

it("is omitted, and retried on the next payload, when Prebid exposes no getConfig", async () => {
(analytics as any).prebidInstance = {};
const first = await analytics.toWitness(emptyAuction, []);
expect(first.page).toBeUndefined();

(analytics as any).prebidInstance = { getConfig: () => undefined };
const second = await analytics.toWitness(emptyAuction, []);
expect(second.page).toEqual({
optableRTDInstalled: 0,
optableRTDConfigured: 0,
rtdDelay: undefined,
udDelay: undefined,
});
});
});

it("should transform auction and bid won events to witness format", async () => {
const auctionEndEvent = {
auctionId: "auction-123",
Expand Down
31 changes: 31 additions & 0 deletions lib/addons/prebid/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ class OptablePrebidAnalytics {
private missedAuctionIds = new Set<string>();
private pendingTimeoutBids = new Map<string, any[]>();
private prebidInstance: any;
private pageConfigSent = false;

/**
* Create a new OptablePrebidAnalytics instance.
Expand Down Expand Up @@ -363,6 +364,26 @@ class OptablePrebidAnalytics {
* Clear all stored analytics data (useful for tests).
* @returns void
*/
/**
* Page configuration, sent on the first auction payload of the page only.
* @returns RTD presence and auction delays, or undefined when Prebid exposes no getConfig.
*/
pageConfig(): Record<string, unknown> | undefined {
if (typeof this.prebidInstance?.getConfig !== "function") return undefined;
try {
const rtd = this.prebidInstance.getConfig("realTimeData") || {};
const userSync = this.prebidInstance.getConfig("userSync") || {};
return {
optableRTDInstalled: (this.prebidInstance.installedModules || []).includes("optableRtdProvider") ? 1 : 0,
optableRTDConfigured: (rtd.dataProviders || []).some((p: { name?: string }) => p?.name === "optable") ? 1 : 0,
rtdDelay: rtd.auctionDelay,
udDelay: userSync.auctionDelay,
};
} catch (e) {
return undefined;
}
}

clearData() {
this.auctions.clear();
this.missedAuctionIds.clear();
Expand Down Expand Up @@ -496,6 +517,16 @@ class OptablePrebidAnalytics {
originSlug: this.optableInstance?.dcn?.site || (window as any).optable?.site || "unknown",
};

// Page configuration, sent once per page. A flag instead of pageAuctionsCount === 1 because
// payloads are built after bidWinTimeout, when later auctions may already have bumped the count.
if (!this.pageConfigSent) {
const page = this.pageConfig();
if (page) {
witnessData.page = page;
this.pageConfigSent = true;
}
}

// Log summary with bid counts
this.log(
`Auction ${auctionId} processed: ${bidderRequests.length} requests, ${totalBids} total bids, ${bidsReceived.length} received, ${noBids.length} no-bids, ${timeoutBids.length} timeouts`
Expand Down
23 changes: 22 additions & 1 deletion lib/addons/prototypes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,32 @@ This addon integrates Optable analytics with Prebid.js, allowing you to send auc
- **Constructor**:
`new OptablePrebidAnalytics(sdkInstance, options)`
- `sdkInstance`: An instance of the Optable SDK.
- `options`: Object with options such as `debug`, `analytics`, and `tenant`.
- `options`: Object with options such as `debug`, `analytics`, `tenant`, and `cacheKey` (localStorage key of
the targeting cache, default `OPTABLE_RESOLVED`).

- **hookIntoPrebid(pbjs)**:
Hooks the analytics into the provided Prebid.js instance.

## Troubleshooting fields in `auction_processed`

Added to make enrichment and uplift problems answerable from the analytics table. All are small, and the
per-bidder field is omitted in the normal case.

| Field | Level | Meaning |
| ----------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `bidderRequests[].om` | bidder | Which of the auction's `optableSources` this bidder received, as a bitmask: bit `i` is `optableSources[i]`. **Absent means the bidder received every source.** `0` means none. |
| `bidderRequests[].bids[].src` | bid | `"s2s"` for Prebid Server responses; absent for client-side bids. |
| `unmatchedBids` | auction | Received bids that matched no bidder request, even after falling back to bidder code and ad unit. Absent when 0. |
| `slotSeq` | auction | Earlier auctions of this ad unit on this page view. `0` is the slot's first auction; higher values are refreshes or re-auctions. |
| `t0` | auction | Milliseconds from page start (`performance.timeOrigin`) to auction start. |
| `consent` | auction | Bitmask: `1` GDPR applies, `2` Global Privacy Control on, `4` US privacy opt-out, `8` GPP string present, `16` TCF blocked storage for some module on this page. |
| `cacheAge` | auction | Seconds since the targeting cache was written. `-1` when there is no cache. Absent when the cache was written before this field existed. |
| `idsMs` | auction | Set when the targeting cache was written during this page view: milliseconds from page start to our IDs landing. Compare with `t0` to see whether an auction started before our IDs were available. |
| `page` | first auction of a page only | `{ optableRTDInstalled, optableRTDConfigured, rtdDelay, udDelay }`: whether `optableRtdProvider` is in `pbjs.installedModules` (1/0), whether the `optable` RTD provider is registered in `realTimeData.dataProviders` (1/0), `realTimeData.auctionDelay`, and `userSync.auctionDelay`. |

Cache age relies on the SDK writing a `<cacheKey>:ts` sibling with the write time whenever targeting is
stored (`lib/core/storage.ts`).

## Example

```js
Expand Down
145 changes: 133 additions & 12 deletions lib/addons/prototypes/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ class OptablePrebidAnalytics {
this.missedAuctionIds = new Set();
this.maxAuctionDataSize = 20;

// Page-level state for the capture fields
this.slotAuctionCounts = {};
this.storageBlocked = false;

sessionStorage.optableSessionDepthIndex = (Number(sessionStorage?.optableSessionDepthIndex) || 0) + 1;

this.log("OptablePrebidAnalytics initialized");
Expand Down Expand Up @@ -60,7 +64,9 @@ class OptablePrebidAnalytics {
setHooks(pbjs) {
this.log("Processing past events");
pbjs.getEvents().forEach((event) => {
if (event.eventType === "auctionInit") {
if (event.eventType === "tcf2Enforcement") {
this.noteTcfEnforcement(event.args);
} else if (event.eventType === "auctionInit") {
this.missedAuctionIds.add(event.args.auctionId);
} else if (event.eventType === "auctionEnd") {
this.missedAuctionIds.delete(event.args.auctionId);
Expand All @@ -70,6 +76,7 @@ class OptablePrebidAnalytics {
});

this.log("Hooking into Prebid.js events");
pbjs.onEvent("tcf2Enforcement", (event) => this.noteTcfEnforcement(event));
pbjs.onEvent("auctionEnd", (event) => {
const missed = this.missedAuctionIds.has(event.auctionId);
if (missed) {
Expand All @@ -80,6 +87,67 @@ class OptablePrebidAnalytics {
});
}

noteTcfEnforcement(args) {
if (args?.storageBlocked?.length) {
this.storageBlocked = true;
}
}

/**
* Consent state at this auction, as a bitmask:
* 1 GDPR applies, 2 Global Privacy Control, 4 US privacy opt-out,
* 8 GPP string present, 16 TCF blocked storage for some module on this page.
*/
consentFlags(bidderRequest) {
let flags = 0;
if (bidderRequest?.gdprConsent?.gdprApplies) flags |= 1;
if (typeof navigator !== "undefined" && navigator.globalPrivacyControl === true) flags |= 2;
if (typeof bidderRequest?.uspConsent === "string" && bidderRequest.uspConsent.charAt(2) === "Y") flags |= 4;
if (bidderRequest?.gppConsent?.gppString) flags |= 8;
if (this.storageBlocked) flags |= 16;
return flags;
}

/**
* Targeting cache state. cacheAge is seconds since the cache was written, -1 when there is
* no cache, and absent when the cache predates the write timestamp. idsMs is set when the
* cache was written during this page view: ms from page start to our IDs landing.
*/
cacheState() {
const key = this.config.cacheKey || "OPTABLE_RESOLVED";
try {
if (!localStorage.getItem(key)) return { cacheAge: -1 };
const ts = Number(localStorage.getItem(`${key}:ts`));
if (!ts) return {};
const state = { cacheAge: Math.max(0, Math.round((Date.now() - ts) / 1000)) };
const origin = typeof performance !== "undefined" ? performance.timeOrigin : undefined;
if (origin && ts >= origin) state.idsMs = Math.round(ts - origin);
return state;
} catch (e) {
return {};
}
}

/**
* Page configuration, sent on the first auction of the page only.
*/
pageConfig() {
const getConfig = this.prebidInstance?.getConfig;
if (typeof getConfig !== "function") return undefined;
try {
const rtd = this.prebidInstance.getConfig("realTimeData") || {};
const userSync = this.prebidInstance.getConfig("userSync") || {};
return {
optableRTDInstalled: (this.prebidInstance.installedModules || []).includes("optableRtdProvider") ? 1 : 0,
optableRTDConfigured: (rtd.dataProviders || []).some((p) => p?.name === "optable") ? 1 : 0,
rtdDelay: rtd.auctionDelay,
udDelay: userSync.auctionDelay,
};
} catch (e) {
return undefined;
}
}

/**
* Hook into Prebid.js events
*/
Expand All @@ -101,7 +169,16 @@ class OptablePrebidAnalytics {
}

async trackAuctionEnd(event, missed) {
const { auctionId, timeout, bidderRequests = [], bidsReceived = [], noBids = [], timeoutBids = [] } = event;
const {
auctionId,
timeout,
timestamp,
bidderRequests = [],
bidsReceived = [],
noBids = [],
timeoutBids = [],
} = event;
let unmatchedBids = 0;

window.optable.pageAuctionIndex = (Number(window.optable.pageAuctionIndex) || 0) + 1;

Expand Down Expand Up @@ -167,11 +244,23 @@ class OptablePrebidAnalytics {

// Merge in bidsReceived → update individual bids as RECEIVED
bidsReceived.forEach((b) => {
const bidId = b.requestId;
const br = bidToRequest[bidId];
let bidId = b.requestId;
let br = bidToRequest[bidId];
if (!br) {
this.log(`No bidderRequest found for bidId=${bidId}`);
return;
// Prebid Server responses can carry a requestId that matches no client bidId.
// Fall back to the same bidder and ad unit, first bid still waiting for a response.
const bidderCode = b.bidderCode || b.bidder;
const candidate = auction.bidderRequests.find(
(r) => r.bidderCode === bidderCode && r.bids.some((x) => x.adUnitCode === b.adUnitCode && x.cpm == null)
);
const match = candidate?.bids.find((x) => x.adUnitCode === b.adUnitCode && x.cpm == null);
if (!match) {
unmatchedBids += 1;
this.log(`No bidderRequest found for bidId=${bidId}`);
return;
}
br = candidate;
bidId = match.bidId;
}

// Find the specific bid to update
Expand All @@ -180,6 +269,7 @@ class OptablePrebidAnalytics {
// Update existing bid
Object.assign(bidObj, {
status: STATUS.RECEIVED,
source: b.source,
cpm: b.cpm,
size: `${b.width}x${b.height}`,
currency: b.currency,
Expand All @@ -194,6 +284,7 @@ class OptablePrebidAnalytics {
adUnitId: b.adUnitId,
transactionId: b.transactionId,
src: b.src,
source: b.source,
cpm: b.cpm,
size: `${b.width}x${b.height}`,
currency: b.currency,
Expand Down Expand Up @@ -247,32 +338,46 @@ class OptablePrebidAnalytics {
let adUnitCode;
let totalBids = 0;

auction.bidderRequests.forEach((br) => {
br.optableMatchers.forEach((m) => oMatchersSet.add(m));
br.optableSources.forEach((s) => oSourcesSet.add(s));
br.liSources.forEach((s) => lSourcesSet.add(s));
});
const auctionSources = Array.from(oSourcesSet);
// Per-bidder delivery as a bitmask over auctionSources (bit i = auctionSources[i]).
// Omitted when the bidder received every source, so the usual case adds nothing.
const fullMask = auctionSources.length && auctionSources.length <= 30 ? 2 ** auctionSources.length - 1 : 0;

const witnessData = {
bidderRequests: auction.bidderRequests.map((br) => {
br.optableMatchers.forEach((m) => oMatchersSet.add(m));
br.optableSources.forEach((s) => oSourcesSet.add(s));
br.liSources.forEach((s) => lSourcesSet.add(s));
return {
const out = {
bidderCode: br.bidderCode,
bids: br.bids.map((b) => {
adUnitCode = adUnitCode || b.adUnitCode;
if (b.cpm != null) totalBids += 1;
return {
const bid = {
floorMin: b.floorMin,
cpm: b.cpm,
size: b.size,
bidId: b.bidId,
splitTestAssignment: b.splitTestAssignment,
};
if (b.source === "s2s") bid.src = "s2s";
return bid;
}),
};
if (fullMask) {
const mask = auctionSources.reduce((m, s, i) => (br.optableSources.includes(s) ? m + 2 ** i : m), 0);
if (mask !== fullMask) out.om = mask;
}
return out;
}),
auctionId,
adUnitCode,
totalRequests: bidderRequests.length,
totalBids,
optableMatchers: Array.from(oMatchersSet),
optableSources: Array.from(oSourcesSet),
optableSources: auctionSources,
liveintentEIDs: Array.from(lSourcesSet),
missed,
url: `${window.location.hostname}${window.location.pathname}`,
Expand All @@ -283,6 +388,22 @@ class OptablePrebidAnalytics {
pageAuctionsCount: window.optable?.pageAuctionIndex || 1,
originSlug: window.optable?.site || this.optableInstance?.dcn?.site || "unknown", // optable.site first since dcn is analytics always
};

// Capture fields for troubleshooting (see lib/addons/prototypes/README.md)
const slotKey = adUnitCode || event.adUnitCodes?.[0];
if (slotKey) {
witnessData.slotSeq = this.slotAuctionCounts[slotKey] || 0;
this.slotAuctionCounts[slotKey] = witnessData.slotSeq + 1;
}
const origin = typeof performance !== "undefined" ? performance.timeOrigin : undefined;
if (typeof timestamp === "number" && origin) witnessData.t0 = Math.round(timestamp - origin);
witnessData.consent = this.consentFlags(bidderRequests[0]);
Object.assign(witnessData, this.cacheState());
if (unmatchedBids) witnessData.unmatchedBids = unmatchedBids;
if (witnessData.pageAuctionsCount === 1) {
const page = this.pageConfig();
if (page) witnessData.page = page;
}
// Log summary with bid counts
this.log(
`Auction ${auctionId} processed: ${bidderRequests.length} requests, ${totalBids} total bids, ${bidsReceived.length} received, ${noBids.length} no-bids, ${timeoutBids.length} timeouts`
Expand Down
Loading