build.webkit.org/dashboard: Move layoutTestResultsDirectoryURLForIteration implementa...
[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/v2/js/statistics.js');
9
10 // FIXME: We shouldn't use a global variable like this.
11 var settings;
12 function main(argv)
13 {
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},
18     ]);
19     if (!options)
20         return;
21
22     settings = JSON.parse(fs.readFileSync(options['--change-detection-config-json'], 'utf8'));
23     settings.secondsToSleep = options['--seconds-to-sleep'];
24
25     fetchManifestAndAnalyzeData(options['--server-config-json']);
26 }
27
28 function parseArgument(argv, acceptedOptions) {
29     var args = argv.slice(2);
30     var options = {}
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;
37                 next = null;
38                 break;
39             }
40         }
41         if (next) {
42             console.error('Invalid argument:', current);
43             return null;
44         }
45     }
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');
50             return null;
51         }
52         var value = options[name] || option['default'];
53         var converter = option['type'];
54         options[name] = converter ? converter(value) : value;
55     }
56     return options;
57 }
58
59 function fetchManifestAndAnalyzeData(serverConfigJSON)
60 {
61     loadServerConfig(serverConfigJSON);
62
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);
67     }).then(function () {
68         console.log('');
69         console.log('Sleeing for', settings.secondsToSleep, 'seconds');
70         setTimeout(function () {
71             fetchManifestAndAnalyzeData(serverConfigJSON);
72         }, settings.secondsToSleep * 1000);
73     });
74 }
75
76 function loadServerConfig(serverConfigJSON)
77 {
78     var serverConfig = JSON.parse(fs.readFileSync(serverConfigJSON, 'utf8'));
79
80     var server = serverConfig['server'];
81     if ('auth' in server)
82         server['auth'] = server['auth']['username'] + ':' + server['auth']['password'];
83
84     settings.perfserver = server;
85     settings.slave = serverConfig['slave'];
86 }
87
88 function mapInOrder(array, callback, startIndex)
89 {
90     if (startIndex === undefined)
91         startIndex = 0;
92     if (startIndex >= array.length)
93         return;
94
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);
99     return next();
100 }
101
102 function configurationsForTesting(manifest)
103 {
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});
114                 }
115             }
116         }
117     }
118
119     var platforms = manifest.all;
120     for (var config of configurations) {
121         var metric = manifest.metrics[config.metricId];
122
123         var testPath = [];
124         var id = metric.test;
125         while (id) {
126             var test = manifest.tests[id];
127             testPath.push(test.name);
128             id = test.parentId;
129         }
130
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;
139     }
140
141     return configurations;
142 }
143
144 function analyzeConfiguration(config)
145 {
146     var minTime = Date.now() - settings.maxDays * 24 * 3600 * 1000;
147
148     console.log('');
149     console.log('== Analyzing the last', settings.maxDays, 'days:', config.fullTestName, 'on', config.platformName, '==');
150
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 });
154
155         var summary;
156         var range;
157         for (range of filteredRanges) {
158             var summary = summarizeRange(config, range);
159             console.log('Detected:', summary);
160         }
161
162         if (!range) {
163             console.log('Nothing to analyze');
164             return;
165         }
166
167         return createAnalysisTaskAndNotify(config, range, summary);
168     });
169 }
170
171 function computeRangesForTesting(server, strategies, platformId, metricId)
172 {
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);
178         return;
179     }
180
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);
184         return;
185     }
186
187     var currentPromise = getJSON(server, RunsData.pathForFetchingRuns(platformId, metricId)).then(function (response) {
188         if (response.status != 'OK')
189             throw response;
190         return RunsData.createRunsDataInResponse(response).configurations.current;
191     }, function (reason) {
192         console.error('Failed to fetch the measurements:', reason);
193     });
194
195     var analysisTasksPromise = getJSON(server, '/api/analysis-tasks?platform=' + platformId + '&metric=' + metricId).then(function (response) {
196         if (response.status != 'OK')
197             throw response;
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);
201     });
202
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);
208
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]);
212             return {
213                 startIndex: range[0],
214                 endIndex: range[1],
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,
221             };
222         });
223
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;
230                 if (!disjoint)
231                     range.overlappingAnalysisTasks.push(task);
232             }
233         }
234
235         return ranges;
236     });
237 }
238
239 function createAnalysisTaskAndNotify(config, range, summary)
240 {
241     var segmentationStrategy = settings.strategies.segmentation.label;
242     var testRangeStrategy = settings.strategies.testRange.label;
243
244     var analysisTaskData = {
245         name: summary,
246         startRun: range.startMeasurement.id(),
247         endRun: range.endMeasurement.id(),
248         segmentationStrategy: segmentationStrategy,
249         testRangeStrategy: testRangeStrategy,
250
251         slaveName: settings.slave.name,
252         slavePassword: settings.slave.password,
253     };
254
255     return postJSON(settings.perfserver, '/privileged-api/create-analysis-task', analysisTaskData).then(function (response) {
256         if (response['status'] != 'OK')
257             throw response;
258
259         var analysisTaskId = response['taskId'];
260
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>'
270             + '</table><br>'
271             + '<a href="' + analysisTaskURL + '">Open the analysis task</a>';
272
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)';
278                 return;
279             }
280             if (status != 'OK')
281                 throw response;
282
283             var triggerable = response['triggerables'][0];
284             var rootSets = {};
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)
289                     continue;
290                 rootSets[config.repositories[repositoryId].name] = [startRevision, endRevision];
291             }
292
293             var testData = {
294                 task: analysisTaskId,
295                 name: 'Confirming the ' + changeType,
296                 rootSets: rootSets,
297                 repetitionCount: Math.max(2, Math.min(8, Math.floor((range.endIndex - range.startIndex) / 4))),
298
299                 slaveName: settings.slave.name,
300                 slavePassword: settings.slave.password,
301             };
302
303             return postJSON(settings.perfserver, '/privileged-api/create-test-group', testData).then(function (response) {
304                 if (response['status'] != 'OK')
305                     throw response;
306                 message += ' (triggered an A/B testing)';
307             });
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);
316             });
317         });
318     }).catch(function (reason) {
319         console.error('  Failed to create an analysis task', reason);
320     });
321 }
322
323 function findStrategyByLabel(list, label)
324 {
325     for (var strategy of list) {
326         if (strategy.label == label)
327             return strategy;
328     }
329     return null;
330 }
331
332 function changeTypeForRange(config, range)
333 {
334     var endValueIsLarger = range.relativeChangeInSegmentedValues > 0;
335     return endValueIsLarger == config.smallerIsBetter ? 'regression' : 'progression';
336 }
337
338 function summarizeRange(config, range)
339 {
340     return 'Potential ' + Math.abs(range.relativeChangeInSegmentedValues * 100).toPrecision(2) + '% '
341         + changeTypeForRange(config, range) + ' between ' + formatTimeRange(range.startTime, range.endTime);
342 }
343
344 function formatTimeRange(start, end)
345 {
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;
354 }
355
356 function getJSON(server, path, data)
357 {
358     return fetchJSON(server.scheme, {
359         'hostname': server.host,
360         'port': server.port,
361         'auth': server.auth,
362         'path': path,
363         'method': 'GET',
364     }, 'application/json');
365 }
366
367 function postJSON(server, path, data)
368 {
369     return fetchJSON(server.scheme, {
370         'hostname': server.host,
371         'port': server.port,
372         'auth': server.auth,
373         'path': path,
374         'method': 'POST',
375     }, 'application/json', JSON.stringify(data));
376 }
377
378 function postNotification(server, template, title, message)
379 {
380     var notification = instantiateNotificationTemplate(template, title, message);
381     return fetchJSON(server.scheme, {
382         'hostname': server.host,
383         'port': server.port,
384         'auth': server.auth,
385         'path': server.path,
386         'method': server.method,
387     }, 'application/json', JSON.stringify(notification));
388 }
389
390 function instantiateNotificationTemplate(template, title, message)
391 {
392     var instance = {};
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);
399         else
400             instance[name] = value;
401     }
402     return instance;
403 }
404
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 () {
413                 try {
414                     var json = JSON.parse(responseText);
415                 } catch (error) {
416                     reject({error: error, responseText: responseText});
417                 }
418                 resolve(json);
419             });
420         });
421         request.on('error', function (error) { reject(error); });
422         if (contentType)
423             request.setHeader('Content-Type', contentType);
424         if (content)
425             request.write(content);
426         request.end();
427     });
428 }
429
430 main(process.argv);