blob: 42e91972d9090d244b7ba5664561a231c9ad4ad5 [file] [log] [blame] [edit]
package chromeperf
import (
"context"
"fmt"
"net/url"
"strconv"
"strings"
"time"
"go.skia.org/infra/go/metrics2"
"go.skia.org/infra/go/skerr"
"go.skia.org/infra/go/sklog"
perfgit "go.skia.org/infra/perf/go/git"
"go.skia.org/infra/perf/go/types"
)
const (
AnomalyAPIName = "anomalies"
AddFuncName = "add"
FindFuncName = "find"
FindTimeFuncName = "find_time"
GetFuncName = "get"
)
var invalidChars = []string{"?"}
// CommitNumberAnomalyMap is a map of Anomaly, keyed by commit number.
type CommitNumberAnomalyMap map[types.CommitNumber]Anomaly
// AnomalyMap is a map of CommitNumberAnomalyMap, keyed by traceId.
type AnomalyMap map[string]CommitNumberAnomalyMap
// Anomaly defines the object return from Chrome Perf API.
type Anomaly struct {
Id int `json:"id"`
TestPath string `json:"test_path"`
BugId int `json:"bug_id"`
StartRevision int `json:"start_revision"`
EndRevision int `json:"end_revision"`
// The hashes below are needed for cases where the commit numbers are
// different in chromeperf and in the perf instance. We can use these
// hashes to look up the correct commit number from the database.
StartRevisionHash string `json:"start_revision_hash,omitempty"`
EndRevisionHash string `json:"end_revision_hash,omitempty"`
IsImprovement bool `json:"is_improvement"`
Recovered bool `json:"recovered"`
State string `json:"state"`
Statistics string `json:"statistic"`
Unit string `json:"units"`
DegreeOfFreedom float64 `json:"degrees_of_freedom"`
MedianBeforeAnomaly float64 `json:"median_before_anomaly"`
MedianAfterAnomaly float64 `json:"median_after_anomaly"`
PValue float64 `json:"p_value"`
SegmentSizeAfter int `json:"segment_size_after"`
SegmentSizeBefore int `json:"segment_size_before"`
StdDevBeforeAnomaly float64 `json:"std_dev_before_anomaly"`
TStatistics float64 `json:"t_statistic"`
SubscriptionName string `json:"subscription_name"`
BugComponent string `json:"bug_component"`
BugLabels []string `json:"bug_labels"`
BugCcEmails []string `json:"bug_cc_emails"`
}
// AnomalyForRevision defines struct to contain anomaly data for a specific revision
type AnomalyForRevision struct {
StartRevision int `json:"start_revision"`
EndRevision int `json:"end_revision"`
Anomaly Anomaly `json:"anomaly"`
Params map[string][]string `json:"params"`
TestPath string `json:"test_path"`
}
// GetKey returns a string representing a key based on the params for the anomaly.
func (anomaly *AnomalyForRevision) GetKey() string {
paramKeys := []string{"master", "bot", "benchmark", "test", "subtest_1", "subtest_2", "subtest_3", "subtest_4", "subtest_5"}
var sb strings.Builder
for _, key := range paramKeys {
paramValue, ok := anomaly.Params[key]
if ok {
sb.WriteString(paramValue[0])
sb.WriteString("-")
}
}
return sb.String()
}
// GetParamValue returns the value for the given param name in the anomaly.
// Returns empty string if the value is not present.
func (anomaly *AnomalyForRevision) GetParamValue(paramName string) string {
if paramValue, ok := anomaly.Params[paramName]; ok {
return paramValue[0]
}
return ""
}
// GetTestPath returns the test path representation for the anomaly.
// To maintain parity with the legacy dashboard, this testpath does not
// include the master/bot/benchmark portion of the path.
func (anomaly *AnomalyForRevision) GetTestPath() string {
var sb strings.Builder
sb.WriteString(anomaly.GetParamValue("test"))
subtestKeys := []string{"subtest_1", "subtest_2", "subtest_3", "subtest_4", "subtest_5"}
for _, key := range subtestKeys {
value := anomaly.GetParamValue(key)
if value == "" {
break
}
sb.WriteString("/")
sb.WriteString(value)
}
return sb.String()
}
// RevisionInfo defines struct to contain revision information
type RevisionInfo struct {
Master string `json:"master"`
Bot string `json:"bot"`
Benchmark string `json:"benchmark"`
StartRevision int `json:"start_revision"`
EndRevision int `json:"end_revision"`
StartTime int64 `json:"start_time"`
EndTime int64 `json:"end_time"`
TestPath string `json:"test"`
IsImprovement bool `json:"is_improvement"`
BugId string `json:"bug_id"`
ExploreUrl string `json:"explore_url"`
Query string `json:"query"`
AnomalyIds []string `json:"anomaly_ids"`
}
// GetAnomaliesRequest struct to request anomalies from the chromeperf api.
// The parameters can be one of below described.
// 1. Revision: Retrieves anomalies around that revision number.
// 2. Tests-MinRevision-MaxRevision: Retrieves anomalies for the given set of tests between the min and max revisions
type GetAnomaliesRequest struct {
Tests []string `json:"tests,omitempty"`
MaxRevision string `json:"max_revision,omitempty"`
MinRevision string `json:"min_revision,omitempty"`
Revision int `json:"revision,omitempty"`
}
type GetAnomaliesTimeBasedRequest struct {
Tests []string `json:"tests,omitempty"`
StartTime time.Time `json:"start_time,omitempty"`
EndTime time.Time `json:"end_time,omitempty"`
}
type GetAnomaliesResponse struct {
Anomalies map[string][]Anomaly `json:"anomalies"`
}
// ReportRegressionRequest provides a struct for the data that is sent over
// to chromeperf when a regression is detected.
type ReportRegressionRequest struct {
StartRevision int32 `json:"start_revision"`
EndRevision int32 `json:"end_revision"`
ProjectID string `json:"project_id"`
TestPath string `json:"test_path"`
IsImprovement bool `json:"is_improvement"`
BotName string `json:"bot_name"`
Internal bool `json:"internal_only"`
MedianBeforeAnomaly float32 `json:"median_before_anomaly"`
MedianAfterAnomaly float32 `json:"median_after_anomaly"`
}
// ReportRegressionResponse provides a struct to hold the response data
// returned by the add anomalies api.
type ReportRegressionResponse struct {
AnomalyId string `json:"anomaly_id"`
AlertGroupId string `json:"alert_group_id"`
}
// AnomalyApiClient provides interface to interact with chromeperf "anomalies" api
type AnomalyApiClient interface {
// ReportRegression sends regression information to chromeperf.
ReportRegression(ctx context.Context, testPath string, startCommitPosition int32, endCommitPosition int32, projectId string, isImprovement bool, botName string, internal bool, medianBefore float32, medianAfter float32) (*ReportRegressionResponse, error)
// GetAnomalyFromUrlSafeKey returns the anomaly details based on the urlsafe key.
GetAnomalyFromUrlSafeKey(ctx context.Context, key string) (map[string][]string, Anomaly, error)
// GetAnomalies retrieves anomalies for a given set of traces within the supplied commit positions.
GetAnomalies(ctx context.Context, traceNames []string, startCommitPosition int, endCommitPosition int) (AnomalyMap, error)
// GetAnomaliesTimeBased retrieves anomalies for a given set of traces within the supplied commit positions.
GetAnomaliesTimeBased(ctx context.Context, traceNames []string, startTime time.Time, endTime time.Time) (AnomalyMap, error)
// GetAnomaliesAroundRevision retrieves traces with anomalies that were generated around a specific commit
GetAnomaliesAroundRevision(ctx context.Context, revision int) ([]AnomalyForRevision, error)
}
// anomalyApiClientImpl implements AnomalyApiClient
type anomalyApiClientImpl struct {
chromeperfClient ChromePerfClient
perfGit perfgit.Git
sendAnomalyCalled metrics2.Counter
sendAnomalyFailed metrics2.Counter
getAnomaliesCalled metrics2.Counter
getAnomaliesFailed metrics2.Counter
}
// NewAnomalyApiClient returns a new AnomalyApiClient instance.
func NewAnomalyApiClient(ctx context.Context, git perfgit.Git) (AnomalyApiClient, error) {
cpClient, err := NewChromePerfClient(ctx, "", false)
if err != nil {
return nil, err
}
return newAnomalyApiClient(cpClient, git), nil
}
func newAnomalyApiClient(cpClient ChromePerfClient, git perfgit.Git) AnomalyApiClient {
return &anomalyApiClientImpl{
chromeperfClient: cpClient,
perfGit: git,
sendAnomalyCalled: metrics2.GetCounter("chrome_perf_send_anomaly_called"),
sendAnomalyFailed: metrics2.GetCounter("chrome_perf_send_anomaly_failed"),
getAnomaliesCalled: metrics2.GetCounter("chrome_perf_get_anomalies_called"),
getAnomaliesFailed: metrics2.GetCounter("chrome_perf_get_anomalies_failed"),
}
}
// ReportRegression implements AnomalyApiClient, sends regression information to chromeperf.
func (cp *anomalyApiClientImpl) ReportRegression(
ctx context.Context,
testPath string,
startCommitPosition int32,
endCommitPosition int32,
projectId string,
isImprovement bool,
botName string,
internal bool,
medianBefore float32,
medianAfter float32) (*ReportRegressionResponse, error) {
request := &ReportRegressionRequest{
TestPath: testPath,
StartRevision: startCommitPosition,
EndRevision: endCommitPosition,
ProjectID: projectId,
IsImprovement: isImprovement,
BotName: botName,
Internal: internal,
MedianBeforeAnomaly: medianBefore,
MedianAfterAnomaly: medianAfter,
}
acceptedStatusCodes := []int{
200, // Success
404, // NotFound - This is returned if the param value names are different.
}
response := ReportRegressionResponse{}
err := cp.chromeperfClient.SendPostRequest(ctx, AnomalyAPIName, AddFuncName, request, &response, acceptedStatusCodes)
if err != nil {
cp.sendAnomalyFailed.Inc(1)
return nil, skerr.Wrapf(err, "Failed to get chrome perf response when sending anomalies.")
}
cp.sendAnomalyCalled.Inc(1)
return &response, nil
}
// GetAnomalyFromUrlSafeKey returns the anomaly details based on the urlsafe key.
func (cp *anomalyApiClientImpl) GetAnomalyFromUrlSafeKey(ctx context.Context, key string) (map[string][]string, Anomaly, error) {
getAnomaliesResp := &GetAnomaliesResponse{}
var anomaly Anomaly
err := cp.chromeperfClient.SendGetRequest(ctx, AnomalyAPIName, GetFuncName, url.Values{"key": {key}}, getAnomaliesResp)
if err != nil {
return nil, anomaly, skerr.Wrapf(err, "Failed to get anomaly data based on url safe key: %s", key)
}
var queryParams map[string][]string
if len(getAnomaliesResp.Anomalies) > 0 {
for testPath, respAnomaly := range getAnomaliesResp.Anomalies {
queryParams = getParams(testPath)
anomaly = respAnomaly[0]
break
}
}
return queryParams, anomaly, nil
}
// GetAnomalies implements AnomalyApiClient, it calls chrome perf API to fetch anomlies.
func (cp *anomalyApiClientImpl) GetAnomalies(ctx context.Context, traceNames []string, startCommitPosition int, endCommitPosition int) (AnomalyMap, error) {
testPathes := make([]string, 0)
testPathTraceNameMap := make(map[string]string)
for _, traceName := range traceNames {
// Build chrome perf test_path from skia perf traceName
testPath, stat, err := traceNameToTestPath(traceName)
if err != nil {
sklog.Errorf("Failed to build chrome perf test path from trace name %q: %s", traceName, err)
} else if stat == "value" { // We will only show anomalies for the traces of the 'value' stat.
testPathes = append(testPathes, testPath)
testPathTraceNameMap[testPath] = traceName
}
}
if len(testPathes) > 0 {
cp.getAnomaliesCalled.Inc(1)
// Call Chrome Perf API to fetch anomalies.
request := &GetAnomaliesRequest{
Tests: testPathes,
MaxRevision: strconv.Itoa(endCommitPosition),
MinRevision: strconv.Itoa(startCommitPosition),
}
getAnomaliesResp := &GetAnomaliesResponse{}
err := cp.chromeperfClient.SendPostRequest(ctx, AnomalyAPIName, FindFuncName, *request, getAnomaliesResp, []int{200})
if err != nil {
return nil, skerr.Wrapf(err, "Failed to call chrome perf endpoint.")
}
return cp.getAnomalyMapFromChromePerfResult(ctx, getAnomaliesResp, testPathTraceNameMap), nil
}
return AnomalyMap{}, nil
}
func (cp *anomalyApiClientImpl) GetAnomaliesTimeBased(ctx context.Context, traceNames []string, startTime time.Time, endTime time.Time) (AnomalyMap, error) {
testPaths := make([]string, 0)
testPathTraceNameMap := make(map[string]string)
for _, traceName := range traceNames {
// Build chrome perf test_path from skia perf traceName
testPath, stat, err := traceNameToTestPath(traceName)
if err != nil {
sklog.Errorf("Failed to build chrome perf test path from trace name %q: %s", traceName, err)
} else if stat == "value" { // We will only show anomalies for the traces of the 'value' stat.
testPaths = append(testPaths, testPath)
testPathTraceNameMap[testPath] = traceName
}
}
if len(testPaths) > 0 {
cp.getAnomaliesCalled.Inc(1)
// Call Chrome Perf API to fetch anomalies.
request := &GetAnomaliesTimeBasedRequest{
Tests: testPaths,
StartTime: startTime,
EndTime: endTime,
}
getAnomaliesResp := &GetAnomaliesResponse{}
err := cp.chromeperfClient.SendPostRequest(ctx, AnomalyAPIName, FindTimeFuncName, *request, getAnomaliesResp, []int{200})
if err != nil {
return nil, skerr.Wrapf(err, "Failed to call chrome perf endpoint.")
}
return cp.getAnomalyMapFromChromePerfResult(ctx, getAnomaliesResp, testPathTraceNameMap), nil
}
return AnomalyMap{}, nil
}
// GetAnomaliesAroundRevision implements AnomalyApiClient, returns anomalies around the given revision.
func (cp *anomalyApiClientImpl) GetAnomaliesAroundRevision(ctx context.Context, revision int) ([]AnomalyForRevision, error) {
request := &GetAnomaliesRequest{
Revision: revision,
}
getAnomaliesResp := &GetAnomaliesResponse{}
err := cp.chromeperfClient.SendPostRequest(ctx, AnomalyAPIName, FindFuncName, *request, getAnomaliesResp, []int{200})
if err != nil {
return nil, skerr.Wrapf(err, "Failed to call chrome perf endpoint.")
}
response := []AnomalyForRevision{}
for testPath, cpAnomalies := range getAnomaliesResp.Anomalies {
for _, anomaly := range cpAnomalies {
params := getParams(testPath)
if len(testPath) > 0 && len(params) == 0 {
return nil, skerr.Fmt("Test path likely has more params than expected. Test path: %s", testPath)
}
response = append(response, AnomalyForRevision{
TestPath: testPath,
StartRevision: anomaly.StartRevision,
EndRevision: anomaly.EndRevision,
Anomaly: anomaly,
Params: params,
})
}
}
return response, nil
}
// traceNameToTestPath converts trace name to Chrome Perf test path.
// For example, for the trace name, ",benchmark=Blazor,bot=MacM1,
// master=ChromiumPerf,test=timeToFirstContentfulPaint_avg,subtest_1=111,
// subtest_2=222,...subtest_7=777,unit=microsecond,improvement_direction=up,"
// test path will be: "ChromiumPerf/MacM1/Blazor/timeToFirstContentfulPaint_avg
// /111/222/.../777"
// It also returns the trace statistics like 'value', 'sum', 'max', 'min' or 'error'
func traceNameToTestPath(traceName string) (string, string, error) {
keyValueEquations := strings.Split(traceName, ",")
if len(keyValueEquations) == 0 {
return "", "", fmt.Errorf("Cannot build test path from trace name: %q.", traceName)
}
paramKeyValueMap := map[string]string{}
for _, keyValueEquation := range keyValueEquations {
keyValueArray := strings.Split(keyValueEquation, "=")
if len(keyValueArray) == 2 {
paramKeyValueMap[keyValueArray[0]] = keyValueArray[1]
}
}
statistics := ""
if val, ok := paramKeyValueMap["stat"]; ok {
statistics = val
}
testPath := ""
if val, ok := paramKeyValueMap["master"]; ok {
testPath += val
} else {
return "", "", fmt.Errorf("Cannot get master from trace name: %q.", traceName)
}
if val, ok := paramKeyValueMap["bot"]; ok {
testPath += "/" + val
} else {
return "", "", fmt.Errorf("Cannot get bot from trace name: %q.", traceName)
}
if val, ok := paramKeyValueMap["benchmark"]; ok {
testPath += "/" + val
} else {
return "", "", fmt.Errorf("Cannot get benchmark from trace name: %q.", traceName)
}
if val, ok := paramKeyValueMap["test"]; ok {
testPath += "/" + val
} else {
return "", "", fmt.Errorf("Cannot get test from trace name: %q.", traceName)
}
for i := 1; i <= 7; i++ {
key := "subtest_" + strconv.Itoa(i)
if val, ok := paramKeyValueMap[key]; ok {
testPath += "/" + val
} else {
break
}
}
return testPath, statistics, nil
}
func (cp *anomalyApiClientImpl) getAnomalyMapFromChromePerfResult(ctx context.Context, getAnomaliesResp *GetAnomaliesResponse, testPathTraceNameMap map[string]string) AnomalyMap {
result := AnomalyMap{}
for testPath, anomalyArr := range getAnomaliesResp.Anomalies {
if traceName, ok := testPathTraceNameMap[testPath]; !ok {
sklog.Errorf("Got unknown test path %s from chrome perf for testPathTraceNameMap: %s", testPath, testPathTraceNameMap)
} else {
commitNumberAnomalyMap := CommitNumberAnomalyMap{}
for _, anomaly := range anomalyArr {
if anomaly.EndRevisionHash == "" {
commitNumberAnomalyMap[types.CommitNumber(anomaly.EndRevision)] = anomaly
} else {
commitNum, err := cp.perfGit.CommitNumberFromGitHash(ctx, anomaly.EndRevisionHash)
if err != nil {
sklog.Errorf("Unable to find commit number for git hash %s", anomaly.EndRevisionHash)
continue
}
commitNumberAnomalyMap[commitNum] = anomaly
}
}
result[traceName] = commitNumberAnomalyMap
}
}
return result
}
func getParams(testPath string) map[string][]string {
params := map[string][]string{}
testPathParts := strings.Split(testPath, "/")
if len(testPathParts) == 0 {
return params
}
paramKeys := []string{"master", "bot", "benchmark", "test", "subtest_1", "subtest_2", "subtest_3", "subtest_4", "subtest_5"}
if len(testPathParts) > len(paramKeys) {
return params
}
i := 0
for _, testPart := range testPathParts {
for _, invalidChar := range invalidChars {
testPart = strings.ReplaceAll(testPart, invalidChar, "_")
}
if _, ok := params[paramKeys[i]]; !ok {
params[paramKeys[i]] = []string{testPart}
} else {
params[paramKeys[i]] = append(params[paramKeys[i]], testPart)
}
i++
}
return params
}