Skip to content

Commit

Permalink
Merge pull request #154 from synyx/range-rules
Browse files Browse the repository at this point in the history
Range rules
  • Loading branch information
BuJo authored Nov 20, 2023
2 parents 50335ca + c2c152f commit 6dc73cb
Show file tree
Hide file tree
Showing 5 changed files with 181 additions and 86 deletions.
15 changes: 13 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Releases

## v1.x

* Add a `when` rule to be able to match on when the alert happened.
* Add more expressive rules, making it possible to express a
`greater than` on numerical values. See `README` for details.
* Breaking configuration changes:
* The `what` rules are now combined via `AND` with label rules.
This streamlines the behaviour, making it behave like the label
rules themselves. It also makes it possible e.g. to express that
a rule matcher only applies when the alert is old.

## v1.0 - 2023-10-23 Maintenance

* Revise look of alerts
Expand Down Expand Up @@ -45,8 +56,8 @@
## v0.14 - 2023-05-16 Stability

* Make management port configurable via `-mgmtAddr :8987`
* Add net/pprof for debugging, see http://127.0.0.1:8987/debug/pprof
* Add Down state for health endpoint if last collection too old
* Add `net/pprof` for debugging, see http://127.0.0.1:8987/debug/pprof
* Add `Down` state for health endpoint if last collection too old

Breaking Changes:

Expand Down
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,32 @@ what = "fooo service"
```toml
[[rule]]
description = "Ignore Drafts"
what = "Thing"
when = "> 60"
[rule.label]
Draft = "true"
```

* The `label` section selects items via labels. In this example it would match
an item which has the label `Draft` which matches the given regular expression.
* The label rules will combine as `AND`.
* `what` rules will combine as `OR` with label rules.
* `what` rules will combine as `AND` with label rules.
* `when` rules will combine with `AND` with label and `what` rules.

#### Matching Rules

The default is to match the value in the configuration as a regular expression.
However, this can be changed by specifying an operator.

* `~= string`: Explicitly require a regular expression to be matched
* `= string`: Require the string to exactly match. In case the value is
numeric, this will mean that the value will compared like a floating point
value. This means that differences below `1e-8` will be considered to be
the same.
* `> number`: Require both configuration and the value in the alert to be a
numerical value and that the value in the alert to be bigger than the
configured number.
This also applies to the `<`, `>=`, `<=` operators.

## License

