@adobe/spacecat-shared-rum-api-client 2.41.1 → 2.43.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,15 @@
1
+ ## [@adobe/spacecat-shared-rum-api-client-v2.43.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-rum-api-client-v2.42.0...@adobe/spacecat-shared-rum-api-client-v2.43.0) (2026-06-15)
2
+
3
+ ### Features
4
+
5
+ * **rum-api-client:** add resolveRumDomainKey shared utility ([#1662](https://github.com/adobe/spacecat-shared/issues/1662)) ([c3249f0](https://github.com/adobe/spacecat-shared/commit/c3249f011727e9297b191bf6ca0a78d6f462942e))
6
+
7
+ ## [@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)
8
+
9
+ ### Features
10
+
11
+ * 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))
12
+
1
13
  ## [@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)
2
14
 
3
15
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-rum-api-client",
3
- "version": "2.41.1",
3
+ "version": "2.43.0",
4
4
  "description": "Shared modules of the Spacecat Services - Rum API client",
5
5
  "type": "module",
6
6
  "engines": {
package/src/client.js ADDED
@@ -0,0 +1,209 @@
1
+ /*
2
+ * Copyright 2024 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
+ import { hasText, fetch } from '@adobe/spacecat-shared-utils';
13
+ import { fetchBundles, createBundleStream } from './common/rum-bundler-client.js';
14
+ import notfound from './functions/404.js';
15
+ import notfoundInternalLinks from './functions/404-internal-links.js';
16
+ import cwv from './functions/cwv.js';
17
+ import formVitals from './functions/form-vitals.js';
18
+ import experiment from './functions/experiment.js';
19
+ import trafficAcquisition from './functions/traffic-acquisition.js';
20
+ import totalMetrics from './functions/total-metrics.js';
21
+ import variant from './functions/variant.js';
22
+ import pageviews from './functions/pageviews.js';
23
+ import trafficMetrics from './functions/traffic-metrics.js';
24
+ import rageclick from './functions/opportunities/rageclick.js';
25
+ import highInorganicHighBounceRate from './functions/opportunities/high-inorganic-high-bounce-rate.js';
26
+ import highOrganicLowCtr from './functions/opportunities/high-organic-low-ctr.js';
27
+ import trafficAnalysis from './functions/traffic-analysis.js';
28
+ import optimizationReportMetrics from './functions/reports/optimization/metrics.js';
29
+ import optimizationReportGraph from './functions/reports/optimization/graph.js';
30
+ import userEngagement from './functions/user-engagement.js';
31
+
32
+ // exported for tests
33
+ export const RUM_BUNDLER_API_HOST = 'https://bundles.aem.page';
34
+
35
+ const HANDLERS = {
36
+ 404: notfound,
37
+ '404-internal-links': notfoundInternalLinks,
38
+ cwv,
39
+ 'form-vitals': formVitals,
40
+ experiment,
41
+ 'traffic-acquisition': trafficAcquisition,
42
+ variant,
43
+ rageclick,
44
+ totalMetrics,
45
+ 'high-inorganic-high-bounce-rate': highInorganicHighBounceRate,
46
+ 'high-organic-low-ctr': highOrganicLowCtr,
47
+ pageviews,
48
+ trafficMetrics,
49
+ 'traffic-analysis': trafficAnalysis,
50
+ 'optimization-report-metrics': optimizationReportMetrics,
51
+ 'optimization-report-graph': optimizationReportGraph,
52
+ 'user-engagement': userEngagement,
53
+ };
54
+
55
+ function sanitize(opts) {
56
+ return {
57
+ ...opts,
58
+ /* c8 ignore next 1 */
59
+ ...(hasText(opts.domainkey) && { domainkey: `${opts.domainkey.slice(0, 3)}***` }),
60
+ };
61
+ }
62
+
63
+ export default class RUMAPIClient {
64
+ static createFrom(context) {
65
+ const { env, log = console } = context;
66
+ const { RUM_ADMIN_KEY: rumAdminKey } = env;
67
+
68
+ if (context.rumApiClient) {
69
+ return context.rumApiClient;
70
+ }
71
+
72
+ const client = new RUMAPIClient({ rumAdminKey }, log);
73
+ context.rumApiClient = client;
74
+ return client;
75
+ }
76
+
77
+ constructor({ rumAdminKey }, log) {
78
+ this.log = log;
79
+ this.rumAdminKey = rumAdminKey;
80
+ this.domainkeyCache = {};
81
+ }
82
+
83
+ async _exchangeDomainkey(domain) {
84
+ if (hasText(this.domainkeyCache[domain])) {
85
+ return this.domainkeyCache[domain];
86
+ }
87
+
88
+ const resp = await fetch(`${RUM_BUNDLER_API_HOST}/domainkey/${domain}`, {
89
+ headers: {
90
+ Authorization: `Bearer ${this.rumAdminKey}`,
91
+ },
92
+ });
93
+
94
+ if (!resp.ok) {
95
+ throw new Error(`Error during fetching domainkey for domain '${domain} using admin key. Status: ${resp.status}`);
96
+ }
97
+
98
+ try {
99
+ const json = await resp.json();
100
+ if (!hasText(json.domainkey)) {
101
+ throw new Error(`Unexpected response: ${JSON.stringify(json)}`);
102
+ }
103
+ this.domainkeyCache[domain] = json.domainkey;
104
+ return json.domainkey;
105
+ } catch (e) {
106
+ throw new Error(`Error during fetching domainkey for domain '${domain} using admin key. Error: ${e.message}`);
107
+ }
108
+ }
109
+
110
+ async _getDomainkey(opts) {
111
+ const { domain, domainkey } = opts;
112
+
113
+ if (!hasText(domainkey) && !hasText(this.rumAdminKey)) {
114
+ throw new Error('You need to provide a \'domainkey\' or set RUM_ADMIN_KEY env variable');
115
+ }
116
+
117
+ if (hasText(domainkey)) {
118
+ return domainkey;
119
+ }
120
+
121
+ return this._exchangeDomainkey(domain);
122
+ }
123
+
124
+ async retrieveDomainkey(domain) {
125
+ return this._exchangeDomainkey(domain);
126
+ }
127
+
128
+ // eslint-disable-next-line class-methods-use-this
129
+ async query(query, opts) {
130
+ const { handler, checkpoints } = HANDLERS[query] || {};
131
+ if (!handler) {
132
+ throw new Error(`Unknown query ${query}`);
133
+ }
134
+
135
+ try {
136
+ const domainkey = await this._getDomainkey(opts);
137
+ const bundles = await fetchBundles({
138
+ ...opts,
139
+ domainkey,
140
+ checkpoints,
141
+ }, this.log);
142
+
143
+ this.log.debug(`Query "${query}" fetched ${bundles.length} bundles`); // maybe even remove?
144
+ return handler(bundles, opts);
145
+ } catch (e) {
146
+ throw new Error(`Query '${query}' failed. Opts: ${JSON.stringify(sanitize(opts))}. Reason: ${e.message}`);
147
+ }
148
+ }
149
+
150
+ // eslint-disable-next-line class-methods-use-this
151
+ async queryMulti(queries, opts) {
152
+ const queryHandlers = [];
153
+ const allCheckpoints = new Set();
154
+
155
+ for (const query of queries) {
156
+ const { handler, checkpoints = [] } = HANDLERS[query] || {};
157
+
158
+ if (!handler) {
159
+ throw new Error(`Unknown query: ${query}`);
160
+ }
161
+
162
+ queryHandlers.push({ query, handler });
163
+ checkpoints.forEach((checkpoint) => allCheckpoints.add(checkpoint));
164
+ }
165
+
166
+ try {
167
+ const domainkey = await this._getDomainkey(opts);
168
+
169
+ // Fetch bundles with deduplicated checkpoints
170
+ const bundles = await fetchBundles({
171
+ ...opts,
172
+ domainkey,
173
+ checkpoints: [...allCheckpoints],
174
+ }, this.log);
175
+
176
+ const results = {};
177
+ this.log.debug(`Multi query ${JSON.stringify(queries.join(', '))} fetched ${bundles.length} bundles`); // maybe even remove?
178
+
179
+ // Execute each query handler sequentially
180
+ for (const { query, handler } of queryHandlers) {
181
+ // eslint-disable-next-line no-await-in-loop
182
+ results[query] = await handler(bundles, opts);
183
+ }
184
+
185
+ return results;
186
+ } catch (e) {
187
+ throw new Error(`Multi query failed. Queries: ${JSON.stringify(queries)}, Opts: ${JSON.stringify(sanitize(opts))}. Reason: ${e.message}`);
188
+ }
189
+ }
190
+
191
+ async queryStream(query, opts) {
192
+ const { handler, checkpoints } = HANDLERS[query] || {};
193
+ if (!handler) {
194
+ throw new Error(`Unknown query ${query}`);
195
+ }
196
+
197
+ try {
198
+ const domainkey = await this._getDomainkey(opts);
199
+ return createBundleStream({
200
+ ...opts,
201
+ domainkey,
202
+ checkpoints,
203
+ handler,
204
+ }, this.log);
205
+ } catch (e) {
206
+ throw new Error(`Query stream '${query}' failed. Opts: ${JSON.stringify(sanitize(opts))}. Reason: ${e.message}`);
207
+ }
208
+ }
209
+ }
@@ -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/index.d.ts CHANGED
@@ -50,6 +50,27 @@ export interface RUMAPIOptions {
50
50
  }>;
