From a4bfda832994f5c8be8e9fd93b3743be8014ef25 Mon Sep 17 00:00:00 2001 From: mosherBT Date: Tue, 27 Oct 2026 10:42:41 -0400 Subject: [PATCH 1/3] analytics: troubleshooting fields captured at auction end in V2 prebid analytics --- lib/addons/prebid/analytics.md | 13 +++ lib/addons/prebid/analytics.test.ts | 134 +++++++++++++++++++++++++++- lib/addons/prebid/analytics.ts | 90 ++++++++++++++++++- 3 files changed, 235 insertions(+), 2 deletions(-) diff --git a/lib/addons/prebid/analytics.md b/lib/addons/prebid/analytics.md index 6b5fdd78..1cc75342 100644 --- a/lib/addons/prebid/analytics.md +++ b/lib/addons/prebid/analytics.md @@ -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 of the same ad unit on this page (0 for the first). | +| `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. | +| `cacheAge` | Seconds since this SDK's targeting cache was written; `-1` when there is no cache; absent when the cache predates the write timestamp. | +| `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 diff --git a/lib/addons/prebid/analytics.test.ts b/lib/addons/prebid/analytics.test.ts index d58cdfed..dc3fb68c 100644 --- a/lib/addons/prebid/analytics.test.ts +++ b/lib/addons/prebid/analytics.test.ts @@ -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 { @@ -893,6 +894,136 @@ describe("OptablePrebidAnalytics", () => { }); }); + describe("troubleshooting fields", () => { + const bidderRequest = (bidderCode: string, sources: string[], extra: Record = {}) => ({ + 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) => { + const auctionId = `auction-capture-${(seq += 1)}`; + analytics.trackAuctionEnd({ auctionId, ...event }); + return analytics["auctions"].get(auctionId).capture; + }; + + beforeEach(() => { + jest.useFakeTimers(); + 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" } 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); + }); + + 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"); @@ -1127,7 +1258,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)); diff --git a/lib/addons/prebid/analytics.ts b/lib/addons/prebid/analytics.ts index 882a2d5c..c8588026 100644 --- a/lib/addons/prebid/analytics.ts +++ b/lib/addons/prebid/analytics.ts @@ -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"; @@ -54,6 +55,7 @@ interface AuctionItem { bidWonEvents: any[]; timeoutBids: any[]; sampled: boolean; + capture: Record; } class OptablePrebidAnalytics { @@ -67,6 +69,8 @@ class OptablePrebidAnalytics { private pendingTimeoutBids = new Map(); private prebidInstance: any; private pageConfigSent = false; + private slotAuctionCounts: Record = {}; + private storageBlocked = false; /** * Create a new OptablePrebidAnalytics instance. @@ -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, @@ -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) => { @@ -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) => { @@ -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); @@ -330,6 +339,7 @@ class OptablePrebidAnalytics { bidWonEvents: [], timeoutBids, sampled, + capture: this.captureAtAuctionEnd(event), }); this.pendingTimeoutBids.delete(auctionId); @@ -397,6 +407,73 @@ 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 { + const { timestamp, bidderRequests = [] } = event; + const capture: Record = {}; + + const slotKey = + 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]); + + 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. + */ + private cacheState(): Record { + const { dcn } = this.optableInstance; + if (!dcn) 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 = { 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(); @@ -574,6 +651,17 @@ 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. + const auctionSources = witnessData.optableSources as string[]; + const fullMask = auctionSources.length && auctionSources.length <= 30 ? 2 ** auctionSources.length - 1 : 0; + 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) { From 24249fb0684b40472647cc2ab4031f1408459be5 Mon Sep 17 00:00:00 2001 From: mosherBT Date: Tue, 27 Oct 2026 12:58:44 -0400 Subject: [PATCH 2/3] comments --- lib/addons/prebid/analytics.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/addons/prebid/analytics.test.ts b/lib/addons/prebid/analytics.test.ts index dc3fb68c..d24ea155 100644 --- a/lib/addons/prebid/analytics.test.ts +++ b/lib/addons/prebid/analytics.test.ts @@ -913,7 +913,7 @@ describe("OptablePrebidAnalytics", () => { }; beforeEach(() => { - jest.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ["performance"] }); localStorage.clear(); analytics = new OptablePrebidAnalytics(mockOptableInstance, { analytics: true }); }); From 50a7ae4284f62206f09f54b5ed6a77658d7c7cdd Mon Sep 17 00:00:00 2001 From: mosherBT Date: Wed, 28 Oct 2026 11:36:25 -0400 Subject: [PATCH 3/3] comments --- lib/addons/prebid/analytics.md | 16 ++++++++-------- lib/addons/prebid/analytics.test.ts | 5 ++++- lib/addons/prebid/analytics.ts | 6 ++++-- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/lib/addons/prebid/analytics.md b/lib/addons/prebid/analytics.md index 1cc75342..b470db07 100644 --- a/lib/addons/prebid/analytics.md +++ b/lib/addons/prebid/analytics.md @@ -145,14 +145,14 @@ Troubleshooting fields, same meaning as in the V1 add-on. The page-dependent one are captured when the auction ends, since the payload is only built after `bidWinTimeout`: -| Field | Meaning | -| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| `slotSeq` | Earlier auctions of the same ad unit on this page (0 for the first). | -| `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. | -| `cacheAge` | Seconds since this SDK's targeting cache was written; `-1` when there is no cache; absent when the cache predates the write timestamp. | -| `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. | +| 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`, diff --git a/lib/addons/prebid/analytics.test.ts b/lib/addons/prebid/analytics.test.ts index d24ea155..d519b83b 100644 --- a/lib/addons/prebid/analytics.test.ts +++ b/lib/addons/prebid/analytics.test.ts @@ -960,7 +960,7 @@ describe("OptablePrebidAnalytics", () => { }); it("reads the SDK's own targeting cache for cacheAge", () => { - const dcn = { host: "dcn.example.com", node: "node1" } as any; + 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; @@ -971,6 +971,9 @@ describe("OptablePrebidAnalytics", () => { 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 () => { diff --git a/lib/addons/prebid/analytics.ts b/lib/addons/prebid/analytics.ts index c8588026..12c1fd8d 100644 --- a/lib/addons/prebid/analytics.ts +++ b/lib/addons/prebid/analytics.ts @@ -455,11 +455,12 @@ class OptablePrebidAnalytics { /** * 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. + * 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 { const { dcn } = this.optableInstance; - if (!dcn) return {}; + if (!dcn?.consent?.deviceAccess) return {}; try { const [key] = generateTargetingKeys(dcn).read; if (!localStorage.getItem(key)) return { cacheAge: -1 }; @@ -653,6 +654,7 @@ class OptablePrebidAnalytics { // 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; if (fullMask) {