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
13 changes: 13 additions & 0 deletions lib/addons/prebid/analytics.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,19 @@ responses can carry a `requestId` that matches no client bid; those go to the
first bid of the same bidder and ad unit still without a response. Bids that fit
nowhere are counted in `unmatchedBids`, omitted when 0.

Troubleshooting fields, same meaning as in the V1 add-on. The page-dependent ones
are captured when the auction ends, since the payload is only built after
`bidWinTimeout`:

| Field | Meaning |
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `slotSeq` | Earlier auctions on this page whose first ad unit is this auction's first ad unit (0 for the first). Other ad units in the auction aren't counted. |
| `t0` | Auction start, in ms from page start. |
| `consent` | 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 the page. Bits 1, 4 and 8 come from the first bidder request, so an auction without bidder requests reports them as 0. |
| `cacheAge` | Seconds since this SDK's targeting cache was written; `-1` when there is no cache; absent when the cache predates the write timestamp or without device access consent. |
| `idsMs` | Set when the targeting cache was written during this page view: ms from page start to the write. |
| `om` | Per bidder request, a bitmask over `optableSources` (bit i = `optableSources[i]`). Omitted when the bidder received every source. |

`referrer` is the referring hostname only, never the full referring URL.
`trafficSource` classifies it as one of `direct`, `internal`, `paid`,
`campaign`, `email`, `organic_search`, `social` or `referral`. Both are
Expand Down
137 changes: 136 additions & 1 deletion lib/addons/prebid/analytics.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import OptablePrebidAnalytics, { initPrebidAnalytics } from "./analytics";
import type OptableSDK from "../../sdk";
import { generateTargetingKeys } from "../../core/storage-keys";

// Mock the SDK_WRAPPER_VERSION global
declare global {
Expand Down Expand Up @@ -893,6 +894,139 @@ describe("OptablePrebidAnalytics", () => {
});
});

describe("troubleshooting fields", () => {
const bidderRequest = (bidderCode: string, sources: string[], extra: Record<string, unknown> = {}) => ({
bidderCode,
bidderRequestId: `br-${bidderCode}`,
ortb2: {
site: { domain: "example.com" },
user: { eids: sources.map((source) => ({ source, inserter: "optable.co", matcher: "m" })) },
},
bids: [{ bidId: `bid-${bidderCode}`, adUnitCode: "slot-1" }],
...extra,
});
let seq = 0;
const captureOf = (event: Record<string, unknown>) => {
const auctionId = `auction-capture-${(seq += 1)}`;
analytics.trackAuctionEnd({ auctionId, ...event });
return analytics["auctions"].get(auctionId).capture;
};

beforeEach(() => {
jest.useFakeTimers({ doNotFake: ["performance"] });
localStorage.clear();
analytics = new OptablePrebidAnalytics(mockOptableInstance, { analytics: true });
});

afterEach(() => {
jest.useRealTimers();
delete (navigator as any).globalPrivacyControl;
});

it("counts earlier auctions of the same ad unit on the page in slotSeq", () => {
const event = { bidderRequests: [bidderRequest("a", [])] };
expect(captureOf(event).slotSeq).toBe(0);
expect(captureOf(event).slotSeq).toBe(1);
expect(captureOf({ adUnitCodes: ["slot-2"] }).slotSeq).toBe(0);
});

it("reports t0 as the auction start relative to page start", () => {
const capture = captureOf({ timestamp: performance.timeOrigin + 1234, bidderRequests: [] });
expect(capture.t0).toBe(1234);
});

it("encodes GDPR, GPC, US privacy opt-out, GPP and TCF-blocked storage in consent", () => {
Object.defineProperty(navigator, "globalPrivacyControl", { value: true, configurable: true });
analytics.setHooks({
onEvent: jest.fn(),
getEvents: () => [{ eventType: "tcf2Enforcement", args: { storageBlocked: ["someModule"] } }],
});

const capture = captureOf({
bidderRequests: [
bidderRequest("a", [], {
gdprConsent: { gdprApplies: true },
uspConsent: "1YYN",
gppConsent: { gppString: "DBABM~" },
}),
],
});

expect(capture.consent).toBe(1 | 2 | 4 | 8 | 16);
});

it("reports consent 0 when nothing applies", () => {
expect(captureOf({ bidderRequests: [bidderRequest("a", [], { uspConsent: "1YNN" })] }).consent).toBe(0);
});

it("reads the SDK's own targeting cache for cacheAge", () => {
const dcn = { host: "dcn.example.com", node: "node1", consent: { deviceAccess: true } } as any;
analytics = new OptablePrebidAnalytics({ ...mockOptableInstance, dcn } as any, { analytics: true });
const [key] = generateTargetingKeys(dcn).read;

expect(captureOf({ bidderRequests: [] }).cacheAge).toBe(-1);

localStorage.setItem(key, "{}");
expect(captureOf({ bidderRequests: [] }).cacheAge).toBeUndefined();

localStorage.setItem(`${key}:ts`, String(Date.now() - 5000));
expect(captureOf({ bidderRequests: [] }).cacheAge).toBe(5);

dcn.consent.deviceAccess = false;
expect(captureOf({ bidderRequests: [] }).cacheAge).toBeUndefined();
});

it("sends the values captured at auction end, not at send time", async () => {
analytics.trackAuctionEnd({ auctionId: "auction-first", bidderRequests: [bidderRequest("a", [])] });
analytics.trackAuctionEnd({ auctionId: "auction-second", bidderRequests: [bidderRequest("a", [])] });
await jest.runAllTimersAsync();

expect(mockOptableInstance.witness).toHaveBeenCalledWith(
"optable.prebid.auction",
expect.objectContaining({ auctionId: "auction-first", slotSeq: 0, consent: 0 })
);
expect(mockOptableInstance.witness).toHaveBeenCalledWith(
"optable.prebid.auction",
expect.objectContaining({ auctionId: "auction-second", slotSeq: 1 })
);
});

it("omits om when every bidder received every Optable source", async () => {
const result = await analytics.toWitness(
{
auctionId: "auction-om-full",
bidderRequests: [
bidderRequest("a", ["uidapi.com", "criteo.com"]),
bidderRequest("b", ["uidapi.com", "criteo.com"]),
],
},
[]
);

(result.bidderRequests as any[]).forEach((br) => expect(br.om).toBeUndefined());
});

it("sends om as a bitmask over optableSources when a bidder missed some", async () => {
const result = await analytics.toWitness(
{
auctionId: "auction-om-partial",
bidderRequests: [
bidderRequest("a", ["uidapi.com", "criteo.com"]),
bidderRequest("b", ["criteo.com"]),
bidderRequest("c", []),
],
},
[]
);

const byCode = Object.fromEntries((result.bidderRequests as any[]).map((br) => [br.bidderCode, br]));
expect(result.optableSources).toEqual(["uidapi.com", "criteo.com"]);
expect(byCode.a.om).toBeUndefined();
expect(byCode.b.om).toBe(2);
expect(byCode.c.om).toBe(0);
});
});

