From 8c438974ecf95201df28a7482ec7fef69c24f96c Mon Sep 17 00:00:00 2001 From: eknis Date: Wed, 3 Dec 2025 13:00:05 +0900 Subject: [PATCH 1/8] IntimateMerger Analytics Adapter : initial release --- modules/imAnalyticsAdapter.js | 280 +++++++++++++++ modules/imAnalyticsAdapter.md | 40 +++ test/spec/modules/imAnalyticsAdapter_spec.js | 349 +++++++++++++++++++ 3 files changed, 669 insertions(+) create mode 100644 modules/imAnalyticsAdapter.js create mode 100644 modules/imAnalyticsAdapter.md create mode 100644 test/spec/modules/imAnalyticsAdapter_spec.js diff --git a/modules/imAnalyticsAdapter.js b/modules/imAnalyticsAdapter.js new file mode 100644 index 00000000000..b66842fb874 --- /dev/null +++ b/modules/imAnalyticsAdapter.js @@ -0,0 +1,280 @@ +import { logMessage, deepAccess } from '../src/utils.js'; +import adapter from '../libraries/analyticsAdapter/AnalyticsAdapter.js'; +import adapterManager, { coppaDataHandler, gdprDataHandler, gppDataHandler, uspDataHandler } from '../src/adapterManager.js'; +import { EVENTS } from '../src/constants.js'; +import { sendBeacon } from '../src/ajax.js'; + +const DEFAULT_BID_WON_TIMEOUT = 1500; // 1.5 second for initial batch +const DEFAULT_CID = 5126; +const API_BASE_URL = 'https://b6.im-apps.net/bid'; + + +const cache = { + auctions: {} +}; + +/** + * Get CID from adapter options + * @param {Object} options - Adapter options + * @returns {string} CID or default value + */ +function getCid(options) { + return (options && options.cid) || DEFAULT_CID; +} + +/** + * Get Bid Won Timeout from adapter options + * @param {Object} options - Adapter options + * @returns {number} Timeout in ms or default value + */ +function getWaitTimeout(options) { + return (options && options.waitTimeout) || DEFAULT_BID_WON_TIMEOUT; +} + +/** + * Build API URL with CID from options + * @param {Object} options - Adapter options + * @param {string} endpoint - Endpoint path + * @returns {string} Full API URL + */ +function buildApiUrlWithOptions(options, endpoint, auctionId) { + const cid = getCid(options); + return `${API_BASE_URL}/${cid}/${endpoint}/${auctionId}`; +} + +/** + * Send data to API endpoint using sendBeacon + * @param {string} url - API endpoint URL + * @param {Object} payload - Data to send + */ +function sendToApi(url, payload) { + const data = JSON.stringify(payload); + const blob = new Blob([data], { type: 'application/json' }); + sendBeacon(url, blob); +} + +/** + * Clear timer if exists + * @param {number|null} timer - Timer ID + * @returns {null} + */ +function clearTimer(timer) { + if (timer) { + clearTimeout(timer); + } +} + +/** + * Get consent data from bidder requests + * @returns {Object} Consent data object + */ +function getConsentData() { + const gdprConsent = gdprDataHandler.getConsentData() || {}; + const uspConsent = uspDataHandler.getConsentData(); + const gppConsent = gppDataHandler.getConsentData() || {}; + return { + gdpr: gdprConsent.gdprApplies ? 1 : 0, + usp: uspConsent, + coppa: Number(coppaDataHandler.getCoppa()), + ...(gppConsent.applicableSections && gppConsent.gppString && { + gpp: gppConsent.applicableSections.toString(), + gppStr: gppConsent.gppString + }) + }; +} + +/** + * Extract meta fields from bid won arguments + * @param {Object} meta - Meta object + * @returns {Object} Extracted meta fields + */ +function extractMetaFields(meta) { + return { + domains: meta.advertiserDomains || [], + catId: meta.primaryCatId || '', + catIds: meta.secondaryCatIds || [], + aid: meta.advertiserId || '', + advertiser: meta.advertiserName || '', + bid: meta.brandId || '', + brand: meta.brandName || '', + }; +} + +// IM Analytics Adapter implementation +const imAnalyticsAdapter = Object.assign( + adapter({ analyticsType: 'endpoint' }), + { + /** + * Track Prebid.js events + * @param {Object} params - Event parameters + * @param {string} params.eventType - Type of event + * @param {Object} params.args - Event arguments + */ + track({ eventType, args }) { + switch (eventType) { + case EVENTS.AUCTION_INIT: + logMessage('IM Analytics: AUCTION_INIT', args); + this.handleAuctionInit(args); + break; + + case EVENTS.BID_WON: + logMessage('IM Analytics: BID_WON', args); + this.handleWonBidsData(args); + break; + + case EVENTS.AUCTION_END: + logMessage('IM Analytics: AUCTION_END', args); + this.handleAuctionEnd(args.auctionId); + break; + } + }, + + /** + * Handle auction end event - schedule won bids send + * @param {string} auctionId - Auction ID + */ + handleAuctionEnd(auctionId) { + const auction = cache.auctions[auctionId]; + if (auction) { + clearTimer(auction.wonBidsTimer); + auction.wonBidsTimer = setTimeout(() => { + this.sendWonBidsData(auctionId); + }, getBidWonTimeout(this.options)); + } + }, + + /** + * Handle auction init event + * @param {Object} args - Auction arguments + */ + handleAuctionInit(args) { + const consentData = getConsentData(); + const imUid = deepAccess(args.bidderRequests, '0.bids.0.userId.imuid') ?? ''; + cache.auctions[args.auctionId] = { + imUid, + consentData, + wonSent: false, + wonBids: [], + wonBidsTimer: null, + auctionInitTimestamp: args.timestamp + }; + this.handleAucInitData(args, imUid, consentData); + }, + /** + * Handle auction init data - send immediately for PV tracking + * @param {Object} args - Auction arguments + * @param {Object} consent - Consent data + */ + handleAucInitData(args, uid, consent) { + const payload = { + url: window.location.href, + ref: document.referrer || '', + ...this.transformAucInitData(args), + uid, + consent + }; + + sendToApi(buildApiUrlWithOptions(this.options, 'pv', args.auctionId), payload); + }, + + /** + * Transform auction data for auction init event + * @param {Object} auctionArgs - Auction arguments + * @returns {Object} Transformed auction data + */ + transformAucInitData(auctionArgs) { + return { + ts: auctionArgs.timestamp, + adUnit: (auctionArgs.adUnits || []).length + }; + }, + + /** + * Handle won bids data - batch first, then individual + * @param {Object} bidWonArgs - Bid won arguments + */ + handleWonBidsData(bidWonArgs) { + const auctionId = bidWonArgs.auctionId; + const auction = cache.auctions[auctionId]; + + if (!auction) return; + + this.cacheWonBid(auctionId, bidWonArgs); + + // If initial batch has been sent, send immediately + if (auction.wonSent) { + this.sendWonBidsData(auctionId); + } + }, + + /** + * Cache won bid for batch send + * @param {string} auctionId - Auction ID + * @param {Object} bidWonArgs - Bid won arguments + */ + cacheWonBid(auctionId, bidWonArgs) { + const auction = cache.auctions[auctionId]; + if (auction) { + // Deduplicate based on requestId + if (auction.wonBids.some(bid => bid.requestId === bidWonArgs.requestId)) { + return; + } + auction.wonBids.push(this.transformWonBidsData(bidWonArgs)); + } + }, + + /** + * Transform bid won data for payload + * @param {Object} bidWonArgs - Bid won arguments + * @returns {Object} Transformed bid won data + */ + transformWonBidsData(bidWonArgs) { + const meta = bidWonArgs.meta || {}; + + return { + requestId: bidWonArgs.requestId, + bidderCode: bidWonArgs.bidderCode, + ...extractMetaFields(meta) + }; + }, + + /** + * Send accumulated won bids data to API - batch send after 1500ms + * @param {string} auctionId - Auction ID to send data for + */ + sendWonBidsData(auctionId) { + const auction = cache.auctions[auctionId]; + if (!auction || auction.wonBids.length === 0) { + return; + } + + const consent = auction.consentData; + const ts = auction.auctionInitTimestamp || Date.now(); + auction.wonSent = true; + auction.wonBidsTimer = null; + const bids = auction.wonBids; + const uid = auction.imUid; + auction.wonBids = []; + sendToApi(buildApiUrlWithOptions(this.options, 'won', auctionId), { + bids, + ts, + uid, + consent, + }); + } + } +); + +const originalEnableAnalytics = imAnalyticsAdapter.enableAnalytics; +imAnalyticsAdapter.enableAnalytics = function(config) { + this.options = (config && config.options) || {}; + logMessage('IM Analytics: enableAnalytics called with cid:', this.options.cid); + originalEnableAnalytics.call(this, config); +}; + +adapterManager.registerAnalyticsAdapter({ + adapter: imAnalyticsAdapter, + code: 'imAnalytics' +}); + +export default imAnalyticsAdapter; diff --git a/modules/imAnalyticsAdapter.md b/modules/imAnalyticsAdapter.md new file mode 100644 index 00000000000..1cd6e9c4724 --- /dev/null +++ b/modules/imAnalyticsAdapter.md @@ -0,0 +1,40 @@ +# Overview + +``` +Module Name: IM Analytics Adapter +Module Type: Analytics Adapter +``` + +#### About + +Analytics Adapter for IM-DMP. + +Please visit [intimatemerger.com/im-uid](https://intimatemerger.com/r/im-uid) and request your Customer ID to get started. + +If you are an existing publisher and you already use +[IM-UID](https://docs.prebid.org/dev-docs/modules/userid-submodules/imuid.html), +you can use the same Customer ID for this analytics adapter. + +By enabling this adapter, you agree to Intimate Merger's privacy policy at +. + +#### Analytics Options + +| Parameter | Scope | Type | Example | Description | +|-----------|-------|------|---------|-------------| +| `cid` | required | number | 5126 | The Customer ID provided by Intimate Merger. | +| `waitTimeout` | optional | number | 1500 | Wait time in milliseconds before sending batched requests. (Default: 1500) | + +#### Example Configuration + +```javascript +pbjs.enableAnalytics({ + provider: 'imAnalytics', + options: { + /* Required: Customer ID */ + cid: 5126, + /* Optional: Wait 2 seconds */ + waitTimeout: 2000 + } +}); +``` diff --git a/test/spec/modules/imAnalyticsAdapter_spec.js b/test/spec/modules/imAnalyticsAdapter_spec.js new file mode 100644 index 00000000000..f4655fe3d67 --- /dev/null +++ b/test/spec/modules/imAnalyticsAdapter_spec.js @@ -0,0 +1,349 @@ +import imAnalyticsAdapter from 'modules/imAnalyticsAdapter.js'; +import { expect } from 'chai'; +import { EVENTS } from 'src/constants.js'; +import * as utils from 'src/utils.js'; +import { coppaDataHandler, gdprDataHandler, gppDataHandler, uspDataHandler } from 'src/adapterManager.js'; +import sinon from 'sinon'; + +describe('imAnalyticsAdapter', function() { + let sandbox; + let requests; + const BID_WON_TIMEOUT = 1500; + + beforeEach(function() { + sandbox = sinon.createSandbox(); + requests = []; + + sandbox.stub(navigator, 'sendBeacon').callsFake((url, data) => { + requests.push({ + url, + data + }); + return true; + }); + + sandbox.stub(utils, 'logMessage'); + sandbox.stub(gdprDataHandler, 'getConsentData').returns({}); + sandbox.stub(uspDataHandler, 'getConsentData').returns(null); + sandbox.stub(gppDataHandler, 'getConsentData').returns({}); + sandbox.stub(coppaDataHandler, 'getCoppa').returns(false); + }); + + afterEach(function() { + sandbox.restore(); + imAnalyticsAdapter.disableAnalytics(); + requests = []; + }); + + describe('enableAnalytics', function() { + it('should catch the config options', function() { + imAnalyticsAdapter.enableAnalytics({ + provider: 'imAnalytics', + options: { + cid: 1234 + } + }); + expect(imAnalyticsAdapter.options.cid).to.equal(1234); + }); + + it('should use default cid if not provided', function() { + imAnalyticsAdapter.enableAnalytics({ + provider: 'imAnalytics' + }); + expect(imAnalyticsAdapter.options.cid).to.be.undefined; + + const cid = (imAnalyticsAdapter.options && imAnalyticsAdapter.options.cid) || 5126; + expect(cid).to.equal(5126); + }); + }); + + describe('track', function() { + const bidWonArgs = { + auctionId: 'auc-1', + bidder: 'rubicon', + bidderCode: 'rubicon', + cpm: 1.5, + currency: 'USD', + originalCpm: 1.5, + originalCurrency: 'USD', + adUnitCode: 'div-1', + timeToRespond: 100, + meta: { + advertiserDomains: ['example.com'] + } + }; + + beforeEach(function() { + imAnalyticsAdapter.enableAnalytics({ + provider: 'imAnalytics', + options: { + cid: 5126 + } + }); + }); + + describe('AUCTION_INIT', function() { + it('should send pv event immediately', function() { + const args = { + auctionId: 'auc-1', + timestamp: 1234567890, + bidderRequests: [{ + bids: [{ + userId: {} + }] + }], + adUnits: [{}, {}] + }; + + imAnalyticsAdapter.track({ + eventType: EVENTS.AUCTION_INIT, + args + }); + + expect(requests.length).to.equal(1); + expect(requests[0].url).to.include('/pv'); + }); + + it('should include imuid as uid in pv payload', async function() { + const args = { + auctionId: 'auc-1', + timestamp: 1234567890, + bidderRequests: [{ + bids: [{ + userId: { + imuid: 'test-imuid' + } + }] + }], + adUnits: [{}, {}] + }; + + imAnalyticsAdapter.track({ + eventType: EVENTS.AUCTION_INIT, + args + }); + + expect(requests.length).to.equal(1); + const payload = JSON.parse(await requests[0].data.text()); + expect(payload.uid).to.equal('test-imuid'); + }); + + it('should set uid to empty string when imuid is not available', async function() { + const args = { + auctionId: 'auc-2', + timestamp: 1234567890, + bidderRequests: [], + adUnits: [] + }; + + imAnalyticsAdapter.track({ + eventType: EVENTS.AUCTION_INIT, + args + }); + + expect(requests.length).to.equal(1); + const payload = JSON.parse(await requests[0].data.text()); + expect(payload.uid).to.equal(''); + }); + + it('should include consent data in pv payload', async function() { + gdprDataHandler.getConsentData.returns({ gdprApplies: true }); + uspDataHandler.getConsentData.returns('1YNN'); + gppDataHandler.getConsentData.returns({ applicableSections: [7], gppString: 'gpp-string' }); + coppaDataHandler.getCoppa.returns(true); + + const args = { + auctionId: 'auc-1', + timestamp: 1234567890, + bidderRequests: [], + adUnits: [] + }; + + imAnalyticsAdapter.track({ + eventType: EVENTS.AUCTION_INIT, + args + }); + + const payload = JSON.parse(await requests[0].data.text()); + expect(payload.consent.gdpr).to.equal(1); + expect(payload.consent.usp).to.equal('1YNN'); + expect(payload.consent.coppa).to.equal(1); + expect(payload.consent.gpp).to.equal('7'); + expect(payload.consent.gppStr).to.equal('gpp-string'); + }); + }); + + describe('BID_WON', function() { + it('should cache bid won events and send after timeout', function() { + const clock = sandbox.useFakeTimers(); + imAnalyticsAdapter.track({ + eventType: EVENTS.AUCTION_INIT, + args: { auctionId: 'auc-1', bidderRequests: [] } + }); + requests = []; + + imAnalyticsAdapter.track({ + eventType: EVENTS.BID_WON, + args: bidWonArgs + }); + + expect(requests.length).to.equal(0); + + imAnalyticsAdapter.track({ + eventType: EVENTS.AUCTION_END, + args: { auctionId: 'auc-1' } + }); + + clock.tick(10); + expect(requests.length).to.equal(0); + + clock.tick(BID_WON_TIMEOUT + 10); + + expect(requests.length).to.equal(1); + expect(requests[0].url).to.include('/won'); + }); + + it('should send subsequent won bids immediately', function() { + const clock = sandbox.useFakeTimers(); + + imAnalyticsAdapter.track({ + eventType: EVENTS.AUCTION_INIT, + args: { auctionId: 'auc-1', bidderRequests: [] } + }); + requests = []; + + imAnalyticsAdapter.track({ + eventType: EVENTS.BID_WON, + args: { ...bidWonArgs, requestId: 'req-1' } + }); + + imAnalyticsAdapter.track({ + eventType: EVENTS.AUCTION_END, + args: { auctionId: 'auc-1' } + }); + + clock.tick(BID_WON_TIMEOUT + 10); + expect(requests.length).to.equal(1); + + imAnalyticsAdapter.track({ + eventType: EVENTS.BID_WON, + args: { ...bidWonArgs, requestId: 'req-2' } + }); + + expect(requests.length).to.equal(2); + }); + + it('should deduplicate won bids with same requestId', function() { + const clock = sandbox.useFakeTimers(); + + imAnalyticsAdapter.track({ + eventType: EVENTS.AUCTION_INIT, + args: { auctionId: 'auc-1', bidderRequests: [] } + }); + requests = []; + + imAnalyticsAdapter.track({ + eventType: EVENTS.BID_WON, + args: { ...bidWonArgs, requestId: 'req-1' } + }); + imAnalyticsAdapter.track({ + eventType: EVENTS.BID_WON, + args: { ...bidWonArgs, requestId: 'req-1' } + }); + + imAnalyticsAdapter.track({ + eventType: EVENTS.AUCTION_END, + args: { auctionId: 'auc-1' } + }); + + clock.tick(BID_WON_TIMEOUT + 10); + expect(requests.length).to.equal(1); + }); + + it('should ignore BID_WON for unknown auctionId', function() { + imAnalyticsAdapter.track({ + eventType: EVENTS.BID_WON, + args: { ...bidWonArgs, auctionId: 'unknown' } + }); + + expect(requests.length).to.equal(0); + }); + }); + + describe('AUCTION_END', function() { + it('should schedule sending of won bids', function() { + const clock = sandbox.useFakeTimers(); + + imAnalyticsAdapter.track({ + eventType: EVENTS.AUCTION_INIT, + args: { auctionId: 'auc-1', bidderRequests: [] } + }); + requests = []; + + imAnalyticsAdapter.track({ + eventType: EVENTS.BID_WON, + args: { ...bidWonArgs, auctionId: 'auc-1' } + }); + + expect(requests.length).to.equal(0); + + imAnalyticsAdapter.track({ + eventType: EVENTS.AUCTION_END, + args: { auctionId: 'auc-1' } + }); + + clock.tick(BID_WON_TIMEOUT + 10); + expect(requests.length).to.equal(1); + expect(requests[0].url).to.include('/won'); + }); + + it('should include uid in won payload', async function() { + const clock = sandbox.useFakeTimers(); + + imAnalyticsAdapter.track({ + eventType: EVENTS.AUCTION_INIT, + args: { + auctionId: 'auc-1', + bidderRequests: [{ + bids: [{ + userId: { imuid: 'test-imuid' } + }] + }] + } + }); + requests = []; + + imAnalyticsAdapter.track({ + eventType: EVENTS.BID_WON, + args: { ...bidWonArgs, requestId: 'req-1' } + }); + imAnalyticsAdapter.track({ + eventType: EVENTS.AUCTION_END, + args: { auctionId: 'auc-1' } + }); + + clock.tick(BID_WON_TIMEOUT + 10); + const payload = JSON.parse(await requests[0].data.text()); + expect(payload.uid).to.equal('test-imuid'); + }); + + it('should not send if no won bids', function() { + const clock = sandbox.useFakeTimers(); + + imAnalyticsAdapter.track({ + eventType: EVENTS.AUCTION_INIT, + args: { auctionId: 'auc-1', bidderRequests: [] } + }); + requests = []; + + imAnalyticsAdapter.track({ + eventType: EVENTS.AUCTION_END, + args: { auctionId: 'auc-1' } + }); + + clock.tick(BID_WON_TIMEOUT + 10); + expect(requests.length).to.equal(0); + }); + }); + }); +}); From fda5f5c9cde2c4d8302b252c612255323754f563 Mon Sep 17 00:00:00 2001 From: eknis Date: Wed, 15 Apr 2026 13:04:01 +0900 Subject: [PATCH 2/8] IntimateMerger Analytics Adapter : fix event overlap --- modules/imAnalyticsAdapter.js | 13 ++++++--- test/spec/modules/imAnalyticsAdapter_spec.js | 28 ++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/modules/imAnalyticsAdapter.js b/modules/imAnalyticsAdapter.js index b66842fb874..7214d178b87 100644 --- a/modules/imAnalyticsAdapter.js +++ b/modules/imAnalyticsAdapter.js @@ -139,7 +139,7 @@ const imAnalyticsAdapter = Object.assign( clearTimer(auction.wonBidsTimer); auction.wonBidsTimer = setTimeout(() => { this.sendWonBidsData(auctionId); - }, getBidWonTimeout(this.options)); + }, getWaitTimeout(this.options)); } }, @@ -244,14 +244,19 @@ const imAnalyticsAdapter = Object.assign( */ sendWonBidsData(auctionId) { const auction = cache.auctions[auctionId]; - if (!auction || auction.wonBids.length === 0) { + if (!auction) { return; } - const consent = auction.consentData; - const ts = auction.auctionInitTimestamp || Date.now(); auction.wonSent = true; auction.wonBidsTimer = null; + + if (auction.wonBids.length === 0) { + return; + } + + const consent = auction.consentData; + const ts = auction.auctionInitTimestamp || Date.now(); const bids = auction.wonBids; const uid = auction.imUid; auction.wonBids = []; diff --git a/test/spec/modules/imAnalyticsAdapter_spec.js b/test/spec/modules/imAnalyticsAdapter_spec.js index f4655fe3d67..24d880fdecf 100644 --- a/test/spec/modules/imAnalyticsAdapter_spec.js +++ b/test/spec/modules/imAnalyticsAdapter_spec.js @@ -344,6 +344,34 @@ describe('imAnalyticsAdapter', function() { clock.tick(BID_WON_TIMEOUT + 10); expect(requests.length).to.equal(0); }); + + it('should send BID_WON immediately when it arrives after timer fired with no bids', function() { + const clock = sandbox.useFakeTimers(); + + imAnalyticsAdapter.track({ + eventType: EVENTS.AUCTION_INIT, + args: { auctionId: 'auc-1', bidderRequests: [] } + }); + requests = []; + + imAnalyticsAdapter.track({ + eventType: EVENTS.AUCTION_END, + args: { auctionId: 'auc-1' } + }); + + // timer fires with no bids + clock.tick(BID_WON_TIMEOUT + 10); + expect(requests.length).to.equal(0); + + // late BID_WON arrives after timer + imAnalyticsAdapter.track({ + eventType: EVENTS.BID_WON, + args: { ...bidWonArgs, requestId: 'req-1' } + }); + + expect(requests.length).to.equal(1); + expect(requests[0].url).to.include('/won'); + }); }); }); }); From 7bbd3c7fcbc55701f7fd4ad5073d41673eb5959b Mon Sep 17 00:00:00 2001 From: eknis Date: Wed, 15 Apr 2026 13:17:14 +0900 Subject: [PATCH 3/8] IntimateMerger Analytics Adapter : refactoring --- modules/imAnalyticsAdapter.js | 14 +++- modules/imAnalyticsAdapter.md | 1 + test/spec/modules/imAnalyticsAdapter_spec.js | 70 ++++++++++++++++++-- 3 files changed, 75 insertions(+), 10 deletions(-) diff --git a/modules/imAnalyticsAdapter.js b/modules/imAnalyticsAdapter.js index 7214d178b87..f6cdac8d557 100644 --- a/modules/imAnalyticsAdapter.js +++ b/modules/imAnalyticsAdapter.js @@ -8,7 +8,6 @@ const DEFAULT_BID_WON_TIMEOUT = 1500; // 1.5 second for initial batch const DEFAULT_CID = 5126; const API_BASE_URL = 'https://b6.im-apps.net/bid'; - const cache = { auctions: {} }; @@ -23,7 +22,7 @@ function getCid(options) { } /** - * Get Bid Won Timeout from adapter options + * Get wait timeout from adapter options * @param {Object} options - Adapter options * @returns {number} Timeout in ms or default value */ @@ -163,6 +162,7 @@ const imAnalyticsAdapter = Object.assign( /** * Handle auction init data - send immediately for PV tracking * @param {Object} args - Auction arguments + * @param {string} uid - IM-UID value * @param {Object} consent - Consent data */ handleAucInitData(args, uid, consent) { @@ -252,6 +252,7 @@ const imAnalyticsAdapter = Object.assign( auction.wonBidsTimer = null; if (auction.wonBids.length === 0) { + delete cache.auctions[auctionId]; return; } @@ -259,7 +260,7 @@ const imAnalyticsAdapter = Object.assign( const ts = auction.auctionInitTimestamp || Date.now(); const bids = auction.wonBids; const uid = auction.imUid; - auction.wonBids = []; + delete cache.auctions[auctionId]; sendToApi(buildApiUrlWithOptions(this.options, 'won', auctionId), { bids, ts, @@ -277,6 +278,13 @@ imAnalyticsAdapter.enableAnalytics = function(config) { originalEnableAnalytics.call(this, config); }; +const originalDisableAnalytics = imAnalyticsAdapter.disableAnalytics; +imAnalyticsAdapter.disableAnalytics = function() { + Object.values(cache.auctions).forEach(auction => clearTimer(auction.wonBidsTimer)); + cache.auctions = {}; + originalDisableAnalytics.call(this); +}; + adapterManager.registerAnalyticsAdapter({ adapter: imAnalyticsAdapter, code: 'imAnalytics' diff --git a/modules/imAnalyticsAdapter.md b/modules/imAnalyticsAdapter.md index 1cd6e9c4724..40b0943c308 100644 --- a/modules/imAnalyticsAdapter.md +++ b/modules/imAnalyticsAdapter.md @@ -20,6 +20,7 @@ By enabling this adapter, you agree to Intimate Merger's privacy policy at #### Analytics Options +{: .table .table-bordered .table-striped } | Parameter | Scope | Type | Example | Description | |-----------|-------|------|---------|-------------| | `cid` | required | number | 5126 | The Customer ID provided by Intimate Merger. | diff --git a/test/spec/modules/imAnalyticsAdapter_spec.js b/test/spec/modules/imAnalyticsAdapter_spec.js index 24d880fdecf..06060575102 100644 --- a/test/spec/modules/imAnalyticsAdapter_spec.js +++ b/test/spec/modules/imAnalyticsAdapter_spec.js @@ -203,7 +203,7 @@ describe('imAnalyticsAdapter', function() { expect(requests[0].url).to.include('/won'); }); - it('should send subsequent won bids immediately', function() { + it('should drop BID_WON for an auction whose cache entry has been cleaned up', function() { const clock = sandbox.useFakeTimers(); imAnalyticsAdapter.track({ @@ -222,15 +222,17 @@ describe('imAnalyticsAdapter', function() { args: { auctionId: 'auc-1' } }); + // initial batch sends and deletes cache entry clock.tick(BID_WON_TIMEOUT + 10); expect(requests.length).to.equal(1); + // BID_WON after cache cleanup is dropped imAnalyticsAdapter.track({ eventType: EVENTS.BID_WON, args: { ...bidWonArgs, requestId: 'req-2' } }); - expect(requests.length).to.equal(2); + expect(requests.length).to.equal(1); }); it('should deduplicate won bids with same requestId', function() { @@ -345,7 +347,7 @@ describe('imAnalyticsAdapter', function() { expect(requests.length).to.equal(0); }); - it('should send BID_WON immediately when it arrives after timer fired with no bids', function() { + it('should drop BID_WON that arrives after timer fired with no bids', function() { const clock = sandbox.useFakeTimers(); imAnalyticsAdapter.track({ @@ -359,19 +361,73 @@ describe('imAnalyticsAdapter', function() { args: { auctionId: 'auc-1' } }); - // timer fires with no bids + // timer fires with no bids, cache entry is deleted clock.tick(BID_WON_TIMEOUT + 10); expect(requests.length).to.equal(0); - // late BID_WON arrives after timer + // late BID_WON is dropped after cache cleanup imAnalyticsAdapter.track({ eventType: EVENTS.BID_WON, args: { ...bidWonArgs, requestId: 'req-1' } }); - expect(requests.length).to.equal(1); - expect(requests[0].url).to.include('/won'); + expect(requests.length).to.equal(0); + }); + }); + }); + + describe('disableAnalytics', function() { + it('should clear pending timers and reset cache', function() { + const clock = sandbox.useFakeTimers(); + + imAnalyticsAdapter.enableAnalytics({ + provider: 'imAnalytics', + options: { cid: 5126 } + }); + + imAnalyticsAdapter.track({ + eventType: EVENTS.AUCTION_INIT, + args: { auctionId: 'auc-1', bidderRequests: [] } }); + imAnalyticsAdapter.track({ + eventType: EVENTS.BID_WON, + args: { auctionId: 'auc-1', bidderCode: 'rubicon', requestId: 'req-1', meta: {} } + }); + imAnalyticsAdapter.track({ + eventType: EVENTS.AUCTION_END, + args: { auctionId: 'auc-1' } + }); + requests = []; + + // disable before timer fires + imAnalyticsAdapter.disableAnalytics(); + + // timer would have fired here, but should be cancelled + clock.tick(BID_WON_TIMEOUT + 10); + expect(requests.length).to.equal(0); + + // re-enable and verify no stale state + imAnalyticsAdapter.enableAnalytics({ + provider: 'imAnalytics', + options: { cid: 5126 } + }); + imAnalyticsAdapter.track({ + eventType: EVENTS.AUCTION_INIT, + args: { auctionId: 'auc-1', bidderRequests: [] } + }); + requests = []; + + imAnalyticsAdapter.track({ + eventType: EVENTS.BID_WON, + args: { auctionId: 'auc-1', bidderCode: 'rubicon', requestId: 'req-2', meta: {} } + }); + imAnalyticsAdapter.track({ + eventType: EVENTS.AUCTION_END, + args: { auctionId: 'auc-1' } + }); + + clock.tick(BID_WON_TIMEOUT + 10); + expect(requests.length).to.equal(1); }); }); }); From 2af04fa8d8b5000dd55cddf83a8982f790227c3a Mon Sep 17 00:00:00 2001 From: eknis Date: Wed, 15 Apr 2026 13:29:48 +0900 Subject: [PATCH 4/8] IntimateMerger Analytics Adapter : Update modules/imAnalyticsAdapter.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- modules/imAnalyticsAdapter.md | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/imAnalyticsAdapter.md b/modules/imAnalyticsAdapter.md index 40b0943c308..4e0bb27ec50 100644 --- a/modules/imAnalyticsAdapter.md +++ b/modules/imAnalyticsAdapter.md @@ -3,6 +3,7 @@ ``` Module Name: IM Analytics Adapter Module Type: Analytics Adapter +Maintainer: Intimate Merger ``` #### About From bae5e82016cf9138924a7d1a3a363b95d898f601 Mon Sep 17 00:00:00 2001 From: eknis Date: Wed, 15 Apr 2026 14:24:23 +0900 Subject: [PATCH 5/8] IntimateMerger Analytics Adapter : Update waitTimeout Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- modules/imAnalyticsAdapter.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/modules/imAnalyticsAdapter.js b/modules/imAnalyticsAdapter.js index f6cdac8d557..bbd28c1571a 100644 --- a/modules/imAnalyticsAdapter.js +++ b/modules/imAnalyticsAdapter.js @@ -27,12 +27,14 @@ function getCid(options) { * @returns {number} Timeout in ms or default value */ function getWaitTimeout(options) { - return (options && options.waitTimeout) || DEFAULT_BID_WON_TIMEOUT; + const waitTimeout = options && options.waitTimeout; + return (typeof waitTimeout === 'number' && waitTimeout >= 0) + ? waitTimeout + : DEFAULT_BID_WON_TIMEOUT; } /** * Build API URL with CID from options - * @param {Object} options - Adapter options * @param {string} endpoint - Endpoint path * @returns {string} Full API URL */ From 96ebf231f494c78cbea337504a7a81a829dfbbce Mon Sep 17 00:00:00 2001 From: eknis Date: Wed, 15 Apr 2026 14:24:51 +0900 Subject: [PATCH 6/8] IntimateMerger Analytics Adapter : Update modules/imAnalyticsAdapter.js docs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- modules/imAnalyticsAdapter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/imAnalyticsAdapter.js b/modules/imAnalyticsAdapter.js index bbd28c1571a..2ea1b387246 100644 --- a/modules/imAnalyticsAdapter.js +++ b/modules/imAnalyticsAdapter.js @@ -15,7 +15,7 @@ const cache = { /** * Get CID from adapter options * @param {Object} options - Adapter options - * @returns {string} CID or default value + * @returns {string|number} CID or default value */ function getCid(options) { return (options && options.cid) || DEFAULT_CID; From f24530d5eb04c22c82b978268c4a5af2da2bbbc5 Mon Sep 17 00:00:00 2001 From: eknis Date: Thu, 16 Apr 2026 16:00:38 +0900 Subject: [PATCH 7/8] IntimateMerger Analytics Adapter : Update docs --- modules/imAnalyticsAdapter.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/modules/imAnalyticsAdapter.js b/modules/imAnalyticsAdapter.js index 2ea1b387246..4c34aa71f6d 100644 --- a/modules/imAnalyticsAdapter.js +++ b/modules/imAnalyticsAdapter.js @@ -35,7 +35,9 @@ function getWaitTimeout(options) { /** * Build API URL with CID from options + * @param {Object} options - Adapter options * @param {string} endpoint - Endpoint path + * @param {string} auctionId - Auction ID * @returns {string} Full API URL */ function buildApiUrlWithOptions(options, endpoint, auctionId) { From a66f33b781cfe7c73eb5bf1a907a9bf33f3969c4 Mon Sep 17 00:00:00 2001 From: eknis Date: Thu, 16 Apr 2026 16:05:48 +0900 Subject: [PATCH 8/8] IntimateMerger Analytics Adapter : cid optional --- modules/imAnalyticsAdapter.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/imAnalyticsAdapter.md b/modules/imAnalyticsAdapter.md index 4e0bb27ec50..72cfbe19666 100644 --- a/modules/imAnalyticsAdapter.md +++ b/modules/imAnalyticsAdapter.md @@ -24,7 +24,7 @@ By enabling this adapter, you agree to Intimate Merger's privacy policy at {: .table .table-bordered .table-striped } | Parameter | Scope | Type | Example | Description | |-----------|-------|------|---------|-------------| -| `cid` | required | number | 5126 | The Customer ID provided by Intimate Merger. | +| `cid` | optional | number | 5126 | The Customer ID provided by Intimate Merger. | | `waitTimeout` | optional | number | 1500 | Wait time in milliseconds before sending batched requests. (Default: 1500) | #### Example Configuration @@ -33,7 +33,7 @@ By enabling this adapter, you agree to Intimate Merger's privacy policy at pbjs.enableAnalytics({ provider: 'imAnalytics', options: { - /* Required: Customer ID */ + /* Optional: Customer ID */ cid: 5126, /* Optional: Wait 2 seconds */ waitTimeout: 2000