Refactor duplicate code for calling into media controls
[WebKit-https.git] / Websites / perf.webkit.org / tools / detect-changes.js
1 #!/usr/local/bin/node
2
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/shared/statistics.js');
9 var StatisticsStrategies = require('../public/v2/statistics-strategies.js');
10 var parseArguments = require('./js/parse-arguments.js').parseArguments;
11
12 // FIXME: We shouldn't use a global variable like this.
13 var settings;
14 function main(argv)
15 {
16     var options = parseArguments(argv, [
17         {name: '--server-config-json', required: true},
18         {name: '--change-detection-config-json', required: true},
19         {name: '--seconds-to-sleep', type: parseFloat, default: 1200},
20     ]);
21     if (!options)
22         return;
23
24     settings = JSON.parse(fs.readFileSync(options['--change-detection-config-json'], 'utf8'));
25     settings.secondsToSleep = options['--seconds-to-sleep'];
26
27     fetchManifestAndAnalyzeData(options['--server-config-json']);
28 }
29
30 function fetchManifestAndAnalyzeData(serverConfigJSON)
31 {
32     loadServerConfig(serverConfigJSON);
33
34     getJSON(settings.perfserver, '/data/manifest.json').then(function (manifest) {
35         return mapInOrder(configurationsForTesting(manifest), analyzeConfiguration);
36     }).catch(function (reason) {
37         console.error('Failed to obtain the manifest file', reason);
38     }).then(function () {
39         console.log('');
40         console.log('Sleeing for', settings.secondsToSleep, 'seconds');
41         setTimeout(function () {
42             fetchManifestAndAnalyzeData(serverConfigJSON);
43         }, settings.secondsToSleep * 1000);
44     });
45 }
46
47 function loadServerConfig(serverConfigJSON)
48 {
49     var serverConfig = JSON.parse(fs.readFileSync(serverConfigJSON, 'utf8'));
50
51     var server = serverConfig['server'];
52     if ('auth' in server)
53         server['auth'] = server['auth']['username'] + ':' + server['auth']['password'];
54
55     settings.perfserver = server;
56     settings.slave = serverConfig['slave'];
57 }
58
59 function mapInOrder(array, callback, startIndex)
60 {
61     if (startIndex === undefined)
62         startIndex = 0;
63     if (startIndex >= array.length)
64         return;
65
66     var next = function () { return mapInOrder(array, callback, startIndex + 1); };
67     var returnValue = callback(array[startIndex]);
68     if (typeof(returnValue) === 'object' && returnValue instanceof Promise)
69         return returnValue.then(next).catch(next);
70     return next();
71 }
72
73 function configurationsForTesting(manifest)
74 {
75     var configurations = [];
76     for (var name in manifest.dashboards) {
77         var dashboard = manifest.dashboards[name];
78         for (var row of dashboard) {
79             for (var cell of row) {
80                 if (cell instanceof Array) {
81                     var platformId = parseInt(cell[0]);
82                     var metricId = parseInt(cell[1]);
83                     if (!isNaN(platformId) && !isNaN(metricId))
84                         configurations.push({platformId: platformId, metricId: metricId});
85                 }
86             }
87         }
88     }
89
90     var platforms = manifest.all;
91     for (var config of configurations) {
92         var metric = manifest.metrics[config.metricId];
93
94         var testPath = [];
95         var id = metric.test;
96         while (id) {
97             var test = manifest.tests[id];
98             testPath.push(test.name);
99             id = test.parentId;
100         }
101
102         config.unit = RunsData.unitFromMetricName(metric.name);
103         config.smallerIsBetter = RunsData.isSmallerBetter(config.unit);
104         config.platformName = platforms[config.platformId].name;
105         config.testName = testPath.reverse().join(' > ');
106         config.fullTestName = config.testName + ':' + metric.name;
107         config.repositories = manifest.repositories;
108         if (metric.aggregator)
109             config.fullTestName += ':' + metric.aggregator;
110     }
111
112     return configurations;
113 }
114
115 function analyzeConfiguration(config)
116 {
117     var minTime = Date.now() - settings.maxDays * 24 * 3600 * 1000;
118
119     console.log('');
120     console.log('== Analyzing the last', settings.maxDays, 'days:', config.fullTestName, 'on', config.platformName, '==');
121
122     return computeRangesForTesting(settings.perfserver, settings.strategies, config.platformId, config.metricId).then(function (ranges) {
123         var filteredRanges = ranges.filter(function (range) { return range.endTime >= minTime && !range.overlappingAnalysisTasks.length; })
124             .sort(function (a, b) { return a.endTime - b.endTime });
125
126         var summary;
127         var range;
128         for (range of filteredRanges) {
129             var summary = summarizeRange(config, range);
130             console.log('Detected:', summary);
131         }
132
133         if (!range) {
134             console.log('Nothing to analyze');
135             return;
136         }
137
138         return createAnalysisTaskAndNotify(config, range, summary);
139     });
140 }
141
142 function computeRangesForTesting(server, strategies, platformId, metricId)
143 {
144     // FIXME: Store the segmentation strategy on the server side.
145     // FIXME: Configure each strategy.
146     var segmentationStrategy = findStrategyByLabel(StatisticsStrategies.MovingAverageStrategies, strategies.segmentation.label);
147     if (!segmentationStrategy) {
148         console.error('Failed to find the segmentation strategy: ' + strategies.segmentation.label);
149         return;
150     }
151
152     var testRangeStrategy = findStrategyByLabel(StatisticsStrategies.TestRangeSelectionStrategies, strategies.testRange.label);
153     if (!testRangeStrategy) {
154         console.error('Failed to find the test range selection strategy: ' + strategies.testRange.label);
155         return;
156     }
157
158     var currentPromise = getJSON(server, RunsData.pathForFetchingRuns(platformId, metricId)).then(function (response) {
159         if (response.status != 'OK')
160             throw response;
161         return RunsData.createRunsDataInResponse(response).configurations.current;
162     }, function (reason) {
163         console.error('Failed to fetch the measurements:', reason);
164     });
165
166     var analysisTasksPromise = getJSON(server, '/api/analysis-tasks?platform=' + platformId + '&metric=' + metricId).then(function (response) {
167         if (response.status != 'OK')
168             throw response;
169         return response.analysisTasks.filter(function (task) { return task.startRun && task.endRun; });
170     }, function (reason) {
171         console.error('Failed to fetch the analysis tasks:', reason);
172     });
173
174     return Promise.all([currentPromise, analysisTasksPromise]).then(function (results) {
175         var currentTimeSeries = results[0].timeSeriesByCommitTime();
176         var analysisTasks = results[1];
177         var rawValues = currentTimeSeries.rawValues();
178         var segmentedValues = StatisticsStrategies.executeStrategy(segmentationStrategy, rawValues);
179
180         var ranges = StatisticsStrategies.executeStrategy(testRangeStrategy, rawValues, [segmentedValues]).map(function (range) {
181             var startPoint = currentTimeSeries.findPointByIndex(range[0]);
182             var endPoint = currentTimeSeries.findPointByIndex(range[1]);
183             return {
184                 startIndex: range[0],
185                 endIndex: range[1],
186                 overlappingAnalysisTasks: [],
187                 startTime: startPoint.time,
188                 endTime: endPoint.time,
189                 relativeChangeInSegmentedValues: (segmentedValues[range[1]] - segmentedValues[range[0]]) / segmentedValues[range[0]],
190                 startMeasurement: startPoint.measurement,
191                 endMeasurement: endPoint.measurement,
192             };
193         });
194
195         for (var task of analysisTasks) {
196             var taskStartPoint = currentTimeSeries.findPointByMeasurementId(task.startRun);
197             var taskEndPoint = currentTimeSeries.findPointByMeasurementId(task.endRun);
198             for (var range of ranges) {
199                 var disjoint = range.endIndex < taskStartPoint.seriesIndex
200                     || taskEndPoint.seriesIndex < range.startIndex;
201                 if (!disjoint)
202                     range.overlappingAnalysisTasks.push(task);
203             }
204         }
205
206         return ranges;
207     });
208 }
209
210 function createAnalysisTaskAndNotify(config, range, summary)
211 {
212     var segmentationStrategy = settings.strategies.segmentation.label;
213     var testRangeStrategy = settings.strategies.testRange.label;
214
215     var analysisTaskData = {
216         name: summary,
217         startRun: range.startMeasurement.id(),
218         endRun: range.endMeasurement.id(),
219         segmentationStrategy: segmentationStrategy,
220         testRangeStrategy: testRangeStrategy,
221
222         slaveName: settings.slave.name,
223         slavePassword: settings.slave.password,
224     };
225
226     return postJSON(settings.perfserver, '/privileged-api/create-analysis-task', analysisTaskData).then(function (response) {
227         if (response['status'] != 'OK')
228             throw response;
229
230         var analysisTaskId = response['taskId'];
231
232         var title = '[' + config.testName + '][' + config.platformName + '] ' + summary;
233         var analysisTaskURL = settings.perfserver.scheme + '://' + settings.perfserver.host + '/v2/#/analysis/task/' + analysisTaskId;
234         var changeType = changeTypeForRange(config, range);
235         // FIXME: Templatize this.
236         var message = '<b>' + settings.notification.serviceName + '</b> detected a potential ' + changeType + ':<br><br>'
237             + '<table border=1><caption>' + summary + '</caption><tbody>'
238             + '<tr><th>Test</th><td>' + config.fullTestName + '</td></tr>'
239             + '<tr><th>Platform</th><td>' + config.platformName + '</td></tr>'
240             + '<tr><th>Algorithm</th><td>' + segmentationStrategy + '<br>' + testRangeStrategy + '</td></tr>'
241             + '</table><br>'
242             + '<a href="' + analysisTaskURL + '">Open the analysis task</a>';
243
244         return getJSON(settings.perfserver, '/api/triggerables?task=' + analysisTaskId).then(function (response) {
245             var status = response['status'];
246             var triggerables = response['triggerables'] || [];
247             if (status == 'TriggerableNotFoundForTask' || triggerables.length != 1) {
248                 message += ' (A/B testing was not available)';
249                 return;
250             }
251             if (status != 'OK')
252                 throw response;
253
254             var triggerable = response['triggerables'][0];
255             var commitSets = {};
256             for (var repositoryId of triggerable['acceptedRepositories']) {
257                 var startRevision = range.startMeasurement.revisionForRepository(repositoryId);
258                 var endRevision = range.endMeasurement.revisionForRepository(repositoryId);
259                 if (startRevision == null || endRevision == null)
260                     continue;
261                 commitSets[config.repositories[repositoryId].name] = [startRevision, endRevision];
262             }
263
264             var testData = {
265                 task: analysisTaskId,
266                 name: 'Confirming the ' + changeType,
267                 commitSets: commitSets,
268                 repetitionCount: Math.max(2, Math.min(8, Math.floor((range.endIndex - range.startIndex) / 4))),
269
270                 slaveName: settings.slave.name,
271                 slavePassword: settings.slave.password,
272             };
273
274             return postJSON(settings.perfserver, '/privileged-api/create-test-group', testData).then(function (response) {
275                 if (response['status'] != 'OK')
276                     throw response;
277                 message += ' (triggered an A/B testing)';
278             });
279         }).catch(function (reason) {
280             console.error(reason);
281             message += ' (failed to create a new A/B testing)';
282         }).then(function () {
283             return postNotification(settings.notification.server, settings.notification.template, title, message).then(function () {
284                 console.log('  Sent a notification');
285             }, function (reason) {
286                 console.error('  Failed to send a notification', reason);
287             });
288         });
289     }).catch(function (reason) {
290         console.error('  Failed to create an analysis task', reason);
291     });
292 }
293
294 function findStrategyByLabel(list, label)
295 {
296     for (var strategy of list) {
297         if (strategy.label == label)
298             return strategy;
299     }
300     return null;
301 }
302
303 function changeTypeForRange(config, range)
304 {
305     var endValueIsLarger = range.relativeChangeInSegmentedValues > 0;
306     return endValueIsLarger == config.smallerIsBetter ? 'regression' : 'progression';
307 }
308
309 function summarizeRange(config, range)
310 {
311     return 'Potential ' + Math.abs(range.relativeChangeInSegmentedValues * 100).toPrecision(2) + '% '
312         + changeTypeForRange(config, range) + ' between ' + formatTimeRange(range.startTime, range.endTime);
313 }
314
315 function formatTimeRange(start, end)
316 {
317     var formatter = function (date) { return date.toISOString().replace('T', ' ').replace(/:\d{2}\.\d+Z$/, ''); }
318     var formattedStart = formatter(start);
319     var formattedEnd = formatter(end);
320     if (start.toDateString() == end.toDateString())
321         return formattedStart + ' and ' + formattedEnd.substring(formattedEnd.indexOf(' ') + 1);
322     if (start.getFullYear() == end.getFullYear())
323         return formattedStart + ' and ' + formattedEnd.substring(5);
324     return formattedStart + ' and ' + formattedEnd;
325 }
326
327 function getJSON(server, path, data)
328 {
329     return fetchJSON(server.scheme, {
330         'hostname': server.host,
331         'port': server.port,
332         'auth': server.auth,
333         'path': path,
334         'method': 'GET',
335     }, 'application/json');
336 }
337
338 function postJSON(server, path, data)
339 {
340     return fetchJSON(server.scheme, {
341         'hostname': server.host,
342         'port': server.port,
343         'auth': server.auth,
344         'path': path,
345         'method': 'POST',
346     }, 'application/json', JSON.stringify(data));
347 }
348
349 function postNotification(server, template, title, message)
350 {
351     var notification = instantiateNotificationTemplate(template, title, message);
352     return fetchJSON(server.scheme, {
353         'hostname': server.host,
354         'port': server.port,
355         'auth': server.auth,
356         'path': server.path,
357         'method': server.method,
358     }, 'application/json', JSON.stringify(notification));
359 }
360
361 function instantiateNotificationTemplate(template, title, message)
362 {
363     var instance = {};
364     for (var name in template) {
365         var value = template[name];
366         if (typeof(value) === 'string')
367             instance[name] = value.replace(/\$title/g, title).replace(/\$message/g, message);
368         else if (typeof(template[name]) === 'object')
369             instance[name] = instantiateNotificationTemplate(value, title, message);
370         else
371             instance[name] = value;
372     }
373     return instance;
374 }
375
376 function fetchJSON(schemeName, options, contentType, content) {
377     var requester = schemeName == 'https' ? https : http;
378     return new Promise(function (resolve, reject) {
379         var request = requester.request(options, function (response) {
380             var responseText = '';
381             response.setEncoding('utf8');
382             response.on('data', function (chunk) { responseText += chunk; });
383             response.on('end', function () {
384                 try {
385                     var json = JSON.parse(responseText);
386                 } catch (error) {
387                     reject({error: error, responseText: responseText});
388                 }
389                 resolve(json);
390             });
391         });
392         request.on('error', function (error) { reject(error); });
393         if (contentType)
394             request.setHeader('Content-Type', contentType);
395         if (content)
396             request.write(content);
397         request.end();
398     });
399 }
400
401 main(process.argv);