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