Prometheus time series database - Calculation of alarm Prometheus time series database - Calculation of alarm

Posted by mr_hacker on Fri, 24 Dec 2021 18:18:45 +0100

Prometheus time series database - Calculation of alarm

In the previous article, the author described Prometheus's data insertion, storage and query in detail. But as a monitoring artifact, alarm calculation function is essential. The natural Prometheus also provides flexible and powerful alarm rules that allow us to play freely. In this article, the author will take the reader to see how Prometheus handles alarm rules.

Alarm architecture

Prometheus is only responsible for alarm calculation, and the specific alarm triggering is completed by AlertManager. If we don't want to change the AlertManager to complete the custom routing rules, we can also connect to another system through webhook (for example, a program that converts to kafka).

In this article, the author will not design alertManager, but focus on the calculation logic of Prometheus's own alarm rules.

One of the simplest alarm rules

rules:
	alert: HTTPRequestRateLow
	expr: http_requests < 100
	for: 60s
	labels:
		severity: warning
	annotations:
		description: "http request rate low"

The above rule is that if the number of http requests is less than 100 and lasts for 1min, we will start the alarm, and the alarm level is warning

When will this calculation be triggered

After loading the Rules, Prometheus follows the evaluation_interval is a global configuration to constantly calculate Rules. The code logic is as follows:

rules/manager.go

func (g *Group) run(ctx context.Context) {
	iter := func() {
		......
		g.Eval(ctx,evalTimestamp)
		......
	}
	// g.interval = evaluation_interval
	tick := time.NewTicker(g.interval)
	defer tick.Stop()
	......
	for {
		......
		case <-tick.C:
			......
			iter()
	}
}

The call of g.Eval is:

func (g *Group) Eval(ctx context.Context, ts time.Time) {
	// For all rule s
	for i, rule := range g.rules {
		......
		// First calculate whether there is data conforming to the rule
		vector, err := rule.Eval(ctx, ts, g.opts.QueryFunc, g.opts.ExternalURL)
		......
		// Then send
		ar.sendAlerts(ctx, ts, g.opts.ResendDelay, g.interval, g.opts.NotifyFunc)
	}
	......
}

The whole process is shown in the figure below:

Calculation of a single rule

As we can see, the most important thing is rule Eval this function. The code is as follows:

func (r *AlertingRule) Eval(ctx context.Context, ts time.Time, query QueryFunc, externalURL *url.URL) (promql.Vector, error) {
	// NewInstantQuery was finally called
	res, err = query(ctx,r.vector.String(),ts)
	......
	// Alarm assembly logic
	......
	// active alarm state transition
}

This Eval contains all logic for the calculation / assembly / transmission of alarms. We first focus on the most important computational logic. That is, the query. In fact, this query is a simple encapsulation of NewInstantQuery.

func EngineQueryFunc(engine *promql.Engine, q storage.Queryable) QueryFunc {
	return func(ctx context.Context, qs string, t time.Time) (promql.Vector, error) {
		q, err := engine.NewInstantQuery(q, qs, t)
		......
		res := q.Exec(ctx)
	}
}

That is, it performs a query for an instantaneous vector. The query expression is based on the alarm rule given earlier, that is

http_requests < 100 

Since you want to evaluate the expression, the first step must be to construct it into an AST. The tree structure is shown in the following figure:

It is resolved that the left node is a VectorSelect and its lablelMatcher is

__name__:http_requests

Then we can evaluate the left node VectorSelector. You can directly use the inverted index to query in the head (because the instant query is the current time, it must be in memory).

To know the specific calculation process, see the author's previous blog Prometheus time series database - data query. After calculating the data of the left node, we can compare it with the right node to calculate the final result. The specific code is:

func (ev *evaluator) eval(expr Expr) Value {
	......
	case *BinaryExpr:
	......
		case lt == ValueTypeVector && rt == ValueTypeScalar:
			return ev.rangeEval(func(v []Value, enh *EvalNodeHelper) Vector {
				return ev.VectorscalarBinop(e.Op, v[0].(Vector), Scalar{V: v[1].(Vector)[0].Point.V}, false, e.ReturnBool, enh)
			}, e.LHS, e.RHS)
	.......
}

The last function called is:

func (ev *evaluator) VectorBinop(op ItemType, lhs, rhs Vector, matching *VectorMatching, returnBool bool, enh *EvalNodeHelper) Vector {
	// All data sample s calculated for the left node
	for _, lhsSample := range lhs {
		......
		// Because LV = 75 on the left < rv = 100 on the right, and op is less
		/**
			vectorElemBinop(){
				case LESS
					return lhs, lhs < rhs
			}
		**/
		// The result obtained here is value = 75 and keep = true
		value, keep := vectorElemBinop(op, lv, rv)
		......
		if keep {
			......
			// Here 75 is put into the output, that is to say, we did get the data in the final calculation.
			enh.out = append(enh.out.sample)
		}
	}
}

As shown in the figure below:

Finally, our expr output is

sample {
	Point {t:0,V:75}
	Metric {__name__:http_requests,instance:0,job:api-server}
		
}

Alarm state transition

After the calculation process is finished, the author also talks about the state change of the alarm, that is, the for in the rule in the initial alarm rule, that is, the alarm lasts for(1min in the rule), and we really alarm. In order to achieve this function, a state machine is needed. Here, the author only expounds the logic from pending - > firing.

In the previous Eval method, there is the following paragraph

func (r *AlertingRule) Eval(ctx context.Context, ts time.Time, query QueryFunc, externalURL *url.URL) (promql.Vector, error) {
	for _, smpl := range res {
	......
			if alert, ok := r.active[h]; ok && alert.State != StateInactive {
			alert.Value = smpl.V
			alert.Annotations = annotations
			continue
		}
		// If this alarm is not in the active map, put it in the active map
		// Note that the hash here still has no zipper method, and there is a minimal probability hash conflict
r.active[h] = &Alert{
			Labels:      lbs,
			Annotations: annotations,
			ActiveAt:    ts,
			State:       StatePending,
			Value:       smpl.V,
		}
	}
	......
	// Transition logic of alarm state
	for fp, a := range r.active {
		// If the current r.active alarm is no longer in the result just calculated 		 if _, ok := resultFPs[fp]; !ok {
			// If the status is Pending to be sent
			if a.State == StatePending || (!a.ResolvedAt.IsZero() && ts.Sub(a.ResolvedAt) > resolvedRetention) {
				delete(r.active, fp)
			}
			......
			continue
		}
		// For an existing Active alarm, if its Active time is > r.holdduration, it is specified by for
		if a.State == StatePending && ts.Sub(a.ActiveAt) >= r.holdDuration {
			// We set the alarm to be sent
			a.State = StateFiring
			a.FiredAt = ts
		}
		......
	
	}
}

The logic of the above code is shown in the figure below:

summary

Prometheus, as a monitoring artifact, provides us with a variety of traversal. Its powerful alarm calculation function is one of them. Only by understanding the calculation principle of alarm can we make better use of it.