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

Yandex Analytics Adapter: initial release #10876

Merged
merged 12 commits into from
Jan 30, 2024
154 changes: 154 additions & 0 deletions modules/yandexAnalyticsAdapter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import buildAdapter from '../libraries/analyticsAdapter/AnalyticsAdapter.js';
import adapterManager from '../src/adapterManager.js';
import { logError, logInfo } from '../src/utils.js';
import CONSTANTS from '../src/constants.json';
import * as events from '../src/events.js';

const timeoutIds = {};
const tryUntil = (operationId, conditionCb, cb) => {
if (!conditionCb()) {
cb();
timeoutIds[operationId] = setTimeout(
() => tryUntil(conditionCb, conditionCb, cb),
100
);
}
};

const clearTryUntilTimeouts = (timeouts) => {
timeouts.forEach((timeoutID) => {
if (timeoutIds[timeoutID]) {
clearTimeout(timeoutIds[timeoutID]);
}
});
};

const SEND_EVENTS_BUNDLE_TIMEOUT = 1500;
const {
BID_REQUESTED,
BID_RESPONSE,
BID_ADJUSTMENT,
BID_WON,
BIDDER_DONE,
AUCTION_END,
BID_TIMEOUT,
} = CONSTANTS.EVENTS;

export const EVENTS_TO_TRACK = [
BID_REQUESTED,
BID_RESPONSE,
BID_ADJUSTMENT,
BID_WON,
BIDDER_DONE,
AUCTION_END,
BID_TIMEOUT,
Copy link
Contributor

Choose a reason for hiding this comment

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

Wouldn't these all be undefined since they're declared inside the EVENTS object above?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

There would be ["bidRequested", "bidResponse", "bidAdjustment", ...]. lines 42-52 have destruction syntax, so for example BID_REQUESTED will be CONSTANTS.EVENTS.BID_REQUESTED, that is "bidRequested" as defined here and so on

];

const yandexAnalytics = Object.assign(buildAdapter({ analyticsType: 'endpoint' }), {
bufferedEvents: [],
initTimeoutId: 0,
counters: {},
counterInitTimeouts: {},
oneCounterInited: false,

onEvent: (eventName, eventData) => {
const innerEvent = {
event: eventName,
data: eventData,
};
yandexAnalytics.bufferedEvents.push(innerEvent);
},

sendEvents: () => {
if (yandexAnalytics.bufferedEvents.length) {
const data = yandexAnalytics.bufferedEvents.splice(
0,
yandexAnalytics.bufferedEvents.length
);

Object.keys(yandexAnalytics.counters).forEach((counterId) => {
yandexAnalytics.counters[counterId].pbjs(data);
});
}
setTimeout(yandexAnalytics.sendEvents, SEND_EVENTS_BUNDLE_TIMEOUT);
},

onCounterInit: (counterId) => {
yandexAnalytics.counters[counterId] = window[`yaCounter${counterId}`];
logInfo(`Found metrika counter ${counterId}`);
if (!yandexAnalytics.oneCounterInited) {
yandexAnalytics.oneCounterInited = true;
setTimeout(() => {
yandexAnalytics.sendEvents();
}, SEND_EVENTS_BUNDLE_TIMEOUT);
clearTimeout(yandexAnalytics.initTimeoutId);
}
},

enableAnalytics: (config) => {
yandexAnalytics.options = (config && config.options) || {};
const { counters } = yandexAnalytics.options || {};
const validCounters = counters.filter((counterId) => {
if (!counterId) {
return false;
}

if (isNaN(counterId)) {
return false;
}

return true;
});

if (!validCounters.length) {
logError('options.counters contains no valid counter ids');
return;
}

const unsubscribeCallbacks = [
() => clearTryUntilTimeouts(['countersInit']),
];

yandexAnalytics.initTimeoutId = setTimeout(() => {
yandexAnalytics.bufferedEvents = [];
unsubscribeCallbacks.forEach((cb) => cb());
logError(`Can't find metrika counter after 25 seconds.`);
logError('Aborting yandex analytics provider initialization.');
}, 25000);

events.getEvents().forEach((event) => {
if (event && EVENTS_TO_TRACK.indexOf(event.eventType) >= 0) {
yandexAnalytics.onEvent(event.eventType, event);
}
});

EVENTS_TO_TRACK.forEach((eventName) => {
const eventCallback = yandexAnalytics.onEvent.bind(null, eventName);
unsubscribeCallbacks.push(() => events.off(eventName, eventCallback));
events.on(eventName, eventCallback);
});

let allCountersInited = false;
tryUntil('countersInit', () => allCountersInited, () => {
allCountersInited = validCounters.reduce((result, counterId) => {
if (yandexAnalytics.counters[counterId]) {
return result && true;
}

if (window[`yaCounter${counterId}`]) {
yandexAnalytics.onCounterInit(counterId);
return result && true;
}

return false;
}, true);
});
},
});

adapterManager.registerAnalyticsAdapter({
adapter: yandexAnalytics,
code: 'yandexAnalytics'
});

