Perf dashboard should automatically detect regressions
authorrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Fri, 24 Apr 2015 01:16:37 +0000 (01:16 +0000)
committerrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Fri, 24 Apr 2015 01:16:37 +0000 (01:16 +0000)
https://bugs.webkit.org/show_bug.cgi?id=141443

Reviewed by Anders Carlsson.

Added a node.js script detect-changes.js to detect potential regressions and progressions
on the graphs tracked on v2 dashboards.

* init-database.sql: Added analysis_strategies table and task_segmentation and task_test_range
columns to analysis_tasks to keep the segmentation and test range selection strategies used
to create an analysis task.

* public/api/analysis-tasks.php:
(format_task): Include task_segmentation and analysis_tasks in the results.

* public/include/json-header.php:
(remote_user_name): Returns null when the privileged API is authenticated as a slave instead
of a CSRF prevention token.
(should_authenticate_as_slave): Added.
(ensure_privileged_api_data_and_token_or_slave): Added. Authenticate as a slave if slaveName
and slavePassword are specified. Since detect-changes.js and other slaves are not susceptible
to a CSRF attack, we don't need to check a CSRF token.

* public/privileged-api/create-analysis-task.php:
(main): Use ensure_privileged_api_data_and_token_or_slave to let detect-changes.js create new
analysis task. Also add or find segmentation and test range selection strategies if specified.

* public/privileged-api/create-test-group.php:
(main): Use ensure_privileged_api_data_and_token_or_slave.

* public/privileged-api/generate-csrf-token.php:

* public/v2/app.js:
(App.Pane._computeMovingAverageAndOutliers): _executeStrategy has been moved to Statistics.

* public/v2/data.js: Export Measurement, RunsData, TimeSeries. Used in detect-changes.js.
(Array.prototype.find): Added a polyfill to be used in node.js.
(RunsData.fetchRuns):
(RunsData.pathForFetchingRuns): Extracted from fetchRuns. Used in detect-changes.js.
(RunsData.createRunsDataInResponse): Extracted from App.Manifest._formatFetchedData to use it
in detect-changes.js.
(RunsData.unitFromMetricName): Ditto.
(RunsData.isSmallerBetter): Ditto.
(RunsData.prototype._timeSeriesByTimeInternal): Added secondaryTime to sort points when commit
times are identical.
(TimeSeries): When commit times are identical, order points based on build time. This is needed
for when we trigger two builds at two different OS versions with the same WebKit revision since
OS versions don't change the commit times.
(TimeSeries.prototype.findPointByIndex): Added.
(TimeSeries.prototype.rawValues): Added.

* public/v2/js/statistics.js:
(Statistics.TestRangeSelectionStrategies.[0]): Use the 99% two-sided probability as claimed in the
description of this strategy instead of the default probability. Also fixed a bug that debugging
code was referring to non-existent variables.
(Statistics.executeStrategy): Moved from App.Pane (app.js).

* public/v2/manifest.js:
(App.Manifest._formatFetchedData): Various code has been extracted into RunsData in data.js to be
used in detect-changes.js.

* tools/detect-changes.js: Added. The script fetches the manifest JSON, analyzes each graph in
the v2 dashboards, and creates an analysis task for the latest regression or progression detected.
It also schedules an A/B testing if possible and notifies another server; e.g. to send an email.
(main): Loads the settings JSON specified in the argument.
(fetchManifestAndAnalyzeData): The main loop that periodically wakes up to do the analysis.
(mapInOrder): Executes callback sequentially (i.e. blocking) on each item in the array.
(configurationsForTesting): Finds every (platform, metric) pair to analyze in the v2 dashbaords,
and computes various values for when statistically significant changes are detected later.
(analyzeConfiguration): Finds potential regressions and progression in the last X days where X
is the specified maximum number of days using the specified strategies. Sort the resultant ranges
in chronological order and create a new analysis task for the very last change we detected. We'll
eventually create an analysis task for all detected changes since we're repeating the analysis in
fetchManifestAndAnalyzeData after some time.
(computeRangesForTesting): Fetch measured values and compute ranges to test using the specified
segmentation and test range selection strategies. Once ranges are found, find overlapping analysis
tasks as they need to be filtered out in analyzeConfiguration to avoid creating multiple analysis
tasks for the same range (e.g. humans may create one before the script gets to do it).
(createAnalysisTaskAndNotify): Create a new analysis task for the specified range, trigger an A/B
testing if available, and notify another server with a HTML message as specified.
(findStrategyByLabel):
(changeTypeForRange): A change is a regression if values are getting larger in a smaller-is-better
test or values are getting smaller in a larger-is-better test and vice versa.
(summarizeRange): Create a human readable string that summarizes the change detected. e.g.
"Potential 3.2% regression detected between 2015-04-20 12:00 and 17:00".
(formatTimeRange):
(getJSON):
(postJSON):
(postNotification): Recursively replaces $title and $massage in the specified JSON template.
(instantiateNotificationTemplate):
(fetchJSON):

git-svn-id: https://svn.webkit.org/repository/webkit/trunk@183232 268f45cc-cd09-0410-ab3c-d52691b4dbfc

