Skip to content

feat: introduce KPI metrics for broken-backlinks #725

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Mar 6, 2025
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
24 changes: 15 additions & 9 deletions src/backlinks/handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { AuditBuilder } from '../common/audit-builder.js';
import { getScrapedDataForSiteId } from '../support/utils.js';
import { convertToOpportunity } from '../common/opportunity.js';
import { createOpportunityData } from './opportunity-data-mapper.js';
import calculateKpiDeltasForAudit from './kpi-metrics.js';

const auditType = Audit.AUDIT_TYPES.BROKEN_BACKLINKS;
const TIMEOUT = 3000;
Expand Down Expand Up @@ -235,22 +236,27 @@ export const generateSuggestionData = async (finalUrl, auditData, context, site)
};

/**
* Converts audit data to an opportunity and synchronizes suggestions.
*
* @param {string} auditUrl - The URL of the audit.
* @param {Object} auditData - The data from the audit.
* @param {Object} context - The context contains logging and data access utilities.
*/

export async function opportunityAndSuggestions(auditUrl, auditData, context) {
* Converts audit data to an opportunity and synchronizes suggestions.
*
* @param {string} auditUrl - The URL of the audit.
* @param {Object} auditData - The data from the audit.
* @param {Object} context - The context contains logging and data access utilities.
* @param {Object} site - The site object.
*/

export async function opportunityAndSuggestions(auditUrl, auditData, context, site) {
const { log } = context;

const kpiDeltas = await calculateKpiDeltasForAudit(auditData, context, site);

const opportunity = await convertToOpportunity(
auditUrl,
auditData,
context,
createOpportunityData,
auditType,
kpiDeltas,
);
const { log } = context;

const buildKey = (data) => `${data.url_from}|${data.url_to}`;

Expand Down
85 changes: 85 additions & 0 deletions src/backlinks/kpi-metrics.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* 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.
*/

import { getStoredMetrics, isNonEmptyArray } from '@adobe/spacecat-shared-utils';

const CPC_DEFAULT_VALUE = 2.69;
const TRAFFIC_BANDS = [
{ threshold: 25000000, band: 0.03 },
{ threshold: 10000000, band: 0.02 },
{ threshold: 1000000, band: 0.01 },
{ threshold: 500000, band: 0.0075 },
{ threshold: 10000, band: 0.005 },
];

const getTrafficBand = (traffic) => {
for (const { threshold, band } of TRAFFIC_BANDS) {
if (traffic > threshold) {
return band;
}
}
return 0.001;
};

const calculateKpiMetrics = async (auditData, context, site) => {
const { log } = context;
const storedMetricsConfig = {
...context,
s3: {
s3Bucket: context.env?.S3_IMPORTER_BUCKET_NAME,
s3Client: context.s3Client,
},
};

const siteId = site.getId();
const rumTrafficData = await getStoredMetrics(
{ source: 'rum', metric: 'all-traffic', siteId },
storedMetricsConfig,
);

if (!isNonEmptyArray(rumTrafficData)) {
log.info(`No RUM traffic data found for site ${siteId}`);
return null;
}

const organicTrafficData = await getStoredMetrics(
{ source: 'ahrefs', metric: 'organic-traffic', siteId },
storedMetricsConfig,
);

let CPC = CPC_DEFAULT_VALUE;

if (isNonEmptyArray(organicTrafficData)) {
const latestOrganicTrafficData = organicTrafficData.sort(
(a, b) => new Date(b.time) - new Date(a.time),
)[0];
CPC = latestOrganicTrafficData.cost / latestOrganicTrafficData.value;
}

const projectedTrafficLost = auditData?.auditResult?.brokenBacklinks?.reduce((sum, backlink) => {
const { traffic_domain: referringTraffic, urlsSuggested } = backlink;
const trafficBand = getTrafficBand(referringTraffic);
const targetUrl = urlsSuggested?.[0];
const targetTrafficData = rumTrafficData.find((data) => data.url === targetUrl);
const proposedTargetTraffic = targetTrafficData?.earned ?? 0;
return sum + (proposedTargetTraffic * trafficBand);
}, 0);

const projectedTrafficValue = projectedTrafficLost * CPC;

return {
projectedTrafficLost,
projectedTrafficValue,
};
};

export default calculateKpiMetrics;
6 changes: 4 additions & 2 deletions src/backlinks/opportunity-data-mapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/

export function createOpportunityData() {
export function createOpportunityData(kpiMetrics) {
return {
runbook: 'https://adobe.sharepoint.com/:w:/r/sites/aemsites-engineering/_layouts/15/doc2.aspx?sourcedoc=%7BAC174971-BA97-44A9-9560-90BE6C7CF789%7D&file=Experience_Success_Studio_Broken_Backlinks_Runbook.docx&action=default&mobileredirect=true',
origin: 'AUTOMATION',
Expand All @@ -26,6 +26,8 @@ export function createOpportunityData() {
],
},
tags: ['Traffic acquisition'],
data: null,
data: {
...kpiMetrics,
},
};
}
85 changes: 81 additions & 4 deletions test/audits/backlinks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import sinonChai from 'sinon-chai';
import nock from 'nock';
import { FirefallClient } from '@adobe/spacecat-shared-gpt-client';
import auditDataMock from '../fixtures/broken-backlinks/audit.json' with { type: 'json' };
import auditDataSuggestionsMock from '../fixtures/broken-backlinks/auditWithSuggestions.json' with { type: 'json' };
import rumTraffic from '../fixtures/broken-backlinks/all-traffic.json' with { type: 'json' };
import { brokenBacklinksAuditRunner, opportunityAndSuggestions, generateSuggestionData } from '../../src/backlinks/handler.js';
import { MockContextBuilder } from '../shared.js';
import {
Expand All @@ -39,6 +41,8 @@ import {
brokenBacklinksSuggestions,
suggestions,
} from '../fixtures/broken-backlinks/suggestion.js';
import { organicTraffic } from '../fixtures/broken-backlinks/organic-traffic.js';
import calculateKpiMetrics from '../../src/backlinks/kpi-metrics.js';

use(sinonChai);
use(chaiAsPromised);
Expand All @@ -65,6 +69,7 @@ describe('Backlinks Tests', function () {
AHREFS_API_BASE_URL: 'https://ahrefs.com',
AHREFS_API_KEY: 'ahrefs-api',
S3_SCRAPER_BUCKET_NAME: 'test-bucket',
S3_IMPORTER_BUCKET_NAME: 'test-import-bucket',
},
s3Client: {
send: sandbox.stub(),
Expand Down Expand Up @@ -124,6 +129,17 @@ describe('Backlinks Tests', function () {
});

it('should transform the audit result into an opportunity in the post processor and create a new opportunity', async () => {
context.s3Client.send.onCall(0).resolves({
Body: {
transformToString: sinon.stub().resolves(JSON.stringify(rumTraffic)),
},
});

context.s3Client.send.onCall(1).resolves({
Body: {
transformToString: sinon.stub().resolves(JSON.stringify(organicTraffic(site))),
},
});
context.dataAccess.Site.findById = sinon.stub().withArgs('site1').resolves(site);
context.dataAccess.Opportunity.create.resolves(brokenBacklinksOpportunity);
brokenBacklinksOpportunity.addSuggestions.resolves(brokenBacklinksSuggestions);
Expand All @@ -132,13 +148,18 @@ describe('Backlinks Tests', function () {

ahrefsMock(site.getBaseURL(), auditDataMock.auditResult);

await opportunityAndSuggestions(auditUrl, auditDataMock, context);
await opportunityAndSuggestions(auditUrl, auditDataSuggestionsMock, context, site);

const kpiDeltas = {
projectedTrafficLost: sinon.match.number,
projectedTrafficValue: sinon.match.number,
};

expect(context.dataAccess.Opportunity.create)
.to
.have
.been
.calledOnceWith(opportunityData(auditDataMock.siteId, auditDataMock.id));
.calledOnceWith(opportunityData(auditDataMock.siteId, auditDataMock.id, kpiDeltas));
expect(brokenBacklinksOpportunity.addSuggestions).to.have.been.calledOnceWith(suggestions);
});

Expand All @@ -152,7 +173,7 @@ describe('Backlinks Tests', function () {

ahrefsMock(site.getBaseURL(), auditDataMock.auditResult);

await opportunityAndSuggestions(auditUrl, auditDataMock, context);
await opportunityAndSuggestions(auditUrl, auditDataSuggestionsMock, context, site);

expect(context.dataAccess.Opportunity.create).to.not.have.been.called;
expect(brokenBacklinksOpportunity.setAuditId).to.have.been.calledOnceWith(auditDataMock.id);
Expand All @@ -177,7 +198,7 @@ describe('Backlinks Tests', function () {
.reply(200, auditDataMock.auditResult);

try {
await opportunityAndSuggestions(auditUrl, auditDataMock, context);
await opportunityAndSuggestions(auditUrl, auditDataSuggestionsMock, context, site);
} catch (e) {
expect(e.message).to.equal(errorMessage);
}
Expand Down Expand Up @@ -446,4 +467,60 @@ describe('Backlinks Tests', function () {
expect(context.log.error).to.have.been.calledWith('Batch processing error: Firefall error');
});
});

describe('calculateKpiMetrics', () => {
const auditData = {
auditResult: {
brokenBacklinks: [
{ traffic_domain: 25000001, urlsSuggested: ['https://foo.com/bar/redirect'] },
{ traffic_domain: 10000001, urlsSuggested: ['https://foo.com/bar/baz/redirect'] },
{ traffic_domain: 10001, urlsSuggested: ['https://foo.com/qux/redirect'] },
{ traffic_domain: 100, urlsSuggested: ['https://foo.com/bar/baz/qux/redirect'] },
],
},
};

it('should calculate metrics correctly for a single broken backlink', async () => {
context.s3Client.send.onCall(0).resolves({
Body: {
transformToString: sinon.stub().resolves(JSON.stringify(rumTraffic)),
},
});

context.s3Client.send.onCall(1).resolves({
Body: {
transformToString: sinon.stub().resolves(JSON.stringify(organicTraffic(site))),
},
});

const result = await calculateKpiMetrics(auditData, context, site);
expect(result.projectedTrafficLost).to.equal(26788.645);
expect(result.projectedTrafficValue).to.equal(534287.974025892);
});

it('skips URL if no RUM data is available for just individual URLs', async () => {
delete rumTraffic[0].earned;
context.s3Client.send.onCall(0).resolves({
Body: {
transformToString: sinon.stub().resolves(JSON.stringify(rumTraffic)),
},
});

context.s3Client.send.onCall(1).resolves({
Body: {
transformToString: sinon.stub().resolves(JSON.stringify(organicTraffic(site))),
},
});

const result = await calculateKpiMetrics(auditData, context, site);
expect(result.projectedTrafficLost).to.equal(14545.045000000002);
});

it('returns early if there is no RUM traffic data', async () => {
context.s3Client.send.onCall(0).resolves(null);

const result = await calculateKpiMetrics(auditData, context, site);
expect(result).to.be.null;
});
});
});
44 changes: 44 additions & 0 deletions test/fixtures/broken-backlinks/all-traffic.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
[
{
"url": "https://foo.com/bar/redirect",
"total": 3864740,
"paid": 5640,
"earned": 408120,
"owned": 3450980
},
{
"url": "https://foo.com/bar/baz/redirect",
"total": 419400,
"paid": 226500,
"earned": 565477,
"owned": 192900
},
{
"url": "https://foo.com/baz/bar/redirect",
"total": 3864740,
"paid": 5640,
"earned": 408120,
"owned": 3450980
},
{
"url": "https://foo.com/qux/redirect",
"total": 419400,
"paid": 226500,
"earned": 565477,
"owned": 192900
},
{
"url": "https://foo.com/bar/qux/redirect",
"total": 3864740,
"paid": 5640,
"earned": 408120,
"owned": 3450980
},
{
"url": "https://foo.com/bar/baz/qux/redirect",
"total": 3864740,
"paid": 5640,
"earned": 408120,
"owned": 3450980
}
]
39 changes: 39 additions & 0 deletions test/fixtures/broken-backlinks/auditWithSuggestions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"siteId": "site-id",
"id": "audit-id",
"auditResult": {
"brokenBacklinks": [
{
"title": "backlink that redirects to www and throw connection error",
"url_from": "https://from.com/from-2",
"url_to": "https://foo.com/redirects-throws-error",
"urlsSuggested": ["https://foo.com/redirects-throws-error-1", "https://foo.com/redirects-throws-error-2"],
"aiRationale": "The suggested URLs are similar to the original URL and are likely to be the correct destination.",
"traffic_domain": 550000
},
{
"title": "backlink that returns 429",
"url_from": "https://from.com/from-3",
"url_to": "https://foo.com/returns-429",
"urlsSuggested": ["https://foo.com/returns-429-suggestion-1", "https://foo.com/returns-429-suggestion-2"],
"aiRationale": "The suggested URLs are similar to the original URL and are likely to be the correct destination.",
"traffic_domain": 11000
},
{
"title": "backlink that is not excluded",
"url_from": "https://from.com/from-not-excluded",
"url_to": "https://foo.com/not-excluded",
"urlsSuggested": ["https://foo.com/not-excluded-suggestion-1", "https://foo.com/not-excluded-suggestion-2"],
"aiRationale": "The suggested URLs are similar to the original URL and are likely to be the correct destination.",
"traffic_domain": 5500
},
{
"title": "backlink that returns 404",
"url_from": "https://from.com/from-1",
"url_to": "https://foo.com/returns-404",
"traffic_domain": 1100000
}
]
},
"fullAuditRef": "https://ahrefs.com/site-explorer/broken-backlinks?select=title%2Curl_from%2Curl_to%2Ctraffic_domain&limit=50&mode=prefix&order_by=domain_rating_source%3Adesc%2Ctraffic_domain%3Adesc&target=https%3A%2F%2Faudit.url&output=json&where=%7B%22and%22%3A%5B%7B%22field%22%3A%22domain_rating_source%22%2C%22is%22%3A%5B%22gte%22%2C29.5%5D%7D%2C%7B%22field%22%3A%22traffic_domain%22%2C%22is%22%3A%5B%22gte%22%2C500%5D%7D%2C%7B%22field%22%3A%22links_external%22%2C%22is%22%3A%5B%22lte%22%2C300%5D%7D%5D%7D"
}
6 changes: 4 additions & 2 deletions test/fixtures/broken-backlinks/opportunity.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const otherOpportunity = {
getType: () => 'other',
};

export const opportunityData = (siteId, auditId) => ({
export const opportunityData = (siteId, auditId, kpiDeltas) => ({
siteId,
auditId,
runbook: 'https://adobe.sharepoint.com/:w:/r/sites/aemsites-engineering/_layouts/15/doc2.aspx?sourcedoc=%7BAC174971-BA97-44A9-9560-90BE6C7CF789%7D&file=Experience_Success_Studio_Broken_Backlinks_Runbook.docx&action=default&mobileredirect=true',
Expand All @@ -44,5 +44,7 @@ export const opportunityData = (siteId, auditId) => ({
],
},
tags: ['Traffic acquisition'],
data: null,
data: {
...kpiDeltas,
},
});
Loading