Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions central/metrics/aggregator/common/aggregator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package common

import (
"iter"

"github.com/prometheus/client_golang/prometheus"
)

type aggregationKey string // e.g. IMPORTANT_VULNERABILITY_SEVERITY|true

// aggregatedRecord is a single gauge metric record.
type aggregatedRecord struct {
labels prometheus.Labels
total int
}

// aggregator computes the aggregation result.
type aggregator[Finding Countable] struct {
result map[MetricName]map[aggregationKey]*aggregatedRecord
mcfg MetricsConfiguration
labelOrder map[Label]int
getters map[Label]func(Finding) string
}

func makeAggregator[Finding Countable](mcfg MetricsConfiguration, labelOrder map[Label]int, getters map[Label]func(Finding) string) *aggregator[Finding] {
aggregated := make(map[MetricName]map[aggregationKey]*aggregatedRecord)
for metric := range mcfg {
aggregated[metric] = make(map[aggregationKey]*aggregatedRecord)
}
return &aggregator[Finding]{aggregated, mcfg, labelOrder, getters}
}

// count the finding in the aggregation result.
func (r *aggregator[Finding]) count(finding Finding) {
labelValue := func(label Label) string {
return r.getters[label](finding)
}
for metric, labels := range r.mcfg {
if key, labels := makeAggregationKey(labels, labelValue, r.labelOrder); key != "" {
if rec, ok := r.result[metric][key]; ok {
rec.total += finding.Count()
} else {
r.result[metric][key] = &aggregatedRecord{labels, finding.Count()}
}
}
}
}

// makeAggregationKey computes an aggregation key according to the labels from
// the provided expression, and the map of the requested labels to their values.
// The values in the key are sorted according to the provided labelOrder map.
//
// Example:
//
// "Cluster=*prod,Deployment" => "pre-prod|backend", {"Cluster": "pre-prod", "Deployment": "backend")}
func makeAggregationKey(labelExpression map[Label]Expression, getter func(Label) string, labelOrder map[Label]int) (aggregationKey, prometheus.Labels) {
labels := make(prometheus.Labels)
values := make(orderedValues, len(labelExpression))
for label, value := range collectMatchingLabels(labelExpression, getter) {
labels[string(label)] = value
values = append(values, valueOrder{labelOrder[label], value})
}
if len(labels) != len(labelExpression) {
return "", nil
}
return aggregationKey(values.join('|')), labels
}

// collectMatchingLabels returns an iterator over the labels and the values that
// match the expressions.
func collectMatchingLabels(labelExpression map[Label]Expression, getter func(Label) string) iter.Seq2[Label, string] {
return func(yield func(Label, string) bool) {
for label, expression := range labelExpression {
value := getter(label)
if expression.match(value) && !yield(label, value) {
return
}
}
}
}
150 changes: 150 additions & 0 deletions central/metrics/aggregator/common/aggregator_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package common

import (
"testing"

"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
)

type testFinding struct {
OneOrMore
index int // the index of the data sample from the testData array.
}

func Test_aggregator(t *testing.T) {
getters := map[Label]func(testFinding) string{
"Severity": func(tf testFinding) string { return testData[tf.index]["Severity"] },
"Cluster": func(tf testFinding) string { return testData[tf.index]["Cluster"] },
"Namespace": func(tf testFinding) string { return testData[tf.index]["Namespace"] },
}
a := makeAggregator(makeTestMetricLabelExpression(t), testLabelOrder, getters)
assert.NotNil(t, a)
assert.Equal(t, map[MetricName]map[aggregationKey]*aggregatedRecord{
"Test_aggregator_metric1": {},
"Test_aggregator_metric2": {},
}, a.result)

a.count(testFinding{index: 0})

assert.Equal(t, map[MetricName]map[aggregationKey]*aggregatedRecord{
"Test_aggregator_metric1": {
"cluster 1|CRITICAL": &aggregatedRecord{
labels: prometheus.Labels{"Cluster": testData[0]["Cluster"], "Severity": testData[0]["Severity"]},
total: 1,
}},
"Test_aggregator_metric2": {
"ns 1": &aggregatedRecord{
labels: prometheus.Labels{"Namespace": testData[0]["Namespace"]},
total: 1,
}},
}, a.result)

a.count(testFinding{index: 0})

assert.Equal(t, map[MetricName]map[aggregationKey]*aggregatedRecord{
"Test_aggregator_metric1": {
"cluster 1|CRITICAL": &aggregatedRecord{
labels: prometheus.Labels{"Cluster": testData[0]["Cluster"], "Severity": testData[0]["Severity"]},
total: 2,
}},
"Test_aggregator_metric2": {
"ns 1": &aggregatedRecord{
labels: prometheus.Labels{"Namespace": testData[0]["Namespace"]},
total: 2,
}},
}, a.result)

a.count(testFinding{index: 1})

assert.Equal(t, map[MetricName]map[aggregationKey]*aggregatedRecord{
"Test_aggregator_metric1": {
"cluster 1|CRITICAL": &aggregatedRecord{
labels: prometheus.Labels{"Cluster": testData[0]["Cluster"], "Severity": testData[0]["Severity"]},
total: 2,
},
},
"Test_aggregator_metric2": {
"ns 1": &aggregatedRecord{
labels: prometheus.Labels{"Namespace": testData[0]["Namespace"]},
total: 2,
},
"ns 2": &aggregatedRecord{
labels: prometheus.Labels{"Namespace": testData[1]["Namespace"]},
total: 1,
},
},
}, a.result)

a.count(testFinding{OneOrMore: 5, index: 1})

assert.Equal(t, map[MetricName]map[aggregationKey]*aggregatedRecord{
"Test_aggregator_metric1": {
"cluster 1|CRITICAL": &aggregatedRecord{
labels: prometheus.Labels{"Cluster": testData[0]["Cluster"], "Severity": testData[0]["Severity"]},
total: 2,
},
},
"Test_aggregator_metric2": {
"ns 1": &aggregatedRecord{
labels: prometheus.Labels{"Namespace": testData[0]["Namespace"]},
total: 2,
},
"ns 2": &aggregatedRecord{
labels: prometheus.Labels{"Namespace": testData[1]["Namespace"]},
total: 6,
},
},
}, a.result)
}

