Alerting: Support extra labels in the Prometheus conversion API (#109136)

This commit is contained in:
Alexander Akhmetov 2025-08-05 00:03:21 +02:00 committed by GitHub
parent 96c1cd225c
commit f65e501e1b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 212 additions and 15 deletions

View File

@ -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 {

View File

@ -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()

View File

@ -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)

View File

@ -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"