@adobe/spacecat-shared-rum-api-client 2.3.0 → 2.5.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,17 @@
1
+ # [@adobe/spacecat-shared-rum-api-client-v2.5.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-rum-api-client-v2.4.0...@adobe/spacecat-shared-rum-api-client-v2.5.0) (2024-07-23)
2
+
3
+
4
+ ### Features
5
+
6
+ * Experimentation entity ([#288](https://github.com/adobe/spacecat-shared/issues/288)) ([774e2c7](https://github.com/adobe/spacecat-shared/commit/774e2c7013c9e617c745c494e20e1cdd8cce71e7))
7
+
8
+ # [@adobe/spacecat-shared-rum-api-client-v2.4.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-rum-api-client-v2.3.0...@adobe/spacecat-shared-rum-api-client-v2.4.0) (2024-07-19)
9
+
10
+
11
+ ### Features
12
+
13
+ * traffic acquisition detection ([#286](https://github.com/adobe/spacecat-shared/issues/286)) ([b3d1f1c](https://github.com/adobe/spacecat-shared/commit/b3d1f1caa288ed5cdda367b64dea24886cf87afb))
14
+
1
15
  # [@adobe/spacecat-shared-rum-api-client-v2.3.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-rum-api-client-v2.2.1...@adobe/spacecat-shared-rum-api-client-v2.3.0) (2024-07-12)
2
16
 
3
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-rum-api-client",
3
- "version": "2.3.0",
3
+ "version": "2.5.0",
4
4
  "description": "Shared modules of the Spacecat Services - Rum API client",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -0,0 +1,128 @@
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 object-curly-newline */
13
+
14
+ import { hasText } from '@adobe/spacecat-shared-utils';
15
+
16
+ /*
17
+ * --------- DEFINITIONS ----------------
18
+ */
19
+
20
+ // Referrer related
21
+ // matches second level domains 1:1 ignoring subdomains and top-level domains
22
+ // for example: https://l.instagram.com matches, whereas https://wwww.linstagram.com does not
23
+ const searchEngines = /^(https?:\/\/)?(.*\.)?(google|yahoo|bing|yandex|baidu|duckduckgo|brave|ecosia|aol|startpage|ask)\.(.*)(\/|$)/;
24
+ const socialMedias = /^(https?:\/\/)?(.*\.)?(facebook|tiktok|snapchat|x|twitter|pinterest|reddit|linkedin|threads|quora|discord|tumblr|mastodon|bluesky|instagram)\.(.*)(\/|$)/;
25
+ const adNetworks = /googlesyndication|2mdn/;
26
+ const videoPlatforms = /^(https?:\/\/)?(.*\.)?(youtube|vimeo|twitch|dailymotion|wistia)\.(.*)(\/|$)/;
27
+
28
+ // UTM Source related
29
+ const paidDisplaySources = ['gdn'];
30
+
31
+ // UTM Medium related
32
+ // matches 'pp', *cp[acmuv]*, *ppc*, *paid*
33
+ const paidUTMMediums = /^\bpp\b|(.*(cp[acmuv]|ppc|paid|display|banner|poster|placement).*)$/;
34
+ const searchEngineUTMMediums = ['google', 'paidsearch', 'paidsearchnb', 'sea', 'sem'];
35
+ const socialMediaUTMMediums = ['facebook', 'gnews', 'instagramfeed', 'instagramreels', 'instagramstories', 'line', 'linkedin', 'metasearch', 'organicsocialown', 'paidsocial', 'social', 'sociallinkedin', 'socialpaid'];
36
+ const affiliateUTMMediums = ['aff', 'affiliate', 'affiliatemarketing'];
37
+ const organicUTMMediums = ['organicsocial'];
38
+ const emailUTMMediums = ['em', 'email', 'mail', 'newsletter'];
39
+ const smsUTMMediums = ['sms', 'mms'];
40
+ const qrUTMMediums = ['qr', 'qrcode'];
41
+ const pushUTMMediums = ['push', 'pushnotification'];
42
+
43
+ // Tracking params - based on the checkpoints we have in rum-enhancer now
44
+ // const organicTrackingParams = ['srsltid']; WE DO NOT HAVE THIS AS OF NOW
45
+ const paidTrackingParams = ['paid'];
46
+ const emailTrackingParams = ['email'];
47
+
48
+ /*
49
+ * --------- HELPERS ----------------
50
+ */
51
+
52
+ const any = () => true;
53
+
54
+ const anyOf = (truth) => (text) => {
55
+ if (Array.isArray(truth)) return truth.includes(text);
56
+ if (truth instanceof RegExp) return truth.test(text);
57
+ return truth === text;
58
+ };
59
+
60
+ const none = (input) => (Array.isArray(input) ? input.length === 0 : !hasText(input));
61
+
62
+ const not = (truth) => (text) => {
63
+ if (!hasText(text)) return false;
64
+ if (Array.isArray(truth)) return !truth.includes(text);
65
+ if (truth instanceof RegExp) return !truth.test(text);
66
+ return truth !== text;
67
+ };
68
+
69
+ const notEmpty = (text) => hasText(text);
70
+
71
+ /*
72
+ * --------- RULES ----------------
73
+ */
74
+
75
+ // ORDER IS IMPORTANT
76
+ const RULES = (origin) => ([
77
+ // PAID
78
+ { type: 'paid', category: 'search', referrer: anyOf(searchEngines), utmSource: any, utmMedium: anyOf(searchEngineUTMMediums), tracking: none },
79
+ { type: 'paid', category: 'search', referrer: anyOf(searchEngines), utmSource: any, utmMedium: any, tracking: anyOf(paidTrackingParams) },
80
+ { type: 'paid', category: 'social', referrer: anyOf(socialMedias), utmSource: any, utmMedium: anyOf(socialMediaUTMMediums), tracking: none },
81
+ { type: 'paid', category: 'social', referrer: anyOf(socialMedias), utmSource: any, utmMedium: any, tracking: anyOf(paidTrackingParams) },
82
+ { type: 'paid', category: 'video', referrer: anyOf(videoPlatforms), utmSource: any, utmMedium: anyOf(paidUTMMediums), tracking: any },
83
+ { type: 'paid', category: 'video', referrer: anyOf(videoPlatforms), utmSource: any, utmMedium: any, tracking: anyOf(paidTrackingParams) },
84
+ { type: 'paid', category: 'display', referrer: notEmpty, utmSource: any, utmMedium: anyOf(paidUTMMediums), tracking: any },
85
+ { type: 'paid', category: 'display', referrer: anyOf(adNetworks), utmSource: any, utmMedium: any, tracking: any },
86
+ { type: 'paid', category: 'display', referrer: notEmpty, utmSource: anyOf(paidDisplaySources), utmMedium: any, tracking: any },
87
+ { type: 'paid', category: 'affiliate', referrer: notEmpty, utmSource: any, utmMedium: anyOf(affiliateUTMMediums), tracking: any },
88
+ { type: 'paid', category: 'uncategorized', referrer: not(origin), utmSource: any, utmMedium: anyOf(paidUTMMediums), tracking: any },
89
+ { type: 'paid', category: 'uncategorized', referrer: not(origin), utmSource: any, utmMedium: any, tracking: anyOf(paidTrackingParams) },
90
+
91
+ // EARNED
92
+ { type: 'earned', category: 'search', referrer: anyOf(searchEngines), utmSource: none, utmMedium: none, tracking: none },
93
+ { type: 'earned', category: 'search', referrer: anyOf(searchEngines), utmSource: any, utmMedium: not(paidUTMMediums), tracking: not(paidTrackingParams) },
94
+ { type: 'earned', category: 'social', referrer: anyOf(socialMedias), utmSource: none, utmMedium: none, tracking: none },
95
+ { type: 'earned', category: 'social', referrer: not(origin), utmSource: any, utmMedium: anyOf(organicUTMMediums), tracking: none },
96
+ { type: 'earned', category: 'video', referrer: anyOf(videoPlatforms), utmSource: none, utmMedium: none, tracking: none },
97
+ { type: 'earned', category: 'video', referrer: anyOf(videoPlatforms), utmSource: any, utmMedium: not(paidUTMMediums), tracking: none },
98
+ { type: 'earned', category: 'referral', referrer: not(origin), utmSource: none, utmMedium: none, tracking: none },
99
+
100
+ // OWNED
101
+ { type: 'owned', category: 'direct', referrer: none, utmSource: none, utmMedium: none, tracking: none },
102
+ { type: 'owned', category: 'internal', referrer: anyOf(origin), utmSource: none, utmMedium: none, tracking: none },
103
+ { type: 'owned', category: 'email', referrer: any, utmSource: any, utmMedium: any, tracking: anyOf(emailTrackingParams) },
104
+ { type: 'owned', category: 'email', referrer: any, utmSource: any, utmMedium: anyOf(emailUTMMediums), tracking: any },
105
+ { type: 'owned', category: 'sms', referrer: none, utmSource: any, utmMedium: anyOf(smsUTMMediums), tracking: none },
106
+ { type: 'owned', category: 'qr', referrer: none, utmSource: any, utmMedium: anyOf(qrUTMMediums), tracking: none },
107
+ { type: 'owned', category: 'push', referrer: none, utmSource: any, utmMedium: anyOf(pushUTMMediums), tracking: none },
108
+
109
+ // FALLBACK
110
+ { type: 'owned', category: 'uncategorized', referrer: any, utmSource: any, utmMedium: any, tracking: any },
111
+ ]);
112
+
113
+ export function classifyTrafficSource(url, referrer, utmSource, utmMedium, trackingParams) {
114
+ const { origin } = new URL(url);
115
+ const rules = RULES(origin);
116
+
117
+ const { type, category } = rules.find((rule) => (
118
+ rule.referrer(referrer)
119
+ && rule.utmSource(utmSource)
120
+ && rule.utmMedium(utmMedium)
121
+ && rule.tracking(trackingParams)
122
+ ));
123
+
124
+ return {
125
+ type,
126
+ category,
127
+ };
128
+ }
@@ -47,12 +47,49 @@ function getOrCreateVariantObject(variants, variantName) {
47
47
  return variantObject;
48
48
  }
49
49
 
50
+ function updateInferredStartAndEndDate(experimentObject, time) {
51
+ const bundleTime = new Date(time);
52
+ const yesterday = new Date();
53
+ yesterday.setDate(yesterday.getDate() - 1);
54
+ yesterday.setHours(0, 0, 0, 0);
55
+ const bundleDate = new Date(bundleTime);
56
+ bundleDate.setHours(0, 0, 0, 0);
57
+ if (!experimentObject.inferredStartDate && !experimentObject.inferredEndDate) {
58
+ // adding the inferredStartDate and inferredEndDate properties for the first time
59
+ // eslint-disable-next-line no-param-reassign
60
+ experimentObject.inferredStartDate = time;
61
+ // check if bundleTime is before yesterday
62
+ if (bundleDate < yesterday) {
63
+ // RUM data is delayed by a day, so if we don't have
64
+ // any RUM data for yesterday, so we can infer the endDate
65
+ // eslint-disable-next-line no-param-reassign
66
+ experimentObject.inferredEndDate = time;
67
+ } else {
68
+ // eslint-disable-next-line no-param-reassign
69
+ experimentObject.inferredEndDate = null;
70
+ }
71
+ } else {
72
+ const inferredStartDateObj = new Date(experimentObject.inferredStartDate);
73
+ if (bundleTime < inferredStartDateObj) {
74
+ // eslint-disable-next-line no-param-reassign
75
+ experimentObject.inferredStartDate = time;
76
+ }
77
+ if (bundleDate < yesterday) {
78
+ if (!experimentObject.inferredEndDate
79
+ || (bundleTime > new Date(experimentObject.inferredEndDate))) {
80
+ // eslint-disable-next-line no-param-reassign
81
+ experimentObject.inferredEndDate = time;
82
+ }
83
+ }
84
+ }
85
+ }
86
+
50
87
  function handler(bundles) {
51
88
  const experimentInsights = {};
52
89
  for (const bundle of bundles) {
53
- const experimentEvent = bundle.events.find((e) => e.checkpoint === 'experiment');
90
+ const experimentEvent = bundle.events?.find((e) => e.checkpoint === 'experiment');
54
91
  if (experimentEvent) {
55
- const { url, weight } = bundle;
92
+ const { url, weight, time } = bundle;
56
93
  if (!experimentInsights[url]) {
57
94
  experimentInsights[url] = [];
58
95
  }
@@ -60,6 +97,7 @@ function handler(bundles) {
60
97
  const variantName = experimentEvent.target;
61
98
  const experimentObject = getOrCreateExperimentObject(experimentInsights[url], experimentName);
62
99
  const variantObject = getOrCreateVariantObject(experimentObject.variants, variantName);
100
+ updateInferredStartAndEndDate(experimentObject, time);
63
101
  variantObject.views += weight;
64
102
 
65
103
  const metrics = {};
@@ -0,0 +1,73 @@
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 { classifyTrafficSource } from '../common/traffic.js';
14
+
15
+ function extractHints(bundle) {
16
+ const findEvent = (checkpoint, source = '') => bundle.events.find((e) => e.checkpoint === checkpoint && (!source || e.source === source)) || {};
17
+
18
+ const referrer = findEvent('enter').source || '';
19
+ const utmSource = findEvent('utm', 'utm_source').target || '';
20
+ const utmMedium = findEvent('utm', 'utm_medium').target || '';
21
+ const tracking = findEvent('paid').checkpoint || findEvent('email').checkpoint || '';
22
+
23
+ return {
24
+ url: bundle.url,
25
+ weight: bundle.weight,
26
+ referrer,
27
+ utmSource,
28
+ utmMedium,
29
+ tracking,
30
+ };
31
+ }
32
+
33
+ function collectByUrlAndTrafficSource(acc, { url, weight, trafficSource }) {
34
+ acc[url] = acc[url] || { total: 0 };
35
+ acc[url][trafficSource] = (acc[url][trafficSource] || 0) + weight;
36
+ acc[url].total += weight;
37
+ return acc;
38
+ }
39
+
40
+ function transformFormat(trafficSources) {
41
+ return Object.entries(trafficSources).map(([url, value]) => ({
42
+ url,
43
+ total: value.total,
44
+ sources: Object.entries(value)
45
+ .filter(([source]) => source !== 'total')
46
+ .map(([source, views]) => ({ type: source, views })),
47
+ }));
48
+ }
49
+
50
+ function handler(bundles) {
51
+ const trafficSources = bundles
52
+ .map(extractHints)
53
+ .map((row) => {
54
+ const {
55
+ type,
56
+ category,
57
+ } = classifyTrafficSource(row.url, row.referrer, row.utmSource, row.utmMedium, row.tracking);
58
+ return {
59
+ url: row.url,
60
+ weight: row.weight,
61
+ trafficSource: `${type}:${category}`,
62
+ };
63
+ })
64
+ .reduce(collectByUrlAndTrafficSource, {});
65
+
66
+ return transformFormat(trafficSources)
67
+ .sort((a, b) => b.total - a.total); // sort desc by total views
68
+ }
69
+
70
+ export default {
71
+ handler,
72
+ checkpoints: ['email', 'enter', 'paid', 'utm'],
73
+ };
package/src/index.js CHANGED
@@ -13,12 +13,14 @@ import { fetchBundles } from './common/rum-bundler-client.js';
13
13
  import notfound from './functions/404.js';
14
14
  import cwv from './functions/cwv.js';
15
15
  import experiment from './functions/experiment.js';
16
+ import trafficAcquisition from './functions/traffic-acquisition.js';
16
17
  import variant from './functions/variant.js';
17
18
 
18
19
  const HANDLERS = {
19
20
  404: notfound,
20
21
  cwv,
21
22
  experiment,
23
+ 'traffic-acquisition': trafficAcquisition,
22
24
  variant,
23
25
  };
24
26