12 files changed:
Websites/perf.webkit.org/ChangeLog
Websites/perf.webkit.org/init-database.sql
Websites/perf.webkit.org/public/api/analysis-tasks.php
Websites/perf.webkit.org/public/include/json-header.php
Websites/perf.webkit.org/public/privileged-api/create-analysis-task.php
Websites/perf.webkit.org/public/privileged-api/create-test-group.php
Websites/perf.webkit.org/public/privileged-api/generate-csrf-token.php
Websites/perf.webkit.org/public/v2/app.js
Websites/perf.webkit.org/public/v2/data.js
Websites/perf.webkit.org/public/v2/js/statistics.js
Websites/perf.webkit.org/public/v2/manifest.js
Websites/perf.webkit.org/tools/detect-changes.js [new file with mode: 0644]

index b6aa8c4..804ecbb 100644 (file)
@@ -1,3 +1,97 @@
+2015-04-23  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Perf dashboard should automatically detect regressions
+        https://bugs.webkit.org/show_bug.cgi?id=141443
+
+        Reviewed by Anders Carlsson.
+
+        Added a node.js script detect-changes.js to detect potential regressions and progressions
+        on the graphs tracked on v2 dashboards.
+
+        * init-database.sql: Added analysis_strategies table and task_segmentation and task_test_range
+        columns to analysis_tasks to keep the segmentation and test range selection strategies used
+        to create an analysis task.
+
+        * public/api/analysis-tasks.php:
+        (format_task): Include task_segmentation and analysis_tasks in the results.
+
+        * public/include/json-header.php:
+        (remote_user_name): Returns null when the privileged API is authenticated as a slave instead
+        of a CSRF prevention token.
+        (should_authenticate_as_slave): Added.
+        (ensure_privileged_api_data_and_token_or_slave): Added. Authenticate as a slave if slaveName
+        and slavePassword are specified. Since detect-changes.js and other slaves are not susceptible
+        to a CSRF attack, we don't need to check a CSRF token.
+
+        * public/privileged-api/create-analysis-task.php:
+        (main): Use ensure_privileged_api_data_and_token_or_slave to let detect-changes.js create new
+        analysis task. Also add or find segmentation and test range selection strategies if specified.
+
+        * public/privileged-api/create-test-group.php:
+        (main): Use ensure_privileged_api_data_and_token_or_slave.
+
+        * public/privileged-api/generate-csrf-token.php:
+
+        * public/v2/app.js:
+        (App.Pane._computeMovingAverageAndOutliers): _executeStrategy has been moved to Statistics.
+
+        * public/v2/data.js: Export Measurement, RunsData, TimeSeries. Used in detect-changes.js.
+        (Array.prototype.find): Added a polyfill to be used in node.js.
+        (RunsData.fetchRuns):
+        (RunsData.pathForFetchingRuns): Extracted from fetchRuns. Used in detect-changes.js.
+        (RunsData.createRunsDataInResponse): Extracted from App.Manifest._formatFetchedData to use it
+        in detect-changes.js.
+        (RunsData.unitFromMetricName): Ditto.
+        (RunsData.isSmallerBetter): Ditto.
+        (RunsData.prototype._timeSeriesByTimeInternal): Added secondaryTime to sort points when commit
+        times are identical.
+        (TimeSeries): When commit times are identical, order points based on build time. This is needed
+        for when we trigger two builds at two different OS versions with the same WebKit revision since
+        OS versions don't change the commit times.
+        (TimeSeries.prototype.findPointByIndex): Added.
+        (TimeSeries.prototype.rawValues): Added.
+
+        * public/v2/js/statistics.js:
+        (Statistics.TestRangeSelectionStrategies.[0]): Use the 99% two-sided probability as claimed in the
+        description of this strategy instead of the default probability. Also fixed a bug that debugging
+        code was referring to non-existent variables.
+        (Statistics.executeStrategy): Moved from App.Pane (app.js).
+
+        * public/v2/manifest.js:
+        (App.Manifest._formatFetchedData): Various code has been extracted into RunsData in data.js to be
+        used in detect-changes.js.
+
+        * tools/detect-changes.js: Added. The script fetches the manifest JSON, analyzes each graph in
+        the v2 dashboards, and creates an analysis task for the latest regression or progression detected.
+        It also schedules an A/B testing if possible and notifies another server; e.g. to send an email. 
+        (main): Loads the settings JSON specified in the argument.
+        (fetchManifestAndAnalyzeData): The main loop that periodically wakes up to do the analysis.
+        (mapInOrder): Executes callback sequentially (i.e. blocking) on each item in the array.
+        (configurationsForTesting): Finds every (platform, metric) pair to analyze in the v2 dashbaords,
+        and computes various values for when statistically significant changes are detected later.
+        (analyzeConfiguration): Finds potential regressions and progression in the last X days where X
+        is the specified maximum number of days using the specified strategies. Sort the resultant ranges
+        in chronological order and create a new analysis task for the very last change we detected. We'll
+        eventually create an analysis task for all detected changes since we're repeating the analysis in
+        fetchManifestAndAnalyzeData after some time.
+        (computeRangesForTesting): Fetch measured values and compute ranges to test using the specified
+        segmentation and test range selection strategies. Once ranges are found, find overlapping analysis
+        tasks as they need to be filtered out in analyzeConfiguration to avoid creating multiple analysis
+        tasks for the same range (e.g. humans may create one before the script gets to do it).
+        (createAnalysisTaskAndNotify): Create a new analysis task for the specified range, trigger an A/B
+        testing if available, and notify another server with a HTML message as specified.
+        (findStrategyByLabel):
+        (changeTypeForRange): A change is a regression if values are getting larger in a smaller-is-better
+        test or values are getting smaller in a larger-is-better test and vice versa.
+        (summarizeRange): Create a human readable string that summarizes the change detected. e.g.
+        "Potential 3.2% regression detected between 2015-04-20 12:00 and 17:00".
+        (formatTimeRange):
+        (getJSON):
+        (postJSON):
+        (postNotification): Recursively replaces $title and $massage in the specified JSON template.
+        (instantiateNotificationTemplate):
+        (fetchJSON):
+
 2015-04-20  Ryosuke Niwa  <rniwa@webkit.org>
 
         Perf dashboard should have UI to set status on analysis tasks