export default yandexAnalytics;
36 changes: 36 additions & 0 deletions modules/yandexAnalyticsAdapter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Overview

```
Module Name: Yandex Analytics Adapter
Module Type: Analytics Adapter
Maintainer: prebid@yandex-team.com
```

# Description

This adapter is designed to work with [Yandex Metrica](https://metrica.yandex.com/about) - Top-5 worldwide web analytics tool.

Disclosure: provider use Metrica Tag build based on https://github.com/yandex/metrica-tag, ~60 kB gzipped.

## How to setup provider

Register your application on https://metrica.yandex.com/ and get counter id.
Insert counter initialization code obtained from the page https://metrica.yandex.com/settings?id={counterId} into your html code.
Init provider like this, where `123` is your counter id.

Note: If you have Single Page Application (SPA), [configure your tag](https://yandex.com/support/metrica/code/counter-spa-setup.html).

```javascript
pbjs.enableAnalytics({
provider: 'yandexAnalytics',
options: {
counters: [
123,
],
},
});
```

## Where to find data

Go to https://metrika.yandex.com/dashboard -> Prebid Analytics
147 changes: 147 additions & 0 deletions test/spec/modules/yandexAnalyticsAdapter_spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import * as sinon from 'sinon';
import yandexAnalytics, { EVENTS_TO_TRACK } from 'modules/yandexAnalyticsAdapter.js';
import * as log from '../../../src/utils.js'
import * as events from '../../../src/events.js';

describe('Yandex analytics adapter testing', () => {
const sandbox = sinon.createSandbox();
let clock;
let logError;
let getEvents;
let onEvent;
const counterId = 123;
const counterWindowKey = 'yaCounter123';

beforeEach(() => {
yandexAnalytics.counters = {};
yandexAnalytics.counterInitTimeouts = {};
yandexAnalytics.bufferedEvents = [];
yandexAnalytics.oneCounterInited = false;
clock = sinon.useFakeTimers();
logError = sandbox.stub(log, 'logError');
sandbox.stub(log, 'logInfo');
getEvents = sandbox.stub(events, 'getEvents').returns([]);
onEvent = sandbox.stub(events, 'on');
sandbox.stub(window.document, 'createElement').callsFake((tag) => {
const element = {
tag,
events: {},
attributes: {},
addEventListener: (event, cb) => {
element.events[event] = cb;
},
removeEventListener: (event, cb) => {
chai.expect(element.events[event]).to.equal(cb);
},
setAttribute: (attr, val) => {
element.attributes[attr] = val;
},
};
return element;
});
});

afterEach(() => {
window.Ya = null;
window[counterWindowKey] = null;
sandbox.restore();
clock.restore();
});

it('fails if timeout for counter insertion is exceeded', () => {
yandexAnalytics.enableAnalytics({
options: {
counters: [
123,
],
},
});
clock.tick(25001);
chai.expect(yandexAnalytics.bufferedEvents).to.deep.equal([]);
sinon.assert.calledWith(logError, `Can't find metrika counter after 25 seconds.`);
sinon.assert.calledWith(logError, `Aborting yandex analytics provider initialization.`);
});

it('fails if no valid counters provided', () => {
yandexAnalytics.enableAnalytics({
options: {
counters: [
'abc',
],
},
});
sinon.assert.calledWith(logError, 'options.counters contains no valid counter ids');
});

it('subscribes to events if counter is already present', () => {
window[counterWindowKey] = {
pbjs: sandbox.stub(),
};

getEvents.returns([
{
eventType: EVENTS_TO_TRACK[0],
},
{
eventType: 'Some_untracked_event',
}
]);
const eventsToSend = [{
event: EVENTS_TO_TRACK[0],
data: {
eventType: EVENTS_TO_TRACK[0],
}
}];

yandexAnalytics.enableAnalytics({
options: {
counters: [
counterId,
],
},
});

EVENTS_TO_TRACK.forEach((eventName, i) => {
const [event, callback] = onEvent.getCall(i).args;
chai.expect(event).to.equal(eventName);
callback(i);
eventsToSend.push({
event: eventName,
data: i,
});
});

clock.tick(1501);

const [ sentEvents ] = window[counterWindowKey].pbjs.getCall(0).args;
chai.expect(sentEvents).to.deep.equal(eventsToSend);
});

it('waits for counter initialization', () => {
window.Ya = {};
// Simulatin metrika script initialization
yandexAnalytics.enableAnalytics({
options: {
counters: [
counterId,
],
},
});

// Sending event
const [event, eventCallback] = onEvent.getCall(0).args;
eventCallback({});

const counterPbjsMethod = sandbox.stub();
window[`yaCounter${counterId}`] = {
pbjs: counterPbjsMethod,
};
clock.tick(2001);

const [ sentEvents ] = counterPbjsMethod.getCall(0).args;
chai.expect(sentEvents).to.deep.equal([{
event,
data: {},
}]);
});
});