Support multiple configs per input metric.

When generating metric values using expression evaluation, there are new
use-cases for more than one configuration for each sensor value.
Expressions are more flexible than aggregation rules in Prometheus, so
it makes sense to derive multiple metrics from the same sensor input.
This commit is contained in:
Christian Schneider
2023-12-13 13:38:44 +01:00
parent f380bea81d
commit 9f92116fe3
4 changed files with 99 additions and 102 deletions

View File

@@ -32,19 +32,16 @@ func NewJSONObjectExtractor(p Parser) Extractor {
continue
}
// Find a valid metrics config
config, found := p.findMetricConfig(path, deviceID)
if !found {
continue
// Find all valid metric configs
for _, config := range p.findMetricConfigs(path, deviceID) {
id := metricID(topic, path, deviceID, config.PrometheusName)
m, err := p.parseMetric(config, id, rawValue)
if err != nil {
return nil, fmt.Errorf("failed to parse valid value from '%v' for metric %q: %w", rawValue, config.PrometheusName, err)
}
m.Topic = topic
mc = append(mc, m)
}
id := metricID(topic, path, deviceID, config.PrometheusName)
m, err := p.parseMetric(config, id, rawValue)
if err != nil {
return nil, fmt.Errorf("failed to parse valid metric value: %w", err)
}
m.Topic = topic
mc = append(mc, m)
}
return mc, nil
}
@@ -52,35 +49,34 @@ func NewJSONObjectExtractor(p Parser) Extractor {
func NewMetricPerTopicExtractor(p Parser, metricNameRegex *config.Regexp) Extractor {
return func(topic string, payload []byte, deviceID string) (MetricCollection, error) {
var mc MetricCollection
metricName := metricNameRegex.GroupValue(topic, config.MetricNameRegexGroup)
if metricName == "" {
return nil, fmt.Errorf("failed to find valid metric in topic path")
}
// Find a valid metrics config
config, found := p.findMetricConfig(metricName, deviceID)
if !found {
return nil, nil
}
var rawValue interface{}
if config.PayloadField != "" {
parsed := gojsonq.New(gojsonq.SetSeparator(p.separator)).FromString(string(payload))
rawValue = parsed.Find(config.PayloadField)
parsed.Reset()
if rawValue == nil {
return nil, fmt.Errorf("failed to extract field %s from payload %s", config.PayloadField, payload)
// Find all valid metric configs
for _, config := range p.findMetricConfigs(metricName, deviceID) {
var rawValue interface{}
if config.PayloadField != "" {
parsed := gojsonq.New(gojsonq.SetSeparator(p.separator)).FromString(string(payload))
rawValue = parsed.Find(config.PayloadField)
parsed.Reset()
if rawValue == nil {
return nil, fmt.Errorf("failed to extract field %q from payload %q for metric %q", config.PayloadField, payload, metricName)
}
} else {
rawValue = string(payload)
}
} else {
rawValue = string(payload)
}
id := metricID(topic, metricName, deviceID, config.PrometheusName)
m, err := p.parseMetric(config, id, rawValue)
if err != nil {
return nil, fmt.Errorf("failed to parse metric: %w", err)
id := metricID(topic, metricName, deviceID, config.PrometheusName)
m, err := p.parseMetric(config, id, rawValue)
if err != nil {
return nil, fmt.Errorf("failed to parse valid value from '%v' for metric %q: %w", rawValue, config.PrometheusName, err)
}
m.Topic = topic
mc = append(mc, m)
}
m.Topic = topic
return MetricCollection{m}, nil
return mc, nil
}
}

View File

@@ -11,7 +11,7 @@ import (
func TestNewJSONObjectExtractor_parseMetric(t *testing.T) {
now = testNow
type fields struct {
metricConfigs map[string][]config.MetricConfig
metricConfigs map[string][]*config.MetricConfig
}
type args struct {
metricPath string
@@ -31,8 +31,8 @@ func TestNewJSONObjectExtractor_parseMetric(t *testing.T) {
name: "string value",
separator: "->",
fields: fields{
map[string][]config.MetricConfig{
"SDS0X1->PM2->5": []config.MetricConfig{
map[string][]*config.MetricConfig{
"SDS0X1->PM2->5": {
{
PrometheusName: "temperature",
MQTTName: "SDS0X1.PM2.5",
@@ -57,8 +57,8 @@ func TestNewJSONObjectExtractor_parseMetric(t *testing.T) {
name: "string value with dots in path",
separator: "->",
fields: fields{
map[string][]config.MetricConfig{
"SDS0X1->PM2.5": []config.MetricConfig{
map[string][]*config.MetricConfig{
"SDS0X1->PM2.5": {
{
PrometheusName: "temperature",
MQTTName: "SDS0X1->PM2.5",
@@ -83,8 +83,8 @@ func TestNewJSONObjectExtractor_parseMetric(t *testing.T) {
name: "metric matching SensorNameFilter",
separator: ".",
fields: fields{
map[string][]config.MetricConfig{
"temperature": []config.MetricConfig{
map[string][]*config.MetricConfig{
"temperature": {
{
PrometheusName: "temperature",
MQTTName: "temperature",
@@ -110,8 +110,8 @@ func TestNewJSONObjectExtractor_parseMetric(t *testing.T) {
name: "metric not matching SensorNameFilter",
separator: ".",
fields: fields{
map[string][]config.MetricConfig{
"temperature": []config.MetricConfig{
map[string][]*config.MetricConfig{
"temperature": {
{
PrometheusName: "temperature",
MQTTName: "temperature",

View File

@@ -44,7 +44,7 @@ type Parser struct {
separator string
// Maps the mqtt metric name to a list of configs
// The first that matches SensorNameFilter will be used
metricConfigs map[string][]config.MetricConfig
metricConfigs map[string][]*config.MetricConfig
// Directory holding state files
stateDir string
// Per-metric state
@@ -130,10 +130,10 @@ func defaultExprEnv() map[string]interface{} {
}
func NewParser(metrics []config.MetricConfig, separator, stateDir string) Parser {
cfgs := make(map[string][]config.MetricConfig)
cfgs := make(map[string][]*config.MetricConfig)
for i := range metrics {
key := metrics[i].MQTTName
cfgs[key] = append(cfgs[key], metrics[i])
cfgs[key] = append(cfgs[key], &metrics[i])
}
return Parser{
separator: separator,
@@ -144,24 +144,24 @@ func NewParser(metrics []config.MetricConfig, separator, stateDir string) Parser
}
// Config returns the underlying metrics config
func (p *Parser) config() map[string][]config.MetricConfig {
func (p *Parser) config() map[string][]*config.MetricConfig {
return p.metricConfigs
}
// validMetric returns config matching the metric and deviceID
// Second return value indicates if config was found.
func (p *Parser) findMetricConfig(metric string, deviceID string) (config.MetricConfig, bool) {
// validMetric returns all configs matching the metric and deviceID.
func (p *Parser) findMetricConfigs(metric string, deviceID string) []*config.MetricConfig {
configs := []*config.MetricConfig{}
for _, c := range p.metricConfigs[metric] {
if c.SensorNameFilter.Match(deviceID) {
return c, true
configs = append(configs, c)
}
}
return config.MetricConfig{}, false
return configs
}
// parseMetric parses the given value according to the given deviceID and metricPath. The config allows to
// parse a metric value according to the device ID.
func (p *Parser) parseMetric(cfg config.MetricConfig, metricID string, value interface{}) (Metric, error) {
func (p *Parser) parseMetric(cfg *config.MetricConfig, metricID string, value interface{}) (Metric, error) {
var metricValue float64
var err error

View File

@@ -32,7 +32,7 @@ func TestParser_parseMetric(t *testing.T) {
now = testNow
type fields struct {
metricConfigs map[string][]config.MetricConfig
metricConfigs map[string][]*config.MetricConfig
}
type args struct {
metricPath string
@@ -50,8 +50,8 @@ func TestParser_parseMetric(t *testing.T) {
{
name: "value without timestamp",
fields: fields{
map[string][]config.MetricConfig{
"temperature": []config.MetricConfig{
map[string][]*config.MetricConfig{
"temperature": {
{
PrometheusName: "temperature",
ValueType: "gauge",
@@ -76,8 +76,8 @@ func TestParser_parseMetric(t *testing.T) {
{
name: "string value",
fields: fields{
map[string][]config.MetricConfig{
"temperature": []config.MetricConfig{
map[string][]*config.MetricConfig{
"temperature": {
{
PrometheusName: "temperature",
ValueType: "gauge",
@@ -101,8 +101,8 @@ func TestParser_parseMetric(t *testing.T) {
{
name: "scaled string value",
fields: fields{
map[string][]config.MetricConfig{
"temperature": []config.MetricConfig{
map[string][]*config.MetricConfig{
"temperature": {
{
PrometheusName: "temperature",
ValueType: "gauge",
@@ -127,8 +127,8 @@ func TestParser_parseMetric(t *testing.T) {
{
name: "string value failure",
fields: fields{
map[string][]config.MetricConfig{
"temperature": []config.MetricConfig{
map[string][]*config.MetricConfig{
"temperature": {
{
PrometheusName: "temperature",
ValueType: "gauge",
@@ -146,8 +146,8 @@ func TestParser_parseMetric(t *testing.T) {
{
name: "float value",
fields: fields{
map[string][]config.MetricConfig{
"temperature": []config.MetricConfig{
map[string][]*config.MetricConfig{
"temperature": {
{
PrometheusName: "temperature",
ValueType: "gauge",
@@ -171,8 +171,8 @@ func TestParser_parseMetric(t *testing.T) {
{
name: "scaled float value",
fields: fields{
map[string][]config.MetricConfig{
"humidity": []config.MetricConfig{
map[string][]*config.MetricConfig{
"humidity": {
{
PrometheusName: "humidity",
ValueType: "gauge",
@@ -197,8 +197,8 @@ func TestParser_parseMetric(t *testing.T) {
{
name: "negative scaled float value",
fields: fields{
map[string][]config.MetricConfig{
"humidity": []config.MetricConfig{
map[string][]*config.MetricConfig{
"humidity": {
{
PrometheusName: "humidity",
ValueType: "gauge",
@@ -223,8 +223,8 @@ func TestParser_parseMetric(t *testing.T) {
{
name: "bool value true",
fields: fields{
map[string][]config.MetricConfig{
"enabled": []config.MetricConfig{
map[string][]*config.MetricConfig{
"enabled": {
{
PrometheusName: "enabled",
ValueType: "gauge",
@@ -248,8 +248,8 @@ func TestParser_parseMetric(t *testing.T) {
{
name: "scaled bool value",
fields: fields{
map[string][]config.MetricConfig{
"enabled": []config.MetricConfig{
map[string][]*config.MetricConfig{
"enabled": {
{
PrometheusName: "enabled",
ValueType: "gauge",
@@ -274,8 +274,8 @@ func TestParser_parseMetric(t *testing.T) {
{
name: "bool value false",
fields: fields{
map[string][]config.MetricConfig{
"enabled": []config.MetricConfig{
map[string][]*config.MetricConfig{
"enabled": {
{
PrometheusName: "enabled",
ValueType: "gauge",
@@ -299,8 +299,8 @@ func TestParser_parseMetric(t *testing.T) {
{
name: "string mapping value success",
fields: fields{
map[string][]config.MetricConfig{
"enabled": []config.MetricConfig{
map[string][]*config.MetricConfig{
"enabled": {
{
PrometheusName: "enabled",
ValueType: "gauge",
@@ -330,8 +330,8 @@ func TestParser_parseMetric(t *testing.T) {
{
name: "string mapping value failure default to error value",
fields: fields{
map[string][]config.MetricConfig{
"enabled": []config.MetricConfig{
map[string][]*config.MetricConfig{
"enabled": {
{
PrometheusName: "enabled",
ValueType: "gauge",
@@ -362,8 +362,8 @@ func TestParser_parseMetric(t *testing.T) {
{
name: "string mapping value failure no error value",
fields: fields{
map[string][]config.MetricConfig{
"enabled": []config.MetricConfig{
map[string][]*config.MetricConfig{
"enabled": {
{
PrometheusName: "enabled",
ValueType: "gauge",
@@ -387,8 +387,8 @@ func TestParser_parseMetric(t *testing.T) {
{
name: "metric not configured",
fields: fields{
map[string][]config.MetricConfig{
"enabled": []config.MetricConfig{
map[string][]*config.MetricConfig{
"enabled": {
{
PrometheusName: "enabled",
ValueType: "gauge",
@@ -413,8 +413,8 @@ func TestParser_parseMetric(t *testing.T) {
{
name: "unexpected type",
fields: fields{
map[string][]config.MetricConfig{
"enabled": []config.MetricConfig{
map[string][]*config.MetricConfig{
"enabled": {
{
PrometheusName: "enabled",
ValueType: "gauge",
@@ -439,8 +439,8 @@ func TestParser_parseMetric(t *testing.T) {
{
name: "monotonic gauge, step 1: initial value",
fields: fields{
map[string][]config.MetricConfig{
"aenergy.total": []config.MetricConfig{
map[string][]*config.MetricConfig{
"aenergy.total": {
{
PrometheusName: "total_energy",
ValueType: "gauge",
@@ -464,8 +464,8 @@ func TestParser_parseMetric(t *testing.T) {
{
name: "monotonic gauge, step 2: monotonic increase does not add offset",
fields: fields{
map[string][]config.MetricConfig{
"aenergy.total": []config.MetricConfig{
map[string][]*config.MetricConfig{
"aenergy.total": {
{
PrometheusName: "total_energy",
ValueType: "gauge",
@@ -489,8 +489,8 @@ func TestParser_parseMetric(t *testing.T) {
{
name: "monotonic gauge, step 3: raw metric is reset, last value becomes the new offset",
fields: fields{
map[string][]config.MetricConfig{
"aenergy.total": []config.MetricConfig{
map[string][]*config.MetricConfig{
"aenergy.total": {
{
PrometheusName: "total_energy",
ValueType: "gauge",
@@ -514,8 +514,8 @@ func TestParser_parseMetric(t *testing.T) {
{
name: "monotonic gauge, step 4: monotonic increase with offset",
fields: fields{
map[string][]config.MetricConfig{
"aenergy.total": []config.MetricConfig{
map[string][]*config.MetricConfig{
"aenergy.total": {
{
PrometheusName: "total_energy",
ValueType: "gauge",
@@ -539,8 +539,8 @@ func TestParser_parseMetric(t *testing.T) {
{
name: "integrate positive values using expressions, step 1",
fields: fields{
map[string][]config.MetricConfig{
"apower": []config.MetricConfig{
map[string][]*config.MetricConfig{
"apower": {
{
PrometheusName: "total_energy",
ValueType: "gauge",
@@ -564,8 +564,8 @@ func TestParser_parseMetric(t *testing.T) {
{
name: "integrate positive values using expressions, step 2",
fields: fields{
map[string][]config.MetricConfig{
"apower": []config.MetricConfig{
map[string][]*config.MetricConfig{
"apower": {
{
PrometheusName: "total_energy",
ValueType: "gauge",
@@ -590,8 +590,8 @@ func TestParser_parseMetric(t *testing.T) {
{
name: "integrate positive values using expressions, step 3",
fields: fields{
map[string][]config.MetricConfig{
"apower": []config.MetricConfig{
map[string][]*config.MetricConfig{
"apower": {
{
PrometheusName: "total_energy",
ValueType: "gauge",
@@ -616,8 +616,8 @@ func TestParser_parseMetric(t *testing.T) {
{
name: "integrate positive values using expressions, step 4",
fields: fields{
map[string][]config.MetricConfig{
"apower": []config.MetricConfig{
map[string][]*config.MetricConfig{
"apower": {
{
PrometheusName: "total_energy",
ValueType: "gauge",
@@ -649,13 +649,14 @@ func TestParser_parseMetric(t *testing.T) {
p.metricConfigs = tt.fields.metricConfigs
// Find a valid metrics config
config, found := p.findMetricConfig(tt.args.metricPath, tt.args.deviceID)
if !found {
configs := p.findMetricConfigs(tt.args.metricPath, tt.args.deviceID)
if len(configs) != 1 {
if !tt.wantErr {
t.Errorf("MetricConfig not found")
}
return
}
config := configs[0]
id := metricID("", tt.args.metricPath, tt.args.deviceID, config.PrometheusName)
got, err := p.parseMetric(config, id, tt.args.value)