Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions modules/imAnalyticsAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -256,15 +280,14 @@ const imAnalyticsAdapter = Object.assign(
auction.wonBidsTimer = null;

if (auction.wonBids.length === 0) {
delete cache.auctions[auctionId];
return;
}

const consent = auction.consentData;
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,
Expand Down
5 changes: 4 additions & 1 deletion modules/imAnalyticsAdapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
}
});
```
139 changes: 131 additions & 8 deletions test/spec/modules/imAnalyticsAdapter_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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({
Expand All @@ -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() {
Expand Down Expand Up @@ -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({
Expand All @@ -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');
});
});
});
Expand Down
Loading