blob: 90f366e98bd2a1708c5de8282eb19b94a39ee7bf [file] [log] [blame]
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.
}
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,
}
}