Skip to content

Commit d0c9753

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 15d4535 commit d0c9753

File tree

7 files changed

+207
-16
lines changed

7 files changed

+207
-16
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

+49-2
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,7 +28,19 @@ 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
@@ -31,6 +53,20 @@ modules:
3153
count: '{.count}' # dynamic value
3254
boolean: '{.some_boolean}'
3355

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+
69+
3470
animals:
3571
metrics:
3672
- name: animal
@@ -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:

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)