| package alerts |
| |
| import ( |
| "encoding/json" |
| "fmt" |
| "net/url" |
| "sort" |
| "strconv" |
| "strings" |
| |
| "go.skia.org/infra/go/paramtools" |
| "go.skia.org/infra/go/skerr" |
| "go.skia.org/infra/perf/go/types" |
| ) |
| |
| const ( |
| // BadAlertID is the value of an Alert.ID if it is invalid, i.e. hasn't |
| // been stored yet. |
| // |
| // TODO(jcgregorio) Make Alert.ID its own type and BadAlertID and |
| // instance of that type. |
| BadAlertID = int64(-1) |
| |
| // BadAlertIDAsString is the value of an Alert.ID if it is invalid, i.e. |
| // hasn't been stored yet. |
| BadAlertIDAsAsString = "-1" |
| ) |
| |
| var ( |
| // DefaultSparse is the default value for Config.Sparse. |
| DefaultSparse = false |
| ) |
| |
| // Direction a step takes that will cause an alert. |
| type Direction string |
| |
| // The values for the Direction enum. Run 'go generate' if you |
| // add/remove/update these values. You must have 'stringer' installed, i.e. |
| // |
| // go get golang.org/x/tools/cmd/stringer |
| const ( |
| BOTH Direction = "BOTH" |
| UP Direction = "UP" |
| DOWN Direction = "DOWN" |
| ) |
| |
| // AllDirections is a list of all possible Direction values. |
| var AllDirections = []Direction{ |
| UP, |
| DOWN, |
| BOTH, |
| } |
| |
| // ConfigState is the current state of an alerts.Config. |
| type ConfigState string |
| |
| // The values for the AlertConfigState enum. Run 'go generate' if you |
| // add/remove/update these values. You must have 'stringer' installed, i.e. |
| // |
| // go get golang.org/x/tools/cmd/stringer |
| const ( |
| ACTIVE ConfigState = "ACTIVE" |
| DELETED ConfigState = "DELETED" |
| ) |
| |
| // AllConfigState is a list of all possible ConfigState values. |
| var AllConfigState = []ConfigState{ |
| ACTIVE, |
| DELETED, |
| } |
| |
| // ConfigStateToInt converts the string ConfigState into an int, which it used |
| // to be, used only when storing Alerts. |
| func ConfigStateToInt(c ConfigState) int { |
| if c == DELETED { |
| return 1 |
| } |
| return 0 |
| } |
| |
| // SerializesToString adds custom JSON marshalling to an int64 so it serializes |
| // to/from a string, which is needed when sending int64's to the browser, |
| // including serializing int64(0) to the empty string "". |
| // |
| // Needed because: https://github.com/golang/go/issues/47102 |
| type SerializesToString int64 |
| |
| // UnmarshalJSON implements json.Unmarshaler |
| func (s *SerializesToString) UnmarshalJSON(b []byte) error { |
| var asString string |
| if err := json.Unmarshal(b, &asString); err != nil { |
| return skerr.Wrap(err) |
| } |
| if asString == "" { |
| *s = 0 |
| return nil |
| } |
| i, err := strconv.ParseInt(asString, 10, 64) |
| if err != nil { |
| return skerr.Wrap(err) |
| } |
| *s = SerializesToString(i) |
| |
| return nil |
| } |
| |
| // MarshalJSON implements json.Marshaler. |
| func (s SerializesToString) MarshalJSON() ([]byte, error) { |
| if s == InvalidIssueTrackerComponent { |
| return json.Marshal("") |
| } |
| return json.Marshal(fmt.Sprintf("%d", s)) |
| } |
| |
| // InvalidIssueTrackerComponent indicates the issue tracker component hasn't |
| // been set. |
| const InvalidIssueTrackerComponent = 0 |
| |
| // Alert represents the configuration for one alert. |
| type Alert struct { |
| // We need to keep the int64 version of the ID around to support Cloud |
| // Datastore. Once everyone migrates to SQL backed datastores it can be |
| // removed. |
| IDAsString string `json:"id_as_string" ` |
| DisplayName string `json:"display_name" ` |
| Query string `json:"query" ` // The query to perform on the trace store to select the traces to alert on. |
| Alert string `json:"alert" ` // Email address to send alerts to. |
| IssueTrackerComponent SerializesToString `json:"issue_tracker_component" go2ts:"string"` // The issue tracker component to send alerts to. |
| Interesting float32 `json:"interesting" ` // The regression interestingness threshold. |
| BugURITemplate string `json:"bug_uri_template"` // URI Template used for reporting bugs. Format TBD. |
| Algo types.RegressionDetectionGrouping `json:"algo" ` // Which clustering algorithm to use. |
| Step types.StepDetection `json:"step" ` |
| |
| // Which algorithm to use to detect steps. |
| StateAsString ConfigState `json:"state" ` // The state of the config. |
| Owner string `json:"owner" ` // Email address of the person that owns this alert. |
| StepUpOnly bool `json:"step_up_only"` // If true then only steps up will trigger an alert. [Deprecated, use DirectionAsString.] |
| |
| // Direction is here to support the legacy format of Alerts where Direction |
| // was an integer enum, with 0 = BOTH, 1 = UP, and 2 = DOWN. This is only |
| // needed for Cloud Datastore, not SQL backed stores. This can be deleted |
| // after migrating away from Cloud Datastore. |
| Direction int `json:"-" ` |
| DirectionAsString Direction `json:"direction" ` // Which direction will trigger an alert. |
| Radius int `json:"radius" ` // How many commits to each side of a commit to consider when looking for a step. 0 means use the server default. |
| K int `json:"k" ` // The K in k-means clustering. 0 means use an algorithmically chosen value based on the data. |
| GroupBy string `json:"group_by" ` // A comma separated list of keys in the paramset that all Clustering should be broken up across. Keys must not appear in Query. |
| Sparse bool `json:"sparse" ` // Data is sparse, so only include commits that have data. |
| MinimumNum int `json:"minimum_num"` // How many traces need to be found interesting before an alert is fired. |
| Category string `json:"category" ` // Which category this alert falls into. |
| |
| // Action to take for this alert. It could be none, report or bisect. |
| Action types.AlertAction `json:"action,omitempty"` // What action should be taken by the detected anomalies. |
| |
| // Subscription fields. |
| SubscriptionName string `json:"sub_name,omitempty"` |
| SubscriptionRevision string `json:"sub_revision,omitempty"` |
| } |
| |
| type AlertsStatus struct { |
| Alerts int `json:"alerts"` |
| } |
| |
| // SetIDFromInt64 sets both the integer and string IDs. |
| func (c *Alert) SetIDFromInt64(id int64) { |
| c.IDAsString = fmt.Sprintf("%d", id) |
| } |
| |
| // IDAsStringToInt returns the IDAsString as an int64. |
| // |
| // An invalid alert id (-1) will be returned if the string can't be parsed. |
| func (c *Alert) IDAsStringToInt() int64 { |
| return IDAsStringToInt(c.IDAsString) |
| } |
| |
| // IDAsStringToInt returns the IDAsString as an int64. |
| // |
| // An invalid alert id (-1) will be returned if the string can't be parsed. |
| func IDAsStringToInt(s string) int64 { |
| i, err := strconv.ParseInt(s, 10, 64) |
| if err != nil { |
| return BadAlertID |
| } |
| return i |
| } |
| |
| // IDToString returns the alerts ID formatted as a string. |
| func IDToString(id int64) string { |
| return fmt.Sprintf("%d", id) |
| } |
| |
| // StateToInt converts the State into an int which is used when storing Alerts. |
| func (c *Alert) StateToInt() int { |
| return ConfigStateToInt(c.StateAsString) |
| } |
| |
| // SetIDFromString sets the Alerts ID to the parsed value of the string. |
| // |
| // An invalid alert id (-1) will be set if the string can't be parsed. |
| func (c *Alert) SetIDFromString(s string) { |
| c.IDAsString = s |
| } |
| |
| // GroupedBy returns the parsed GroupBy value as a slice of strings. |
| func (c *Alert) GroupedBy() []string { |
| ret := []string{} |
| for _, s := range strings.Split(c.GroupBy, ",") { |
| s = strings.TrimSpace(s) |
| if s == "" { |
| continue |
| } |
| ret = append(ret, s) |
| } |
| return ret |
| } |
| |
| // KeyValue holds a single Params key and value, used in 'Combination'. |
| type KeyValue struct { |
| Key string |
| Value string |
| } |
| |
| // Combination is a slice of KeyValue's, returned from GroupCombinations. |
| type Combination []KeyValue |
| |
| func newCombinationFromParams(keys []string, p paramtools.Params) Combination { |
| ret := Combination{} |
| for _, key := range keys { |
| ret = append(ret, KeyValue{Key: key, Value: p[key]}) |
| } |
| return ret |
| } |
| |
| // GroupCombinations returns a slice of Combinations that represent |
| // all the GroupBy combinations possible for the given ParamSet. |
| // |
| // I.e. for: |
| // |
| // ps := paramtools.ParamSet{ |
| // "model": []string{"nexus4", "nexus6", "nexus6"}, |
| // "config": []string{"565", "8888", "nvpr"}, |
| // "arch": []string{"ARM", "x86"}, |
| // } |
| // |
| // the GroupCombinations for a GroupBy of "config, arch" would be: |
| // |
| // []Combination{ |
| // Combination{KeyValue{"arch", "ARM"}, KeyValue{"config", "565"}}, |
| // Combination{KeyValue{"arch", "ARM"}, KeyValue{"config", "8888"}}, |
| // Combination{KeyValue{"arch", "ARM"}, KeyValue{"config", "nvpr"}}, |
| // Combination{KeyValue{"arch", "x86"}, KeyValue{"config", "565"}}, |
| // Combination{KeyValue{"arch", "x86"}, KeyValue{"config", "8888"}}, |
| // Combination{KeyValue{"arch", "x86"}, KeyValue{"config", "nvpr"}}, |
| // } |
| func (c *Alert) GroupCombinations(ps paramtools.ReadOnlyParamSet) ([]Combination, error) { |
| keys := c.GroupedBy() |
| sort.Strings(keys) |
| paramsCh, err := ps.CartesianProduct(keys) |
| if err != nil { |
| return nil, skerr.Wrapf(err, "invalid GroupBy") |
| } |
| |
| ret := []Combination{} |
| for p := range paramsCh { |
| ret = append(ret, newCombinationFromParams(keys, p)) |
| } |
| |
| return ret, nil |
| } |
| |
| // QueriesFromParamset uses GroupCombinations to produce the full set of |
| // queries that this Config represents. |
| func (c *Alert) QueriesFromParamset(paramset paramtools.ReadOnlyParamSet) ([]string, error) { |
| ret := []string{} |
| if len(c.GroupBy) != 0 { |
| allCombinations, err := c.GroupCombinations(paramset) |
| if err != nil { |
| return nil, fmt.Errorf("Failed to build GroupBy combinations: %s", err) |
| } |
| for _, combo := range allCombinations { |
| parsed, err := url.ParseQuery(c.Query) |
| if err != nil { |
| return nil, fmt.Errorf("Found invalid query %q: %s", c.Query, err) |
| } |
| for _, kv := range combo { |
| parsed[kv.Key] = []string{kv.Value} |
| } |
| ret = append(ret, parsed.Encode()) |
| } |
| } else { |
| ret = append(ret, c.Query) |
| } |
| return ret, nil |
| } |
| |
| // Validate returns true if the Alert is valid. |
| func (c *Alert) Validate() error { |
| parsed, err := url.ParseQuery(c.Query) |
| if err != nil { |
| return fmt.Errorf("Invalid Config: Invalid Query: %s", err) |
| } |
| if c.GroupBy != "" { |
| for _, groupParam := range c.GroupedBy() { |
| if _, ok := parsed[groupParam]; ok { |
| return fmt.Errorf("Invalid Config: Group By values %q must not appear in the Query: %q ", c.GroupBy, c.Query) |
| } |
| } |
| } |
| if c.StepUpOnly { |
| c.StepUpOnly = false |
| c.DirectionAsString = UP |
| } |
| return nil |
| } |
| |
| // NewConfig creates a new Config properly initialized. |
| func NewConfig() *Alert { |
| return &Alert{ |
| IDAsString: fmt.Sprintf("%d", BadAlertID), |
| Algo: types.KMeansGrouping, |
| StateAsString: ACTIVE, |
| Sparse: DefaultSparse, |
| DirectionAsString: BOTH, |
| } |
| } |