Skip to content
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

feat: load balance test location in WPT (DELO-4766) #95

Merged
Merged
2 changes: 1 addition & 1 deletion app.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ wpt(app);

// catch 404 and forward to error handler
app.all('*', function (req, res) {
res.send('what???', 404);
res.status(404).send('what???');
});

app.use(function (req, res, next) {
Expand Down
7 changes: 6 additions & 1 deletion config/default.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,12 @@ const conf = {
"lcp": process.env.LCP_PATH || "data.median.firstView.largestPaints",
"lcpURL": process.env.LCP_URL_PATH || "data.median.firstView.LargestContentfulPaintImageURL"
},
timeout: process.env.WTP_TIMEOUT || 30000
timeout: process.env.WTP_TIMEOUT || 30000,
"locationSelector": {
"cacheTtl": process.env.WTP_LS_CACHE_TTL || 10,
"updateTimeout": process.env.WTP_LS_UPDATE_TIMEOUT || 20,
"defaultLocation": process.env.WTP_LS_DEFAULT_LOCATION || "IAD_US_01"
}
},
"cloudinary": {
"cloudName": process.env.CLOUDINARY_NAME,
Expand Down
13 changes: 7 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,16 @@
},
"dependencies": {
"async": "^3.2.6",
"async-mutex": "^0.5.0",
"body-parser": "~1.20.3",
"bytes": "^3.1.2",
"cloudinary": "1.41.3",
"config": "^3.3.12",
"cookie-parser": "~1.4.7",
"debug": "~4.3.7",
"dotenv": "^16.4.5",
"express": "~4.21.1",
"got": "^14.4.3",
"debug": "~4.4.0",
"dotenv": "^16.4.7",
"express": "~4.21.2",
"got": "^14.4.5",
"lodash": "^4.17.21",
"rollbar": "^2.26.4",
"valid-url": "^1.0.9"
Expand All @@ -26,8 +27,8 @@
"chai": "^4.5.0",
"chai-http": "^4.4.0",
"husky": "^8.0.3",
"mocha": "^10.7.3",
"nock": "^13.5.5",
"mocha": "^10.8.2",
"nock": "^13.5.6",
"patch-package": "^8.0.0",
"sinon": "^16.1.3",
"sinon-chai": "^3.7.0"
Expand Down
13 changes: 13 additions & 0 deletions routes/wpt.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
const express = require('express');
const validUrl = require('valid-url');
const apiCaller = require('../wtp/apiCaller');
const locationSelector = require("../wtp/locationSelector");
const logger = require('../logger').logger;
const {LOG_LEVEL_INFO, LOG_LEVEL_WARNING, LOG_LEVEL_ERROR, LOG_LEVEL_CRITICAL, LOG_LEVEL_DEBUG} = require('../logger');
const path = require('path');
Expand Down Expand Up @@ -81,7 +82,19 @@ const wtp = (app) => {
app.get('/version', (req, res) => {
const packageJson = require('../package.json');
res.json({version: packageJson.version});
});

app.get('/locations', async (req, res) => {
let locations = locationSelector.cachedAllLocations;
res.json({locations});
})

app.get('/locations/current', async (req, res) => {
let location = locationSelector.location;
let lastUpdated = new Date(locationSelector.lastUpdated || 0).toISOString();
res.json({location, lastUpdated});
})

};

module.exports = wtp;
13 changes: 5 additions & 8 deletions wtp/apiCaller.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

"use strict";


const path = require('path');
const got = (...args) => import('got').then(({default: got}) => got(...args));
const config = require('config');
Expand All @@ -16,7 +15,8 @@ const {truncateString} = require('../util/strings');
const RESULTS_URL = 'https://www.webpagetest.org/jsonResult.php';
const RUN_TEST_URL = 'http://www.webpagetest.org/runtest.php';
const GET_TEST_STATUS = 'http://www.webpagetest.org/testStatus.php';

const locationSelector = require('./locationSelector');
const apiKeys = require('./apiKey');

const getTestResults = async (testId, quality, cb) => {
let options = {
Expand Down Expand Up @@ -58,11 +58,8 @@ const getTestResults = async (testId, quality, cb) => {
}
};


const runWtpTest = async (url, mobile, cb) => {
//logger.info('Running new test ' + url);
const apiKeys = config.get('wtp.apiKey').split(',');
const apiKey = apiKeys[Math.floor(Math.random() * apiKeys.length)];
let options = {
method: "POST",
url: RUN_TEST_URL,
Expand All @@ -72,12 +69,12 @@ const runWtpTest = async (url, mobile, cb) => {
width: config.get('wtp.viewportWidth'),
height: config.get('wtp.viewportHeight'),
custom: config.get('wtp.imageScript'),
location: 'Dulles:Chrome.Native', // Native means no speed shaping in browser, full speed ahead
mobile: (mobile) ? 1 : 0,
location: await locationSelector.getLocation() + ':Chrome.Native', // Native means no speed shaping in browser, full speed ahead
mobile: (mobile) ? 1 : 0,
fvonly: 1, // first view only
timeline: 1 // workaround for WPT sometimes hanging on getComputedStyle()
},
headers: { 'User-Agent': 'WebSpeedTest', 'X-WPT-API-KEY': apiKey },
headers: { 'User-Agent': 'WebSpeedTest', 'X-WPT-API-KEY': apiKeys.getRandom() },
throwHttpErrors: false
};
let response;
Expand Down
13 changes: 13 additions & 0 deletions wtp/apiKey.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"use strict";

const config = require('config');

function getRandom() {
const apiKeys = config.get('wtp.apiKey').split(',');
const apiKey = apiKeys[Math.floor(Math.random() * apiKeys.length)];
return apiKey;
}

module.exports = {
getRandom: getRandom
};
136 changes: 136 additions & 0 deletions wtp/locationSelector.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
const got = (...args) => import('got').then(({default: got}) => got(...args));
const config = require('config');
const {Mutex, withTimeout, E_TIMEOUT} = require('async-mutex');
const apiKeys = require('./apiKey');
const path = require("path");
const logger = require('../logger').logger;

const GET_LOCATIONS = 'http://www.webpagetest.org/getLocations.php?f=json';

class LocationSelector {
constructor() {
if (!LocationSelector.instance) {
this.cachedAllLocations = [];
this.location = config.get('wtp.locationSelector.defaultLocation');
this.lastUpdated = null;
this.mutex = withTimeout(new Mutex(), config.get('wtp.locationSelector.updateTimeout') * 1000);
LocationSelector.instance = this;
}
return LocationSelector.instance;
}

isExpired() {
const now = Date.now();
return (!this.lastUpdated || (now - this.lastUpdated) > config.get('wtp.locationSelector.cacheTtl') * 1000);
}

async fetchLocations() {
let options = {
method: "GET",
url: GET_LOCATIONS,
headers: {'User-Agent': 'WebSpeedTest', 'X-WPT-API-KEY': apiKeys.getRandom()},
};
let response;
let rollBarMsg = {};
try {
response = await got(options);
const {statusCode, body} = response;
let bodyJson = JSON.parse(body);
rollBarMsg = {thirdPartyErrorCode: response.statusCode, file: path.basename((__filename))};
if (statusCode !== 200) {
rollBarMsg.thirdPartyErrorBody = bodyJson;
logger.error('WPT returned bad status', rollBarMsg);
return;
}
return bodyJson.data;
} catch (error) {
logger.critical('Error fetching WTP locations', JSON.stringify(error, Object.getOwnPropertyNames(error)));
}
};

getLocationScore(loc) {
// no instances running, hopefully they will be spin up for our request?
if (this.getLocationCapacity(loc) == 0) {
return 1;
}

let metrics = loc.PendingTests;
return (metrics.HighPriority + metrics.Testing) / (metrics.Idle + metrics.Testing)
}

getLocationCapacity(loc) {
return loc.PendingTests.Idle + loc.PendingTests.Testing;
}

getBestLocationId(locations) {
let selected = locations.reduce((acc, cur) => {
// if nothing to compare to, use current value
if (!acc) {
return cur;
}

// if acc less loaded
if (acc.score < cur.score) {
return acc;
}

// if cur less loaded
if (acc.score > cur.score) {
return cur;
}

// if same load on acc and cur
// then choose the one with bigger capacity (Idle + Testing)
return this.getLocationCapacity(acc) > this.getLocationCapacity(cur) ? acc : cur;
});

return selected.location;
}

async updateLocations() {
const newLocations = await this.fetchLocations();
if (!newLocations) {
return
}

const filtered = Object.keys(newLocations)
.filter(key => key.includes("_US_")) // we only want US-based instances
.reduce((arr, key) => {
return [...arr, newLocations[key]];
}, []);

if (filtered.length === 0) {
return
}

// enrich locations with our internal score
filtered.forEach((loc) => {
loc.score = this.getLocationScore(loc);
});

this.location = this.getBestLocationId(filtered);
this.cachedAllLocations = filtered;
this.lastUpdated = Date.now();
};

async getLocation() {
if (this.isExpired()) {
try {
await this.mutex.runExclusive(async () => {
if (this.isExpired()) {
await this.updateLocations();
}
});
} catch (e) {
if (e === E_TIMEOUT) {
logger.error('Locations update is taking too long', e);
}
}
}

return this.location;
}
}

const instance = new LocationSelector();
module.exports = instance;
Loading
Loading