Skip to content

Commit aad4f4c

Browse files
feat: select least loaded WPT location on new test run (DELO-4766)
1 parent 5359fd7 commit aad4f4c

File tree

5 files changed

+137
-8
lines changed

5 files changed

+137
-8
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
},
1010
"dependencies": {
1111
"async": "^3.2.6",
12+
"async-mutex": "^0.5.0",
1213
"body-parser": "~1.20.3",
1314
"bytes": "^3.1.2",
1415
"cloudinary": "1.41.3",

routes/wpt.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
const express = require('express');
33
const validUrl = require('valid-url');
44
const apiCaller = require('../wtp/apiCaller');
5+
const locationSelector = require("../wtp/locationSelector");
56
const logger = require('../logger').logger;
67
const {LOG_LEVEL_INFO, LOG_LEVEL_WARNING, LOG_LEVEL_ERROR, LOG_LEVEL_CRITICAL, LOG_LEVEL_DEBUG} = require('../logger');
78
const path = require('path');
@@ -81,7 +82,19 @@ const wtp = (app) => {
8182
app.get('/version', (req, res) => {
8283
const packageJson = require('../package.json');
8384
res.json({version: packageJson.version});
85+
});
86+
87+
app.get('/locations', async (req, res) => {
88+
let locations = locationSelector.cachedAllLocations;
89+
res.json({locations});
8490
})
91+
92+
app.get('/locations/current', async (req, res) => {
93+
let location = locationSelector.location;
94+
let lastUpdated = new Date(locationSelector.lastUpdated || 0).toISOString();
95+
res.json({location, lastUpdated});
96+
})
97+
8598
};
8699

87100
module.exports = wtp;

wtp/apiCaller.js

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
"use strict";
66

7-
87
const path = require('path');
98
const got = (...args) => import('got').then(({default: got}) => got(...args));
109
const config = require('config');
@@ -16,7 +15,8 @@ const {truncateString} = require('../util/strings');
1615
const RESULTS_URL = 'https://www.webpagetest.org/jsonResult.php';
1716
const RUN_TEST_URL = 'http://www.webpagetest.org/runtest.php';
1817
const GET_TEST_STATUS = 'http://www.webpagetest.org/testStatus.php';
19-
18+
const locationSelector = require('./locationSelector');
19+
const apiKeys = require('./apiKey');
2020

2121
const getTestResults = async (testId, quality, cb) => {
2222
let options = {
@@ -58,11 +58,8 @@ const getTestResults = async (testId, quality, cb) => {
5858
}
5959
};
6060

61-
6261
const runWtpTest = async (url, mobile, cb) => {
6362
//logger.info('Running new test ' + url);
64-
const apiKeys = config.get('wtp.apiKey').split(',');
65-
const apiKey = apiKeys[Math.floor(Math.random() * apiKeys.length)];
6663
let options = {
6764
method: "POST",
6865
url: RUN_TEST_URL,
@@ -72,12 +69,12 @@ const runWtpTest = async (url, mobile, cb) => {
7269
width: config.get('wtp.viewportWidth'),
7370
height: config.get('wtp.viewportHeight'),
7471
custom: config.get('wtp.imageScript'),
75-
location: 'Dulles:Chrome.Native', // Native means no speed shaping in browser, full speed ahead
76-
mobile: (mobile) ? 1 : 0,
72+
location: await locationSelector.getLocation() + ':Chrome.Native', // Native means no speed shaping in browser, full speed ahead
73+
mobile: (mobile) ? 1 : 0,
7774
fvonly: 1, // first view only
7875
timeline: 1 // workaround for WPT sometimes hanging on getComputedStyle()
7976
},
80-
headers: { 'User-Agent': 'WebSpeedTest', 'X-WPT-API-KEY': apiKey },
77+
headers: { 'User-Agent': 'WebSpeedTest', 'X-WPT-API-KEY': apiKeys.get() },
8178
throwHttpErrors: false
8279
};
8380
let response;

wtp/apiKey.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"use strict";
2+
3+
const config = require('config');
4+
5+
function get() {
6+
const apiKeys = config.get('wtp.apiKey').split(',');
7+
const apiKey = apiKeys[Math.floor(Math.random() * apiKeys.length)];
8+
return apiKey;
9+
}
10+
11+
module.exports = {
12+
get
13+
};

wtp/locationSelector.js

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
const got = (...args) => import('got').then(({default: got}) => got(...args));
2+
const {Mutex} = require('async-mutex');
3+
const apiKeys = require('./apiKey');
4+
const path = require("path");
5+
const logger = require('../logger').logger;
6+
7+
const GET_LOCATIONS = 'http://www.webpagetest.org/getLocations.php?f=json';
8+
9+
class LocationSelector {
10+
CACHE_TTL = 10;
11+
DEFAULT_LOCATION = 'IAD_US_01';
12+
13+
constructor() {
14+
if (!LocationSelector.instance) {
15+
this.cachedAllLocations = [];
16+
this.location = this.DEFAULT_LOCATION;
17+
this.lastUpdated = null;
18+
this.mutex = new Mutex();
19+
LocationSelector.instance = this;
20+
}
21+
return LocationSelector.instance;
22+
}
23+
24+
isExpired() {
25+
const now = Date.now();
26+
return (!this.lastUpdated || (now - this.lastUpdated) > this.CACHE_TTL * 1000);
27+
}
28+
29+
async fetchLocations() {
30+
let options = {
31+
method: "GET",
32+
url: GET_LOCATIONS,
33+
headers: {'User-Agent': 'WebSpeedTest', 'X-WPT-API-KEY': apiKeys.get()},
34+
};
35+
let response;
36+
let rollBarMsg = {};
37+
try {
38+
response = await got(options);
39+
const {statusCode, body} = response;
40+
let bodyJson = JSON.parse(body);
41+
rollBarMsg = {thirdPartyErrorCode: response.statusCode, file: path.basename((__filename))};
42+
if (statusCode !== 200) {
43+
rollBarMsg.thirdPartyErrorBody = bodyJson;
44+
logger.error('WPT returned bad status', rollBarMsg);
45+
return;
46+
}
47+
return bodyJson.data;
48+
} catch (error) {
49+
logger.critical('Error fetching WTP locations', JSON.stringify(error, Object.getOwnPropertyNames(error)));
50+
}
51+
};
52+
53+
getLocationScore(location) {
54+
let metrics = location.PendingTests;
55+
56+
// Idle + Testing ==> capacity
57+
if (metrics.Idle + metrics.Testing == 0) {
58+
return 1; // no instances running, hopefully they will be spin up for our request?
59+
}
60+
61+
return (metrics.HighPriority + metrics.Testing) / (metrics.Idle + metrics.Testing)
62+
}
63+
64+
getBestLocationId(locations) {
65+
let selected = locations.reduce((acc, cur) => acc && this.getLocationScore(acc) < this.getLocationScore(cur) ? acc : cur);
66+
return selected.location;
67+
}
68+
69+
async updateLocations() {
70+
const newLocations = await this.fetchLocations();
71+
if (!newLocations) {
72+
return
73+
}
74+
75+
const filtered = Object.keys(newLocations)
76+
.filter(key => key.includes("_US_")) // we only want US-based instances
77+
.reduce((arr, key) => {
78+
return [...arr, newLocations[key]];
79+
}, []);
80+
81+
if (filtered.length === 0) {
82+
return
83+
}
84+
85+
this.location = this.getBestLocationId(filtered);
86+
this.cachedAllLocations = filtered;
87+
this.lastUpdated = Date.now();
88+
};
89+
90+
async getLocation() {
91+
if (this.isExpired()) {
92+
await this.mutex.runExclusive(async () => {
93+
if (this.isExpired()) {
94+
await this.updateLocations();
95+
}
96+
});
97+
}
98+
99+
return this.location;
100+
}
101+
}
102+
103+
const instance = new LocationSelector();
104+
105+
module.exports = instance;

0 commit comments

Comments
 (0)