diff --git a/lib/addons/prebid/analytics.md b/lib/addons/prebid/analytics.md index 6a21c839..3ae024ee 100644 --- a/lib/addons/prebid/analytics.md +++ b/lib/addons/prebid/analytics.md @@ -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. diff --git a/lib/addons/prebid/analytics.test.ts b/lib/addons/prebid/analytics.test.ts index cd457c41..001ccd17 100644 --- a/lib/addons/prebid/analytics.test.ts +++ b/lib/addons/prebid/analytics.test.ts @@ -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 + )[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", diff --git a/lib/addons/prebid/analytics.ts b/lib/addons/prebid/analytics.ts index bec0197c..8ee9dcd5 100644 --- a/lib/addons/prebid/analytics.ts +++ b/lib/addons/prebid/analytics.ts @@ -53,6 +53,7 @@ class OptablePrebidAnalytics { private missedAuctionIds = new Set(); private pendingTimeoutBids = new Map(); private prebidInstance: any; + private pageConfigSent = false; /** * Create a new OptablePrebidAnalytics instance. @@ -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 | 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(); @@ -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` diff --git a/lib/addons/prototypes/README.md b/lib/addons/prototypes/README.md index dc98d91b..204c0d99 100644 --- a/lib/addons/prototypes/README.md +++ b/lib/addons/prototypes/README.md @@ -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 `:ts` sibling with the write time whenever targeting is +stored (`lib/core/storage.ts`). + ## Example ```js diff --git a/lib/addons/prototypes/analytics.js b/lib/addons/prototypes/analytics.js index fbdf4f1e..9c352fa4 100644 --- a/lib/addons/prototypes/analytics.js +++ b/lib/addons/prototypes/analytics.js @@ -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"); @@ -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); @@ -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) { @@ -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 */ @@ -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; @@ -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 @@ -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, @@ -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, @@ -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}`, @@ -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` diff --git a/lib/addons/prototypes/analytics.test.js b/lib/addons/prototypes/analytics.test.js new file mode 100644 index 00000000..8abf5694 --- /dev/null +++ b/lib/addons/prototypes/analytics.test.js @@ -0,0 +1,212 @@ +import OptablePrebidAnalytics from "./analytics"; + +global.SDK_WRAPPER_VERSION = "1.0.0-test"; + +function makeAnalytics(config = {}) { + const witness = jest.fn().mockResolvedValue(undefined); + const analytics = new OptablePrebidAnalytics({ witness }, { analytics: true, tenant: "t", ...config }); + return { analytics, witness }; +} + +function bidderRequest(bidderCode, sources, extra = {}) { + return { + bidderCode, + bidderRequestId: `br-${bidderCode}`, + ortb2: { + user: { ext: { eids: sources.map((source) => ({ source, inserter: "optable.co", uids: [{ id: "x" }] })) } }, + }, + bids: [{ bidId: `bid-${bidderCode}`, adUnitCode: "slot-1" }], + ...extra, + }; +} + +async function run(analytics, witness, event) { + await analytics.trackAuctionEnd({ auctionId: `a-${Math.random()}`, ...event }, false); + const calls = witness.mock.calls; + return JSON.parse(calls[calls.length - 1][1].auction); +} + +beforeEach(() => { + window.optable = {}; + localStorage.clear(); + delete navigator.globalPrivacyControl; +}); + +describe("per-bidder EID delivery (om)", () => { + test("omitted when every bidder received every source", async () => { + const { analytics, witness } = makeAnalytics(); + const p = await run(analytics, witness, { + bidderRequests: [ + bidderRequest("a", ["uidapi.com", "criteo.com"]), + bidderRequest("b", ["uidapi.com", "criteo.com"]), + ], + }); + expect(p.optableSources).toEqual(["uidapi.com", "criteo.com"]); + p.bidderRequests.forEach((br) => expect(br.om).toBeUndefined()); + }); + + test("bitmask over the auction-level sources when a bidder missed some", async () => { + const { analytics, witness } = makeAnalytics(); + const p = await run(analytics, witness, { + bidderRequests: [ + bidderRequest("a", ["uidapi.com", "criteo.com"]), + bidderRequest("b", ["criteo.com"]), + bidderRequest("c", []), + ], + }); + const byCode = Object.fromEntries(p.bidderRequests.map((br) => [br.bidderCode, br])); + expect(p.optableSources).toEqual(["uidapi.com", "criteo.com"]); + expect(byCode.a.om).toBeUndefined(); + expect(byCode.b.om).toBe(2); + expect(byCode.c.om).toBe(0); + }); +}); + +describe("slotSeq and t0", () => { + test("counts earlier auctions of the same ad unit on the page", async () => { + const { analytics, witness } = makeAnalytics(); + const ev = () => ({ bidderRequests: [bidderRequest("a", [])] }); + expect((await run(analytics, witness, ev())).slotSeq).toBe(0); + expect((await run(analytics, witness, ev())).slotSeq).toBe(1); + expect((await run(analytics, witness, ev())).slotSeq).toBe(2); + }); + + test("t0 is the auction start relative to page start", async () => { + const { analytics, witness } = makeAnalytics(); + const p = await run(analytics, witness, { + timestamp: performance.timeOrigin + 1234, + bidderRequests: [bidderRequest("a", [])], + }); + expect(p.t0).toBe(1234); + }); +}); + +describe("consent flags", () => { + test("encodes GDPR, GPC, US privacy opt-out, GPP and blocked storage", async () => { + const { analytics, witness } = makeAnalytics(); + Object.defineProperty(navigator, "globalPrivacyControl", { value: true, configurable: true }); + analytics.noteTcfEnforcement({ storageBlocked: ["someModule"] }); + const p = await run(analytics, witness, { + bidderRequests: [ + bidderRequest("a", [], { + gdprConsent: { gdprApplies: true }, + uspConsent: "1YYN", + gppConsent: { gppString: "DBABM~" }, + }), + ], + }); + expect(p.consent).toBe(1 | 2 | 4 | 8 | 16); + }); + + test("zero when nothing applies", async () => { + const { analytics, witness } = makeAnalytics(); + const p = await run(analytics, witness, { bidderRequests: [bidderRequest("a", [], { uspConsent: "1YNN" })] }); + expect(p.consent).toBe(0); + }); +}); + +describe("cache state", () => { + test("-1 when there is no targeting cache", async () => { + const { analytics, witness } = makeAnalytics(); + const p = await run(analytics, witness, { bidderRequests: [bidderRequest("a", [])] }); + expect(p.cacheAge).toBe(-1); + expect(p.idsMs).toBeUndefined(); + }); + + test("age in seconds from the :ts sibling, idsMs when written this page", async () => { + const { analytics, witness } = makeAnalytics(); + localStorage.setItem("OPTABLE_RESOLVED", "{}"); + localStorage.setItem("OPTABLE_RESOLVED:ts", String(Date.now() - 5000)); + const p = await run(analytics, witness, { bidderRequests: [bidderRequest("a", [])] }); + expect(p.cacheAge).toBeGreaterThanOrEqual(4); + expect(p.cacheAge).toBeLessThanOrEqual(6); + }); + + test("absent when the cache predates the timestamp", async () => { + const { analytics, witness } = makeAnalytics(); + localStorage.setItem("OPTABLE_RESOLVED", "{}"); + const p = await run(analytics, witness, { bidderRequests: [bidderRequest("a", [])] }); + expect(p.cacheAge).toBeUndefined(); + }); + + test("uses the configured cache key", async () => { + const { analytics, witness } = makeAnalytics({ cacheKey: "CUSTOM" }); + localStorage.setItem("CUSTOM", "{}"); + localStorage.setItem("CUSTOM:ts", String(Date.now())); + const p = await run(analytics, witness, { bidderRequests: [bidderRequest("a", [])] }); + expect(p.cacheAge).toBe(0); + }); +}); + +describe("page config", () => { + test("sent on the first auction of the page only", async () => { + const { analytics, witness } = makeAnalytics(); + analytics.prebidInstance = { + version: "v10.27.0", + installedModules: ["rtdModule", "optableRtdProvider"], + getConfig: (k) => + ({ + realTimeData: { auctionDelay: 150, dataProviders: [{ name: "optable", waitForIt: true }] }, + userSync: { auctionDelay: 250 }, + })[k], + }; + const first = await run(analytics, witness, { bidderRequests: [bidderRequest("a", [])] }); + expect(first.page).toEqual({ optableRTDInstalled: 1, optableRTDConfigured: 1, rtdDelay: 150, udDelay: 250 }); + const second = await run(analytics, witness, { bidderRequests: [bidderRequest("a", [])] }); + expect(second.page).toBeUndefined(); + }); + + test("reports the RTD module installed but not configured", async () => { + const { analytics, witness } = makeAnalytics(); + analytics.prebidInstance = { + installedModules: ["optableRtdProvider"], + getConfig: () => undefined, + }; + const p = await run(analytics, witness, { bidderRequests: [bidderRequest("a", [])] }); + expect(p.page).toMatchObject({ optableRTDInstalled: 1, optableRTDConfigured: 0 }); + }); + + test("reports the RTD module not installed when installedModules is missing", async () => { + const { analytics, witness } = makeAnalytics(); + analytics.prebidInstance = { + getConfig: (k) => (k === "realTimeData" ? { dataProviders: [{ name: "optable" }] } : undefined), + }; + const p = await run(analytics, witness, { bidderRequests: [bidderRequest("a", [])] }); + expect(p.page).toMatchObject({ optableRTDInstalled: 0, optableRTDConfigured: 1 }); + }); +}); + +describe("Prebid Server responses", () => { + test("matched by bidder and ad unit when the requestId matches no client bid", async () => { + const { analytics, witness } = makeAnalytics(); + const p = await run(analytics, witness, { + bidderRequests: [bidderRequest("a", [])], + bidsReceived: [{ requestId: "s2s-xyz", bidderCode: "a", adUnitCode: "slot-1", cpm: 1.5, source: "s2s" }], + }); + const bid = p.bidderRequests[0].bids[0]; + expect(bid.cpm).toBe(1.5); + expect(bid.src).toBe("s2s"); + expect(p.totalBids).toBe(1); + expect(p.unmatchedBids).toBeUndefined(); + }); + + test("counted as unmatched when no bidder or ad unit fits", async () => { + const { analytics, witness } = makeAnalytics(); + const p = await run(analytics, witness, { + bidderRequests: [bidderRequest("a", [])], + bidsReceived: [{ requestId: "s2s-xyz", bidderCode: "zzz", adUnitCode: "slot-9", cpm: 1.5, source: "s2s" }], + }); + expect(p.unmatchedBids).toBe(1); + expect(p.totalBids).toBe(0); + }); + + test("client bids keep the existing shape (no src field)", async () => { + const { analytics, witness } = makeAnalytics(); + const p = await run(analytics, witness, { + bidderRequests: [bidderRequest("a", [])], + bidsReceived: [{ requestId: "bid-a", bidderCode: "a", adUnitCode: "slot-1", cpm: 2, source: "client" }], + }); + expect(p.bidderRequests[0].bids[0].src).toBeUndefined(); + expect(p.bidderRequests[0].bids[0].cpm).toBe(2); + }); +}); diff --git a/lib/core/storage.test.js b/lib/core/storage.test.js index e6af6458..28f8787a 100644 --- a/lib/core/storage.test.js +++ b/lib/core/storage.test.js @@ -63,6 +63,29 @@ describe("LocalStorage", () => { }); }); + test("setTargeting writes a :ts sibling with the write time and clearTargeting removes it", () => { + const store = new LocalStorage(randomConfig()); + const tsKeys = () => { + const keys = []; + for (let i = 0; i < localStorage.length; i += 1) { + const k = localStorage.key(i); + if (k.endsWith(":ts")) keys.push(k); + } + return keys; + }; + localStorage.clear(); + const before = Date.now(); + store.setTargeting({ user: [], audience: [] }); + const keys = tsKeys(); + expect(keys.length).toBeGreaterThan(0); + keys.forEach((k) => { + expect(Number(localStorage.getItem(k))).toBeGreaterThanOrEqual(before); + expect(localStorage.getItem(k.slice(0, -3))).not.toBeNull(); + }); + store.clearTargeting(); + expect(tsKeys()).toEqual([]); + }); + test("setTargeting normalizes wire refs into the source-keyed sidecar", () => { const store = new LocalStorage(randomConfig()); const ref = { diff --git a/lib/core/storage.ts b/lib/core/storage.ts index 67e0ce5b..64d75a47 100644 --- a/lib/core/storage.ts +++ b/lib/core/storage.ts @@ -17,6 +17,7 @@ const pairEIDSource = "pair-protocol.com"; class LocalStorage { private passportKeys: StorageKeys; private targetingKeys: StorageKeys; + private targetingTsKeys: StorageKeys; private siteKeys: StorageKeys; private pairKeys: StorageKeys; private oisKeys: StorageKeys; @@ -25,6 +26,12 @@ class LocalStorage { constructor(private config: ResolvedConfig) { this.passportKeys = generatePassportKeys(config); this.targetingKeys = generateTargetingKeys(config); + // ":ts" siblings hold the epoch ms of the last targeting write, so analytics can + // report cache age without changing the cache format that wrappers parse. + this.targetingTsKeys = { + read: this.targetingKeys.read.map((k) => `${k}:ts`), + write: this.targetingKeys.write.map((k) => `${k}:ts`), + }; this.siteKeys = generateSiteKeys(config); this.pairKeys = generatedPairKeys(); this.oisKeys = generateOISKeys(config); @@ -87,6 +94,7 @@ class LocalStorage { // Stored copies always hold the cache format: source-keyed refs, no pointers. this.writeToStorageKeys(this.targetingKeys, JSON.stringify(replaceCache(targeting))); + this.writeToStorageKeys(this.targetingTsKeys, String(Date.now())); this.setPairIDs(targeting); } @@ -181,6 +189,7 @@ class LocalStorage { clearTargeting() { this.clearStorageKeys(this.targetingKeys); + this.clearStorageKeys(this.targetingTsKeys); } clearSite() {