51
51
  }
52
52
 
53
+ /**
54
+ * Result returned by resolveRumDomainKey.
55
+ */
56
+ export interface RumDomainKeyResult {
57
+ /** True if a RUM domain key was found for any candidate domain. */
58
+ hasDomainKey: boolean;
59
+ /** True if the lookup was aborted due to the 3 s shared timeout. */
60
+ timedOut: boolean;
61
+ }
62
+
63
+ /**
64
+ * Checks whether a site has a RUM domain key by trying up to four candidate domains
65
+ * in priority order: overrideBaseURL, www.override, baseURL, www.base.
66
+ * Stops on the first successful lookup. The 3 s timeout is shared across all candidates.
67
+ *
68
+ * @param site - Site model instance (requires getId, getBaseURL, getConfig).
69
+ * @param context - Request/worker context (requires env and log).
70
+ * @returns { hasDomainKey, timedOut }
71
+ */
72
+ export function resolveRumDomainKey(site: object, context: UniversalContext): Promise<RumDomainKeyResult>;
73
+
53
74
  export default class RUMAPIClient {
54
75
  /**
55
76
  * Static factory method to create an instance of RUMAPIClient.
package/src/index.js CHANGED
@@ -9,201 +9,8 @@
9
9
  * OF ANY KIND, either express or implied. See the License for the specific language
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
- import { hasText, fetch } from '@adobe/spacecat-shared-utils';
13
- import { fetchBundles, createBundleStream } from './common/rum-bundler-client.js';
14
- import notfound from './functions/404.js';
15
- import notfoundInternalLinks from './functions/404-internal-links.js';
16
- import cwv from './functions/cwv.js';
17
- import formVitals from './functions/form-vitals.js';
18
- import experiment from './functions/experiment.js';
19
- import trafficAcquisition from './functions/traffic-acquisition.js';
20
- import totalMetrics from './functions/total-metrics.js';
21
- import variant from './functions/variant.js';
22
- import pageviews from './functions/pageviews.js';
23
- import trafficMetrics from './functions/traffic-metrics.js';
24
- import rageclick from './functions/opportunities/rageclick.js';
25
- import highInorganicHighBounceRate from './functions/opportunities/high-inorganic-high-bounce-rate.js';
26
- import highOrganicLowCtr from './functions/opportunities/high-organic-low-ctr.js';
27
- import trafficAnalysis from './functions/traffic-analysis.js';
28
- import optimizationReportMetrics from './functions/reports/optimization/metrics.js';
29
- import optimizationReportGraph from './functions/reports/optimization/graph.js';
30
- import userEngagement from './functions/user-engagement.js';
12
+ import RUMAPIClient from './client.js';
31
13
 
32
- // exported for tests
33
- export const RUM_BUNDLER_API_HOST = 'https://bundles.aem.page';
34
-
35
- const HANDLERS = {
36
- 404: notfound,
37
- '404-internal-links': notfoundInternalLinks,
38
- cwv,
39
- 'form-vitals': formVitals,
40
- experiment,
41
- 'traffic-acquisition': trafficAcquisition,
42
- variant,
43
- rageclick,
44
- totalMetrics,
45
- 'high-inorganic-high-bounce-rate': highInorganicHighBounceRate,
46
- 'high-organic-low-ctr': highOrganicLowCtr,
47
- pageviews,
48
- trafficMetrics,
49
- 'traffic-analysis': trafficAnalysis,
50
- 'optimization-report-metrics': optimizationReportMetrics,
51
- 'optimization-report-graph': optimizationReportGraph,
52
- 'user-engagement': userEngagement,
53
- };
54
-
55
- function sanitize(opts) {
56
- return {
57
- ...opts,
58
- /* c8 ignore next 1 */
59
- ...(hasText(opts.domainkey) && { domainkey: `${opts.domainkey.slice(0, 3)}***` }),
60
- };
61
- }
62
-
63
- export default class RUMAPIClient {
64
- static createFrom(context) {
65
- const { env, log = console } = context;
66
- const { RUM_ADMIN_KEY: rumAdminKey } = env;
67
-
68
- if (context.rumApiClient) {
69
- return context.rumApiClient;
70
- }
71
-
72
- const client = new RUMAPIClient({ rumAdminKey }, log);
73
- context.rumApiClient = client;
74
- return client;
75
- }
76
-
77
- constructor({ rumAdminKey }, log) {
78
- this.log = log;
79
- this.rumAdminKey = rumAdminKey;
80
- this.domainkeyCache = {};
81
- }
82
-
83
- async _exchangeDomainkey(domain) {
84
- if (hasText(this.domainkeyCache[domain])) {
85
- return this.domainkeyCache[domain];
86
- }
87
-
88
- const resp = await fetch(`${RUM_BUNDLER_API_HOST}/domainkey/${domain}`, {
89
- headers: {
90
- Authorization: `Bearer ${this.rumAdminKey}`,
91
- },
92
- });
93
-
94
- if (!resp.ok) {
95
- throw new Error(`Error during fetching domainkey for domain '${domain} using admin key. Status: ${resp.status}`);
96
- }
97
-
98
- try {
99
- const json = await resp.json();
100
- if (!hasText(json.domainkey)) {
101
- throw new Error(`Unexpected response: ${JSON.stringify(json)}`);
102
- }
103
- this.domainkeyCache[domain] = json.domainkey;
104
- return json.domainkey;
105
- } catch (e) {
106
- throw new Error(`Error during fetching domainkey for domain '${domain} using admin key. Error: ${e.message}`);
107
- }
108
- }
109
-
110
- async _getDomainkey(opts) {
111
- const { domain, domainkey } = opts;
112
-
113
- if (!hasText(domainkey) && !hasText(this.rumAdminKey)) {
114
- throw new Error('You need to provide a \'domainkey\' or set RUM_ADMIN_KEY env variable');
115
- }
116
-
117
- if (hasText(domainkey)) {
118
- return domainkey;
119
- }
120
-
121
- return this._exchangeDomainkey(domain);
122
- }
123
-
124
- async retrieveDomainkey(domain) {
125
- return this._exchangeDomainkey(domain);
126
- }
127
-
128
- // eslint-disable-next-line class-methods-use-this
129
- async query(query, opts) {
130
- const { handler, checkpoints } = HANDLERS[query] || {};
131
- if (!handler) {
132
- throw new Error(`Unknown query ${query}`);
133
- }
134
-
135
- try {
136
- const domainkey = await this._getDomainkey(opts);
137
- const bundles = await fetchBundles({
138
- ...opts,
139
- domainkey,
140
- checkpoints,
141
- }, this.log);
142
-
143
- this.log.debug(`Query "${query}" fetched ${bundles.length} bundles`); // maybe even remove?
144
- return handler(bundles, opts);
145
- } catch (e) {
146
- throw new Error(`Query '${query}' failed. Opts: ${JSON.stringify(sanitize(opts))}. Reason: ${e.message}`);
147
- }
148
- }
149
-
150
- // eslint-disable-next-line class-methods-use-this
151
- async queryMulti(queries, opts) {
152
- const queryHandlers = [];
153
- const allCheckpoints = new Set();
154
-
155
- for (const query of queries) {
156
- const { handler, checkpoints = [] } = HANDLERS[query] || {};
157
-
158
- if (!handler) {
159
- throw new Error(`Unknown query: ${query}`);
160
- }
161
-
162
- queryHandlers.push({ query, handler });
163
- checkpoints.forEach((checkpoint) => allCheckpoints.add(checkpoint));
164
- }
165
-
166
- try {
167
- const domainkey = await this._getDomainkey(opts);
168
-
169
- // Fetch bundles with deduplicated checkpoints
170
- const bundles = await fetchBundles({
171
- ...opts,
172
- domainkey,
173
- checkpoints: [...allCheckpoints],
174
- }, this.log);
175
-
176
- const results = {};
177
- this.log.debug(`Multi query ${JSON.stringify(queries.join(', '))} fetched ${bundles.length} bundles`); // maybe even remove?
178
-
179
- // Execute each query handler sequentially
180
- for (const { query, handler } of queryHandlers) {
181
- // eslint-disable-next-line no-await-in-loop
182
- results[query] = await handler(bundles, opts);
183
- }
184
-
185
- return results;
186
- } catch (e) {
187
- throw new Error(`Multi query failed. Queries: ${JSON.stringify(queries)}, Opts: ${JSON.stringify(sanitize(opts))}. Reason: ${e.message}`);
188
- }
189
- }
190
-
191
- async queryStream(query, opts) {
192
- const { handler, checkpoints } = HANDLERS[query] || {};
193
- if (!handler) {
194
- throw new Error(`Unknown query ${query}`);
195
- }
196
-
197
- try {
198
- const domainkey = await this._getDomainkey(opts);
199
- return createBundleStream({
200
- ...opts,
201
- domainkey,
202
- checkpoints,
203
- handler,
204
- }, this.log);
205
- } catch (e) {
206
- throw new Error(`Query stream '${query}' failed. Opts: ${JSON.stringify(sanitize(opts))}. Reason: ${e.message}`);
207
- }
208
- }
209
- }
14
+ export default RUMAPIClient;
15
+ export { RUM_BUNDLER_API_HOST } from './client.js';
16
+ export { resolveRumDomainKey } from './rum-domain-key.js';
@@ -0,0 +1,111 @@
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
+ import RUMAPIClient from './client.js';
14
+
15
+ const RUM_CHECK_TIMEOUT_MS = 3000;
16
+
17
+ /**
18
+ * Resolves whether a site has a RUM domain key by trying candidates in priority order:
19
+ * 1. overrideBaseURL hostname (from fetchConfig, when set)
20
+ * 2. www.{overrideHostname}
21
+ * 3. baseURL hostname
22
+ * 4. www.{baseHostname}
23
+ *
24
+ * Stops on the first successful lookup. The 3 s timeout is a shared budget across
25
+ * the full candidate loop, not a per-domain limit.
26
+ *
27
+ * @param {object} site - Site model instance (getBaseURL, getConfig, getId).
28
+ * @param {object} context - Request/worker context (env, log).
29
+ * @returns {Promise<{hasDomainKey: boolean, timedOut: boolean}>}
30
+ */
31
+ export async function resolveRumDomainKey(site, context) {
32
+ const { log } = context;
33
+ const siteId = site.getId();
34
+
35
+ const siteConfig = site.getConfig();
36
+ const overrideBaseURL = siteConfig.getFetchConfig()?.overrideBaseURL;
37
+
38
+ let overrideHostname = null;
39
+ if (overrideBaseURL) {
40
+ try {
41
+ overrideHostname = new URL(overrideBaseURL).hostname;
42
+ } catch {
43
+ log.warn(`[rum-domain-key] Malformed overrideBaseURL for site ${siteId}: ${overrideBaseURL}, falling back to baseURL`);
44
+ }
45
+ }
46
+
47
+ let baseHostname;
48
+ try {
49
+ baseHostname = new URL(site.getBaseURL()).hostname;
50
+ } catch {
51
+ log.error(`[rum-domain-key] Malformed baseURL for site ${siteId}: ${site.getBaseURL()}, skipping`);
52
+ return { hasDomainKey: false, timedOut: false };
53
+ }
54
+
55
+ const withWww = (d) => (d && !d.startsWith('www.') ? `www.${d}` : null);
56
+
57
+ const domains = [...new Set([
58
+ overrideHostname,
59
+ withWww(overrideHostname),
60
+ baseHostname,
61
+ withWww(baseHostname),
62
+ ].filter(Boolean))];
63
+
64
+ let hasDomainKey = false;
65
+ let timeoutId;
66
+ let timedOut = false;
67
+ let cancelled = false;
68
+
69
+ const rumApiClient = RUMAPIClient.createFrom(context);
70
+
71
+ try {
72
+ await Promise.race([
73
+ (async () => {
74
+ for (const domain of domains) {
75
+ if (cancelled) {
76
+ break;
77
+ }
78
+ try {
79
+ // eslint-disable-next-line no-await-in-loop
80
+ await rumApiClient.retrieveDomainkey(domain);
81
+ hasDomainKey = true;
82
+ return;
83
+ } catch (e) {
84
+ log.info(`[rum-domain-key] RUM check failed for ${domain}: ${e.message}`);
85
+ }
86
+ }
87
+ if (!hasDomainKey) {
88
+ log.warn(`[rum-domain-key] No domain key found for site ${siteId} across all candidates: ${domains.join(', ')}`);
89
+ }
90
+ })(),
91
+ new Promise((_, reject) => {
92
+ timeoutId = setTimeout(() => {
93
+ timedOut = true;
94
+ cancelled = true;
95
+ reject(new Error('RUM check timed out'));
96
+ }, RUM_CHECK_TIMEOUT_MS);
97
+ }),
98
+ ]);
99
+ } catch (e) {
100
+ if (timedOut) {
101
+ log.warn(`[rum-domain-key] RUM check timed out for site ${siteId} across: ${domains.join(', ')}`);
102
+ /* c8 ignore next 3 */
103
+ } else {
104
+ log.warn(`[rum-domain-key] Unexpected error during RUM check for site ${siteId}: ${e.message}`);
105
+ }
106
+ } finally {
107
+ clearTimeout(timeoutId);
108
+ }
109
+
110
+ return { hasDomainKey, timedOut };
111
+ }
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) => {