Expand Down
106 changes: 30 additions & 76 deletions pkg/aggregation/aggregator.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import (
"context"
"fmt"
html "html/template"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
Expand Down Expand Up @@ -387,108 +387,62 @@ func (a *Aggregator) Reconfigure(cfg *config.Config) {

// allow will match rules against the ruleset.
func (a *Aggregator) allow(dashboard *config.Dashboard, alert Alert) string {
reason := a.matchAlertWithReason(dashboard, alert)

switch dashboard.Mode {
case config.Including:
return a.allowIncluding(dashboard, alert)
// Revert logic when the dashboard configuration is in `including` mode.
if reason == "" {
return "Unmatched"
} else {
return ""
}
case config.Excluding:
return a.allowExcluding(dashboard, alert)
return reason
}
panic("unknown mode: " + dashboard.Mode.String())
}

// allowIncluding will allow anything which matches the configured rules.
func (a *Aggregator) allowIncluding(dashboard *config.Dashboard, alert Alert) string {
// matchAlertWithReason will match anything which does match against any of the
// configured rules.
func (a *Aggregator) matchAlertWithReason(dashboard *config.Dashboard, alert Alert) string {
nextRule:
for _, rule := range dashboard.Filter {
// if it's a rule working on the `what`:
// `what` contains a description what is being alerted and should be a
// human understandable description. The rule simply matches against
// that.
// A match in including mode means, that this should be shown.
if rule.What != nil && rule.What.MatchString(alert.What) {
return ""
}

// If there are no label rules, skip label matching
if len(rule.Labels) == 0 {
continue nextRule
}

res := make(map[string]*regexp.Regexp)

// Test if the rule is applicable to the given alert
for l, r := range rule.Labels {
if x, ok := alert.Labels[l]; !ok {
// if the label does not exist on the alert, it cannot match
// thus skip this rule and try the next one
continue nextRule
} else {
res[x] = r
}
}
matchers := make(map[string]config.RuleMatcher)

// All fields of the rule exist as labels on the alert:
// If all the labels match the rule, a match is found,
// meaning the rules are combined via `AND`.
matchCount := 0
for a, b := range res {
if b.MatchString(a) {
matchCount++
}
}
if matchCount == len(res) {
return ""
}
}

// getting here in including mode means that no rule matched, thus
// the item should not be shown.
return "unmatched"
}

// allowExcluding will allow anything which does not match against any of the
// // configured rules.
func (a *Aggregator) allowExcluding(dashboard *config.Dashboard, alert Alert) string {
nextRule:
for _, rule := range dashboard.Filter {
// if it's a rule working on the `what`:
// `what` contains a description what is being alerted and should be a
// human understandable description. The rule simply matches against
// that.
// Continue with other matchers if there is no `what` rule, or it doesn't
// match, thus combining with other matchers via OR.
// if it's a rule working on top level concepts:
if rule.What != nil && rule.What.MatchString(alert.What) {
return rule.Description
}

// If there are no label rules, skip label matching
if len(rule.Labels) == 0 {
continue nextRule
// `what` contains a description what is being alerted and should be a
// human understandable description. The rule simply matches against
// that.
matchers[alert.What] = rule.What
} else if rule.When != nil {
// `when` is a duration, which is converted to seconds. The rule simply matches against
// that.
seconds := strconv.FormatFloat(alert.When.Seconds(), 'f', 0, 64)
matchers[seconds] = rule.When
}

res := make(map[string]*regexp.Regexp)

// Test if the rule is applicable to the given alert
// Test if any of the labels are applicable to the given alert
for l, r := range rule.Labels {
if x, ok := alert.Labels[l]; !ok {
// if the label does not exist on the alert, it cannot match
// thus skip this rule and try the next one
continue nextRule
} else {
res[x] = r
matchers[x] = r
}
}

// All fields of the rule exist as labels on the alert:
// If all the labels match the rule, a match is found,
// If all the applicable matchers return a match, this rule matches,
// meaning the rules are combined via `AND`.
matchCount := 0
for a, b := range res {
if b.MatchString(a) {
for alertValue, matcher := range matchers {
if matcher.MatchString(alertValue) {
matchCount++
}
}
if matchCount == len(res) {
if matchCount == len(matchers) {
return rule.Description
}
}
Expand Down
19 changes: 12 additions & 7 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
"text/template"
"time"
Expand Down Expand Up @@ -57,8 +56,9 @@ type Dashboard struct {

type Rule struct {
Description string
What *regexp.Regexp
Labels map[string]*regexp.Regexp
What RuleMatcher
When RuleMatcher
Labels map[string]RuleMatcher
}

type mainConfig struct {
Expand Down Expand Up @@ -274,20 +274,25 @@ func (cfg *Config) loadDashboardConfig(file string) error {
}

func parseRule(r map[string]interface{}) Rule {
labels := make(map[string]*regexp.Regexp)
labels := make(map[string]RuleMatcher)
if labelFilters, ok := r["label"]; ok {
for n, l := range labelFilters.(map[string]interface{}) {
labels[n] = regexp.MustCompile(l.(string))
labels[n] = ParseRuleMatcher(l.(string))
}
}
var what *regexp.Regexp
var what RuleMatcher
if w, ok := r["what"]; ok {
what = regexp.MustCompile(w.(string))
what = ParseRuleMatcher(w.(string))
}
var when RuleMatcher
if w, ok := r["when"]; ok {
when = ParseRuleMatcher(w.(string))
}

br := Rule{
Description: r["description"].(string),
What: what,
When: when,
Labels: labels,
}
return br
Expand Down
107 changes: 107 additions & 0 deletions pkg/config/rule_matching.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package config

import (
"math"
"regexp"
"strconv"
)

const (
gt = iota
eq
ge
lt
le
)

type RuleMatcher interface {
MatchString(s string) bool
}

var prefixMatcher = regexp.MustCompile(`^(~=|>|<|=|<=|>=)\s+(.*)`)

func ParseRuleMatcher(value string) RuleMatcher {
matches := prefixMatcher.FindStringSubmatch(value)
if matches != nil {
prefix := matches[1]
value := matches[2]
switch prefix {
case "~=":
return regexpMatcher{regexp.MustCompile(value)}
case ">":
return newNumberMatcher(gt, value)
case "=":
if _, err := strconv.ParseFloat(value[2:], 64); err == nil {
return newNumberMatcher(eq, value[2:])
} else {
return equalityMatcher{value[2:]}
}
case "<":
return newNumberMatcher(lt, value)
case "<=":
return newNumberMatcher(le, value)
case ">=":
return newNumberMatcher(ge, value)
}
}

return regexpMatcher{regexp.MustCompile(value)}
}

type regexpMatcher struct {
r *regexp.Regexp
}

func (m regexpMatcher) MatchString(s string) bool {
return m.r.MatchString(s)
}

type numberMatcher struct {
operation int
number float64
}

func newNumberMatcher(op int, s string) numberMatcher {
number, err := strconv.ParseFloat(s, 64)
if err != nil {
panic("config parsing error")
}
return numberMatcher{op, number}
}

func (m numberMatcher) MatchString(s string) bool {
number, err := strconv.ParseFloat(s, 64)
if err != nil {
// an invalid number cannot be compared, thus returns as non-matching
return false
}

switch m.operation {
case gt:
return !floatEqualEnough(m.number, number) && number > m.number
case eq:
return floatEqualEnough(m.number, number)
case ge:
return floatEqualEnough(m.number, number) || number > m.number
case lt:
return !floatEqualEnough(m.number, number) && number < m.number
case le:
return floatEqualEnough(m.number, number) || number < m.number
}

return false
}

const epsilon = 1e-8

func floatEqualEnough(a, b float64) bool {
return math.Abs(a-b) <= epsilon
}

type equalityMatcher struct {
s string
}

func (m equalityMatcher) MatchString(s string) bool {
return m.s == s
}

0 comments on commit 6dc73cb

Please sign in to comment.