@adobe/spacecat-shared-rum-api-client 2.41.0 → 2.42.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,16 @@
1
+ ## [@adobe/spacecat-shared-rum-api-client-v2.42.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-rum-api-client-v2.41.1...@adobe/spacecat-shared-rum-api-client-v2.42.0) (2026-06-09)
2
+
3
+ ### Features
4
+
5
+ * Add per-field engagement metrics for forms ([#1653](https://github.com/adobe/spacecat-shared/issues/1653)) ([b6dba5b](https://github.com/adobe/spacecat-shared/commit/b6dba5bd9abb623aecf2d3f5d8e1e36bf427a919))
6
+
7
+ ## [@adobe/spacecat-shared-rum-api-client-v2.41.1](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-rum-api-client-v2.41.0...@adobe/spacecat-shared-rum-api-client-v2.41.1) (2026-05-29)
8
+
9
+ ### Bug Fixes
10
+
11
+ * **deps:** update adobe fixes ([#1609](https://github.com/adobe/spacecat-shared/issues/1609)) ([ccdcc2b](https://github.com/adobe/spacecat-shared/commit/ccdcc2b88c1b03e5c58ed682daaaadb382638395))
12
+ * **deps:** update external major (major) ([#1611](https://github.com/adobe/spacecat-shared/issues/1611)) ([5a80797](https://github.com/adobe/spacecat-shared/commit/5a807979187f1ebcc32b088faee9165aa38cc461))
13
+
1
14
  ## [@adobe/spacecat-shared-rum-api-client-v2.41.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-rum-api-client-v2.40.13...@adobe/spacecat-shared-rum-api-client-v2.41.0) (2026-05-21)
2
15
 
3
16
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-rum-api-client",
3
- "version": "2.41.0",
3
+ "version": "2.42.0",
4
4
  "description": "Shared modules of the Spacecat Services - Rum API client",
5
5
  "type": "module",
6
6
  "engines": {
@@ -37,7 +37,7 @@
37
37
  "dependencies": {
38
38
  "@adobe/fetch": "4.3.0",
39
39
  "@adobe/helix-shared-wrap": "2.0.2",
40
- "@adobe/helix-universal": "5.4.1",
40
+ "@adobe/helix-universal": "5.4.2",
41
41
  "@adobe/rum-distiller": "1.23.0",
42
42
  "@adobe/spacecat-shared-utils": "1.81.1",
43
43
  "aws4": "1.13.2",
@@ -47,7 +47,7 @@
47
47
  "chai": "6.2.2",
48
48
  "chai-as-promised": "8.0.2",
49
49
  "nock": "14.0.15",
50
- "sinon": "21.1.2",
50
+ "sinon": "22.0.0",
51
51
  "sinon-chai": "4.0.1",
52
52
  "typescript": "6.0.3"
53
53
  }
@@ -0,0 +1,106 @@
1
+ /*
2
+ * Copyright 2026 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ // Per-field form engagement helper. NOT a RUM query — invoked from form-vitals.js to
14
+ // attach a `fieldEngagement` property to each form-vitals entry (like trafficacquisition).
15
+
16
+ import { FORM_KEYWORDS_TO_FILTER, isFormSource } from '../utils.js';
17
+
18
+ const average = (nums) => (nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : 0);
19
+
20
+ const isSearchEvent = (event) => FORM_KEYWORDS_TO_FILTER.some(
21
+ (kw) => event.source?.toLowerCase().includes(kw) || event.target?.toLowerCase().includes(kw),
22
+ );
23
+
24
+ /**
25
+ * Average time (in seconds) spent on a field before the next interaction.
26
+ * Direct port of time_spent_in_seconds() from forms-analysis-automation:
27
+ * within each bundle, sort events by timeDelta; for every click/fill on this
28
+ * selector, take the gap to the next event; average the gaps and convert ms → s.
29
+ */
30
+ function timeSpentInSeconds(urlBundles, selector) {
31
+ const deltaDiffs = [];
32
+ for (const bundle of urlBundles) {
33
+ const sortedEvents = [...bundle.events].sort((a, b) => a.timeDelta - b.timeDelta);
34
+ for (let i = 0; i < sortedEvents.length - 1; i += 1) {
35
+ const event = sortedEvents[i];
36
+ if ((event.checkpoint === 'click' || event.checkpoint === 'fill')
37
+ && event.source === selector) {
38
+ deltaDiffs.push(sortedEvents[i + 1].timeDelta - event.timeDelta);
39
+ }
40
+ }
41
+ }
42
+ return Math.round((average(deltaDiffs) / 1000) * 100) / 100;
43
+ }
44
+
45
+ /** Average absolute timeDelta of a field's click/fill events — used for output ordering. */
46
+ function avgInteractionTime(urlBundles, selector) {
47
+ const times = [];
48
+ for (const bundle of urlBundles) {
49
+ for (const event of bundle.events) {
50
+ if ((event.checkpoint === 'click' || event.checkpoint === 'fill')
51
+ && event.source === selector && event.timeDelta != null) {
52
+ times.push(event.timeDelta);
53
+ }
54
+ }
55
+ }
56
+ return average(times);
57
+ }
58
+
59
+ /**
60
+ * Single-traversal collection of weighted click/fill counts per field selector.
61
+ * Returns a Map<source, { clicks, fills }> for all form-scoped field events.
62
+ */
63
+ function getFieldMetrics(urlBundles, formSourceKey) {
64
+ const metricsMap = new Map();
65
+ for (const bundle of urlBundles) {
66
+ for (const event of bundle.events) {
67
+ if ((event.checkpoint === 'click' || event.checkpoint === 'fill')
68
+ && event.source && !isSearchEvent(event)
69
+ && isFormSource(formSourceKey, event.source)) {
70
+ if (!metricsMap.has(event.source)) {
71
+ metricsMap.set(event.source, { clicks: 0, fills: 0 });
72
+ }
73
+ const metrics = metricsMap.get(event.source);
74
+ if (event.checkpoint === 'click') {
75
+ metrics.clicks += bundle.weight;
76
+ } else {
77
+ metrics.fills += bundle.weight;
78
+ }
79
+ }
80
+ }
81
+ }
82
+ return metricsMap;
83
+ }
84
+
85
+ /**
86
+ * Computes per-field engagement for a single form on a page.
87
+ *
88
+ * @param {Array} urlBundles - RUM bundles already filtered to the form's page URL
89
+ * @param {string} formSourceKey - form key extracted from the form source (e.g. 'abc'
90
+ * for 'form#abc'), or 'unknown'
91
+ * @returns {Array<{source: string, clicks: number, fills: number, avg_time_spend: number}>}
92
+ * fields ordered by average absolute timeDelta (natural form interaction order)
93
+ */
94
+ export function computeFieldEngagement(urlBundles, formSourceKey) {
95
+ const fieldMetrics = getFieldMetrics(urlBundles, formSourceKey);
96
+
97
+ return [...fieldMetrics.keys()]
98
+ .map((source) => ({
99
+ source,
100
+ clicks: fieldMetrics.get(source).clicks,
101
+ fills: fieldMetrics.get(source).fills,
102
+ avg_time_spend: timeSpentInSeconds(urlBundles, source),
103
+ }))
104
+ .sort((a, b) => avgInteractionTime(urlBundles, a.source)
105
+ - avgInteractionTime(urlBundles, b.source));
106
+ }
@@ -12,7 +12,10 @@
12
12
 
13
13
  import { DataChunks, facets } from '@adobe/rum-distiller';
14
14
  import trafficAcquisition from './traffic-acquisition.js';
15
- import { DELIMITER, generateKey, loadBundles } from '../utils.js';
15
+ import { computeFieldEngagement } from './form-field-engagement.js';
16
+ import {
17
+ DELIMITER, generateKey, loadBundles, isFormSource,
18
+ } from '../utils.js';
16
19
 
17
20
  const METRICS = ['formview', 'formengagement', 'formsubmit'];
18
21
  const CHECKPOINTS = ['viewblock', 'click', 'fill', 'formsubmit', 'navigate', 'viewmedia', 'experiment'];
@@ -53,15 +56,6 @@ function filterEvents(bundles) {
53
56
  }));
54
57
  }
55
58
 
56
- function isFormSource(source, eventSource) {
57
- const excludeSrc = ['form.', 'form#'];
58
- if (source === 'unknown') {
59
- return /\bform\b/.test(eventSource?.toLowerCase()) && !excludeSrc.some((exclude) => eventSource?.includes(exclude));
60
- } else {
61
- return eventSource?.includes(source);
62
- }
63
- }
64
-
65
59
  const metricFns = {
66
60
  formview: (source) => (bundle) => {
67
61
  const formView = bundle.events.find((e) => e.checkpoint === 'viewblock' && isFormSource(source, e.source));
@@ -321,6 +315,13 @@ function handler(bundles) {
321
315
  iframeSrc,
322
316
  });
323
317
  }
318
+ // attach per-field engagement for this form (mirrors the trafficacquisition property)
319
+ const fieldMatch = formVitalCopy.formsource?.match(/form[#.]((?:\\[0-9a-fA-F]{1,6}\s?|\w|-)+)/);
320
+ const fieldFormSourceKey = fieldMatch ? fieldMatch[1] : 'unknown';
321
+ formVitalCopy.fieldEngagement = computeFieldEngagement(
322
+ bundles.filter((b) => b.url === formVitalCopy.url),
323
+ fieldFormSourceKey,
324
+ );
324
325
  return formVitalCopy;
325
326
  });
326
327
 
package/src/utils.js CHANGED
@@ -16,6 +16,21 @@ import { classifyTraffic } from './common/traffic.js';
16
16
 
17
17
  export const DELIMITER = '≡';
18
18
 
19
+ export const FORM_KEYWORDS_TO_FILTER = ['search'];
20
+
21
+ /**
22
+ * Checks whether a bundle event's source belongs to the given form source key.
23
+ * Shared between form-vitals.js and form-field-engagement.js.
24
+ */
25
+ export function isFormSource(source, eventSource) {
26
+ const excludeSrc = ['form.', 'form#'];
27
+ if (source === 'unknown') {
28
+ return /\bform\b/.test(eventSource?.toLowerCase()) && !excludeSrc.some((exclude) => eventSource?.includes(exclude));
29
+ } else {
30
+ return eventSource?.includes(source);
31
+ }
32
+ }
33
+
19
34
  export const generateKey = (...keys) => keys.join(DELIMITER);
20
35
 
21
36
  export const trafficSeriesFn = (memo, type) => (bundle) => {