Skip to content

Commit e4fa7aa

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 1596a70 commit e4fa7aa

File tree

7 files changed

+213
-22
lines changed

7 files changed

+213
-22
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,12 +16,19 @@ package exporter
1616
import (
1717
"bytes"
1818
"encoding/json"
19+
"fmt"
20+
"reflect"
1921
"time"
2022

2123
"github.com/go-kit/log"
2224
"github.com/go-kit/log/level"
25+
"github.com/google/cel-go/cel"
26+
"github.com/google/cel-go/common/types/ref"
2327
"github.com/prometheus-community/json_exporter/config"
2428
"github.com/prometheus/client_golang/prometheus"
29+
"google.golang.org/protobuf/encoding/protojson"
30+
"google.golang.org/protobuf/proto"
31+
structpb "google.golang.org/protobuf/types/known/structpb"
2532
"k8s.io/client-go/util/jsonpath"
2633
)
2734

@@ -34,6 +41,7 @@ type JSONMetricCollector struct {
3441
type JSONMetric struct {
3542
Desc *prometheus.Desc
3643
Type config.ScrapeType
44+
EngineType config.EngineType
3745
KeyJSONPath string
3846
ValueJSONPath string
3947
LabelsJSONPaths []string
@@ -51,7 +59,8 @@ func (mc JSONMetricCollector) Collect(ch chan<- prometheus.Metric) {
5159
for _, m := range mc.JSONMetrics {
5260
switch m.Type {
5361
case config.ValueScrape:
54-
value, err := extractValue(mc.Logger, mc.Data, m.KeyJSONPath, false)
62+
level.Debug(mc.Logger).Log("msg", "Extracting value for metric", "path", m.KeyJSONPath, "metric", m.Desc)
63+
value, err := extractValue(mc.Logger, m.EngineType, mc.Data, m.KeyJSONPath, false)
5564
if err != nil {
5665
level.Error(mc.Logger).Log("msg", "Failed to extract value for metric", "path", m.KeyJSONPath, "err", err, "metric", m.Desc)
5766
continue
@@ -62,7 +71,7 @@ func (mc JSONMetricCollector) Collect(ch chan<- prometheus.Metric) {
6271
m.Desc,
6372
m.ValueType,
6473
floatValue,
65-
extractLabels(mc.Logger, mc.Data, m.LabelsJSONPaths)...,
74+
extractLabels(mc.Logger, m.EngineType, mc.Data, m.LabelsJSONPaths)...,
6675
)
6776
ch <- timestampMetric(mc.Logger, m, mc.Data, metric)
6877
} else {
@@ -71,7 +80,8 @@ func (mc JSONMetricCollector) Collect(ch chan<- prometheus.Metric) {
7180
}
7281

7382
case config.ObjectScrape:
74-
values, err := extractValue(mc.Logger, mc.Data, m.KeyJSONPath, true)
83+
level.Debug(mc.Logger).Log("msg", "Extracting object for metric", "path", m.KeyJSONPath, "metric", m.Desc)
84+
values, err := extractValue(mc.Logger, m.EngineType, mc.Data, m.KeyJSONPath, true)
7585
if err != nil {
7686
level.Error(mc.Logger).Log("msg", "Failed to extract json objects for metric", "err", err, "metric", m.Desc)
7787
continue
@@ -85,7 +95,7 @@ func (mc JSONMetricCollector) Collect(ch chan<- prometheus.Metric) {
8595
level.Error(mc.Logger).Log("msg", "Failed to marshal data to json", "path", m.ValueJSONPath, "err", err, "metric", m.Desc, "data", data)
8696
continue
8797
}
88-
value, err := extractValue(mc.Logger, jdata, m.ValueJSONPath, false)
98+
value, err := extractValue(mc.Logger, m.EngineType, jdata, m.ValueJSONPath, false)
8999
if err != nil {
90100
level.Error(mc.Logger).Log("msg", "Failed to extract value for metric", "path", m.ValueJSONPath, "err", err, "metric", m.Desc)
91101
continue
@@ -96,7 +106,7 @@ func (mc JSONMetricCollector) Collect(ch chan<- prometheus.Metric) {
96106
m.Desc,
97107
m.ValueType,
98108
floatValue,
99-
extractLabels(mc.Logger, jdata, m.LabelsJSONPaths)...,
109+
extractLabels(mc.Logger, m.EngineType, jdata, m.LabelsJSONPaths)...,
100110
)
101111
ch <- timestampMetric(mc.Logger, m, jdata, metric)
102112
} else {
@@ -105,7 +115,7 @@ func (mc JSONMetricCollector) Collect(ch chan<- prometheus.Metric) {
105115
}
106116
}
107117
} else {
108-
level.Error(mc.Logger).Log("msg", "Failed to convert extracted objects to json", "err", err, "metric", m.Desc)
118+
level.Error(mc.Logger).Log("msg", "Failed to convert extracted objects to json", "value", values, "err", err, "metric", m.Desc)
109119
continue
110120
}
111121
default:
@@ -115,8 +125,19 @@ func (mc JSONMetricCollector) Collect(ch chan<- prometheus.Metric) {
115125
}
116126
}
117127

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

@@ -148,11 +169,70 @@ func extractValue(logger log.Logger, data []byte, path string, enableJSONOutput
148169
return buf.String(), nil
149170
}
150171

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

exporter/util.go

+3-1
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ func CreateMetricsList(c config.Module) ([]JSONMetric, error) {
103103
variableLabels,
104104
nil,
105105
),
106+
EngineType: metric.Engine,
106107
KeyJSONPath: metric.Path,
107108
LabelsJSONPaths: variableLabelsValues,
108109
ValueType: valueType,
@@ -125,6 +126,7 @@ func CreateMetricsList(c config.Module) ([]JSONMetric, error) {
125126
variableLabels,
126127
nil,
127128
),
129+
EngineType: metric.Engine,
128130
KeyJSONPath: metric.Path,
129131
ValueJSONPath: valuePath,
130132
LabelsJSONPaths: variableLabelsValues,
@@ -134,7 +136,7 @@ func CreateMetricsList(c config.Module) ([]JSONMetric, error) {
134136
metrics = append(metrics, jsonMetric)
135137
}
136138
default:
137-
return nil, fmt.Errorf("Unknown metric type: '%s', for metric: '%s'", metric.Type, metric.Name)
139+
return nil, fmt.Errorf("unknown metric type: '%s', for metric: '%s'", metric.Type, metric.Name)
138140
}
139141
}
140142
return metrics, nil

0 commit comments

Comments
 (0)