From 6770bad207be5fccf9d7558a06dda50de5fb5f81 Mon Sep 17 00:00:00 2001
From: Dmytro Kozlov <kozlovdmitriyy@gmail.com>
Date: Mon, 4 Dec 2023 16:40:33 +0100
Subject: [PATCH] app/vmalert: expose `/vmalert/api/v1/rule` and `/api/v1/rule`
 API which returns rule status in JSON format (#5397)

* app/vmalert: expose `/vmalert/api/v1/rule` and `/api/v1/rule` API which returns rule status in JSON format

* app/vmalert: hide updates if query param not set

* app/vmalert: fix panic (recursion call)

* app/vmalert: add needed group name and file name

* app/vmalert: fix comment, update behavior

* app/vmalert: fix description

* app/vmalert: simplify API for /api/v1/rule

Signed-off-by: hagen1778 <roman@victoriametrics.com>

* app/vmalert: simplify API for /api/v1/rule

Signed-off-by: hagen1778 <roman@victoriametrics.com>

* app/vmalert: simplify API for /api/v1/rule

Signed-off-by: hagen1778 <roman@victoriametrics.com>

* app/vmalert: simplify API for /api/v1/rule

Signed-off-by: hagen1778 <roman@victoriametrics.com>

* app/vmalert: simplify API for /api/v1/rule

Signed-off-by: hagen1778 <roman@victoriametrics.com>

---------

Signed-off-by: hagen1778 <roman@victoriametrics.com>
Co-authored-by: Aliaksandr Valialkin <valyala@victoriametrics.com>
Co-authored-by: hagen1778 <roman@victoriametrics.com>
---
 app/vmalert/rule/alerting.go  |  2 ++
 app/vmalert/rule/recording.go | 30 +++++++++++++++++-------------
 app/vmalert/rule/rule.go      | 14 +++++++-------
 app/vmalert/web.go            | 18 ++++++++++++++++++
 app/vmalert/web_test.go       | 22 ++++++++++++++++++++++
 app/vmalert/web_types.go      | 23 +++++++++++++++++++++--
 docs/CHANGELOG.md             |  1 +
 docs/vmalert.md               |  1 +
 8 files changed, 89 insertions(+), 22 deletions(-)

diff --git a/app/vmalert/rule/alerting.go b/app/vmalert/rule/alerting.go
index ae10f4fe3b..93584930d5 100644
--- a/app/vmalert/rule/alerting.go
+++ b/app/vmalert/rule/alerting.go
@@ -30,6 +30,7 @@ type AlertingRule struct {
 	Annotations   map[string]string
 	GroupID       uint64
 	GroupName     string
+	File          string
 	EvalInterval  time.Duration
 	Debug         bool
 
@@ -67,6 +68,7 @@ func NewAlertingRule(qb datasource.QuerierBuilder, group *Group, cfg config.Rule
 		Annotations:   cfg.Annotations,
 		GroupID:       group.ID(),
 		GroupName:     group.Name,
+		File:          group.File,
 		EvalInterval:  group.Interval,
 		Debug:         cfg.Debug,
 		q: qb.BuildWithParams(datasource.QuerierParams{
diff --git a/app/vmalert/rule/recording.go b/app/vmalert/rule/recording.go
index e74d1b8309..196f51456a 100644
--- a/app/vmalert/rule/recording.go
+++ b/app/vmalert/rule/recording.go
@@ -17,12 +17,14 @@ import (
 // to evaluate configured Expression and
 // return TimeSeries as result.
 type RecordingRule struct {
-	Type    config.Type
-	RuleID  uint64
-	Name    string
-	Expr    string
-	Labels  map[string]string
-	GroupID uint64
+	Type      config.Type
+	RuleID    uint64
+	Name      string
+	Expr      string
+	Labels    map[string]string
+	GroupID   uint64
+	GroupName string
+	File      string
 
 	q datasource.Querier
 
@@ -52,13 +54,15 @@ func (rr *RecordingRule) ID() uint64 {
 // NewRecordingRule creates a new RecordingRule
 func NewRecordingRule(qb datasource.QuerierBuilder, group *Group, cfg config.Rule) *RecordingRule {
 	rr := &RecordingRule{
-		Type:    group.Type,
-		RuleID:  cfg.ID,
-		Name:    cfg.Record,
-		Expr:    cfg.Expr,
-		Labels:  cfg.Labels,
-		GroupID: group.ID(),
-		metrics: &recordingRuleMetrics{},
+		Type:      group.Type,
+		RuleID:    cfg.ID,
+		Name:      cfg.Record,
+		Expr:      cfg.Expr,
+		Labels:    cfg.Labels,
+		GroupID:   group.ID(),
+		GroupName: group.Name,
+		File:      group.File,
+		metrics:   &recordingRuleMetrics{},
 		q: qb.BuildWithParams(datasource.QuerierParams{
 			DataSourceType:     group.Type.String(),
 			EvaluationInterval: group.Interval,
diff --git a/app/vmalert/rule/rule.go b/app/vmalert/rule/rule.go
index 10728cff65..0bad2ff65f 100644
--- a/app/vmalert/rule/rule.go
+++ b/app/vmalert/rule/rule.go
@@ -43,26 +43,26 @@ type ruleState struct {
 // StateEntry stores rule's execution states
 type StateEntry struct {
 	// stores last moment of time rule.Exec was called
-	Time time.Time
+	Time time.Time `json:"time"`
 	// stores the timesteamp with which rule.Exec was called
-	At time.Time
+	At time.Time `json:"at"`
 	// stores the duration of the last rule.Exec call
-	Duration time.Duration
+	Duration time.Duration `json:"duration"`
 	// stores last error that happened in Exec func
 	// resets on every successful Exec
 	// may be used as Health ruleState
-	Err error
+	Err error `json:"error"`
 	// stores the number of samples returned during
 	// the last evaluation
-	Samples int
+	Samples int `json:"samples"`
 	// stores the number of time series fetched during
 	// the last evaluation.
 	// Is supported by VictoriaMetrics only, starting from v1.90.0
 	// If seriesFetched == nil, then this attribute was missing in
 	// datasource response (unsupported).
-	SeriesFetched *int
+	SeriesFetched *int `json:"series_fetched"`
 	// stores the curl command reflecting the HTTP request used during rule.Exec
-	Curl string
+	Curl string `json:"curl"`
 }
 
 // GetLastEntry returns latest stateEntry of rule
diff --git a/app/vmalert/web.go b/app/vmalert/web.go
index f34bad1971..04004a4467 100644
--- a/app/vmalert/web.go
+++ b/app/vmalert/web.go
@@ -132,6 +132,24 @@ func (rh *requestHandler) handler(w http.ResponseWriter, r *http.Request) bool {
 		w.Header().Set("Content-Type", "application/json")
 		w.Write(data)
 		return true
+	case "/vmalert/api/v1/rule", "/api/v1/rule":
+		rule, err := rh.getRule(r)
+		if err != nil {
+			httpserver.Errorf(w, r, "%s", err)
+			return true
+		}
+		rwu := apiRuleWithUpdates{
+			apiRule:      rule,
+			StateUpdates: rule.Updates,
+		}
+		data, err := json.Marshal(rwu)
+		if err != nil {
+			httpserver.Errorf(w, r, "failed to marshal rule: %s", err)
+			return true
+		}
+		w.Header().Set("Content-Type", "application/json")
+		w.Write(data)
+		return true
 	case "/-/reload":
 		logger.Infof("api config reload was called, sending sighup")
 		procutil.SelfSIGHUP()
diff --git a/app/vmalert/web_test.go b/app/vmalert/web_test.go
index c55ef5cbc5..12beb63fab 100644
--- a/app/vmalert/web_test.go
+++ b/app/vmalert/web_test.go
@@ -143,6 +143,28 @@ func TestHandler(t *testing.T) {
 			t.Errorf("expected 1 group got %d", length)
 		}
 	})
+	t.Run("/api/v1/rule?ruleID&groupID", func(t *testing.T) {
+		expRule := ruleToAPI(ar)
+		gotRule := apiRule{}
+		getResp(ts.URL+"/"+expRule.APILink(), &gotRule, 200)
+
+		if expRule.ID != gotRule.ID {
+			t.Errorf("expected to get Rule %q; got %q instead", expRule.ID, gotRule.ID)
+		}
+
+		gotRule = apiRule{}
+		getResp(ts.URL+"/vmalert/"+expRule.APILink(), &gotRule, 200)
+
+		if expRule.ID != gotRule.ID {
+			t.Errorf("expected to get Rule %q; got %q instead", expRule.ID, gotRule.ID)
+		}
+
+		gotRuleWithUpdates := apiRuleWithUpdates{}
+		getResp(ts.URL+"/"+expRule.APILink(), &gotRuleWithUpdates, 200)
+		if gotRuleWithUpdates.StateUpdates == nil || len(gotRuleWithUpdates.StateUpdates) < 1 {
+			t.Fatalf("expected %+v to have state updates field not empty", gotRuleWithUpdates.StateUpdates)
+		}
+	})
 }
 
 func TestEmptyResponse(t *testing.T) {
diff --git a/app/vmalert/web_types.go b/app/vmalert/web_types.go
index 62d17f379b..61c0d21912 100644
--- a/app/vmalert/web_types.go
+++ b/app/vmalert/web_types.go
@@ -151,6 +151,10 @@ type apiRule struct {
 	ID string `json:"id"`
 	// GroupID is an unique Group's ID
 	GroupID string `json:"group_id"`
+	// GroupName is Group name rule belong to
+	GroupName string `json:"group_name"`
+	// File is file name where rule is defined
+	File string `json:"file"`
 	// Debug shows whether debug mode is enabled
 	Debug bool `json:"debug"`
 
@@ -160,6 +164,19 @@ type apiRule struct {
 	Updates []rule.StateEntry `json:"-"`
 }
 
+// apiRuleWithUpdates represents apiRule but with extra fields for marshalling
+type apiRuleWithUpdates struct {
+	apiRule
+	// Updates contains the ordered list of recorded ruleStateEntry objects
+	StateUpdates []rule.StateEntry `json:"updates,omitempty"`
+}
+
+// APILink returns a link to the rule's JSON representation.
+func (ar apiRule) APILink() string {
+	return fmt.Sprintf("api/v1/rule?%s=%s&%s=%s",
+		paramGroupID, ar.GroupID, paramRuleID, ar.ID)
+}
+
 // WebLink returns a link to the alert which can be used in UI.
 func (ar apiRule) WebLink() string {
 	return fmt.Sprintf("rule?%s=%s&%s=%s",
@@ -227,8 +244,10 @@ func alertingToAPI(ar *rule.AlertingRule) apiRule {
 		Debug:             ar.Debug,
 
 		// encode as strings to avoid rounding in JSON
-		ID:      fmt.Sprintf("%d", ar.ID()),
-		GroupID: fmt.Sprintf("%d", ar.GroupID),
+		ID:        fmt.Sprintf("%d", ar.ID()),
+		GroupID:   fmt.Sprintf("%d", ar.GroupID),
+		GroupName: ar.GroupName,
+		File:      ar.File,
 	}
 	if lastState.Err != nil {
 		r.LastError = lastState.Err.Error()
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 63cd4c3b0c..a77acfb099 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -37,6 +37,7 @@ The sandbox cluster installation is running under the constant load generated by
 * FEATURE: [vmagent](https://docs.victoriametrics.com/vmagent.html): show all the dropped targets together with the reason why they are dropped at `http://vmagent:8429/service-discovery` page. Previously targets, which were dropped because of [target sharding](https://docs.victoriametrics.com/vmagent.html#scraping-big-number-of-targets) weren't displayed on this page. This could complicate service discovery debugging. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/5389).
 * FEATURE: reduce the default value for `-import.maxLineLen` command-line flag from 100MB to 10MB in order to prevent excessive memory usage during data import via [/api/v1/import](https://docs.victoriametrics.com/#how-to-import-data-in-json-line-format).
 * FEATURE: [vmagent](https://docs.victoriametrics.com/vmagent.html): add `keep_if_contains` and `drop_if_contains` relabeling actions. See [these docs](https://docs.victoriametrics.com/vmagent.html#relabeling-enhancements) for details.
+* FEATURE: [vmalert](https://docs.victoriametrics.com/vmalert.html): provide `/vmalert/api/v1/rule` and `/api/v1/rule` API endpoints to get the rule object in JSON format. See [these docs](https://docs.victoriametrics.com/vmalert.html#web) for details.
 * FEATURE: [MetricsQL](https://docs.victoriametrics.com/MetricsQL.html): add [day_of_year()](https://docs.victoriametrics.com/MetricsQL.html#day_of_year) function, which returns the day of the year for each of the given unix timestamps. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/5345) for details. Thanks to @luckyxiaoqiang for the [pull request](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/5368/).
 * FEATURE: all VictoriaMetrics binaries: expose additional metrics at `/metrics` page, which may simplify debugging of VictoriaMetrics components (see [this feature request](https://github.com/VictoriaMetrics/metrics/issues/54)):
   * `go_sched_latencies_seconds` - the [histogram](https://docs.victoriametrics.com/keyConcepts.html#histogram), which shows the time goroutines have spent in runnable state before actually running. Big values point to the lack of CPU time for the current workload.
diff --git a/docs/vmalert.md b/docs/vmalert.md
index 4ebf795506..ce932df417 100644
--- a/docs/vmalert.md
+++ b/docs/vmalert.md
@@ -659,6 +659,7 @@ or time series modification via [relabeling](https://docs.victoriametrics.com/vm
   Used as alert source in AlertManager.
 * `http://<vmalert-addr>/vmalert/alert?group_id=<group_id>&alert_id=<alert_id>` - get alert status in web UI.
 * `http://<vmalert-addr>/vmalert/rule?group_id=<group_id>&rule_id=<rule_id>` - get rule status in web UI.
+* `http://<vmalert-addr>/vmalert/api/v1/rule?group_id=<group_id>&alert_id=<alert_id>` - get rule status in JSON format.
 * `http://<vmalert-addr>/metrics` - application metrics.
 * `http://<vmalert-addr>/-/reload` - hot configuration reload.