Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Range rules #154

Merged
merged 9 commits into from
Nov 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 ""
BuJo marked this conversation as resolved.
Show resolved Hide resolved
}
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
}