Like Prometheus, but for logs.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
loki/pkg/tool/rules/rules.go

273 lines
7.5 KiB

package rules
import (
"fmt"
"strings"
"github.com/prometheus/prometheus/model/rulefmt"
"github.com/prometheus/prometheus/promql/parser"
log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
logql "github.com/grafana/loki/v3/pkg/logql/syntax"
"github.com/grafana/loki/v3/pkg/tool/rules/rwrulefmt"
)
// RuleNamespace is used to parse a slightly modified prometheus
// rule file format, if no namespace is set, the default namespace
// is used. Namespace is functionally the same as a file name.
type RuleNamespace struct {
// Namespace field only exists for setting namespace in namespace body instead of file name
Namespace string `yaml:"namespace,omitempty"`
Filepath string `yaml:"-"`
Groups []rwrulefmt.RuleGroup `yaml:"groups"`
}
// LintExpressions runs the `expr` from a rule through the PromQL or LogQL parser and
// compares its output. If it differs from the parser, it uses the parser's instead.
func (r RuleNamespace) LintExpressions() (int, int, error) {
var parseFn func(string) (fmt.Stringer, error)
queryLanguage := "LogQL"
parseFn = func(s string) (fmt.Stringer, error) {
return logql.ParseExpr(s)
}
// `count` represents the number of rules we evalated.
// `mod` represents the number of rules linted.
var count, mod int
for i, group := range r.Groups {
for j, rule := range group.Rules {
log.WithFields(log.Fields{"rule": getRuleName(rule)}).Debugf("linting %s", queryLanguage)
exp, err := parseFn(rule.Expr)
if err != nil {
return count, mod, err
}
count++
if rule.Expr != exp.String() {
log.WithFields(log.Fields{
"rule": getRuleName(rule),
"currentExpr": rule.Expr,
"afterExpr": exp.String(),
}).Debugf("expression differs")
mod++
r.Groups[i].Rules[j].Expr = exp.String()
}
}
}
return count, mod, nil
}
// CheckRecordingRules checks that recording rules have at least one colon in their name, this is based
// on the recording rules best practices here: https://prometheus.io/docs/practices/rules/
// Returns the number of rules that don't match the requirements.
func (r RuleNamespace) CheckRecordingRules(strict bool) int {
var name string
var count int
reqChunks := 2
if strict {
reqChunks = 3
}
for _, group := range r.Groups {
for _, rule := range group.Rules {
// Assume if there is a rule.Record that this is a recording rule.
if rule.Record == "" {
continue
}
name = rule.Record
log.WithFields(log.Fields{"rule": name}).Debugf("linting recording rule name")
chunks := strings.Split(name, ":")
if len(chunks) < reqChunks {
count++
log.WithFields(log.Fields{
"rule": getRuleName(rule),
"ruleGroup": group.Name,
"file": r.Filepath,
"error": "recording rule name does not match level:metric:operation format, must contain at least one colon",
}).Errorf("bad recording rule name")
}
}
}
return count
}
// AggregateBy modifies the aggregation rules in groups to include a given Label.
// If the applyTo function is provided, the aggregation is applied only to rules
// for which the applyTo function returns true.
func (r RuleNamespace) AggregateBy(label string, applyTo func(group rwrulefmt.RuleGroup, rule rulefmt.Rule) bool) (int, int, error) {
// `count` represents the number of rules we evaluated.
// `mod` represents the number of rules we modified - a modification can either be a lint or adding the
// label in the aggregation.
var count, mod int
for i, group := range r.Groups {
for j, rule := range group.Rules {
// Skip it if the applyTo function returns false.
if applyTo != nil && !applyTo(group, rule) {
log.WithFields(log.Fields{
"group": group.Name,
"rule": getRuleName(rule),
}).Debugf("skipped")
count++
continue
}
log.WithFields(log.Fields{"rule": getRuleName(rule)}).Debugf("evaluating...")
exp, err := parser.ParseExpr(rule.Expr)
if err != nil {
return count, mod, err
}
count++
// Given inspect will help us traverse every node in the AST, Let's create the
// function that will modify the labels.
f := exprNodeInspectorFunc(rule, label)
parser.Inspect(exp, f)
// Only modify the ones that actually changed.
if rule.Expr != exp.String() {
log.WithFields(log.Fields{
"rule": getRuleName(rule),
"currentExpr": rule.Expr,
"afterExpr": exp.String(),
}).Debugf("expression differs")
mod++
r.Groups[i].Rules[j].Expr = exp.String()
}
}
}
return count, mod, nil
}
// exprNodeInspectorFunc returns a PromQL inspector.
// It modifies most PromQL expressions to include a given label.
func exprNodeInspectorFunc(rule rulefmt.Rule, label string) func(node parser.Node, path []parser.Node) error {
return func(node parser.Node, _ []parser.Node) error {
var err error
switch n := node.(type) {
case *parser.AggregateExpr:
err = prepareAggregationExpr(n, label, getRuleName(rule))
case *parser.BinaryExpr:
err = prepareBinaryExpr(n, label, getRuleName(rule))
default:
return err
}
return err
}
}
func prepareAggregationExpr(e *parser.AggregateExpr, label string, ruleName string) error {
// If the aggregation is about dropping labels (e.g. without), we don't want to modify
// this expression. Omission as long as it is not the cluster label will include it.
// TODO: We probably want to check whenever the label we're trying to include is included in the omission.
if e.Without {
return nil
}
for _, lbl := range e.Grouping {
// It already has the label we want to aggregate by.
if lbl == label {
return nil
}
}
log.WithFields(
log.Fields{"rule": ruleName, "lbls": strings.Join(e.Grouping, ", ")},
).Debugf("aggregation without '%s' label, adding.", label)
e.Grouping = append(e.Grouping, label)
return nil
}
func prepareBinaryExpr(e *parser.BinaryExpr, label string, rule string) error {
if e.VectorMatching == nil {
return nil
}
if !e.VectorMatching.On {
return nil
}
for _, lbl := range e.VectorMatching.MatchingLabels {
// It already has the label we want to add in the expression.
if lbl == label {
return nil
}
}
log.WithFields(
log.Fields{"rule": rule, "lbls": strings.Join(e.VectorMatching.MatchingLabels, ", ")},
).Debugf("binary expression without '%s' label, adding.", label)
e.VectorMatching.MatchingLabels = append(e.VectorMatching.MatchingLabels, label)
return nil
}
// Validate each rule in the rule namespace is valid
func (r RuleNamespace) Validate() []error {
set := map[string]struct{}{}
var errs []error
for _, g := range r.Groups {
if g.Name == "" {
errs = append(errs, fmt.Errorf("groupname should not be empty"))
}
if _, ok := set[g.Name]; ok {
errs = append(
errs,
fmt.Errorf("groupname: \"%s\" is repeated in the same namespace", g.Name),
)
}
set[g.Name] = struct{}{}
errs = append(errs, ValidateRuleGroup(g)...)
}
return errs
}
// ValidateRuleGroup validates a rulegroup
func ValidateRuleGroup(g rwrulefmt.RuleGroup) []error {
var errs []error
for i, r := range g.Rules {
ruleNode := rulefmt.RuleNode{
Record: yaml.Node{Value: r.Record},
Alert: yaml.Node{Value: r.Alert},
Expr: yaml.Node{Value: r.Expr},
}
for _, err := range r.Validate(ruleNode) {
var ruleName string
if r.Alert != "" {
ruleName = r.Alert
} else {
ruleName = r.Record
}
errs = append(errs, &rulefmt.Error{
Group: g.Name,
Rule: i,
RuleName: ruleName,
Err: err,
})
}
}
return errs
}
func getRuleName(r rulefmt.Rule) string {
if r.Record != "" {
return r.Record
}
return r.Alert
}