Skip to content

Commit 676f086

Browse files
committed
WIP: Initial support for CEL
This adds initial support for queries using the common expression language (CEL). ValueToJSON function is adapted from https://github.com/google/cel-go/blob/cfbf821f1b458533051306305a39b743db7c4bdb/codelab/codelab.go#L274 (Apache-2.0 Licensed) Signed-off-by: Manuel Rüger <[email protected]>
1 parent 84bb6d2 commit 676f086

File tree

7 files changed

+223
-28
lines changed

7 files changed

+223
-28
lines changed

README.md

+19-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ json_exporter
22
========================
33
[![CircleCI](https://circleci.com/gh/prometheus-community/json_exporter.svg?style=svg)](https://circleci.com/gh/prometheus-community/json_exporter)
44

5-
A [prometheus](https://prometheus.io/) exporter which scrapes remote JSON by JSONPath.
5+
A [prometheus](https://prometheus.io/) exporter which scrapes remote JSON by JSONPath or [CEL (Common Expression Language)](https://github.com/google/cel-spec).
66

77
- [Supported JSONPath Syntax](https://kubernetes.io/docs/reference/kubectl/jsonpath/)
88
- [Examples configurations](/examples)
@@ -21,6 +21,24 @@ Serving HTTP on :: port 8000 (http://[::]:8000/) ...
2121
## TEST with 'default' module
2222

2323
$ curl "http://localhost:7979/probe?module=default&target=http://localhost:8000/examples/data.json"
24+
# HELP example_cel_global_value Example of a top-level global value scrape in the json using cel
25+
# TYPE example_cel_global_value gauge
26+
example_cel_global_value{environment="beta",location="planet-mars"} 1234
27+
# HELP example_cel_timestamped_value_count Example of a timestamped value scrape in the json
28+
# TYPE example_cel_timestamped_value_count untyped
29+
example_cel_timestamped_value_count{environment="beta"} 2
30+
# HELP example_cel_value_active Example of sub-level value scrapes from a json
31+
# TYPE example_cel_value_active untyped
32+
example_cel_value_active{environment="beta",id="id-A"} 1
33+
example_cel_value_active{environment="beta",id="id-C"} 1
34+
# HELP example_cel_value_boolean Example of sub-level value scrapes from a json
35+
# TYPE example_cel_value_boolean untyped
36+
example_cel_value_boolean{environment="beta",id="id-A"} 1
37+
example_cel_value_boolean{environment="beta",id="id-C"} 0
38+
# HELP example_cel_value_count Example of sub-level value scrapes from a json
39+
# TYPE example_cel_value_count untyped
40+
example_cel_value_count{environment="beta",id="id-A"} 1
41+
example_cel_value_count{environment="beta",id="id-C"} 3
2442
# HELP example_global_value Example of a top-level global value scrape in the json
2543
# TYPE example_global_value untyped
2644
example_global_value{environment="beta",location="planet-mars"} 1234

config/config.go

+12-1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
// Metric contains values that define a metric
2424
type Metric struct {
2525
Name string
26+
Engine EngineType
2627
Path string
2728
Labels map[string]string
2829
Type ScrapeType
@@ -44,7 +45,14 @@ type ValueType string
4445
const (
4546
ValueTypeGauge ValueType = "gauge"
4647
ValueTypeCounter ValueType = "counter"
47-
ValueTypeUntyped ValueType = "untyped"
48+
ValueTypeUntyped ValueType = "untyped" // default
49+
)
50+
51+
type EngineType string
52+
53+
const (
54+
EngineTypeJSONPath EngineType = "jsonpath" // default
55+
EngineTypeCEL EngineType = "cel"
4856
)
4957

5058
// Config contains multiple modules.
@@ -89,6 +97,9 @@ func LoadConfig(configPath string) (Config, error) {
8997
if module.Metrics[i].ValueType == "" {
9098
module.Metrics[i].ValueType = ValueTypeUntyped
9199
}
100+
if module.Metrics[i].Engine == "" {
101+
module.Metrics[i].Engine = EngineTypeJSONPath
102+
}
92103
}
93104
}
94105

examples/config.yml

+55-8
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,17 @@ modules:
99
help: Example of a top-level global value scrape in the json
1010
labels:
1111
environment: beta # static label
12-
location: 'planet-{.location}' # dynamic label
12+
location: 'planet-{ .location }' # dynamic label
13+
14+
- name: example_cel_global_value
15+
engine: cel
16+
path: '.counter'
17+
help: Example of a top-level global value scrape in the json using cel
18+
valuetype: 'gauge'
19+
labels:
20+
environment: "\"beta\"" # static label. Quotes need to be escaped for CEL
21+
location: "\"planet-\"+.location" # dynamic label. Quotes need to be escaped for CEL
22+
1323
- name: example_timestamped_value
1424
type: object
1525
path: '{ .values[?(@.state == "INACTIVE")] }'
@@ -18,18 +28,44 @@ modules:
1828
labels:
1929
environment: beta # static label
2030
values:
21-
count: '{.count}' # dynamic value
31+
count: '{ .count }' # dynamic value
32+
33+
- name: example_cel_timestamped_value
34+
type: object
35+
engine: cel
36+
path: ".values.filter(i, i.state == \"INACTIVE\")"
37+
epochTimestamp: '.timestamp'
38+
help: Example of a timestamped value scrape in the json
39+
labels:
40+
environment: "\"beta\"" # static label
41+
values:
42+
count: '.count' # dynamic value
43+
2244
- name: example_value
2345
type: object
2446
help: Example of sub-level value scrapes from a json
25-
path: '{.values[?(@.state == "ACTIVE")]}'
47+
path: '{ .values[?(@.state == "ACTIVE")] }'
2648
labels:
2749
environment: beta # static label
28-
id: '{.id}' # dynamic label
50+
id: '{ .id }' # dynamic label
2951
values:
3052
active: 1 # static value
31-
count: '{.count}' # dynamic value
32-
boolean: '{.some_boolean}'
53+
count: '{ .count }' # dynamic value
54+
boolean: '{ .some_boolean }'
55+
56+
- name: example_cel_value
57+
type: object
58+
engine: cel
59+
help: Example of sub-level value scrapes from a json
60+
path: ".values.filter(i, i.state == \"ACTIVE\")"
61+
labels:
62+
environment: "\"beta\"" # static label
63+
id: '.id' # dynamic label
64+
values:
65+
active: 1 # static value
66+
count: '.count' # dynamic value
67+
boolean: '.some_boolean'
68+
3369

3470
animals:
3571
metrics:
@@ -43,6 +79,17 @@ modules:
4379
values:
4480
population: '{ .population }'
4581

82+
- name: animal_cel
83+
type: object
84+
engine: cel
85+
help: Example of top-level lists in a separate module
86+
path: '[*]'
87+
labels:
88+
name: '.noun'
89+
predator: '.predator'
90+
values:
91+
population: '.population'
92+
4693
## HTTP connection configurations can be set in 'modules.<module_name>.http_client_config' field. For full http client config parameters, ref: https://pkg.go.dev/github.com/prometheus/common/config?tab=doc#HTTPClientConfig
4794
#
4895
# http_client_config:
@@ -59,11 +106,11 @@ modules:
59106
## If 'modueles.<module_name>.body' field is set, it will be sent by the exporter as the body content in the scrape request. The HTTP method will also be set as 'POST' in this case.
60107
# body:
61108
# content: |
62-
# {"time_diff": "1m25s", "anotherVar": "some value"}
109+
# { "time_diff": "1m25s", "anotherVar": "some value" }
63110

64111
## The body content can also be a Go Template (https://golang.org/pkg/text/template), with all the functions from the Sprig library (https://masterminds.github.io/sprig/) available. All the query parameters sent by prometheus in the scrape query to the exporter, are available in the template.
65112
# body:
66113
# content: |
67-
# {"time_diff": "{{ duration `95` }}","anotherVar": "{{ .myVal | first }}"}
114+
# { "time_diff": "{{ duration `95` }}","anotherVar": "{{ .myVal | first }}" }
68115
# templatize: true
69116

exporter/collector.go

+105-10
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,18 @@ package exporter
1616
import (
1717
"bytes"
1818
"encoding/json"
19+
"fmt"
1920
"log/slog"
21+
"reflect"
2022
"time"
2123

24+
"github.com/google/cel-go/cel"
25+
"github.com/google/cel-go/common/types/ref"
2226
"github.com/prometheus-community/json_exporter/config"
2327
"github.com/prometheus/client_golang/prometheus"
28+
"google.golang.org/protobuf/encoding/protojson"
29+
"google.golang.org/protobuf/proto"
30+
structpb "google.golang.org/protobuf/types/known/structpb"
2431
"k8s.io/client-go/util/jsonpath"
2532
)
2633

@@ -33,6 +40,7 @@ type JSONMetricCollector struct {
3340
type JSONMetric struct {
3441
Desc *prometheus.Desc
3542
Type config.ScrapeType
43+
EngineType config.EngineType
3644
KeyJSONPath string
3745
ValueJSONPath string
3846
LabelsJSONPaths []string
@@ -50,7 +58,8 @@ func (mc JSONMetricCollector) Collect(ch chan<- prometheus.Metric) {
5058
for _, m := range mc.JSONMetrics {
5159
switch m.Type {
5260
case config.ValueScrape:
53-
value, err := extractValue(mc.Logger, mc.Data, m.KeyJSONPath, false)
61+
mc.Logger.Debug("Extracting value for metric", "path", m.KeyJSONPath, "metric", m.Desc)
62+
value, err := extractValue(mc.Logger, m.EngineType, mc.Data, m.KeyJSONPath, false)
5463
if err != nil {
5564
mc.Logger.Error("Failed to extract value for metric", "path", m.KeyJSONPath, "err", err, "metric", m.Desc)
5665
continue
@@ -61,7 +70,7 @@ func (mc JSONMetricCollector) Collect(ch chan<- prometheus.Metric) {
6170
m.Desc,
6271
m.ValueType,
6372
floatValue,
64-
extractLabels(mc.Logger, mc.Data, m.LabelsJSONPaths)...,
73+
extractLabels(mc.Logger, m.EngineType, mc.Data, m.LabelsJSONPaths)...,
6574
)
6675
ch <- timestampMetric(mc.Logger, m, mc.Data, metric)
6776
} else {
@@ -70,7 +79,8 @@ func (mc JSONMetricCollector) Collect(ch chan<- prometheus.Metric) {
7079
}
7180

7281
case config.ObjectScrape:
73-
values, err := extractValue(mc.Logger, mc.Data, m.KeyJSONPath, true)
82+
mc.Logger.Debug("Extracting object for metric", "path", m.KeyJSONPath, "metric", m.Desc)
83+
values, err := extractValue(mc.Logger, m.EngineType, mc.Data, m.KeyJSONPath, true)
7484
if err != nil {
7585
mc.Logger.Error("Failed to extract json objects for metric", "err", err, "metric", m.Desc)
7686
continue
@@ -84,7 +94,7 @@ func (mc JSONMetricCollector) Collect(ch chan<- prometheus.Metric) {
8494
mc.Logger.Error("Failed to marshal data to json", "path", m.ValueJSONPath, "err", err, "metric", m.Desc, "data", data)
8595
continue
8696
}
87-
value, err := extractValue(mc.Logger, jdata, m.ValueJSONPath, false)
97+
value, err := extractValue(mc.Logger, m.EngineType, jdata, m.ValueJSONPath, false)
8898
if err != nil {
8999
mc.Logger.Error("Failed to extract value for metric", "path", m.ValueJSONPath, "err", err, "metric", m.Desc)
90100
continue
@@ -95,7 +105,7 @@ func (mc JSONMetricCollector) Collect(ch chan<- prometheus.Metric) {
95105
m.Desc,
96106
m.ValueType,
97107
floatValue,
98-
extractLabels(mc.Logger, jdata, m.LabelsJSONPaths)...,
108+
extractLabels(mc.Logger, m.EngineType, jdata, m.LabelsJSONPaths)...,
99109
)
100110
ch <- timestampMetric(mc.Logger, m, jdata, metric)
101111
} else {
@@ -104,7 +114,7 @@ func (mc JSONMetricCollector) Collect(ch chan<- prometheus.Metric) {
104114
}
105115
}
106116
} else {
107-
mc.Logger.Error("Failed to convert extracted objects to json", "err", err, "metric", m.Desc)
117+
mc.Logger.Error("Failed to convert extracted objects to json", "value", values, "err", err, "metric", m.Desc)
108118
continue
109119
}
110120
default:
@@ -114,8 +124,19 @@ func (mc JSONMetricCollector) Collect(ch chan<- prometheus.Metric) {
114124
}
115125
}
116126

127+
func extractValue(logger *slog.Logger, engine config.EngineType, data []byte, path string, enableJSONOutput bool) (string, error) {
128+
switch engine {
129+
case config.EngineTypeJSONPath:
130+
return extractValueJSONPath(logger, data, path, enableJSONOutput)
131+
case config.EngineTypeCEL:
132+
return extractValueCEL(logger, data, path, enableJSONOutput)
133+
default:
134+
return "", fmt.Errorf("Unknown engine type: %s", engine)
135+
}
136+
}
137+
117138
// Returns the last matching value at the given json path
118-
func extractValue(logger *slog.Logger, data []byte, path string, enableJSONOutput bool) (string, error) {
139+
func extractValueJSONPath(logger *slog.Logger, data []byte, path string, enableJSONOutput bool) (string, error) {
119140
var jsonData interface{}
120141
buf := new(bytes.Buffer)
121142

@@ -147,11 +168,70 @@ func extractValue(logger *slog.Logger, data []byte, path string, enableJSONOutpu
147168
return buf.String(), nil
148169
}
149170

171+
// Returns the last matching value at the given json path
172+
func extractValueCEL(logger *slog.Logger, data []byte, expression string, enableJSONOutput bool) (string, error) {
173+
174+
var jsonData map[string]any
175+
176+
err := json.Unmarshal(data, &jsonData)
177+
if err != nil {
178+
logger.Error("Failed to unmarshal data to json", "err", err, "data", data)
179+
return "", err
180+
}
181+
182+
inputVars := make([]cel.EnvOption, 0, len(jsonData))
183+
for k := range jsonData {
184+
inputVars = append(inputVars, cel.Variable(k, cel.DynType))
185+
}
186+
187+
env, err := cel.NewEnv(inputVars...)
188+
189+
if err != nil {
190+
logger.Error("Failed to set up CEL environment", "err", err, "data", data)
191+
return "", err
192+
}
193+
194+
ast, issues := env.Compile(expression)
195+
if issues != nil && issues.Err() != nil {
196+
logger.Error("CEL type-check error", "err", issues.String(), "expression", expression)
197+
return "", err
198+
}
199+
prg, err := env.Program(ast)
200+
if err != nil {
201+
logger.Error("CEL program construction error", "err", err)
202+
return "", err
203+
}
204+
205+
out, _, err := prg.Eval(jsonData)
206+
if err != nil {
207+
logger.Error("Failed to evaluate cel query", "err", err, "expression", expression, "data", jsonData)
208+
return "", err
209+
}
210+
211+
// Since we are finally going to extract only float64, unquote if necessary
212+
213+
//res, err := jsonpath.UnquoteExtend(fmt.Sprintf("%g", out))
214+
//if err == nil {
215+
// level.Error(logger).Log("msg","Triggered")
216+
// return res, nil
217+
//}
218+
logger.Error("Triggered later", "val", out)
219+
if enableJSONOutput {
220+
res, err := valueToJSON(out)
221+
if err != nil {
222+
return "", err
223+
}
224+
return res, nil
225+
}
226+
227+
return fmt.Sprintf("%v", out), nil
228+
}
229+
150230
// Returns the list of labels created from the list of provided json paths
151-
func extractLabels(logger *slog.Logger, data []byte, paths []string) []string {
231+
func extractLabels(logger *slog.Logger, engine config.EngineType, data []byte, paths []string) []string {
152232
labels := make([]string, len(paths))
153233
for i, path := range paths {
154-
if result, err := extractValue(logger, data, path, false); err == nil {
234+
if result, err := extractValue(logger, engine, data, path, false); err == nil {
155235
labels[i] = result
156236
} else {
157237
logger.Error("Failed to extract label value", "err", err, "path", path, "data", data)
@@ -164,7 +244,7 @@ func timestampMetric(logger *slog.Logger, m JSONMetric, data []byte, pm promethe
164244
if m.EpochTimestampJSONPath == "" {
165245
return pm
166246
}
167-
ts, err := extractValue(logger, data, m.EpochTimestampJSONPath, false)
247+
ts, err := extractValue(logger, m.EngineType, data, m.EpochTimestampJSONPath, false)
168248
if err != nil {
169249
logger.Error("Failed to extract timestamp for metric", "path", m.KeyJSONPath, "err", err, "metric", m.Desc)
170250
return pm
@@ -177,3 +257,18 @@ func timestampMetric(logger *slog.Logger, m JSONMetric, data []byte, pm promethe
177257
timestamp := time.UnixMilli(epochTime)
178258
return prometheus.NewMetricWithTimestamp(timestamp, pm)
179259
}
260+
261+
// valueToJSON converts the CEL type to a protobuf JSON representation and
262+
// marshals the result to a string.
263+
func valueToJSON(val ref.Val) (string, error) {
264+
v, err := val.ConvertToNative(reflect.TypeOf(&structpb.Value{}))
265+
if err != nil {
266+
return "", err
267+
}
268+
marshaller := protojson.MarshalOptions{Indent: " "}
269+
bytes, err := marshaller.Marshal(v.(proto.Message))
270+
if err != nil {
271+
return "", err
272+
}
273+
return string(bytes), err
274+
}

exporter/util.go

+3-1
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ func CreateMetricsList(c config.Module) ([]JSONMetric, error) {
102102
variableLabels,
103103
nil,
104104
),
105+
EngineType: metric.Engine,
105106
KeyJSONPath: metric.Path,
106107
LabelsJSONPaths: variableLabelsValues,
107108
ValueType: valueType,
@@ -124,6 +125,7 @@ func CreateMetricsList(c config.Module) ([]JSONMetric, error) {
124125
variableLabels,
125126
nil,
126127
),
128+
EngineType: metric.Engine,
127129
KeyJSONPath: metric.Path,
128130
ValueJSONPath: valuePath,
129131
LabelsJSONPaths: variableLabelsValues,
@@ -133,7 +135,7 @@ func CreateMetricsList(c config.Module) ([]JSONMetric, error) {
133135
metrics = append(metrics, jsonMetric)
134136
}
135137
default:
136-
return nil, fmt.Errorf("Unknown metric type: '%s', for metric: '%s'", metric.Type, metric.Name)
138+
return nil, fmt.Errorf("unknown metric type: '%s', for metric: '%s'", metric.Type, metric.Name)
137139
}
138140
}
139141
return metrics, nil

0 commit comments

Comments
 (0)