index efb5247..1712623 100644 (file)
@@ -174,11 +174,17 @@ CREATE TABLE reports (
     report_failure varchar(64),
     report_failure_details text);
 
+CREATE TABLE analysis_strategies (
+    strategy_id serial PRIMARY KEY,
+    strategy_name varchar(64) NOT NULL);
+
 CREATE TYPE analysis_task_result_type as ENUM ('progression', 'regression', 'unchanged', 'inconclusive');
 CREATE TABLE analysis_tasks (
     task_id serial PRIMARY KEY,
     task_name varchar(256) NOT NULL,
     task_author varchar(256),
+    task_segmentation integer REFERENCES analysis_strategies,
+    task_test_range integer REFERENCES analysis_strategies,
     task_created_at timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
     task_platform integer REFERENCES platforms NOT NULL,
     task_metric integer REFERENCES test_metrics NOT NULL,
index e7fe6b2..947f952 100644 (file)
@@ -81,6 +81,8 @@ function format_task($task_row) {
         'id' => $task_row['task_id'],
         'name' => $task_row['task_name'],
         'author' => $task_row['task_author'],
+        'segmentationStrategy' => $task_row['task_segmentation'],
+        'testRangeStragegy' => $task_row['task_test_range'],
         'createdAt' => strtotime($task_row['task_created_at']) * 1000,
         'platform' => $task_row['task_platform'],
         'metric' => $task_row['task_metric'],
index a58e816..f5215f9 100644 (file)
@@ -96,8 +96,21 @@ function ensure_privileged_api_data_and_token() {
     return $data;
 }
 
-function remote_user_name() {
-    return array_get($_SERVER, 'REMOTE_USER');
+function remote_user_name($data) {
+    return should_authenticate_as_slave($data) ? NULL : array_get($_SERVER, 'REMOTE_USER');
+}
+
+function should_authenticate_as_slave($data) {
+    return array_key_exists('slaveName', $data) && array_key_exists('slavePassword', $data);
+}
+
+function ensure_privileged_api_data_and_token_or_slave($db) {
+    $data = ensure_privileged_api_data();
+    if (should_authenticate_as_slave($data))
+        verify_slave($db, $data);
+    else if (!verify_token(array_get($data, 'token')))
+        exit_with_error('InvalidToken');
+    return $data;
 }
 
 function compute_token() {
index cb8e522..0a9d32f 100644 (file)
@@ -3,26 +3,48 @@
 require_once('../include/json-header.php');
 
 function main() {
-    $data = ensure_privileged_api_data_and_token();
+    $db = connect();
+    $data = ensure_privileged_api_data_and_token_or_slave($db);
 
-    $author = remote_user_name();
+    $author = remote_user_name($data);
     $name = array_get($data, 'name');
     $start_run_id = array_get($data, 'startRun');
     $end_run_id = array_get($data, 'endRun');
 
+    $segmentation_name = array_get($data, 'segmentationStrategy');
+    $test_range_name = array_get($data, 'testRangeStrategy');
+
     if (!$name)
         exit_with_error('MissingName', array('name' => $name));
     $range = array('startRunId' => $start_run_id, 'endRunId' => $end_run_id);
     if (!$start_run_id || !$end_run_id)
         exit_with_error('MissingRange', $range);
 
-    $db = connect();
     $start_run = ensure_row_by_id($db, 'test_runs', 'run', $start_run_id, 'InvalidStartRun', $range);
     $end_run = ensure_row_by_id($db, 'test_runs', 'run', $end_run_id, 'InvalidEndRun', $range);
 
     $config = ensure_config_from_runs($db, $start_run, $end_run);
 
     $db->begin_transaction();
+
+    $segmentation_id = NULL;
+    if ($segmentation_name) {
+        $segmentation_id = $db->select_or_insert_row('analysis_strategies', 'strategy', array('name' => $segmentation_name));
+        if (!$segmentation_id) {
+            $db->rollback_transaction();
+            exit_with_error('CannotFindOrInsertSegmentationStrategy', array('segmentationStrategy' => $segmentation_name));
+        }
+    }
+
+    $test_range_id = NULL;
+    if ($test_range_name) {
+        $test_range_id = $db->select_or_insert_row('analysis_strategies', 'strategy', array('name' => $test_range_name));
+        if (!$test_range_id) {
+            $db->rollback_transaction();
+            exit_with_error('CannotFindOrInsertTestRangeStrategy', array('testRangeStrategy' => $test_range_name));
+        }
+    }
+
     $duplicate = $db->select_first_row('analysis_tasks', 'task', array('start_run' => $start_run_id, 'end_run' => $end_run_id));
     if ($duplicate) {
         $db->rollback_transaction();
@@ -35,7 +57,9 @@ function main() {
         'platform' => $config['config_platform'],
         'metric' => $config['config_metric'],
         'start_run' => $start_run_id,
-        'end_run' => $end_run_id));
+        'end_run' => $end_run_id,
+        'segmentation' => $segmentation_id,
+        'test_range' => $test_range_id));
     $db->commit_transaction();
 
     exit_with_success(array('taskId' => $task_id));
index d1c7560..d180b30 100644 (file)
@@ -3,9 +3,9 @@
 require_once('../include/json-header.php');
 
 function main() {
-    $data = ensure_privileged_api_data_and_token();
-
-    $author = remote_user_name();
+    $db = connect();
+    $data = ensure_privileged_api_data_and_token_or_slave($db);
+    $author = remote_user_name($data);
 
     $task_id = array_get($data, 'task');
     $name = array_get($data, 'name');
@@ -19,7 +19,6 @@ function main() {
     if ($repetition_count < 1)
         exit_with_error('InvalidRepetitionCount', array('repetitionCount' => $repetition_count));
 
-    $db = connect();
     $task = $db->select_first_row('analysis_tasks', 'task', array('id' => $task_id));
     if (!$task)
         exit_with_error('InvalidTask', array('task' => $task_id));
index be3f0fe..42d8d72 100644 (file)
@@ -2,7 +2,7 @@
 
 require_once('../include/json-header.php');
 
-ensure_privileged_api_data();
+$data = ensure_privileged_api_data();
 
 $expiritaion = time() + 3600; // Valid for one hour.
 $_COOKIE['CSRFSalt'] = rand();
@@ -11,6 +11,6 @@ $_COOKIE['CSRFExpiration'] = $expiritaion;
 setcookie('CSRFSalt', $_COOKIE['CSRFSalt']);
 setcookie('CSRFExpiration', $expiritaion);
 
-exit_with_success(array('user' => remote_user_name(), 'token' => compute_token(), 'expiration' => $expiritaion * 1000));
+exit_with_success(array('user' => remote_user_name($data), 'token' => compute_token(), 'expiration' => $expiritaion * 1000));
 
 ?>
index 835aeff..8100d43 100755 (executable)
@@ -583,19 +583,21 @@ App.Pane = Ember.Object.extend({
     _computeMovingAverageAndOutliers: function (chartData, movingAverageStrategy, envelopingStrategy, testRangeSelectionStrategy, anomalyDetectionStrategies)
     {
         var currentTimeSeriesData = chartData.current.series();
+
+        var rawValues = chartData.current.rawValues();
         var movingAverageIsSetByUser = movingAverageStrategy && movingAverageStrategy.execute;
-        var movingAverageValues = this._executeStrategy(
-            movingAverageIsSetByUser ? movingAverageStrategy : Statistics.MovingAverageStrategies[0], currentTimeSeriesData);
+        var movingAverageValues = Statistics.executeStrategy(
+            movingAverageIsSetByUser ? movingAverageStrategy : Statistics.MovingAverageStrategies[0], rawValues);
         if (!movingAverageValues)
             return null;
 
         var testRangeCandidates = [];
         if (movingAverageStrategy && movingAverageStrategy.isSegmentation && testRangeSelectionStrategy && testRangeSelectionStrategy.execute)
-            testRangeCandidates = this._executeStrategy(testRangeSelectionStrategy, currentTimeSeriesData, [movingAverageValues]);
+            testRangeCandidates = Statistics.executeStrategy(testRangeSelectionStrategy, rawValues, [movingAverageValues]);
 
         var envelopeIsSetByUser = envelopingStrategy && envelopingStrategy.execute;
-        var envelopeDelta = this._executeStrategy(envelopeIsSetByUser ? envelopingStrategy : Statistics.EnvelopingStrategies[0],
-            currentTimeSeriesData, [movingAverageValues]);
+        var envelopeDelta = Statistics.executeStrategy(envelopeIsSetByUser ? envelopingStrategy : Statistics.EnvelopingStrategies[0],
+            rawValues, [movingAverageValues]);
 
         for (var i = 0; i < currentTimeSeriesData.length; i++) {
             var currentValue = currentTimeSeriesData[i].value;
@@ -610,7 +612,7 @@ App.Pane = Ember.Object.extend({
         if (anomalyDetectionStrategies.length) {
             var isAnomalyArray = new Array(currentTimeSeriesData.length);
             for (var strategy of anomalyDetectionStrategies) {
-                var anomalyLengths = this._executeStrategy(strategy, currentTimeSeriesData, [movingAverageValues, envelopeDelta]);
+                var anomalyLengths = Statistics.executeStrategy(strategy, rawValues, [movingAverageValues, envelopeDelta]);
                 for (var i = 0; i < currentTimeSeriesData.length; i++)
                     isAnomalyArray[i] = isAnomalyArray[i] || anomalyLengths[i];
             }
@@ -642,15 +644,6 @@ App.Pane = Ember.Object.extend({
             testRangeCandidates: testRangeCandidates,
         };
     },
-    _executeStrategy: function (strategy, currentTimeSeriesData, additionalArguments)
-    {
-        var parameters = (strategy.parameterList || []).map(function (param) {
-            var parsed = parseFloat(param.value);
-            return Math.min(param.max || Infinity, Math.max(param.min || -Infinity, isNaN(parsed) ? 0 : parsed));
-        });
-        parameters.push(currentTimeSeriesData.map(function (point) { return point.value }));
-        return strategy.execute.apply(window, parameters.concat(additionalArguments));
-    },
     _updateStrategyConfigIfNeeded: function (strategy, configName)
     {
         var config = null;
index 1e0c32d..83a1bad 100755 (executable)
@@ -1,5 +1,15 @@
 // We don't use DS.Model for these object types because we can't afford to process millions of them.
 
+if (!Array.prototype.find) {
+    Array.prototype.find = function (callback) {
+        for (var item of this) {
+            if (callback(item))
+                return item;
+        }
+        return undefined;
+    }
+}
+
 var PrivilegedAPI = {
     _token: null,
     _expiration: null,
@@ -333,6 +343,7 @@ RunsData.prototype._timeSeriesByTimeInternal = function (useCommitType, includeO
         series.push({
             measurement: measurement,
             time: useCommitType ? measurement.latestCommitTime() : measurement.buildTime(),
+            secondaryTime: measurement.buildTime(),
             value: measurement.mean(),
             interval: measurement.confidenceInterval(),
             markedOutlier: measurement.markedOutlier(),
@@ -345,28 +356,14 @@ RunsData.prototype._timeSeriesByTimeInternal = function (useCommitType, includeO
 // we don't have to fetch the entire time series to just show the last 3 days.
 RunsData.fetchRuns = function (platformId, metricId, testGroupId, useCache)
 {
-    var url = useCache ? '../data/' : '../api/runs/';
-
-    url += platformId + '-' + metricId + '.json';
-    if (testGroupId)
-        url += '?testGroup=' + testGroupId;
-
+    var url = this.pathForFetchingRuns(platformId, metricId, testGroupId, useCache);
     return new Ember.RSVP.Promise(function (resolve, reject) {
         $.getJSON(url, function (response) {
             if (response.status != 'OK') {
                 reject(response.status);
                 return;
             }
-            delete response.status;
-
-            var data = response.configurations;
-            for (var config in data)
-                data[config] = new RunsData(data[config]);
-            
-            if (response.lastModified)
-                response.lastModified = new Date(response.lastModified);
-
-            resolve(response);
+            resolve(RunsData.createRunsDataInResponse(response));
         }).fail(function (xhr, status, error) {
             if (xhr.status == 404 && useCache)
                 resolve(null);
@@ -376,9 +373,58 @@ RunsData.fetchRuns = function (platformId, metricId, testGroupId, useCache)
     });
 }
 
+RunsData.pathForFetchingRuns = function (platformId, metricId, testGroupId, useCache)
+{
+    var path = useCache ? '/data/' : '/api/runs/';
+
+    path += platformId + '-' + metricId + '.json';
+    if (testGroupId)
+        path += '?testGroup=' + testGroupId;
+
+    return path;
+}
+
+RunsData.createRunsDataInResponse = function (response)
+{
+    delete response.status;
+
+    var data = response.configurations;
+    for (var config in data)
+        data[config] = new RunsData(data[config]);
+
+    if (response.lastModified)
+        response.lastModified = new Date(response.lastModified);
+
+    return response;
+}
+
+// FIXME: It was a mistake to put this in the client side. We should put this back in the JSON.
+RunsData.unitFromMetricName = function (metricName)
+{
+    var suffix = metricName.match('([A-z][a-z]+|FrameRate)$')[0];
+    var unit = {
+        'FrameRate': 'fps',
+        'Runs': '/s',
+        'Time': 'ms',
+        'Malloc': 'bytes',
+        'Heap': 'bytes',
+        'Allocations': 'bytes'
+    }[suffix];
+    return unit;
+}
+
+RunsData.isSmallerBetter = function (unit)
+{
+    return unit != 'fps' && unit != '/s';
+}
+
 function TimeSeries(series)
 {
-    this._series = series.sort(function (a, b) { return a.time - b.time; });
+    this._series = series.sort(function (a, b) {
+        var diff = a.time - b.time;
+        return diff ? diff : a.secondaryTime - b.secondaryTime;
+    });
+
     var self = this;
     var min = undefined;
     var max = undefined;
@@ -394,6 +440,13 @@ function TimeSeries(series)
     this._max = max;
 }
 
+TimeSeries.prototype.findPointByIndex = function (index)
+{
+    if (!this._series || index < 0 || index >= this._series.length)
+        return null;
+    return this._series[index];
+}
+
 TimeSeries.prototype.findPointByBuild = function (buildId)
 {
     return this._series.find(function (point) { return point.measurement.buildId() == buildId; })
@@ -462,6 +515,11 @@ TimeSeries.prototype.minMaxForTimeRange = function (startTime, endTime, ignoreOu
 
 TimeSeries.prototype.series = function () { return this._series; }
 
+TimeSeries.prototype.rawValues = function ()
+{
+    return this._series.map(function (point) { return point.value });
+}
+
 TimeSeries.prototype.lastPoint = function ()
 {
     if (!this._series || !this._series.length)
@@ -482,3 +540,10 @@ TimeSeries.prototype.nextPoint = function (point)
         return null;
     return this._series[point.seriesIndex + 1];
 }
+
+if (typeof module != 'undefined') {
+    Statistics = require('./js/statistics.js');
+    module.exports.Measurement = Measurement;
+    module.exports.RunsData = RunsData;
+    module.exports.TimeSeries = TimeSeries;
+}
index afa21e0..af10437 100755 (executable)
@@ -487,7 +487,7 @@ var Statistics = new (function () {
                     for (var leftEdge = i - 2, rightEdge = i + 2; leftEdge >= 0 && rightEdge <= values.length; leftEdge--, rightEdge++) {
                         if (segmentedValues[leftEdge] != previousMean || segmentedValues[rightEdge - 1] != currentMean)
                             break;
-                        var result = Statistics.computeWelchsT(values, leftEdge, i - leftEdge, values, i, rightEdge - i);
+                        var result = Statistics.computeWelchsT(values, leftEdge, i - leftEdge, values, i, rightEdge - i, 0.98);
                         if (result.significantlyDifferent) {
                             selectedRanges.push([leftEdge, rightEdge - 1]);
                             found = true;
@@ -495,7 +495,7 @@ var Statistics = new (function () {
                         }
                     }
                     if (!found && Statistics.debuggingTestingRangeNomination)
-                        console.log('Failed to find a testing range at', i, 'changing from', previousValue, 'to', currentValue);
+                        console.log('Failed to find a testing range at', i, 'changing from', previousMean, 'to', currentMean);
                     previousMean = currentMean;
                 }
                 return selectedRanges;
@@ -565,6 +565,16 @@ var Statistics = new (function () {
         },
     ]
 
+    this.executeStrategy = function (strategy, rawValues, additionalArguments)
+    {
+        var parameters = (strategy.parameterList || []).map(function (param) {
+            var parsed = parseFloat(param.value);
+            return Math.min(param.max || Infinity, Math.max(param.min || -Infinity, isNaN(parsed) ? 0 : parsed));
+        });
+        parameters.push(rawValues);
+        return strategy.execute.apply(strategy, parameters.concat(additionalArguments));
+    };
+
 })();
 
 if (typeof module != 'undefined') {
index 6a961aa..610b607 100755 (executable)
@@ -317,17 +317,8 @@ App.Manifest = Ember.Controller.extend({
     },
     _formatFetchedData: function (metricName, configurations)
     {
-        var suffix = metricName.match('([A-z][a-z]+|FrameRate)$')[0];
-        var unit = {
-            'FrameRate': 'fps',
-            'Runs': '/s',
-            'Time': 'ms',
-            'Malloc': 'bytes',
-            'Heap': 'bytes',
-            'Allocations': 'bytes'
-        }[suffix];
-
-        var smallerIsBetter = unit != 'fps' && unit != '/s'; // Assume smaller is better for unit-less metrics.
+        var unit = RunsData.unitFromMetricName(metricName);
+        var smallerIsBetter = RunsData.isSmallerBetter(unit);
 
         var useSI = unit == 'bytes';
         var unitSuffix = unit ? ' ' + unit : '';
diff --git a/Websites/perf.webkit.org/tools/detect-changes.js b/Websites/perf.webkit.org/tools/detect-changes.js
new file mode 100644 (file)
index 0000000..84fa7e6
--- /dev/null
@@ -0,0 +1,374 @@
+#!/usr/local/bin/node
+
+var fs = require('fs');
+var http = require('http');
+var https = require('https');
+var data = require('../public/v2/data.js');
+var RunsData = data.RunsData;
+var Statistics = require('../public/v2/js/statistics.js');
+
+var settings;
+function main(argv)
+{
+    if (argv.length < 3) {
+        console.error('Please specify the settings JSON path');
+        return 1;
+    }
+
+    settings = JSON.parse(fs.readFileSync(argv[2], 'utf8'));
+
+    fetchManifestAndAnalyzeData();
+}
+
+function fetchManifestAndAnalyzeData()
+{
+    getJSON(settings.perfserver, '/data/manifest.json').then(function (manifest) {
+        return mapInOrder(configurationsForTesting(manifest), analyzeConfiguration);
+    }).catch(function (reason) {
+        console.error('Failed to obtain the manifest file');
+    }).then(function () {
+        console.log('');
+        console.log('Sleeing for', settings.secondsToSleep, 'seconds');
+        setTimeout(fetchManifestAndAnalyzeData, settings.secondsToSleep * 1000);
+    });
+}
+
+function mapInOrder(array, callback, startIndex)
+{
+    if (startIndex === undefined)
+        startIndex = 0;
+    if (startIndex >= array.length)
+        return;
+
+    var next = function () { return mapInOrder(array, callback, startIndex + 1); };
+    var returnValue = callback(array[startIndex]);
+    if (typeof(returnValue) === 'object' && returnValue instanceof Promise)
+        return returnValue.then(next).catch(next);
+    return next();
+}
+
+function configurationsForTesting(manifest)
+{
+    var configurations = [];
+    for (var name in manifest.dashboards) {
+        var dashboard = manifest.dashboards[name];
+        for (var row of dashboard) {
+            for (var cell of row) {
+                if (cell instanceof Array)
+                    configurations.push({platformId: parseInt(cell[0]), metricId: parseInt(cell[1])});
+            }
+        }
+    }
+
+    var platforms = manifest.all;
+    for (var config of configurations) {
+        var metric = manifest.metrics[config.metricId];
+
+        var testPath = [];
+        var id = metric.test;
+        while (id) {
+            var test = manifest.tests[id];
+            testPath.push(test.name);
+            id = test.parentId;
+        }
+
+        config.unit = RunsData.unitFromMetricName(metric.name);
+        config.smallerIsBetter = RunsData.isSmallerBetter(config.unit);
+        config.platformName = platforms[config.platformId].name;
+        config.testName = testPath.reverse().join(' > ');
+        config.fullTestName = config.testName + ':' + metric.name;
+        config.repositories = manifest.repositories;
+        if (metric.aggregator)
+            config.fullTestName += ':' + metric.aggregator;
+    }
+
+    return configurations;
+}
+
+function analyzeConfiguration(config)
+{
+    var minTime = Date.now() - settings.maxDays * 24 * 3600 * 1000;
+
+    console.log('');
+    console.log('== Analyzing the last', settings.maxDays, 'days:', config.fullTestName, 'on', config.platformName, '==');
+
+    return computeRangesForTesting(settings.perfserver, settings.strategies, config.platformId, config.metricId).then(function (ranges) {
+        var filteredRanges = ranges.filter(function (range) { return range.endTime >= minTime && !range.overlappingAnalysisTasks.length; })
+            .sort(function (a, b) { return a.endTime - b.endTime });
+
+        var summary;
+        var range;
+        for (range of filteredRanges) {
+            var summary = summarizeRange(config, range);
+            console.log('Detected:', summary);
+        }
+
+        if (!range) {
+            console.log('Nothing to analyze');
+            return;
+        }
+
+        return createAnalysisTaskAndNotify(config, range, summary);
+    });
+}
+
+function computeRangesForTesting(server, strategies, platformId, metricId)
+{
+    // FIXME: Store the segmentation strategy on the server side.
+    // FIXME: Configure each strategy.
+    var segmentationStrategy = findStrategyByLabel(Statistics.MovingAverageStrategies, strategies.segmentation.label);
+    if (!segmentationStrategy) {
+        console.error('Failed to find the segmentation strategy: ' + strategies.segmentation.label);
+        return;
+    }
+
+    var testRangeStrategy = findStrategyByLabel(Statistics.TestRangeSelectionStrategies, strategies.testRange.label);
+    if (!testRangeStrategy) {
+        console.error('Failed to find the test range selection strategy: ' + strategies.testRange.label);
+        return;
+    }
+
+    var currentPromise = getJSON(server, RunsData.pathForFetchingRuns(platformId, metricId)).then(function (response) {
+        if (response.status != 'OK')
+            throw response;
+        return RunsData.createRunsDataInResponse(response).configurations.current;
+    }, function (reason) {
+        console.error('Failed to fetch the measurements:', reason);
+    });
+
+    var analysisTasksPromise = getJSON(server, '/api/analysis-tasks?platform=' + platformId + '&metric=' + metricId).then(function (response) {
+        if (response.status != 'OK')
+            throw response;
+        return response.analysisTasks.filter(function (task) { return task.startRun && task.endRun; });
+    }, function (reason) {
+        console.error('Failed to fetch the analysis tasks:', reason);
+    });
+
+    return Promise.all([currentPromise, analysisTasksPromise]).then(function (results) {
+        var currentTimeSeries = results[0].timeSeriesByCommitTime();
+        var analysisTasks = results[1];
+        var rawValues = currentTimeSeries.rawValues();
+        var segmentedValues = Statistics.executeStrategy(segmentationStrategy, rawValues);
+
+        var ranges = Statistics.executeStrategy(testRangeStrategy, rawValues, [segmentedValues]).map(function (range) {
+            var startPoint = currentTimeSeries.findPointByIndex(range[0]);
+            var endPoint = currentTimeSeries.findPointByIndex(range[1]);
+            return {
+                startIndex: range[0],
+                endIndex: range[1],
+                overlappingAnalysisTasks: [],
+                startTime: startPoint.time,
+                endTime: endPoint.time,
+                relativeChangeInSegmentedValues: (segmentedValues[range[1]] - segmentedValues[range[0]]) / segmentedValues[range[0]],
+                startMeasurement: startPoint.measurement,
+                endMeasurement: endPoint.measurement,
+            };
+        });
+
+        for (var task of analysisTasks) {
+            var taskStartPoint = currentTimeSeries.findPointByMeasurementId(task.startRun);
+            var taskEndPoint = currentTimeSeries.findPointByMeasurementId(task.endRun);
+            for (var range of ranges) {
+                var disjoint = range.endIndex < taskStartPoint.seriesIndex
+                    || taskEndPoint.seriesIndex < range.startIndex;
+                if (!disjoint)
+                    range.overlappingAnalysisTasks.push(task);
+            }
+        }
+
+        return ranges;
+    });
+}
+
+function createAnalysisTaskAndNotify(config, range, summary)
+{
+    var segmentationStrategy = settings.strategies.segmentation.label;
+    var testRangeStrategy = settings.strategies.testRange.label;
+
+    var analysisTaskData = {
+        name: summary,
+        startRun: range.startMeasurement.id(),
+        endRun: range.endMeasurement.id(),
+        segmentationStrategy: segmentationStrategy,
+        testRangeStrategy: testRangeStrategy,
+
+        slaveName: settings.slave.name,
+        slavePassword: settings.slave.password,
+    };
+
+    return postJSON(settings.perfserver, '/privileged-api/create-analysis-task', analysisTaskData).then(function (response) {
+        if (response['status'] != 'OK')
+            throw response;
+
+        var analysisTaskId = response['taskId'];
+
+        var title = '[' + config.testName + '][' + config.platformName + '] ' + summary;
+        var analysisTaskURL = settings.perfserver.scheme + '://' + settings.perfserver.host + '/v2/#/analysis/task/' + analysisTaskId;
+        var changeType = changeTypeForRange(config, range);
+        // FIXME: Templatize this.
+        var message = '<b>' + settings.notification.serviceName + '</b> detected a potential ' + changeType + ':<br><br>'
+            + '<table border=1><caption>' + summary + '</caption><tbody>'
+            + '<tr><th>Test</th><td>' + config.fullTestName + '</td></tr>'
+            + '<tr><th>Platform</th><td>' + config.platformName + '</td></tr>'
+            + '<tr><th>Algorithm</th><td>' + segmentationStrategy + '<br>' + testRangeStrategy + '</td></tr>'
+            + '</table><br>'
+            + '<a href="' + analysisTaskURL + '">Open the analysis task</a>';
+
+        return getJSON(settings.perfserver, '/api/triggerables?task=' + analysisTaskId).then(function (response) {
+            var status = response['status'];
+            var triggerables = response['triggerables'] || [];
+            if (status == 'TriggerableNotFoundForTask' || triggerables.length != 1) {
+                message += ' (A/B testing was not available)';
+                return;
+            }
+            if (status != 'OK')
+                throw response;
+
+            var triggerable = response['triggerables'][0];
+            var rootSets = {};
+            for (var repositoryId of triggerable['acceptedRepositories']) {
+                var startRevision = range.startMeasurement.revisionForRepository(repositoryId);
+                var endRevision = range.endMeasurement.revisionForRepository(repositoryId);
+                if (startRevision == null || endRevision == null)
+                    continue;
+                rootSets[config.repositories[repositoryId].name] = [startRevision, endRevision];
+            }
+
+            var testData = {
+                task: analysisTaskId,
+                name: 'Confirming the ' + changeType,
+                rootSets: rootSets,
+                repetitionCount: Math.max(2, Math.min(8, Math.floor((range.endIndex - range.startIndex) / 4))),
+
+                slaveName: settings.slave.name,
+                slavePassword: settings.slave.password,
+            };
+
+            return postJSON(settings.perfserver, '/privileged-api/create-test-group', testData).then(function (response) {
+                if (response['status'] != 'OK')
+                    throw response;
+                message += ' (triggered an A/B testing)';
+            });
+        }).catch(function (reason) {
+            console.error(reason);
+            message += ' (failed to create a new A/B testing)';
+        }).then(function () {
+            return postNotification(settings.notification.server, settings.notification.template, title, message).then(function () {
+                console.log('  Sent a notification');
+            }, function (reason) {
+                console.error('  Failed to send a notification', reason);
+            });
+        });
+    }).catch(function (reason) {
+        console.error('  Failed to create an analysis task', reason);
+    });
+}
+
+function findStrategyByLabel(list, label)
+{
+    for (var strategy of list) {
+        if (strategy.label == label)
+            return strategy;
+    }
+    return null;
+}
+
+function changeTypeForRange(config, range)
+{
+    var endValueIsLarger = range.relativeChangeInSegmentedValues > 0;
+    return endValueIsLarger == config.smallerIsBetter ? 'regression' : 'progression';
+}
+
+function summarizeRange(config, range)
+{
+    return 'Potential ' + Math.abs(range.relativeChangeInSegmentedValues * 100).toPrecision(2) + '% '
+        + changeTypeForRange(config, range) + ' between ' + formatTimeRange(range.startTime, range.endTime);
+}
+
+function formatTimeRange(start, end)
+{
+    var formatter = function (date) { return date.toISOString().replace('T', ' ').replace(/:\d{2}\.\d+Z$/, ''); }
+    var formattedStart = formatter(start);
+    var formattedEnd = formatter(end);
+    if (start.toDateString() == end.toDateString())
+        return formattedStart + ' and ' + formattedEnd.substring(formattedEnd.indexOf(' ') + 1);
+    if (start.getFullYear() == end.getFullYear())
+        return formattedStart + ' and ' + formattedEnd.substring(5);
+    return formattedStart + ' and ' + formattedEnd;
+}
+
+function getJSON(server, path, data)
+{
+    return fetchJSON(server.scheme, {
+        'hostname': server.host,
+        'port': server.port,
+        'auth': server.auth,
+        'path': path,
+        'method': 'GET',
+    }, 'application/json');
+}
+
+function postJSON(server, path, data)
+{
+    return fetchJSON(server.scheme, {
+        'hostname': server.host,
+        'port': server.port,
+        'auth': server.auth,
+        'path': path,
+        'method': 'POST',
+    }, 'application/json', JSON.stringify(data));
+}
+
+function postNotification(server, template, title, message)
+{
+    var notification = instantiateNotificationTemplate(template, title, message);
+    return fetchJSON(server.scheme, {
+        'hostname': server.host,
+        'port': server.port,
+        'auth': server.auth,
+        'path': server.path,
+        'method': server.method,
+    }, 'application/json', JSON.stringify(notification));
+}
+
+function instantiateNotificationTemplate(template, title, message)
+{
+    var instance = {};
+    for (var name in template) {
+        var value = template[name];
+        if (typeof(value) === 'string')
+            instance[name] = value.replace(/\$title/g, title).replace(/\$message/g, message);
+        else if (typeof(template[name]) === 'object')
+            instance[name] = instantiateNotificationTemplate(value, title, message);
+        else
+            instance[name] = value;
+    }
+    return instance;
+}
+
+function fetchJSON(schemeName, options, contentType, content) {
+    var requester = schemeName == 'https' ? https : http;
+    return new Promise(function (resolve, reject) {
+        var request = requester.request(options, function (response) {
+            var responseText = '';
+            response.setEncoding('utf8');
+            response.on('data', function (chunk) { responseText += chunk; });
+            response.on('end', function () {
+                try {
+                    var json = JSON.parse(responseText);
+                } catch (error) {
+                    reject({error: error, responseText: responseText});
+                }
+                resolve(json);
+            });
+        });
+        request.on('error', function (error) { reject(error); });
+        if (contentType)
+            request.setHeader('Content-Type', contentType);
+        if (content)
+            request.write(content);
+        request.end();
+    });
+}
+
+main(process.argv);