@adobe/spacecat-shared-rum-api-client 2.5.4 → 2.7.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 +14 -0
- package/package.json +1 -1
- package/src/common/rum-bundler-client.js +104 -1
- package/src/functions/experiment.js +29 -20
- package/src/functions/traffic-acquisition.js +1 -75
- package/src/index.d.ts +29 -15
- package/src/index.js +37 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [@adobe/spacecat-shared-rum-api-client-v2.7.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-rum-api-client-v2.6.0...@adobe/spacecat-shared-rum-api-client-v2.7.0) (2024-08-06)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* add support for multiple experiments on the same page ([#309](https://github.com/adobe/spacecat-shared/issues/309)) ([8252e1a](https://github.com/adobe/spacecat-shared/commit/8252e1a74885a73db6193d24facf439b571e9523))
|
|
7
|
+
|
|
8
|
+
# [@adobe/spacecat-shared-rum-api-client-v2.6.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-rum-api-client-v2.5.4...@adobe/spacecat-shared-rum-api-client-v2.6.0) (2024-08-02)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* multi-query support for rum-client ([#311](https://github.com/adobe/spacecat-shared/issues/311)) ([c3ac6a2](https://github.com/adobe/spacecat-shared/commit/c3ac6a20396874d0abffdcdcd50406e9718a426b))
|
|
14
|
+
|
|
1
15
|
# [@adobe/spacecat-shared-rum-api-client-v2.5.4](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-rum-api-client-v2.5.3...@adobe/spacecat-shared-rum-api-client-v2.5.4) (2024-08-01)
|
|
2
16
|
|
|
3
17
|
|
package/package.json
CHANGED
|
@@ -50,6 +50,109 @@ function getUrlChunks(urls, chunkSize) {
|
|
|
50
50
|
.map((_, index) => urls.slice(index * chunkSize, (index + 1) * chunkSize));
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/* c8 ignore start */
|
|
54
|
+
/*
|
|
55
|
+
* throw-away code for a single customer who customized the experimentation engine
|
|
56
|
+
* this code will be removed once they start using the default exp engine
|
|
57
|
+
*
|
|
58
|
+
* this function fetches experiment manifests, then merges variants data into controls data
|
|
59
|
+
*
|
|
60
|
+
* ie:
|
|
61
|
+
*
|
|
62
|
+
* if the customer runs for an experiment where variants are as following:
|
|
63
|
+
* control: /
|
|
64
|
+
* challenger-1: /a1/
|
|
65
|
+
* challenger-2: /a2/
|
|
66
|
+
*
|
|
67
|
+
* then data for the `/a1/` and `/a2` are counted towards `/`'s data
|
|
68
|
+
*/
|
|
69
|
+
async function mergeBundlesWithSameId(bundles) {
|
|
70
|
+
if (!bundles[0]?.url?.includes('bamboohr.com')) return bundles;
|
|
71
|
+
const prodBaseUrl = 'https://www.bamboohr.com/experiments/';
|
|
72
|
+
const previewBaseUrl = 'https://main--bamboohr-website--bamboohr.hlx.page/experiments/archive/';
|
|
73
|
+
const manifestUrls = [
|
|
74
|
+
...new Set(bundles.flatMap((bundle) => bundle.events
|
|
75
|
+
.filter((e) => e.checkpoint === 'experiment')
|
|
76
|
+
.map((e) => e.source))),
|
|
77
|
+
];
|
|
78
|
+
const manifestUrlPromises = manifestUrls.map(async (experiment) => {
|
|
79
|
+
try {
|
|
80
|
+
const response = await fetch(`${prodBaseUrl}${experiment}/manifest.json`);
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
throw new Error('manifest request failed');
|
|
83
|
+
}
|
|
84
|
+
const data = await response.json();
|
|
85
|
+
return { url: `${prodBaseUrl}${experiment}/manifest.json`, data };
|
|
86
|
+
} catch (error) {
|
|
87
|
+
try {
|
|
88
|
+
const previewUrlResponse = await fetch(`${previewBaseUrl}${experiment}/manifest.json`);
|
|
89
|
+
if (!previewUrlResponse.ok) {
|
|
90
|
+
throw new Error('manifest request failed');
|
|
91
|
+
}
|
|
92
|
+
const previewUrlData = await previewUrlResponse.json();
|
|
93
|
+
return { url: `${previewBaseUrl}${experiment}/manifest.json`, data: previewUrlData };
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return { url: `${previewBaseUrl}${experiment}/manifest.json`, data: null };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
const experiments = await Promise.all(manifestUrlPromises);
|
|
100
|
+
let hasSeenPages = false; // required for multi-page experiments
|
|
101
|
+
const variants = (await Promise.all(experiments.map((e) => e.data)))
|
|
102
|
+
.filter((json) => json && Object.keys(json).length > 0)
|
|
103
|
+
.flatMap((json) => json.experiences?.data ?? [])
|
|
104
|
+
.filter((data) => {
|
|
105
|
+
if (data.Name === 'Pages') {
|
|
106
|
+
hasSeenPages = true;
|
|
107
|
+
} else if (['Percentage Split', 'Label', 'Blocks'].includes(data.Name)) {
|
|
108
|
+
// reset the flag when we see the next experiment
|
|
109
|
+
hasSeenPages = false;
|
|
110
|
+
}
|
|
111
|
+
return data.Name === 'Pages' || (hasSeenPages && data.Name === '');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const mapping = variants.reduce((acc, cur) => {
|
|
115
|
+
Object.entries(cur)
|
|
116
|
+
.filter(([k]) => !['Name', 'Control'].includes(k))
|
|
117
|
+
.forEach(([, v]) => {
|
|
118
|
+
acc[new URL(v).pathname] = new URL(cur.Control).pathname;
|
|
119
|
+
});
|
|
120
|
+
return acc;
|
|
121
|
+
}, {});
|
|
122
|
+
|
|
123
|
+
const variantPaths = Object.keys(mapping);
|
|
124
|
+
|
|
125
|
+
const getControlPath = (url) => {
|
|
126
|
+
const path = new URL(url).pathname;
|
|
127
|
+
if (variantPaths.includes(path)) return mapping[path];
|
|
128
|
+
return path;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const byIdAndPath = bundles.reduce((acc, cur) => {
|
|
132
|
+
const controlPath = getControlPath(cur.url);
|
|
133
|
+
const key = `${cur.id}-${controlPath}`;
|
|
134
|
+
if (!acc[key]) acc[key] = [];
|
|
135
|
+
if (variantPaths.includes(new URL(cur.url).pathname)) {
|
|
136
|
+
// eslint-disable-next-line no-param-reassign
|
|
137
|
+
cur.url = new URL(controlPath, cur.url).href;
|
|
138
|
+
}
|
|
139
|
+
acc[key].push(cur);
|
|
140
|
+
return acc;
|
|
141
|
+
}, {});
|
|
142
|
+
|
|
143
|
+
const merged = Object.entries(byIdAndPath).flatMap(([, v]) => {
|
|
144
|
+
let value = v;
|
|
145
|
+
if (v.length > 1) {
|
|
146
|
+
v[0].events.push(...v.slice(1).flatMap((bundle) => bundle.events));
|
|
147
|
+
value = [v[0]];
|
|
148
|
+
}
|
|
149
|
+
return value;
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
return Object.values(merged);
|
|
153
|
+
}
|
|
154
|
+
/* c8 ignore end */
|
|
155
|
+
|
|
53
156
|
async function fetchBundles(opts = {}) {
|
|
54
157
|
const {
|
|
55
158
|
domain,
|
|
@@ -84,7 +187,7 @@ async function fetchBundles(opts = {}) {
|
|
|
84
187
|
const bundles = await Promise.all(responses.map((response) => response.json()));
|
|
85
188
|
result.push(...bundles.flatMap((b) => b.rumBundles.map(filterBundles(checkpoints))));
|
|
86
189
|
}
|
|
87
|
-
return result;
|
|
190
|
+
return mergeBundlesWithSameId(result);
|
|
88
191
|
}
|
|
89
192
|
|
|
90
193
|
export {
|
|
@@ -73,41 +73,50 @@ function updateInferredStartAndEndDate(experimentObject, time) {
|
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
function calculateMetrics(bundle) {
|
|
77
|
+
const metrics = {};
|
|
78
|
+
for (const checkpoint of METRIC_CHECKPOINTS) {
|
|
79
|
+
metrics[checkpoint] = {};
|
|
80
|
+
}
|
|
81
|
+
for (const event of bundle.events) {
|
|
82
|
+
if (METRIC_CHECKPOINTS.includes(event.checkpoint)) {
|
|
83
|
+
const { source, checkpoint } = event;
|
|
84
|
+
if (!metrics[checkpoint][source]) {
|
|
85
|
+
metrics[checkpoint][source] = bundle.weight;
|
|
86
|
+
} else {
|
|
87
|
+
metrics[checkpoint][source] += bundle.weight;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return metrics;
|
|
92
|
+
}
|
|
93
|
+
|
|
76
94
|
function handler(bundles) {
|
|
77
95
|
const experimentInsights = {};
|
|
78
96
|
for (const bundle of bundles) {
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
97
|
+
const experimentEvents = bundle.events?.filter(
|
|
98
|
+
(e) => EXPERIMENT_CHECKPOINT.includes(e.checkpoint),
|
|
99
|
+
);
|
|
100
|
+
const { url, weight, time } = bundle;
|
|
101
|
+
const metrics = calculateMetrics(bundle);
|
|
102
|
+
for (const experimentEvent of experimentEvents) {
|
|
82
103
|
if (!experimentInsights[url]) {
|
|
83
104
|
experimentInsights[url] = [];
|
|
84
105
|
}
|
|
85
106
|
const experimentName = experimentEvent.source;
|
|
86
107
|
const variantName = experimentEvent.target;
|
|
87
|
-
const experimentObject = getOrCreateExperimentObject(
|
|
108
|
+
const experimentObject = getOrCreateExperimentObject(
|
|
109
|
+
experimentInsights[url],
|
|
110
|
+
experimentName,
|
|
111
|
+
);
|
|
88
112
|
const variantObject = getOrCreateVariantObject(experimentObject.variants, variantName);
|
|
89
113
|
updateInferredStartAndEndDate(experimentObject, time);
|
|
90
114
|
variantObject.views += weight;
|
|
91
|
-
|
|
92
|
-
const metrics = {};
|
|
93
|
-
for (const checkpoint of METRIC_CHECKPOINTS) {
|
|
94
|
-
metrics[checkpoint] = {};
|
|
95
|
-
}
|
|
96
|
-
for (const event of bundle.events) {
|
|
97
|
-
if (METRIC_CHECKPOINTS.includes(event.checkpoint)) {
|
|
98
|
-
const { source, checkpoint } = event;
|
|
99
|
-
if (!metrics[checkpoint][source]) {
|
|
100
|
-
metrics[checkpoint][source] = weight;
|
|
101
|
-
} else {
|
|
102
|
-
metrics[checkpoint][source] += weight;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
115
|
// combine metrics and variantObject, considering the interaction events
|
|
107
116
|
// only once during the session
|
|
108
117
|
for (const checkpoint of METRIC_CHECKPOINTS) {
|
|
109
118
|
// eslint-disable-next-line no-restricted-syntax
|
|
110
|
-
for (const source in metrics[checkpoint]) {
|
|
119
|
+
for (const source in metrics?.[checkpoint]) {
|
|
111
120
|
if (!variantObject[checkpoint][source]) {
|
|
112
121
|
variantObject[checkpoint][source] = weight;
|
|
113
122
|
} else {
|
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { classifyTrafficSource } from '../common/traffic.js';
|
|
14
|
-
import { fetch } from '../utils.js';
|
|
15
14
|
|
|
16
15
|
function extractHints(bundle) {
|
|
17
16
|
const findEvent = (checkpoint, source = '') => bundle.events.find((e) => e.checkpoint === checkpoint && (!source || e.source === source)) || {};
|
|
@@ -48,81 +47,8 @@ function transformFormat(trafficSources) {
|
|
|
48
47
|
}));
|
|
49
48
|
}
|
|
50
49
|
|
|
51
|
-
/* c8 ignore start */
|
|
52
|
-
/*
|
|
53
|
-
* throw-away code for a single customer who customized the experimentation engine
|
|
54
|
-
* this code will be removed once they start using the default exp engine
|
|
55
|
-
*
|
|
56
|
-
* this function fetches experiment manifests, then merges variants data into controls data
|
|
57
|
-
*
|
|
58
|
-
* ie:
|
|
59
|
-
*
|
|
60
|
-
* if the customer runs for an experiment where variants are as following:
|
|
61
|
-
* control: /
|
|
62
|
-
* challenger-1: /a1/
|
|
63
|
-
* challenger-2: /a2/
|
|
64
|
-
*
|
|
65
|
-
* then data for the `/a1/` and `/a2` are counted towards `/`'s data
|
|
66
|
-
*/
|
|
67
|
-
async function mergeBundlesWithSameId(bundles) {
|
|
68
|
-
if (!bundles[0]?.url.includes('bamboohr.com')) return bundles;
|
|
69
|
-
const manifestUrls = [
|
|
70
|
-
...new Set(bundles.flatMap((bundle) => bundle.events
|
|
71
|
-
.filter((e) => e.checkpoint === 'experiment')
|
|
72
|
-
.map((e) => e.source))),
|
|
73
|
-
].map((experiment) => fetch(`https://www.bamboohr.com/experiments/${experiment}/manifest.json`));
|
|
74
|
-
|
|
75
|
-
const experiments = await Promise.all(manifestUrls);
|
|
76
|
-
const variants = (await Promise.all(experiments.map((e) => e.json().catch(() => {}))))
|
|
77
|
-
.filter((json) => json && Object.keys(json).length > 0)
|
|
78
|
-
.flatMap((json) => json.experiences?.data ?? [])
|
|
79
|
-
.filter((data) => data.Name === 'Pages');
|
|
80
|
-
|
|
81
|
-
const mapping = variants.reduce((acc, cur) => {
|
|
82
|
-
Object.entries(cur)
|
|
83
|
-
.filter(([k]) => !['Name', 'Control'].includes(k))
|
|
84
|
-
.forEach(([, v]) => {
|
|
85
|
-
acc[new URL(v).pathname] = new URL(cur.Control).pathname;
|
|
86
|
-
});
|
|
87
|
-
return acc;
|
|
88
|
-
}, {});
|
|
89
|
-
|
|
90
|
-
const variantPaths = Object.keys(mapping);
|
|
91
|
-
|
|
92
|
-
const getControlPath = (url) => {
|
|
93
|
-
const path = new URL(url).pathname;
|
|
94
|
-
if (variantPaths.includes(path)) return mapping[path];
|
|
95
|
-
return path;
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
const byIdAndPath = bundles.reduce((acc, cur) => {
|
|
99
|
-
const controlPath = getControlPath(cur.url);
|
|
100
|
-
const key = `${cur.id}-${controlPath}`;
|
|
101
|
-
if (!acc[key]) acc[key] = [];
|
|
102
|
-
if (variantPaths.includes(new URL(cur.url).pathname)) {
|
|
103
|
-
// eslint-disable-next-line no-param-reassign
|
|
104
|
-
cur.url = new URL(controlPath, cur.url).href;
|
|
105
|
-
}
|
|
106
|
-
acc[key].push(cur);
|
|
107
|
-
return acc;
|
|
108
|
-
}, {});
|
|
109
|
-
|
|
110
|
-
const merged = Object.entries(byIdAndPath).flatMap(([, v]) => {
|
|
111
|
-
let value = v;
|
|
112
|
-
if (v.length > 1) {
|
|
113
|
-
v[0].events.push(...v.slice(1).flatMap((bundle) => bundle.events));
|
|
114
|
-
value = [v[0]];
|
|
115
|
-
}
|
|
116
|
-
return value;
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
return Object.values(merged);
|
|
120
|
-
}
|
|
121
|
-
/* c8 ignore end */
|
|
122
|
-
|
|
123
50
|
async function handler(bundles) {
|
|
124
|
-
const
|
|
125
|
-
const trafficSources = merged
|
|
51
|
+
const trafficSources = bundles
|
|
126
52
|
.map(extractHints)
|
|
127
53
|
.map((row) => {
|
|
128
54
|
const {
|
package/src/index.d.ts
CHANGED
|
@@ -21,26 +21,40 @@ export interface RUMAPIOptions {
|
|
|
21
21
|
|
|
22
22
|
export default class RUMAPIClient {
|
|
23
23
|
/**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
24
|
+
* Static factory method to create an instance of RUMAPIClient.
|
|
25
|
+
* @param {UniversalContext} context - An object containing the AWS Lambda context information
|
|
26
|
+
* @returns An instance of RUMAPIClient.
|
|
27
|
+
* @remarks This method is designed to create a new instance from an AWS Lambda context.
|
|
28
|
+
* The created instance is stored in the Lambda context, and subsequent calls to
|
|
29
|
+
* this method will return the singleton instance if previously created.
|
|
30
|
+
*/
|
|
31
31
|
static createFrom(context: UniversalContext): RUMAPIClient;
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
* Constructor for creating an instance of RUMAPIClient.
|
|
35
|
+
*/
|
|
36
36
|
constructor();
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
39
|
+
* Asynchronous method to run queries against RUM Bundler API.
|
|
40
|
+
* @param {string} query - Name of the query to run.
|
|
41
|
+
* @param {RUMAPIOptions} opts - A object containing options for query to run.
|
|
42
|
+
* @returns A Promise resolving to an object with the query results.
|
|
43
|
+
* @remarks See the README.md for the available queries.
|
|
44
|
+
*/
|
|
45
45
|
query(query: string, opts?: RUMAPIOptions): Promise<object>;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Asynchronous method to run multiple queries against the data fetched from RUM Bundler API.
|
|
49
|
+
*
|
|
50
|
+
* This method makes a single call to the RUM Bundler API to fetch the raw data, then applies
|
|
51
|
+
* all the requested queries to this raw data. The results are returned in an object where each
|
|
52
|
+
* key corresponds to a query name and each value contains the result of that query.
|
|
53
|
+
*
|
|
54
|
+
* @param {string[]} queries - An array of query names to execute.
|
|
55
|
+
* @param {RUMAPIOptions} [opts] - Optional object containing options for the queries.
|
|
56
|
+
* @returns {Promise<object>} A Promise that resolves to an object where each key is the name
|
|
57
|
+
* of a query, and each value is the result of that query.
|
|
58
|
+
*/
|
|
59
|
+
queryMulti(queries: string[], opts?: RUMAPIOptions): Promise<object[]>;
|
|
46
60
|
}
|
package/src/index.js
CHANGED
|
@@ -49,4 +49,41 @@ export default class RUMAPIClient {
|
|
|
49
49
|
throw new Error(`Query '${query}' failed. Opts: ${JSON.stringify(opts)}. Reason: ${e.message}`);
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
|
+
|
|
53
|
+
// eslint-disable-next-line class-methods-use-this
|
|
54
|
+
async queryMulti(queries, opts) {
|
|
55
|
+
const queryHandlers = [];
|
|
56
|
+
const allCheckpoints = new Set();
|
|
57
|
+
|
|
58
|
+
for (const query of queries) {
|
|
59
|
+
const { handler, checkpoints = [] } = HANDLERS[query] || {};
|
|
60
|
+
|
|
61
|
+
if (!handler) {
|
|
62
|
+
throw new Error(`Unknown query: ${query}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
queryHandlers.push({ query, handler });
|
|
66
|
+
checkpoints.forEach((checkpoint) => allCheckpoints.add(checkpoint));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
// Fetch bundles with deduplicated checkpoints
|
|
71
|
+
const bundles = await fetchBundles({
|
|
72
|
+
...opts,
|
|
73
|
+
checkpoints: [...allCheckpoints],
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const results = {};
|
|
77
|
+
|
|
78
|
+
// Execute each query handler sequentially
|
|
79
|
+
for (const { query, handler } of queryHandlers) {
|
|
80
|
+
// eslint-disable-next-line no-await-in-loop
|
|
81
|
+
results[query] = await handler(bundles);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return results;
|
|
85
|
+
} catch (e) {
|
|
86
|
+
throw new Error(`Multi query failed. Queries: ${JSON.stringify(queries)}, Opts: ${JSON.stringify(opts)}. Reason: ${e.message}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
52
89
|
}
|