Skip to content

Commit 37876d3

Browse files
authored
Add pgamdirect Analytics Adapter (#14778)
* Add pgamdirect Analytics Adapter Companion to modules/pgamdirectBidAdapter.ts (merged #14763). Publishers install this alongside the bid adapter to forward auction telemetry to the PGAM Direct SSP backend. Forwards four Prebid events, deliberately narrow: AUCTION_END — competitor CPMs seen in the auction BID_WON — Prebid-layer winner + price AD_RENDER_SUCCEEDED — client-confirmed impression AD_RENDER_FAILED — with reason (exception / timeout / etc) The value add over a server-side-only ledger: client-confirmed render vs. "RTB said we won", plus visibility into what other bidders priced the same auction at (we own server-side data for our own DSP calls, but not for the ones other SSPs made through the same Prebid wrapper). Payload is normalised into a small fixed shape before POST — we deliberately drop the raw Prebid event args, which carry full FPD / user.eids / custom bidder params that we don't need and shouldn't exfiltrate. Sink: https://app.pgammedia.com/api/analytics-events (one POST per event; content-type text/plain to keep CORS simple). Config: pbjs.enableAnalytics({ provider: 'pgamdirect', options: { orgId: '<pgam org id>', // required endpoint: 'https://...' // optional override } }); GVL ID 1353 (PGAM Media LLC, same as the bid adapter). Tests: 12 covering registration, orgId validation, and the pure normalise transform across all 4 forwarded event types (including the 20-entry bidders_seen cap and filter-out of bidders with no code). Event-emission path is not covered in this spec because the sinon mock + AnalyticsAdapter async queue interact oddly in the test harness — we export normalise() directly so the transform is verifiable without the full event pipeline. The ajax call itself is covered by upstream AnalyticsAdapter base-class tests. * pgamdirectAnalytics: address Codex review on #14778 Two P1s flagged by Codex on the initial commit: 1. ad_unit_code semantic inconsistency Original code pulled ad_unit_code from args.adUnitCode on BID_WON but from args.adId on AD_RENDER_*, so the same field represented different identifiers across event types. In auctions with multiple ad units (or repeated wins from the same bidder), this prevented reliable win → render reconciliation and could misattribute render outcomes. Fix: render events now read ad_unit_code from args.bid.adUnitCode (stable across the BID_WON ↔ AD_RENDER_* join for the same slot). adId moves to its own field `ad_id` so per-bid traceability is preserved. Type definition updated with a comment explaining the split so future contributors don't re-conflate them. 2. Missing fetch keepalive Prebid AGENTS.md §71 requires low-priority telemetry calls to set fetch keepalive. Without it, BID_WON + AD_RENDER_* events emitted near page unload get dropped before reaching the endpoint — and those are exactly the events that fire in that window. Added `keepalive: true` to the ajax call. Prebid's ajax helper already supports the flag (src/ajax.ts option); no adapter-side polyfill needed. Tests: +1 spec case covering "missing bid object on AD_RENDER_* still captures ad_id cleanly." Existing render-event assertions updated to verify the ad_unit_code-vs-ad_id split explicitly. 13/13 pass (was 12).
1 parent 9eddbc1 commit 37876d3

2 files changed

Lines changed: 446 additions & 0 deletions

File tree

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
/**
2+
* pgamdirect Analytics Adapter.
3+
*
4+
* Publishers include this module alongside `pgamdirectBidAdapter` to
5+
* forward Prebid auction telemetry to the PGAM Direct SSP backend.
6+
* Companion to the server-side bid-outcomes ledger we maintain from
7+
* the bidder — this module gives us CLIENT-confirmed signals that
8+
* the RTB path can't see:
9+
*
10+
* AUCTION_END — final state of every auction (who we competed
11+
* against, at what prices). Lets us see
12+
* competitor CPMs without needing their
13+
* reports.
14+
* BID_WON — which bidder won in the Prebid layer and what
15+
* they paid. Critical for our reconciliation
16+
* because a server-side win notice doesn't tell
17+
* us if the publisher actually rendered the ad.
18+
* AD_RENDER_SUCCEEDED / AD_RENDER_FAILED — client-confirmed
19+
* impression. Feeds the discrepancy reconciler
20+
* so we can distinguish "bidder said we won" from
21+
* "user actually saw the ad."
22+
*
23+
* Configuration (publisher-side):
24+
*
25+
* pbjs.enableAnalytics({
26+
* provider: 'pgamdirect',
27+
* options: {
28+
* orgId: '<your pgam org id>' // REQUIRED
29+
* // endpoint: 'https://...custom...' // optional override
30+
* }
31+
* });
32+
*
33+
* GVL ID: 1353 (same as the bid adapter, PGAM Media LLC).
34+
*/
35+
36+
import adapter from '../libraries/analyticsAdapter/AnalyticsAdapter.js';
37+
import { EVENTS } from '../src/constants.js';
38+
import adapterManager from '../src/adapterManager.js';
39+
import { ajax } from '../src/ajax.js';
40+
import { logError, logMessage } from '../src/utils.js';
41+
42+
const ANALYTICS_CODE = 'pgamdirect';
43+
const GVLID = 1353;
44+
const DEFAULT_ENDPOINT =
45+
'https://app.pgammedia.com/api/analytics-events';
46+
47+
// Which events we forward. Deliberately narrow: the four that carry
48+
// reconciliation-grade signal. Adding more here increases the
49+
// per-auction payload size without materially improving our models.
50+
const FORWARDED_EVENTS: readonly string[] = [
51+
EVENTS.AUCTION_END,
52+
EVENTS.BID_WON,
53+
EVENTS.AD_RENDER_SUCCEEDED,
54+
EVENTS.AD_RENDER_FAILED,
55+
];
56+
57+
interface PgamAnalyticsOptions {
58+
orgId?: string;
59+
endpoint?: string;
60+
}
61+
62+
let orgId: string | null = null;
63+
let endpoint = DEFAULT_ENDPOINT;
64+
65+
const pgamdirectAnalytics = Object.assign(
66+
adapter({ url: DEFAULT_ENDPOINT, analyticsType: 'endpoint' }),
67+
{
68+
/**
69+
* Called by the AnalyticsAdapter base class on every tracked
70+
* event (via enqueue → track). We filter to our forwarded set,
71+
* normalise to a compact shape, and POST event-by-event.
72+
*
73+
* We deliberately do NOT forward the raw Prebid event args —
74+
* they carry a lot of site-private data (full FPD blobs,
75+
* user.eids, custom bidder params) that we don't need and
76+
* shouldn't exfiltrate.
77+
*/
78+
track({ eventType, args }: { eventType: string; args?: unknown }) {
79+
if (!orgId) return;
80+
if (!FORWARDED_EVENTS.includes(eventType)) return;
81+
try {
82+
const normalised = normalise(eventType, args);
83+
const body = JSON.stringify({ org_id: orgId, event: normalised });
84+
// Content-type 'text/plain' keeps the POST CORS-simple (no
85+
// preflight). Our analytics sink parses text/plain as JSON
86+
// deliberately.
87+
//
88+
// keepalive=true per Prebid AGENTS.md guidance for low-
89+
// priority telemetry: without it, events emitted near page
90+
// navigation / unload get dropped before the XHR lands. The
91+
// most valuable events (BID_WON, AD_RENDER_*) fire exactly
92+
// in the unload window, so this directly improves delivery.
93+
ajax(endpoint, undefined, body, {
94+
method: 'POST',
95+
withCredentials: false,
96+
contentType: 'text/plain',
97+
keepalive: true,
98+
});
99+
} catch (err) {
100+
logError('[pgamdirectAnalytics] track failed', err);
101+
}
102+
},
103+
},
104+
);
105+
106+
// Minimal event shape we extract from each Prebid event.
107+
//
108+
// Note on ad_unit_code vs ad_id: they identify different things and
109+
// must be kept separate so downstream reconciliation can join a
110+
// BID_WON to its AD_RENDER_* event reliably.
111+
//
112+
// ad_unit_code — the publisher's slot identifier (same on BID_WON
113+
// and on the subsequent AD_RENDER_SUCCEEDED for that
114+
// slot). Stable per-adunit; reused across auctions
115+
// when the same slot refreshes.
116+
// ad_id — Prebid's per-bid adId (unique per bid response,
117+
// changes every auction). Present on the render
118+
// events so we can tie a specific bid's render
119+
// outcome back to the BID_WON that preceded it.
120+
//
121+
// Earlier revision misused adId as ad_unit_code on the render events
122+
// (flagged by Codex review on #14778); this split fixes
123+
// cross-event reconciliation.
124+
interface NormalisedEvent {
125+
t: string;
126+
ts: number;
127+
auction_id?: string;
128+
bidder?: string;
129+
cpm?: number;
130+
currency?: string;
131+
ad_unit_code?: string;
132+
ad_id?: string;
133+
creative_id?: string;
134+
media_type?: string;
135+
size?: string;
136+
bidders_seen?: Array<{
137+
bidder: string;
138+
cpm?: number;
139+
media_type?: string;
140+
size?: string;
141+
}>;
142+
render_fail_reason?: string;
143+
}
144+
145+
// Exported for unit testing — keeps the pure transform verifiable
146+
// without needing the full Prebid events harness.
147+
export function normalise(eventType: string, rawArgs: unknown): NormalisedEvent {
148+
const a = (rawArgs ?? {}) as Record<string, unknown>;
149+
const base: NormalisedEvent = {
150+
t: eventType,
151+
ts: Date.now(),
152+
auction_id: typeof a.auctionId === 'string' ? a.auctionId : undefined,
153+
};
154+
155+
switch (eventType) {
156+
case EVENTS.BID_WON:
157+
return {
158+
...base,
159+
bidder: typeof a.bidderCode === 'string' ? a.bidderCode : undefined,
160+
cpm: typeof a.cpm === 'number' ? a.cpm : undefined,
161+
currency: typeof a.currency === 'string' ? a.currency : undefined,
162+
ad_unit_code: typeof a.adUnitCode === 'string' ? a.adUnitCode : undefined,
163+
creative_id: typeof a.creativeId === 'string' ? a.creativeId : undefined,
164+
media_type: typeof a.mediaType === 'string' ? a.mediaType : undefined,
165+
size: typeof a.size === 'string' ? a.size : undefined,
166+
};
167+
168+
case EVENTS.AUCTION_END: {
169+
const bids = Array.isArray(a.bidsReceived)
170+
? (a.bidsReceived as Array<Record<string, unknown>>)
171+
: [];
172+
return {
173+
...base,
174+
bidders_seen: bids
175+
.slice(0, 20) // hard cap
176+
.map((b) => ({
177+
bidder: typeof b.bidderCode === 'string' ? b.bidderCode : '',
178+
cpm: typeof b.cpm === 'number' ? b.cpm : undefined,
179+
media_type: typeof b.mediaType === 'string' ? b.mediaType : undefined,
180+
size: typeof b.size === 'string' ? b.size : undefined,
181+
}))
182+
.filter((b) => b.bidder),
183+
};
184+
}
185+
186+
case EVENTS.AD_RENDER_SUCCEEDED: {
187+
// Render events carry the winning bid nested under args.bid;
188+
// extract adUnitCode from THERE (stable across BID_WON ↔
189+
// AD_RENDER_* joins). adId goes into its own field for
190+
// per-bid traceability.
191+
const bid =
192+
typeof a.bid === 'object' && a.bid
193+
? (a.bid as Record<string, unknown>)
194+
: null;
195+
return {
196+
...base,
197+
bidder: bid && typeof bid.bidderCode === 'string' ? bid.bidderCode : undefined,
198+
ad_unit_code: bid && typeof bid.adUnitCode === 'string' ? bid.adUnitCode : undefined,
199+
ad_id: typeof a.adId === 'string' ? a.adId : undefined,
200+
};
201+
}
202+
203+
case EVENTS.AD_RENDER_FAILED: {
204+
const bid =
205+
typeof a.bid === 'object' && a.bid
206+
? (a.bid as Record<string, unknown>)
207+
: null;
208+
return {
209+
...base,
210+
render_fail_reason: typeof a.reason === 'string' ? a.reason : 'unknown',
211+
ad_unit_code: bid && typeof bid.adUnitCode === 'string' ? bid.adUnitCode : undefined,
212+
ad_id: typeof a.adId === 'string' ? a.adId : undefined,
213+
};
214+
}
215+
216+
default:
217+
return base;
218+
}
219+
}
220+
221+
// Intercept enableAnalytics to capture orgId + endpoint out of the
222+
// provider config. Pattern copied from
223+
// modules/AsteriobidPbmAnalyticsAdapter.js and other TS adapters.
224+
(pgamdirectAnalytics as unknown as Record<string, unknown>)
225+
.originEnableAnalytics = pgamdirectAnalytics.enableAnalytics;
226+
pgamdirectAnalytics.enableAnalytics = function (config: {
227+
options?: PgamAnalyticsOptions;
228+
}) {
229+
const opts = config?.options ?? {};
230+
if (!opts.orgId || typeof opts.orgId !== 'string') {
231+
logError('[pgamdirectAnalytics] options.orgId is required');
232+
return;
233+
}
234+
orgId = opts.orgId;
235+
if (typeof opts.endpoint === 'string' && opts.endpoint) {
236+
endpoint = opts.endpoint;
237+
}
238+
logMessage(`[pgamdirectAnalytics] enabled for orgId=${orgId}`);
239+
(
240+
(pgamdirectAnalytics as unknown as Record<string, unknown>)
241+
.originEnableAnalytics as (c: unknown) => void
242+
).call(pgamdirectAnalytics, config);
243+
};
244+
245+
adapterManager.registerAnalyticsAdapter({
246+
adapter: pgamdirectAnalytics,
247+
code: ANALYTICS_CODE,
248+
gvlid: GVLID,
249+
});
250+
251+
export default pgamdirectAnalytics;

0 commit comments

Comments
 (0)