func Test_makeAggregationKey(t *testing.T) {
testMetric := map[Label]string{
"Cluster": "value",
"IsFixable": "false",
}
getter := func(label Label) string {
return testMetric[label]
}

t.Run("matching", func(t *testing.T) {
key, labels := makeAggregationKey(
map[Label]Expression{
"Cluster": {{"=", "*al*"}},
"IsFixable": {},
},
getter,
testLabelOrder)
assert.Equal(t, aggregationKey("value|false"), key)
assert.Equal(t, prometheus.Labels{
"Cluster": "value",
"IsFixable": "false",
}, labels)
})

t.Run("not matching", func(t *testing.T) {
key, labels := makeAggregationKey(
map[Label]Expression{
"Cluster": {{"=", "missing"}},
"IsFixable": {},
},
getter, testLabelOrder)
assert.Equal(t, aggregationKey(""), key)
assert.Nil(t, labels)
})
}

func Test_collectMatchingLabels(t *testing.T) {
i := 0
for range collectMatchingLabels(map[Label]Expression{
"label1": {&Condition{"=", "value"}, &Condition{"=", "value"}},
"label2": {&Condition{"=", "value"}, &Condition{"=", "value"}},
"label3": {},
}, func(l Label) string {
i++
return "value"
}) {
break
}
assert.Equal(t, 1, i)
}
54 changes: 54 additions & 0 deletions central/metrics/aggregator/common/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Package common provides utilities for aggregating findings into Prometheus metrics.
// It includes functionality for parsing metric configurations, matching findings
// against label expression, and generating aggregation results.
//
// # Finding Aggregation
//
// Finding aggregation is the process of grouping findings based on specific label
// expression and generating metrics that summarize the findings. The aggregation
// process involves the following steps:
//
// 1. **Matching Labels**: The `collectMatchingLabels` function iterates over the label
// expression and evaluates whether a finding matches the specified conditions.
// It yields the labels and their corresponding values that satisfy the expression.
//
// 2. **Generating Aggregation Keys**: The `makeAggregationKey` function
// computes a unique `aggregationKey` for each set of matching labels. The `aggregationKey`
// is a string representation of the label values, sorted according to a predefined
// label order.
//
// 3. **Storing Aggregation Results**: The aggregation results are computed by the `count`
// method of the `aggregator` structure. The result is stored in the `result` property
// which maps metric names to their corresponding records. Each record contains
// the labels and the total count of findings that match the aggregation criteria.
//
// # Aggregation Key
//
// An `aggregationKey` is a unique identifier for a set of label values. It is constructed
// by concatenating the values of the labels in a predefined order, separated by a
// delimiter (`|`). This ensures that each combination of label values has a unique
// key.
//
// Example:
//
// Given the following label expression and finding:
//
// Label Expression:
// - "Cluster": `=*prod`
// - "Deployment": `=*backend`
//
// Finding:
// - "Cluster": "pre-prod"
// - "Deployment": "backend"
//
// The resulting `aggregationKey` would be:
//
// "pre-prod|backend"
//
// The corresponding Prometheus labels would be:
//
// {"Cluster": "pre-prod", "Deployment": "backend"}
//
// This key is used to uniquely identify and aggregate findings that match the same
// set of label expression.
package common
31 changes: 31 additions & 0 deletions central/metrics/aggregator/common/ordered_values.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package common

import (
"slices"
"strings"
)

// orderedValues is a list of elements knowing their order.
// orderedValues.join(sep) method joins the elements according to their order.
type orderedValues []valueOrder

type valueOrder struct {
int
string
}

func (ov valueOrder) cmp(b valueOrder) int {
return ov.int - b.int
}

func (ov orderedValues) join(sep rune) string {
slices.SortFunc(ov, valueOrder.cmp)
sb := strings.Builder{}
for _, value := range ov {
if sb.Len() > 0 {
sb.WriteRune(sep)
}
sb.WriteString(value.string)
}
return sb.String()
}
32 changes: 32 additions & 0 deletions central/metrics/aggregator/common/ordered_values_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package common

import (
"testing"

"github.com/stretchr/testify/assert"
)

func Test_orderedValues(t *testing.T) {
ov := orderedValues{
{int: 2, string: "b"},
{int: 1, string: "a"},
{int: 3, string: "c"},
}
assert.Equal(t, "a,b,c", ov.join(','))

// Test with duplicate ints.
ov2 := orderedValues{
{int: 2, string: "b"},
{int: 1, string: "a"},
{int: 2, string: "bb"},
}
joined := ov2.join('-')
assert.True(t, joined == "a-b-bb" || joined == "a-bb-b", joined)

// Test with empty slice.
assert.Empty(t, orderedValues{}.join(','))

// Test with single element
ov4 := orderedValues{{int: 5, string: "s"}}
assert.Equal(t, "s", ov4.join('|'))
}
Loading