@adobe/spacecat-shared-rum-api-client 1.8.4 → 2.0.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.0.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-rum-api-client-v1.8.4...@adobe/spacecat-shared-rum-api-client-v2.0.0) (2024-06-06)
2
+
3
+
4
+ ### Features
5
+
6
+ * client for rum bundler ([#252](https://github.com/adobe/spacecat-shared/issues/252)) ([854fc58](https://github.com/adobe/spacecat-shared/commit/854fc583d7184048d22c357cf11f349d3c1cfc9d))
7
+
8
+
9
+ ### BREAKING CHANGES
10
+
11
+ * With this change RUMAPIClient will not be using
12
+ helix-run-query as datasource anymore
13
+
1
14
  # [@adobe/spacecat-shared-rum-api-client-v1.8.4](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-rum-api-client-v1.8.3...@adobe/spacecat-shared-rum-api-client-v1.8.4) (2024-06-01)
2
15
 
3
16
 
package/README.md CHANGED
@@ -12,7 +12,7 @@ npm install @adobe/spacecat-shared-rum-api-client
12
12
 
13
13
  ## Usage
14
14
 
15
- ### Creating and instance from Helix UniversalContext
15
+ #### Creating and instance from Helix UniversalContext
16
16
 
17
17
  ```js
18
18
  const context = {}; // Your AWS Lambda context object
@@ -20,66 +20,117 @@ const rumApiClient = RUMAPIClient.createFrom(context);
20
20
 
21
21
  ```
22
22
 
23
- ### Constructor
24
-
25
- `RUMAPIClient` class needs RUM API domain key to be instantiated:
23
+ #### From constructor
26
24
 
27
25
  ```js
28
- const domainKey = "your-domain-key";
29
- const rumApiClient = new RUMAPIClient(domainKey);
26
+ const rumApiClient = new RUMAPIClient();
30
27
  ```
31
28
 
32
- ### Creating a RUM Backlink
29
+ ### Running a query
33
30
 
34
31
  ```js
35
- const url = "https://example.com";
36
- const expiryInDays = 7;
37
-
38
- const backlink = await rumApiClient.createRUMBacklink(url, expiryInDays);
39
- console.log(`Backlink created: ${backlink}`)
32
+ const opts = {
33
+ domain: 'www.aem.live',
34
+ domainkey: '<domain-key>',
35
+ granularity: 'hourly',
36
+ interval: 10
37
+ }
38
+
39
+ const result = await rumApiClient.query('cwv', opts);
40
+ console.log(`Query result: ${result}`)
40
41
  ```
41
42
 
42
- ### Creating a 404 Report Backlink
43
-
44
- ```js
45
- const url = "https://example.com";
46
- const expiryInDays = 7;
47
-
48
- const backlink = await rumApiClient.create404Backlink(url, expiryInDays);
49
- console.log(`Backlink created: ${backlink}`)
43
+ **Note**: all queries must be lowercase
44
+
45
+ ### Query Options: the 'opts' object
46
+
47
+ | option | required | default | remarks |
48
+ |-------------|----------|---------|---------------------|
49
+ | domain | yes | | |
50
+ | domainkey | yes | | |
51
+ | interval | no | 7 | days in integer |
52
+ | granularity | no | daily | 'daily' or 'hourly' |
53
+
54
+ ## Available queries
55
+
56
+ ### cwv
57
+
58
+ Calculates the CWV data for a given domain within the requested interval. It gets the
59
+ P75 values for LCP, CLS, INP, TTFB metrics, along with the number of data points available for
60
+ each metric. Additionally, it provides grouping by URL and includes the count of page view data.
61
+
62
+ An example response:
63
+
64
+ ```json
65
+ [
66
+ {
67
+ "url": "https://www.aem.live/home",
68
+ "pageviews": 2620,
69
+ "lcp": 2099.699999988079,
70
+ "lcpCount": 9,
71
+ "cls": 0.020660136604802475,
72
+ "clsCount": 7,
73
+ "inp": 12,
74
+ "inpCount": 3,
75
+ "ttfb": 520.4500000476837,
76
+ "ttfbCount": 18
77
+ },
78
+ {
79
+ "url": "https://www.aem.live/developer/block-collection",
80
+ "pageviews": 2000,
81
+ "lcp": 512.1249999403954,
82
+ "lcpCount": 4,
83
+ "cls": 0.0005409526209424976,
84
+ "clsCount": 4,
85
+ "inp": 20,
86
+ "inpCount": 2,
87
+ "ttfb": 122.90000003576279,
88
+ "ttfbCount": 4
89
+ }
90
+ ]
50
91
  ```
92
+ ### 404
93
+
94
+ Calculates the number of 404 errors for a specified domain within the requested interval. The results
95
+ are grouped by URL and the source of the 404 error. The output includes all the various sources that
96
+ direct traffic to the 404 page, as well as the total number of views originating from these sources.
97
+
98
+ An example response:
99
+
100
+ ```json
101
+ [
102
+ {
103
+ "url": "https://www.aem.live/developer/tutorial",
104
+ "views": 400,
105
+ "all_sources": [
106
+ "https://www.google.com",
107
+ "",
108
+ "https://www.instagram.com"
109
+ ],
110
+ "source_count": 3,
111
+ "top_source": "https://www.google.com"
112
+ },
113
+ {
114
+ "url": "https://www.aem.live/some-other-page",
115
+ "views": 300,
116
+ "all_sources": [
117
+ "https://www.bing.com",
118
+ ""
119
+ ],
120
+ "source_count": 2,
121
+ "top_source": ""
122
+ },
123
+ {
124
+ "url": "https://www.aem.live/developer/",
125
+ "views": 100,
126
+ "all_sources": [
127
+ ""
128
+ ],
129
+ "source_count": 1,
130
+ "top_source": ""
131
+ }
132
+ ]
51
133
 
52
- ### Getting RUM Dashboard Data
53
-
54
- ```js
55
- const url = "example.com";
56
-
57
- const rumData = await rumApiClient.getRUMDashboard({ url });
58
- console.log(`RUM data: ${rumData}`)
59
- ```
60
-
61
- ### Getting 404 checkpoints
62
-
63
- ```js
64
- const url = "example.com";
65
-
66
- const backlink = await rumApiClient.get404Sources({ url });
67
- console.log(`404 Checkpoints: ${backlink}`)
68
- ```
69
-
70
- ### Getting Edge Delivery Services Domains
71
-
72
- ```js
73
- const url = "all";
74
-
75
- const domains = await rumApiClient.getDomainList({}, url);
76
- console.log(`Backlink created: ${backlink}`)
77
- ```
78
-
79
- ## Testing
80
- Run the included tests with the following command:
81
- ```
82
- npm test
83
134
  ```
84
135
 
85
136
  ## Linting
package/package.json CHANGED
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-rum-api-client",
3
- "version": "1.8.4",
3
+ "version": "2.0.0",
4
4
  "description": "Shared modules of the Spacecat Services - Rum API client",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "types": "src/index.d.ts",
8
8
  "scripts": {
9
- "test": "c8 mocha",
9
+ "test": "c8 mocha 'test/**/*.test.js'",
10
10
  "lint": "eslint .",
11
- "clean": "rm -rf package-lock.json node_modules"
11
+ "clean": "rm -rf package-lock.json node_modules",
12
+ "run": "node src/test.js"
12
13
  },
13
14
  "mocha": {
14
15
  "require": "test/setup-env.js",
@@ -34,7 +35,8 @@
34
35
  "@adobe/helix-shared-wrap": "2.0.2",
35
36
  "@adobe/helix-universal": "4.5.2",
36
37
  "@adobe/spacecat-shared-utils": "1.4.0",
37
- "aws4": "1.13.0"
38
+ "aws4": "1.13.0",
39
+ "d3-array": "3.2.4"
38
40
  },
39
41
  "devDependencies": {
40
42
  "chai": "4.4.1",
@@ -0,0 +1,28 @@
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
+
13
+ /**
14
+ * Calculates the total page views by URL from an array of bundles.
15
+ * @param {Array<Object>} bundles - An array of RUM bundles (NOT Flat bundles).
16
+ * @returns {Object} An object where keys are URLs and values are the total page views for each URL.
17
+ */
18
+ function pageviewsByUrl(bundles) {
19
+ return bundles.reduce((acc, cur) => {
20
+ if (!acc[cur.url]) acc[cur.url] = 0;
21
+ acc[cur.url] += cur.weight;
22
+ return acc;
23
+ }, {});
24
+ }
25
+
26
+ export {
27
+ pageviewsByUrl,
28
+ };
@@ -0,0 +1,16 @@
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
+
13
+ export const GRANULARITY = {
14
+ HOURLY: 'HOURLY',
15
+ DAILY: 'DAILY',
16
+ };
@@ -0,0 +1,195 @@
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
+
13
+ /**
14
+ * This utility class is designed to flatten checkpoints of RUM bundles served from the
15
+ * RUM bundler API. Its primary purpose is to simplify data juggling.
16
+ *
17
+ * RUM bundles are returned grouped by id. Often, there's a need to further group the bundles by
18
+ * another checkpoint such as URL, source, or target. By using this class, RUM bundles can be
19
+ * flattened for easier implementation of all required grouping operations.
20
+ *
21
+ * This class extends the standard Array class, to make all standard functions such as filter, map,
22
+ * and reduce available for use with this class as well.
23
+ *
24
+ * For instance, when the RUM bundler returns "bundles" in the following format:
25
+ *
26
+ * ```json
27
+ * [
28
+ * {
29
+ * "id": "BSX",
30
+ * "time": "2024-05-26T05:00:02.706Z",
31
+ * "url": "https://www.aem.live/developer/tutorial",
32
+ * "weight": 100,
33
+ * "events": [
34
+ * {
35
+ * "checkpoint": "navigate",
36
+ * "target": "visible",
37
+ * "source": "https://www.aem.live/docs/",
38
+ * "timeDelta": 2706.199951171875
39
+ * },
40
+ * {
41
+ * "checkpoint": "loadresource",
42
+ * "target": 4,
43
+ * "source": "https://www.aem.live/new-nav.plain.html",
44
+ * "timeDelta": 2707.699951171875
45
+ * },
46
+ * {
47
+ * "checkpoint": "play",
48
+ * "source": "https://www.hlx.live/developer/videos/tutorial-step1.mp4",
49
+ * "timeDelta": 12671.89990234375
50
+ * },
51
+ * {
52
+ * "checkpoint": "viewmedia",
53
+ * "target": "https://www.aem.live/developer/media_1c03ad909a87a4e318a33e780b93e4a1f8e7581a3.png",
54
+ * "timeDelta": 43258.39990234375
55
+ * }
56
+ * ]
57
+ * }
58
+ * ]
59
+ * ```
60
+ *
61
+ * After flattening, the FlatBundle appears as follows:
62
+ *
63
+ * ```json
64
+ * [
65
+ * {
66
+ * "id": "BSX",
67
+ * "time": "2024-05-26T05:00:02.706Z",
68
+ * "url": "https://www.aem.live/developer/tutorial",
69
+ * "weight": 100,
70
+ * "checkpoint": "navigate",
71
+ * "target": "visible",
72
+ * "source": "https://www.aem.live/docs/",
73
+ * "timeDelta": 2706.199951171875
74
+ * },
75
+ * {
76
+ * "id": "BSX",
77
+ * "time": "2024-05-26T05:00:02.706Z",
78
+ * "url": "https://www.aem.live/developer/tutorial",
79
+ * "weight": 100,
80
+ * "checkpoint": "loadresource",
81
+ * "target": 4,
82
+ * "source": "https://www.aem.live/new-nav.plain.html",
83
+ * "timeDelta": 2707.699951171875
84
+ * },
85
+ * {
86
+ * "id": "BSX",
87
+ * "time": "2024-05-26T05:00:02.706Z",
88
+ * "url": "https://www.aem.live/developer/tutorial",
89
+ * "weight": 100,
90
+ * "checkpoint": "play",
91
+ * "source": "https://www.hlx.live/developer/videos/tutorial-step1.mp4",
92
+ * "timeDelta": 12671.89990234375
93
+ * },
94
+ * {
95
+ * "id": "BSX",
96
+ * "time": "2024-05-26T05:00:02.706Z",
97
+ * "url": "https://www.aem.live/developer/tutorial",
98
+ * "weight": 100,
99
+ * "checkpoint": "viewmedia",
100
+ * "target": "https://www.aem.live/developer/media_1c03ad909a87a4e318a33e780b93e4a1f8e7581a3.png",
101
+ * "timeDelta": 43258.39990234375
102
+ * }
103
+ * ]
104
+ *
105
+ * ```
106
+ *
107
+ * @extends Array
108
+ */
109
+ export class FlatBundle extends Array {
110
+ static fromArray(array) {
111
+ const flattened = array.flatMap((bundle) => {
112
+ const temp = { ...bundle };
113
+ delete temp.events;
114
+ return bundle.events.map((event) => ({
115
+ ...temp,
116
+ ...event,
117
+ }));
118
+ });
119
+
120
+ Object.setPrototypeOf(flattened, FlatBundle.prototype);
121
+ return flattened;
122
+ }
123
+
124
+ /**
125
+ * Groups FlatBundles by one or more keys. For example, this method can be used to
126
+ * group Flat Bundles by url and source as shown below:
127
+ *
128
+ * ```js
129
+ * FlatBundle.fromArray(bundles)
130
+ * .groupBy('url', 'source');
131
+ * ```
132
+ * The output of this code returns a nested object with keys ("url", "source") and
133
+ * "items", nested to the depth of the number of keys used for grouping.
134
+ *
135
+ * An example response for grouping by url and source could be like:
136
+ *
137
+ * ```json
138
+ * [
139
+ * {
140
+ * "url": "some-url",
141
+ * "items": [
142
+ * { "source": "source1", "items": [] },
143
+ * { "source": "source2", "items": [] }
144
+ * ]
145
+ * },
146
+ * {
147
+ * "url": "some-other-url",
148
+ * "items": [
149
+ * { "source": "source3", "items": [] }
150
+ * ]
151
+ * }
152
+ * ]
153
+ * ```
154
+ *
155
+ *
156
+ * @param {...string} keys - The keys to group by.
157
+ * @returns {Array} The grouped flat bundles.
158
+ */
159
+ groupBy(...keys) {
160
+ // Initialize the result as an empty array
161
+ const result = [];
162
+
163
+ // Create a map to hold references to the current levels
164
+ const map = new Map();
165
+
166
+ // Iterate over each item in the array
167
+ for (const item of this) {
168
+ let currentLevel = result;
169
+ let mapLevel = map;
170
+
171
+ // Iterate over each key to build the nested structure
172
+ for (const key of keys) {
173
+ const groupValue = item[key];
174
+
175
+ // Check if the group already exists
176
+ if (!mapLevel.has(groupValue)) {
177
+ // Create a new group if it doesn't exist
178
+ const newGroup = { [key]: groupValue, items: [] };
179
+ currentLevel.push(newGroup);
180
+ mapLevel.set(groupValue, { group: newGroup, nextMap: new Map() });
181
+ }
182
+
183
+ // Move to the next level
184
+ const { group, nextMap } = mapLevel.get(groupValue);
185
+ currentLevel = group.items;
186
+ mapLevel = nextMap;
187
+ }
188
+
189
+ // Add the item to the final level
190
+ currentLevel.push(item);
191
+ }
192
+
193
+ return result;
194
+ }
195
+ }
@@ -0,0 +1,92 @@
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
+ /* eslint-disable no-await-in-loop */
13
+
14
+ import { hasText } from '@adobe/spacecat-shared-utils';
15
+ import { fetch } from '../utils.js';
16
+ import { GRANULARITY } from './constants.js';
17
+
18
+ const BASE_URL = 'https://rum.fastly-aem.page/bundles';
19
+ const HOURS_IN_DAY = 24;
20
+ const ONE_HOUR = 1000 * 60 * 60;
21
+ const ONE_DAY = ONE_HOUR * HOURS_IN_DAY;
22
+
23
+ const CHUNK_SIZE = 31;
24
+
25
+ function filterBundles(checkpoints = []) {
26
+ return (bundle) => {
27
+ if (checkpoints.length > 0) {
28
+ const events = bundle.events.filter((event) => checkpoints.includes(event.checkpoint));
29
+ return {
30
+ ...bundle,
31
+ events,
32
+ };
33
+ }
34
+ return bundle;
35
+ };
36
+ }
37
+
38
+ function constructUrl(domain, date, granularity, domainkey) {
39
+ const year = date.getUTCFullYear();
40
+ const month = (date.getUTCMonth() + 1).toString().padStart(2, '0');
41
+ const day = date.getUTCDate().toString().padStart(2, '0');
42
+ const hour = granularity.toUpperCase() === GRANULARITY.HOURLY ? `/${date.getUTCHours().toString().padStart(2, '0')}` : '';
43
+
44
+ return `${BASE_URL}/${domain}/${year}/${month}/${day}${hour}?domainkey=${domainkey}`;
45
+ }
46
+
47
+ function getUrlChunks(urls, chunkSize) {
48
+ return Array(Math.ceil(urls.length / chunkSize))
49
+ .fill()
50
+ .map((_, index) => urls.slice(index * chunkSize, (index + 1) * chunkSize));
51
+ }
52
+
53
+ async function fetchBundles(opts = {}) {
54
+ const {
55
+ domain,
56
+ domainkey,
57
+ interval = 7,
58
+ granularity = GRANULARITY.DAILY,
59
+ checkpoints = [],
60
+ } = opts;
61
+
62
+ if (!hasText(domain) || !hasText(domainkey)) {
63
+ throw new Error('Missing required parameters');
64
+ }
65
+
66
+ const multiplier = granularity.toUpperCase() === GRANULARITY.HOURLY ? ONE_HOUR : ONE_DAY;
67
+ const range = granularity.toUpperCase() === GRANULARITY.HOURLY
68
+ ? interval * HOURS_IN_DAY
69
+ : interval + 1;
70
+
71
+ const urls = [];
72
+ const currentDate = new Date();
73
+
74
+ for (let i = 0; i < range; i += 1) {
75
+ const date = new Date(currentDate.getTime() - i * multiplier);
76
+ urls.push(constructUrl(domain, date, granularity, domainkey));
77
+ }
78
+
79
+ const chunks = getUrlChunks(urls, CHUNK_SIZE);
80
+
81
+ const result = [];
82
+ for (const chunk of chunks) {
83
+ const responses = await Promise.all(chunk.map((url) => fetch(url)));
84
+ const bundles = await Promise.all(responses.map((response) => response.json()));
85
+ result.push(...bundles.flatMap((b) => b.rumBundles.map(filterBundles(checkpoints))));
86
+ }
87
+ return result;
88
+ }
89
+
90
+ export {
91
+ fetchBundles,
92
+ };
@@ -0,0 +1,46 @@
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
+
13
+ import { FlatBundle } from '../common/flat-bundle.js';
14
+
15
+ function collect404s(groupedByUrlAndSource) {
16
+ const { url, items: itemsByUrl } = groupedByUrlAndSource;
17
+
18
+ // find top source which has the most amount of occurrences
19
+ const { source: topSource } = itemsByUrl.reduce(
20
+ (max, obj) => (obj.items.length > max.items.length ? obj : max),
21
+ );
22
+
23
+ // calculate the total number of views per 404 event
24
+ const views = itemsByUrl.flatMap((item) => item.items).reduce((acc, cur) => acc + cur.weight, 0);
25
+
26
+ return {
27
+ url,
28
+ views,
29
+ all_sources: itemsByUrl.map((item) => item.source),
30
+ source_count: itemsByUrl.length,
31
+ top_source: topSource,
32
+ };
33
+ }
34
+
35
+ function handler(bundles) {
36
+ return FlatBundle.fromArray(bundles)
37
+ .filter((row) => row.checkpoint === '404')
38
+ .groupBy('url', 'source')
39
+ .map(collect404s)
40
+ .sort((a, b) => b.views - a.views); // sort desc by views
41
+ }
42
+
43
+ export default {
44
+ handler,
45
+ checkpoints: ['404'],
46
+ };
@@ -0,0 +1,75 @@
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
+
13
+ import { quantile } from 'd3-array';
14
+ import { pageviewsByUrl } from '../common/aggregateFns.js';
15
+ import { FlatBundle } from '../common/flat-bundle.js';
16
+
17
+ const CWV_METRICS = ['lcp', 'cls', 'inp', 'ttfb'].map((metric) => `cwv-${metric}`);
18
+
19
+ function collectCWVs(groupedByUrlIdTime) {
20
+ const { url, items: itemsByUrl } = groupedByUrlIdTime;
21
+
22
+ // first level: grouped by url
23
+ const CWVs = itemsByUrl.reduce((acc, { items: itemsById }) => {
24
+ // second level: grouped by id
25
+ const itemsByTime = itemsById.flatMap((itemById) => itemById.items);
26
+ // third level: grouped by time
27
+ const maximums = itemsByTime.reduce((values, item) => {
28
+ // each session (id-time) can contain multiple measurement for the same metric
29
+ // we need to find the maximum per metric type
30
+ // eslint-disable-next-line no-param-reassign
31
+ values[item.checkpoint] = Math.max(values[item.checkpoint] || 0, item.value);
32
+ return values;
33
+ }, {});
34
+
35
+ // max values per id for each metric type are collected into an array
36
+ CWV_METRICS.forEach((metric) => {
37
+ if (!acc[metric]) acc[metric] = [];
38
+ if (maximums[metric]) {
39
+ acc[metric].push(maximums[metric]);
40
+ }
41
+ });
42
+ return acc;
43
+ }, {});
44
+
45
+ return {
46
+ url,
47
+ lcp: quantile(CWVs['cwv-lcp'], 0.75) || null,
48
+ lcpCount: CWVs['cwv-lcp'].length,
49
+ cls: quantile(CWVs['cwv-cls'], 0.75) || null,
50
+ clsCount: CWVs['cwv-cls'].length,
51
+ inp: quantile(CWVs['cwv-inp'], 0.75) || null,
52
+ inpCount: CWVs['cwv-inp'].length,
53
+ ttfb: quantile(CWVs['cwv-ttfb'], 0.75) || null,
54
+ ttfbCount: CWVs['cwv-ttfb'].length,
55
+ };
56
+ }
57
+
58
+ function handler(bundles) {
59
+ const pageviews = pageviewsByUrl(bundles);
60
+
61
+ return FlatBundle.fromArray(bundles)
62
+ .groupBy('url', 'id', 'time')
63
+ .map(collectCWVs)
64
+ .filter((row) => row.lcp || row.cls || row.inp || row.ttfb) // filter out pages with no cwv data
65
+ .map((acc) => {
66
+ acc.pageviews = pageviews[acc.url];
67
+ return acc;
68
+ })
69
+ .sort((a, b) => b.pageviews - a.pageviews); // sort desc by pageviews
70
+ }
71
+
72
+ export default {
73
+ handler,
74
+ checkpoints: ['cwv-lcp', 'cwv-cls', 'cwv-inp', 'cwv-ttfb'],
75
+ };
package/src/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /*
2
- * Copyright 2023 Adobe. All rights reserved.
2
+ * Copyright 2024 Adobe. All rights reserved.
3
3
  * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
4
  * you may not use this file except in compliance with the License. You may obtain a copy
5
5
  * of the License at http://www.apache.org/licenses/LICENSE-2.0
@@ -13,136 +13,34 @@
13
13
  import { UniversalContext } from '@adobe/helix-universal';
14
14
 
15
15
  export interface RUMAPIOptions {
16
- interval?: number;
17
- startdate?: string,
18
- enddate?: string,
19
- offset?: number;
20
- limit?: number;
21
- url?: string;
22
- }
23
-
24
- export interface RUMDashboardOptions {
25
- interval?: number;
26
- startdate?: string,
27
- enddate?: string,
28
- offset?: number;
29
- limit?: number;
30
- domainkey?: string,
31
- url?: string;
16
+ domain: string;
17
+ domainkey: string;
18
+ interval?: number;
19
+ granularity?: 'hourly' | 'daily';
32
20
  }
33
21
 
34
22
  export default class RUMAPIClient {
35
23
  /**
36
- * Static factory method to create an instance of RUMAPIClient.
37
- * @param {UniversalContext} context - An object containing the AWS Lambda context information
38
- * @returns An instance of RUMAPIClient.
39
- * @remarks This method is designed to create a new instance from an AWS Lambda context.
40
- * The created instance is stored in the Lambda context, and subsequent calls to
41
- * this method will return the singleton instance if previously created.
42
- */
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
+ */
43
31
  static createFrom(context: UniversalContext): RUMAPIClient;
44
32
 
45
33
  /**
46
- * Constructor for creating an instance of RUMAPIClient.
47
- * @param {string} domainkey - A string parameter representing the domain key of the RUM API.
48
- * This key is used to authenticate and interact with the RUM API.
49
- * @remarks The domain key is specific to the RUM API.
50
- */
51
- constructor(domainkey: string);
52
-
53
- /**
54
- * Asynchronous method to create a RUM backlink.
55
- * @param {string} url - A string representing the URL for the backlink.
56
- * @param {number} expiry - An integer representing the expiry value for the backlink.
57
- * @param {RUMDashboardOptions} params - An object representing the parameters to be included
58
- * @returns A Promise resolving to a string representing url of the created backlink.
59
- * @remarks This method creates a backlink to the RUM dashboard, allowing users
60
- * to view their reports and monitor real user activities.
61
- */
62
- createRUMBacklink(url: string, expiry: number, params?: RUMDashboardOptions): Promise<string>;
63
-
64
- /**
65
- * Asynchronous method to create a 404 backlink.
66
- * @param {string} url - A string representing the URL for the backlink.
67
- * @param {number} expiry - An integer representing the expiry value for the backlink.
68
- * @param {RUMDashboardOptions} params - An object representing the parameters to be included
69
- * @returns A Promise resolving to a string representing url of the created backlink.
70
- * @remarks This method creates a backlink to the 404 report, allowing users
71
- * to view their 404 pages.
72
- */
73
- create404Backlink(url: string, expiry: number, params?: RUMDashboardOptions): Promise<string>;
74
-
75
- /**
76
- * Asynchronous method to return the RUM dashboard API call response data.
77
- * @param {RUMAPIOptions} params - An object representing the parameters to be included
78
- * for the RUM Dashboard API call.
79
- * @returns A Promise resolving to the RUM dashboard response data.
80
- */
81
- getRUMDashboard(params?: RUMAPIOptions): Promise<Array<object>>;
82
-
83
- /**
84
- * Asynchronous method to return the Experimentation API call response data.
85
- * @param {RUMAPIOptions} params - An object representing the parameters to be included
86
- * for the Experimentation data API call.
87
- * @returns A Promise resolving to the Experimentation response data.
88
- */
89
- getExperimentationData(params?: RUMAPIOptions): Promise<Array<object>>;
90
-
91
- /**
92
- * Method to return the url composed of params that the Experimentation API is called with.
93
- * @param {RUMAPIOptions} params - An object representing the parameters to be included
94
- * for the Experimentation API call.
95
- * @returns A string returning the Experimentation url including query parameters.
96
- */
97
- createExperimentationURL(params?: RUMAPIOptions): string;
98
-
99
- /**
100
- * Method to return the url composed of params that the rum-sources API is called with.
101
- * @param {RUMAPIOptions} params - An object representing the parameters to be included
102
- * for the rum-sources API call.
103
- * @returns A string returning the rum-sources url including query parameters.
104
- */
105
- createConversionURL(params?: RUMAPIOptions): string;
106
-
107
- /**
108
- * Asynchronous method to return the 404 sources API call response data.
109
- * @param {RUMAPIOptions} params - An object representing the parameters to be included
110
- * for the 404 sources API call.
111
- * @returns A Promise resolving to the 404 sources response data.
112
- */
113
- get404Sources(params?: RUMAPIOptions): Promise<Array<object>>;
114
-
115
- /**
116
- * Method to return the url composed of params that the RUM Dashboard API is called with.
117
- * @param {RUMAPIOptions} params - An object representing the parameters to be included
118
- * for the RUM Dashboard API call.
119
- * @returns A string returning to the RUM Dashboard url including query parameters.
120
- */
121
- createRUMURL(params?: RUMAPIOptions): string;
122
-
123
- /**
124
- * Method to return the url composed of params that the 404 sources API is called with.
125
- * @param {RUMAPIOptions} params - An object representing the parameters to be included
126
- * for the 404 sources API call.
127
- * @returns A string returning to the 404 sources url including query parameters.
128
- */
129
- create404URL(params?: RUMAPIOptions): string;
130
-
131
- /**
132
- * Asynchronous method to return an array with the domain for a specific url
133
- * or an array of all domain urls
134
- * @param {RUMAPIOptions} params - An object representing the parameters to be included
135
- * for the domain list call.
136
- * @returns A Promise resolving to an array of the domain for a specific url
137
- * or an array of all domain urls .
138
- */
139
- getDomainList(params?: RUMAPIOptions): Promise<Array<string>>;
34
+ * Constructor for creating an instance of RUMAPIClient.
35
+ */
36
+ constructor();
140
37
 
141
38
  /**
142
- * Asynchronous method to return the rum-sources API call response data.
143
- * @param {RUMAPIOptions} params - An object representing the parameters to be included
144
- * for the rum-sources data API call.
145
- * @returns A Promise resolving to the conversion response data.
146
- */
147
- getConversionData(params?: RUMAPIOptions): Promise<Array<string>>;
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
+ query(query: string, opts?: RUMAPIOptions): Promise<object>;
148
46
  }
package/src/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*
2
- * Copyright 2023 Adobe. All rights reserved.
2
+ * Copyright 2024 Adobe. All rights reserved.
3
3
  * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
4
  * you may not use this file except in compliance with the License. You may obtain a copy
5
5
  * of the License at http://www.apache.org/licenses/LICENSE-2.0
@@ -9,192 +9,38 @@
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 { createUrl } from '@adobe/fetch';
13
- import {
14
- hasText, isArray, isInteger, isObject, dateAfterDays,
15
- } from '@adobe/spacecat-shared-utils';
16
- import { fetch } from './utils.js';
12
+ import { fetchBundles } from './common/rum-bundler-client.js';
13
+ import notfound from './functions/404.js';
14
+ import cwv from './functions/cwv.js';
17
15
 
18
- const APIS = {
19
- ROTATE_DOMAINKEYS: 'https://helix-pages.anywhere.run/helix-services/run-query@v3/rotate-domainkeys',
20
- RUM_DASHBOARD_UI: 'https://data.aem.live/rum-dashboard',
21
- NOT_FOUND_DASHBOARD_UI: 'https://data.aem.live/404-reports',
22
- RUM_DASHBOARD: 'https://helix-pages.anywhere.run/helix-services/run-query@v3/rum-dashboard',
23
- DOMAIN_LIST: 'https://helix-pages.anywhere.run/helix-services/run-query@v3/dash/domain-list',
24
- RUM_404: 'https://helix-pages.anywhere.run/helix-services/run-query@v3/rum-404',
25
- RUM_SOURCES: 'https://helix-pages.anywhere.run/helix-services/run-query@v3/rum-sources',
26
- RUM_EXPERIMENTS: 'https://helix-pages.anywhere.run/helix-services/run-query@v3/rum-experiments',
16
+ const HANDLERS = {
17
+ 404: notfound,
18
+ cwv,
27
19
  };
28
20
 
29
- const DOMAIN_LIST_DEFAULT_PARAMS = {
30
- interval: 30,
31
- offset: 0,
32
- limit: 100000,
33
- };
34
-
35
- export const RUM_DEFAULT_PARAMS = {
36
- interval: 7,
37
- offset: 0,
38
- limit: 101,
39
- };
40
-
41
- export const CONVERSION_DEFAULT_PARAMS = {
42
- ...RUM_DEFAULT_PARAMS,
43
- checkpoint: 'convert',
44
- aggregate: false,
45
- };
46
-
47
- export const create404URL = (params = {}) => createUrl(
48
- APIS.RUM_404,
49
- {
50
- ...RUM_DEFAULT_PARAMS, ...params,
51
- },
52
- );
53
-
54
- export const createRUMURL = (params = {}) => createUrl(
55
- APIS.RUM_DASHBOARD,
56
- {
57
- ...RUM_DEFAULT_PARAMS, ...params,
58
- },
59
- );
60
-
61
- export const createExperimentationURL = (params = {}) => createUrl(
62
- APIS.RUM_EXPERIMENTS,
63
- {
64
- ...RUM_DEFAULT_PARAMS, ...params,
65
- },
66
- );
67
-
68
- export const createConversionURL = (params = {}) => createUrl(
69
- APIS.RUM_SOURCES,
70
- {
71
- ...CONVERSION_DEFAULT_PARAMS, ...params,
72
- },
73
- );
74
-
75
- export async function sendRequest(url, opts) {
76
- let respJson;
77
- try {
78
- const resp = await (isObject(opts) ? fetch(url, opts) : fetch(url));
79
- respJson = await resp.json();
80
- } catch (e) {
81
- throw new Error(`Error during rum api call: ${e.message}`);
82
- }
83
-
84
- const data = respJson?.results?.data;
85
- if (!isArray(data)) {
86
- throw new Error('Unexpected response from rum api. $.results.data is not array');
87
- }
88
-
89
- return data;
90
- }
91
-
92
- async function generateDomainKey(domainkey, url, expiry) {
93
- if (!hasText(url) || !isInteger(expiry)) {
94
- throw new Error('Invalid input: url and expiry date parameters are required');
95
- }
96
-
97
- const params = {
98
- domainkey,
99
- url,
100
- expiry: dateAfterDays(expiry).toISOString(),
101
- note: 'generated by spacecat alerting',
102
- };
103
-
104
- const data = await sendRequest(createUrl(APIS.ROTATE_DOMAINKEYS, params), { method: 'POST' });
105
-
106
- if (data.length === 0) {
107
- throw new Error('Unexpected response: Rum api returned empty result');
108
- }
109
-
110
- if (data[0].status !== 'success') {
111
- throw new Error('Unexpected response: Response was not successful');
112
- }
113
-
114
- if (!hasText(data[0].key)) {
115
- throw new Error('Unexpected response: Rum api returned null domain key');
116
- }
117
-
118
- return data[0].key;
119
- }
120
-
121
- async function createBacklink(dashboardUrl, domainKey, domainUrl, expiry, params = {}) {
122
- const scopedDomainKey = await generateDomainKey(domainKey, domainUrl, expiry);
123
- const dataDeskParams = { ...params };
124
- if (dataDeskParams.startdate) {
125
- delete dataDeskParams.interval;
126
- }
127
- return createUrl(dashboardUrl, {
128
- offset: 0,
129
- limit: 100,
130
- url: domainUrl,
131
- domainkey: scopedDomainKey,
132
- ...(dataDeskParams?.startdate ? {} : { interval: expiry }),
133
- ...dataDeskParams,
134
- });
135
- }
136
-
137
21
  export default class RUMAPIClient {
138
22
  static createFrom(context) {
139
23
  if (context.rumApiClient) return context.rumApiClient;
140
24
 
141
- const { RUM_DOMAIN_KEY: domainkey } = context.env;
142
-
143
- const client = new RUMAPIClient(domainkey);
25
+ const client = new RUMAPIClient();
144
26
  context.rumApiClient = client;
145
27
  return client;
146
28
  }
147
29
 
148
- constructor(domainkey) {
149
- if (!hasText(domainkey)) {
150
- throw Error('RUM API Client needs a domain key to be set');
151
- }
152
-
153
- this.domainkey = domainkey;
154
- }
155
-
156
- async getRUMDashboard(params = {}) {
157
- return sendRequest(createRUMURL({ ...params, domainkey: this.domainkey }));
158
- }
159
-
160
- async getExperimentationData(params = {}) {
161
- return sendRequest(createExperimentationURL({ ...params, domainkey: this.domainkey }));
162
- }
163
-
164
- async getConversionData(params = {}) {
165
- return sendRequest(createConversionURL({ ...params, domainkey: this.domainkey }));
166
- }
167
-
168
- async get404Sources(params = {}) {
169
- return sendRequest(create404URL({ ...params, domainkey: this.domainkey }));
170
- }
171
-
172
- async getDomainList(params = {}) {
173
- const data = await sendRequest(createUrl(
174
- APIS.DOMAIN_LIST,
175
- { domainkey: this.domainkey, ...DOMAIN_LIST_DEFAULT_PARAMS, ...params },
176
- ));
177
-
178
- return data.map((row) => row.hostname);
179
- }
30
+ // eslint-disable-next-line class-methods-use-this
31
+ async query(query, opts) {
32
+ const { handler, checkpoints } = HANDLERS[query] || {};
33
+ if (!handler) throw new Error(`Unknown query ${query}`);
180
34
 
181
- async createRUMBacklink(url, expiry, params) {
182
- return createBacklink(
183
- APIS.RUM_DASHBOARD_UI,
184
- this.domainkey,
185
- url,
186
- expiry,
187
- params,
188
- );
189
- }
35
+ try {
36
+ const bundles = await fetchBundles({
37
+ ...opts,
38
+ checkpoints,
39
+ });
190
40
 
191
- async create404Backlink(url, expiry, params) {
192
- return createBacklink(
193
- APIS.NOT_FOUND_DASHBOARD_UI,
194
- this.domainkey,
195
- url,
196
- expiry,
197
- params,
198
- );
41
+ return handler(bundles);
42
+ } catch (e) {
43
+ throw new Error(`Query '${query}' failed. Opts: ${JSON.stringify(opts)}. Reason: ${e.message}`);
44
+ }
199
45
  }
200
46
  }