From bd87b46fe3aa2167ea56902b9e965ca73e6af91c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Glawaty?= Date: Tue, 1 Oct 2024 05:07:17 +0200 Subject: [PATCH] Metric events renaming - added configuration options `metrics.events` and `metrics.params` - removed configuration option `metrics.disabledEvents` - events can be now disabled also via the option `metrics.events` - updated docs - updated changelog --- CHANGELOG.md | 4 + docs/integration-guide.md | 24 +++- src/client/embed/client.mjs | 6 +- src/client/embed/config.mjs | 17 +++ src/client/standard/client.mjs | 7 +- src/client/standard/config.mjs | 15 +- src/frame/banner-frame-messenger.mjs | 2 +- src/metrics/events-config.mjs | 51 +++++++ src/metrics/metrics-events-listener.mjs | 180 ++++++++++++++++++++---- src/metrics/metrics-sender.mjs | 20 +-- 10 files changed, 264 insertions(+), 62 deletions(-) create mode 100644 src/metrics/events-config.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 942cc5b..247f755 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added support for banners closing. - HTML banners can be simply closed via any element with a data attribute `data-amp-banner-close=""`. +- Added the ability to rename events and their parameters using the `metrics.events` and `metrics.params` options. ### Changed - Changed arguments that are passed to the listeners `amp:banner:attached`, `amp:banner:state-changed`, `amp:fetch:error` and `amp:fetch:success`. Arguments are now passed as an object, so instead of `(banner) => {}` it is necessary to write `({ banner }) => {}`, respectively `({ response }) => {}` in case of events `amp:fetch:error` and `amp:fetch:success`. - Updated docs. +### Removed +- Removed the `metrics.disabledEvents` option. Events can now be disabled by putting `false` next to the event name in the `metrics.events` option. + ## [1.6.0] - 2024-09-19 ### Added - Added support for new banner option `fetchpriority`. diff --git a/docs/integration-guide.md b/docs/integration-guide.md index 859cf2d..4a35fcd 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -50,7 +50,8 @@ const AMPClient = AMPClientFactory.create({ | **interaction.intersectionRatioMap** | `object` | `{}` | No | The "map" of intersection ratios. Keys must be numeric and represents a number of pixels. The values must match the same criteria as the option `interaction.defaultIntersectionRatio`. If a banner does not have an equal or greater pixel count than any option, then `defaultIntersectionRatio` is used. | | **interaction.firstTimeSeenTimeout** | `integer` | `1000` | No | The value indicates, in milliseconds, how long the banner must be visible in the user's viewport before it is evaluated as having been seen for the first time. The minimum allowed value is 500. | | **metrics.receiver** | `null/string/function/array` | `null` | No | Metrics are sent to the selected receiver if the value is set. The value can be a custom function, or one of the following strings: `"plausible"`, `"gtag"`, `"gtm"` or `"debug"`. Alternatively, an array can be passed if we would like to send metrics to multiple receivers. For example, `["plausible", "gtag"]`. | -| **metrics.disabledEvents** | `array` | `[]` | No | Names of metric events that should not be sent. | +| **metrics.events** | `object` | `{}` | No | Used to rename metric events, or to disable them completely if `false` is specified instead of an event name. | +| **metrics.params** | `object` | `{}` | No | Used to rename metric event parameters. | | **closing.storage** | `string` | `"memoryStorage"` | No | The storage where information about banners closed by the user is stored. Allowed values are `memoryStorage` (default, banners are not closed permanently), `localStorage` and `sessionStorage`. | | **closing.key** | `string` | `"amp-closed-banners"` | No | The storage key under which information about closed banners is stored. | | **closing.maxItems** | `integer` | `500` | No | Maximum number of closed items (banners) in the storage. | @@ -81,7 +82,26 @@ const AMPClient = AMPClientFactory.create({ }, metrics: { receiver: 'gtm', - disabledEvents: ['amp:banner:loaded'], + events: { + 'amp:banner:loaded': 'BannerLoaded', + 'amp:banner:displayed': 'BannerDisplayed', + 'amp:banner:fully-displayed': false, + 'amp:banner:clicked': 'BannerClicked', + 'amp:banner:closed': 'BannerClosed', + }, + params: { + channel_code: 'amp_channelCode', + banner_id: 'amp_bannerId', + banner_name: 'amp_bannerName', + position_id: 'amp_positionId', + position_code: 'amp_positionCode', + position_name: 'amp_positionName', + campaign_id: 'amp_campaignId', + campaign_code: 'amp_campaignCode', + campaign_name: 'amp_campaignName', + breakpoint: 'amp_breakpoint', + link: 'amp_clickedLink', + } }, closing: { storage: 'localStorage', diff --git a/src/client/embed/client.mjs b/src/client/embed/client.mjs index bfc9194..66be5cf 100644 --- a/src/client/embed/client.mjs +++ b/src/client/embed/client.mjs @@ -7,8 +7,9 @@ import { ParentFrameMessenger } from '../../frame/parent-frame-messenger.mjs'; import { BannerInteractionWatcher } from '../../interaction/banner-interaction-watcher.mjs'; import { MetricsSender } from '../../metrics/metrics-sender.mjs'; import { MetricsEventsListener } from '../../metrics/metrics-events-listener.mjs'; +import { EventsConfig } from '../../metrics/events-config.mjs'; import { State } from '../../banner/state.mjs'; -import {ClosingManager} from "../../banner/closing/closing-manager.mjs"; +import { ClosingManager } from '../../banner/closing/closing-manager.mjs'; export class Client { #version; @@ -73,6 +74,7 @@ export class Client { this.#redrawBanners(); this.#bannerInteractionWatcher.start(); + this.#metricsEventsListener.attach(new EventsConfig(this.#extendedConfig.metrics)); }); this.#frameMessenger.on('windowResized', ({ data }) => { @@ -81,7 +83,7 @@ export class Client { }); this.#frameMessenger.listen(); - this.#metricsEventsListener.attach(); + this.#metricsEventsListener.collectBeforeAttach(); this.#closingManager.attachUi(); } diff --git a/src/client/embed/config.mjs b/src/client/embed/config.mjs index f144341..95d7c4a 100644 --- a/src/client/embed/config.mjs +++ b/src/client/embed/config.mjs @@ -23,6 +23,10 @@ export function createExtendedConfig(options) { intersectionRatioMap: {}, firstTimeSeenTimeout: 1000, }, + metrics: { + events: {}, + params: {}, + }, }, options); // interaction @@ -56,5 +60,18 @@ export function createExtendedConfig(options) { throw new Error(`The option "interaction.firstTimeSeenTimeout" must be a int with a minimum value of 500, "${config.interaction.firstTimeSeenTimeout}" passed.`); } + // metrics + if ('object' !== typeof config.metrics) { + throw new Error(`The option "metrics" must be an object of the format { events: object{ *: string|false }, params: object{ *: string } }, ${JSON.stringify(config.metrics)} passed.`); + } + + if ('object' !== typeof config.metrics.events) { + throw new Error(`The option "metrics.event" must be an object of the format { *: string|false }, ${JSON.stringify(config.metrics.events)} passed.`); + } + + if ('object' !== typeof config.metrics.params) { + throw new Error(`The option "metrics.params" must be an object of the format { *: string }, ${JSON.stringify(config.metrics.params)} passed.`); + } + return config; } diff --git a/src/client/standard/client.mjs b/src/client/standard/client.mjs index 72dd407..a8835b8 100644 --- a/src/client/standard/client.mjs +++ b/src/client/standard/client.mjs @@ -13,6 +13,7 @@ import { BannerRenderer } from '../../renderer/banner-renderer.mjs'; import { BannerInteractionWatcher } from '../../interaction/banner-interaction-watcher.mjs'; import { MetricsEventsListener } from '../../metrics/metrics-events-listener.mjs'; import { MetricsSender } from '../../metrics/metrics-sender.mjs'; +import { EventsConfig } from '../../metrics/events-config.mjs'; import { BannerFrameMessenger } from '../../frame/banner-frame-messenger.mjs'; import { getHtmlElement } from '../../utils/dom-helpers.mjs'; @@ -68,7 +69,6 @@ export class Client { this.#metricsSender = MetricsSender.createFromReceivers( options.metrics.receiver, - options.metrics.disabledEvents, ); this.#metricsEventsListener = new MetricsEventsListener( this.#metricsSender, @@ -119,7 +119,10 @@ export class Client { }); this.#frameMessenger.listen(); - this.#metricsEventsListener.attach(); + this.#metricsEventsListener.attach(new EventsConfig({ + events: options.metrics.events, + params: options.metrics.params, + })); this.#bannerInteractionWatcher.start(); this.#closingManager.attachUi(); } diff --git a/src/client/standard/config.mjs b/src/client/standard/config.mjs index ebb6097..bc84ea8 100644 --- a/src/client/standard/config.mjs +++ b/src/client/standard/config.mjs @@ -28,7 +28,8 @@ export function createConfig(options) { }, metrics: { receiver: null, - disabledEvents: [], + events: {}, + params: {}, }, closing: { storage: 'memoryStorage', @@ -118,7 +119,7 @@ export function createConfig(options) { // metrics if ('object' !== typeof config.metrics) { - throw new Error(`The option "metrics" must be an object of the format { receiver: null|string|function|array, disabledEvents: array }, ${config.metrics} passed.`); + throw new Error(`The option "metrics" must be an object of the format { receiver: null|string|function|array, events: object{ *: string|false }, params: object{ *: string } }, ${config.metrics} passed.`); } if (null !== config.metrics.receiver && -1 === ['string', 'function'].indexOf(typeof config.metrics.receiver) && !Array.isArray(config.metrics.receiver)) { @@ -135,14 +136,12 @@ export function createConfig(options) { config.metrics.receiver = null !== config.metrics.receiver ? [config.metrics.receiver] : []; } - if (!Array.isArray(config.metrics.disabledEvents)) { - throw new Error(`The option "metrics.disabledEvents" must an array of strings (event names), "${config.metrics.disabledEvents}" passed.`); + if ('object' !== typeof config.metrics.events) { + throw new Error(`The option "metrics.event" must be an object of the format { *: string|false }, ${JSON.stringify(config.metrics.events)} passed.`); } - for (let disabledEventIndex in config.metrics.disabledEvents) { - if ('string' !== typeof config.metrics.disabledEvents[disabledEventIndex]) { - throw new Error(`The option "metrics.disabledEvents.${disabledEventIndex}" must be a string, "${config.metrics.disabledEvents[disabledEventIndex]}" passed.`); - } + if ('object' !== typeof config.metrics.params) { + throw new Error(`The option "metrics.params" must be an object of the format { *: string }, ${JSON.stringify(config.metrics.params)} passed.`); } // closing diff --git a/src/frame/banner-frame-messenger.mjs b/src/frame/banner-frame-messenger.mjs index d0cc606..3a6fceb 100644 --- a/src/frame/banner-frame-messenger.mjs +++ b/src/frame/banner-frame-messenger.mjs @@ -123,7 +123,7 @@ export class BannerFrameMessenger extends FrameMessenger { #onMetricsMessage({ data }) { const { eventName, eventArgs } = data; - if (this.#metricsSender.hasAnyReceiver() && this.#metricsSender.isEventEnabled(eventName)) { + if (this.#metricsSender.hasAnyReceiver()) { this.#metricsSender.send(eventName, eventArgs); } } diff --git a/src/metrics/events-config.mjs b/src/metrics/events-config.mjs new file mode 100644 index 0000000..c64ee9f --- /dev/null +++ b/src/metrics/events-config.mjs @@ -0,0 +1,51 @@ +import { Events } from './events.mjs'; + +export class EventsConfig { + constructor(config) { + const events = {}; + events[Events.BANNER_LOADED] = Events.BANNER_LOADED; + events[Events.BANNER_DISPLAYED] = Events.BANNER_DISPLAYED; + events[Events.BANNER_FULLY_DISPLAYED] = Events.BANNER_FULLY_DISPLAYED; + events[Events.BANNER_CLICKED] = Events.BANNER_CLICKED; + events[Events.BANNER_CLOSED] = Events.BANNER_CLOSED; + + const params = { + channel_code: 'channel_code', + banner_id: 'banner_id', + banner_name: 'banner_name', + position_id: 'position_id', + position_code: 'position_code', + position_name: 'position_name', + campaign_id: 'campaign_id', + campaign_code: 'campaign_code', + campaign_name: 'campaign_name', + breakpoint: 'breakpoint', + link: 'link', + }; + + if ('events' in config) { + for (let eventKey in config.events) { + const eventValue = config.events[eventKey]; + + if (true === eventValue || !(eventKey in events)) { + continue; + } + + if (false === eventValue || 'string' === typeof eventValue) { + events[eventKey] = eventValue; + } + } + } + + if ('params' in config) { + for (let paramKey in config.params) { + if (paramKey in params && 'string' === typeof config.params[paramKey]) { + params[paramKey] = config.params[paramKey]; + } + } + } + + this.events = events; + this.params = params; + } +} diff --git a/src/metrics/metrics-events-listener.mjs b/src/metrics/metrics-events-listener.mjs index 9b2b7b7..203fa63 100644 --- a/src/metrics/metrics-events-listener.mjs +++ b/src/metrics/metrics-events-listener.mjs @@ -1,4 +1,5 @@ import { Events as MetricsEvents } from './events.mjs'; +import { EventsConfig } from './events-config.mjs'; import { Events } from '../event/events.mjs'; import { State } from '../banner/state.mjs'; @@ -7,6 +8,11 @@ export class MetricsEventsListener { #eventBus; #channelCode; #attached; + #beforeAttachedQueue = { + started: false, + events: [], + cleanup: [], + }; /** * @param {MetricsSender} metricsSender @@ -20,7 +26,75 @@ export class MetricsEventsListener { this.#attached = false; } - attach() { + collectBeforeAttach() { + if (this.#beforeAttachedQueue.started) { + return; + } + + this.#beforeAttachedQueue.started = true; + + const eventBus = this.#eventBus; + const config = new EventsConfig({}); + + this.#beforeAttachedQueue.cleanup.push( + eventBus.subscribe(Events.ON_BANNER_STATE_CHANGED, ({ banner }) => { + if (banner.isEmbed() || State.RENDERED !== banner.state || 1 !== banner.stateCounter) { + return; + } + + for (let fingerprint of banner.fingerprints) { + this.#beforeAttachedQueue.events.push({ + name: MetricsEvents.BANNER_LOADED, + params: this.#createBaseMetricsParams({ config, fingerprint, banner }), + }); + } + }), + ); + + this.#beforeAttachedQueue.cleanup.push( + eventBus.subscribe(Events.ON_BANNER_FIRST_TIME_SEEN, ({ fingerprint, banner }) => { + this.#beforeAttachedQueue.events.push({ + name: MetricsEvents.BANNER_DISPLAYED, + params: this.#createBaseMetricsParams({ config, fingerprint, banner }), + }); + }), + ); + + this.#beforeAttachedQueue.cleanup.push( + eventBus.subscribe(Events.ON_BANNER_FIRST_TIME_FULLY_SEEN, ({ fingerprint, banner }) => { + this.#beforeAttachedQueue.events.push({ + name: MetricsEvents.BANNER_FULLY_DISPLAYED, + params: this.#createBaseMetricsParams({ config, fingerprint, banner }), + }); + }), + ); + + this.#beforeAttachedQueue.cleanup.push( + eventBus.subscribe(Events.ON_BANNER_LINK_CLICKED, ({ fingerprint, banner, target }) => { + const params = this.#createBaseMetricsParams({ config, fingerprint, banner }); + params[config.params.link] = target.href || ''; + + this.#beforeAttachedQueue.events.push({ + name: MetricsEvents.BANNER_CLICKED, + params: params, + }); + }), + ); + + this.#beforeAttachedQueue.cleanup.push( + eventBus.subscribe(Events.ON_BANNER_AFTER_CLOSE, ({ fingerprint, banner }) => { + this.#beforeAttachedQueue.events.push({ + name: MetricsEvents.BANNER_CLOSED, + params: this.#createBaseMetricsParams({ config, fingerprint, banner }), + }); + }), + ); + } + + /** + * @param {EventsConfig} config + */ + attach(config) { if (this.#attached) { return; } @@ -28,68 +102,112 @@ export class MetricsEventsListener { this.#attached = true; const eventBus = this.#eventBus; - const channelCode = this.#channelCode; const metricsSender = this.#metricsSender; if (!metricsSender.hasAnyReceiver()) { + this.#clearBeforeAttachedQueue(); + return; } - const createBaseMetricsArgs = (fingerprint, banner) => { - let breakpoint = banner.getCurrentBreakpoint(fingerprint.bannerId); - breakpoint = null === breakpoint ? 'default' : `${'min' === banner.positionData.breakpointType ? '>=' : '<='}${breakpoint}`; - - return { - channel_code: channelCode, - banner_id: fingerprint.bannerId, - banner_name: fingerprint.bannerName, - position_id: fingerprint.positionId, - position_code: fingerprint.positionCode, - position_name: fingerprint.positionName, - campaign_id: fingerprint.campaignId, - campaign_code: fingerprint.campaignCode, - campaign_name: fingerprint.campaignName, - breakpoint: breakpoint, + const bannerLoadedEventName = config.events[MetricsEvents.BANNER_LOADED]; + const bannerDisplayedEventName = config.events[MetricsEvents.BANNER_DISPLAYED]; + const bannerFullyDisplayedEventName = config.events[MetricsEvents.BANNER_FULLY_DISPLAYED]; + const bannerClickedEventName = config.events[MetricsEvents.BANNER_CLICKED]; + const bannerClosedEventName = config.events[MetricsEvents.BANNER_CLOSED]; + + if (this.#beforeAttachedQueue.started) { + for (let i in this.#beforeAttachedQueue.events) { + const { name, params } = this.#beforeAttachedQueue.events[i]; + + if (false === config.events[name]) { + continue; + } + + const mappedParams = {}; + + for (let paramKey in params) { + mappedParams[config.params[paramKey]] = params[paramKey]; + } + + metricsSender.send(config.events[name], mappedParams); } - }; - if (metricsSender.isEventEnabled(MetricsEvents.BANNER_LOADED)) { + this.#clearBeforeAttachedQueue(); + } + + if (false !== bannerLoadedEventName) { eventBus.subscribe(Events.ON_BANNER_STATE_CHANGED, ({ banner }) => { if (banner.isEmbed() || State.RENDERED !== banner.state || 1 !== banner.stateCounter) { return; } for (let fingerprint of banner.fingerprints) { - metricsSender.send(MetricsEvents.BANNER_LOADED, createBaseMetricsArgs(fingerprint, banner)); + metricsSender.send(bannerLoadedEventName, this.#createBaseMetricsParams({ config, fingerprint, banner })); } }); } - if (metricsSender.isEventEnabled(MetricsEvents.BANNER_DISPLAYED)) { + if (false !== bannerDisplayedEventName) { eventBus.subscribe(Events.ON_BANNER_FIRST_TIME_SEEN, ({ fingerprint, banner }) => { - metricsSender.send(MetricsEvents.BANNER_DISPLAYED, createBaseMetricsArgs(fingerprint, banner)); + metricsSender.send(bannerDisplayedEventName, this.#createBaseMetricsParams({ config, fingerprint, banner })); }); } - if (metricsSender.isEventEnabled(MetricsEvents.BANNER_FULLY_DISPLAYED)) { + if (false !== bannerFullyDisplayedEventName) { eventBus.subscribe(Events.ON_BANNER_FIRST_TIME_FULLY_SEEN, ({ fingerprint, banner }) => { - metricsSender.send(MetricsEvents.BANNER_FULLY_DISPLAYED, createBaseMetricsArgs(fingerprint, banner)); + metricsSender.send(bannerFullyDisplayedEventName, this.#createBaseMetricsParams({ config, fingerprint, banner })); }); } - if (metricsSender.isEventEnabled(MetricsEvents.BANNER_CLICKED)) { + if (false !== bannerClickedEventName) { eventBus.subscribe(Events.ON_BANNER_LINK_CLICKED, ({ fingerprint, banner, target }) => { - metricsSender.send(MetricsEvents.BANNER_CLICKED, { - ...createBaseMetricsArgs(fingerprint, banner), - link: target.href || '', - }) + const params = this.#createBaseMetricsParams({ config, fingerprint, banner }); + params[config.params.link] = target.href || ''; + + metricsSender.send(bannerClickedEventName, params); }); } - if (metricsSender.isEventEnabled(MetricsEvents.BANNER_CLOSED)) { + if (false !== bannerClosedEventName) { eventBus.subscribe(Events.ON_BANNER_AFTER_CLOSE, ({ fingerprint, banner }) => { - metricsSender.send(MetricsEvents.BANNER_CLOSED, createBaseMetricsArgs(fingerprint, banner)); + metricsSender.send(bannerClosedEventName, this.#createBaseMetricsParams({ config, fingerprint, banner })); }); } } + + /** + * @param {EventsConfig} config + * @param {Fingerprint} fingerprint + * @param {Banner} banner + * + * @return {Object} + */ + #createBaseMetricsParams = ({ config, fingerprint, banner }) => { + let breakpoint = banner.getCurrentBreakpoint(fingerprint.bannerId); + breakpoint = null === breakpoint ? 'default' : `${'min' === banner.positionData.breakpointType ? '>=' : '<='}${breakpoint}`; + + const params = {}; + params[config.params.channel_code] = this.#channelCode; + params[config.params.banner_id] = fingerprint.bannerId; + params[config.params.banner_name] = fingerprint.bannerName; + params[config.params.position_id] = fingerprint.positionId; + params[config.params.position_code] = fingerprint.positionCode; + params[config.params.position_name] = fingerprint.positionName; + params[config.params.campaign_id] = fingerprint.campaignId; + params[config.params.campaign_code] = fingerprint.campaignCode; + params[config.params.campaign_name] = fingerprint.campaignName; + params[config.params.breakpoint] = breakpoint; + + return params; + }; + + #clearBeforeAttachedQueue() { + for (let i in this.#beforeAttachedQueue.cleanup) { + this.#beforeAttachedQueue.cleanup[i](); + } + + this.#beforeAttachedQueue.events = []; + this.#beforeAttachedQueue.cleanup = []; + } } diff --git a/src/metrics/metrics-sender.mjs b/src/metrics/metrics-sender.mjs index 855154d..61d3b64 100644 --- a/src/metrics/metrics-sender.mjs +++ b/src/metrics/metrics-sender.mjs @@ -1,4 +1,3 @@ -import { Events as MetricsEvents } from './events.mjs'; import { default as plausibleReceiver } from './plausible-receiver.mjs'; import { default as gtagReceiver } from './gtag-receiver.mjs'; import { default as gtmReceiver } from './gtm-receiver.mjs'; @@ -6,20 +5,17 @@ import { default as debugReceiver } from './debug-receiver.mjs'; export class MetricsSender { #callbacks; - #disabledEvents; /** * @param {Array>} callbacks - * @param {Array} disabledEvents */ - constructor(callbacks, disabledEvents) { + constructor(callbacks) { this.#callbacks = Array.isArray(callbacks) ? callbacks : [callbacks]; - this.#disabledEvents = disabledEvents; } - static createFromReceivers(receivers, disabledEvents) { + static createFromReceivers(receivers) { if (!receivers) { - return new MetricsSender([], disabledEvents); + return new MetricsSender([]); } receivers = Array.isArray(receivers) ? receivers : [receivers]; @@ -49,22 +45,14 @@ export class MetricsSender { } } - return new MetricsSender(callbacks, disabledEvents); + return new MetricsSender(callbacks); } hasAnyReceiver() { return this.#callbacks.length; } - isEventEnabled(eventName) { - return -1 !== MetricsEvents.EVENTS.indexOf(eventName) && -1 === this.#disabledEvents.indexOf(eventName); - } - send(eventName, eventArgs) { - if (!this.isEventEnabled(eventName)) { - return; - } - for (let callback of this.#callbacks) { callback(eventName, eventArgs); }