describe("log", () => {
it("should not log when debug is false", () => {
const consoleSpy = jest.spyOn(console, "log");
Expand Down Expand Up @@ -1127,7 +1261,8 @@ describe("OptablePrebidAnalytics", () => {
const result = analytics.hookIntoPrebid(mockPbjs as any);

expect(result).toBe(true);
expect(mockPbjs.onEvent).toHaveBeenCalledTimes(3);
expect(mockPbjs.onEvent).toHaveBeenCalledTimes(4);
expect(mockPbjs.onEvent).toHaveBeenCalledWith("tcf2Enforcement", expect.any(Function));
expect(mockPbjs.onEvent).toHaveBeenCalledWith("bidTimeout", expect.any(Function));
expect(mockPbjs.onEvent).toHaveBeenCalledWith("auctionEnd", expect.any(Function));
expect(mockPbjs.onEvent).toHaveBeenCalledWith("bidWon", expect.any(Function));
Expand Down
92 changes: 91 additions & 1 deletion lib/addons/prebid/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { WitnessProperties } from "../../edge/witness";
import type OptableSDK from "../../sdk";
import { buildRequest } from "../../core/network";
import { getTrafficSource } from "../../core/traffic-source";
import { generateTargetingKeys } from "../../core/storage-keys";

import * as Bowser from "bowser";

Expand Down Expand Up @@ -54,6 +55,7 @@ interface AuctionItem {
bidWonEvents: any[];
timeoutBids: any[];
sampled: boolean;
capture: Record<string, number>;
}

class OptablePrebidAnalytics {
Expand All @@ -67,6 +69,8 @@ class OptablePrebidAnalytics {
private pendingTimeoutBids = new Map<string, any[]>();
private prebidInstance: any;
private pageConfigSent = false;
private slotAuctionCounts: Record<string, number> = {};
private storageBlocked = false;

/**
* Create a new OptablePrebidAnalytics instance.
Expand Down Expand Up @@ -123,6 +127,7 @@ class OptablePrebidAnalytics {
? new Date(Math.min(...auction.bidWonEvents.map((e: any) => e._receivedAt.getTime()))).toISOString()
: null;
payload["optableLoaded"] = !auction.missed;
Object.assign(payload, auction.capture);

navigator.sendBeacon(
witnessUrl,
Expand Down Expand Up @@ -209,7 +214,9 @@ class OptablePrebidAnalytics {
setHooks(pbjs: any) {
this.log("Processing past events");
pbjs.getEvents().forEach((event: any) => {
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 === "bidTimeout") {
(event.args as any[]).forEach((bid: any) => {
Expand All @@ -232,6 +239,7 @@ class OptablePrebidAnalytics {
});

this.log("Hooking into Prebid.js events");
pbjs.onEvent("tcf2Enforcement", (event: any) => this.noteTcfEnforcement(event));
pbjs.onEvent("bidTimeout", (timedOutBids: any[]) => {
this.log("bidTimeout event received", timedOutBids);
timedOutBids.forEach((bid: any) => {
Expand Down Expand Up @@ -317,6 +325,7 @@ class OptablePrebidAnalytics {
? new Date(Math.min(...storedAuction.bidWonEvents.map((e: any) => e._receivedAt.getTime()))).toISOString()
: null;
payload["optableLoaded"] = !effectiveMissed;
Object.assign(payload, storedAuction.capture);
this.sendToWitnessAPI("optable.prebid.auction", payload);
this.auctions.delete(auctionId);
}, this.config.bidWinTimeout);
Expand All @@ -330,6 +339,7 @@ class OptablePrebidAnalytics {
bidWonEvents: [],
timeoutBids,
sampled,
capture: this.captureAtAuctionEnd(event),
});
this.pendingTimeoutBids.delete(auctionId);

Expand Down Expand Up @@ -397,6 +407,74 @@ class OptablePrebidAnalytics {
}
}

/**
* Troubleshooting fields that depend on page state. Read when the auction ends, because the
* payload is only built after bidWinTimeout.
* @param event - The raw Prebid auctionEnd event object.
* @returns slotSeq, t0, consent and the targeting cache state.
*/
private captureAtAuctionEnd(event: any): Record<string, number> {
const { timestamp, bidderRequests = [] } = event;
const capture: Record<string, number> = {};

const slotKey =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: slotSeq keys on the first ad unit of the auction only. A multi-unit auction, like the usual page-load auction, increments only that unit's count. This matches V1, but the .md wording "Earlier auctions of the same ad unit" overstates it. Maybe "earlier auctions whose first ad unit is this one", or count every ad unit in the auction.

bidderRequests.flatMap((br: any) => br.bids ?? []).find((b: any) => b.adUnitCode)?.adUnitCode ||
event.adUnitCodes?.[0];
if (slotKey) {
capture.slotSeq = this.slotAuctionCounts[slotKey] || 0;
this.slotAuctionCounts[slotKey] = capture.slotSeq + 1;
}
const origin = typeof performance !== "undefined" ? performance.timeOrigin : undefined;
if (typeof timestamp === "number" && origin) capture.t0 = Math.round(timestamp - origin);
capture.consent = this.consentFlags(bidderRequests[0]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: consent comes from bidderRequests[0] only. An auction with no bidder requests reports 0, which looks the same as "nothing applies". That's fine for troubleshooting, but worth a line in analytics.md.


return Object.assign(capture, this.cacheState());
}

private noteTcfEnforcement(args: any) {
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.
*/
private consentFlags(bidderRequest: any): number {
let flags = 0;
if (bidderRequest?.gdprConsent?.gdprApplies) flags |= 1;
if (typeof navigator !== "undefined" && (navigator as any).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;
}

/**
* State of this SDK's targeting cache. 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. Absent without
* device access consent, like the SDK's own storage reads.
*/
private cacheState(): Record<string, number> {
const { dcn } = this.optableInstance;
if (!dcn?.consent?.deviceAccess) return {};
try {
const [key] = generateTargetingKeys(dcn).read;
if (!localStorage.getItem(key)) return { cacheAge: -1 };
const ts = Number(localStorage.getItem(`${key}:ts`));
if (!ts) return {};
const state: Record<string, number> = { 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 {};
}
}

clearData() {
this.auctions.clear();
this.missedAuctionIds.clear();
Expand Down Expand Up @@ -574,6 +652,18 @@ class OptablePrebidAnalytics {
};
if (unmatchedBids) witnessData.unmatchedBids = unmatchedBids;

// Per-bidder delivery as a bitmask over optableSources (bit i = optableSources[i]).
// Omitted when the bidder received every source, so the usual case adds nothing.
// Capped at 30 sources so the mask stays a positive 32-bit integer for bitwise reads downstream.
const auctionSources = witnessData.optableSources as string[];
const fullMask = auctionSources.length && auctionSources.length <= 30 ? 2 ** auctionSources.length - 1 : 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: om is silently skipped when there are more than 30 sources. That's unreachable in practice, but a short comment saying why the cap exists (e.g. keeping the mask in 32-bit int range for downstream bitwise ops) would help the next reader.

if (fullMask) {
(witnessData.bidderRequests as any[]).forEach((br) => {
const mask = auctionSources.reduce((m, s, i) => (br.optableSources.includes(s) ? m + 2 ** i : m), 0);
if (mask !== fullMask) br.om = mask;
});
}

// 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) {
Expand Down