Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/spacecat-shared-data-access/src/models/site/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const IMPORT_TYPES = {
CWV_WEEKLY: 'cwv-weekly',
TRAFFIC_ANALYSIS: 'traffic-analysis',
TOP_FORMS: 'top-forms',
USER_ENGAGEMENT: 'user-engagement',
};

export const IMPORT_DESTINATIONS = {
Expand Down Expand Up @@ -152,6 +153,10 @@ export const IMPORT_TYPE_SCHEMAS = {
limit: Joi.number().integer().min(1).max(2000)
.optional(),
}),
[IMPORT_TYPES.USER_ENGAGEMENT]: Joi.object({
type: Joi.string().valid(IMPORT_TYPES.USER_ENGAGEMENT).required(),
...IMPORT_BASE_KEYS,
}),
};

export const DEFAULT_IMPORT_CONFIGS = {
Expand Down Expand Up @@ -228,6 +233,12 @@ export const DEFAULT_IMPORT_CONFIGS = {
sources: ['rum'],
enabled: true,
},
'user-engagement': {
type: 'user-engagement',
destinations: ['default'],
sources: ['rum'],
enabled: true,
},
};

export const configSchema = Joi.object({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1079,7 +1079,7 @@ describe('Config Tests', () => {
.to.throw().and.satisfy((error) => {
expect(error.message).to.include('Configuration validation error');
expect(error.cause.details[0].context.message)
.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]');
.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]');
expect(error.cause.details[0].context.details)
.to.eql([
{
Expand Down Expand Up @@ -1213,6 +1213,17 @@ describe('Config Tests', () => {
key: 'type',
},
},
{
message: '"imports[0].type" must be [user-engagement]',
path: ['imports', 0, 'type'],
type: 'any.only',
context: {
valids: ['user-engagement'],
label: 'imports[0].type',
value: 'organic-keywords',
key: 'type',
},
},
]);
return true;
});
Expand Down
22 changes: 22 additions & 0 deletions packages/spacecat-shared-rum-api-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,28 @@ An example response:
]
```

### User engagement

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).
Ref. - https://github.com/adobe/rum-distiller/blob/22f8b3caa6d700f4d1cbe29a94b7da34b9d50764/series.js#L89

An example response:

```json
[
{
"url": "https://www.example.com/home",
"totalTraffic": 5000,
"engagementPercentage": 50
},
{
"url": "https://www.example.com/about",
"totalTraffic": 2000,
"engagementPercentage": 40
}
]
```

## Linting
Lint the codebase using:
```
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* Copyright 2025 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

const evaluateEngagement = (bundle) => {
const clickEngagement = bundle.events.filter((evt) => evt.checkpoint === 'click').length > 0
? bundle.weight
: 0;
const contentEngagement = bundle.events
.filter((evt) => evt.checkpoint === 'viewmedia' || evt.checkpoint === 'viewblock')
.length > 3
? bundle.weight
: 0;
return clickEngagement || contentEngagement;
};

/**
* Calculates 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).
* Ref. - https://github.com/adobe/rum-distiller/blob/22f8b3caa6d700f4d1cbe29a94b7da34b9d50764/series.js#L89
*
* @param {Array} bundles - The RUM bundles to calculate engagement metrics for.
* @returns {Array} An array of engagement metrics for each page.
*/
function handler(bundles) {
const urlsData = {};
bundles.forEach((bundle) => {
const engagementTraffic = evaluateEngagement(bundle);
if (!urlsData[bundle.url]) {
urlsData[bundle.url] = {
url: bundle.url,
totalTraffic: bundle.weight,
engagementTraffic,
engagementPercentage: (engagementTraffic / bundle.weight) * 100,
};
} else {
urlsData[bundle.url].totalTraffic += bundle.weight;
urlsData[bundle.url].engagementTraffic += engagementTraffic;
urlsData[bundle.url].engagementPercentage = Math.round(
(urlsData[bundle.url].engagementTraffic / urlsData[bundle.url].totalTraffic) * 100,
);
}
});

return Object.values(urlsData);
}

export default {
handler,
checkpoints: ['click', 'viewmedia', 'viewblock'],
};
2 changes: 2 additions & 0 deletions packages/spacecat-shared-rum-api-client/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import highOrganicLowCtr from './functions/opportunities/high-organic-low-ctr.js
import trafficAnalysis from './functions/traffic-analysis.js';
import optimizationReportMetrics from './functions/reports/optimization/metrics.js';
import optimizationReportGraph from './functions/reports/optimization/graph.js';
import userEngagement from './functions/user-engagement.js';

// exported for tests
export const RUM_BUNDLER_API_HOST = 'https://bundles.aem.page';
Expand All @@ -48,6 +49,7 @@ const HANDLERS = {
'traffic-analysis': trafficAnalysis,
'optimization-report-metrics': optimizationReportMetrics,
'optimization-report-graph': optimizationReportGraph,
'user-engagement': userEngagement,
};

function sanitize(opts) {
Expand Down
155 changes: 155 additions & 0 deletions packages/spacecat-shared-rum-api-client/test/user-engagement.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/*
* Copyright 2025 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
/* eslint-env mocha */

import { expect } from 'chai';
import userEngagement from '../src/functions/user-engagement.js';

describe('userEngagement', () => {
it('calculates user engagement metrics correctly', () => {
const mockBundles = [
{
id: 'bundle1',
url: 'https://example.com/page1',
weight: 100,
events: [
{ checkpoint: 'click', timeDelta: 2000 },
],
},
{
id: 'bundle2',
url: 'https://example.com/page1',
weight: 50,
events: [
// No click event
],
},
{
id: 'bundle3',
url: 'https://example.com/page2',
weight: 200,
events: [
{ checkpoint: 'click', timeDelta: 1200 },
{ checkpoint: 'click', timeDelta: 1500 },
],
},
];

const result = userEngagement.handler(mockBundles);

expect(result).to.be.an('array');
expect(result).to.have.length(2);

// Check page2 (higher totalTraffic, should be first)
const page2 = result[1];
expect(page2.url).to.equal('https://example.com/page2');
expect(page2.totalTraffic).to.equal(200);
expect(page2.engagementPercentage).to.equal(100);

// Check page1
const page1 = result[0];
expect(page1.url).to.equal('https://example.com/page1');
expect(page1.totalTraffic).to.equal(150);
expect(page1.engagementPercentage).to.equal(67);
});

it('handles empty bundles array', () => {
const result = userEngagement.handler([]);
expect(result).to.be.an('array');
expect(result).to.have.length(0);
});

it('handles bundles with no click events', () => {
const mockBundles = [
{
id: 'bundle1',
url: 'https://example.com/page1',
weight: 100,
events: [],
},
];

const result = userEngagement.handler(mockBundles);

expect(result).to.be.an('array');
expect(result).to.have.length(1);

const page = result[0];
expect(page.url).to.equal('https://example.com/page1');
expect(page.totalTraffic).to.equal(100);
expect(page.engagementPercentage).to.equal(0);
});

it('handles content engagement (viewmedia/viewblock)', () => {
const mockBundles = [
{
id: 'bundle1',
url: 'https://example.com/page1',
weight: 100,
events: [
{ checkpoint: 'viewmedia', timeDelta: 1000 },
{ checkpoint: 'viewmedia', timeDelta: 2000 },
{ checkpoint: 'viewmedia', timeDelta: 3000 },
{ checkpoint: 'viewmedia', timeDelta: 4000 },
],
},
];

const result = userEngagement.handler(mockBundles);

expect(result).to.be.an('array');
expect(result).to.have.length(1);

const page = result[0];
expect(page.url).to.equal('https://example.com/page1');
expect(page.totalTraffic).to.equal(100);
expect(page.engagementPercentage).to.equal(100);
});

it('handles mixed click and content engagement', () => {
const mockBundles = [
{
id: 'bundle1',
url: 'https://example.com/page1',
weight: 100,
events: [
{ checkpoint: 'click', timeDelta: 1000 },
],
},
{
id: 'bundle2',
url: 'https://example.com/page1',
weight: 50,
events: [
{ checkpoint: 'viewmedia', timeDelta: 1000 },
{ checkpoint: 'viewmedia', timeDelta: 2000 },
{ checkpoint: 'viewmedia', timeDelta: 3000 },
{ checkpoint: 'viewmedia', timeDelta: 4000 },
],
},
];

const result = userEngagement.handler(mockBundles);

expect(result).to.be.an('array');
expect(result).to.have.length(1);

const page = result[0];
expect(page.url).to.equal('https://example.com/page1');
expect(page.totalTraffic).to.equal(150);
expect(page.engagementPercentage).to.equal(100);
});

it('has correct checkpoints', () => {
expect(userEngagement.checkpoints).to.deep.equal(['click', 'viewmedia', 'viewblock']);
});
});