@adobe/helix-rum-enhancer 2.47.3 → 2.49.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/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ # [2.49.0](https://github.com/adobe/helix-rum-enhancer/compare/v2.48.0...v2.49.0) (2026-07-10)
2
+
3
+
4
+ ### Features
5
+
6
+ * flag untrusted (synthetic) clicks as bots ([94b2893](https://github.com/adobe/helix-rum-enhancer/commit/94b289333d0c8a33435752c8f48f080b5dfa203d))
7
+
8
+ # [2.48.0](https://github.com/adobe/helix-rum-enhancer/compare/v2.47.3...v2.48.0) (2026-07-08)
9
+
10
+
11
+ ### Features
12
+
13
+ * report source for the largest CLS layout shift ([53a0432](https://github.com/adobe/helix-rum-enhancer/commit/53a04321044b444b8f883b63f71dd4394814877b))
14
+ * report source for the largest CLS layout shift ([a74c6f4](https://github.com/adobe/helix-rum-enhancer/commit/a74c6f453b639504b82874750d247b27c5a78f21))
15
+
1
16
  ## [2.47.3](https://github.com/adobe/helix-rum-enhancer/compare/v2.47.2...v2.47.3) (2026-05-13)
2
17
 
3
18
 
@@ -11,6 +11,6 @@
11
11
  */
12
12
  import { fflags } from './fflags.js';
13
13
 
14
- export const KNOWN_PROPERTIES = ['weight', 'id', 'referer', 'checkpoint', 't', 'source', 'target', 'cwv', 'CLS', 'FID', 'LCP', 'INP', 'TTFB'];
14
+ export const KNOWN_PROPERTIES = ['weight', 'id', 'referer', 'checkpoint', 't', 'source', 'target', 'cwv', 'CLS', 'FID', 'LCP', 'INP', 'TTFB', 'ua'];
15
15
  export const DEFAULT_TRACKING_EVENTS = ['click', 'cwv', 'form', 'viewblock', 'viewmedia', 'loadresource', 'utm', 'paid', 'email', 'consent'];
16
16
  fflags.enabled('example', () => DEFAULT_TRACKING_EVENTS.push('example'));
package/modules/dom.js CHANGED
@@ -32,12 +32,20 @@ export const targetSelector = (el) => {
32
32
  }
33
33
  };
34
34
 
35
- function walk(el, checkFn) {
35
+ export const untrustedClickPayload = (event) => {
36
+ if (event && event.isTrusted === false) {
37
+ const ua = navigator.userAgent;
38
+ return { ua: ua.includes('+http') ? ua : `${ua} +http://event.untrusted` };
39
+ }
40
+ return {};
41
+ };
42
+
43
+ function walk(el, check) {
36
44
  if (!el || el === document.body || el === document.documentElement) {
37
45
  return undefined;
38
46
  }
39
47
 
40
- return checkFn(el) || walk(el.parentElement || (el.parentNode && el.parentNode.host), checkFn);
48
+ return check(el) || walk(el.parentElement || (el.parentNode && el.parentNode.host), check);
41
49
  }
42
50
 
43
51
  function isDialog(el) {
@@ -53,19 +61,19 @@ function isButton(el) {
53
61
  return true;
54
62
  }
55
63
  if (el.tagName === 'A') {
56
- return Array.from(el.classList).some((className) => className.match(/button|cta/));
64
+ return Array.from(el.classList).some((cls) => cls.match(/button|cta/));
57
65
  }
58
66
  return el.getAttribute('role') === 'button';
59
67
  }
60
68
 
61
- function getSourceContext(el) {
62
- const formEl = el.closest('form');
63
- if (formEl) {
64
- const id = formEl.getAttribute('id');
69
+ function srcContext(el) {
70
+ const form = el.closest('form');
71
+ if (form) {
72
+ const id = form.getAttribute('id');
65
73
  if (id) {
66
74
  return `form#${CSS.escape(id)}`;
67
75
  }
68
- return `form${formEl.classList.length > 0 ? `.${CSS.escape(formEl.classList[0])}` : ''}`;
76
+ return `form${form.classList.length > 0 ? `.${CSS.escape(form.classList[0])}` : ''}`;
69
77
  }
70
78
  const block = el.closest('.block[data-block-name]');
71
79
  return ((block && `.${block.getAttribute('data-block-name')}`)
@@ -75,7 +83,7 @@ function getSourceContext(el) {
75
83
  || walk(el, (e) => e.id && `#${CSS.escape(e.id)}`));
76
84
  }
77
85
 
78
- function getSourceElement(el) {
86
+ function srcElement(el) {
79
87
  const f = el.closest('form');
80
88
  if (f && Array.from(f.elements).includes(el)) {
81
89
  return (el.tagName.toLowerCase()
@@ -87,7 +95,7 @@ function getSourceElement(el) {
87
95
  || (el.tagName.toLowerCase().match(/^(a|img|video|form)$/) && el.tagName.toLowerCase());
88
96
  }
89
97
 
90
- function getSourceIdentifier(el) {
98
+ function srcId(el) {
91
99
  return (el.id && `#${CSS.escape(el.id)}`)
92
100
  || (el.getAttribute('data-block-name') && `.${el.getAttribute('data-block-name')}`)
93
101
  || (el.classList.length > 0 && `.${CSS.escape(el.classList[0])}`);
@@ -101,9 +109,9 @@ export const sourceSelector = (el) => {
101
109
  if (el.getAttribute('data-rum-source')) {
102
110
  return el.getAttribute('data-rum-source');
103
111
  }
104
- const ctx = getSourceContext(el.parentElement) || '';
105
- const name = getSourceElement(el) || '';
106
- const id = getSourceIdentifier(el) || '';
112
+ const ctx = srcContext(el.parentElement) || '';
113
+ const name = srcElement(el) || '';
114
+ const id = srcId(el) || '';
107
115
  return `${ctx} ${name}${id}`.trim() || `"${el.textContent.substring(0, 10)}"`;
108
116
  /* c8 ignore next 3 */
109
117
  } catch (error) {
package/modules/index.js CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { KNOWN_PROPERTIES, DEFAULT_TRACKING_EVENTS } from './defaults.js';
15
15
  import { urlSanitizers } from './utils.js';
16
- import { targetSelector, sourceSelector } from './dom.js';
16
+ import { targetSelector, sourceSelector, untrustedClickPayload } from './dom.js';
17
17
  import { fflags } from './fflags.js';
18
18
 
19
19
  const { sampleRUM, queue, isSelected } = (window.hlx && window.hlx.rum) ? window.hlx.rum
@@ -27,19 +27,19 @@ const createMO = (cb) => (window.MutationObserver ? new MutationObserver(cb)
27
27
  const [blocksMO, mediaMO] = [blocksMCB, mediaMCB].map(createMO);
28
28
 
29
29
  // Check for the presence of a given cookie
30
- const hasCookieKey = (key) => document.cookie.split(';').some((c) => c.trim().startsWith(`${key}=`));
30
+ const hasCookie = (key) => document.cookie.split(';').some((c) => c.trim().startsWith(`${key}=`));
31
31
 
32
32
  // Set the base path for the plugins
33
- const pluginBasePath = new URL(document.currentScript.src).href.replace(/index(\.map)?\.js/, 'plugins');
33
+ const pluginBase = new URL(document.currentScript.src).href.replace(/index(\.map)?\.js/, 'plugins');
34
34
 
35
35
  const CONSENT_PROVIDERS = [
36
36
  {
37
37
  name: 'onetrust',
38
- detect: () => hasCookieKey('OptanonAlertBoxClosed') || document.querySelector('#onetrust-banner-sdk, #onetrust-pc-sdk'),
38
+ detect: () => hasCookie('OptanonAlertBoxClosed') || document.querySelector('#onetrust-banner-sdk, #onetrust-pc-sdk'),
39
39
  },
40
40
  {
41
41
  name: 'trustarc',
42
- detect: () => ['notice_gdpr_prefs', 'notice_preferences'].some(hasCookieKey) || document.querySelector('#truste-consent-track'),
42
+ detect: () => ['notice_gdpr_prefs', 'notice_preferences'].some(hasCookie) || document.querySelector('#truste-consent-track'),
43
43
  },
44
44
  {
45
45
  name: 'usercentrics',
@@ -47,40 +47,40 @@ const CONSENT_PROVIDERS = [
47
47
  },
48
48
  ];
49
49
 
50
- const getConsentProvider = () => CONSENT_PROVIDERS.find(({ detect }) => detect());
50
+ const getConsent = () => CONSENT_PROVIDERS.find(({ detect }) => detect());
51
51
  const bodyChildMO = {
52
52
  target: document.body,
53
53
  options: { attributes: false, childList: true, subtree: false },
54
54
  };
55
55
 
56
56
  const PLUGINS = {
57
- cwv: `${pluginBasePath}/cwv.js`,
58
- a11y: `${pluginBasePath}/a11y.js`,
57
+ cwv: `${pluginBase}/cwv.js`,
58
+ a11y: `${pluginBase}/a11y.js`,
59
59
  // Interactive elements
60
60
  form: {
61
- url: `${pluginBasePath}/form.js`,
61
+ url: `${pluginBase}/form.js`,
62
62
  when: () => document.querySelector('form'),
63
63
  isBlockDependent: true,
64
- mutationObserverParams: bodyChildMO,
64
+ moParams: bodyChildMO,
65
65
  },
66
66
  redirect: {
67
- url: `${pluginBasePath}/redirect.js`,
67
+ url: `${pluginBase}/redirect.js`,
68
68
  when: ({ perfEntry: pe, urlParameters: usp }) => (
69
69
  pe && (usp.get('redirect_from') || pe.redirectCount > 0 || pe.fetchStart > 50)
70
70
  ),
71
71
  },
72
- video: { url: `${pluginBasePath}/video.js`, when: () => document.querySelector('video'), isBlockDependent: true },
72
+ video: { url: `${pluginBase}/video.js`, when: () => document.querySelector('video'), isBlockDependent: true },
73
73
  webcomponent: {
74
- url: `${pluginBasePath}/webcomponent.js`,
74
+ url: `${pluginBase}/webcomponent.js`,
75
75
  when: () => [...document.querySelectorAll('*')].some((el) => el.tagName && el.tagName.includes('-')),
76
76
  isBlockDependent: true,
77
77
  },
78
78
  // Martech
79
- martech: { url: `${pluginBasePath}/martech.js`, when: ({ urlParameters }) => urlParameters.size > 0 },
79
+ martech: { url: `${pluginBase}/martech.js`, when: ({ urlParameters }) => urlParameters.size > 0 },
80
80
  consent: {
81
- when: () => getConsentProvider(),
81
+ when: () => getConsent(),
82
82
  isBlockDependent: true,
83
- mutationObserverParams: bodyChildMO,
83
+ moParams: bodyChildMO,
84
84
  },
85
85
  // test: broken-plugin
86
86
  };
@@ -94,12 +94,12 @@ function getIntersectionObserver(checkpoint) {
94
94
  if (!window.IntersectionObserver) {
95
95
  return null;
96
96
  }
97
- const observer = new IntersectionObserver((entries) => {
97
+ const obs = new IntersectionObserver((entries) => {
98
98
  try {
99
99
  entries
100
100
  .filter((e) => e.isIntersecting)
101
101
  .forEach((e) => {
102
- observer.unobserve(e.target); // observe only once
102
+ obs.unobserve(e.target); // observe only once
103
103
  const target = targetSelector(e.target);
104
104
  const source = sourceSelector(e.target);
105
105
  if (checkpoint === 'viewmedia') {
@@ -118,15 +118,16 @@ function getIntersectionObserver(checkpoint) {
118
118
  // something went wrong
119
119
  }
120
120
  });
121
- return observer;
121
+ return obs;
122
122
  }
123
123
 
124
- const PLUGIN_PARAMETERS = {
124
+ const PLUGIN_PARAMS = {
125
125
  context: document.body,
126
126
  fflags,
127
127
  sampleRUM,
128
128
  sourceSelector,
129
129
  targetSelector,
130
+ untrustedClickPayload,
130
131
  getIntersectionObserver,
131
132
  createMO,
132
133
  };
@@ -137,14 +138,14 @@ function loadPlugin(key, params) {
137
138
  const plugin = PLUGINS[key];
138
139
  const usp = new URLSearchParams(window.location.search);
139
140
  if (!pluginCache.has(key) && plugin.when && !plugin.when({ ...params, urlParameters: usp })) {
140
- if (plugin.mutationObserverParams && !plugin.isBeingObserved) {
141
+ if (plugin.moParams && !plugin.isBeingObserved) {
141
142
  // eslint-disable-next-line no-use-before-define
142
143
  createPluginMO(key, params, usp);
143
144
  }
144
145
  return null;
145
146
  }
146
147
  if (key === 'consent') {
147
- plugin.url = `${pluginBasePath}/${getConsentProvider().name}.js`;
148
+ plugin.url = `${pluginBase}/${getConsent().name}.js`;
148
149
  }
149
150
 
150
151
  if (!pluginCache.has(key)) {
@@ -156,7 +157,7 @@ function loadPlugin(key, params) {
156
157
  .catch(() => { /* silent plugin error catching */ });
157
158
  }
158
159
 
159
- function loadPlugins(filter = () => true, params = PLUGIN_PARAMETERS) {
160
+ function loadPlugins(filter = () => true, params = PLUGIN_PARAMS) {
160
161
  Object.entries(PLUGINS)
161
162
  .filter(([, plugin]) => filter(plugin))
162
163
  .map(([key]) => loadPlugin(key, params));
@@ -164,19 +165,19 @@ function loadPlugins(filter = () => true, params = PLUGIN_PARAMETERS) {
164
165
 
165
166
  function createPluginMO(key, params, usp) {
166
167
  const plugin = PLUGINS[key];
167
- const observer = createMO(() => {
168
+ const obs = createMO(() => {
168
169
  if (plugin.when({ urlParameters: usp })) {
169
170
  plugin.isBeingObserved = false;
170
- observer.disconnect();
171
+ obs.disconnect();
171
172
  loadPlugin(key, params);
172
173
  }
173
174
  });
174
175
 
175
- if (observer instanceof MutationObserver) {
176
+ if (obs instanceof MutationObserver) {
176
177
  plugin.isBeingObserved = true;
177
- observer.observe(
178
- plugin.mutationObserverParams.target,
179
- plugin.mutationObserverParams.options,
178
+ obs.observe(
179
+ plugin.moParams.target,
180
+ plugin.moParams.options,
180
181
  );
181
182
  }
182
183
  }
@@ -242,7 +243,7 @@ function addNavigationTracking() {
242
243
  } else {
243
244
  sampleRUM('enter', payload); // enter site
244
245
  }
245
- loadPlugin('redirect', { ...PLUGIN_PARAMETERS, perfEntry });
246
+ loadPlugin('redirect', { ...PLUGIN_PARAMS, perfEntry });
246
247
  };
247
248
  const processed = new Set(); // avoid processing duplicate types
248
249
  new PerformanceObserver((list) => list
@@ -257,7 +258,7 @@ function addNavigationTracking() {
257
258
  }
258
259
 
259
260
  function addLoadResourceTracking() {
260
- const observer = new PerformanceObserver((list) => {
261
+ const obs = new PerformanceObserver((list) => {
261
262
  try {
262
263
  const entries = list.getEntries();
263
264
  entries
@@ -265,14 +266,14 @@ function addLoadResourceTracking() {
265
266
  .filter((e) => window.location.hostname === new URL(e.name).hostname || fflags.has('allresources'))
266
267
  .filter((e) => {
267
268
  const { pathname, hostname } = new URL(e.name);
268
- const extensionMatch = pathname.match(
269
+ const extMatch = pathname.match(
269
270
  hostname !== window.location.hostname
270
271
  ? '.*(\\.html$|\\.json|\\.js|graphql|api)'
271
272
  : '.*(\\.plain\\.html$|\\.json|graphql|api)',
272
273
  );
273
274
  const isDropIn = fflags.has('allresources') && (pathname.includes('__dropins__/storefront-') || pathname.includes('scripts/dropins/storefront-'));
274
275
  const isImage = fflags.has('allresources') && pathname.match(/\.(png|jpe?g|svg)$/i);
275
- return extensionMatch || isDropIn || isImage;
276
+ return extMatch || isDropIn || isImage;
276
277
  })
277
278
  .forEach((e) => {
278
279
  sampleRUM('loadresource', { source: e.name, target: Math.round(e.duration) });
@@ -288,7 +289,7 @@ function addLoadResourceTracking() {
288
289
  // something went wrong
289
290
  }
290
291
  });
291
- observer.observe({ type: 'resource', buffered: true });
292
+ obs.observe({ type: 'resource', buffered: true });
292
293
  }
293
294
 
294
295
  // activate blocks mutation observer
@@ -317,22 +318,22 @@ function activateMediaMO() {
317
318
  );
318
319
  }
319
320
 
320
- function addViewBlockTracking(element) {
321
- const blockobserver = getIntersectionObserver('viewblock');
322
- if (blockobserver) {
323
- const blocks = element.getAttribute('data-block-status') ? [element] : element.querySelectorAll('div[data-block-status="loaded"]');
324
- blocks.forEach((b) => blockobserver.observe(b));
321
+ function addViewBlockTracking(el) {
322
+ const blockObs = getIntersectionObserver('viewblock');
323
+ if (blockObs) {
324
+ const blocks = el.getAttribute('data-block-status') ? [el] : el.querySelectorAll('div[data-block-status="loaded"]');
325
+ blocks.forEach((b) => blockObs.observe(b));
325
326
  }
326
327
  }
327
328
 
328
329
  const observedMedia = new Set();
329
330
  function addViewMediaTracking(parent) {
330
- const mediaobserver = getIntersectionObserver('viewmedia');
331
- if (mediaobserver) {
331
+ const mediaObs = getIntersectionObserver('viewmedia');
332
+ if (mediaObs) {
332
333
  parent.querySelectorAll('img, video, audio, iframe').forEach((m) => {
333
334
  if (!observedMedia.has(m)) {
334
335
  observedMedia.add(m);
335
- mediaobserver.observe(m);
336
+ mediaObs.observe(m);
336
337
  }
337
338
  });
338
339
  }
@@ -349,7 +350,7 @@ function blocksMCB(mutations) {
349
350
  .filter((m) => m.type === 'attributes' && m.attributeName === 'data-block-status')
350
351
  .filter((m) => m.target.dataset.blockStatus === 'loaded')
351
352
  .forEach((m) => {
352
- addObserver('form', (el) => loadPlugins((p) => p.isBlockDependent, { ...PLUGIN_PARAMETERS, context: el }), m.target);
353
+ addObserver('form', (el) => loadPlugins((p) => p.isBlockDependent, { ...PLUGIN_PARAMS, context: el }), m.target);
353
354
  addObserver('viewblock', addViewBlockTracking, m.target);
354
355
  });
355
356
  }
@@ -371,7 +372,8 @@ function addTrackingFromConfig() {
371
372
  if (event.optelHandled) {
372
373
  return;
373
374
  }
374
- sampleRUM('click', { target: targetSelector(event.target), source: sourceSelector(event.target) });
375
+ // eslint-disable-next-line max-len
376
+ sampleRUM('click', { target: targetSelector(event.target), source: sourceSelector(event.target), ...untrustedClickPayload(event) });
375
377
  });
376
378
 
377
379
  // Core tracking
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/helix-rum-enhancer",
3
- "version": "2.47.3",
3
+ "version": "2.49.0",
4
4
  "description": "Helix RUM Enhancer",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -33,12 +33,12 @@
33
33
  "reporter-options": "configFile=.mocha-multi.json"
34
34
  },
35
35
  "devDependencies": {
36
- "@adobe/eslint-config-helix": "3.0.26",
37
- "@adobe/helix-rum-js": "2.14.3",
36
+ "@adobe/eslint-config-helix": "3.0.28",
37
+ "@adobe/helix-rum-js": "2.15.3",
38
38
  "@adobe/rollup-plugin-checksum": "1.2.0",
39
- "@eslint/config-helpers": "0.5.5",
39
+ "@eslint/config-helpers": "0.6.0",
40
40
  "@esm-bundle/chai": "4.3.4-fix.0",
41
- "@rollup/plugin-babel": "7.0.0",
41
+ "@rollup/plugin-babel": "7.1.0",
42
42
  "@semantic-release/changelog": "6.0.3",
43
43
  "@semantic-release/exec": "7.1.0",
44
44
  "@semantic-release/git": "10.0.1",
@@ -53,18 +53,18 @@
53
53
  "eslint": "9.30.1",
54
54
  "eslint-plugin-header": "3.1.1",
55
55
  "eslint-plugin-import": "2.32.0",
56
- "globals": "17.5.0",
56
+ "globals": "17.6.0",
57
57
  "husky": "9.1.7",
58
58
  "jsdoc-to-markdown": "9.1.3",
59
59
  "junit-report-builder": "5.1.2",
60
60
  "lint-staged": "16.4.0",
61
- "mocha": "11.7.5",
61
+ "mocha": "11.7.6",
62
62
  "mocha-multi-reporters": "1.5.1",
63
- "rollup": "4.60.2",
63
+ "rollup": "4.62.2",
64
64
  "rollup-plugin-eslint-bundle": "9.0.0",
65
- "semantic-release": "25.0.3",
65
+ "semantic-release": "25.0.5",
66
66
  "semantic-release-slack-bot": "^4.0.2",
67
- "web-vitals": "5.2.0"
67
+ "web-vitals": "5.3.0"
68
68
  },
69
69
  "lint-staged": {
70
70
  "*.js": "eslint",
package/plugins/cwv.js CHANGED
@@ -35,6 +35,17 @@ export default function addCWVTracking({
35
35
  data.target = targetSelector(element);
36
36
  data.source = sourceSelector(element) || (element && element.outerHTML.slice(0, 30));
37
37
  }
38
+ if (measurement.name === 'CLS' && measurement.entries.length > 0) {
39
+ // Attribute CLS to the element of the single largest layout shift
40
+ // (mirrors web-vitals' `largestShiftTarget`, derived from the standard entries).
41
+ const largest = measurement.entries
42
+ .reduce((a, b) => (b.value > (a ? a.value : 0) ? b : a), undefined);
43
+ const node = largest && largest.sources
44
+ && largest.sources.map((s) => s.node).find((n) => n && n.nodeType === 1);
45
+ if (node) {
46
+ data.source = sourceSelector(node) || node.outerHTML.slice(0, 30);
47
+ }
48
+ }
38
49
  sampleRUM('cwv', data);
39
50
  };
40
51
 
@@ -23,6 +23,7 @@ export default function addWebComponentTracking({
23
23
  sampleRUM,
24
24
  sourceSelector,
25
25
  targetSelector,
26
+ untrustedClickPayload,
26
27
  createMO,
27
28
  }) {
28
29
  /**
@@ -90,7 +91,8 @@ export default function addWebComponentTracking({
90
91
  }
91
92
  // eslint-disable-next-line no-param-reassign
92
93
  event.optelHandled = true;
93
- sampleRUM('click', { target: targetSelector(event.target), source: sourceSelector(event.target) });
94
+ // eslint-disable-next-line max-len
95
+ sampleRUM('click', { target: targetSelector(event.target), source: sourceSelector(event.target), ...untrustedClickPayload(event) });
94
96
  });
95
97
  }
96
98
  // look for web components below this element