Skip to content

Commit 0eb8881

Browse files
authored
feat: add query for fetching RUM engagement metrics (#985)
1 parent b1f34e7 commit 0eb8881

File tree

6 files changed

+262
-1
lines changed

6 files changed

+262
-1
lines changed

packages/spacecat-shared-data-access/src/models/site/config.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export const IMPORT_TYPES = {
2727
CWV_WEEKLY: 'cwv-weekly',
2828
TRAFFIC_ANALYSIS: 'traffic-analysis',
2929
TOP_FORMS: 'top-forms',
30+
USER_ENGAGEMENT: 'user-engagement',
3031
};
3132

3233
export const IMPORT_DESTINATIONS = {
@@ -152,6 +153,10 @@ export const IMPORT_TYPE_SCHEMAS = {
152153
limit: Joi.number().integer().min(1).max(2000)
153154
.optional(),
154155
}),
156+
[IMPORT_TYPES.USER_ENGAGEMENT]: Joi.object({
157+
type: Joi.string().valid(IMPORT_TYPES.USER_ENGAGEMENT).required(),
158+
...IMPORT_BASE_KEYS,
159+
}),
155160
};
156161

157162
export const DEFAULT_IMPORT_CONFIGS = {
@@ -228,6 +233,12 @@ export const DEFAULT_IMPORT_CONFIGS = {
228233
sources: ['rum'],
229234
enabled: true,
230235
},
236+
'user-engagement': {
237+
type: 'user-engagement',
238+
destinations: ['default'],
239+
sources: ['rum'],
240+
enabled: true,
241+
},
231242
};
232243

