Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ADS-4131 update reporting format #26

Merged
merged 3 commits into from
Jun 29, 2020
Merged
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
120 changes: 98 additions & 22 deletions modules/mavenDistributionAnalyticsAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import CONSTANTS from '../src/constants.json';
import adaptermanager from '../src/adapterManager.js';
import adapter from '../src/AnalyticsAdapter.js';
import { logError } from '../src/utils.js';
import { logError, logInfo, logWarn } from '../src/utils.js';

// Standard Analytics Adapter code
const AUCTION_END = CONSTANTS.EVENTS.AUCTION_END
Expand All @@ -14,6 +14,7 @@ const BATCH_MESSAGE_FREQUENCY = 1000; // Send results batched on a 1s delay

const PROVIDER_CODE = 'mavenDistributionAnalyticsAdapter'
const MAVEN_DISTRIBUTION_GLOBAL = '$p'
const MAX_BATCH_SIZE = 32

/**
* We may add more fields in the future
Expand Down Expand Up @@ -45,10 +46,10 @@ const MAVEN_DISTRIBUTION_GLOBAL = '$p'
*/

/**
* // cpms, zoneIndexes, and zoneNames all have the same length
* // cpmms, zoneIndexes, and zoneNames all have the same length
* @typedef {{
* auc: string
* cpms: number[]
* cpmms: number[]
* zoneIndexes: number[]
* zoneNames: string[]
* }} AuctionEndSummary
Expand All @@ -61,7 +62,7 @@ const MAVEN_DISTRIBUTION_GLOBAL = '$p'
*/
export function summarizeAuctionEnd(args, adapterConfig) {
/** @type {{[code: string]: number}} */
const cpmsMap = {}
const cpmmsMap = {}
const zoneNames = []
const zoneIndexes = []
const adUnitCodes = []
Expand All @@ -70,7 +71,7 @@ export function summarizeAuctionEnd(args, adapterConfig) {
let someZoneNameNonNull = false
let allZoneNamesNonNull = true
args.adUnits.forEach(adUnit => {
cpmsMap[adUnit.code] = 0
cpmmsMap[adUnit.code] = 0
adUnitCodes.push(adUnit.code)

const zoneConfig = zoneMap[adUnit.code] || {}
Expand All @@ -85,14 +86,14 @@ export function summarizeAuctionEnd(args, adapterConfig) {
allZoneNamesNonNull = allZoneNamesNonNull && zoneNameNonNull
})
args.bidsReceived.forEach(bid => {
cpmsMap[bid.adUnitCode] = Math.max(cpmsMap[bid.adUnitCode], bid.cpm || 0)
cpmmsMap[bid.adUnitCode] = Math.max(cpmmsMap[bid.adUnitCode], Math.round(bid.cpm * 1000 || 0))
})
const cpms = args.adUnits.map(adUnit => cpmsMap[adUnit.code])
const cpmms = args.adUnits.map(adUnit => cpmmsMap[adUnit.code])

/** @type {AuctionEndSummary} */
const eventToSend = {
auc: args.auctionId,
cpms: cpms,
cpmms: cpmms,
}
if (!allZoneNamesNonNull) eventToSend.codes = adUnitCodes
if (someZoneNameNonNull) eventToSend.zoneNames = zoneNames
Expand All @@ -101,24 +102,87 @@ export function summarizeAuctionEnd(args, adapterConfig) {
}

/**
* Price is in microdollars
* @param {AuctionEndSummary[]} batch
* @return {{batch: string}}
* @return {{batch: string, price: number}}
*/
export function createSendOptionsFromBatch(batch) {
const batchJson = JSON.stringify(batch)
return { batch: batchJson }
let price = 0
batch.forEach(auctionEndSummary => {
auctionEndSummary.cpmms.forEach(cpmm => price += cpmm)
})
return { batch: batchJson, price: price }
}

const STATE_DOM_CONTENT_LOADING = 'wait-for-$p-to-be-defined'
const STATE_LIFTIGNITER_LOADING = 'waiting-$p(\'onload\')'
const STATE_LIFTIGNITER_LOADED = 'loaded'
/**
* Wrapper around $p that detects when $p is defined
* and then detects when the script is loaded
* so that the caller can discard events if the script never loads
*/
function LiftIgniterWrapper() {
this._state = null
this._onReadyStateChangeBound = this._onReadyStateChange.bind(this)
this._init()
}
LiftIgniterWrapper.prototype = {
_expectState(state) {
if (this._state != state) {
logError(`wrong state ${this._state}; expected ${state}`)
return false
} else {
return true
}
},
_init() {
this._state = STATE_DOM_CONTENT_LOADING
document.addEventListener('DOMContentLoaded', this._onReadyStateChangeBound)
this._onReadyStateChange()
},
_onReadyStateChange() {
if (!this._expectState(STATE_DOM_CONTENT_LOADING)) return
const found = window[MAVEN_DISTRIBUTION_GLOBAL] != null
logInfo(`wrap$p: onReadyStateChange; $p found = ${found}`)
if (found) {
document.removeEventListener('DOMContentLoaded', this._onReadyStateChangeBound)
this._state = STATE_LIFTIGNITER_LOADING
// note: $p('onload', cb) may synchronously call the callback
window[MAVEN_DISTRIBUTION_GLOBAL]('onload', this._onLiftIgniterLoad.bind(this))
}
},
_onLiftIgniterLoad() {
if (!this._expectState(STATE_LIFTIGNITER_LOADING)) return
this._state = STATE_LIFTIGNITER_LOADED
logInfo(`wrap$p: onLiftIgniterLoad`)
},
checkIsLoaded() {
if (this._state === STATE_DOM_CONTENT_LOADING) {
this._onReadyStateChange()
}
return this._state === STATE_LIFTIGNITER_LOADED
},
sendPrebid(obj) {
if (!this._expectState(STATE_LIFTIGNITER_LOADED)) return
window[MAVEN_DISTRIBUTION_GLOBAL]('send', 'prebid', obj)
logInfo(`wrap$p: $p('send')`)
},
}

/**
* @param {MavenDistributionAdapterConfig} adapterConfig
* @property {object[] | null} batch
* @property {number | null} batchTimeout
* @property {MavenDistributionAdapterConfig} adapterConfig
* @property {LiftIgniterWrapper} liftIgniterWrapper
*/
function MavenDistributionAnalyticsAdapterInner(adapterConfig) {
this.batch = null
function MavenDistributionAnalyticsAdapterInner(adapterConfig, liftIgniterWrapper) {
this.batch = []
this.batchTimeout = null
this.adapterConfig = adapterConfig
this.liftIgniterWrapper = liftIgniterWrapper
}
MavenDistributionAnalyticsAdapterInner.prototype = {
/**
Expand All @@ -128,22 +192,28 @@ MavenDistributionAnalyticsAdapterInner.prototype = {
const {eventType, args} = typeAndArgs
if (eventType === AUCTION_END) {
const eventToSend = summarizeAuctionEnd(args, this.adapterConfig)
if (!this.batch) {
this.batch = []
if (this.timeout == null) {
this.timeout = setTimeout(this._sendBatch.bind(this), BATCH_MESSAGE_FREQUENCY)
logInfo(`$p: added auctionEnd to new batch`)
} else {
logInfo(`$p: added auctionEnd to existing batch`)
}
this.batch.push(eventToSend)
}
},

_sendBatch() {
const sendOptions = createSendOptionsFromBatch(this.batch)
if (window[MAVEN_DISTRIBUTION_GLOBAL]) {
window[MAVEN_DISTRIBUTION_GLOBAL]('send', 'prebid', sendOptions)
this.timeout = null
this.batch = null
} else {
this.timeout = setTimeout(this._sendBatch.bind(this), BATCH_MESSAGE_FREQUENCY)
this.timeout = null
const countToDiscard = this.batch.length - MAX_BATCH_SIZE
if (countToDiscard > 0) {
logWarn(`$p: Discarding ${countToDiscard} old items`)
this.batch.splice(0, countToDiscard)
}
if (this.liftIgniterWrapper.checkIsLoaded()) {
logInfo(`$p: Sending ${this.batch.length} items`)
const sendOptions = createSendOptionsFromBatch(this.batch)
this.liftIgniterWrapper.sendPrebid(sendOptions)
this.batch.length = 0
}
},
}
Expand All @@ -169,7 +239,8 @@ MavenDistributionAnalyticsAdapter.prototype = {
if (adapterConfig.options == null) {
return logError(`Adapter ${PROVIDER_CODE}: options null; disabling`)
}
const inner = new MavenDistributionAnalyticsAdapterInner(adapterConfig)
const liftIgniterWrapper = new LiftIgniterWrapper()
const inner = new MavenDistributionAnalyticsAdapterInner(adapterConfig, liftIgniterWrapper)
const base = adapter({global: MAVEN_DISTRIBUTION_GLOBAL})
base.track = inner.track.bind(inner)
base.enableAnalytics(adapterConfig)
Expand All @@ -191,4 +262,9 @@ adaptermanager.registerAnalyticsAdapter({
code: PROVIDER_CODE,
});

// export for unit test
export const testables = {
LiftIgniterWrapper,
}

export default mavenDistributionAnalyticsAdapter;
14 changes: 14 additions & 0 deletions src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -354,9 +354,23 @@ export function logError() {
}
}

let getTimestamp
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Has this been through a linter? Beyond that, you might want to keep this within the mavenDistribution module code, just to make it more feasible to merge it into the public Prebid.js repo.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes npx gulp test runs eslint. Vipul says he prefers to see the timestamp on all log messages including the ones internal to prebid. Yes it does make it annoying that it will make it harder to merge in the future.

if (window.performance && window.performance.now) {
getTimestamp = function getTimestamp() {
// truncate any partial millisecond
return window.performance.now() | 0
}
} else {
const initTime = +new Date()
getTimestamp = function getTimestamp() {
return new Date() - initTime
}
}

function decorateLog(args, prefix) {
args = [].slice.call(args);
prefix && args.unshift(prefix);
args.unshift(getTimestamp())
args.unshift('display: inline-block; color: #fff; background: #3b88c3; padding: 1px 4px; border-radius: 3px;');
args.unshift('%cPrebid');
return args;
Expand Down
101 changes: 94 additions & 7 deletions test/spec/modules/mavenDistributionAnalyticsAdapter_spec.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,92 @@
import { createSendOptionsFromBatch, summarizeAuctionEnd } from '../../../modules/mavenDistributionAnalyticsAdapter.js';
import { createSendOptionsFromBatch, summarizeAuctionEnd, testables } from '../../../modules/mavenDistributionAnalyticsAdapter.js';

var assert = require('assert');

describe('MavenDistributionAnalyticsAdapter', function () {
describe('LiftIgniterWrapper', () => {
let addEventListener, removeEventListener
const domContentLoadListeners = []
const que = []
beforeEach(() => {
window.$p = null
addEventListener = sinon.stub(document, 'addEventListener').callsFake((type, fn) => {
if (type !== 'DOMContentLoaded') {
throw Error(`Unsupported type for stub; expected DOMContentLoaded; got ${type}`)
}
domContentLoadListeners.push(fn)
});
removeEventListener = sinon.stub(document, 'removeEventListener').callsFake((type, fn) => {
if (type !== 'DOMContentLoaded') {
throw Error(`Unsupported type for stub; expected DOMContentLoaded; got ${type}`)
}
for (let i = domContentLoadListeners.length; i-- > 0;) {
if (domContentLoadListeners[i] === fn) {
domContentLoadListeners.splice(i, 1)
}
}
});
})
afterEach(() => {
addEventListener.restore()
removeEventListener.restore()
domContentLoadListeners.length = 0
window.$p = null
que.length = 0
})
function defineDollarPStub() {
window.$p = (...args) => que.push(args)
}
function defineDollarPReal() {
window.$p = (name, arg) => {
if (name !== 'onload') {
throw new Error(`Only onload is supported`)
}
arg()
}
for (let i = 0; i < que.length; i++) {
window.$p.apply(window, que[i])
}
que.length = 0
}
it('should check for existence of $p and $p(onload) each time checkIsLoaded is called', () => {
const liftIgniterWrapper = new testables.LiftIgniterWrapper()
assert.equal(liftIgniterWrapper.checkIsLoaded(), false)
assert.equal(liftIgniterWrapper._state, 'wait-for-$p-to-be-defined')
defineDollarPStub()
assert.equal(liftIgniterWrapper.checkIsLoaded(), false)
assert.equal(liftIgniterWrapper._state, 'waiting-$p(\'onload\')')
assert.equal(que.length, 1)
const [firstCommand, cb] = que[0]
assert.equal(firstCommand, 'onload')
cb()
assert.equal(liftIgniterWrapper._state, 'loaded')
assert.equal(liftIgniterWrapper.checkIsLoaded(), true)
})
it('should check $p and $p(onload) when there is a DOMContentLoad event', () => {
assert.equal(window.$p, null)
const liftIgniterWrapper = new testables.LiftIgniterWrapper()
const listener = domContentLoadListeners[0]
assert.notEqual(listener, null)
assert.equal(liftIgniterWrapper._state, 'wait-for-$p-to-be-defined')
defineDollarPStub()

listener()
assert.equal(liftIgniterWrapper._state, 'waiting-$p(\'onload\')')
assert.equal(que.length, 1)
assert.equal(domContentLoadListeners.length, 0, 'should removeListener after $p is defined')
const [firstCommand, cb] = que[0]
assert.equal(firstCommand, 'onload')
cb()
assert.equal(liftIgniterWrapper._state, 'loaded')
assert.equal(liftIgniterWrapper.checkIsLoaded(), true)
})
it('should work with LiftIgniter already loaded (synchronous $p(onload))', () => {
defineDollarPReal()
const liftIgniterWrapper = new testables.LiftIgniterWrapper()
assert.equal(liftIgniterWrapper._state, 'loaded')
assert.equal(liftIgniterWrapper.checkIsLoaded(), true)
})
})
describe('summarizeAuctionEnd', function () {
it('should summarize', () => {
const args = {
Expand Down Expand Up @@ -795,7 +879,7 @@ describe('MavenDistributionAnalyticsAdapter', function () {
const actualSummary = summarizeAuctionEnd(args, adapterConfig)
const expectedSummary = {
'auc': 'e0a2febe-dc05-4999-87ed-4c40022b6796',
cpms: [0],
cpmms: [0],
zoneIndexes: [0],
zoneNames: ['fixed_bottom'],
}
Expand Down Expand Up @@ -2175,21 +2259,24 @@ describe('MavenDistributionAnalyticsAdapter', function () {
const actual = summarizeAuctionEnd(mavenArgs, adapterConfig)
const expected = {
auc: 'd01409e4-580d-4107-8d92-3c5dec19b41a',
cpms: [ 2.604162 ],
cpmms: [ 2604 ],
codes: [ 'gpt-slot-channel-banner-top' ],
}
assert.deepEqual(actual, expected)
})
});
describe('createSendOptionsFromBatch', () => {
it('should create batch json', () => {
const actual = createSendOptionsFromBatch({
const actual = createSendOptionsFromBatch([{
auc: 'aaa',
cpms: [0.04],
cpmms: [40],
zoneIndexes: [3],
zoneNames: ['sidebar']
})
const expected = {batch: '{"auc":"aaa","cpms":[0.04],"zoneIndexes":[3],"zoneNames":["sidebar"]}'}
}])
const expected = {
batch: '[{"auc":"aaa","cpmms":[40],"zoneIndexes":[3],"zoneNames":["sidebar"]}]',
price: 40,
}
assert.deepEqual(actual, expected)
})
})
Expand Down