3 var fs = require('fs');
4 var http = require('http');
5 var https = require('https');
6 var data = require('../public/v2/data.js');
7 var RunsData = data.RunsData;
8 var Statistics = require('../public/v2/js/statistics.js');
10 // FIXME: We shouldn't use a global variable like this.
14 var options = parseArgument(argv, [
15 {name: '--server-config-json', required: true},
16 {name: '--change-detection-config-json', required: true},
17 {name: '--seconds-to-sleep', type: parseFloat, default: 1200},
22 settings = JSON.parse(fs.readFileSync(options['--change-detection-config-json'], 'utf8'));
23 settings.secondsToSleep = options['--seconds-to-sleep'];
25 fetchManifestAndAnalyzeData(options['--server-config-json']);
28 function parseArgument(argv, acceptedOptions) {
29 var args = argv.slice(2);
31 for (var i = 0; i < args.length; i += 2) {
32 var current = args[i];
33 var next = args[i + 1];
34 for (var option of acceptedOptions) {
35 if (current == option['name']) {
36 options[option['name']] = next;
42 console.error('Invalid argument:', current);
46 for (var option of acceptedOptions) {
47 var name = option['name'];
48 if (option['required'] && !(name in options)) {
49 console.log('Required argument', name, 'is missing');
52 var value = options[name] || option['default'];
53 var converter = option['type'];
54 options[name] = converter ? converter(value) : value;
59 function fetchManifestAndAnalyzeData(serverConfigJSON)
61 loadServerConfig(serverConfigJSON);
63 getJSON(settings.perfserver, '/data/manifest.json').then(function (manifest) {
64 return mapInOrder(configurationsForTesting(manifest), analyzeConfiguration);
65 }).catch(function (reason) {
66 console.error('Failed to obtain the manifest file', reason);
69 console.log('Sleeing for', settings.secondsToSleep, 'seconds');
70 setTimeout(function () {
71 fetchManifestAndAnalyzeData(serverConfigJSON);
72 }, settings.secondsToSleep * 1000);
76 function loadServerConfig(serverConfigJSON)
78 var serverConfig = JSON.parse(fs.readFileSync(serverConfigJSON, 'utf8'));
80 var server = serverConfig['server'];
82 server['auth'] = server['auth']['username'] + ':' + server['auth']['password'];
84 settings.perfserver = server;
85 settings.slave = serverConfig['slave'];
88 function mapInOrder(array, callback, startIndex)
90 if (startIndex === undefined)
92 if (startIndex >= array.length)
95 var next = function () { return mapInOrder(array, callback, startIndex + 1); };
96 var returnValue = callback(array[startIndex]);
97 if (typeof(returnValue) === 'object' && returnValue instanceof Promise)
98 return returnValue.then(next).catch(next);
102 function configurationsForTesting(manifest)
104 var configurations = [];
105 for (var name in manifest.dashboards) {
106 var dashboard = manifest.dashboards[name];
107 for (var row of dashboard) {
108 for (var cell of row) {
109 if (cell instanceof Array) {
110 var platformId = parseInt(cell[0]);
111 var metricId = parseInt(cell[1]);
112 if (!isNaN(platformId) && !isNaN(metricId))
113 configurations.push({platformId: platformId, metricId: metricId});
119 var platforms = manifest.all;
120 for (var config of configurations) {
121 var metric = manifest.metrics[config.metricId];
124 var id = metric.test;
126 var test = manifest.tests[id];
127 testPath.push(test.name);
131 config.unit = RunsData.unitFromMetricName(metric.name);
132 config.smallerIsBetter = RunsData.isSmallerBetter(config.unit);
133 config.platformName = platforms[config.platformId].name;
134 config.testName = testPath.reverse().join(' > ');
135 config.fullTestName = config.testName + ':' + metric.name;
136 config.repositories = manifest.repositories;
137 if (metric.aggregator)
138 config.fullTestName += ':' + metric.aggregator;
141 return configurations;
144 function analyzeConfiguration(config)
146 var minTime = Date.now() - settings.maxDays * 24 * 3600 * 1000;
149 console.log('== Analyzing the last', settings.maxDays, 'days:', config.fullTestName, 'on', config.platformName, '==');
151 return computeRangesForTesting(settings.perfserver, settings.strategies, config.platformId, config.metricId).then(function (ranges) {
152 var filteredRanges = ranges.filter(function (range) { return range.endTime >= minTime && !range.overlappingAnalysisTasks.length; })
153 .sort(function (a, b) { return a.endTime - b.endTime });
157 for (range of filteredRanges) {
158 var summary = summarizeRange(config, range);
159 console.log('Detected:', summary);
163 console.log('Nothing to analyze');
167 return createAnalysisTaskAndNotify(config, range, summary);
171 function computeRangesForTesting(server, strategies, platformId, metricId)
173 // FIXME: Store the segmentation strategy on the server side.
174 // FIXME: Configure each strategy.
175 var segmentationStrategy = findStrategyByLabel(Statistics.MovingAverageStrategies, strategies.segmentation.label);
176 if (!segmentationStrategy) {
177 console.error('Failed to find the segmentation strategy: ' + strategies.segmentation.label);
181 var testRangeStrategy = findStrategyByLabel(Statistics.TestRangeSelectionStrategies, strategies.testRange.label);
182 if (!testRangeStrategy) {
183 console.error('Failed to find the test range selection strategy: ' + strategies.testRange.label);
187 var currentPromise = getJSON(server, RunsData.pathForFetchingRuns(platformId, metricId)).then(function (response) {
188 if (response.status != 'OK')
190 return RunsData.createRunsDataInResponse(response).configurations.current;
191 }, function (reason) {
192 console.error('Failed to fetch the measurements:', reason);
195 var analysisTasksPromise = getJSON(server, '/api/analysis-tasks?platform=' + platformId + '&metric=' + metricId).then(function (response) {
196 if (response.status != 'OK')
198 return response.analysisTasks.filter(function (task) { return task.startRun && task.endRun; });
199 }, function (reason) {
200 console.error('Failed to fetch the analysis tasks:', reason);
203 return Promise.all([currentPromise, analysisTasksPromise]).then(function (results) {
204 var currentTimeSeries = results[0].timeSeriesByCommitTime();
205 var analysisTasks = results[1];
206 var rawValues = currentTimeSeries.rawValues();
207 var segmentedValues = Statistics.executeStrategy(segmentationStrategy, rawValues);
209 var ranges = Statistics.executeStrategy(testRangeStrategy, rawValues, [segmentedValues]).map(function (range) {
210 var startPoint = currentTimeSeries.findPointByIndex(range[0]);
211 var endPoint = currentTimeSeries.findPointByIndex(range[1]);
213 startIndex: range[0],
215 overlappingAnalysisTasks: [],
216 startTime: startPoint.time,
217 endTime: endPoint.time,
218 relativeChangeInSegmentedValues: (segmentedValues[range[1]] - segmentedValues[range[0]]) / segmentedValues[range[0]],
219 startMeasurement: startPoint.measurement,
220 endMeasurement: endPoint.measurement,
224 for (var task of analysisTasks) {
225 var taskStartPoint = currentTimeSeries.findPointByMeasurementId(task.startRun);
226 var taskEndPoint = currentTimeSeries.findPointByMeasurementId(task.endRun);
227 for (var range of ranges) {
228 var disjoint = range.endIndex < taskStartPoint.seriesIndex
229 || taskEndPoint.seriesIndex < range.startIndex;
231 range.overlappingAnalysisTasks.push(task);
239 function createAnalysisTaskAndNotify(config, range, summary)
241 var segmentationStrategy = settings.strategies.segmentation.label;
242 var testRangeStrategy = settings.strategies.testRange.label;
244 var analysisTaskData = {
246 startRun: range.startMeasurement.id(),
247 endRun: range.endMeasurement.id(),
248 segmentationStrategy: segmentationStrategy,
249 testRangeStrategy: testRangeStrategy,
251 slaveName: settings.slave.name,
252 slavePassword: settings.slave.password,
255 return postJSON(settings.perfserver, '/privileged-api/create-analysis-task', analysisTaskData).then(function (response) {
256 if (response['status'] != 'OK')
259 var analysisTaskId = response['taskId'];
261 var title = '[' + config.testName + '][' + config.platformName + '] ' + summary;
262 var analysisTaskURL = settings.perfserver.scheme + '://' + settings.perfserver.host + '/v2/#/analysis/task/' + analysisTaskId;
263 var changeType = changeTypeForRange(config, range);
264 // FIXME: Templatize this.
265 var message = '<b>' + settings.notification.serviceName + '</b> detected a potential ' + changeType + ':<br><br>'
266 + '<table border=1><caption>' + summary + '</caption><tbody>'
267 + '<tr><th>Test</th><td>' + config.fullTestName + '</td></tr>'
268 + '<tr><th>Platform</th><td>' + config.platformName + '</td></tr>'
269 + '<tr><th>Algorithm</th><td>' + segmentationStrategy + '<br>' + testRangeStrategy + '</td></tr>'
271 + '<a href="' + analysisTaskURL + '">Open the analysis task</a>';
273 return getJSON(settings.perfserver, '/api/triggerables?task=' + analysisTaskId).then(function (response) {
274 var status = response['status'];
275 var triggerables = response['triggerables'] || [];
276 if (status == 'TriggerableNotFoundForTask' || triggerables.length != 1) {
277 message += ' (A/B testing was not available)';
283 var triggerable = response['triggerables'][0];
285 for (var repositoryId of triggerable['acceptedRepositories']) {
286 var startRevision = range.startMeasurement.revisionForRepository(repositoryId);
287 var endRevision = range.endMeasurement.revisionForRepository(repositoryId);
288 if (startRevision == null || endRevision == null)
290 rootSets[config.repositories[repositoryId].name] = [startRevision, endRevision];
294 task: analysisTaskId,
295 name: 'Confirming the ' + changeType,
297 repetitionCount: Math.max(2, Math.min(8, Math.floor((range.endIndex - range.startIndex) / 4))),
299 slaveName: settings.slave.name,
300 slavePassword: settings.slave.password,
303 return postJSON(settings.perfserver, '/privileged-api/create-test-group', testData).then(function (response) {
304 if (response['status'] != 'OK')
306 message += ' (triggered an A/B testing)';
308 }).catch(function (reason) {
309 console.error(reason);
310 message += ' (failed to create a new A/B testing)';
311 }).then(function () {
312 return postNotification(settings.notification.server, settings.notification.template, title, message).then(function () {
313 console.log(' Sent a notification');
314 }, function (reason) {
315 console.error(' Failed to send a notification', reason);
318 }).catch(function (reason) {
319 console.error(' Failed to create an analysis task', reason);
323 function findStrategyByLabel(list, label)
325 for (var strategy of list) {
326 if (strategy.label == label)
332 function changeTypeForRange(config, range)
334 var endValueIsLarger = range.relativeChangeInSegmentedValues > 0;
335 return endValueIsLarger == config.smallerIsBetter ? 'regression' : 'progression';
338 function summarizeRange(config, range)
340 return 'Potential ' + Math.abs(range.relativeChangeInSegmentedValues * 100).toPrecision(2) + '% '
341 + changeTypeForRange(config, range) + ' between ' + formatTimeRange(range.startTime, range.endTime);
344 function formatTimeRange(start, end)
346 var formatter = function (date) { return date.toISOString().replace('T', ' ').replace(/:\d{2}\.\d+Z$/, ''); }
347 var formattedStart = formatter(start);
348 var formattedEnd = formatter(end);
349 if (start.toDateString() == end.toDateString())
350 return formattedStart + ' and ' + formattedEnd.substring(formattedEnd.indexOf(' ') + 1);
351 if (start.getFullYear() == end.getFullYear())
352 return formattedStart + ' and ' + formattedEnd.substring(5);
353 return formattedStart + ' and ' + formattedEnd;
356 function getJSON(server, path, data)
358 return fetchJSON(server.scheme, {
359 'hostname': server.host,
364 }, 'application/json');
367 function postJSON(server, path, data)
369 return fetchJSON(server.scheme, {
370 'hostname': server.host,
375 }, 'application/json', JSON.stringify(data));
378 function postNotification(server, template, title, message)
380 var notification = instantiateNotificationTemplate(template, title, message);
381 return fetchJSON(server.scheme, {
382 'hostname': server.host,
386 'method': server.method,
387 }, 'application/json', JSON.stringify(notification));
390 function instantiateNotificationTemplate(template, title, message)
393 for (var name in template) {
394 var value = template[name];
395 if (typeof(value) === 'string')
396 instance[name] = value.replace(/\$title/g, title).replace(/\$message/g, message);
397 else if (typeof(template[name]) === 'object')
398 instance[name] = instantiateNotificationTemplate(value, title, message);
400 instance[name] = value;
405 function fetchJSON(schemeName, options, contentType, content) {
406 var requester = schemeName == 'https' ? https : http;
407 return new Promise(function (resolve, reject) {
408 var request = requester.request(options, function (response) {
409 var responseText = '';
410 response.setEncoding('utf8');
411 response.on('data', function (chunk) { responseText += chunk; });
412 response.on('end', function () {
414 var json = JSON.parse(responseText);
416 reject({error: error, responseText: responseText});
421 request.on('error', function (error) { reject(error); });
423 request.setHeader('Content-Type', contentType);
425 request.write(content);