mirror of https://github.com/grafana/grafana.git
Alerting: Support extra labels in the Prometheus conversion API (#109136)
This commit is contained in:
parent
96c1cd225c
commit
f65e501e1b
|
|
@ -56,6 +56,10 @@ const (
|
|||
// The value should be comma-separated key=value pairs, e.g., "environment=production,team=alerting".
|
||||
mergeMatchersHeader = "X-Grafana-Alerting-Merge-Matchers"
|
||||
|
||||
// extraLabelsHeader is the header that specifies extra labels to be added to all imported rules.
|
||||
// The value should be comma-separated key=value pairs, e.g., "environment=production,team=alerting".
|
||||
extraLabelsHeader = "X-Grafana-Alerting-Extra-Labels"
|
||||
|
||||
// configIdentifierHeader is the header that specifies the identifier for imported Alertmanager config.
|
||||
configIdentifierHeader = "X-Grafana-Alerting-Config-Identifier"
|
||||
defaultConfigIdentifier = "default"
|
||||
|
|
@ -397,6 +401,12 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusPostRuleGroups(c *context
|
|||
return errorToResponse(err)
|
||||
}
|
||||
|
||||
extraLabels, err := parseExtraLabelsHeader(c)
|
||||
if err != nil {
|
||||
logger.Error("Failed to parse extra labels header", "error", err)
|
||||
return errorToResponse(err)
|
||||
}
|
||||
|
||||
// 2. Convert Prometheus Rules to GMA
|
||||
grafanaGroups := make([]*models.AlertRuleGroup, 0, len(promNamespaces))
|
||||
for ns, rgs := range promNamespaces {
|
||||
|
|
@ -427,6 +437,7 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusPostRuleGroups(c *context
|
|||
pauseAlertRules,
|
||||
keepOriginalRuleDefinition,
|
||||
notificationSettings,
|
||||
extraLabels,
|
||||
logger,
|
||||
)
|
||||
if err != nil {
|
||||
|
|
@ -477,6 +488,7 @@ func (srv *ConvertPrometheusSrv) convertToGrafanaRuleGroup(
|
|||
pauseAlertRules bool,
|
||||
keepOriginalRuleDefinition bool,
|
||||
notificationSettings []models.NotificationSettings,
|
||||
extraLabels map[string]string,
|
||||
logger log.Logger,
|
||||
) (*models.AlertRuleGroup, error) {
|
||||
logger.Info("Converting Prometheus rules to Grafana rules", "rules", len(promGroup.Rules), "folder_uid", namespaceUID, "datasource_uid", ds.UID, "datasource_type", ds.Type)
|
||||
|
|
@ -518,6 +530,7 @@ func (srv *ConvertPrometheusSrv) convertToGrafanaRuleGroup(
|
|||
KeepOriginalRuleDefinition: util.Pointer(keepOriginalRuleDefinition),
|
||||
EvaluationOffset: &srv.cfg.PrometheusConversion.RuleQueryOffset,
|
||||
NotificationSettings: notificationSettings,
|
||||
ExtraLabels: extraLabels,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
|
|
@ -758,6 +771,35 @@ func parseNotificationSettingsHeader(ctx *contextmodel.ReqContext) ([]models.Not
|
|||
return notificationSettings, nil
|
||||
}
|
||||
|
||||
// parseKeyValuePairs parses a comma-separated list of key=value pairs.
|
||||
// Expected format: "key1=value1,key2=value2"
|
||||
func parseKeyValuePairs(input string, headerName string) (map[string]string, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
result := make(map[string]string)
|
||||
|
||||
for pair := range strings.SplitSeq(input, ",") {
|
||||
parts := strings.SplitN(strings.TrimSpace(pair), "=", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, errInvalidHeaderValue(headerName, errors.New("format should be 'key=value,key2=value2'"))
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
|
||||
if key == "" || value == "" {
|
||||
return nil, errInvalidHeaderValue(headerName, errors.New("keys and values cannot be empty"))
|
||||
}
|
||||
|
||||
result[key] = value
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// parseMergeMatchersHeader parses the merge matchers header value.
|
||||
// Expected format: "key1=value1,key2=value2"
|
||||
func parseMergeMatchersHeader(c *contextmodel.ReqContext) (amconfig.Matchers, error) {
|
||||
|
|
@ -767,21 +809,13 @@ func parseMergeMatchersHeader(c *contextmodel.ReqContext) (amconfig.Matchers, er
|
|||
return amconfig.Matchers{}, errInvalidHeaderValue(mergeMatchersHeader, errors.New("value cannot be empty"))
|
||||
}
|
||||
|
||||
kvPairs, err := parseKeyValuePairs(matchersStr, mergeMatchersHeader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
matchers := amconfig.Matchers{}
|
||||
|
||||
for pair := range strings.SplitSeq(matchersStr, ",") {
|
||||
parts := strings.SplitN(strings.TrimSpace(pair), "=", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, errInvalidHeaderValue(mergeMatchersHeader, errors.New("format should be 'key=value,key2=value2'"))
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
|
||||
if key == "" || value == "" {
|
||||
return nil, errInvalidHeaderValue(mergeMatchersHeader, errors.New("keys and values cannot be empty"))
|
||||
}
|
||||
|
||||
for key, value := range kvPairs {
|
||||
matchers = append(matchers, &labels.Matcher{
|
||||
Type: labels.MatchEqual,
|
||||
Name: key,
|
||||
|
|
@ -792,6 +826,13 @@ func parseMergeMatchersHeader(c *contextmodel.ReqContext) (amconfig.Matchers, er
|
|||
return matchers, nil
|
||||
}
|
||||
|
||||
// parseExtraLabelsHeader parses the extra labels header value.
|
||||
// Expected format: "key1=value1,key2=value2"
|
||||
func parseExtraLabelsHeader(c *contextmodel.ReqContext) (map[string]string, error) {
|
||||
labelsStr := strings.TrimSpace(c.Req.Header.Get(extraLabelsHeader))
|
||||
return parseKeyValuePairs(labelsStr, extraLabelsHeader)
|
||||
}
|
||||
|
||||
func formatMergeMatchers(matchers amconfig.Matchers) string {
|
||||
var pairs []string
|
||||
for _, matcher := range matchers {
|
||||
|
|
|
|||
|
|
@ -292,6 +292,91 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) {
|
|||
}
|
||||
})
|
||||
|
||||
t.Run("with extra labels header should apply labels to all rules", func(t *testing.T) {
|
||||
srv, _, ruleStore := createConvertPrometheusSrv(t)
|
||||
rc := createRequestCtx()
|
||||
rc.Req.Header.Set(extraLabelsHeader, "environment=production,team=alerting")
|
||||
|
||||
response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup)
|
||||
require.Equal(t, http.StatusAccepted, response.Status())
|
||||
|
||||
rules, err := ruleStore.ListAlertRules(context.Background(), &models.ListAlertRulesQuery{
|
||||
OrgID: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rules, 2)
|
||||
|
||||
for _, rule := range rules {
|
||||
require.Equal(t, "production", rule.Labels["environment"])
|
||||
require.Equal(t, "alerting", rule.Labels["team"])
|
||||
}
|
||||
|
||||
// Original labels must be preserved
|
||||
alertRule := rules[0]
|
||||
if alertRule.Title == "recorded-metric" {
|
||||
alertRule = rules[1]
|
||||
}
|
||||
require.Equal(t, "critical", alertRule.Labels["severity"])
|
||||
})
|
||||
|
||||
t.Run("with extra labels that conflict with rule labels", func(t *testing.T) {
|
||||
srv, _, ruleStore := createConvertPrometheusSrv(t)
|
||||
rc := createRequestCtx()
|
||||
// rules in the simpleGroup already have a severity label, so
|
||||
// it should not be overwritten by the label from the header
|
||||
rc.Req.Header.Set(extraLabelsHeader, "environment=production,severity=low")
|
||||
|
||||
response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup)
|
||||
require.Equal(t, http.StatusAccepted, response.Status())
|
||||
|
||||
rules, err := ruleStore.ListAlertRules(context.Background(), &models.ListAlertRulesQuery{
|
||||
OrgID: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rules, 2)
|
||||
|
||||
for _, rule := range rules {
|
||||
require.Equal(t, "production", rule.Labels["environment"])
|
||||
require.NotEqual(t, "low", rule.Labels["severity"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with invalid extra labels header should return 400", func(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
headerValue string
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "missing equals sign",
|
||||
headerValue: "environment,team=platform",
|
||||
expectedError: "Invalid value for header X-Grafana-Alerting-Extra-Labels: format should be 'key=value,key2=value2'",
|
||||
},
|
||||
{
|
||||
name: "empty key",
|
||||
headerValue: "=production,team=platform",
|
||||
expectedError: "Invalid value for header X-Grafana-Alerting-Extra-Labels: keys and values cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "empty value",
|
||||
headerValue: "environment=,team=platform",
|
||||
expectedError: "Invalid value for header X-Grafana-Alerting-Extra-Labels: keys and values cannot be empty",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv, _, _ := createConvertPrometheusSrv(t)
|
||||
rc := createRequestCtx()
|
||||
rc.Req.Header.Set(extraLabelsHeader, tc.headerValue)
|
||||
|
||||
response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup)
|
||||
require.Equal(t, http.StatusBadRequest, response.Status())
|
||||
require.Contains(t, string(response.Body()), tc.expectedError)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with empty rule group name should return 400", func(t *testing.T) {
|
||||
srv, _, _ := createConvertPrometheusSrv(t)
|
||||
rc := createRequestCtx()
|
||||
|
|
|
|||
|
|
@ -59,6 +59,9 @@ type Config struct {
|
|||
RecordingRules RulesConfig
|
||||
AlertRules RulesConfig
|
||||
NotificationSettings []models.NotificationSettings
|
||||
// ExtraLabels are labels that will be added to all rules during conversion.
|
||||
// These labels have the lowest precedence and can be overridden by group or rule labels.
|
||||
ExtraLabels map[string]string
|
||||
}
|
||||
|
||||
// RulesConfig contains configuration that applies to either recording or alerting rules.
|
||||
|
|
@ -232,7 +235,8 @@ func (p *Converter) convertRule(orgID int64, namespaceUID string, promGroup Prom
|
|||
title = rule.Alert
|
||||
}
|
||||
|
||||
labels := make(map[string]string, len(rule.Labels)+len(promGroup.Labels)+1)
|
||||
labels := make(map[string]string, len(rule.Labels)+len(promGroup.Labels)+len(p.cfg.ExtraLabels)+1)
|
||||
maps.Copy(labels, p.cfg.ExtraLabels)
|
||||
maps.Copy(labels, promGroup.Labels)
|
||||
maps.Copy(labels, rule.Labels)
|
||||
|
||||
|
|
|
|||
|
|
@ -659,6 +659,73 @@ func TestPrometheusRulesToGrafana_GroupLabels(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestPrometheusRulesToGrafana_ExtraLabels(t *testing.T) {
|
||||
cfg := Config{
|
||||
DatasourceUID: "datasource-uid",
|
||||
DatasourceType: datasources.DS_PROMETHEUS,
|
||||
DefaultInterval: 2 * time.Minute,
|
||||
ExtraLabels: map[string]string{
|
||||
"extra_label": "extra_value",
|
||||
"common_label": "extra_value",
|
||||
"rule_label": "value_from_extra_labels",
|
||||
},
|
||||
}
|
||||
converter, err := NewConverter(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("extra labels are merged with group and rule labels", func(t *testing.T) {
|
||||
promGroup := PrometheusRuleGroup{
|
||||
Name: "test-group-1",
|
||||
Interval: prommodel.Duration(10 * time.Second),
|
||||
Labels: map[string]string{
|
||||
"group_label": "group_value",
|
||||
"common_label": "group_value",
|
||||
},
|
||||
Rules: []PrometheusRule{
|
||||
{
|
||||
Alert: "alert-1",
|
||||
Expr: "cpu_usage > 80",
|
||||
Labels: map[string]string{
|
||||
"rule_label": "rule_value",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
grafanaGroup, err := converter.PrometheusRulesToGrafana(1, "namespace", promGroup)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, grafanaGroup.Rules, 1)
|
||||
|
||||
expectedLabels := withInternalLabel(map[string]string{
|
||||
"extra_label": "extra_value",
|
||||
"common_label": "group_value",
|
||||
"group_label": "group_value",
|
||||
"rule_label": "rule_value",
|
||||
})
|
||||
require.Equal(t, expectedLabels, grafanaGroup.Rules[0].Labels)
|
||||
})
|
||||
|
||||
t.Run("extra labels are applied to recording rules", func(t *testing.T) {
|
||||
promGroup := PrometheusRuleGroup{
|
||||
Name: "test-group-2",
|
||||
Interval: prommodel.Duration(10 * time.Second),
|
||||
Rules: []PrometheusRule{
|
||||
{
|
||||
Record: "http_requests_total:rate5m",
|
||||
Expr: "rate(http_requests_total[5m])",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
grafanaGroup, err := converter.PrometheusRulesToGrafana(1, "namespace", promGroup)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, grafanaGroup.Rules, 1)
|
||||
|
||||
expectedLabels := withInternalLabel(cfg.ExtraLabels)
|
||||
require.Equal(t, expectedLabels, grafanaGroup.Rules[0].Labels)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPrometheusRulesToGrafana_UID(t *testing.T) {
|
||||
orgID := int64(1)
|
||||
namespace := "some-namespace"
|
||||
|
|
|
|||
Loading…
Reference in New Issue