233244
export const configSchema = Joi.object({

packages/spacecat-shared-data-access/test/unit/models/site/config.test.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1079,7 +1079,7 @@ describe('Config Tests', () => {
10791079
.to.throw().and.satisfy((error) => {
10801080
expect(error.message).to.include('Configuration validation error');
10811081
expect(error.cause.details[0].context.message)
1082-
.to.equal('"imports[0].type" must be [llmo-prompts-ahrefs]. "imports[0].destinations[0]" must be [default]. "imports[0].type" must be [organic-keywords-nonbranded]. "imports[0].type" must be [organic-keywords-ai-overview]. "imports[0].type" must be [organic-keywords-feature-snippets]. "imports[0].type" must be [organic-keywords-questions]. "imports[0].type" must be [organic-traffic]. "imports[0].type" must be [all-traffic]. "imports[0].type" must be [top-pages]. "imports[0].type" must be [cwv-daily]. "imports[0].type" must be [cwv-weekly]. "imports[0].type" must be [traffic-analysis]. "imports[0].type" must be [top-forms]');
1082+
.to.equal('"imports[0].type" must be [llmo-prompts-ahrefs]. "imports[0].destinations[0]" must be [default]. "imports[0].type" must be [organic-keywords-nonbranded]. "imports[0].type" must be [organic-keywords-ai-overview]. "imports[0].type" must be [organic-keywords-feature-snippets]. "imports[0].type" must be [organic-keywords-questions]. "imports[0].type" must be [organic-traffic]. "imports[0].type" must be [all-traffic]. "imports[0].type" must be [top-pages]. "imports[0].type" must be [cwv-daily]. "imports[0].type" must be [cwv-weekly]. "imports[0].type" must be [traffic-analysis]. "imports[0].type" must be [top-forms]. "imports[0].type" must be [user-engagement]');
10831083
expect(error.cause.details[0].context.details)
10841084
.to.eql([
10851085
{
@@ -1213,6 +1213,17 @@ describe('Config Tests', () => {
12131213
key: 'type',
12141214
},
12151215
},
1216+
{
1217+
message: '"imports[0].type" must be [user-engagement]',
1218+
path: ['imports', 0, 'type'],
1219+
type: 'any.only',
1220+
context: {
1221+
valids: ['user-engagement'],
1222+
label: 'imports[0].type',
1223+
value: 'organic-keywords',
1224+
key: 'type',
1225+
},
1226+
},
12161227
]);
12171228
return true;
12181229
});

packages/spacecat-shared-rum-api-client/README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,28 @@ An example response:
437437
]
438438
```
439439

440+
### User engagement
441+
442+
Calculates user engagement metrics for all pages from RUM data. A page view is considered engaged if there has been at least some user interaction (click events) or significant content has been viewed (4 or more viewmedia or viewblock events).
443+
Ref. - https://github.com/adobe/rum-distiller/blob/22f8b3caa6d700f4d1cbe29a94b7da34b9d50764/series.js#L89
444+
445+
An example response:
446+
447+
```json
448+
[
449+
{
450+
"url": "https://www.example.com/home",
451+
"totalTraffic": 5000,
452+
"engagementPercentage": 50
453+
},
454+
{
455+
"url": "https://www.example.com/about",
456+
"totalTraffic": 2000,
457+
"engagementPercentage": 40
458+
}
459+
]
460+
```
461+
440462
## Linting
441463
Lint the codebase using:
442464
```
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
* Copyright 2025 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+
const evaluateEngagement = (bundle) => {
14+
const clickEngagement = bundle.events.filter((evt) => evt.checkpoint === 'click').length > 0
15+
? bundle.weight
16+
: 0;
17+
const contentEngagement = bundle.events
18+
.filter((evt) => evt.checkpoint === 'viewmedia' || evt.checkpoint === 'viewblock')
19+
.length > 3
20+
? bundle.weight
21+
: 0;
22+
return clickEngagement || contentEngagement;
23+
};
24+
25+
/**
26+
* Calculates engagement metrics for all pages from RUM data.
27+
* A page view is considered engaged if there has been at least some user interaction (click events)
28+
* or significant content has been viewed (4 or more viewmedia or viewblock events).
29+
* Ref. - https://github.com/adobe/rum-distiller/blob/22f8b3caa6d700f4d1cbe29a94b7da34b9d50764/series.js#L89
30+
*
31+
* @param {Array} bundles - The RUM bundles to calculate engagement metrics for.
32+
* @returns {Array} An array of engagement metrics for each page.
33+
*/
34+
function handler(bundles) {
35+
const urlsData = {};
36+
bundles.forEach((bundle) => {
37+
const engagementTraffic = evaluateEngagement(bundle);
38+
if (!urlsData[bundle.url]) {
39+
urlsData[bundle.url] = {
40+
url: bundle.url,
41+
totalTraffic: bundle.weight,
42+
engagementTraffic,
43+
engagementPercentage: (engagementTraffic / bundle.weight) * 100,
44+
};
45+
} else {
46+
urlsData[bundle.url].totalTraffic += bundle.weight;
47+
urlsData[bundle.url].engagementTraffic += engagementTraffic;
48+
urlsData[bundle.url].engagementPercentage = Math.round(
49+
(urlsData[bundle.url].engagementTraffic / urlsData[bundle.url].totalTraffic) * 100,
50+
);
51+
}
52+
});
53+
54+
return Object.values(urlsData);
55+
}
56+
57+
export default {
58+
handler,
59+
checkpoints: ['click', 'viewmedia', 'viewblock'],
60+
};

packages/spacecat-shared-rum-api-client/src/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import highOrganicLowCtr from './functions/opportunities/high-organic-low-ctr.js
2727
import trafficAnalysis from './functions/traffic-analysis.js';
2828
import optimizationReportMetrics from './functions/reports/optimization/metrics.js';
2929
import optimizationReportGraph from './functions/reports/optimization/graph.js';
30+
import userEngagement from './functions/user-engagement.js';
3031

3132
// exported for tests
3233
export const RUM_BUNDLER_API_HOST = 'https://bundles.aem.page';
@@ -48,6 +49,7 @@ const HANDLERS = {
4849
'traffic-analysis': trafficAnalysis,
4950
'optimization-report-metrics': optimizationReportMetrics,
5051
'optimization-report-graph': optimizationReportGraph,
52+
'user-engagement': userEngagement,
5153
};
5254

5355
function sanitize(opts) {
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/*
2+
* Copyright 2025 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-env mocha */
13+
14+
import { expect } from 'chai';
15+
import userEngagement from '../src/functions/user-engagement.js';
16+
17+
describe('userEngagement', () => {
18+
it('calculates user engagement metrics correctly', () => {
19+
const mockBundles = [
20+
{
21+
id: 'bundle1',
22+
url: 'https://example.com/page1',
23+
weight: 100,
24+
events: [
25+
{ checkpoint: 'click', timeDelta: 2000 },
26+
],
27+
},
28+
{
29+
id: 'bundle2',
30+
url: 'https://example.com/page1',
31+
weight: 50,
32+
events: [
33+
// No click event
34+
],
35+
},
36+
{
37+
id: 'bundle3',
38+
url: 'https://example.com/page2',
39+
weight: 200,
40+
events: [
41+
{ checkpoint: 'click', timeDelta: 1200 },
42+
{ checkpoint: 'click', timeDelta: 1500 },
43+
],
44+
},
45+
];
46+
47+
const result = userEngagement.handler(mockBundles);
48+
49+
expect(result).to.be.an('array');
50+
expect(result).to.have.length(2);
51+
52+
// Check page2 (higher totalTraffic, should be first)
53+
const page2 = result[1];
54+
expect(page2.url).to.equal('https://example.com/page2');
55+
expect(page2.totalTraffic).to.equal(200);
56+
expect(page2.engagementPercentage).to.equal(100);
57+
58+
// Check page1
59+
const page1 = result[0];
60+
expect(page1.url).to.equal('https://example.com/page1');
61+
expect(page1.totalTraffic).to.equal(150);
62+
expect(page1.engagementPercentage).to.equal(67);
63+
});
64+
65+
it('handles empty bundles array', () => {
66+
const result = userEngagement.handler([]);
67+
expect(result).to.be.an('array');
68+
expect(result).to.have.length(0);
69+
});
70+
71+
it('handles bundles with no click events', () => {
72+
const mockBundles = [
73+
{
74+
id: 'bundle1',
75+
url: 'https://example.com/page1',
76+
weight: 100,
77+
events: [],
78+
},
79+
];
80+
81+
const result = userEngagement.handler(mockBundles);
82+
83+
expect(result).to.be.an('array');
84+
expect(result).to.have.length(1);
85+
86+
const page = result[0];
87+
expect(page.url).to.equal('https://example.com/page1');
88+
expect(page.totalTraffic).to.equal(100);
89+
expect(page.engagementPercentage).to.equal(0);
90+
});
91+
92+
it('handles content engagement (viewmedia/viewblock)', () => {
93+
const mockBundles = [
94+
{
95+
id: 'bundle1',
96+
url: 'https://example.com/page1',
97+
weight: 100,
98+
events: [
99+
{ checkpoint: 'viewmedia', timeDelta: 1000 },
100+
{ checkpoint: 'viewmedia', timeDelta: 2000 },
101+
{ checkpoint: 'viewmedia', timeDelta: 3000 },
102+
{ checkpoint: 'viewmedia', timeDelta: 4000 },
103+
],
104+
},
105+
];
106+
107+
const result = userEngagement.handler(mockBundles);
108+
109+
expect(result).to.be.an('array');
110+
expect(result).to.have.length(1);
111+
112+
const page = result[0];
113+
expect(page.url).to.equal('https://example.com/page1');
114+
expect(page.totalTraffic).to.equal(100);
115+
expect(page.engagementPercentage).to.equal(100);
116+
});
117+
118+
it('handles mixed click and content engagement', () => {
119+
const mockBundles = [
120+
{
121+
id: 'bundle1',
122+
url: 'https://example.com/page1',
123+
weight: 100,
124+
events: [
125+
{ checkpoint: 'click', timeDelta: 1000 },
126+
],
127+
},
128+
{
129+
id: 'bundle2',
130+
url: 'https://example.com/page1',
131+
weight: 50,
132+
events: [
133+
{ checkpoint: 'viewmedia', timeDelta: 1000 },
134+
{ checkpoint: 'viewmedia', timeDelta: 2000 },
135+
{ checkpoint: 'viewmedia', timeDelta: 3000 },
136+
{ checkpoint: 'viewmedia', timeDelta: 4000 },
137+
],
138+
},
139+
];
140+
141+
const result = userEngagement.handler(mockBundles);
142+
143+
expect(result).to.be.an('array');
144+
expect(result).to.have.length(1);
145+
146+
const page = result[0];
147+
expect(page.url).to.equal('https://example.com/page1');
148+
expect(page.totalTraffic).to.equal(150);
149+
expect(page.engagementPercentage).to.equal(100);
150+
});
151+
152+
it('has correct checkpoints', () => {
153+
expect(userEngagement.checkpoints).to.deep.equal(['click', 'viewmedia', 'viewblock']);
154+
});
155+
});

0 commit comments

Comments
 (0)