@dotcms/events 0.0.0-stage → 26.10.5-1-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +516 -2
- package/decision.cjs.js +108 -0
- package/decision.esm.js +92 -0
- package/index.cjs.default.js +1 -0
- package/index.cjs.js +3674 -0
- package/index.cjs.mjs +2 -0
- package/index.d.ts +1 -0
- package/index.esm.js +3672 -0
- package/markup.cjs.default.js +1 -0
- package/markup.cjs.js +161 -0
- package/markup.cjs.mjs +2 -0
- package/markup.d.ts +1 -0
- package/markup.esm.js +159 -0
- package/package.json +77 -4
- package/react.cjs.default.js +1 -0
- package/react.cjs.js +39 -0
- package/react.cjs.mjs +2 -0
- package/react.d.ts +1 -0
- package/react.esm.js +37 -0
- package/src/index.d.ts +8 -0
- package/src/lib/clicks/constants.d.ts +14 -0
- package/src/lib/clicks/plugin.d.ts +35 -0
- package/src/lib/clicks/tracker.d.ts +42 -0
- package/src/lib/clicks/utils.d.ts +13 -0
- package/src/lib/contentlets/constants.d.ts +36 -0
- package/src/lib/contentlets/utils.d.ts +64 -0
- package/src/lib/contentlets/viewport.d.ts +23 -0
- package/src/lib/events.d.ts +17 -0
- package/src/lib/experiments/api.d.ts +9 -0
- package/src/lib/experiments/boot.d.ts +53 -0
- package/src/lib/experiments/constants.d.ts +46 -0
- package/src/lib/experiments/decision.d.ts +53 -0
- package/src/lib/experiments/dom.d.ts +62 -0
- package/src/lib/experiments/engine.d.ts +66 -0
- package/src/lib/experiments/markup.d.ts +17 -0
- package/src/lib/experiments/models.d.ts +62 -0
- package/src/lib/experiments/plugin.d.ts +27 -0
- package/src/lib/experiments/store.d.ts +54 -0
- package/src/lib/impressions/constants.d.ts +28 -0
- package/src/lib/impressions/plugin.d.ts +38 -0
- package/src/lib/impressions/tracker.d.ts +67 -0
- package/src/lib/impressions/utils.d.ts +10 -0
- package/src/lib/models.d.ts +130 -0
- package/src/lib/pipeline/constants.d.ts +88 -0
- package/src/lib/pipeline/enricher/plugin.d.ts +32 -0
- package/src/lib/pipeline/identity/activity.d.ts +19 -0
- package/src/lib/pipeline/identity/plugin.d.ts +60 -0
- package/src/lib/pipeline/logger.d.ts +74 -0
- package/src/lib/pipeline/models.d.ts +588 -0
- package/src/lib/pipeline/navigation.d.ts +21 -0
- package/src/lib/pipeline/sender/http.d.ts +9 -0
- package/src/lib/pipeline/sender/plugin.d.ts +47 -0
- package/src/lib/pipeline/sender/queue.d.ts +33 -0
- package/src/lib/pipeline/utils.d.ts +119 -0
- package/src/lib/react/DotCMSExperiment.d.ts +36 -0
- package/src/markup.d.ts +7 -0
- package/src/react.d.ts +8 -0
package/index.cjs.js
ADDED
|
@@ -0,0 +1,3674 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var uve = require('@dotcms/uve');
|
|
4
|
+
var decision = require('./decision.cjs.js');
|
|
5
|
+
var onRouteChange = require('@analytics/router-utils');
|
|
6
|
+
var smartQueue = require('@analytics/queue-utils');
|
|
7
|
+
|
|
8
|
+
/*! *****************************************************************************
|
|
9
|
+
Copyright (c) Microsoft Corporation.
|
|
10
|
+
|
|
11
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
12
|
+
purpose with or without fee is hereby granted.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
15
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
16
|
+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
17
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
18
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
19
|
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
20
|
+
PERFORMANCE OF THIS SOFTWARE.
|
|
21
|
+
***************************************************************************** */
|
|
22
|
+
/* global Reflect, Promise */
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
function __classPrivateFieldGet(receiver, state, kind, f) {
|
|
26
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
|
|
27
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
|
|
28
|
+
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function __classPrivateFieldSet(receiver, state, value, kind, f) {
|
|
32
|
+
if (kind === "m") throw new TypeError("Private method is not writable");
|
|
33
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
|
|
34
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
|
|
35
|
+
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/*
|
|
39
|
+
* Constants of the click tracker.
|
|
40
|
+
*/
|
|
41
|
+
/**
|
|
42
|
+
* Event type for content clicks
|
|
43
|
+
* Must match DotCMSPredefinedEventType.CONTENT_CLICK
|
|
44
|
+
*/
|
|
45
|
+
const CLICK_EVENT_TYPE = 'content_click';
|
|
46
|
+
/**
|
|
47
|
+
* Default debounce time in milliseconds for clicks
|
|
48
|
+
*/
|
|
49
|
+
const DEFAULT_CLICK_THROTTLE_MS = 300;
|
|
50
|
+
/**
|
|
51
|
+
* CSS selector for clickable elements to track
|
|
52
|
+
* Only clicks on <a> and <button> elements are tracked
|
|
53
|
+
*/
|
|
54
|
+
const CLICKABLE_ELEMENTS_SELECTOR = 'a, button';
|
|
55
|
+
|
|
56
|
+
/*
|
|
57
|
+
* Constants the content trackers share: how contentlets are found, and the rescan event.
|
|
58
|
+
*/
|
|
59
|
+
/**
|
|
60
|
+
* Default debounce time in milliseconds for MutationObserver
|
|
61
|
+
*/
|
|
62
|
+
const CONTENTLET_OBSERVER_DEBOUNCE_MS = 250;
|
|
63
|
+
/**
|
|
64
|
+
* CSS class selector for contentlet elements
|
|
65
|
+
*
|
|
66
|
+
* @important This constant is intentionally duplicated in @dotcms/react SDK
|
|
67
|
+
* (core-web/libs/sdk/react/src/lib/next/components/Contentlet/Contentlet.tsx), whose renderers
|
|
68
|
+
* print the class, and dotCMS prints it on traditional pages too. Both constants MUST have the
|
|
69
|
+
* same value ('dotcms-contentlet') for content tracking to find React-rendered contentlets.
|
|
70
|
+
*
|
|
71
|
+
* This duplication is intentional to keep the SDKs independent:
|
|
72
|
+
* - @dotcms/events works without React
|
|
73
|
+
* - @dotcms/react works without events
|
|
74
|
+
* - When both are used together, they must share the same class name
|
|
75
|
+
*
|
|
76
|
+
* If you need to change this value, you MUST update it in both locations:
|
|
77
|
+
* 1. This file (@dotcms/events)
|
|
78
|
+
* 2. Contentlet.tsx in @dotcms/react
|
|
79
|
+
*
|
|
80
|
+
* @see core-web/libs/sdk/react/src/lib/next/components/Contentlet/Contentlet.tsx
|
|
81
|
+
*/
|
|
82
|
+
const CONTENTLET_CLASS = 'dotcms-contentlet';
|
|
83
|
+
/**
|
|
84
|
+
* Attribute that carries a contentlet's identifier (`dataset['dotIdentifier']`). The page
|
|
85
|
+
* renderers print it only once analytics is active, after hydration, so it can land after
|
|
86
|
+
* the contentlet itself.
|
|
87
|
+
*/
|
|
88
|
+
const CONTENTLET_IDENTIFIER_ATTRIBUTE = 'data-dot-identifier';
|
|
89
|
+
/**
|
|
90
|
+
* Window event that asks the content trackers to scan for contentlets again. Sent when
|
|
91
|
+
* contentlets that were hidden become visible, which changes no DOM node the trackers watch
|
|
92
|
+
* (an experiment's rows are revealed through a style rule).
|
|
93
|
+
*/
|
|
94
|
+
const CONTENTLET_RESCAN_EVENT = 'dotcms:events:rescan';
|
|
95
|
+
|
|
96
|
+
/** The window property that traditional pages and plain scripts reach `dotEvents` through. */
|
|
97
|
+
const EVENTS_WINDOW_KEY = 'dotEvents';
|
|
98
|
+
/**
|
|
99
|
+
* The Analytics.js name of the sender plugin. The enricher keys its hooks with it
|
|
100
|
+
* (`page:<name>`, `track:<name>`), so they run for the sender's calls only.
|
|
101
|
+
*/
|
|
102
|
+
const SENDER_PLUGIN_NAME = 'dot-events-sender';
|
|
103
|
+
// Analytics endpoint for sending events to server
|
|
104
|
+
const ANALYTICS_ENDPOINT = '/api/v1/analytics/content/event';
|
|
105
|
+
/**
|
|
106
|
+
* The event types dotCMS accepts, each with its own schema. It rejects any other type, so the
|
|
107
|
+
* SDK sends no custom events.
|
|
108
|
+
*/
|
|
109
|
+
const DotCMSPredefinedEventType = {
|
|
110
|
+
PAGEVIEW: 'pageview',
|
|
111
|
+
CONTENT_IMPRESSION: 'content_impression',
|
|
112
|
+
CONTENT_CLICK: 'content_click',
|
|
113
|
+
CONVERSION: 'conversion'
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* Expected UTM parameter keys for campaign tracking
|
|
117
|
+
*/
|
|
118
|
+
const EXPECTED_UTM_KEYS = [
|
|
119
|
+
'utm_source',
|
|
120
|
+
'utm_medium',
|
|
121
|
+
'utm_campaign',
|
|
122
|
+
'utm_term',
|
|
123
|
+
'utm_content'
|
|
124
|
+
];
|
|
125
|
+
/**
|
|
126
|
+
* Session configuration constants
|
|
127
|
+
*/
|
|
128
|
+
const DEFAULT_SESSION_TIMEOUT_MINUTES = 30;
|
|
129
|
+
/**
|
|
130
|
+
* Session storage key for session ID
|
|
131
|
+
*/
|
|
132
|
+
const SESSION_STORAGE_KEY = 'dot_events_session_id';
|
|
133
|
+
/**
|
|
134
|
+
* User ID configuration constants
|
|
135
|
+
*/
|
|
136
|
+
const USER_ID_KEY = 'dot_events_user_id';
|
|
137
|
+
/**
|
|
138
|
+
* Default queue configuration batch size
|
|
139
|
+
*/
|
|
140
|
+
const DEFAULT_QUEUE_CONFIG_BATCH_SIZE = 15;
|
|
141
|
+
/**
|
|
142
|
+
* Default queue configuration flush interval
|
|
143
|
+
*/
|
|
144
|
+
const DEFAULT_QUEUE_CONFIG_FLUSH_INTERVAL = 5000;
|
|
145
|
+
/**
|
|
146
|
+
* Activity tracking configuration
|
|
147
|
+
* Events used to detect user activity for session management
|
|
148
|
+
* - click: Detects real user interaction with minimal performance impact
|
|
149
|
+
* - visibilitychange: Handled separately to detect tab changes
|
|
150
|
+
*/
|
|
151
|
+
const ACTIVITY_EVENTS = ['click'];
|
|
152
|
+
/**
|
|
153
|
+
* Default queue configuration for event batching
|
|
154
|
+
*/
|
|
155
|
+
const DEFAULT_QUEUE_CONFIG = {
|
|
156
|
+
eventBatchSize: DEFAULT_QUEUE_CONFIG_BATCH_SIZE, // Max events per batch - auto-sends when reached
|
|
157
|
+
flushInterval: DEFAULT_QUEUE_CONFIG_FLUSH_INTERVAL // Time between flushes - sends whatever is queued
|
|
158
|
+
};
|
|
159
|
+
/**
|
|
160
|
+
* Default properties that Analytics.js adds automatically
|
|
161
|
+
* These should be filtered out to only keep user-provided properties
|
|
162
|
+
*/
|
|
163
|
+
const ANALYTICS_JS_DEFAULT_PROPERTIES = [
|
|
164
|
+
'title',
|
|
165
|
+
'url',
|
|
166
|
+
'path',
|
|
167
|
+
'hash',
|
|
168
|
+
'search',
|
|
169
|
+
'width',
|
|
170
|
+
'height',
|
|
171
|
+
'referrer'
|
|
172
|
+
];
|
|
173
|
+
/**
|
|
174
|
+
* Window flag that says the SDK is active on the page. Not this package's name to change:
|
|
175
|
+
* `@dotcms/uve/internal` declares it too (`ANALYTICS_ACTIVE_WINDOW_KEY`), and the React, Vue
|
|
176
|
+
* and Angular SDKs read it to print the contentlet attributes impressions and clicks need.
|
|
177
|
+
*/
|
|
178
|
+
const ANALYTICS_WINDOWS_ACTIVE_KEY = '__dotAnalyticsActive__';
|
|
179
|
+
/**
|
|
180
|
+
* Window event sent once the SDK is active, so the renderers that read
|
|
181
|
+
* `ANALYTICS_WINDOWS_ACTIVE_KEY` render again. Shared with `@dotcms/uve/internal`
|
|
182
|
+
* (`ANALYTICS_READY_EVENT`) like that flag.
|
|
183
|
+
*/
|
|
184
|
+
const ANALYTICS_READY_EVENT = 'dotcms:analytics:ready';
|
|
185
|
+
/**
|
|
186
|
+
* Queue persistence configuration constants
|
|
187
|
+
* Used for storing events in sessionStorage for traditional page navigations
|
|
188
|
+
*/
|
|
189
|
+
/**
|
|
190
|
+
* Session storage key for persistent tab ID
|
|
191
|
+
* This ID remains constant across page navigations within the same browser tab
|
|
192
|
+
*/
|
|
193
|
+
const TAB_ID_STORAGE_KEY = 'dot_events_tab_id';
|
|
194
|
+
/**
|
|
195
|
+
* Prefix for queue storage key in sessionStorage
|
|
196
|
+
* Full key format: dot_events_queue_{tabId}
|
|
197
|
+
*/
|
|
198
|
+
const QUEUE_STORAGE_KEY_PREFIX = 'dot_events_queue';
|
|
199
|
+
/**
|
|
200
|
+
* Maximum age in milliseconds for persisted events
|
|
201
|
+
* Events older than this will be discarded (24 hours)
|
|
202
|
+
*/
|
|
203
|
+
const MAX_EVENT_AGE_MS = 24 * 60 * 60 * 1000;
|
|
204
|
+
|
|
205
|
+
var _DotLogger_packageName, _DotLogger_context, _DotLogger_minLevel;
|
|
206
|
+
/**
|
|
207
|
+
* Priority map for log level comparisons
|
|
208
|
+
* Lower numbers = more verbose, higher numbers = less verbose
|
|
209
|
+
*/
|
|
210
|
+
const LOG_LEVEL_PRIORITY = {
|
|
211
|
+
debug: 0,
|
|
212
|
+
info: 1,
|
|
213
|
+
warn: 2,
|
|
214
|
+
error: 3
|
|
215
|
+
};
|
|
216
|
+
/**
|
|
217
|
+
* Custom logger for DotCMS SDK
|
|
218
|
+
* Provides structured logging with context identification and configurable log level filtering
|
|
219
|
+
*
|
|
220
|
+
* @example
|
|
221
|
+
* ```typescript
|
|
222
|
+
* const logger = new DotLogger('events', 'Impression', 'info');
|
|
223
|
+
* logger.debug('This will not show'); // Below threshold
|
|
224
|
+
* logger.info('Tracker initialized'); // Shows
|
|
225
|
+
* logger.error('Failed to track'); // Shows
|
|
226
|
+
* ```
|
|
227
|
+
*/
|
|
228
|
+
class DotLogger {
|
|
229
|
+
constructor(packageName, context, minLevel = 'warn') {
|
|
230
|
+
_DotLogger_packageName.set(this, void 0);
|
|
231
|
+
_DotLogger_context.set(this, void 0);
|
|
232
|
+
_DotLogger_minLevel.set(this, void 0);
|
|
233
|
+
__classPrivateFieldSet(this, _DotLogger_packageName, packageName, "f");
|
|
234
|
+
__classPrivateFieldSet(this, _DotLogger_context, context, "f");
|
|
235
|
+
__classPrivateFieldSet(this, _DotLogger_minLevel, minLevel, "f");
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Creates the formatted prefix for log messages
|
|
239
|
+
* Format: [dotCMS packageName | Context] [LEVEL]
|
|
240
|
+
*/
|
|
241
|
+
getPrefix(level) {
|
|
242
|
+
return `[dotCMS ${__classPrivateFieldGet(this, _DotLogger_packageName, "f")} | ${__classPrivateFieldGet(this, _DotLogger_context, "f")}] [${level.toUpperCase()}]`;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Checks if a log level should be displayed based on the minimum threshold
|
|
246
|
+
*/
|
|
247
|
+
shouldLog(level) {
|
|
248
|
+
return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[__classPrivateFieldGet(this, _DotLogger_minLevel, "f")];
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Log a DEBUG level message
|
|
252
|
+
* Used for detailed debugging information
|
|
253
|
+
*/
|
|
254
|
+
debug(...args) {
|
|
255
|
+
if (this.shouldLog('debug')) {
|
|
256
|
+
// eslint-disable-next-line no-console
|
|
257
|
+
console.log(this.getPrefix('debug'), ...args);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Log an INFO level message
|
|
262
|
+
* Used for general informational messages
|
|
263
|
+
*/
|
|
264
|
+
info(...args) {
|
|
265
|
+
if (this.shouldLog('info')) {
|
|
266
|
+
// eslint-disable-next-line no-console
|
|
267
|
+
console.info(this.getPrefix('info'), ...args);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Log a WARN level message
|
|
272
|
+
* Used for warning messages that don't prevent operation
|
|
273
|
+
*/
|
|
274
|
+
warn(...args) {
|
|
275
|
+
if (this.shouldLog('warn')) {
|
|
276
|
+
console.warn(this.getPrefix('warn'), ...args);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Log an ERROR level message
|
|
281
|
+
* Always logs regardless of threshold for critical errors
|
|
282
|
+
*/
|
|
283
|
+
error(...args) {
|
|
284
|
+
console.error(this.getPrefix('error'), ...args);
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Create a console group for organizing related log messages
|
|
288
|
+
* Respects the minimum log level threshold
|
|
289
|
+
*/
|
|
290
|
+
group(label) {
|
|
291
|
+
if (this.shouldLog('debug')) {
|
|
292
|
+
// eslint-disable-next-line no-console
|
|
293
|
+
console.group(`${this.getPrefix('debug')} ${label}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* End the current console group
|
|
298
|
+
* Respects the minimum log level threshold
|
|
299
|
+
*/
|
|
300
|
+
groupEnd() {
|
|
301
|
+
if (this.shouldLog('debug')) {
|
|
302
|
+
// eslint-disable-next-line no-console
|
|
303
|
+
console.groupEnd();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Start a timer with the given label
|
|
308
|
+
* Useful for performance measurements
|
|
309
|
+
*/
|
|
310
|
+
time(label) {
|
|
311
|
+
if (this.shouldLog('debug')) {
|
|
312
|
+
// eslint-disable-next-line no-console
|
|
313
|
+
console.time(`${this.getPrefix('debug')} ${label}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* End a timer and log the elapsed time
|
|
318
|
+
* Useful for performance measurements
|
|
319
|
+
*/
|
|
320
|
+
timeEnd(label) {
|
|
321
|
+
if (this.shouldLog('debug')) {
|
|
322
|
+
// eslint-disable-next-line no-console
|
|
323
|
+
console.timeEnd(`${this.getPrefix('debug')} ${label}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Log a message (alias for info)
|
|
328
|
+
* Provided for backward compatibility
|
|
329
|
+
*/
|
|
330
|
+
log(...args) {
|
|
331
|
+
this.info(...args);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
_DotLogger_packageName = new WeakMap(), _DotLogger_context = new WeakMap(), _DotLogger_minLevel = new WeakMap();
|
|
335
|
+
|
|
336
|
+
var _DotCMSActivityTracker_activityListeners, _DotCMSActivityTracker_inactivityTimer, _DotCMSActivityTracker_isThrottled, _DotCMSActivityTracker_config;
|
|
337
|
+
/** Session activity is recorded at most once a second */
|
|
338
|
+
const ACTIVITY_THROTTLE_MS = 1000;
|
|
339
|
+
/**
|
|
340
|
+
* Activity tracking for the events pipeline.
|
|
341
|
+
* Handles user activity monitoring, session management, and inactivity detection.
|
|
342
|
+
* Singleton pattern since we only handle one site at a time.
|
|
343
|
+
*/
|
|
344
|
+
class DotCMSActivityTracker {
|
|
345
|
+
constructor() {
|
|
346
|
+
_DotCMSActivityTracker_activityListeners.set(this, []);
|
|
347
|
+
_DotCMSActivityTracker_inactivityTimer.set(this, null);
|
|
348
|
+
_DotCMSActivityTracker_isThrottled.set(this, false);
|
|
349
|
+
_DotCMSActivityTracker_config.set(this, null);
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Updates activity timestamp (throttled for performance)
|
|
353
|
+
*/
|
|
354
|
+
updateActivityTime() {
|
|
355
|
+
// Reset inactivity timer
|
|
356
|
+
if (__classPrivateFieldGet(this, _DotCMSActivityTracker_inactivityTimer, "f")) {
|
|
357
|
+
clearTimeout(__classPrivateFieldGet(this, _DotCMSActivityTracker_inactivityTimer, "f"));
|
|
358
|
+
}
|
|
359
|
+
__classPrivateFieldSet(this, _DotCMSActivityTracker_inactivityTimer, setTimeout(() => {
|
|
360
|
+
// User became inactive - handle session timeout
|
|
361
|
+
if (__classPrivateFieldGet(this, _DotCMSActivityTracker_config, "f")?.debug) {
|
|
362
|
+
console.warn('[dotCMS events] User became inactive after timeout');
|
|
363
|
+
}
|
|
364
|
+
// Timer has fired, set to null to indicate no active timer
|
|
365
|
+
__classPrivateFieldSet(this, _DotCMSActivityTracker_inactivityTimer, null, "f");
|
|
366
|
+
}, DEFAULT_SESSION_TIMEOUT_MINUTES * 60 * 1000), "f");
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Updates session activity with throttling
|
|
370
|
+
*/
|
|
371
|
+
updateSessionActivity() {
|
|
372
|
+
if (__classPrivateFieldGet(this, _DotCMSActivityTracker_isThrottled, "f"))
|
|
373
|
+
return;
|
|
374
|
+
__classPrivateFieldSet(this, _DotCMSActivityTracker_isThrottled, true, "f");
|
|
375
|
+
this.updateActivityTime();
|
|
376
|
+
setTimeout(() => {
|
|
377
|
+
__classPrivateFieldSet(this, _DotCMSActivityTracker_isThrottled, false, "f");
|
|
378
|
+
}, ACTIVITY_THROTTLE_MS);
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Initializes activity tracking with event listeners
|
|
382
|
+
*/
|
|
383
|
+
initialize(config) {
|
|
384
|
+
this.cleanup();
|
|
385
|
+
__classPrivateFieldSet(this, _DotCMSActivityTracker_config, config, "f");
|
|
386
|
+
// Early return if window is not available (SSR/build time)
|
|
387
|
+
if (typeof window === 'undefined') {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const throttledHandler = () => this.updateSessionActivity();
|
|
391
|
+
// Add activity event listeners
|
|
392
|
+
ACTIVITY_EVENTS.forEach((eventType) => {
|
|
393
|
+
window.addEventListener(eventType, throttledHandler, { passive: true });
|
|
394
|
+
__classPrivateFieldGet(this, _DotCMSActivityTracker_activityListeners, "f").push(() => window.removeEventListener(eventType, throttledHandler));
|
|
395
|
+
});
|
|
396
|
+
// Handle visibility changes for mobile/background scenarios
|
|
397
|
+
const handleVisibilityChange = () => {
|
|
398
|
+
if (document.visibilityState === 'visible') {
|
|
399
|
+
this.updateSessionActivity();
|
|
400
|
+
if (config.debug) {
|
|
401
|
+
console.warn('[dotCMS events] User returned to tab, session reactivated');
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
406
|
+
__classPrivateFieldGet(this, _DotCMSActivityTracker_activityListeners, "f").push(() => document.removeEventListener('visibilitychange', handleVisibilityChange));
|
|
407
|
+
// Initialize activity time
|
|
408
|
+
this.updateActivityTime();
|
|
409
|
+
if (config.debug) {
|
|
410
|
+
console.warn('[dotCMS events] Activity tracking initialized');
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Cleans up all activity tracking listeners
|
|
415
|
+
*/
|
|
416
|
+
cleanup() {
|
|
417
|
+
__classPrivateFieldGet(this, _DotCMSActivityTracker_activityListeners, "f").forEach((cleanup) => cleanup());
|
|
418
|
+
__classPrivateFieldSet(this, _DotCMSActivityTracker_activityListeners, [], "f");
|
|
419
|
+
if (__classPrivateFieldGet(this, _DotCMSActivityTracker_inactivityTimer, "f")) {
|
|
420
|
+
clearTimeout(__classPrivateFieldGet(this, _DotCMSActivityTracker_inactivityTimer, "f"));
|
|
421
|
+
__classPrivateFieldSet(this, _DotCMSActivityTracker_inactivityTimer, null, "f");
|
|
422
|
+
}
|
|
423
|
+
__classPrivateFieldSet(this, _DotCMSActivityTracker_config, null, "f");
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
_DotCMSActivityTracker_activityListeners = new WeakMap(), _DotCMSActivityTracker_inactivityTimer = new WeakMap(), _DotCMSActivityTracker_isThrottled = new WeakMap(), _DotCMSActivityTracker_config = new WeakMap();
|
|
427
|
+
// Create singleton instance
|
|
428
|
+
const activityTracker = new DotCMSActivityTracker();
|
|
429
|
+
/**
|
|
430
|
+
* Updates session activity with throttling
|
|
431
|
+
*/
|
|
432
|
+
const updateSessionActivity = () => {
|
|
433
|
+
activityTracker.updateSessionActivity();
|
|
434
|
+
};
|
|
435
|
+
/**
|
|
436
|
+
* Initializes activity tracking
|
|
437
|
+
*/
|
|
438
|
+
const initializeActivityTracking = (config) => {
|
|
439
|
+
activityTracker.initialize(config);
|
|
440
|
+
};
|
|
441
|
+
/**
|
|
442
|
+
* Cleans up activity tracking listeners and resets analytics state
|
|
443
|
+
*/
|
|
444
|
+
const cleanupActivityTracking = () => {
|
|
445
|
+
activityTracker.cleanup();
|
|
446
|
+
// Reset analytics state and cleanup window properties
|
|
447
|
+
if (typeof window !== 'undefined') {
|
|
448
|
+
window[ANALYTICS_WINDOWS_ACTIVE_KEY] = false;
|
|
449
|
+
// Dispatch cleanup event to notify any subscribers
|
|
450
|
+
window.dispatchEvent(new CustomEvent('dotcms:events:cleanup'));
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
// Performance cache for static browser data that rarely changes
|
|
455
|
+
let staticBrowserData = null;
|
|
456
|
+
// UTM parameters cache to avoid repetitive URL parsing
|
|
457
|
+
let utmCache = null;
|
|
458
|
+
/**
|
|
459
|
+
* Generates a random ID from the current time and Math.random: unique enough for session and
|
|
460
|
+
* user ids, but not cryptographically secure.
|
|
461
|
+
* @internal This function is for internal use only and should not be used outside of the SDK.
|
|
462
|
+
* @param prefix - The prefix for the generated ID
|
|
463
|
+
* @returns A unique ID string with the given prefix
|
|
464
|
+
*/
|
|
465
|
+
const generateSecureId = (prefix) => {
|
|
466
|
+
const timestamp = Date.now();
|
|
467
|
+
const randomPart = Math.random().toString(36).substr(2, 9);
|
|
468
|
+
const extraRandom = Math.random().toString(36).substr(2, 9);
|
|
469
|
+
return `${prefix}_${timestamp}_${randomPart}${extraRandom}`;
|
|
470
|
+
};
|
|
471
|
+
/**
|
|
472
|
+
* Safe localStorage wrapper with error handling.
|
|
473
|
+
* @internal This is for internal use only.
|
|
474
|
+
*/
|
|
475
|
+
const safeLocalStorage = {
|
|
476
|
+
getItem: (key) => {
|
|
477
|
+
try {
|
|
478
|
+
return localStorage.getItem(key);
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
},
|
|
484
|
+
setItem: (key, value) => {
|
|
485
|
+
try {
|
|
486
|
+
localStorage.setItem(key, value);
|
|
487
|
+
}
|
|
488
|
+
catch (e) {
|
|
489
|
+
console.warn(`[dotCMS events] Could not save ${key} to localStorage`);
|
|
490
|
+
throw e;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
/**
|
|
495
|
+
* Safe sessionStorage wrapper with error handling
|
|
496
|
+
*/
|
|
497
|
+
const safeSessionStorage = {
|
|
498
|
+
getItem: (key) => {
|
|
499
|
+
try {
|
|
500
|
+
return sessionStorage.getItem(key);
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
},
|
|
506
|
+
setItem: (key, value) => {
|
|
507
|
+
try {
|
|
508
|
+
sessionStorage.setItem(key, value);
|
|
509
|
+
}
|
|
510
|
+
catch (e) {
|
|
511
|
+
console.warn(`[dotCMS events] Could not save ${key} to sessionStorage`);
|
|
512
|
+
throw e;
|
|
513
|
+
}
|
|
514
|
+
},
|
|
515
|
+
removeItem: (key) => {
|
|
516
|
+
try {
|
|
517
|
+
sessionStorage.removeItem(key);
|
|
518
|
+
}
|
|
519
|
+
catch {
|
|
520
|
+
console.warn(`[dotCMS events] Could not remove ${key} from sessionStorage`);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
/**
|
|
525
|
+
* Gets or generates a user ID from localStorage.
|
|
526
|
+
* @internal This function is for internal use only.
|
|
527
|
+
* @returns The user ID string
|
|
528
|
+
*/
|
|
529
|
+
const getUserId = () => {
|
|
530
|
+
let userId = safeLocalStorage.getItem(USER_ID_KEY);
|
|
531
|
+
if (!userId) {
|
|
532
|
+
userId = generateSecureId('user');
|
|
533
|
+
try {
|
|
534
|
+
safeLocalStorage.setItem(USER_ID_KEY, userId);
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
// Ignore storage errors for user ID (ephemeral user)
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return userId;
|
|
541
|
+
};
|
|
542
|
+
/**
|
|
543
|
+
* Checks if a new day has started since session creation
|
|
544
|
+
*/
|
|
545
|
+
const hasPassedMidnight = (sessionStartTime) => {
|
|
546
|
+
const sessionStart = new Date(sessionStartTime);
|
|
547
|
+
const now = new Date();
|
|
548
|
+
const sessionStartUTC = new Date(sessionStart.getUTCFullYear(), sessionStart.getUTCMonth(), sessionStart.getUTCDate());
|
|
549
|
+
const nowUTC = new Date(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
|
550
|
+
return sessionStartUTC.getTime() !== nowUTC.getTime();
|
|
551
|
+
};
|
|
552
|
+
/**
|
|
553
|
+
* Gets session ID with comprehensive lifecycle management.
|
|
554
|
+
* Returns existing valid session ID or creates a new one if needed.
|
|
555
|
+
* @internal This function is for internal use only.
|
|
556
|
+
*
|
|
557
|
+
* Session validation criteria:
|
|
558
|
+
* 1. User is still active (< 30 min inactivity)
|
|
559
|
+
* 2. Session hasn't passed midnight (UTC)
|
|
560
|
+
*
|
|
561
|
+
* @returns The session ID string
|
|
562
|
+
*/
|
|
563
|
+
const getSessionId = () => {
|
|
564
|
+
const now = Date.now();
|
|
565
|
+
// Early return if window is not available (SSR/build time)
|
|
566
|
+
if (typeof window === 'undefined') {
|
|
567
|
+
return generateSecureId('session_fallback');
|
|
568
|
+
}
|
|
569
|
+
try {
|
|
570
|
+
const sessionData = sessionStorage.getItem(SESSION_STORAGE_KEY);
|
|
571
|
+
if (sessionData) {
|
|
572
|
+
const { sessionId, startTime, lastActivity } = JSON.parse(sessionData);
|
|
573
|
+
// Check if session is still valid
|
|
574
|
+
const hasNotPassedMidnight = !hasPassedMidnight(startTime);
|
|
575
|
+
const hasRecentActivity = now - lastActivity < DEFAULT_SESSION_TIMEOUT_MINUTES * 60 * 1000;
|
|
576
|
+
if (hasNotPassedMidnight && hasRecentActivity) {
|
|
577
|
+
// Update last activity time
|
|
578
|
+
sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify({
|
|
579
|
+
sessionId,
|
|
580
|
+
startTime,
|
|
581
|
+
lastActivity: now
|
|
582
|
+
}));
|
|
583
|
+
return sessionId;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
// Create new session
|
|
587
|
+
const newSessionId = generateSecureId('session');
|
|
588
|
+
const sessionDataToStore = {
|
|
589
|
+
sessionId: newSessionId,
|
|
590
|
+
startTime: now,
|
|
591
|
+
lastActivity: now
|
|
592
|
+
};
|
|
593
|
+
sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(sessionDataToStore));
|
|
594
|
+
return newSessionId;
|
|
595
|
+
}
|
|
596
|
+
catch {
|
|
597
|
+
// Fallback to simple session ID if storage fails
|
|
598
|
+
return generateSecureId('session_fallback');
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
/**
|
|
602
|
+
* Gets analytics context with user and session identification.
|
|
603
|
+
* Used by the identity plugin to inject context into analytics events.
|
|
604
|
+
*
|
|
605
|
+
* @param config - The analytics configuration object
|
|
606
|
+
* @returns The analytics context with site_auth, session_id, user_id and device
|
|
607
|
+
*/
|
|
608
|
+
const getEventContext = (config) => {
|
|
609
|
+
const sessionId = getSessionId();
|
|
610
|
+
const userId = getUserId();
|
|
611
|
+
const device = getDeviceDataForContext();
|
|
612
|
+
return {
|
|
613
|
+
site_auth: config.siteAuth,
|
|
614
|
+
session_id: sessionId,
|
|
615
|
+
user_id: userId,
|
|
616
|
+
device
|
|
617
|
+
};
|
|
618
|
+
};
|
|
619
|
+
/**
|
|
620
|
+
* Gets static browser data (cached for performance)
|
|
621
|
+
*/
|
|
622
|
+
const getStaticBrowserData = () => {
|
|
623
|
+
if (staticBrowserData) {
|
|
624
|
+
return staticBrowserData;
|
|
625
|
+
}
|
|
626
|
+
staticBrowserData = {
|
|
627
|
+
user_language: navigator.language,
|
|
628
|
+
doc_encoding: document.characterSet || document.charset,
|
|
629
|
+
screen_resolution: typeof screen !== 'undefined' && screen.width && screen.height
|
|
630
|
+
? `${screen.width}x${screen.height}`
|
|
631
|
+
: ''
|
|
632
|
+
};
|
|
633
|
+
return staticBrowserData;
|
|
634
|
+
};
|
|
635
|
+
/**
|
|
636
|
+
* Gets current device data for analytics.
|
|
637
|
+
* Combines static browser data with dynamic viewport information.
|
|
638
|
+
* Used by the identity plugin to inject device data into context.
|
|
639
|
+
*
|
|
640
|
+
* @returns Device data with screen resolution, language, and viewport dimensions
|
|
641
|
+
*/
|
|
642
|
+
const getDeviceDataForContext = () => {
|
|
643
|
+
const staticData = getStaticBrowserData();
|
|
644
|
+
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0;
|
|
645
|
+
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
|
|
646
|
+
return {
|
|
647
|
+
screen_resolution: staticData.screen_resolution ?? '',
|
|
648
|
+
language: staticData.user_language ?? '',
|
|
649
|
+
viewport_width: String(viewportWidth),
|
|
650
|
+
viewport_height: String(viewportHeight)
|
|
651
|
+
};
|
|
652
|
+
};
|
|
653
|
+
/**
|
|
654
|
+
* Extracts and transforms UTM parameters from the URL - cached for performance.
|
|
655
|
+
* Returns UTM data in DotCMS format (without 'utm_' prefix).
|
|
656
|
+
* @internal This function is for internal use only.
|
|
657
|
+
* @param location - The Location object to extract UTM parameters from
|
|
658
|
+
* @returns DotCMSEventUtmData object with transformed UTM parameters (source, medium, campaign, etc.)
|
|
659
|
+
*/
|
|
660
|
+
const extractUTMParameters = (location) => {
|
|
661
|
+
const search = location.search;
|
|
662
|
+
// Return cached result if search hasn't changed
|
|
663
|
+
if (utmCache && utmCache.search === search) {
|
|
664
|
+
return utmCache.params;
|
|
665
|
+
}
|
|
666
|
+
const urlParams = new URLSearchParams(search);
|
|
667
|
+
const utmData = {};
|
|
668
|
+
EXPECTED_UTM_KEYS.forEach((key) => {
|
|
669
|
+
const value = urlParams.get(key);
|
|
670
|
+
if (value) {
|
|
671
|
+
// Transform utm_source -> source, utm_medium -> medium, etc.
|
|
672
|
+
const cleanKey = key.replace('utm_', '');
|
|
673
|
+
utmData[cleanKey] = value;
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
// Cache the result
|
|
677
|
+
utmCache = { search, params: utmData };
|
|
678
|
+
return utmData;
|
|
679
|
+
};
|
|
680
|
+
/**
|
|
681
|
+
* Gets timezone offset in the format +HH:mm or -HH:mm
|
|
682
|
+
*/
|
|
683
|
+
const getTimezoneOffset = () => {
|
|
684
|
+
try {
|
|
685
|
+
const offset = new Date().getTimezoneOffset();
|
|
686
|
+
const sign = offset > 0 ? '-' : '+';
|
|
687
|
+
const absOffset = Math.abs(offset);
|
|
688
|
+
const hours = Math.floor(absOffset / 60);
|
|
689
|
+
const minutes = absOffset % 60;
|
|
690
|
+
return `${sign}${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
|
691
|
+
}
|
|
692
|
+
catch {
|
|
693
|
+
return '+00:00';
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
/**
|
|
697
|
+
* Gets local time in ISO format without milliseconds.
|
|
698
|
+
* Used by enricher plugins to add local_time to events.
|
|
699
|
+
*
|
|
700
|
+
* @returns Local time string in ISO 8601 format with timezone offset (e.g., "2024-01-01T12:00:00-05:00")
|
|
701
|
+
*/
|
|
702
|
+
const getLocalTime = () => {
|
|
703
|
+
try {
|
|
704
|
+
const now = new Date();
|
|
705
|
+
const timezoneOffset = getTimezoneOffset();
|
|
706
|
+
// Format: YYYY-MM-DDTHH:mm:ss+HH:mm (without milliseconds)
|
|
707
|
+
const year = now.getFullYear();
|
|
708
|
+
const month = (now.getMonth() + 1).toString().padStart(2, '0');
|
|
709
|
+
const day = now.getDate().toString().padStart(2, '0');
|
|
710
|
+
const hours = now.getHours().toString().padStart(2, '0');
|
|
711
|
+
const minutes = now.getMinutes().toString().padStart(2, '0');
|
|
712
|
+
const seconds = now.getSeconds().toString().padStart(2, '0');
|
|
713
|
+
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}${timezoneOffset}`;
|
|
714
|
+
}
|
|
715
|
+
catch {
|
|
716
|
+
return new Date().toISOString();
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
/**
|
|
720
|
+
* Optimized payload enrichment using existing analytics.js data.
|
|
721
|
+
* Filters out Analytics.js default properties and only keeps user-provided properties in custom.
|
|
722
|
+
* Used by the enricher plugin to transform Analytics.js payload into DotCMS event format.
|
|
723
|
+
*
|
|
724
|
+
* @param payload - The Analytics.js payload with context already injected by identity plugin
|
|
725
|
+
* @param location - The Location object to extract page data from (defaults to window.location)
|
|
726
|
+
* @returns Enriched payload with page, UTM, custom data, and local_time (device is in context)
|
|
727
|
+
*/
|
|
728
|
+
const enrichPagePayloadOptimized = (payload, location = typeof window !== 'undefined' ? window.location : {}) => {
|
|
729
|
+
const local_time = getLocalTime();
|
|
730
|
+
const staticData = getStaticBrowserData();
|
|
731
|
+
// Extract data from analytics.js payload
|
|
732
|
+
const { properties } = payload;
|
|
733
|
+
// Filter out Analytics.js default properties to get only user-provided properties
|
|
734
|
+
const userProvidedProperties = {};
|
|
735
|
+
Object.keys(properties).forEach((key) => {
|
|
736
|
+
if (!ANALYTICS_JS_DEFAULT_PROPERTIES.includes(key)) {
|
|
737
|
+
userProvidedProperties[key] = properties[key];
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
// TODO: Fix this when we detect the locale_id of the page in not server side
|
|
741
|
+
const locale_id = payload.context?.device?.language?.toLowerCase() ?? '';
|
|
742
|
+
const pageData = {
|
|
743
|
+
url: location.href,
|
|
744
|
+
doc_encoding: staticData.doc_encoding,
|
|
745
|
+
doc_hash: location.hash,
|
|
746
|
+
doc_protocol: location.protocol,
|
|
747
|
+
doc_search: location.search,
|
|
748
|
+
doc_host: location.hostname,
|
|
749
|
+
doc_path: location.pathname,
|
|
750
|
+
title: properties['title'] ?? document?.title,
|
|
751
|
+
locale_id
|
|
752
|
+
};
|
|
753
|
+
// Extract UTM parameters from the current URL (already transformed to DotCMS format)
|
|
754
|
+
const utmData = extractUTMParameters(location);
|
|
755
|
+
return {
|
|
756
|
+
...payload,
|
|
757
|
+
page: pageData,
|
|
758
|
+
...(Object.keys(utmData).length > 0 && { utm: utmData }),
|
|
759
|
+
// Only include custom if there are user-provided properties
|
|
760
|
+
...(Object.keys(userProvidedProperties).length > 0 && { custom: userProvidedProperties }),
|
|
761
|
+
local_time
|
|
762
|
+
};
|
|
763
|
+
};
|
|
764
|
+
/**
|
|
765
|
+
* Checks if code is running in a browser environment
|
|
766
|
+
* @returns true if window and document are available
|
|
767
|
+
*/
|
|
768
|
+
const isBrowser$1 = () => {
|
|
769
|
+
return typeof window !== 'undefined' && typeof document !== 'undefined';
|
|
770
|
+
};
|
|
771
|
+
/**
|
|
772
|
+
* Runs a callback when the browser discards the page. A page it keeps in the back/forward
|
|
773
|
+
* cache (`pagehide` with `persisted`) comes back as it was when the visitor returns, with its
|
|
774
|
+
* listeners and observers, so it is left alone. `beforeunload` is not used: it fires before the
|
|
775
|
+
* page goes into that cache too.
|
|
776
|
+
*
|
|
777
|
+
* @param discarded - Called on the `pagehide` that discards the page
|
|
778
|
+
*/
|
|
779
|
+
const onPageDiscard = (discarded) => {
|
|
780
|
+
window.addEventListener('pagehide', (event) => {
|
|
781
|
+
if (!event.persisted) {
|
|
782
|
+
discarded();
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
};
|
|
786
|
+
/**
|
|
787
|
+
* Creates a logger with plugin-specific prefix and configurable log level
|
|
788
|
+
* @param pluginName - Name of the plugin (e.g., 'Click', 'Impression')
|
|
789
|
+
* @param config - Analytics configuration with debug flag and optional logLevel
|
|
790
|
+
* @returns DotLogger instance with configured log level
|
|
791
|
+
*/
|
|
792
|
+
const createPluginLogger = (pluginName, config) => {
|
|
793
|
+
// Use explicit logLevel if provided, otherwise fall back to debug flag
|
|
794
|
+
const level = config.logLevel ?? (config.debug ? 'debug' : 'warn');
|
|
795
|
+
return new DotLogger('events', pluginName, level);
|
|
796
|
+
};
|
|
797
|
+
|
|
798
|
+
/*
|
|
799
|
+
* Helpers of the content trackers: they find contentlets in the DOM, read their data, watch
|
|
800
|
+
* for new ones and decide which trackers run.
|
|
801
|
+
*/
|
|
802
|
+
/**
|
|
803
|
+
* Creates a throttled version of a callback function
|
|
804
|
+
* Ensures the callback is executed at most once every `limitMs` milliseconds
|
|
805
|
+
* @param callback - The function to throttle
|
|
806
|
+
* @param limitMs - The time limit in milliseconds
|
|
807
|
+
* @param options - `trailing`: instead of dropping the calls made inside the window, run the
|
|
808
|
+
* last one when the window ends
|
|
809
|
+
* @returns A throttled function
|
|
810
|
+
*/
|
|
811
|
+
function createThrottle(callback, limitMs, options = {}) {
|
|
812
|
+
let lastRun = 0;
|
|
813
|
+
let trailingTimer = null;
|
|
814
|
+
return (...args) => {
|
|
815
|
+
const now = Date.now();
|
|
816
|
+
const wait = limitMs - (now - lastRun);
|
|
817
|
+
if (wait <= 0) {
|
|
818
|
+
callback(...args);
|
|
819
|
+
lastRun = now;
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
if (options.trailing && trailingTimer === null) {
|
|
823
|
+
trailingTimer = setTimeout(() => {
|
|
824
|
+
trailingTimer = null;
|
|
825
|
+
lastRun = Date.now();
|
|
826
|
+
callback(...args);
|
|
827
|
+
}, wait);
|
|
828
|
+
}
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* Extracts the contentlet identifier from a DOM element
|
|
833
|
+
* @param element - The HTML element containing data attributes
|
|
834
|
+
* @returns The contentlet identifier or null if not found
|
|
835
|
+
*/
|
|
836
|
+
function extractContentletIdentifier(element) {
|
|
837
|
+
return element.dataset['dotIdentifier'] || null;
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Extracts all contentlet data from a DOM element's data attributes
|
|
841
|
+
* @param element - The HTML element containing data attributes
|
|
842
|
+
* @returns Complete contentlet data object
|
|
843
|
+
*/
|
|
844
|
+
function extractContentletData(element) {
|
|
845
|
+
return {
|
|
846
|
+
identifier: element.dataset['dotIdentifier'] || '',
|
|
847
|
+
inode: element.dataset['dotInode'] || '',
|
|
848
|
+
contentType: element.dataset['dotType'] || '',
|
|
849
|
+
title: element.dataset['dotTitle'] || '',
|
|
850
|
+
baseType: element.dataset['dotBasetype'] || ''
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Initial scan delay for DOM readiness
|
|
855
|
+
* Allows React/Next.js to finish rendering before scanning for contentlets
|
|
856
|
+
*/
|
|
857
|
+
const INITIAL_SCAN_DELAY_MS = 100;
|
|
858
|
+
/**
|
|
859
|
+
* Finds all contentlet elements in the DOM
|
|
860
|
+
* @returns Array of contentlet HTMLElements
|
|
861
|
+
*/
|
|
862
|
+
const findContentlets = () => {
|
|
863
|
+
return Array.from(document.querySelectorAll(`.${CONTENTLET_CLASS}`));
|
|
864
|
+
};
|
|
865
|
+
/**
|
|
866
|
+
* Creates a MutationObserver that watches for contentlet changes in the DOM
|
|
867
|
+
* @param callback - Function to call when mutations are detected
|
|
868
|
+
* @param debounceMs - Debounce time in milliseconds (default: 250ms)
|
|
869
|
+
* @param options - `identifiers`: also call back when a contentlet receives its
|
|
870
|
+
* `data-dot-identifier`. The renderers print it after hydration, usually within
|
|
871
|
+
* milliseconds of the contentlet itself, so calls inside the window are not dropped: the
|
|
872
|
+
* last one runs when it ends.
|
|
873
|
+
* @returns Configured and active MutationObserver
|
|
874
|
+
*/
|
|
875
|
+
const createContentletObserver = (callback, debounceMs = CONTENTLET_OBSERVER_DEBOUNCE_MS, options = {}) => {
|
|
876
|
+
const watchIdentifiers = options.identifiers === true;
|
|
877
|
+
const throttledCallback = createThrottle(callback, debounceMs, { trailing: watchIdentifiers });
|
|
878
|
+
const isContentlet = (node) => node.nodeType === Node.ELEMENT_NODE &&
|
|
879
|
+
node.classList?.contains(CONTENTLET_CLASS) === true;
|
|
880
|
+
const observer = new MutationObserver((mutations) => {
|
|
881
|
+
// This reduces observer callback executions by ~90% on dynamic sites
|
|
882
|
+
const hasRelevantChanges = mutations.some((mutation) => {
|
|
883
|
+
// Only the identifier attribute is observed, so any attribute change counts
|
|
884
|
+
// when it lands on a contentlet
|
|
885
|
+
if (mutation.type === 'attributes') {
|
|
886
|
+
return isContentlet(mutation.target);
|
|
887
|
+
}
|
|
888
|
+
// Skip if no nodes were added or removed
|
|
889
|
+
if (mutation.addedNodes.length === 0 && mutation.removedNodes.length === 0) {
|
|
890
|
+
return false;
|
|
891
|
+
}
|
|
892
|
+
// Check if any added/removed nodes are or contain contentlets
|
|
893
|
+
const nodes = [
|
|
894
|
+
...Array.from(mutation.addedNodes),
|
|
895
|
+
...Array.from(mutation.removedNodes)
|
|
896
|
+
];
|
|
897
|
+
return nodes.some((node) => {
|
|
898
|
+
// Only check element nodes
|
|
899
|
+
if (node.nodeType !== Node.ELEMENT_NODE) {
|
|
900
|
+
return false;
|
|
901
|
+
}
|
|
902
|
+
const element = node;
|
|
903
|
+
// Check if node itself is a contentlet
|
|
904
|
+
if (element.classList?.contains(CONTENTLET_CLASS)) {
|
|
905
|
+
return true;
|
|
906
|
+
}
|
|
907
|
+
// Check if node contains contentlets
|
|
908
|
+
return element.querySelector?.(`.${CONTENTLET_CLASS}`) !== null;
|
|
909
|
+
});
|
|
910
|
+
});
|
|
911
|
+
// Only invoke callback if relevant changes detected
|
|
912
|
+
if (hasRelevantChanges) {
|
|
913
|
+
throttledCallback();
|
|
914
|
+
}
|
|
915
|
+
});
|
|
916
|
+
const observeOptions = {
|
|
917
|
+
childList: true,
|
|
918
|
+
subtree: true,
|
|
919
|
+
characterData: false,
|
|
920
|
+
...(watchIdentifiers
|
|
921
|
+
? { attributes: true, attributeFilter: [CONTENTLET_IDENTIFIER_ATTRIBUTE] }
|
|
922
|
+
: { attributes: false })
|
|
923
|
+
};
|
|
924
|
+
// Handle case where document.body might not be available yet if script is in <head>
|
|
925
|
+
const target = document.body || document.documentElement;
|
|
926
|
+
if (target) {
|
|
927
|
+
observer.observe(target, observeOptions);
|
|
928
|
+
}
|
|
929
|
+
else {
|
|
930
|
+
// Fallback: wait for DOMContentLoaded if everything is null (rare)
|
|
931
|
+
window.addEventListener('DOMContentLoaded', () => {
|
|
932
|
+
if (document.body) {
|
|
933
|
+
observer.observe(document.body, observeOptions);
|
|
934
|
+
}
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
return observer;
|
|
938
|
+
};
|
|
939
|
+
/**
|
|
940
|
+
* Cleans up a content tracker when the browser discards the page. A page kept in the
|
|
941
|
+
* back/forward cache keeps its tracker, which works again when the visitor goes back to it.
|
|
942
|
+
* @param cleanup - Function to call when the page is discarded
|
|
943
|
+
*/
|
|
944
|
+
const setupPluginCleanup = (cleanup) => {
|
|
945
|
+
if (!isBrowser$1())
|
|
946
|
+
return;
|
|
947
|
+
onPageDiscard(cleanup);
|
|
948
|
+
};
|
|
949
|
+
/**
|
|
950
|
+
* Gets enhanced tracking plugins based on configuration
|
|
951
|
+
* Returns content impression and click tracking plugins if enabled
|
|
952
|
+
* @param config - Analytics configuration
|
|
953
|
+
* @param impressionPlugin - Impression tracking plugin factory
|
|
954
|
+
* @param clickPlugin - Click tracking plugin factory
|
|
955
|
+
* @returns Array of enabled tracking plugins
|
|
956
|
+
*/
|
|
957
|
+
const getEnhancedTrackingPlugins = (config, impressionPlugin, clickPlugin) => {
|
|
958
|
+
return [
|
|
959
|
+
config.impressions && impressionPlugin(config),
|
|
960
|
+
config.clicks && clickPlugin(config)
|
|
961
|
+
].filter(Boolean);
|
|
962
|
+
};
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* Where a contentlet sits in the viewport, which both content trackers measure.
|
|
966
|
+
* Pure functions without class dependencies for better testability and reusability
|
|
967
|
+
*/
|
|
968
|
+
/**
|
|
969
|
+
* Calculates the visibility ratio of an element in the viewport
|
|
970
|
+
* @param element - The HTML element to check
|
|
971
|
+
* @returns A number between 0 and 1 representing the visible percentage
|
|
972
|
+
*/
|
|
973
|
+
function calculateElementVisibilityRatio(element) {
|
|
974
|
+
const rect = element.getBoundingClientRect();
|
|
975
|
+
const viewHeight = window.innerHeight || document.documentElement.clientHeight;
|
|
976
|
+
const viewWidth = window.innerWidth || document.documentElement.clientWidth;
|
|
977
|
+
const visibleHeight = Math.min(rect.bottom, viewHeight) - Math.max(rect.top, 0);
|
|
978
|
+
const visibleWidth = Math.min(rect.right, viewWidth) - Math.max(rect.left, 0);
|
|
979
|
+
if (visibleHeight <= 0 || visibleWidth <= 0) {
|
|
980
|
+
return 0;
|
|
981
|
+
}
|
|
982
|
+
const visibleArea = visibleHeight * visibleWidth;
|
|
983
|
+
const totalArea = rect.height * rect.width;
|
|
984
|
+
return totalArea > 0 ? visibleArea / totalArea : 0;
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Calculates the offset percentage of an element from the top of the viewport
|
|
988
|
+
* @param element - The HTML element to check
|
|
989
|
+
* @returns Percentage value (can be negative if above viewport)
|
|
990
|
+
*/
|
|
991
|
+
function calculateViewportOffset(element) {
|
|
992
|
+
const rect = element.getBoundingClientRect();
|
|
993
|
+
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
|
994
|
+
const offsetPercentage = (rect.top / viewportHeight) * 100;
|
|
995
|
+
return Math.round(offsetPercentage * 100) / 100;
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* Gets comprehensive viewport metrics for an element
|
|
999
|
+
* @param element - The HTML element to analyze
|
|
1000
|
+
* @returns Object containing offset percentage and visibility ratio
|
|
1001
|
+
*/
|
|
1002
|
+
function getViewportMetrics(element) {
|
|
1003
|
+
const visibilityRatio = calculateElementVisibilityRatio(element);
|
|
1004
|
+
const offsetPercentage = calculateViewportOffset(element);
|
|
1005
|
+
return {
|
|
1006
|
+
offsetPercentage,
|
|
1007
|
+
visibilityRatio
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Handles click events on elements within a contentlet.
|
|
1013
|
+
* The tracker passes the contentlet the click landed in: the innermost one around the clicked
|
|
1014
|
+
* link or button.
|
|
1015
|
+
*
|
|
1016
|
+
* @param event - The mouse event
|
|
1017
|
+
* @param contentletElement - The contentlet container element
|
|
1018
|
+
* @param trackCallback - Callback to execute if the click is valid
|
|
1019
|
+
* @param logger - Logger instance for debug messages
|
|
1020
|
+
*/
|
|
1021
|
+
const handleContentletClick = (event, contentletElement, trackCallback, logger) => {
|
|
1022
|
+
const target = event.target;
|
|
1023
|
+
logger.debug('Click detected on:', target);
|
|
1024
|
+
// 1. Find clickable element (a or button) within the event path
|
|
1025
|
+
const clickableElement = target.closest(CLICKABLE_ELEMENTS_SELECTOR);
|
|
1026
|
+
if (!clickableElement) {
|
|
1027
|
+
logger.debug('No <a> or <button> found in click path');
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
// 2. Verify clickable is actually inside this contentlet (safety check)
|
|
1031
|
+
if (!contentletElement.contains(clickableElement)) {
|
|
1032
|
+
logger.debug('Click was outside contentlet boundary');
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
logger.debug('Found clickable element:', clickableElement);
|
|
1036
|
+
// 3. Extract and validate contentlet data
|
|
1037
|
+
const contentletData = extractContentletData(contentletElement);
|
|
1038
|
+
if (!contentletData.identifier) {
|
|
1039
|
+
logger.debug('Contentlet has no identifier');
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
logger.debug('Contentlet data:', contentletData);
|
|
1043
|
+
// 4. Build payload with metadata
|
|
1044
|
+
const viewportMetrics = getViewportMetrics(contentletElement);
|
|
1045
|
+
// Extract attributes from the clickable element
|
|
1046
|
+
// Filter out: analytics attributes, and already-captured properties (class, id, href)
|
|
1047
|
+
// Format as 'key:value' strings (e.g., ['data-category:val', 'data-campaign:val2'])
|
|
1048
|
+
const attributes = [];
|
|
1049
|
+
for (const attr of clickableElement.attributes) {
|
|
1050
|
+
if (!attr.name.startsWith('data-dot-analytics') &&
|
|
1051
|
+
attr.name !== 'class' &&
|
|
1052
|
+
attr.name !== 'id' &&
|
|
1053
|
+
attr.name !== 'href') {
|
|
1054
|
+
attributes.push(`${attr.name}:${attr.value}`);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
// The contentlet's position among the page's contentlets, read when it is clicked: one
|
|
1058
|
+
// query per click, and the position stays right as contentlets are added or moved
|
|
1059
|
+
const domIndex = findContentlets().indexOf(contentletElement);
|
|
1060
|
+
const payload = {
|
|
1061
|
+
content: {
|
|
1062
|
+
identifier: contentletData.identifier,
|
|
1063
|
+
inode: contentletData.inode,
|
|
1064
|
+
title: contentletData.title,
|
|
1065
|
+
content_type: contentletData.contentType
|
|
1066
|
+
},
|
|
1067
|
+
position: {
|
|
1068
|
+
viewport_offset_pct: viewportMetrics.offsetPercentage,
|
|
1069
|
+
dom_index: domIndex
|
|
1070
|
+
},
|
|
1071
|
+
element: {
|
|
1072
|
+
text: (clickableElement.innerText || clickableElement.textContent || '')
|
|
1073
|
+
.trim()
|
|
1074
|
+
.substring(0, 100), // Limit length
|
|
1075
|
+
type: clickableElement.tagName.toLowerCase(),
|
|
1076
|
+
id: clickableElement.id || '', // Required by backend, empty string if not present
|
|
1077
|
+
class: clickableElement.className || '', // Required by backend, empty string if not present
|
|
1078
|
+
href: clickableElement.getAttribute('href') || '', // Path as written in HTML (relative), empty string for buttons
|
|
1079
|
+
attributes: attributes // Additional attributes (data-*, aria-*, target, etc.)
|
|
1080
|
+
}
|
|
1081
|
+
};
|
|
1082
|
+
trackCallback(CLICK_EVENT_TYPE, payload);
|
|
1083
|
+
};
|
|
1084
|
+
|
|
1085
|
+
var _DotCMSClickTracker_lastClickAt, _DotCMSClickTracker_logger, _DotCMSClickTracker_subscribers, _DotCMSClickTracker_listening, _DotCMSClickTracker_handleClick;
|
|
1086
|
+
/**
|
|
1087
|
+
* Tracks clicks on the links and buttons inside contentlets, through one click listener on the
|
|
1088
|
+
* document, in the capture phase. A click finds its contentlet with `closest`, so:
|
|
1089
|
+
* - nothing scans or observes the page, and a contentlet added at any time counts;
|
|
1090
|
+
* - an app's `stopPropagation()` cannot hide a click from the tracker;
|
|
1091
|
+
* - a click inside nested contentlets counts once, for the innermost;
|
|
1092
|
+
* - clicks on the same contentlet within 300 ms count once.
|
|
1093
|
+
*
|
|
1094
|
+
* @example
|
|
1095
|
+
* ```typescript
|
|
1096
|
+
* const tracker = new DotCMSClickTracker(config);
|
|
1097
|
+
* const subscription = tracker.onClick((eventName, payload) => {
|
|
1098
|
+
* console.log('Click detected:', payload);
|
|
1099
|
+
* });
|
|
1100
|
+
* tracker.initialize();
|
|
1101
|
+
* // Later: subscription.unsubscribe();
|
|
1102
|
+
* ```
|
|
1103
|
+
*/
|
|
1104
|
+
class DotCMSClickTracker {
|
|
1105
|
+
constructor(config) {
|
|
1106
|
+
// When each contentlet was last clicked: a click on one never throttles another
|
|
1107
|
+
_DotCMSClickTracker_lastClickAt.set(this, new WeakMap());
|
|
1108
|
+
_DotCMSClickTracker_logger.set(this, void 0);
|
|
1109
|
+
_DotCMSClickTracker_subscribers.set(this, new Set());
|
|
1110
|
+
_DotCMSClickTracker_listening.set(this, false);
|
|
1111
|
+
/** Reports a click on a link or button inside a contentlet, throttled per contentlet */
|
|
1112
|
+
_DotCMSClickTracker_handleClick.set(this, (event) => {
|
|
1113
|
+
const target = event.target;
|
|
1114
|
+
if (!(target instanceof Element)) {
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
const contentlet = target
|
|
1118
|
+
.closest(CLICKABLE_ELEMENTS_SELECTOR)
|
|
1119
|
+
?.closest(`.${CONTENTLET_CLASS}`);
|
|
1120
|
+
if (!contentlet) {
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
handleContentletClick(event, contentlet, (eventName, payload) => {
|
|
1124
|
+
const now = Date.now();
|
|
1125
|
+
if (now - (__classPrivateFieldGet(this, _DotCMSClickTracker_lastClickAt, "f").get(contentlet) ?? 0) < DEFAULT_CLICK_THROTTLE_MS) {
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
__classPrivateFieldGet(this, _DotCMSClickTracker_lastClickAt, "f").set(contentlet, now);
|
|
1129
|
+
__classPrivateFieldGet(this, _DotCMSClickTracker_subscribers, "f").forEach((callback) => callback(eventName, payload));
|
|
1130
|
+
__classPrivateFieldGet(this, _DotCMSClickTracker_logger, "f").info(`Fired click event for ${payload.content.identifier}`, payload);
|
|
1131
|
+
}, __classPrivateFieldGet(this, _DotCMSClickTracker_logger, "f"));
|
|
1132
|
+
});
|
|
1133
|
+
__classPrivateFieldSet(this, _DotCMSClickTracker_logger, createPluginLogger('Click', config), "f");
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* Subscribe to click events
|
|
1137
|
+
* @param callback - Function called when click is detected
|
|
1138
|
+
* @returns Subscription object with unsubscribe method
|
|
1139
|
+
*/
|
|
1140
|
+
onClick(callback) {
|
|
1141
|
+
__classPrivateFieldGet(this, _DotCMSClickTracker_subscribers, "f").add(callback);
|
|
1142
|
+
return {
|
|
1143
|
+
unsubscribe: () => {
|
|
1144
|
+
__classPrivateFieldGet(this, _DotCMSClickTracker_subscribers, "f").delete(callback);
|
|
1145
|
+
}
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
/** Starts listening for clicks; without a document, as on the server, it does nothing */
|
|
1149
|
+
initialize() {
|
|
1150
|
+
if (!isBrowser$1()) {
|
|
1151
|
+
__classPrivateFieldGet(this, _DotCMSClickTracker_logger, "f").warn('No document, skipping');
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
if (__classPrivateFieldGet(this, _DotCMSClickTracker_listening, "f")) {
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
document.addEventListener('click', __classPrivateFieldGet(this, _DotCMSClickTracker_handleClick, "f"), true);
|
|
1158
|
+
__classPrivateFieldSet(this, _DotCMSClickTracker_listening, true, "f");
|
|
1159
|
+
__classPrivateFieldGet(this, _DotCMSClickTracker_logger, "f").info('Plugin initialized');
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* Stops listening for clicks. Should be called when the plugin is disabled or the page is
|
|
1163
|
+
* discarded.
|
|
1164
|
+
*/
|
|
1165
|
+
cleanup() {
|
|
1166
|
+
if (__classPrivateFieldGet(this, _DotCMSClickTracker_listening, "f")) {
|
|
1167
|
+
document.removeEventListener('click', __classPrivateFieldGet(this, _DotCMSClickTracker_handleClick, "f"), true);
|
|
1168
|
+
__classPrivateFieldSet(this, _DotCMSClickTracker_listening, false, "f");
|
|
1169
|
+
}
|
|
1170
|
+
__classPrivateFieldGet(this, _DotCMSClickTracker_logger, "f").info('Click tracking cleaned up');
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
_DotCMSClickTracker_lastClickAt = new WeakMap(), _DotCMSClickTracker_logger = new WeakMap(), _DotCMSClickTracker_subscribers = new WeakMap(), _DotCMSClickTracker_listening = new WeakMap(), _DotCMSClickTracker_handleClick = new WeakMap();
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* Clicks plugin of the events pipeline.
|
|
1177
|
+
* Handles automatic tracking of clicks on content elements.
|
|
1178
|
+
*
|
|
1179
|
+
* This plugin initializes the click tracker which:
|
|
1180
|
+
* - Listens for clicks once, on the document, in the capture phase
|
|
1181
|
+
* - Reports clicks on <a> or <button> elements inside a contentlet, the innermost one
|
|
1182
|
+
* - Extracts contentlet data, its position when clicked, and element metadata
|
|
1183
|
+
* - Throttles clicks per contentlet to prevent duplicates
|
|
1184
|
+
* - Fires 'content_click' events via subscription callback
|
|
1185
|
+
*
|
|
1186
|
+
* Note: This plugin is only registered if config.clicks is enabled.
|
|
1187
|
+
* See getEnhancedTrackingPlugins() for conditional loading logic.
|
|
1188
|
+
*
|
|
1189
|
+
* @param {PipelineConfig} config - Configuration with clicks settings
|
|
1190
|
+
* @returns {Object} Plugin object with lifecycle methods
|
|
1191
|
+
*/
|
|
1192
|
+
const clicksPlugin = (config) => {
|
|
1193
|
+
let clickTracker = null;
|
|
1194
|
+
let subscription = null;
|
|
1195
|
+
const logger = createPluginLogger('Click', config);
|
|
1196
|
+
return {
|
|
1197
|
+
name: 'dot-events-clicks',
|
|
1198
|
+
/**
|
|
1199
|
+
* Initialize click tracking
|
|
1200
|
+
* Called when Analytics.js initializes the plugin with instance context
|
|
1201
|
+
* @param instance - Analytics.js instance with track method
|
|
1202
|
+
*/
|
|
1203
|
+
initialize: ({ instance }) => {
|
|
1204
|
+
// Create and initialize tracker
|
|
1205
|
+
clickTracker = new DotCMSClickTracker(config);
|
|
1206
|
+
// Subscribe to click events
|
|
1207
|
+
subscription = clickTracker.onClick((eventName, payload) => {
|
|
1208
|
+
instance.track(eventName, payload);
|
|
1209
|
+
});
|
|
1210
|
+
// Start tracking
|
|
1211
|
+
clickTracker.initialize();
|
|
1212
|
+
logger.info('Click tracking plugin initialized');
|
|
1213
|
+
return Promise.resolve();
|
|
1214
|
+
},
|
|
1215
|
+
/**
|
|
1216
|
+
* Setup cleanup handlers when plugin is loaded
|
|
1217
|
+
* Called after Analytics.js completes plugin loading
|
|
1218
|
+
*/
|
|
1219
|
+
loaded: () => {
|
|
1220
|
+
if (isBrowser$1() && clickTracker) {
|
|
1221
|
+
const cleanup = () => {
|
|
1222
|
+
if (subscription) {
|
|
1223
|
+
subscription.unsubscribe();
|
|
1224
|
+
subscription = null;
|
|
1225
|
+
}
|
|
1226
|
+
if (clickTracker) {
|
|
1227
|
+
clickTracker.cleanup();
|
|
1228
|
+
clickTracker = null;
|
|
1229
|
+
logger.info('Click tracking cleaned up on page unload');
|
|
1230
|
+
}
|
|
1231
|
+
};
|
|
1232
|
+
// Cleanup on page unload
|
|
1233
|
+
setupPluginCleanup(cleanup);
|
|
1234
|
+
}
|
|
1235
|
+
return true;
|
|
1236
|
+
}
|
|
1237
|
+
};
|
|
1238
|
+
};
|
|
1239
|
+
|
|
1240
|
+
const MARK_SELECTOR = `[${decision.EXPERIMENT_ATTRIBUTE}]`;
|
|
1241
|
+
const revealedRules = new Set();
|
|
1242
|
+
// Where the browser has adopted style sheets, reveals go to one: a change through the CSSOM,
|
|
1243
|
+
// which no Content-Security-Policy blocks, unlike a style element the script creates.
|
|
1244
|
+
let revealSheet;
|
|
1245
|
+
/**
|
|
1246
|
+
* Tells whether an element is on screen as far as React is concerned: not inside a route that
|
|
1247
|
+
* Activity keeps in the page with `display: none`, nor inside the hidden container where a
|
|
1248
|
+
* Suspense boundary's content waits while it streams in.
|
|
1249
|
+
*
|
|
1250
|
+
* @param element - The element
|
|
1251
|
+
* @returns False when an ancestor keeps it out of view
|
|
1252
|
+
*/
|
|
1253
|
+
const isRendered = (element) => {
|
|
1254
|
+
for (let node = element; node; node = node.parentElement) {
|
|
1255
|
+
if (node.hidden || node.style.display === 'none') {
|
|
1256
|
+
return false;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
return true;
|
|
1260
|
+
};
|
|
1261
|
+
/**
|
|
1262
|
+
* Reads the experiments the current page renders, from their marks: each experiment and
|
|
1263
|
+
* rendered variant once, in document order.
|
|
1264
|
+
*
|
|
1265
|
+
* @returns The experiments and the variants the server rendered
|
|
1266
|
+
*/
|
|
1267
|
+
const readPageExperiments = () => {
|
|
1268
|
+
const marks = new Map();
|
|
1269
|
+
document.querySelectorAll(MARK_SELECTOR).forEach((element) => {
|
|
1270
|
+
const experimentId = element.getAttribute(decision.EXPERIMENT_ATTRIBUTE);
|
|
1271
|
+
if (!experimentId || !isRendered(element)) {
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
const variant = element.getAttribute(decision.VARIANT_ATTRIBUTE) || decision.DEFAULT_VARIANT;
|
|
1275
|
+
marks.set(`${experimentId}\n${variant}`, { experimentId, variant });
|
|
1276
|
+
});
|
|
1277
|
+
return Array.from(marks.values());
|
|
1278
|
+
};
|
|
1279
|
+
/**
|
|
1280
|
+
* Calls back when marks are inserted or rewritten, so the engine decides content that arrives
|
|
1281
|
+
* after its first decision: Suspense content that streams in, or a route that changes only its
|
|
1282
|
+
* query and keeps its elements.
|
|
1283
|
+
*
|
|
1284
|
+
* @param changed - Called once per batch of DOM changes that touches a mark
|
|
1285
|
+
* @returns Stops observing
|
|
1286
|
+
*/
|
|
1287
|
+
const observeMarks = (changed) => {
|
|
1288
|
+
const touchesMark = (record) => record.type === 'attributes' ||
|
|
1289
|
+
Array.from(record.addedNodes).some((node) => node instanceof Element &&
|
|
1290
|
+
(node.matches(MARK_SELECTOR) || !!node.querySelector(MARK_SELECTOR)));
|
|
1291
|
+
const observer = new MutationObserver((records) => {
|
|
1292
|
+
if (records.some(touchesMark)) {
|
|
1293
|
+
changed();
|
|
1294
|
+
}
|
|
1295
|
+
});
|
|
1296
|
+
observer.observe(document.documentElement, {
|
|
1297
|
+
childList: true,
|
|
1298
|
+
subtree: true,
|
|
1299
|
+
attributeFilter: [decision.EXPERIMENT_ATTRIBUTE, decision.VARIANT_ATTRIBUTE]
|
|
1300
|
+
});
|
|
1301
|
+
return () => observer.disconnect();
|
|
1302
|
+
};
|
|
1303
|
+
/**
|
|
1304
|
+
* Adds a reveal rule: to the adopted style sheet where the browser has them, or else to a
|
|
1305
|
+
* style element in the head.
|
|
1306
|
+
*
|
|
1307
|
+
* @param rule - The rule
|
|
1308
|
+
*/
|
|
1309
|
+
const addRevealRule = (rule) => {
|
|
1310
|
+
if ('adoptedStyleSheets' in document) {
|
|
1311
|
+
try {
|
|
1312
|
+
revealSheet ?? (revealSheet = new CSSStyleSheet());
|
|
1313
|
+
if (!document.adoptedStyleSheets.includes(revealSheet)) {
|
|
1314
|
+
document.adoptedStyleSheets = [...document.adoptedStyleSheets, revealSheet];
|
|
1315
|
+
}
|
|
1316
|
+
revealSheet.insertRule(rule, revealSheet.cssRules.length);
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
catch {
|
|
1320
|
+
// A style element instead
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
let style = document.getElementById(decision.REVEAL_STYLE_ID);
|
|
1324
|
+
if (!style) {
|
|
1325
|
+
style = document.createElement('style');
|
|
1326
|
+
style.id = decision.REVEAL_STYLE_ID;
|
|
1327
|
+
document.head.appendChild(style);
|
|
1328
|
+
}
|
|
1329
|
+
style.appendChild(document.createTextNode(rule));
|
|
1330
|
+
};
|
|
1331
|
+
/**
|
|
1332
|
+
* Shows rows the renderer hid. With an id and a variant, only that experiment's rows rendered
|
|
1333
|
+
* with that variant, so a route kept in the page with another render stays hidden; without an
|
|
1334
|
+
* id, all of them (engine off, inside UVE).
|
|
1335
|
+
*
|
|
1336
|
+
* The rule beats the renderer's hiding rule through `:root` and `!important`, whatever the
|
|
1337
|
+
* order of the two in the document.
|
|
1338
|
+
*
|
|
1339
|
+
* @param experimentId - The experiment whose rows to show
|
|
1340
|
+
* @param variant - The variant they were rendered with
|
|
1341
|
+
*/
|
|
1342
|
+
const revealRows = (experimentId, variant) => {
|
|
1343
|
+
if (typeof document === 'undefined') {
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
const selector = experimentId
|
|
1347
|
+
? `[${decision.EXPERIMENT_ATTRIBUTE}="${CSS.escape(experimentId)}"]` +
|
|
1348
|
+
(variant ? `[${decision.VARIANT_ATTRIBUTE}="${CSS.escape(variant)}"]` : '')
|
|
1349
|
+
: MARK_SELECTOR;
|
|
1350
|
+
const rule = `:root ${selector}{visibility:visible !important;animation:none !important}`;
|
|
1351
|
+
if (revealedRules.has(rule)) {
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
revealedRules.add(rule);
|
|
1355
|
+
addRevealRule(rule);
|
|
1356
|
+
// The rows show through a style rule, which no tracker's DOM observer notices, and the
|
|
1357
|
+
// impression tracker skipped them while they were hidden.
|
|
1358
|
+
window.dispatchEvent(new CustomEvent(CONTENTLET_RESCAN_EVENT));
|
|
1359
|
+
};
|
|
1360
|
+
/** The style element that hides the contentlet wrappers while a new visitor is decided. */
|
|
1361
|
+
const CONTENTLET_HIDING_STYLE_ID = 'dotcms-experiment-contentlets';
|
|
1362
|
+
/**
|
|
1363
|
+
* Hides the contentlet wrappers dotCMS prints on the pages it renders: they hold what a variant
|
|
1364
|
+
* changes. Like the markup's rule, it shows them on its own after `DECISION_TIMEOUT_MS`.
|
|
1365
|
+
*/
|
|
1366
|
+
const hideContentlets = () => {
|
|
1367
|
+
if (document.getElementById(CONTENTLET_HIDING_STYLE_ID)) {
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
const style = document.createElement('style');
|
|
1371
|
+
style.id = CONTENTLET_HIDING_STYLE_ID;
|
|
1372
|
+
style.textContent =
|
|
1373
|
+
`.${CONTENTLET_CLASS}{visibility:hidden;animation:dotcms-experiment-timeout 0s linear ${decision.DECISION_TIMEOUT_MS / 1000}s forwards}` +
|
|
1374
|
+
'@keyframes dotcms-experiment-timeout{to{visibility:visible}}';
|
|
1375
|
+
document.head.appendChild(style);
|
|
1376
|
+
};
|
|
1377
|
+
/**
|
|
1378
|
+
* Shows the contentlet wrappers `hideContentlets` hid, and asks the impression tracker to scan
|
|
1379
|
+
* again for them.
|
|
1380
|
+
*/
|
|
1381
|
+
const revealContentlets = () => {
|
|
1382
|
+
const style = document.getElementById(CONTENTLET_HIDING_STYLE_ID);
|
|
1383
|
+
if (!style) {
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
style.remove();
|
|
1387
|
+
window.dispatchEvent(new CustomEvent(CONTENTLET_RESCAN_EVENT));
|
|
1388
|
+
};
|
|
1389
|
+
/**
|
|
1390
|
+
* The variant a page dotCMS rendered shows: the one its contentlet wrappers carry in
|
|
1391
|
+
* `data-dot-variant`, or, when the variant changes none of them (or the wrappers are not
|
|
1392
|
+
* parsed yet), the one its URL asks for. dotCMS renders the variant `variantName` names.
|
|
1393
|
+
*
|
|
1394
|
+
* @param href - The page's URL
|
|
1395
|
+
* @returns The rendered variant
|
|
1396
|
+
*/
|
|
1397
|
+
const readRenderedVariant = (href) => {
|
|
1398
|
+
const wrapped = Array.from(document.querySelectorAll(`.${CONTENTLET_CLASS}[${decision.VARIANT_ATTRIBUTE}]`))
|
|
1399
|
+
.map((wrapper) => wrapper.getAttribute(decision.VARIANT_ATTRIBUTE))
|
|
1400
|
+
.find((variant) => !!variant && variant !== decision.DEFAULT_VARIANT);
|
|
1401
|
+
return wrapped ?? new URL(href).searchParams.get(decision.VARIANT_QUERY_PARAM) ?? decision.DEFAULT_VARIANT;
|
|
1402
|
+
};
|
|
1403
|
+
/**
|
|
1404
|
+
* Holds the page's new `fetch` calls, except `keepalive` ones (an analytics flush), for
|
|
1405
|
+
* `QUIET_MS`. A page being replaced runs until the variant's document arrives, and a hydrated
|
|
1406
|
+
* app goes on prefetching its links; held, they do not compete with the variant's server render.
|
|
1407
|
+
* A held call never settles, and after `QUIET_MS` only new calls go out.
|
|
1408
|
+
*/
|
|
1409
|
+
const holdRequests = () => {
|
|
1410
|
+
const send = window.fetch;
|
|
1411
|
+
window.fetch = (input, init) => init?.keepalive ? send.call(window, input, init) : new Promise(() => undefined);
|
|
1412
|
+
setTimeout(() => {
|
|
1413
|
+
window.fetch = send;
|
|
1414
|
+
}, decision.QUIET_MS);
|
|
1415
|
+
};
|
|
1416
|
+
/**
|
|
1417
|
+
* Replaces the page with a variant's URL: stops its loading, navigates, and holds its new
|
|
1418
|
+
* requests, as the boot script does when it decides from storage.
|
|
1419
|
+
*
|
|
1420
|
+
* @param url - The variant's URL
|
|
1421
|
+
*/
|
|
1422
|
+
const leavePageFor = (url) => {
|
|
1423
|
+
window.stop();
|
|
1424
|
+
window.location.replace(url);
|
|
1425
|
+
holdRequests();
|
|
1426
|
+
};
|
|
1427
|
+
|
|
1428
|
+
const asStringArray = (value) => Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
|
|
1429
|
+
const toEntity = (raw) => {
|
|
1430
|
+
const entity = (raw ?? {});
|
|
1431
|
+
return {
|
|
1432
|
+
experiments: Array.isArray(entity['experiments'])
|
|
1433
|
+
? entity['experiments']
|
|
1434
|
+
: [],
|
|
1435
|
+
includedExperimentIds: asStringArray(entity['includedExperimentIds']),
|
|
1436
|
+
excludedExperimentIds: asStringArray(entity['excludedExperimentIds']),
|
|
1437
|
+
excludedExperimentIdsEnded: asStringArray(entity['excludedExperimentIdsEnded'])
|
|
1438
|
+
};
|
|
1439
|
+
};
|
|
1440
|
+
/**
|
|
1441
|
+
* Asks dotCMS which running experiments the visitor is in.
|
|
1442
|
+
*
|
|
1443
|
+
* @param dotcmsUrl - The dotCMS origin, without a trailing slash
|
|
1444
|
+
* @param exclude - Experiments already evaluated, so the server skips them
|
|
1445
|
+
* @returns `ok` with the entity, `disabled` on a 403 (Experiments off), or `failed`
|
|
1446
|
+
*/
|
|
1447
|
+
const fetchIsUserIncluded = async (dotcmsUrl, exclude) => {
|
|
1448
|
+
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
1449
|
+
const timer = setTimeout(() => controller?.abort(), decision.IS_USER_INCLUDED_REQUEST_TIMEOUT_MS);
|
|
1450
|
+
try {
|
|
1451
|
+
const response = await fetch(`${dotcmsUrl}${decision.IS_USER_INCLUDED_PATH}`, {
|
|
1452
|
+
method: 'POST',
|
|
1453
|
+
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
|
1454
|
+
body: JSON.stringify({ exclude }),
|
|
1455
|
+
signal: controller?.signal ?? null
|
|
1456
|
+
});
|
|
1457
|
+
if (response.status === 403) {
|
|
1458
|
+
return { status: 'disabled' };
|
|
1459
|
+
}
|
|
1460
|
+
if (!response.ok) {
|
|
1461
|
+
return { status: 'failed', httpStatus: response.status };
|
|
1462
|
+
}
|
|
1463
|
+
const json = (await response.json());
|
|
1464
|
+
return { status: 'ok', entity: toEntity(json?.entity) };
|
|
1465
|
+
}
|
|
1466
|
+
catch {
|
|
1467
|
+
return { status: 'failed' };
|
|
1468
|
+
}
|
|
1469
|
+
finally {
|
|
1470
|
+
clearTimeout(timer);
|
|
1471
|
+
}
|
|
1472
|
+
};
|
|
1473
|
+
|
|
1474
|
+
const storageOf = (kind) => {
|
|
1475
|
+
try {
|
|
1476
|
+
return kind === 'local' ? window.localStorage : window.sessionStorage;
|
|
1477
|
+
}
|
|
1478
|
+
catch {
|
|
1479
|
+
return null;
|
|
1480
|
+
}
|
|
1481
|
+
};
|
|
1482
|
+
const read = (kind, key) => {
|
|
1483
|
+
try {
|
|
1484
|
+
const raw = storageOf(kind)?.getItem(key);
|
|
1485
|
+
return raw ? JSON.parse(raw) : null;
|
|
1486
|
+
}
|
|
1487
|
+
catch {
|
|
1488
|
+
return null;
|
|
1489
|
+
}
|
|
1490
|
+
};
|
|
1491
|
+
const write = (kind, key, value) => {
|
|
1492
|
+
try {
|
|
1493
|
+
storageOf(kind)?.setItem(key, JSON.stringify(value));
|
|
1494
|
+
}
|
|
1495
|
+
catch {
|
|
1496
|
+
// Storage can be full or blocked; the engine keeps working in memory.
|
|
1497
|
+
}
|
|
1498
|
+
};
|
|
1499
|
+
/**
|
|
1500
|
+
* Loads the stored assignments, without the ones whose look-back window has passed.
|
|
1501
|
+
*
|
|
1502
|
+
* @returns The assignments, or null when there are none
|
|
1503
|
+
*/
|
|
1504
|
+
const loadAssignments = () => {
|
|
1505
|
+
const stored = read('local', decision.STORAGE_KEYS.assignments);
|
|
1506
|
+
if (!stored || !Array.isArray(stored.experiments)) {
|
|
1507
|
+
return null;
|
|
1508
|
+
}
|
|
1509
|
+
const now = Date.now();
|
|
1510
|
+
return {
|
|
1511
|
+
fetchedAt: stored.fetchedAt ?? 0,
|
|
1512
|
+
experiments: stored.experiments.filter((experiment) => experiment.expiresAt > now),
|
|
1513
|
+
evaluatedIds: Array.isArray(stored.evaluatedIds) ? stored.evaluatedIds : []
|
|
1514
|
+
};
|
|
1515
|
+
};
|
|
1516
|
+
/**
|
|
1517
|
+
* Merges an `isUserIncluded` answer into the stored assignments and saves them.
|
|
1518
|
+
* Ended experiments drop out; every evaluated experiment is remembered, so a visitor
|
|
1519
|
+
* left out by traffic allocation is not evaluated again.
|
|
1520
|
+
*
|
|
1521
|
+
* @param entity - The response entity
|
|
1522
|
+
* @param previous - What was stored before the request
|
|
1523
|
+
* @returns The assignments now stored
|
|
1524
|
+
*/
|
|
1525
|
+
const saveAssignments = (entity, previous) => {
|
|
1526
|
+
const now = Date.now();
|
|
1527
|
+
const ended = new Set(entity.excludedExperimentIdsEnded);
|
|
1528
|
+
const byId = new Map((previous?.experiments ?? [])
|
|
1529
|
+
.filter((experiment) => !ended.has(experiment.id) && experiment.expiresAt > now)
|
|
1530
|
+
.map((experiment) => [experiment.id, experiment]));
|
|
1531
|
+
entity.experiments.forEach((experiment) => {
|
|
1532
|
+
byId.set(experiment.id, {
|
|
1533
|
+
...experiment,
|
|
1534
|
+
expiresAt: now + (experiment.lookBackWindow?.expireMillis || decision.ONE_DAY_MS)
|
|
1535
|
+
});
|
|
1536
|
+
});
|
|
1537
|
+
const evaluated = new Set([
|
|
1538
|
+
...(previous?.evaluatedIds ?? []),
|
|
1539
|
+
...entity.includedExperimentIds,
|
|
1540
|
+
...entity.excludedExperimentIds,
|
|
1541
|
+
...entity.experiments.map((experiment) => experiment.id)
|
|
1542
|
+
]);
|
|
1543
|
+
ended.forEach((id) => evaluated.delete(id));
|
|
1544
|
+
const next = {
|
|
1545
|
+
fetchedAt: now,
|
|
1546
|
+
experiments: [...byId.values()],
|
|
1547
|
+
evaluatedIds: [...evaluated]
|
|
1548
|
+
};
|
|
1549
|
+
write('local', decision.STORAGE_KEYS.assignments, next);
|
|
1550
|
+
return next;
|
|
1551
|
+
};
|
|
1552
|
+
/**
|
|
1553
|
+
* Tells whether this tab should ask `isUserIncluded`: once per tab session, when nothing
|
|
1554
|
+
* is stored, or when the stored answer is older than a day.
|
|
1555
|
+
*
|
|
1556
|
+
* @param stored - The current assignments
|
|
1557
|
+
* @returns True when a request is due
|
|
1558
|
+
*/
|
|
1559
|
+
const isCheckDue = (stored) => {
|
|
1560
|
+
const checkedThisTab = read('session', decision.STORAGE_KEYS.checkedThisTab) === true;
|
|
1561
|
+
return !checkedThisTab || !stored || Date.now() - stored.fetchedAt > decision.ONE_DAY_MS;
|
|
1562
|
+
};
|
|
1563
|
+
/** Records that this tab asked `isUserIncluded`. */
|
|
1564
|
+
const markCheckedThisTab = () => write('session', decision.STORAGE_KEYS.checkedThisTab, true);
|
|
1565
|
+
/** Tells whether the site answered 403 in the last day. */
|
|
1566
|
+
const isDisabledForNow = () => (read('local', decision.STORAGE_KEYS.disabledUntil) ?? 0) > Date.now();
|
|
1567
|
+
/** Remembers a 403 for a day, so sites without Experiments stop paying for the call. */
|
|
1568
|
+
const disableForADay = () => write('local', decision.STORAGE_KEYS.disabledUntil, Date.now() + decision.ONE_DAY_MS);
|
|
1569
|
+
/**
|
|
1570
|
+
* Returns the experiments the visitor reached in this analytics session. A new session
|
|
1571
|
+
* starts empty.
|
|
1572
|
+
*
|
|
1573
|
+
* @param sessionId - The analytics session id
|
|
1574
|
+
* @returns The cumulative `context.experiments`
|
|
1575
|
+
*/
|
|
1576
|
+
const getSessionExperiments = (sessionId) => {
|
|
1577
|
+
const stored = read('session', decision.STORAGE_KEYS.sessionExperiments);
|
|
1578
|
+
return stored && stored.sessionId === sessionId ? stored.experiments : [];
|
|
1579
|
+
};
|
|
1580
|
+
/**
|
|
1581
|
+
* Adds an experiment to the session's `context.experiments`, replacing an older entry
|
|
1582
|
+
* for the same experiment.
|
|
1583
|
+
*
|
|
1584
|
+
* @param sessionId - The analytics session id
|
|
1585
|
+
* @param entry - The experiment the visitor reached
|
|
1586
|
+
*/
|
|
1587
|
+
const joinSessionExperiment = (sessionId, entry) => {
|
|
1588
|
+
const others = getSessionExperiments(sessionId).filter((experiment) => experiment.id !== entry.id);
|
|
1589
|
+
write('session', decision.STORAGE_KEYS.sessionExperiments, {
|
|
1590
|
+
sessionId,
|
|
1591
|
+
experiments: [...others, entry]
|
|
1592
|
+
});
|
|
1593
|
+
};
|
|
1594
|
+
/**
|
|
1595
|
+
* Removes ended experiments from the session's `context.experiments`.
|
|
1596
|
+
*
|
|
1597
|
+
* @param endedIds - Ids `isUserIncluded` reported as ended
|
|
1598
|
+
*/
|
|
1599
|
+
const leaveEndedExperiments = (endedIds) => {
|
|
1600
|
+
if (endedIds.length === 0) {
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
const stored = read('session', decision.STORAGE_KEYS.sessionExperiments);
|
|
1604
|
+
if (!stored) {
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
const ended = new Set(endedIds);
|
|
1608
|
+
write('session', decision.STORAGE_KEYS.sessionExperiments, {
|
|
1609
|
+
sessionId: stored.sessionId,
|
|
1610
|
+
experiments: stored.experiments.filter((experiment) => !ended.has(experiment.id))
|
|
1611
|
+
});
|
|
1612
|
+
};
|
|
1613
|
+
|
|
1614
|
+
/**
|
|
1615
|
+
* Calls back on every navigation inside the page, an SPA route change, that changes the path
|
|
1616
|
+
* or the query. It listens to the browser's Navigation API, which reports every History change,
|
|
1617
|
+
* back and forward included, without patching `history`. Where the browser has none, it falls
|
|
1618
|
+
* back to `@analytics/router-utils`, which reports path changes only.
|
|
1619
|
+
*
|
|
1620
|
+
* @param navigated - Called once the URL has changed
|
|
1621
|
+
* @returns Stops the calls. router-utils has no way to detach, so in the fallback its listener
|
|
1622
|
+
* stays and only stops calling back
|
|
1623
|
+
*/
|
|
1624
|
+
const onNavigation = (navigated) => {
|
|
1625
|
+
const navigation = window.navigation;
|
|
1626
|
+
if (!navigation) {
|
|
1627
|
+
let active = true;
|
|
1628
|
+
onRouteChange(() => {
|
|
1629
|
+
if (active) {
|
|
1630
|
+
navigated();
|
|
1631
|
+
}
|
|
1632
|
+
});
|
|
1633
|
+
return () => {
|
|
1634
|
+
active = false;
|
|
1635
|
+
};
|
|
1636
|
+
}
|
|
1637
|
+
const pageOf = () => `${window.location.pathname}${window.location.search}`;
|
|
1638
|
+
let current = pageOf();
|
|
1639
|
+
const listener = () => {
|
|
1640
|
+
const next = pageOf();
|
|
1641
|
+
// Routers also save their own state on the URL they are on
|
|
1642
|
+
if (next !== current) {
|
|
1643
|
+
current = next;
|
|
1644
|
+
navigated();
|
|
1645
|
+
}
|
|
1646
|
+
};
|
|
1647
|
+
navigation.addEventListener('currententrychange', listener);
|
|
1648
|
+
return () => navigation.removeEventListener('currententrychange', listener);
|
|
1649
|
+
};
|
|
1650
|
+
/**
|
|
1651
|
+
* Calls back each time the browser restores the page from its back/forward cache (`pageshow`
|
|
1652
|
+
* with `persisted`). The page comes back as the visitor left it, its scripts still running, so
|
|
1653
|
+
* nothing that runs on a load runs again, and onNavigation reports nothing: the visitor is
|
|
1654
|
+
* back on the same page, not on another route.
|
|
1655
|
+
*
|
|
1656
|
+
* @param restored - Called on each restore
|
|
1657
|
+
* @returns Stops the calls
|
|
1658
|
+
*/
|
|
1659
|
+
const onPageRestore = (restored) => {
|
|
1660
|
+
const listener = (event) => {
|
|
1661
|
+
if (event.persisted) {
|
|
1662
|
+
restored();
|
|
1663
|
+
}
|
|
1664
|
+
};
|
|
1665
|
+
window.addEventListener('pageshow', listener);
|
|
1666
|
+
return () => window.removeEventListener('pageshow', listener);
|
|
1667
|
+
};
|
|
1668
|
+
|
|
1669
|
+
/**
|
|
1670
|
+
* Tells whether a URL rule from `isUserIncluded` matches a URL.
|
|
1671
|
+
*
|
|
1672
|
+
* @param pattern - The rule, a regular expression in a string
|
|
1673
|
+
* @param href - The URL
|
|
1674
|
+
* @returns False when the rule is missing or is not a valid expression
|
|
1675
|
+
*/
|
|
1676
|
+
const matchesRule = (pattern, href) => {
|
|
1677
|
+
if (!pattern) {
|
|
1678
|
+
return false;
|
|
1679
|
+
}
|
|
1680
|
+
// dotCMS writes the rules for a lowercase address and its own experiments script tests
|
|
1681
|
+
// them that way: the part before the query in lowercase, the query as it is
|
|
1682
|
+
const query = href.indexOf('?');
|
|
1683
|
+
const url = query < 0 ? href.toLowerCase() : href.slice(0, query).toLowerCase() + href.slice(query);
|
|
1684
|
+
try {
|
|
1685
|
+
return new RegExp(pattern).test(url);
|
|
1686
|
+
}
|
|
1687
|
+
catch {
|
|
1688
|
+
return false;
|
|
1689
|
+
}
|
|
1690
|
+
};
|
|
1691
|
+
/**
|
|
1692
|
+
* The warning for a route that renders another variant than the one its URL asks for.
|
|
1693
|
+
*
|
|
1694
|
+
* @param mark - The experiment and the variant the server rendered
|
|
1695
|
+
* @param variant - The variant the URL asks for
|
|
1696
|
+
* @returns The message
|
|
1697
|
+
*/
|
|
1698
|
+
const routeIgnoresVariantMessage = (mark, variant) => `this URL asks for ${variant} of experiment ${mark.experimentId}, but the server rendered ${mark.variant}. ` +
|
|
1699
|
+
'The route does not pass variantName to its page request, so the page is shown as it is and the visit is not counted in the experiment.';
|
|
1700
|
+
/**
|
|
1701
|
+
* The page a URL shows: its path and query, which the route renders. The hash changes nothing.
|
|
1702
|
+
*
|
|
1703
|
+
* @param href - The URL
|
|
1704
|
+
* @returns The path and the query
|
|
1705
|
+
*/
|
|
1706
|
+
const pageOf = (href) => {
|
|
1707
|
+
const url = new URL(href);
|
|
1708
|
+
return url.pathname + url.search;
|
|
1709
|
+
};
|
|
1710
|
+
/**
|
|
1711
|
+
* The URL the page's boot script is replacing the page with, if it is.
|
|
1712
|
+
*
|
|
1713
|
+
* @returns The variant's URL, or undefined when the script did not redirect
|
|
1714
|
+
*/
|
|
1715
|
+
const bootRedirect = () => {
|
|
1716
|
+
const state = window[decision.BOOT_STATE_KEY];
|
|
1717
|
+
return state && typeof state.redirectedTo === 'string'
|
|
1718
|
+
? state.redirectedTo
|
|
1719
|
+
: undefined;
|
|
1720
|
+
};
|
|
1721
|
+
/**
|
|
1722
|
+
* Creates the engine that assigns the visitor, holds and releases pageviews, redirects to
|
|
1723
|
+
* the assigned variant and fills `context.experiments`.
|
|
1724
|
+
*
|
|
1725
|
+
* @param options - dotCMS origin, the assignment timeout and a logger
|
|
1726
|
+
* @returns The engine
|
|
1727
|
+
*/
|
|
1728
|
+
const createExperimentsEngine = ({ dotcmsUrl, timeoutMs, log, warn, onError, navigate = leavePageFor, pages = 'markup' }) => {
|
|
1729
|
+
let assignments = loadAssignments();
|
|
1730
|
+
let disabled = isDisabledForNow();
|
|
1731
|
+
let inflight = null;
|
|
1732
|
+
// URLs already warned about, so a page warns once however many pageviews it sends
|
|
1733
|
+
const warnedUrls = new Set();
|
|
1734
|
+
// The variant's URL, when prepare replaced the page
|
|
1735
|
+
let preparedRedirect;
|
|
1736
|
+
// One decision per page visit and rendered experiment, shared by decide and the mark observer
|
|
1737
|
+
const decisions = new Map();
|
|
1738
|
+
let stopObserving;
|
|
1739
|
+
// Navigations inside the page so far. A visit is the page plus this count: a visitor who
|
|
1740
|
+
// leaves and comes back during a wait starts a new visit, decided and counted on its own
|
|
1741
|
+
let navigations = 0;
|
|
1742
|
+
// The visit whose page runs an experiment the visitor is in: only its events carry them
|
|
1743
|
+
let visitInExperiment;
|
|
1744
|
+
// The hiding rule shows the content after DECISION_TIMEOUT_MS on its own: a longer wait
|
|
1745
|
+
// would show the original, then replace it with the variant
|
|
1746
|
+
const waitMs = Math.min(timeoutMs, decision.DECISION_TIMEOUT_MS);
|
|
1747
|
+
if (timeoutMs > decision.DECISION_TIMEOUT_MS) {
|
|
1748
|
+
warn(`experiments.timeout is ${timeoutMs} ms; the page waits ${decision.DECISION_TIMEOUT_MS} ms at most, as long as the experiment's content stays hidden`);
|
|
1749
|
+
}
|
|
1750
|
+
const stopCountingNavigations = pages === 'markup'
|
|
1751
|
+
? onNavigation(() => {
|
|
1752
|
+
navigations += 1;
|
|
1753
|
+
})
|
|
1754
|
+
: undefined;
|
|
1755
|
+
const findAssignment = (experimentId) => assignments?.experiments.find((experiment) => experiment.id === experimentId);
|
|
1756
|
+
/**
|
|
1757
|
+
* Warns when an experiment the visitor is in runs on the current URL, by its URL rule,
|
|
1758
|
+
* while nothing on the page carries experiment marks: the experiment would never show.
|
|
1759
|
+
*/
|
|
1760
|
+
const warnIfUnmarked = () => {
|
|
1761
|
+
const href = window.location.href;
|
|
1762
|
+
const experiment = assignments?.experiments.find((candidate) => matchesRule(candidate.regexs?.isExperimentPage, href));
|
|
1763
|
+
if (!experiment || warnedUrls.has(href)) {
|
|
1764
|
+
return;
|
|
1765
|
+
}
|
|
1766
|
+
warnedUrls.add(href);
|
|
1767
|
+
warn(`experiment "${experiment.name}" (${experiment.id}) runs on this page, but nothing on it carries the experiment's marks. ` +
|
|
1768
|
+
'Wrap the content the experiment varies in DotCMSExperiment (@dotcms/events/react), or print experimentMarkup (@dotcms/events/markup): ' +
|
|
1769
|
+
'until then the visitor sees the original and the experiment records nothing.');
|
|
1770
|
+
};
|
|
1771
|
+
const check = (force = false) => {
|
|
1772
|
+
if (disabled) {
|
|
1773
|
+
return Promise.resolve();
|
|
1774
|
+
}
|
|
1775
|
+
if (inflight) {
|
|
1776
|
+
return inflight;
|
|
1777
|
+
}
|
|
1778
|
+
if (!force && !isCheckDue(assignments)) {
|
|
1779
|
+
return Promise.resolve();
|
|
1780
|
+
}
|
|
1781
|
+
log('asking isUserIncluded');
|
|
1782
|
+
inflight = fetchIsUserIncluded(dotcmsUrl, assignments?.evaluatedIds ?? [])
|
|
1783
|
+
.then((result) => {
|
|
1784
|
+
if (result.status === 'disabled') {
|
|
1785
|
+
disabled = true;
|
|
1786
|
+
disableForADay();
|
|
1787
|
+
log('isUserIncluded answered 403: experiments off for a day');
|
|
1788
|
+
onError?.({
|
|
1789
|
+
code: 'EXPERIMENTS',
|
|
1790
|
+
status: 403,
|
|
1791
|
+
message: 'isUserIncluded answered 403: experiments are off for the site, for a day'
|
|
1792
|
+
});
|
|
1793
|
+
return;
|
|
1794
|
+
}
|
|
1795
|
+
if (result.status === 'failed') {
|
|
1796
|
+
log('isUserIncluded failed; keeping the stored assignments');
|
|
1797
|
+
onError?.({
|
|
1798
|
+
code: 'EXPERIMENTS',
|
|
1799
|
+
...(result.httpStatus !== undefined && { status: result.httpStatus }),
|
|
1800
|
+
message: 'isUserIncluded failed: the stored assignments stay'
|
|
1801
|
+
});
|
|
1802
|
+
return;
|
|
1803
|
+
}
|
|
1804
|
+
assignments = saveAssignments(result.entity, assignments);
|
|
1805
|
+
leaveEndedExperiments(result.entity.excludedExperimentIdsEnded);
|
|
1806
|
+
markCheckedThisTab();
|
|
1807
|
+
log('assignments', assignments.experiments.map((experiment) => `${experiment.id}: ${experiment.variant.name}`));
|
|
1808
|
+
})
|
|
1809
|
+
.finally(() => {
|
|
1810
|
+
inflight = null;
|
|
1811
|
+
});
|
|
1812
|
+
return inflight;
|
|
1813
|
+
};
|
|
1814
|
+
/** The stored experiment whose `isExperimentPage` rule matches the current URL. */
|
|
1815
|
+
const experimentOnThisPage = () => assignments?.experiments.find((experiment) => experiment.expiresAt > Date.now() &&
|
|
1816
|
+
matchesRule(experiment.regexs?.isExperimentPage, window.location.href));
|
|
1817
|
+
const decisionFor = (mark, href = window.location.href) => decision.decideVariant({
|
|
1818
|
+
experimentId: mark.experimentId,
|
|
1819
|
+
rendered: mark.variant,
|
|
1820
|
+
href,
|
|
1821
|
+
stored: assignments,
|
|
1822
|
+
disabled,
|
|
1823
|
+
now: Date.now(),
|
|
1824
|
+
param: decision.VARIANT_QUERY_PARAM,
|
|
1825
|
+
defaultVariant: decision.DEFAULT_VARIANT
|
|
1826
|
+
});
|
|
1827
|
+
const waitForAnswer = () => Promise.race([
|
|
1828
|
+
check(true).then(() => 'answered'),
|
|
1829
|
+
new Promise((resolve) => setTimeout(() => resolve('timeout'), waitMs))
|
|
1830
|
+
]);
|
|
1831
|
+
/**
|
|
1832
|
+
* The mark of a page dotCMS rendered: its experiment from the URL rule, and the variant its
|
|
1833
|
+
* contentlet wrappers carry. On a first visit nothing stored has the rule yet, so it waits
|
|
1834
|
+
* for `isUserIncluded`, up to the timeout, with the contentlets hidden.
|
|
1835
|
+
*
|
|
1836
|
+
* @returns The mark, or null when no experiment runs on the page for this visitor
|
|
1837
|
+
*/
|
|
1838
|
+
const contentletsMark = async () => {
|
|
1839
|
+
if (disabled) {
|
|
1840
|
+
return null;
|
|
1841
|
+
}
|
|
1842
|
+
if (!assignments && (await waitForAnswer()) === 'timeout') {
|
|
1843
|
+
log('the assignment timed out; showing the page without the experiment');
|
|
1844
|
+
return null;
|
|
1845
|
+
}
|
|
1846
|
+
const experiment = experimentOnThisPage();
|
|
1847
|
+
return experiment
|
|
1848
|
+
? { experimentId: experiment.id, variant: readRenderedVariant(window.location.href) }
|
|
1849
|
+
: null;
|
|
1850
|
+
};
|
|
1851
|
+
/**
|
|
1852
|
+
* Decides one experiment the page renders: shows it, or leaves for the assigned variant.
|
|
1853
|
+
*
|
|
1854
|
+
* @param mark - The experiment and the variant the server rendered
|
|
1855
|
+
* @param href - The page's URL when its decision started
|
|
1856
|
+
* @param reveal - Shows what the page hid while it waited
|
|
1857
|
+
* @returns How it ended; `left` when the visitor went to another page during the wait
|
|
1858
|
+
*/
|
|
1859
|
+
const decideMark = async (mark, href, reveal) => {
|
|
1860
|
+
// The same decision the boot script makes while the HTML is parsed
|
|
1861
|
+
let decision = decisionFor(mark, href);
|
|
1862
|
+
const visit = navigations;
|
|
1863
|
+
if (decision.kind === 'unknown') {
|
|
1864
|
+
log(`holding the pageview for ${mark.experimentId}`);
|
|
1865
|
+
const outcome = await waitForAnswer();
|
|
1866
|
+
// A click during the wait, even one that comes back: the next page is decided on
|
|
1867
|
+
// its own, and this visit neither redirects to a variant of another URL nor counts
|
|
1868
|
+
if (navigations !== visit || pageOf(window.location.href) !== pageOf(href)) {
|
|
1869
|
+
log(`left ${pageOf(href)} while ${mark.experimentId} was decided`);
|
|
1870
|
+
return 'left';
|
|
1871
|
+
}
|
|
1872
|
+
if (outcome === 'timeout') {
|
|
1873
|
+
log('the assignment timed out; showing the page without the experiment');
|
|
1874
|
+
reveal();
|
|
1875
|
+
return 'shown';
|
|
1876
|
+
}
|
|
1877
|
+
decision = decisionFor(mark, href);
|
|
1878
|
+
}
|
|
1879
|
+
switch (decision.kind) {
|
|
1880
|
+
case 'redirect':
|
|
1881
|
+
log(`rendered ${mark.variant}, assigned ${decision.variant}: ${decision.url}`);
|
|
1882
|
+
navigate(decision.url);
|
|
1883
|
+
return 'redirected';
|
|
1884
|
+
case 'assigned': {
|
|
1885
|
+
const assigned = findAssignment(mark.experimentId);
|
|
1886
|
+
if (assigned) {
|
|
1887
|
+
joinSessionExperiment(getSessionId(), {
|
|
1888
|
+
id: assigned.id,
|
|
1889
|
+
running_id: assigned.runningId,
|
|
1890
|
+
variant: assigned.variant.name
|
|
1891
|
+
});
|
|
1892
|
+
visitInExperiment = visit;
|
|
1893
|
+
}
|
|
1894
|
+
log(`showing ${decision.variant} of ${mark.experimentId}`);
|
|
1895
|
+
break;
|
|
1896
|
+
}
|
|
1897
|
+
case 'ignored':
|
|
1898
|
+
warn(routeIgnoresVariantMessage(mark, decision.variant));
|
|
1899
|
+
break;
|
|
1900
|
+
default:
|
|
1901
|
+
log(`not in ${mark.experimentId}; showing the page`);
|
|
1902
|
+
}
|
|
1903
|
+
reveal();
|
|
1904
|
+
return 'shown';
|
|
1905
|
+
};
|
|
1906
|
+
/**
|
|
1907
|
+
* Decides a marked experiment once per page, whoever asks first: decide, for the pageview,
|
|
1908
|
+
* or the observer, for marks that arrive later.
|
|
1909
|
+
*
|
|
1910
|
+
* @param mark - The experiment and the variant the server rendered
|
|
1911
|
+
* @param href - The page's URL
|
|
1912
|
+
* @returns How the decision ended
|
|
1913
|
+
*/
|
|
1914
|
+
const decideOnce = (mark, href) => {
|
|
1915
|
+
const key = `${navigations}\n${pageOf(href)}\n${mark.experimentId}\n${mark.variant}`;
|
|
1916
|
+
let outcome = decisions.get(key);
|
|
1917
|
+
if (!outcome) {
|
|
1918
|
+
outcome = decideMark(mark, href, () => revealRows(mark.experimentId, mark.variant));
|
|
1919
|
+
decisions.set(key, outcome);
|
|
1920
|
+
// A visit left mid-decision is never asked for again: its key has an older count
|
|
1921
|
+
void outcome.then((result) => {
|
|
1922
|
+
if (result === 'left') {
|
|
1923
|
+
decisions.delete(key);
|
|
1924
|
+
}
|
|
1925
|
+
});
|
|
1926
|
+
}
|
|
1927
|
+
return outcome;
|
|
1928
|
+
};
|
|
1929
|
+
/**
|
|
1930
|
+
* Decides every experiment the page renders, in document order; the first redirect wins.
|
|
1931
|
+
*
|
|
1932
|
+
* @param marks - The page's marks
|
|
1933
|
+
* @param href - The page's URL
|
|
1934
|
+
* @returns The page's decision
|
|
1935
|
+
*/
|
|
1936
|
+
const decideMarks = async (marks, href) => {
|
|
1937
|
+
for (const mark of marks) {
|
|
1938
|
+
const outcome = await decideOnce(mark, href);
|
|
1939
|
+
if (outcome === 'redirected') {
|
|
1940
|
+
return { redirected: true };
|
|
1941
|
+
}
|
|
1942
|
+
if (outcome === 'left') {
|
|
1943
|
+
return { redirected: false, left: true };
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
return { redirected: false };
|
|
1947
|
+
};
|
|
1948
|
+
/**
|
|
1949
|
+
* Waits for the marks of a page whose experiment runs on its URL, by the stored rule, while
|
|
1950
|
+
* the page has none yet: content behind Suspense streams in after the page loads, and after
|
|
1951
|
+
* a route change.
|
|
1952
|
+
*
|
|
1953
|
+
* @returns The marks, or none after the timeout
|
|
1954
|
+
*/
|
|
1955
|
+
const waitForMarks = () => new Promise((resolve) => {
|
|
1956
|
+
let stop = () => undefined;
|
|
1957
|
+
const timer = setTimeout(() => {
|
|
1958
|
+
stop();
|
|
1959
|
+
resolve([]);
|
|
1960
|
+
}, waitMs);
|
|
1961
|
+
stop = observeMarks(() => {
|
|
1962
|
+
const marks = readPageExperiments();
|
|
1963
|
+
if (marks.length) {
|
|
1964
|
+
clearTimeout(timer);
|
|
1965
|
+
stop();
|
|
1966
|
+
resolve(marks);
|
|
1967
|
+
}
|
|
1968
|
+
});
|
|
1969
|
+
});
|
|
1970
|
+
/**
|
|
1971
|
+
* From the first decision on, decides the marks that arrive later, as soon as they are in
|
|
1972
|
+
* the page: streamed content, or a route rendered after its pageview.
|
|
1973
|
+
*/
|
|
1974
|
+
const watchLateMarks = () => {
|
|
1975
|
+
stopObserving ?? (stopObserving = observeMarks(() => {
|
|
1976
|
+
const href = window.location.href;
|
|
1977
|
+
readPageExperiments().forEach((mark) => void decideOnce(mark, href));
|
|
1978
|
+
}));
|
|
1979
|
+
};
|
|
1980
|
+
return {
|
|
1981
|
+
prepare: () => {
|
|
1982
|
+
if (pages !== 'contentlets' || disabled) {
|
|
1983
|
+
return false;
|
|
1984
|
+
}
|
|
1985
|
+
const experiment = experimentOnThisPage();
|
|
1986
|
+
if (!experiment) {
|
|
1987
|
+
// A first visit: nothing stored says whether this page runs an experiment
|
|
1988
|
+
if (!assignments) {
|
|
1989
|
+
hideContentlets();
|
|
1990
|
+
}
|
|
1991
|
+
return false;
|
|
1992
|
+
}
|
|
1993
|
+
// The body is not parsed yet: the URL says which variant dotCMS renders
|
|
1994
|
+
const decision = decisionFor({
|
|
1995
|
+
experimentId: experiment.id,
|
|
1996
|
+
variant: readRenderedVariant(window.location.href)
|
|
1997
|
+
});
|
|
1998
|
+
if (decision.kind !== 'redirect') {
|
|
1999
|
+
return false;
|
|
2000
|
+
}
|
|
2001
|
+
log(`replacing the page with ${decision.variant} of ${experiment.id}: ${decision.url}`);
|
|
2002
|
+
preparedRedirect = decision.url;
|
|
2003
|
+
navigate(decision.url);
|
|
2004
|
+
return true;
|
|
2005
|
+
},
|
|
2006
|
+
start: () => {
|
|
2007
|
+
void check();
|
|
2008
|
+
},
|
|
2009
|
+
decide: async () => {
|
|
2010
|
+
// The boot script decided from storage while the HTML was parsed
|
|
2011
|
+
const redirectedTo = preparedRedirect ?? bootRedirect();
|
|
2012
|
+
if (redirectedTo) {
|
|
2013
|
+
log(`the page script is replacing the page: ${redirectedTo}`);
|
|
2014
|
+
return { redirected: true };
|
|
2015
|
+
}
|
|
2016
|
+
const href = window.location.href;
|
|
2017
|
+
const visit = navigations;
|
|
2018
|
+
let marks = readPageExperiments();
|
|
2019
|
+
if (pages === 'markup') {
|
|
2020
|
+
watchLateMarks();
|
|
2021
|
+
// The page runs an experiment by its URL rule, but its marks are not in yet
|
|
2022
|
+
if (!marks.length && !disabled && experimentOnThisPage()) {
|
|
2023
|
+
marks = await waitForMarks();
|
|
2024
|
+
if (navigations !== visit || pageOf(window.location.href) !== pageOf(href)) {
|
|
2025
|
+
return { redirected: false, left: true };
|
|
2026
|
+
}
|
|
2027
|
+
if (!marks.length) {
|
|
2028
|
+
warnIfUnmarked();
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
return decideMarks(marks, href);
|
|
2032
|
+
}
|
|
2033
|
+
// A traditional page that prints the experiment markup is decided by it
|
|
2034
|
+
if (marks.length) {
|
|
2035
|
+
return decideMarks(marks, href);
|
|
2036
|
+
}
|
|
2037
|
+
const mark = await contentletsMark();
|
|
2038
|
+
if (!mark) {
|
|
2039
|
+
revealContentlets();
|
|
2040
|
+
return { redirected: false };
|
|
2041
|
+
}
|
|
2042
|
+
const outcome = await decideMark(mark, href, revealContentlets);
|
|
2043
|
+
if (outcome === 'left') {
|
|
2044
|
+
return { redirected: false, left: true };
|
|
2045
|
+
}
|
|
2046
|
+
return { redirected: outcome === 'redirected' };
|
|
2047
|
+
},
|
|
2048
|
+
contextExperiments: (sessionId) => visitInExperiment === navigations ? getSessionExperiments(sessionId) : [],
|
|
2049
|
+
stop: () => {
|
|
2050
|
+
stopObserving?.();
|
|
2051
|
+
stopObserving = undefined;
|
|
2052
|
+
stopCountingNavigations?.();
|
|
2053
|
+
}
|
|
2054
|
+
};
|
|
2055
|
+
};
|
|
2056
|
+
|
|
2057
|
+
/**
|
|
2058
|
+
* Analytics.js plugin that adds the session's `context.experiments` to every event sent from
|
|
2059
|
+
* a page that runs an experiment the visitor is in. The engine's `isUserIncluded` check starts
|
|
2060
|
+
* in `init`, before Analytics.js loads.
|
|
2061
|
+
*
|
|
2062
|
+
* It must come right after the identity plugin, which creates `context`.
|
|
2063
|
+
*
|
|
2064
|
+
* @param engine - The experiments engine
|
|
2065
|
+
* @returns The plugin
|
|
2066
|
+
*/
|
|
2067
|
+
const experimentsPlugin = (engine) => {
|
|
2068
|
+
const withExperiments = ({ payload }) => {
|
|
2069
|
+
const context = payload?.context;
|
|
2070
|
+
if (!context?.session_id) {
|
|
2071
|
+
return payload;
|
|
2072
|
+
}
|
|
2073
|
+
const experiments = engine.contextExperiments(context.session_id);
|
|
2074
|
+
if (experiments.length === 0) {
|
|
2075
|
+
return payload;
|
|
2076
|
+
}
|
|
2077
|
+
return { ...payload, context: { ...context, experiments } };
|
|
2078
|
+
};
|
|
2079
|
+
return {
|
|
2080
|
+
name: 'dot-events-experiments',
|
|
2081
|
+
pageStart: withExperiments,
|
|
2082
|
+
trackStart: withExperiments,
|
|
2083
|
+
loaded: () => true
|
|
2084
|
+
};
|
|
2085
|
+
};
|
|
2086
|
+
|
|
2087
|
+
/*
|
|
2088
|
+
* Constants of the impression tracker.
|
|
2089
|
+
*/
|
|
2090
|
+
/**
|
|
2091
|
+
* Impression tracking configuration constants
|
|
2092
|
+
*/
|
|
2093
|
+
/**
|
|
2094
|
+
* Default minimum percentage of element that must be visible (0.0 to 1.0)
|
|
2095
|
+
*/
|
|
2096
|
+
const DEFAULT_IMPRESSION_VISIBILITY_THRESHOLD = 0.5;
|
|
2097
|
+
/**
|
|
2098
|
+
* Default minimum time in milliseconds element must be visible
|
|
2099
|
+
*/
|
|
2100
|
+
const DEFAULT_IMPRESSION_DWELL_MS = 750;
|
|
2101
|
+
/**
|
|
2102
|
+
* Default maximum number of elements to track (performance limit)
|
|
2103
|
+
*/
|
|
2104
|
+
const DEFAULT_IMPRESSION_MAX_NODES = 100;
|
|
2105
|
+
/**
|
|
2106
|
+
* Default impression tracking configuration
|
|
2107
|
+
*/
|
|
2108
|
+
const DEFAULT_IMPRESSION_CONFIG = {
|
|
2109
|
+
visibilityThreshold: DEFAULT_IMPRESSION_VISIBILITY_THRESHOLD,
|
|
2110
|
+
dwellMs: DEFAULT_IMPRESSION_DWELL_MS,
|
|
2111
|
+
maxNodes: DEFAULT_IMPRESSION_MAX_NODES
|
|
2112
|
+
};
|
|
2113
|
+
/**
|
|
2114
|
+
* Event type for content impressions
|
|
2115
|
+
* Must match DotCMSPredefinedEventType.CONTENT_IMPRESSION
|
|
2116
|
+
*/
|
|
2117
|
+
const IMPRESSION_EVENT_TYPE = 'content_impression';
|
|
2118
|
+
|
|
2119
|
+
/**
|
|
2120
|
+
* Utility functions for impression tracking
|
|
2121
|
+
*/
|
|
2122
|
+
/**
|
|
2123
|
+
* Checks if an element meets a specific visibility threshold
|
|
2124
|
+
* @param element - The HTML element to check
|
|
2125
|
+
* @param threshold - The required visibility ratio (0.0 to 1.0)
|
|
2126
|
+
* @returns True if the element meets or exceeds the threshold
|
|
2127
|
+
*/
|
|
2128
|
+
function isElementMeetingVisibilityThreshold(element, threshold) {
|
|
2129
|
+
const visibilityRatio = calculateElementVisibilityRatio(element);
|
|
2130
|
+
return visibilityRatio >= threshold;
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
var _DotCMSImpressionTracker_observer, _DotCMSImpressionTracker_mutationObserver, _DotCMSImpressionTracker_elementImpressionStates, _DotCMSImpressionTracker_sessionTrackedImpressions, _DotCMSImpressionTracker_impressionConfig, _DotCMSImpressionTracker_stopWatchingNavigations, _DotCMSImpressionTracker_stopWatchingRestores, _DotCMSImpressionTracker_subscribers, _DotCMSImpressionTracker_logger, _DotCMSImpressionTracker_handleRescan;
|
|
2134
|
+
/**
|
|
2135
|
+
* Tracks content impressions using IntersectionObserver and dwell time.
|
|
2136
|
+
* Emits events through subscriptions when impressions are detected.
|
|
2137
|
+
*/
|
|
2138
|
+
class DotCMSImpressionTracker {
|
|
2139
|
+
constructor(config) {
|
|
2140
|
+
_DotCMSImpressionTracker_observer.set(this, null);
|
|
2141
|
+
_DotCMSImpressionTracker_mutationObserver.set(this, null);
|
|
2142
|
+
_DotCMSImpressionTracker_elementImpressionStates.set(this, new Map());
|
|
2143
|
+
_DotCMSImpressionTracker_sessionTrackedImpressions.set(this, new Set());
|
|
2144
|
+
_DotCMSImpressionTracker_impressionConfig.set(this, void 0);
|
|
2145
|
+
_DotCMSImpressionTracker_stopWatchingNavigations.set(this, null);
|
|
2146
|
+
_DotCMSImpressionTracker_stopWatchingRestores.set(this, null);
|
|
2147
|
+
_DotCMSImpressionTracker_subscribers.set(this, new Set());
|
|
2148
|
+
_DotCMSImpressionTracker_logger.set(this, void 0);
|
|
2149
|
+
/** Scans again on CONTENTLET_RESCAN_EVENT, sent when hidden contentlets are shown */
|
|
2150
|
+
_DotCMSImpressionTracker_handleRescan.set(this, () => {
|
|
2151
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").debug('Rescan requested');
|
|
2152
|
+
this.findAndObserveContentletElements();
|
|
2153
|
+
});
|
|
2154
|
+
__classPrivateFieldSet(this, _DotCMSImpressionTracker_logger, createPluginLogger('Impression', config), "f");
|
|
2155
|
+
__classPrivateFieldSet(this, _DotCMSImpressionTracker_impressionConfig, this.resolveImpressionConfig(config.impressions), "f");
|
|
2156
|
+
}
|
|
2157
|
+
/**
|
|
2158
|
+
* Subscribe to impression events
|
|
2159
|
+
* @param callback - Function called when impression is detected
|
|
2160
|
+
* @returns Subscription object with unsubscribe method
|
|
2161
|
+
*/
|
|
2162
|
+
onImpression(callback) {
|
|
2163
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_subscribers, "f").add(callback);
|
|
2164
|
+
return {
|
|
2165
|
+
unsubscribe: () => {
|
|
2166
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_subscribers, "f").delete(callback);
|
|
2167
|
+
}
|
|
2168
|
+
};
|
|
2169
|
+
}
|
|
2170
|
+
/** Notifies all subscribers of an impression */
|
|
2171
|
+
notifySubscribers(eventName, payload) {
|
|
2172
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_subscribers, "f").forEach((callback) => {
|
|
2173
|
+
try {
|
|
2174
|
+
callback(eventName, payload);
|
|
2175
|
+
}
|
|
2176
|
+
catch (error) {
|
|
2177
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").error('Error in impression subscriber:', error);
|
|
2178
|
+
}
|
|
2179
|
+
});
|
|
2180
|
+
}
|
|
2181
|
+
/**
|
|
2182
|
+
* Merges user config with defaults. IntersectionObserver throws on a visibility threshold
|
|
2183
|
+
* outside 0 to 1, so one outside that range is clamped to it, with a warning
|
|
2184
|
+
*/
|
|
2185
|
+
resolveImpressionConfig(userConfig) {
|
|
2186
|
+
// If boolean/undefined, use defaults
|
|
2187
|
+
if (typeof userConfig !== 'object' || userConfig === null) {
|
|
2188
|
+
return { ...DEFAULT_IMPRESSION_CONFIG };
|
|
2189
|
+
}
|
|
2190
|
+
// Merge user config with defaults
|
|
2191
|
+
const merged = { ...DEFAULT_IMPRESSION_CONFIG, ...userConfig };
|
|
2192
|
+
const requested = merged.visibilityThreshold;
|
|
2193
|
+
const visibilityThreshold = Number.isFinite(requested)
|
|
2194
|
+
? Math.min(1, Math.max(0, requested))
|
|
2195
|
+
: DEFAULT_IMPRESSION_CONFIG.visibilityThreshold;
|
|
2196
|
+
if (visibilityThreshold !== requested) {
|
|
2197
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").warn(`visibilityThreshold must be between 0 and 1, got ${requested}: using ${visibilityThreshold}`);
|
|
2198
|
+
}
|
|
2199
|
+
return { ...merged, visibilityThreshold };
|
|
2200
|
+
}
|
|
2201
|
+
/** Initializes tracking: sets up observers, finds contentlets, handles visibility/navigation */
|
|
2202
|
+
initialize() {
|
|
2203
|
+
// Early return if SSR
|
|
2204
|
+
if (!isBrowser$1()) {
|
|
2205
|
+
return;
|
|
2206
|
+
}
|
|
2207
|
+
// Setup IntersectionObserver
|
|
2208
|
+
this.initializeIntersectionObserver();
|
|
2209
|
+
// Wait for DOM to be ready before scanning
|
|
2210
|
+
// The delay allows React/Next.js to finish initial rendering
|
|
2211
|
+
// before searching for contentlet elements
|
|
2212
|
+
if (typeof window !== 'undefined') {
|
|
2213
|
+
setTimeout(() => {
|
|
2214
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").debug('Running initial scan after timeout...');
|
|
2215
|
+
// Find and observe contentlet elements
|
|
2216
|
+
this.findAndObserveContentletElements();
|
|
2217
|
+
}, INITIAL_SCAN_DELAY_MS);
|
|
2218
|
+
}
|
|
2219
|
+
// Setup mutation observer for dynamic content
|
|
2220
|
+
this.initializeDynamicContentDetector();
|
|
2221
|
+
// Scan again when hidden contentlets are shown without a DOM mutation
|
|
2222
|
+
window.addEventListener(CONTENTLET_RESCAN_EVENT, __classPrivateFieldGet(this, _DotCMSImpressionTracker_handleRescan, "f"));
|
|
2223
|
+
// Listen for visibility changes to pause/resume tracking
|
|
2224
|
+
this.initializePageVisibilityHandler();
|
|
2225
|
+
// Listen for page navigation to reset tracking
|
|
2226
|
+
this.initializePageNavigationHandler();
|
|
2227
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").info('Impression tracking initialized with config:', __classPrivateFieldGet(this, _DotCMSImpressionTracker_impressionConfig, "f"));
|
|
2228
|
+
}
|
|
2229
|
+
/** Sets up IntersectionObserver with configured visibility threshold */
|
|
2230
|
+
initializeIntersectionObserver() {
|
|
2231
|
+
const options = {
|
|
2232
|
+
root: null, // Use viewport as root
|
|
2233
|
+
rootMargin: '0px',
|
|
2234
|
+
threshold: __classPrivateFieldGet(this, _DotCMSImpressionTracker_impressionConfig, "f").visibilityThreshold
|
|
2235
|
+
};
|
|
2236
|
+
__classPrivateFieldSet(this, _DotCMSImpressionTracker_observer, new IntersectionObserver((entries) => {
|
|
2237
|
+
this.processIntersectionChanges(entries);
|
|
2238
|
+
}, options), "f");
|
|
2239
|
+
}
|
|
2240
|
+
/** Finds contentlets in DOM, validates them, and starts observing (respects maxNodes limit) */
|
|
2241
|
+
findAndObserveContentletElements() {
|
|
2242
|
+
if (!__classPrivateFieldGet(this, _DotCMSImpressionTracker_observer, "f"))
|
|
2243
|
+
return;
|
|
2244
|
+
const contentlets = findContentlets();
|
|
2245
|
+
if (contentlets.length === 0) {
|
|
2246
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").warn('No contentlets found to track');
|
|
2247
|
+
return;
|
|
2248
|
+
}
|
|
2249
|
+
// Limit to maxNodes for performance
|
|
2250
|
+
const maxNodesToTrack = Math.min(contentlets.length, __classPrivateFieldGet(this, _DotCMSImpressionTracker_impressionConfig, "f").maxNodes);
|
|
2251
|
+
let observedCount = 0;
|
|
2252
|
+
for (let i = 0; i < maxNodesToTrack; i++) {
|
|
2253
|
+
const element = contentlets[i];
|
|
2254
|
+
if (!element) {
|
|
2255
|
+
continue;
|
|
2256
|
+
}
|
|
2257
|
+
const identifier = extractContentletIdentifier(element);
|
|
2258
|
+
if (identifier) {
|
|
2259
|
+
// Skip elements that shouldn't be tracked
|
|
2260
|
+
const skipReason = this.shouldSkipElement(element);
|
|
2261
|
+
if (skipReason) {
|
|
2262
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").debug(`Skipping element ${identifier} (${skipReason})`);
|
|
2263
|
+
continue;
|
|
2264
|
+
}
|
|
2265
|
+
// Only observe and initialize if this is a new element
|
|
2266
|
+
if (!__classPrivateFieldGet(this, _DotCMSImpressionTracker_elementImpressionStates, "f").has(identifier)) {
|
|
2267
|
+
if (!element.dataset['dotDomIndex']) {
|
|
2268
|
+
element.dataset['dotDomIndex'] = String(i);
|
|
2269
|
+
}
|
|
2270
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_observer, "f").observe(element);
|
|
2271
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_elementImpressionStates, "f").set(identifier, {
|
|
2272
|
+
timer: null,
|
|
2273
|
+
visibleSince: null,
|
|
2274
|
+
tracked: this.hasBeenTrackedInSession(identifier),
|
|
2275
|
+
element
|
|
2276
|
+
});
|
|
2277
|
+
observedCount++;
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").info(`Observing ${observedCount} contentlets`);
|
|
2282
|
+
if (contentlets.length > maxNodesToTrack) {
|
|
2283
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").warn(`${contentlets.length - maxNodesToTrack} contentlets not tracked (maxNodes limit: ${__classPrivateFieldGet(this, _DotCMSImpressionTracker_impressionConfig, "f").maxNodes})`);
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
/**
|
|
2287
|
+
* Watches for contentlets added to the DOM, and for contentlets receiving their
|
|
2288
|
+
* identifier, which the renderers print after hydration (throttled for performance)
|
|
2289
|
+
*/
|
|
2290
|
+
initializeDynamicContentDetector() {
|
|
2291
|
+
if (!isBrowser$1()) {
|
|
2292
|
+
return;
|
|
2293
|
+
}
|
|
2294
|
+
__classPrivateFieldSet(this, _DotCMSImpressionTracker_mutationObserver, createContentletObserver(() => {
|
|
2295
|
+
this.findAndObserveContentletElements();
|
|
2296
|
+
}, CONTENTLET_OBSERVER_DEBOUNCE_MS, { identifiers: true }), "f");
|
|
2297
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").info('MutationObserver enabled for dynamic content detection');
|
|
2298
|
+
}
|
|
2299
|
+
/** Cancels all timers when page is hidden (prevents false impressions) */
|
|
2300
|
+
initializePageVisibilityHandler() {
|
|
2301
|
+
document.addEventListener('visibilitychange', () => {
|
|
2302
|
+
if (document.visibilityState === 'hidden') {
|
|
2303
|
+
// Page is hidden, cancel all timers
|
|
2304
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_elementImpressionStates, "f").forEach((state) => {
|
|
2305
|
+
if (state.timer !== null) {
|
|
2306
|
+
window.clearTimeout(state.timer);
|
|
2307
|
+
state.timer = null;
|
|
2308
|
+
state.visibleSince = null;
|
|
2309
|
+
}
|
|
2310
|
+
});
|
|
2311
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").warn('Page hidden, all impression timers cancelled');
|
|
2312
|
+
}
|
|
2313
|
+
});
|
|
2314
|
+
}
|
|
2315
|
+
/**
|
|
2316
|
+
* Resets tracking when the visitor goes to another page, or comes back to this one from the
|
|
2317
|
+
* back/forward cache. It listens through onNavigation: the browser's Navigation API where
|
|
2318
|
+
* there is one, which needs no patch of `history`
|
|
2319
|
+
*/
|
|
2320
|
+
initializePageNavigationHandler() {
|
|
2321
|
+
__classPrivateFieldSet(this, _DotCMSImpressionTracker_stopWatchingNavigations, onNavigation(() => {
|
|
2322
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").warn(`Navigation detected (${window.location.pathname}), resetting impression tracking`);
|
|
2323
|
+
this.forgetPageImpressions();
|
|
2324
|
+
}), "f");
|
|
2325
|
+
// A restored page is seen again, and counts again, as after a load. Its DOM is the one
|
|
2326
|
+
// the visitor left, so nothing else scans it: observed again from scratch, the observer
|
|
2327
|
+
// reports what is in view
|
|
2328
|
+
__classPrivateFieldSet(this, _DotCMSImpressionTracker_stopWatchingRestores, onPageRestore(() => {
|
|
2329
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").info('Page restored from the back/forward cache, counting impressions again');
|
|
2330
|
+
this.forgetPageImpressions();
|
|
2331
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_observer, "f")?.disconnect();
|
|
2332
|
+
this.findAndObserveContentletElements();
|
|
2333
|
+
}), "f");
|
|
2334
|
+
}
|
|
2335
|
+
/** Forgets the page's impressions, timers and elements, so the next view counts its own */
|
|
2336
|
+
forgetPageImpressions() {
|
|
2337
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_sessionTrackedImpressions, "f").clear();
|
|
2338
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_elementImpressionStates, "f").forEach((state) => {
|
|
2339
|
+
if (state.timer !== null) {
|
|
2340
|
+
window.clearTimeout(state.timer);
|
|
2341
|
+
state.timer = null;
|
|
2342
|
+
state.visibleSince = null;
|
|
2343
|
+
}
|
|
2344
|
+
});
|
|
2345
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_elementImpressionStates, "f").clear();
|
|
2346
|
+
}
|
|
2347
|
+
/** Handles visibility changes: starts timer on enter, cancels on exit */
|
|
2348
|
+
processIntersectionChanges(entries) {
|
|
2349
|
+
// Ignore if page is not visible
|
|
2350
|
+
if (document.visibilityState !== 'visible') {
|
|
2351
|
+
return;
|
|
2352
|
+
}
|
|
2353
|
+
entries.forEach((entry) => {
|
|
2354
|
+
const element = entry.target;
|
|
2355
|
+
const identifier = extractContentletIdentifier(element);
|
|
2356
|
+
if (!identifier)
|
|
2357
|
+
return;
|
|
2358
|
+
if (entry.isIntersecting) {
|
|
2359
|
+
// Element is visible, start tracking
|
|
2360
|
+
this.startImpressionDwellTimer(identifier, element);
|
|
2361
|
+
}
|
|
2362
|
+
else {
|
|
2363
|
+
// Element is not visible, cancel tracking
|
|
2364
|
+
this.cancelImpressionDwellTimer(identifier);
|
|
2365
|
+
}
|
|
2366
|
+
});
|
|
2367
|
+
}
|
|
2368
|
+
/** Starts dwell timer; fires impression if element still visible when timer expires */
|
|
2369
|
+
startImpressionDwellTimer(identifier, element) {
|
|
2370
|
+
const state = __classPrivateFieldGet(this, _DotCMSImpressionTracker_elementImpressionStates, "f").get(identifier);
|
|
2371
|
+
if (!state)
|
|
2372
|
+
return;
|
|
2373
|
+
// Already tracked, skip
|
|
2374
|
+
if (state.tracked || this.hasBeenTrackedInSession(identifier)) {
|
|
2375
|
+
return;
|
|
2376
|
+
}
|
|
2377
|
+
// Timer already running, skip
|
|
2378
|
+
if (state.timer !== null) {
|
|
2379
|
+
return;
|
|
2380
|
+
}
|
|
2381
|
+
// Page not visible, skip
|
|
2382
|
+
if (document.visibilityState !== 'visible') {
|
|
2383
|
+
return;
|
|
2384
|
+
}
|
|
2385
|
+
// Start timer
|
|
2386
|
+
state.visibleSince = Date.now();
|
|
2387
|
+
state.element = element;
|
|
2388
|
+
state.timer = window.setTimeout(() => {
|
|
2389
|
+
// Verify element is still visible before firing (post-dwell check)
|
|
2390
|
+
if (this.isElementStillVisible(element)) {
|
|
2391
|
+
this.trackAndSendImpression(identifier, element);
|
|
2392
|
+
}
|
|
2393
|
+
else {
|
|
2394
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").warn(`Dwell timer expired for ${identifier} but element no longer visible, skipping impression`);
|
|
2395
|
+
// Clear state since we're not tracking
|
|
2396
|
+
state.timer = null;
|
|
2397
|
+
state.visibleSince = null;
|
|
2398
|
+
}
|
|
2399
|
+
}, __classPrivateFieldGet(this, _DotCMSImpressionTracker_impressionConfig, "f").dwellMs);
|
|
2400
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").debug(`Started dwell timer for ${identifier} (${__classPrivateFieldGet(this, _DotCMSImpressionTracker_impressionConfig, "f").dwellMs}ms)`);
|
|
2401
|
+
}
|
|
2402
|
+
/** Cancels active dwell timer (element left viewport before dwell time) */
|
|
2403
|
+
cancelImpressionDwellTimer(identifier) {
|
|
2404
|
+
const state = __classPrivateFieldGet(this, _DotCMSImpressionTracker_elementImpressionStates, "f").get(identifier);
|
|
2405
|
+
if (!state || state.timer === null)
|
|
2406
|
+
return;
|
|
2407
|
+
window.clearTimeout(state.timer);
|
|
2408
|
+
state.timer = null;
|
|
2409
|
+
state.visibleSince = null;
|
|
2410
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").debug(`Cancelled dwell timer for ${identifier}`);
|
|
2411
|
+
}
|
|
2412
|
+
/** Fires impression event with content & position data (page data added by enricher plugin) */
|
|
2413
|
+
trackAndSendImpression(identifier, element) {
|
|
2414
|
+
const state = __classPrivateFieldGet(this, _DotCMSImpressionTracker_elementImpressionStates, "f").get(identifier);
|
|
2415
|
+
if (!state)
|
|
2416
|
+
return;
|
|
2417
|
+
// Calculate actual dwell time
|
|
2418
|
+
const dwellTime = state.visibleSince ? Date.now() - state.visibleSince : 0;
|
|
2419
|
+
// Extract contentlet data using utility
|
|
2420
|
+
const contentletData = extractContentletData(element);
|
|
2421
|
+
// Calculate viewport metrics using utility
|
|
2422
|
+
const viewportMetrics = getViewportMetrics(element);
|
|
2423
|
+
// Read cached DOM index instead of expensive query
|
|
2424
|
+
// Falls back to -1 if not cached (should never happen in normal flow)
|
|
2425
|
+
const domIndex = parseInt(element.dataset['dotDomIndex'] || '-1', 10);
|
|
2426
|
+
// Build impression payload (enricher plugin adds page data automatically)
|
|
2427
|
+
const impressionPayload = {
|
|
2428
|
+
content: {
|
|
2429
|
+
identifier: contentletData.identifier,
|
|
2430
|
+
inode: contentletData.inode,
|
|
2431
|
+
title: contentletData.title,
|
|
2432
|
+
content_type: contentletData.contentType
|
|
2433
|
+
},
|
|
2434
|
+
position: {
|
|
2435
|
+
viewport_offset_pct: viewportMetrics.offsetPercentage,
|
|
2436
|
+
dom_index: domIndex
|
|
2437
|
+
}
|
|
2438
|
+
};
|
|
2439
|
+
// Notify subscribers (plugin will call analytics.track)
|
|
2440
|
+
this.notifySubscribers(IMPRESSION_EVENT_TYPE, impressionPayload);
|
|
2441
|
+
// Mark as tracked in session
|
|
2442
|
+
this.markImpressionAsTracked(identifier);
|
|
2443
|
+
// Clear state
|
|
2444
|
+
state.timer = null;
|
|
2445
|
+
state.visibleSince = null;
|
|
2446
|
+
state.tracked = true;
|
|
2447
|
+
// Stop observing this element (no longer needed)
|
|
2448
|
+
if (__classPrivateFieldGet(this, _DotCMSImpressionTracker_observer, "f")) {
|
|
2449
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_observer, "f").unobserve(element);
|
|
2450
|
+
}
|
|
2451
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").info(`Fired impression for ${identifier} (dwell: ${dwellTime}ms) - element unobserved`, contentletData);
|
|
2452
|
+
}
|
|
2453
|
+
/** Returns skip reason if element is hidden/too small, null if trackable */
|
|
2454
|
+
shouldSkipElement(element) {
|
|
2455
|
+
const rect = element.getBoundingClientRect();
|
|
2456
|
+
const styles = window.getComputedStyle(element);
|
|
2457
|
+
// Check for zero dimensions (empty/collapsed elements)
|
|
2458
|
+
if (rect.height === 0 || rect.width === 0) {
|
|
2459
|
+
return `zero dimensions: ${rect.width}x${rect.height}`;
|
|
2460
|
+
}
|
|
2461
|
+
// Check for very small elements (likely tracking pixels or artifacts)
|
|
2462
|
+
const MIN_SIZE = 10; // pixels
|
|
2463
|
+
if (rect.height < MIN_SIZE || rect.width < MIN_SIZE) {
|
|
2464
|
+
return `too small: ${rect.width}x${rect.height} (minimum: ${MIN_SIZE}px)`;
|
|
2465
|
+
}
|
|
2466
|
+
// Check for hidden elements (visibility: hidden)
|
|
2467
|
+
if (styles.visibility === 'hidden') {
|
|
2468
|
+
return 'visibility: hidden';
|
|
2469
|
+
}
|
|
2470
|
+
// Check for transparent elements (opacity: 0)
|
|
2471
|
+
if (parseFloat(styles.opacity) === 0) {
|
|
2472
|
+
return 'opacity: 0';
|
|
2473
|
+
}
|
|
2474
|
+
// Check for elements with display: none
|
|
2475
|
+
if (styles.display === 'none') {
|
|
2476
|
+
return 'display: none';
|
|
2477
|
+
}
|
|
2478
|
+
// Element passes all checks
|
|
2479
|
+
return null;
|
|
2480
|
+
}
|
|
2481
|
+
/** Post-dwell check: verifies element still meets visibility threshold */
|
|
2482
|
+
isElementStillVisible(element) {
|
|
2483
|
+
// Check page visibility first
|
|
2484
|
+
if (document.visibilityState !== 'visible') {
|
|
2485
|
+
return false;
|
|
2486
|
+
}
|
|
2487
|
+
// Check if element meets visibility threshold using utility
|
|
2488
|
+
return isElementMeetingVisibilityThreshold(element, __classPrivateFieldGet(this, _DotCMSImpressionTracker_impressionConfig, "f").visibilityThreshold);
|
|
2489
|
+
}
|
|
2490
|
+
/** Checks if impression already fired in current page session */
|
|
2491
|
+
hasBeenTrackedInSession(identifier) {
|
|
2492
|
+
return __classPrivateFieldGet(this, _DotCMSImpressionTracker_sessionTrackedImpressions, "f").has(identifier);
|
|
2493
|
+
}
|
|
2494
|
+
/** Marks impression as tracked (prevents duplicates in same page session) */
|
|
2495
|
+
markImpressionAsTracked(identifier) {
|
|
2496
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_sessionTrackedImpressions, "f").add(identifier);
|
|
2497
|
+
}
|
|
2498
|
+
/** Cleanup: disconnects observers, clears timers and state */
|
|
2499
|
+
cleanup() {
|
|
2500
|
+
// Disconnect intersection observer
|
|
2501
|
+
if (__classPrivateFieldGet(this, _DotCMSImpressionTracker_observer, "f")) {
|
|
2502
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_observer, "f").disconnect();
|
|
2503
|
+
__classPrivateFieldSet(this, _DotCMSImpressionTracker_observer, null, "f");
|
|
2504
|
+
}
|
|
2505
|
+
// Disconnect mutation observer
|
|
2506
|
+
if (__classPrivateFieldGet(this, _DotCMSImpressionTracker_mutationObserver, "f")) {
|
|
2507
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_mutationObserver, "f").disconnect();
|
|
2508
|
+
__classPrivateFieldSet(this, _DotCMSImpressionTracker_mutationObserver, null, "f");
|
|
2509
|
+
}
|
|
2510
|
+
window.removeEventListener(CONTENTLET_RESCAN_EVENT, __classPrivateFieldGet(this, _DotCMSImpressionTracker_handleRescan, "f"));
|
|
2511
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_stopWatchingNavigations, "f")?.call(this);
|
|
2512
|
+
__classPrivateFieldSet(this, _DotCMSImpressionTracker_stopWatchingNavigations, null, "f");
|
|
2513
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_stopWatchingRestores, "f")?.call(this);
|
|
2514
|
+
__classPrivateFieldSet(this, _DotCMSImpressionTracker_stopWatchingRestores, null, "f");
|
|
2515
|
+
// Clear all active dwell timers
|
|
2516
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_elementImpressionStates, "f").forEach((state) => {
|
|
2517
|
+
if (state.timer !== null) {
|
|
2518
|
+
window.clearTimeout(state.timer);
|
|
2519
|
+
}
|
|
2520
|
+
});
|
|
2521
|
+
// Clear all state
|
|
2522
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_elementImpressionStates, "f").clear();
|
|
2523
|
+
// Clear all subscribers
|
|
2524
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_subscribers, "f").clear();
|
|
2525
|
+
__classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").info('Impression tracking cleaned up');
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
_DotCMSImpressionTracker_observer = new WeakMap(), _DotCMSImpressionTracker_mutationObserver = new WeakMap(), _DotCMSImpressionTracker_elementImpressionStates = new WeakMap(), _DotCMSImpressionTracker_sessionTrackedImpressions = new WeakMap(), _DotCMSImpressionTracker_impressionConfig = new WeakMap(), _DotCMSImpressionTracker_stopWatchingNavigations = new WeakMap(), _DotCMSImpressionTracker_stopWatchingRestores = new WeakMap(), _DotCMSImpressionTracker_subscribers = new WeakMap(), _DotCMSImpressionTracker_logger = new WeakMap(), _DotCMSImpressionTracker_handleRescan = new WeakMap();
|
|
2529
|
+
|
|
2530
|
+
/**
|
|
2531
|
+
* Impressions plugin of the events pipeline.
|
|
2532
|
+
* Handles automatic tracking of content visibility and impressions.
|
|
2533
|
+
*
|
|
2534
|
+
* This plugin initializes the impression tracker which:
|
|
2535
|
+
* - Uses IntersectionObserver to detect when contentlets are visible
|
|
2536
|
+
* - Tracks dwell time (how long elements are visible)
|
|
2537
|
+
* - Fires 'content-impression' events via instance.track()
|
|
2538
|
+
* - Deduplicates impressions per session
|
|
2539
|
+
*
|
|
2540
|
+
* Analytics.js runs the plugins in the order events.ts registers them: identity, experiments,
|
|
2541
|
+
* impressions, clicks, enricher, sender. This one hooks into no event: its tracker fires
|
|
2542
|
+
* events through instance.track(), which runs them through the other plugins like any event.
|
|
2543
|
+
*
|
|
2544
|
+
* Note: This plugin is only registered if config.impressions is enabled.
|
|
2545
|
+
* See getEnhancedTrackingPlugins() for conditional loading logic.
|
|
2546
|
+
*
|
|
2547
|
+
* @param {PipelineConfig} config - Configuration with impressions settings
|
|
2548
|
+
* @returns {Object} Plugin object with lifecycle methods
|
|
2549
|
+
*/
|
|
2550
|
+
const impressionsPlugin = (config) => {
|
|
2551
|
+
let impressionTracker = null;
|
|
2552
|
+
let subscription = null;
|
|
2553
|
+
const logger = createPluginLogger('Impression', config);
|
|
2554
|
+
return {
|
|
2555
|
+
name: 'dot-events-impressions',
|
|
2556
|
+
/**
|
|
2557
|
+
* Initialize impression tracking
|
|
2558
|
+
* Called when Analytics.js initializes the plugin with instance context
|
|
2559
|
+
* @param instance - Analytics.js instance with track method
|
|
2560
|
+
*/
|
|
2561
|
+
initialize: ({ instance }) => {
|
|
2562
|
+
// Only initialize if impressions are enabled
|
|
2563
|
+
if (!config.impressions) {
|
|
2564
|
+
logger.info('Impression tracking disabled (config.impressions not set)');
|
|
2565
|
+
return Promise.resolve();
|
|
2566
|
+
}
|
|
2567
|
+
// Create and initialize tracker
|
|
2568
|
+
impressionTracker = new DotCMSImpressionTracker(config);
|
|
2569
|
+
impressionTracker.initialize();
|
|
2570
|
+
// Subscribe to impression events and call analytics track
|
|
2571
|
+
subscription = impressionTracker.onImpression((eventName, payload) => {
|
|
2572
|
+
instance.track(eventName, payload);
|
|
2573
|
+
});
|
|
2574
|
+
logger.info('Impression tracking plugin initialized');
|
|
2575
|
+
return Promise.resolve();
|
|
2576
|
+
},
|
|
2577
|
+
/**
|
|
2578
|
+
* Setup cleanup handlers when plugin is loaded
|
|
2579
|
+
* Called after Analytics.js completes plugin loading
|
|
2580
|
+
*/
|
|
2581
|
+
loaded: () => {
|
|
2582
|
+
// Only setup cleanup if tracker was initialized
|
|
2583
|
+
if (isBrowser$1() && impressionTracker) {
|
|
2584
|
+
const cleanup = () => {
|
|
2585
|
+
// Unsubscribe before cleanup
|
|
2586
|
+
if (subscription) {
|
|
2587
|
+
subscription.unsubscribe();
|
|
2588
|
+
subscription = null;
|
|
2589
|
+
}
|
|
2590
|
+
if (impressionTracker) {
|
|
2591
|
+
impressionTracker.cleanup();
|
|
2592
|
+
impressionTracker = null;
|
|
2593
|
+
logger.info('Impression tracking cleaned up on page unload');
|
|
2594
|
+
}
|
|
2595
|
+
};
|
|
2596
|
+
// Cleanup on page unload
|
|
2597
|
+
setupPluginCleanup(cleanup);
|
|
2598
|
+
}
|
|
2599
|
+
return true;
|
|
2600
|
+
}
|
|
2601
|
+
};
|
|
2602
|
+
};
|
|
2603
|
+
|
|
2604
|
+
/**
|
|
2605
|
+
* Plugin that enriches the analytics payload data with page, UTM, and custom data.
|
|
2606
|
+
* Uses Analytics.js lifecycle events to inject enriched data before the main plugin processes it.
|
|
2607
|
+
*
|
|
2608
|
+
* The identity plugin, registered first, injects context: { session_id, site_auth, user_id, device }.
|
|
2609
|
+
* This plugin, registered after the experiments, impressions and clicks plugins, adds page, UTM
|
|
2610
|
+
* and custom data. The sender, registered last, structures the events and sends them.
|
|
2611
|
+
*
|
|
2612
|
+
* This plugin is ONLY responsible for data enrichment - NOT for event structuring or business logic.
|
|
2613
|
+
*/
|
|
2614
|
+
const enricherPlugin = () => {
|
|
2615
|
+
return {
|
|
2616
|
+
name: 'dot-events-enricher',
|
|
2617
|
+
/**
|
|
2618
|
+
* PAGE VIEW ENRICHMENT - Runs after identity context injection
|
|
2619
|
+
* Returns enriched payload with page, utm, and custom data added
|
|
2620
|
+
* @returns {EnrichedAnalyticsPayload} Enriched payload ready for event creation
|
|
2621
|
+
*/
|
|
2622
|
+
[`page:${SENDER_PLUGIN_NAME}`]: ({ payload }) => {
|
|
2623
|
+
const enrichedData = enrichPagePayloadOptimized(payload);
|
|
2624
|
+
if (!enrichedData.page) {
|
|
2625
|
+
throw new Error('[dotCMS events] Missing required page data');
|
|
2626
|
+
}
|
|
2627
|
+
return enrichedData;
|
|
2628
|
+
},
|
|
2629
|
+
/**
|
|
2630
|
+
* TRACK EVENT ENRICHMENT - Runs after identity context injection
|
|
2631
|
+
* Adds page data and timestamp for predefined content events.
|
|
2632
|
+
* For custom events, only adds timestamp.
|
|
2633
|
+
*
|
|
2634
|
+
* @returns {EnrichedTrackPayload} Enriched payload ready for event structuring
|
|
2635
|
+
*/
|
|
2636
|
+
[`track:${SENDER_PLUGIN_NAME}`]: ({ payload }) => {
|
|
2637
|
+
const { event } = payload;
|
|
2638
|
+
const local_time = getLocalTime();
|
|
2639
|
+
// For content_impression, content_click, and conversion events, add page data
|
|
2640
|
+
if (event === DotCMSPredefinedEventType.CONTENT_IMPRESSION ||
|
|
2641
|
+
event === DotCMSPredefinedEventType.CONTENT_CLICK ||
|
|
2642
|
+
event === DotCMSPredefinedEventType.CONVERSION) {
|
|
2643
|
+
return {
|
|
2644
|
+
...payload,
|
|
2645
|
+
page: {
|
|
2646
|
+
title: document.title,
|
|
2647
|
+
url: window.location.href
|
|
2648
|
+
},
|
|
2649
|
+
local_time
|
|
2650
|
+
};
|
|
2651
|
+
}
|
|
2652
|
+
// For custom events, just add local_time
|
|
2653
|
+
return {
|
|
2654
|
+
...payload,
|
|
2655
|
+
local_time
|
|
2656
|
+
};
|
|
2657
|
+
}
|
|
2658
|
+
};
|
|
2659
|
+
};
|
|
2660
|
+
|
|
2661
|
+
/**
|
|
2662
|
+
* Identity plugin of the events pipeline.
|
|
2663
|
+
* Handles user ID generation, session management, and activity tracking.
|
|
2664
|
+
* This plugin provides consistent identity context across all analytics events.
|
|
2665
|
+
*
|
|
2666
|
+
* It runs first: events.ts registers identity, experiments, impressions, clicks, enricher and
|
|
2667
|
+
* sender, in that order, and each plugin reads what the ones before it wrote.
|
|
2668
|
+
*
|
|
2669
|
+
* @param {PipelineConfig} config - Configuration object containing server URL, site key, and debug settings
|
|
2670
|
+
* @returns {Object} Plugin object with methods for initialization and event processing
|
|
2671
|
+
*/
|
|
2672
|
+
const identityPlugin = (config) => {
|
|
2673
|
+
return {
|
|
2674
|
+
name: 'dot-events-identity',
|
|
2675
|
+
/**
|
|
2676
|
+
* Initialize the identity plugin
|
|
2677
|
+
* Sets up activity tracking for session management
|
|
2678
|
+
*/
|
|
2679
|
+
initialize: () => {
|
|
2680
|
+
initializeActivityTracking(config);
|
|
2681
|
+
return Promise.resolve();
|
|
2682
|
+
},
|
|
2683
|
+
/**
|
|
2684
|
+
* Inject identity context into page events and updates session activity for session management
|
|
2685
|
+
* This runs BEFORE the enricher plugin
|
|
2686
|
+
*/
|
|
2687
|
+
pageStart: ({ payload }) => {
|
|
2688
|
+
updateSessionActivity();
|
|
2689
|
+
const context = getEventContext(config);
|
|
2690
|
+
return {
|
|
2691
|
+
...payload,
|
|
2692
|
+
context
|
|
2693
|
+
};
|
|
2694
|
+
},
|
|
2695
|
+
/**
|
|
2696
|
+
* Inject identity context into track events and updates session activity for session management
|
|
2697
|
+
* This runs BEFORE the enricher plugin
|
|
2698
|
+
*/
|
|
2699
|
+
trackStart: ({ payload }) => {
|
|
2700
|
+
updateSessionActivity();
|
|
2701
|
+
const context = getEventContext(config);
|
|
2702
|
+
return {
|
|
2703
|
+
...payload,
|
|
2704
|
+
context
|
|
2705
|
+
};
|
|
2706
|
+
},
|
|
2707
|
+
/**
|
|
2708
|
+
* Cleans up activity tracking when the browser discards the page. A page kept in the
|
|
2709
|
+
* back/forward cache keeps it, and keeps `__dotAnalyticsActive__` set, so the
|
|
2710
|
+
* renderers still print the contentlet attributes once the visitor goes back to it.
|
|
2711
|
+
*/
|
|
2712
|
+
loaded: () => {
|
|
2713
|
+
if (typeof window !== 'undefined') {
|
|
2714
|
+
onPageDiscard(cleanupActivityTracking);
|
|
2715
|
+
}
|
|
2716
|
+
return true;
|
|
2717
|
+
}
|
|
2718
|
+
};
|
|
2719
|
+
};
|
|
2720
|
+
|
|
2721
|
+
/**
|
|
2722
|
+
* Hands a failure to the configured handler. A handler that throws must not break the page.
|
|
2723
|
+
*
|
|
2724
|
+
* @param config - The analytics configuration
|
|
2725
|
+
* @param error - The failure
|
|
2726
|
+
*/
|
|
2727
|
+
const notify = (config, error) => {
|
|
2728
|
+
try {
|
|
2729
|
+
config.onError?.(error);
|
|
2730
|
+
}
|
|
2731
|
+
catch {
|
|
2732
|
+
// The app's handler failed; the events pipeline carries on
|
|
2733
|
+
}
|
|
2734
|
+
};
|
|
2735
|
+
/**
|
|
2736
|
+
* Reads the response body, when it is JSON.
|
|
2737
|
+
*
|
|
2738
|
+
* @param response - The events response
|
|
2739
|
+
* @returns The body, or null
|
|
2740
|
+
*/
|
|
2741
|
+
const readBody = async (response) => {
|
|
2742
|
+
try {
|
|
2743
|
+
return (await response.json());
|
|
2744
|
+
}
|
|
2745
|
+
catch {
|
|
2746
|
+
return null;
|
|
2747
|
+
}
|
|
2748
|
+
};
|
|
2749
|
+
/**
|
|
2750
|
+
* Reports what dotCMS answered to an events request: an error status, or an accepted request
|
|
2751
|
+
* whose body says some events failed.
|
|
2752
|
+
*
|
|
2753
|
+
* @param response - The events response
|
|
2754
|
+
* @param body - Its body, when JSON
|
|
2755
|
+
* @param config - The analytics configuration
|
|
2756
|
+
* @param events - How many events the request carried
|
|
2757
|
+
*/
|
|
2758
|
+
const reportResponse = (response, body, config, events) => {
|
|
2759
|
+
const errors = body?.entity?.errors;
|
|
2760
|
+
const failed = body?.entity?.failed ?? 0;
|
|
2761
|
+
if (response.ok && failed === 0) {
|
|
2762
|
+
return;
|
|
2763
|
+
}
|
|
2764
|
+
const reason = errors?.[0]?.message ?? body?.message ?? (response.statusText || 'Unknown Error');
|
|
2765
|
+
notify(config, {
|
|
2766
|
+
code: 'REJECTED',
|
|
2767
|
+
message: response.ok
|
|
2768
|
+
? `${failed} of ${events} event(s) failed: ${reason}`
|
|
2769
|
+
: `HTTP ${response.status}: ${reason}`,
|
|
2770
|
+
status: response.status,
|
|
2771
|
+
detail: errors ?? body,
|
|
2772
|
+
events
|
|
2773
|
+
});
|
|
2774
|
+
};
|
|
2775
|
+
/**
|
|
2776
|
+
* Send analytics events to the server using fetch API
|
|
2777
|
+
* @param payload - The event payload data
|
|
2778
|
+
* @param config - The analytics configuration
|
|
2779
|
+
* @param keepalive - Use keepalive mode for page unload scenarios (default: false)
|
|
2780
|
+
* @returns A promise that resolves when the request is complete
|
|
2781
|
+
*/
|
|
2782
|
+
const sendEvents = async (payload, config, keepalive = false) => {
|
|
2783
|
+
const logger = createPluginLogger('HTTP', config);
|
|
2784
|
+
const endpoint = `${config.server}${ANALYTICS_ENDPOINT}`;
|
|
2785
|
+
const body = JSON.stringify(payload);
|
|
2786
|
+
logger.info(`Sending ${payload.events.length} event(s)${keepalive ? ' (keepalive)' : ''}`, {
|
|
2787
|
+
payload
|
|
2788
|
+
});
|
|
2789
|
+
try {
|
|
2790
|
+
const fetchOptions = {
|
|
2791
|
+
method: 'POST',
|
|
2792
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2793
|
+
body
|
|
2794
|
+
};
|
|
2795
|
+
// Only add keepalive-specific options when keepalive is true
|
|
2796
|
+
if (keepalive) {
|
|
2797
|
+
fetchOptions.keepalive = true;
|
|
2798
|
+
fetchOptions.credentials = 'omit'; // Required for keepalive requests
|
|
2799
|
+
// Fire and forget - don't await response with keepalive
|
|
2800
|
+
// The browser will send the request even if the page unloads
|
|
2801
|
+
fetch(endpoint, fetchOptions)
|
|
2802
|
+
.then(async (response) => {
|
|
2803
|
+
reportResponse(response, await readBody(response), config, payload.events.length);
|
|
2804
|
+
})
|
|
2805
|
+
.catch((err) => {
|
|
2806
|
+
logger.error('Keepalive request failed (browser may have ignored it):', err);
|
|
2807
|
+
notify(config, {
|
|
2808
|
+
code: 'NETWORK',
|
|
2809
|
+
message: String(err),
|
|
2810
|
+
events: payload.events.length
|
|
2811
|
+
});
|
|
2812
|
+
});
|
|
2813
|
+
// We can't know if it succeeded, but for keepalive=true contexts we usually assume "sent"
|
|
2814
|
+
// or we return false since we can't confirm.
|
|
2815
|
+
// However, the caller usually ignores the return value for keepalive.
|
|
2816
|
+
return true;
|
|
2817
|
+
}
|
|
2818
|
+
// Normal request - await and check response
|
|
2819
|
+
const response = await fetch(endpoint, fetchOptions);
|
|
2820
|
+
if (!response.ok) {
|
|
2821
|
+
// Always log the HTTP status code
|
|
2822
|
+
const statusText = response.statusText || 'Unknown Error';
|
|
2823
|
+
const baseErrorMessage = `HTTP ${response.status}: ${statusText}`;
|
|
2824
|
+
const errorData = await readBody(response);
|
|
2825
|
+
if (errorData?.message) {
|
|
2826
|
+
logger.warn(`${errorData.message} (${baseErrorMessage})`);
|
|
2827
|
+
}
|
|
2828
|
+
else if (errorData) {
|
|
2829
|
+
// JSON parsed successfully but no message property
|
|
2830
|
+
logger.warn(`${baseErrorMessage} - No error message in response`);
|
|
2831
|
+
}
|
|
2832
|
+
else {
|
|
2833
|
+
logger.warn(`${baseErrorMessage} - Failed to parse error response`);
|
|
2834
|
+
}
|
|
2835
|
+
reportResponse(response, errorData, config, payload.events.length);
|
|
2836
|
+
return false;
|
|
2837
|
+
}
|
|
2838
|
+
// Accepted: dotCMS may still have failed some of the events
|
|
2839
|
+
reportResponse(response, await readBody(response), config, payload.events.length);
|
|
2840
|
+
return true;
|
|
2841
|
+
}
|
|
2842
|
+
catch (error) {
|
|
2843
|
+
logger.error('Error sending event:', error);
|
|
2844
|
+
notify(config, { code: 'NETWORK', message: String(error), events: payload.events.length });
|
|
2845
|
+
return false;
|
|
2846
|
+
}
|
|
2847
|
+
};
|
|
2848
|
+
|
|
2849
|
+
/**
|
|
2850
|
+
* What sets two events' contexts apart: the site, the session, the visitor and the
|
|
2851
|
+
* experiments. The device alone does not, since the viewport changes between events, as it
|
|
2852
|
+
* does when a phone's address bar hides on scroll.
|
|
2853
|
+
*
|
|
2854
|
+
* @param context - An event's context
|
|
2855
|
+
* @returns A key that is equal for contexts that can share a request
|
|
2856
|
+
*/
|
|
2857
|
+
const contextKey = ({ site_auth, session_id, user_id, experiments }) => JSON.stringify([site_auth, session_id, user_id, experiments ?? []]);
|
|
2858
|
+
/**
|
|
2859
|
+
* Groups events into request bodies, in order: consecutive events whose contexts share a key
|
|
2860
|
+
* go out together, with the latest of their contexts. Events of a page that runs an
|
|
2861
|
+
* experiment and of one that does not never share a context.
|
|
2862
|
+
*
|
|
2863
|
+
* @param items - Events with the context each was created with
|
|
2864
|
+
* @returns One request body per run of events
|
|
2865
|
+
*/
|
|
2866
|
+
const toRequestBodies = (items) => {
|
|
2867
|
+
const bodies = [];
|
|
2868
|
+
let lastKey;
|
|
2869
|
+
items.forEach(({ event, context }) => {
|
|
2870
|
+
const key = contextKey(context);
|
|
2871
|
+
const last = bodies[bodies.length - 1];
|
|
2872
|
+
if (last && key === lastKey) {
|
|
2873
|
+
last.events.push(event);
|
|
2874
|
+
last.context = context;
|
|
2875
|
+
}
|
|
2876
|
+
else {
|
|
2877
|
+
bodies.push({ context, events: [event] });
|
|
2878
|
+
lastKey = key;
|
|
2879
|
+
}
|
|
2880
|
+
});
|
|
2881
|
+
return bodies;
|
|
2882
|
+
};
|
|
2883
|
+
/**
|
|
2884
|
+
* Creates a queue manager for batching analytics events.
|
|
2885
|
+
* Uses factory function pattern consistent with the plugin architecture.
|
|
2886
|
+
*/
|
|
2887
|
+
const createEventQueue = (config) => {
|
|
2888
|
+
const logger = createPluginLogger('Queue', config);
|
|
2889
|
+
let eventQueue = null;
|
|
2890
|
+
// The context each queued event was created with
|
|
2891
|
+
let contextOf = new WeakMap();
|
|
2892
|
+
/**
|
|
2893
|
+
* Whether to use keepalive mode for sending events
|
|
2894
|
+
* true for page unload (visibilitychange, pagehide), false for normal sends
|
|
2895
|
+
*/
|
|
2896
|
+
let keepalive = false;
|
|
2897
|
+
/**
|
|
2898
|
+
* Track if we're in a SPA navigation to avoid unnecessary flushes
|
|
2899
|
+
*/
|
|
2900
|
+
let isSPANavigation = false;
|
|
2901
|
+
let currentPath = typeof window !== 'undefined' ? window.location.pathname : '';
|
|
2902
|
+
/**
|
|
2903
|
+
* Unique identifier for this browser tab
|
|
2904
|
+
* Used to prevent race conditions when multiple tabs write to sessionStorage
|
|
2905
|
+
*/
|
|
2906
|
+
let tabId = '';
|
|
2907
|
+
/**
|
|
2908
|
+
* Parallel array to track events, with their contexts, for persistence
|
|
2909
|
+
* smartQueue doesn't expose its internal array, so we maintain a copy
|
|
2910
|
+
*/
|
|
2911
|
+
let eventsForPersistence = [];
|
|
2912
|
+
// Merge user config with defaults (allows partial configuration)
|
|
2913
|
+
// After merge, queueConfig always has all required values
|
|
2914
|
+
const queueConfig = {
|
|
2915
|
+
...DEFAULT_QUEUE_CONFIG,
|
|
2916
|
+
...(typeof config.queue === 'object' ? config.queue : {})
|
|
2917
|
+
};
|
|
2918
|
+
/**
|
|
2919
|
+
* Get storage key for this tab's queue
|
|
2920
|
+
*/
|
|
2921
|
+
const getStorageKey = () => {
|
|
2922
|
+
return `${QUEUE_STORAGE_KEY_PREFIX}_${tabId}`;
|
|
2923
|
+
};
|
|
2924
|
+
/**
|
|
2925
|
+
* Validate persisted queue structure
|
|
2926
|
+
* @param data - Data from sessionStorage
|
|
2927
|
+
* @returns Validated queue or null if invalid
|
|
2928
|
+
*/
|
|
2929
|
+
const validatePersistedQueue = (data) => {
|
|
2930
|
+
const q = data;
|
|
2931
|
+
if (!q || typeof q !== 'object' || Array.isArray(q)) {
|
|
2932
|
+
return null;
|
|
2933
|
+
}
|
|
2934
|
+
// Validate minimal structural requirements
|
|
2935
|
+
if (typeof q.tabId !== 'string' ||
|
|
2936
|
+
typeof q.timestamp !== 'number' ||
|
|
2937
|
+
!Number.isFinite(q.timestamp) ||
|
|
2938
|
+
!Array.isArray(q.events)) {
|
|
2939
|
+
logger.warn('Invalid persisted queue: structural mismatch');
|
|
2940
|
+
return null;
|
|
2941
|
+
}
|
|
2942
|
+
// Validate minimal event structure: each event with its context
|
|
2943
|
+
const validEvents = q.events.filter((item) => item &&
|
|
2944
|
+
typeof item === 'object' &&
|
|
2945
|
+
item.event &&
|
|
2946
|
+
typeof item.event === 'object' &&
|
|
2947
|
+
'event_type' in item.event &&
|
|
2948
|
+
item.context &&
|
|
2949
|
+
typeof item.context === 'object');
|
|
2950
|
+
return {
|
|
2951
|
+
tabId: q.tabId,
|
|
2952
|
+
timestamp: q.timestamp,
|
|
2953
|
+
events: validEvents
|
|
2954
|
+
};
|
|
2955
|
+
};
|
|
2956
|
+
/**
|
|
2957
|
+
* Load events from sessionStorage
|
|
2958
|
+
* @returns Persisted queue or null if not found/invalid
|
|
2959
|
+
*/
|
|
2960
|
+
const loadFromStorage = () => {
|
|
2961
|
+
try {
|
|
2962
|
+
const key = getStorageKey();
|
|
2963
|
+
const stored = safeSessionStorage.getItem(key);
|
|
2964
|
+
if (!stored) {
|
|
2965
|
+
logger.debug('No persisted queue found');
|
|
2966
|
+
return null;
|
|
2967
|
+
}
|
|
2968
|
+
const parsed = JSON.parse(stored);
|
|
2969
|
+
const validated = validatePersistedQueue(parsed);
|
|
2970
|
+
if (!validated) {
|
|
2971
|
+
// Clear invalid storage
|
|
2972
|
+
safeSessionStorage.removeItem(key);
|
|
2973
|
+
return null;
|
|
2974
|
+
}
|
|
2975
|
+
// Check if events are too old (24 hours)
|
|
2976
|
+
const age = Date.now() - validated.timestamp;
|
|
2977
|
+
if (age > MAX_EVENT_AGE_MS) {
|
|
2978
|
+
logger.warn(`Persisted events too old (${Math.round(age / 1000 / 60 / 60)}h), discarding`);
|
|
2979
|
+
safeSessionStorage.removeItem(key);
|
|
2980
|
+
return null;
|
|
2981
|
+
}
|
|
2982
|
+
logger.info(`Loaded ${validated.events.length} persisted event(s) from storage (age: ${Math.round(age / 1000)}s)`);
|
|
2983
|
+
return validated;
|
|
2984
|
+
}
|
|
2985
|
+
catch (error) {
|
|
2986
|
+
logger.error('Failed to load persisted queue', error);
|
|
2987
|
+
// Clear corrupted storage
|
|
2988
|
+
safeSessionStorage.removeItem(getStorageKey());
|
|
2989
|
+
return null;
|
|
2990
|
+
}
|
|
2991
|
+
};
|
|
2992
|
+
/**
|
|
2993
|
+
* Persist current queue to sessionStorage
|
|
2994
|
+
*/
|
|
2995
|
+
const persistToStorage = () => {
|
|
2996
|
+
if (eventsForPersistence.length === 0) {
|
|
2997
|
+
return;
|
|
2998
|
+
}
|
|
2999
|
+
try {
|
|
3000
|
+
const key = getStorageKey();
|
|
3001
|
+
const queueData = {
|
|
3002
|
+
tabId,
|
|
3003
|
+
timestamp: Date.now(),
|
|
3004
|
+
events: eventsForPersistence
|
|
3005
|
+
};
|
|
3006
|
+
safeSessionStorage.setItem(key, JSON.stringify(queueData));
|
|
3007
|
+
logger.debug(`Persisted ${eventsForPersistence.length} event(s) to storage`);
|
|
3008
|
+
}
|
|
3009
|
+
catch (error) {
|
|
3010
|
+
if (error instanceof Error && error.name === 'QuotaExceededError') {
|
|
3011
|
+
logger.warn('sessionStorage quota exceeded, continuing without persistence');
|
|
3012
|
+
}
|
|
3013
|
+
else {
|
|
3014
|
+
logger.error('Failed to persist queue', error);
|
|
3015
|
+
}
|
|
3016
|
+
}
|
|
3017
|
+
};
|
|
3018
|
+
/**
|
|
3019
|
+
* Clear persisted queue from sessionStorage
|
|
3020
|
+
*/
|
|
3021
|
+
const clearStorage = () => {
|
|
3022
|
+
try {
|
|
3023
|
+
const key = getStorageKey();
|
|
3024
|
+
safeSessionStorage.removeItem(key);
|
|
3025
|
+
logger.debug('Persisted queue cleared from storage');
|
|
3026
|
+
}
|
|
3027
|
+
catch (error) {
|
|
3028
|
+
logger.error('Failed to clear persisted queue', error);
|
|
3029
|
+
}
|
|
3030
|
+
};
|
|
3031
|
+
/**
|
|
3032
|
+
* Sends events right away, outside the batches. Used for the events an earlier page
|
|
3033
|
+
* persisted, which go out when this page loads (that caller passes useKeepalive false),
|
|
3034
|
+
* each with the context it was created with.
|
|
3035
|
+
* @param events - Events to send, with their contexts
|
|
3036
|
+
* @param useKeepalive - Whether to send with keepalive
|
|
3037
|
+
* @returns Promise<boolean> - true if every request succeeded
|
|
3038
|
+
*/
|
|
3039
|
+
const sendImmediately = async (events, useKeepalive = true) => {
|
|
3040
|
+
if (events.length === 0) {
|
|
3041
|
+
return true;
|
|
3042
|
+
}
|
|
3043
|
+
logger.info(`Sending ${events.length} persisted event(s) immediately`);
|
|
3044
|
+
const sent = await Promise.all(toRequestBodies(events).map((payload) => sendEvents(payload, config, useKeepalive)));
|
|
3045
|
+
return sent.every(Boolean);
|
|
3046
|
+
};
|
|
3047
|
+
/**
|
|
3048
|
+
* Send batch of events to server
|
|
3049
|
+
* Called by smartQueue - uses keepalive mode when flushing on page unload
|
|
3050
|
+
* @param events - The batch of events to send
|
|
3051
|
+
* @param _rest - Remaining events in queue (unused, required by smartQueue API)
|
|
3052
|
+
*/
|
|
3053
|
+
const sendBatch = (events, _rest) => {
|
|
3054
|
+
const batch = events.flatMap((event) => {
|
|
3055
|
+
const context = contextOf.get(event);
|
|
3056
|
+
return context ? [{ event, context }] : [];
|
|
3057
|
+
});
|
|
3058
|
+
if (batch.length === 0)
|
|
3059
|
+
return;
|
|
3060
|
+
logger.debug(`Sending batch of ${events.length} event(s)`, {
|
|
3061
|
+
events,
|
|
3062
|
+
keepalive
|
|
3063
|
+
});
|
|
3064
|
+
// Send events (fire-and-forget with keepalive): one request per context in the batch
|
|
3065
|
+
toRequestBodies(batch).forEach((payload) => sendEvents(payload, config, keepalive));
|
|
3066
|
+
// Remove sent events from parallel tracking array
|
|
3067
|
+
eventsForPersistence = eventsForPersistence.filter((queued) => !events.some((sent) => sent === queued.event));
|
|
3068
|
+
// Always update storage after dispatching the send — even for keepalive flushes.
|
|
3069
|
+
// Note: sendEvents is fire-and-forget (the returned promise is not awaited),
|
|
3070
|
+
// so this runs regardless of whether the HTTP request succeeds.
|
|
3071
|
+
// sessionStorage writes are synchronous and complete before page unload,
|
|
3072
|
+
// so the persistence state stays consistent. Previously, keepalive flushes
|
|
3073
|
+
// skipped the storage update, which caused the next page load to re-send
|
|
3074
|
+
// the same events from persistence, producing duplicates.
|
|
3075
|
+
if (eventsForPersistence.length === 0) {
|
|
3076
|
+
clearStorage();
|
|
3077
|
+
}
|
|
3078
|
+
else {
|
|
3079
|
+
persistToStorage();
|
|
3080
|
+
}
|
|
3081
|
+
};
|
|
3082
|
+
/**
|
|
3083
|
+
* Flush remaining events when page becomes hidden or unloads
|
|
3084
|
+
* Enables keepalive mode and triggers smartQueue to flush ALL events
|
|
3085
|
+
*/
|
|
3086
|
+
const flushRemaining = () => {
|
|
3087
|
+
if (!eventQueue || eventQueue.size() === 0 || eventsForPersistence.length === 0)
|
|
3088
|
+
return;
|
|
3089
|
+
logger.info(`Flushing ${eventQueue.size()} events (page hidden/unload)`);
|
|
3090
|
+
// Use keepalive mode for reliable delivery during page unload
|
|
3091
|
+
keepalive = true;
|
|
3092
|
+
// Flush all events - flush(true) makes smartQueue recursively batch until empty
|
|
3093
|
+
eventQueue.flush(true);
|
|
3094
|
+
};
|
|
3095
|
+
/**
|
|
3096
|
+
* Handle visibility change - flush when page becomes hidden
|
|
3097
|
+
* This is more reliable than beforeunload/unload, especially on mobile
|
|
3098
|
+
*
|
|
3099
|
+
* IMPORTANT: We skip flush if this is a SPA navigation (client-side routing)
|
|
3100
|
+
* Only flush on real tab changes or browser close
|
|
3101
|
+
*/
|
|
3102
|
+
const handleVisibilityChange = () => {
|
|
3103
|
+
logger.debug('handleVisibilityChange', document.visibilityState);
|
|
3104
|
+
if (document.visibilityState === 'hidden') {
|
|
3105
|
+
// Don't flush if this is just a SPA navigation
|
|
3106
|
+
if (isSPANavigation) {
|
|
3107
|
+
logger.debug('Skipping flush (SPA navigation detected), persisting to storage');
|
|
3108
|
+
// For SPA navigation, persist events without flushing
|
|
3109
|
+
// Events stay in memory and are preserved across client-side routing
|
|
3110
|
+
persistToStorage();
|
|
3111
|
+
return;
|
|
3112
|
+
}
|
|
3113
|
+
// Real visibility change (tab switch, browser close, etc.)
|
|
3114
|
+
flushRemaining();
|
|
3115
|
+
}
|
|
3116
|
+
else if (document.visibilityState === 'visible') {
|
|
3117
|
+
// Reset SPA navigation flag when page becomes visible again
|
|
3118
|
+
isSPANavigation = false;
|
|
3119
|
+
}
|
|
3120
|
+
};
|
|
3121
|
+
return {
|
|
3122
|
+
/**
|
|
3123
|
+
* Initialize the queue with smart batching
|
|
3124
|
+
*/
|
|
3125
|
+
initialize: () => {
|
|
3126
|
+
// 1. Load or generate persistent tab ID
|
|
3127
|
+
// Tab ID must remain constant across page navigations within the same browser tab
|
|
3128
|
+
if (typeof window !== 'undefined') {
|
|
3129
|
+
// Try to load existing tab ID from sessionStorage
|
|
3130
|
+
const storedTabId = safeSessionStorage.getItem(TAB_ID_STORAGE_KEY);
|
|
3131
|
+
if (storedTabId) {
|
|
3132
|
+
// Reuse existing tab ID from previous page
|
|
3133
|
+
tabId = storedTabId;
|
|
3134
|
+
logger.debug(`Reusing Tab ID: ${tabId}`);
|
|
3135
|
+
}
|
|
3136
|
+
else {
|
|
3137
|
+
// Generate new tab ID (Robust check for crypto.randomUUID)
|
|
3138
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
3139
|
+
tabId = crypto.randomUUID();
|
|
3140
|
+
}
|
|
3141
|
+
else {
|
|
3142
|
+
// Fallback for non-secure contexts or older browsers/environments
|
|
3143
|
+
tabId = generateSecureId('tab');
|
|
3144
|
+
logger.debug('crypto.randomUUID not available, using fallback generator');
|
|
3145
|
+
}
|
|
3146
|
+
try {
|
|
3147
|
+
safeSessionStorage.setItem(TAB_ID_STORAGE_KEY, tabId);
|
|
3148
|
+
}
|
|
3149
|
+
catch {
|
|
3150
|
+
// Ignore storage error for tab ID, will just generate a new one on next load
|
|
3151
|
+
}
|
|
3152
|
+
logger.debug(`Generated new Tab ID: ${tabId}`);
|
|
3153
|
+
}
|
|
3154
|
+
}
|
|
3155
|
+
// 2. Load persisted events from previous page (if any)
|
|
3156
|
+
const persisted = loadFromStorage();
|
|
3157
|
+
if (persisted && persisted.events.length > 0) {
|
|
3158
|
+
// Send persisted events immediately WITHOUT keepalive (to confirm success)
|
|
3159
|
+
// This ensures events from previous page navigation are sent reliably
|
|
3160
|
+
sendImmediately(persisted.events, false).then((success) => {
|
|
3161
|
+
if (success) {
|
|
3162
|
+
// Clear storage ONLY after successful transmission
|
|
3163
|
+
clearStorage();
|
|
3164
|
+
}
|
|
3165
|
+
else {
|
|
3166
|
+
logger.warn('Failed to send persisted events, keeping in storage for next retry');
|
|
3167
|
+
}
|
|
3168
|
+
});
|
|
3169
|
+
}
|
|
3170
|
+
// 3. Initialize smartQueue for new events
|
|
3171
|
+
eventQueue = smartQueue((items, rest) => {
|
|
3172
|
+
sendBatch(items);
|
|
3173
|
+
}, {
|
|
3174
|
+
max: queueConfig.eventBatchSize,
|
|
3175
|
+
interval: queueConfig.flushInterval,
|
|
3176
|
+
throttle: false // Always false - enables both batch size and interval triggers
|
|
3177
|
+
});
|
|
3178
|
+
// 4. Setup page visibility and unload listeners
|
|
3179
|
+
// visibilitychange is more reliable than beforeunload, especially on mobile
|
|
3180
|
+
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
|
3181
|
+
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
3182
|
+
// pagehide as fallback for browsers without bfcache or older browsers
|
|
3183
|
+
window.addEventListener('pagehide', flushRemaining);
|
|
3184
|
+
// Detect SPA navigations using @analytics/router-utils
|
|
3185
|
+
// Handles Next.js, React Router, Vue Router, etc.
|
|
3186
|
+
onRouteChange((newPath) => {
|
|
3187
|
+
isSPANavigation = true;
|
|
3188
|
+
currentPath = newPath;
|
|
3189
|
+
logger.debug(`SPA navigation detected (${currentPath})`);
|
|
3190
|
+
// Reset flag after a short delay (SPA navigation is done)
|
|
3191
|
+
setTimeout(() => {
|
|
3192
|
+
isSPANavigation = false;
|
|
3193
|
+
}, 100);
|
|
3194
|
+
});
|
|
3195
|
+
}
|
|
3196
|
+
},
|
|
3197
|
+
/**
|
|
3198
|
+
* Add event to queue
|
|
3199
|
+
* smartQueue handles all batching logic automatically:
|
|
3200
|
+
* - Sends immediately when eventBatchSize reached (with throttle: false)
|
|
3201
|
+
* - Sends pending events every flushInterval
|
|
3202
|
+
*/
|
|
3203
|
+
enqueue: (event, context) => {
|
|
3204
|
+
if (!eventQueue)
|
|
3205
|
+
return;
|
|
3206
|
+
// Each event goes out with the context it was created with
|
|
3207
|
+
contextOf.set(event, context);
|
|
3208
|
+
// Add to parallel tracking array for persistence
|
|
3209
|
+
eventsForPersistence.push({ event, context });
|
|
3210
|
+
// Calculate predicted size before push to show correct order in logs
|
|
3211
|
+
const predictedSize = eventQueue.size() + 1;
|
|
3212
|
+
const maxSize = queueConfig.eventBatchSize;
|
|
3213
|
+
const willBeFull = predictedSize >= maxSize;
|
|
3214
|
+
logger.debug(`Event added. Queue size: ${predictedSize}/${maxSize}${willBeFull ? ' (full, sending...)' : ''}`, { eventType: event.event_type, event });
|
|
3215
|
+
// Push triggers sendBatch callback if queue is full (throttle: false)
|
|
3216
|
+
eventQueue.push(event);
|
|
3217
|
+
// Persist to sessionStorage after enqueue
|
|
3218
|
+
persistToStorage();
|
|
3219
|
+
},
|
|
3220
|
+
/**
|
|
3221
|
+
* Get queue size for debugging
|
|
3222
|
+
* Returns the number of events in smartQueue
|
|
3223
|
+
*/
|
|
3224
|
+
size: () => eventQueue?.size() ?? 0,
|
|
3225
|
+
/**
|
|
3226
|
+
* Clean up queue resources
|
|
3227
|
+
* Flushes remaining events and cleans up listeners
|
|
3228
|
+
*
|
|
3229
|
+
* IMPORTANT: Does NOT clear sessionStorage
|
|
3230
|
+
* - Storage is updated when sendBatch dispatches a batch, whether or not its request
|
|
3231
|
+
* succeeds, and in initialize()
|
|
3232
|
+
* - This allows events to persist across traditional page navigations
|
|
3233
|
+
*/
|
|
3234
|
+
cleanup: () => {
|
|
3235
|
+
// Flush remaining events with keepalive
|
|
3236
|
+
flushRemaining();
|
|
3237
|
+
// Remove event listeners
|
|
3238
|
+
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
|
3239
|
+
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
3240
|
+
window.removeEventListener('pagehide', flushRemaining);
|
|
3241
|
+
}
|
|
3242
|
+
// Clean up memory only (not sessionStorage)
|
|
3243
|
+
eventQueue = null;
|
|
3244
|
+
contextOf = new WeakMap();
|
|
3245
|
+
keepalive = false;
|
|
3246
|
+
eventsForPersistence = [];
|
|
3247
|
+
}
|
|
3248
|
+
};
|
|
3249
|
+
};
|
|
3250
|
+
|
|
3251
|
+
/**
|
|
3252
|
+
* Analytics plugin for tracking page views and custom events in DotCMS applications.
|
|
3253
|
+
* This plugin handles:
|
|
3254
|
+
* 1. Event structuring (deciding between predefined and custom events)
|
|
3255
|
+
* 2. Building complete request bodies
|
|
3256
|
+
* 3. Sending analytics data to the DotCMS server
|
|
3257
|
+
* 4. Managing initialization and queue management
|
|
3258
|
+
*
|
|
3259
|
+
* The enricher plugin runs BEFORE this plugin and adds page/utm/custom data.
|
|
3260
|
+
* This plugin receives enriched payloads and structures them into proper events.
|
|
3261
|
+
*
|
|
3262
|
+
* @param {PipelineConfig} config - Configuration object containing API key, server URL,
|
|
3263
|
+
* debug mode, auto page view settings, and queue config
|
|
3264
|
+
* @returns {Object} Plugin object with methods for initialization and event tracking
|
|
3265
|
+
*/
|
|
3266
|
+
const senderPlugin = (config) => {
|
|
3267
|
+
let isInitialized = false;
|
|
3268
|
+
// Queue is enabled if queue is not explicitly false
|
|
3269
|
+
const enableQueue = config.queue !== false;
|
|
3270
|
+
let queue = null;
|
|
3271
|
+
/**
|
|
3272
|
+
* Dispatches event to queue or directly to server
|
|
3273
|
+
*/
|
|
3274
|
+
const dispatchEvent = (requestBody) => {
|
|
3275
|
+
const event = requestBody.events[0];
|
|
3276
|
+
const context = requestBody.context;
|
|
3277
|
+
// Use queue or send directly
|
|
3278
|
+
if (enableQueue && queue) {
|
|
3279
|
+
if (event) {
|
|
3280
|
+
queue.enqueue(event, context);
|
|
3281
|
+
}
|
|
3282
|
+
}
|
|
3283
|
+
else {
|
|
3284
|
+
// Direct send without queue (when queue === false)
|
|
3285
|
+
sendEvents(requestBody, config);
|
|
3286
|
+
}
|
|
3287
|
+
};
|
|
3288
|
+
return {
|
|
3289
|
+
name: SENDER_PLUGIN_NAME,
|
|
3290
|
+
config,
|
|
3291
|
+
/**
|
|
3292
|
+
* Initialize the plugin with optional queue management
|
|
3293
|
+
*/
|
|
3294
|
+
initialize: () => {
|
|
3295
|
+
isInitialized = true;
|
|
3296
|
+
// Initialize queue if enabled (queue is undefined or an object)
|
|
3297
|
+
if (enableQueue) {
|
|
3298
|
+
queue = createEventQueue(config);
|
|
3299
|
+
queue.initialize();
|
|
3300
|
+
}
|
|
3301
|
+
return Promise.resolve();
|
|
3302
|
+
},
|
|
3303
|
+
/**
|
|
3304
|
+
* Track a page view event
|
|
3305
|
+
* Receives enriched payload from the enricher plugin and structures it into a pageview event
|
|
3306
|
+
*/
|
|
3307
|
+
page: ({ payload }) => {
|
|
3308
|
+
if (!isInitialized) {
|
|
3309
|
+
throw new Error('[dotCMS events] Plugin not initialized');
|
|
3310
|
+
}
|
|
3311
|
+
const { context, page, utm, custom, local_time } = payload;
|
|
3312
|
+
if (!page) {
|
|
3313
|
+
throw new Error('[dotCMS events] Missing required page data');
|
|
3314
|
+
}
|
|
3315
|
+
const requestBody = {
|
|
3316
|
+
context,
|
|
3317
|
+
events: [
|
|
3318
|
+
{
|
|
3319
|
+
event_type: DotCMSPredefinedEventType.PAGEVIEW,
|
|
3320
|
+
local_time,
|
|
3321
|
+
data: {
|
|
3322
|
+
page,
|
|
3323
|
+
...(utm && { utm }),
|
|
3324
|
+
...(custom && { custom })
|
|
3325
|
+
}
|
|
3326
|
+
}
|
|
3327
|
+
]
|
|
3328
|
+
};
|
|
3329
|
+
dispatchEvent(requestBody);
|
|
3330
|
+
},
|
|
3331
|
+
/**
|
|
3332
|
+
* Builds a content impression, content click or conversion from the enriched payload.
|
|
3333
|
+
*
|
|
3334
|
+
* - content_impression → extracts from properties, combines with enriched page data
|
|
3335
|
+
* - content_click → extracts from properties, combines with enriched page data
|
|
3336
|
+
* - conversion → its name and the page
|
|
3337
|
+
*
|
|
3338
|
+
* Those, with pageviews, are the only event types dotCMS accepts.
|
|
3339
|
+
*/
|
|
3340
|
+
track: ({ payload }) => {
|
|
3341
|
+
if (!isInitialized) {
|
|
3342
|
+
throw new Error('[dotCMS events] Plugin not initialized');
|
|
3343
|
+
}
|
|
3344
|
+
const { event, properties, context, local_time } = payload;
|
|
3345
|
+
let analyticsEvent;
|
|
3346
|
+
// One case per event type dotCMS accepts
|
|
3347
|
+
switch (event) {
|
|
3348
|
+
case DotCMSPredefinedEventType.CONTENT_IMPRESSION: {
|
|
3349
|
+
// Extract impression data from properties (sent by tracker)
|
|
3350
|
+
const impressionPayload = properties;
|
|
3351
|
+
const { content, position } = impressionPayload;
|
|
3352
|
+
const { page } = payload; // Added by enricher
|
|
3353
|
+
if (!content || !position || !page) {
|
|
3354
|
+
throw new Error('[dotCMS events] Missing required impression data');
|
|
3355
|
+
}
|
|
3356
|
+
analyticsEvent = {
|
|
3357
|
+
event_type: DotCMSPredefinedEventType.CONTENT_IMPRESSION,
|
|
3358
|
+
local_time,
|
|
3359
|
+
data: {
|
|
3360
|
+
content,
|
|
3361
|
+
position,
|
|
3362
|
+
page
|
|
3363
|
+
}
|
|
3364
|
+
};
|
|
3365
|
+
break;
|
|
3366
|
+
}
|
|
3367
|
+
case DotCMSPredefinedEventType.CONTENT_CLICK: {
|
|
3368
|
+
// Extract click data from properties (sent by click plugin)
|
|
3369
|
+
const clickPayload = properties;
|
|
3370
|
+
const { content, position, element } = clickPayload;
|
|
3371
|
+
const { page } = payload; // Added by enricher
|
|
3372
|
+
if (!content || !position || !element || !page) {
|
|
3373
|
+
throw new Error('[dotCMS events] Missing required click data');
|
|
3374
|
+
}
|
|
3375
|
+
analyticsEvent = {
|
|
3376
|
+
event_type: DotCMSPredefinedEventType.CONTENT_CLICK,
|
|
3377
|
+
local_time,
|
|
3378
|
+
data: {
|
|
3379
|
+
content,
|
|
3380
|
+
position,
|
|
3381
|
+
element,
|
|
3382
|
+
page
|
|
3383
|
+
}
|
|
3384
|
+
};
|
|
3385
|
+
break;
|
|
3386
|
+
}
|
|
3387
|
+
case DotCMSPredefinedEventType.CONVERSION: {
|
|
3388
|
+
// Extract conversion data from properties (sent by user)
|
|
3389
|
+
const { name } = properties;
|
|
3390
|
+
const { page } = payload; // Added by enricher
|
|
3391
|
+
if (!name || !page) {
|
|
3392
|
+
throw new Error('[dotCMS events] Missing required conversion data');
|
|
3393
|
+
}
|
|
3394
|
+
analyticsEvent = {
|
|
3395
|
+
event_type: DotCMSPredefinedEventType.CONVERSION,
|
|
3396
|
+
local_time,
|
|
3397
|
+
data: {
|
|
3398
|
+
conversion: { name },
|
|
3399
|
+
page
|
|
3400
|
+
}
|
|
3401
|
+
};
|
|
3402
|
+
break;
|
|
3403
|
+
}
|
|
3404
|
+
default:
|
|
3405
|
+
// Only the SDK calls track, with the types above
|
|
3406
|
+
throw new Error(`[dotCMS events] ${event} is not an event type dotCMS accepts`);
|
|
3407
|
+
}
|
|
3408
|
+
const requestBody = {
|
|
3409
|
+
context,
|
|
3410
|
+
events: [analyticsEvent]
|
|
3411
|
+
};
|
|
3412
|
+
dispatchEvent(requestBody);
|
|
3413
|
+
},
|
|
3414
|
+
/**
|
|
3415
|
+
* Check if the plugin is loaded
|
|
3416
|
+
*/
|
|
3417
|
+
loaded: () => isInitialized
|
|
3418
|
+
};
|
|
3419
|
+
};
|
|
3420
|
+
|
|
3421
|
+
/** Calls made before `init`, replayed once it runs. Capped so a missing `init` can't grow it. */
|
|
3422
|
+
const MAX_PENDING_CALLS = 50;
|
|
3423
|
+
/**
|
|
3424
|
+
* A storage that lives as long as the page. Analytics.js persists an anonymous id that nothing
|
|
3425
|
+
* here reads, and its default storage falls back to a cookie when localStorage is
|
|
3426
|
+
* unavailable: with this one, nothing it keeps reaches localStorage, sessionStorage or a
|
|
3427
|
+
* cookie.
|
|
3428
|
+
*
|
|
3429
|
+
* @returns The storage
|
|
3430
|
+
*/
|
|
3431
|
+
const createMemoryStorage = () => {
|
|
3432
|
+
const values = new Map();
|
|
3433
|
+
return {
|
|
3434
|
+
getItem: (key) => values.get(key),
|
|
3435
|
+
setItem: (key, value) => void values.set(key, value),
|
|
3436
|
+
removeItem: (key) => void values.delete(key)
|
|
3437
|
+
};
|
|
3438
|
+
};
|
|
3439
|
+
let instance = null;
|
|
3440
|
+
let engine = null;
|
|
3441
|
+
let activeConfigKey = null;
|
|
3442
|
+
let lastPageKey = null;
|
|
3443
|
+
let debug = false;
|
|
3444
|
+
const pendingCalls = [];
|
|
3445
|
+
const isBrowser = () => typeof window !== 'undefined' && typeof document !== 'undefined';
|
|
3446
|
+
const log = (...args) => {
|
|
3447
|
+
if (debug) {
|
|
3448
|
+
// eslint-disable-next-line no-console
|
|
3449
|
+
console.info('[dotCMS events]', ...args);
|
|
3450
|
+
}
|
|
3451
|
+
};
|
|
3452
|
+
let onErrorHandler;
|
|
3453
|
+
/**
|
|
3454
|
+
* Hands a failure to the app's `onError`. A handler that throws must not break the page.
|
|
3455
|
+
*
|
|
3456
|
+
* @param error - The failure
|
|
3457
|
+
*/
|
|
3458
|
+
const reportError = (error) => {
|
|
3459
|
+
try {
|
|
3460
|
+
onErrorHandler?.(error);
|
|
3461
|
+
}
|
|
3462
|
+
catch {
|
|
3463
|
+
// The app's handler failed; events carry on
|
|
3464
|
+
}
|
|
3465
|
+
};
|
|
3466
|
+
/** Reports a setup mistake in the page, only in debug like `log`. */
|
|
3467
|
+
const warn = (message) => {
|
|
3468
|
+
if (debug) {
|
|
3469
|
+
console.warn(`[dotCMS events] ${message}`);
|
|
3470
|
+
}
|
|
3471
|
+
};
|
|
3472
|
+
const defer = (call) => {
|
|
3473
|
+
if (pendingCalls.length < MAX_PENDING_CALLS) {
|
|
3474
|
+
pendingCalls.push(call);
|
|
3475
|
+
}
|
|
3476
|
+
};
|
|
3477
|
+
// Optional settings pass through only when set, so the core applies its own defaults.
|
|
3478
|
+
const toAnalyticsConfig = (config) => ({
|
|
3479
|
+
server: config.dotcmsUrl.replace(/\/+$/, ''),
|
|
3480
|
+
siteAuth: config.siteAuth,
|
|
3481
|
+
debug: Boolean(config.debug),
|
|
3482
|
+
autoPageView: config.autoPageView !== false,
|
|
3483
|
+
...(config.logLevel !== undefined && { logLevel: config.logLevel }),
|
|
3484
|
+
...(config.queue !== undefined && { queue: config.queue }),
|
|
3485
|
+
...(config.impressions !== undefined && { impressions: config.impressions }),
|
|
3486
|
+
...(config.clicks !== undefined && { clicks: config.clicks }),
|
|
3487
|
+
onError: reportError
|
|
3488
|
+
});
|
|
3489
|
+
const assignmentTimeout = (config) => typeof config.experiments === 'object' && typeof config.experiments.timeout === 'number'
|
|
3490
|
+
? config.experiments.timeout
|
|
3491
|
+
: decision.DEFAULT_ASSIGNMENT_TIMEOUT_MS;
|
|
3492
|
+
const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
|
3493
|
+
/**
|
|
3494
|
+
* Sends a pageview, after the experiments engine decides the page. When the engine
|
|
3495
|
+
* redirects, the pageview is dropped: the variant's page sends its own.
|
|
3496
|
+
*/
|
|
3497
|
+
const sendPageView = async (data) => {
|
|
3498
|
+
if (!instance) {
|
|
3499
|
+
return;
|
|
3500
|
+
}
|
|
3501
|
+
if (engine) {
|
|
3502
|
+
const { redirected, left } = await engine.decide();
|
|
3503
|
+
if (redirected) {
|
|
3504
|
+
log('pageview dropped: redirecting to the assigned variant');
|
|
3505
|
+
return;
|
|
3506
|
+
}
|
|
3507
|
+
// Sent now, it would carry the URL of the page the visitor went to
|
|
3508
|
+
if (left) {
|
|
3509
|
+
log('pageview dropped: the visitor left the page while it was decided');
|
|
3510
|
+
return;
|
|
3511
|
+
}
|
|
3512
|
+
}
|
|
3513
|
+
instance.page(data);
|
|
3514
|
+
};
|
|
3515
|
+
const trackAutomaticPageView = async () => {
|
|
3516
|
+
const pageKey = `${window.location.pathname}${window.location.search}`;
|
|
3517
|
+
if (pageKey === lastPageKey) {
|
|
3518
|
+
return;
|
|
3519
|
+
}
|
|
3520
|
+
lastPageKey = pageKey;
|
|
3521
|
+
await sendPageView({});
|
|
3522
|
+
};
|
|
3523
|
+
const startAutomaticPageViews = () => {
|
|
3524
|
+
const first = () => void trackAutomaticPageView();
|
|
3525
|
+
if (document.readyState === 'loading') {
|
|
3526
|
+
document.addEventListener('DOMContentLoaded', first, { once: true });
|
|
3527
|
+
}
|
|
3528
|
+
else {
|
|
3529
|
+
first();
|
|
3530
|
+
}
|
|
3531
|
+
// Wait two frames after a navigation, so the new route's rows are in the DOM.
|
|
3532
|
+
onNavigation(() => {
|
|
3533
|
+
void nextFrame()
|
|
3534
|
+
.then(nextFrame)
|
|
3535
|
+
.then(() => trackAutomaticPageView());
|
|
3536
|
+
});
|
|
3537
|
+
// A page restored from the back/forward cache keeps the URL already counted, but the
|
|
3538
|
+
// visitor sees it again: it counts, as it does when the browser loads it again instead
|
|
3539
|
+
onPageRestore(() => void sendPageView({}));
|
|
3540
|
+
};
|
|
3541
|
+
/**
|
|
3542
|
+
* Starts the SDK: what `dotEvents.init` does, with how the pages mark their experiment. Apps call
|
|
3543
|
+
* `dotEvents.init`, for pages that print the experiment markup; the script dotCMS injects into
|
|
3544
|
+
* traditional pages calls this with `contentlets`, since those pages carry dotCMS's contentlet
|
|
3545
|
+
* wrappers instead. Not exported by the package.
|
|
3546
|
+
*
|
|
3547
|
+
* @param config - The app's config
|
|
3548
|
+
* @param pages - How the pages mark what their experiment varies
|
|
3549
|
+
*/
|
|
3550
|
+
const initEvents = (config, pages) => {
|
|
3551
|
+
if (!isBrowser()) {
|
|
3552
|
+
return;
|
|
3553
|
+
}
|
|
3554
|
+
const configKey = `${config?.dotcmsUrl}|${config?.siteAuth}`;
|
|
3555
|
+
// Claimed as soon as init starts: Analytics.js loads after this call returns
|
|
3556
|
+
if (activeConfigKey) {
|
|
3557
|
+
if (configKey !== activeConfigKey) {
|
|
3558
|
+
console.warn('[dotCMS events] init was called again with another config; ignored');
|
|
3559
|
+
}
|
|
3560
|
+
return;
|
|
3561
|
+
}
|
|
3562
|
+
debug = Boolean(config?.debug);
|
|
3563
|
+
onErrorHandler = config?.onError;
|
|
3564
|
+
if (!config?.dotcmsUrl || !config?.siteAuth) {
|
|
3565
|
+
console.error('[dotCMS events] init needs dotcmsUrl and siteAuth');
|
|
3566
|
+
return;
|
|
3567
|
+
}
|
|
3568
|
+
if (uve.getUVEState()) {
|
|
3569
|
+
// Inside the editor nothing is tracked, and nothing may stay hidden.
|
|
3570
|
+
revealRows();
|
|
3571
|
+
window[ANALYTICS_WINDOWS_ACTIVE_KEY] = false;
|
|
3572
|
+
log('inside UVE: events off');
|
|
3573
|
+
return;
|
|
3574
|
+
}
|
|
3575
|
+
activeConfigKey = configKey;
|
|
3576
|
+
const analyticsConfig = toAnalyticsConfig(config);
|
|
3577
|
+
const experimentsOn = config.experiments !== false;
|
|
3578
|
+
engine = experimentsOn
|
|
3579
|
+
? createExperimentsEngine({
|
|
3580
|
+
dotcmsUrl: analyticsConfig.server,
|
|
3581
|
+
timeoutMs: assignmentTimeout(config),
|
|
3582
|
+
log,
|
|
3583
|
+
warn,
|
|
3584
|
+
onError: reportError,
|
|
3585
|
+
pages
|
|
3586
|
+
})
|
|
3587
|
+
: null;
|
|
3588
|
+
if (engine) {
|
|
3589
|
+
// On pages dotCMS renders, a returning visitor is decided now, while the head is parsed.
|
|
3590
|
+
// Otherwise a new visitor's isUserIncluded leaves now, while Analytics.js loads.
|
|
3591
|
+
if (!engine.prepare()) {
|
|
3592
|
+
engine.start();
|
|
3593
|
+
}
|
|
3594
|
+
}
|
|
3595
|
+
else {
|
|
3596
|
+
revealRows();
|
|
3597
|
+
}
|
|
3598
|
+
// The page renderers print contentlet attributes while analytics is active, so they
|
|
3599
|
+
// learn it now, before they hydrate, and never render twice.
|
|
3600
|
+
window[ANALYTICS_WINDOWS_ACTIVE_KEY] = true;
|
|
3601
|
+
window.dispatchEvent(new CustomEvent(ANALYTICS_READY_EVENT));
|
|
3602
|
+
// Traditional pages and plain scripts reach the same object as window.dotEvents.
|
|
3603
|
+
const globalScope = window;
|
|
3604
|
+
if (!globalScope[EVENTS_WINDOW_KEY]) {
|
|
3605
|
+
globalScope[EVENTS_WINDOW_KEY] = dotEvents;
|
|
3606
|
+
}
|
|
3607
|
+
// Analytics.js reads the time zone when its module is evaluated: loading it here keeps
|
|
3608
|
+
// that work, and the module's, out of the task that starts the app. Calls wait in
|
|
3609
|
+
// pendingCalls meanwhile.
|
|
3610
|
+
void import('analytics')
|
|
3611
|
+
.then(({ Analytics }) => {
|
|
3612
|
+
const plugins = [
|
|
3613
|
+
identityPlugin(analyticsConfig),
|
|
3614
|
+
...(engine ? [experimentsPlugin(engine)] : []),
|
|
3615
|
+
...getEnhancedTrackingPlugins(analyticsConfig, impressionsPlugin, clicksPlugin),
|
|
3616
|
+
enricherPlugin(),
|
|
3617
|
+
senderPlugin(analyticsConfig)
|
|
3618
|
+
];
|
|
3619
|
+
// Analytics.js reads `storage` from its config, though its types leave it out
|
|
3620
|
+
const analyticsJsConfig = {
|
|
3621
|
+
app: 'dotEvents',
|
|
3622
|
+
debug: analyticsConfig.debug,
|
|
3623
|
+
plugins,
|
|
3624
|
+
storage: createMemoryStorage()
|
|
3625
|
+
};
|
|
3626
|
+
instance = Analytics(analyticsJsConfig);
|
|
3627
|
+
log('ready', {
|
|
3628
|
+
experiments: experimentsOn,
|
|
3629
|
+
autoPageView: analyticsConfig.autoPageView
|
|
3630
|
+
});
|
|
3631
|
+
pendingCalls.splice(0).forEach((call) => call());
|
|
3632
|
+
if (analyticsConfig.autoPageView) {
|
|
3633
|
+
startAutomaticPageViews();
|
|
3634
|
+
}
|
|
3635
|
+
})
|
|
3636
|
+
.catch((error) => {
|
|
3637
|
+
console.error('[dotCMS events] Analytics.js failed to load; no events are sent', error);
|
|
3638
|
+
});
|
|
3639
|
+
};
|
|
3640
|
+
/**
|
|
3641
|
+
* The events SDK. There is one object per page: every import gets the same instance,
|
|
3642
|
+
* configured by the one `init` call.
|
|
3643
|
+
*/
|
|
3644
|
+
const dotEvents = {
|
|
3645
|
+
init(config) {
|
|
3646
|
+
initEvents(config, 'markup');
|
|
3647
|
+
},
|
|
3648
|
+
conversion(name) {
|
|
3649
|
+
if (!isBrowser()) {
|
|
3650
|
+
return;
|
|
3651
|
+
}
|
|
3652
|
+
if (!name || name.trim() === '') {
|
|
3653
|
+
console.warn('[dotCMS events] a conversion needs a name');
|
|
3654
|
+
return;
|
|
3655
|
+
}
|
|
3656
|
+
if (!instance) {
|
|
3657
|
+
defer(() => dotEvents.conversion(name));
|
|
3658
|
+
return;
|
|
3659
|
+
}
|
|
3660
|
+
instance.track(DotCMSPredefinedEventType.CONVERSION, { name });
|
|
3661
|
+
},
|
|
3662
|
+
pageView(data = {}) {
|
|
3663
|
+
if (!isBrowser()) {
|
|
3664
|
+
return;
|
|
3665
|
+
}
|
|
3666
|
+
if (!instance) {
|
|
3667
|
+
defer(() => dotEvents.pageView(data));
|
|
3668
|
+
return;
|
|
3669
|
+
}
|
|
3670
|
+
void sendPageView(data);
|
|
3671
|
+
}
|
|
3672
|
+
};
|
|
3673
|
+
|
|
3674
|
+
exports.dotEvents = dotEvents;
|