diff --git a/modules/imAnalyticsAdapter.js b/modules/imAnalyticsAdapter.js index 4c34aa71f6d..baea095c015 100644 --- a/modules/imAnalyticsAdapter.js +++ b/modules/imAnalyticsAdapter.js @@ -6,6 +6,7 @@ import { sendBeacon } from '../src/ajax.js'; const DEFAULT_BID_WON_TIMEOUT = 1500; // 1.5 second for initial batch const DEFAULT_CID = 5126; +const DEFAULT_CACHE_TTL = 30 * 1000; // 30 seconds const API_BASE_URL = 'https://b6.im-apps.net/bid'; const cache = { @@ -33,6 +34,16 @@ function getWaitTimeout(options) { : DEFAULT_BID_WON_TIMEOUT; } +/** + * Get cache TTL from adapter options + * @param {Object} options - Adapter options + * @returns {number} TTL in ms + */ +function getTtl(options) { + const ttl = options && options.cacheTtl; + return (typeof ttl === 'number' && ttl > 0) ? ttl : DEFAULT_CACHE_TTL; +} + /** * Build API URL with CID from options * @param {Object} options - Adapter options @@ -151,8 +162,21 @@ const imAnalyticsAdapter = Object.assign( * @param {Object} args - Auction arguments */ handleAuctionInit(args) { + const now = Date.now(); + const ttl = getTtl(this.options); + Object.keys(cache.auctions).forEach(id => { + const entry = cache.auctions[id]; + if (now - (entry.auctionInitTimestamp || 0) > ttl) { + clearTimer(entry.wonBidsTimer); + delete cache.auctions[id]; + } + }); + const consentData = getConsentData(); const imUid = deepAccess(args.bidderRequests, '0.bids.0.userId.imuid') ?? ''; + if (cache.auctions[args.auctionId]) { + clearTimer(cache.auctions[args.auctionId].wonBidsTimer); + } cache.auctions[args.auctionId] = { imUid, consentData, @@ -256,7 +280,6 @@ const imAnalyticsAdapter = Object.assign( auction.wonBidsTimer = null; if (auction.wonBids.length === 0) { - delete cache.auctions[auctionId]; return; } @@ -264,7 +287,7 @@ const imAnalyticsAdapter = Object.assign( const ts = auction.auctionInitTimestamp || Date.now(); const bids = auction.wonBids; const uid = auction.imUid; - delete cache.auctions[auctionId]; + auction.wonBids = []; sendToApi(buildApiUrlWithOptions(this.options, 'won', auctionId), { bids, ts, diff --git a/modules/imAnalyticsAdapter.md b/modules/imAnalyticsAdapter.md index 72cfbe19666..676ce0e47bc 100644 --- a/modules/imAnalyticsAdapter.md +++ b/modules/imAnalyticsAdapter.md @@ -26,6 +26,7 @@ By enabling this adapter, you agree to Intimate Merger's privacy policy at |-----------|-------|------|---------|-------------| | `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) | +| `cacheTtl` | optional | number | 30000 | Time in milliseconds before stale auction cache entries are removed. (Default: 30000) | #### Example Configuration @@ -36,7 +37,9 @@ pbjs.enableAnalytics({ /* Optional: Customer ID */ cid: 5126, /* Optional: Wait 2 seconds */ - waitTimeout: 2000 + waitTimeout: 2000, + /* Optional: Remove stale auction cache after 60 seconds */ + cacheTtl: 60000 } }); ``` diff --git a/test/spec/modules/imAnalyticsAdapter_spec.js b/test/spec/modules/imAnalyticsAdapter_spec.js index 06060575102..91ae073e550 100644 --- a/test/spec/modules/imAnalyticsAdapter_spec.js +++ b/test/spec/modules/imAnalyticsAdapter_spec.js @@ -171,6 +171,129 @@ describe('imAnalyticsAdapter', function() { expect(payload.consent.gpp).to.equal('7'); expect(payload.consent.gppStr).to.equal('gpp-string'); }); + + it('should cancel existing timer when same auctionId receives duplicate AUCTION_INIT', function() { + const clock = sandbox.useFakeTimers(); + + // first AUCTION_INIT + imAnalyticsAdapter.track({ + eventType: EVENTS.AUCTION_INIT, + args: { auctionId: 'auc-dup', timestamp: clock.now, bidderRequests: [], adUnits: [] } + }); + imAnalyticsAdapter.track({ + eventType: EVENTS.BID_WON, + args: { ...bidWonArgs, auctionId: 'auc-dup', requestId: 'req-1' } + }); + imAnalyticsAdapter.track({ + eventType: EVENTS.AUCTION_END, + args: { auctionId: 'auc-dup' } + }); + requests = []; + + // duplicate AUCTION_INIT for the same auctionId arrives before timer fires + imAnalyticsAdapter.track({ + eventType: EVENTS.AUCTION_INIT, + args: { auctionId: 'auc-dup', timestamp: clock.now, bidderRequests: [], adUnits: [] } + }); + requests = []; + + // old timer should be cancelled, so no won request is sent + clock.tick(BID_WON_TIMEOUT + 10); + expect(requests.length).to.equal(0); + }); + + it('should remove stale auction entries that exceed TTL on AUCTION_INIT', function() { + const clock = sandbox.useFakeTimers(); + + // start the first auction + imAnalyticsAdapter.track({ + eventType: EVENTS.AUCTION_INIT, + args: { auctionId: 'auc-stale', timestamp: clock.now, bidderRequests: [], adUnits: [] } + }); + requests = []; + + // advance past the default TTL (30 seconds) + clock.tick(30 * 1000 + 1); + + // start a second auction, which should trigger removal of auc-stale + imAnalyticsAdapter.track({ + eventType: EVENTS.AUCTION_INIT, + args: { auctionId: 'auc-new', timestamp: clock.now, bidderRequests: [], adUnits: [] } + }); + + // BID_WON for the removed stale entry should be ignored + imAnalyticsAdapter.track({ + eventType: EVENTS.BID_WON, + args: { ...bidWonArgs, auctionId: 'auc-stale', requestId: 'req-stale' } + }); + + expect(requests.length).to.equal(1); // only the pv for auc-new + }); + + it('should keep entries within TTL on AUCTION_INIT', function() { + const clock = sandbox.useFakeTimers(); + + imAnalyticsAdapter.track({ + eventType: EVENTS.AUCTION_INIT, + args: { auctionId: 'auc-active', timestamp: clock.now, bidderRequests: [], adUnits: [] } + }); + requests = []; + + // advance less than the TTL + clock.tick(10 * 1000); + + imAnalyticsAdapter.track({ + eventType: EVENTS.AUCTION_INIT, + args: { auctionId: 'auc-new2', timestamp: clock.now, bidderRequests: [], adUnits: [] } + }); + + // auc-active is still alive, so BID_WON should be processed + imAnalyticsAdapter.track({ + eventType: EVENTS.AUCTION_END, + args: { auctionId: 'auc-active' } + }); + imAnalyticsAdapter.track({ + eventType: EVENTS.BID_WON, + args: { ...bidWonArgs, auctionId: 'auc-active', requestId: 'req-active' } + }); + + clock.tick(BID_WON_TIMEOUT + 10); + const wonRequests = requests.filter(r => r.url.includes('/won')); + expect(wonRequests.length).to.equal(1); + }); + + it('should use cacheTtl from options when provided', function() { + // set cacheTtl to 5 seconds + imAnalyticsAdapter.disableAnalytics(); + imAnalyticsAdapter.enableAnalytics({ + provider: 'imAnalytics', + options: { cid: 5126, cacheTtl: 5 * 1000 } + }); + + const clock = sandbox.useFakeTimers(); + + imAnalyticsAdapter.track({ + eventType: EVENTS.AUCTION_INIT, + args: { auctionId: 'auc-custom-ttl', timestamp: clock.now, bidderRequests: [], adUnits: [] } + }); + requests = []; + + // advance past 5 seconds (still within the default 30s TTL) + clock.tick(5 * 1000 + 1); + + imAnalyticsAdapter.track({ + eventType: EVENTS.AUCTION_INIT, + args: { auctionId: 'auc-after', timestamp: clock.now, bidderRequests: [], adUnits: [] } + }); + + // auc-custom-ttl should have been removed + imAnalyticsAdapter.track({ + eventType: EVENTS.BID_WON, + args: { ...bidWonArgs, auctionId: 'auc-custom-ttl', requestId: 'req-custom' } + }); + + expect(requests.length).to.equal(1); // only the pv for auc-after + }); }); describe('BID_WON', function() { @@ -203,7 +326,7 @@ describe('imAnalyticsAdapter', function() { expect(requests[0].url).to.include('/won'); }); - it('should drop BID_WON for an auction whose cache entry has been cleaned up', function() { + it('should send subsequent BID_WON immediately after initial batch', function() { const clock = sandbox.useFakeTimers(); imAnalyticsAdapter.track({ @@ -222,17 +345,16 @@ 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 + // subsequent BID_WON sent immediately via lightweight cache state imAnalyticsAdapter.track({ eventType: EVENTS.BID_WON, args: { ...bidWonArgs, requestId: 'req-2' } }); - expect(requests.length).to.equal(1); + expect(requests.length).to.equal(2); }); it('should deduplicate won bids with same requestId', function() { @@ -347,7 +469,7 @@ describe('imAnalyticsAdapter', function() { expect(requests.length).to.equal(0); }); - it('should drop BID_WON that arrives after timer fired with no bids', function() { + it('should send BID_WON immediately when it arrives after timer fired with no bids', function() { const clock = sandbox.useFakeTimers(); imAnalyticsAdapter.track({ @@ -361,17 +483,18 @@ describe('imAnalyticsAdapter', function() { args: { auctionId: 'auc-1' } }); - // timer fires with no bids, cache entry is deleted + // timer fires with no bids, lightweight cache state is kept clock.tick(BID_WON_TIMEOUT + 10); expect(requests.length).to.equal(0); - // late BID_WON is dropped after cache cleanup + // late BID_WON sent immediately via lightweight cache state imAnalyticsAdapter.track({ eventType: EVENTS.BID_WON, args: { ...bidWonArgs, requestId: 'req-1' } }); - expect(requests.length).to.equal(0); + expect(requests.length).to.equal(1); + expect(requests[0].url).to.include('/won'); }); }); });