diff --git a/README.md b/README.md index adbbdf2..09ca7c5 100644 --- a/README.md +++ b/README.md @@ -39,35 +39,37 @@ $ cat examples/data.json $ cat examples/config.yml --- -metrics: -- name: example_global_value - path: "{ .counter }" - help: Example of a top-level global value scrape in the json - labels: - environment: beta # static label - location: "planet-{.location}" # dynamic label +modules: + default: + metrics: + - name: example_global_value + path: "{ .counter }" + help: Example of a top-level global value scrape in the json + labels: + environment: beta # static label + location: "planet-{.location}" # dynamic label -- name: example_value - type: object - help: Example of sub-level value scrapes from a json - path: '{.values[?(@.state == "ACTIVE")]}' - labels: - environment: beta # static label - id: '{.id}' # dynamic label - values: - active: 1 # static value - count: '{.count}' # dynamic value - boolean: '{.some_boolean}' + - name: example_value + type: object + help: Example of sub-level value scrapes from a json + path: '{.values[?(@.state == "ACTIVE")]}' + labels: + environment: beta # static label + id: '{.id}' # dynamic label + values: + active: 1 # static value + count: '{.count}' # dynamic value + boolean: '{.some_boolean}' -headers: - X-Dummy: my-test-header + headers: + X-Dummy: my-test-header $ python -m SimpleHTTPServer 8000 & Serving HTTP on 0.0.0.0 port 8000 ... $ ./json_exporter --config.file examples/config.yml & -$ curl "http://localhost:7979/probe?target=http://localhost:8000/examples/data.json" | grep ^example +$ curl "http://localhost:7979/probe?module=default&target=http://localhost:8000/examples/data.json" | grep ^example example_global_value{environment="beta",location="planet-mars"} 1234 example_value_active{environment="beta",id="id-A"} 1 example_value_active{environment="beta",id="id-C"} 1 diff --git a/cmd/main.go b/cmd/main.go index 3f4bcb1..926cd81 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -16,6 +16,7 @@ package cmd import ( "context" "encoding/json" + "fmt" "net/http" "os" @@ -86,9 +87,19 @@ func probeHandler(w http.ResponseWriter, r *http.Request, logger log.Logger, con defer cancel() r = r.WithContext(ctx) + module := r.URL.Query().Get("module") + if module == "" { + module = "default" + } + if _, ok := config.Modules[module]; !ok { + http.Error(w, fmt.Sprintf("Unknown module %q", module), http.StatusBadRequest) + level.Debug(logger).Log("msg", "Unknown module", "module", module) + return + } + registry := prometheus.NewPedanticRegistry() - metrics, err := exporter.CreateMetricsList(config) + metrics, err := exporter.CreateMetricsList(config.Modules[module]) if err != nil { level.Error(logger).Log("msg", "Failed to create metrics list from config", "err", err) } @@ -102,7 +113,7 @@ func probeHandler(w http.ResponseWriter, r *http.Request, logger log.Logger, con return } - fetcher := exporter.NewJSONFetcher(ctx, logger, config, r.URL.Query()) + fetcher := exporter.NewJSONFetcher(ctx, logger, config.Modules[module], r.URL.Query()) data, err := fetcher.FetchJSON(target) if err != nil { http.Error(w, "Failed to fetch JSON response. TARGET: "+target+", ERROR: "+err.Error(), http.StatusServiceUnavailable) diff --git a/cmd/main_test.go b/cmd/main_test.go index 6bb03d8..1f8b0d3 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -32,9 +32,9 @@ func TestFailIfSelfSignedCA(t *testing.T) { })) defer target.Close() - req := httptest.NewRequest("GET", "http://example.com/foo"+"?target="+target.URL, nil) + req := httptest.NewRequest("GET", "http://example.com/foo"+"?module=default&target="+target.URL, nil) recorder := httptest.NewRecorder() - probeHandler(recorder, req, log.NewNopLogger(), config.Config{}) + probeHandler(recorder, req, log.NewNopLogger(), config.Config{Modules: map[string]config.Module{"default": {}}}) resp := recorder.Result() body, _ := ioutil.ReadAll(resp.Body) @@ -45,13 +45,21 @@ func TestFailIfSelfSignedCA(t *testing.T) { } func TestSucceedIfSelfSignedCA(t *testing.T) { - c := config.Config{} - c.HTTPClientConfig.TLSConfig.InsecureSkipVerify = true + c := config.Config{ + Modules: map[string]config.Module{ + "default": { + HTTPClientConfig: pconfig.HTTPClientConfig{ + TLSConfig: pconfig.TLSConfig{ + InsecureSkipVerify: true, + }, + }, + }}, + } target := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { })) defer target.Close() - req := httptest.NewRequest("GET", "http://example.com/foo"+"?target="+target.URL, nil) + req := httptest.NewRequest("GET", "http://example.com/foo"+"?module=default&target="+target.URL, nil) recorder := httptest.NewRecorder() probeHandler(recorder, req, log.NewNopLogger(), c) @@ -63,6 +71,29 @@ func TestSucceedIfSelfSignedCA(t *testing.T) { } } +func TestDefaultModule(t *testing.T) { + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + })) + defer target.Close() + + req := httptest.NewRequest("GET", "http://example.com/foo"+"?target="+target.URL, nil) + recorder := httptest.NewRecorder() + probeHandler(recorder, req, log.NewNopLogger(), config.Config{Modules: map[string]config.Module{"default": {}}}) + + resp := recorder.Result() + if resp.StatusCode != http.StatusOK { + t.Fatalf("Default module test fails unexpectedly, expected 200, got %d", resp.StatusCode) + } + + // Module doesn't exist. + recorder = httptest.NewRecorder() + probeHandler(recorder, req, log.NewNopLogger(), config.Config{Modules: map[string]config.Module{"foo": {}}}) + resp = recorder.Result() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("Default module test fails unexpectedly, expected 400, got %d", resp.StatusCode) + } +} + func TestFailIfTargetMissing(t *testing.T) { req := httptest.NewRequest("GET", "http://example.com/foo", nil) recorder := httptest.NewRecorder() @@ -86,9 +117,9 @@ func TestDefaultAcceptHeader(t *testing.T) { })) defer target.Close() - req := httptest.NewRequest("GET", "http://example.com/foo"+"?target="+target.URL, nil) + req := httptest.NewRequest("GET", "http://example.com/foo"+"?module=default&target="+target.URL, nil) recorder := httptest.NewRecorder() - probeHandler(recorder, req, log.NewNopLogger(), config.Config{}) + probeHandler(recorder, req, log.NewNopLogger(), config.Config{Modules: map[string]config.Module{"default": {}}}) resp := recorder.Result() body, _ := ioutil.ReadAll(resp.Body) @@ -118,7 +149,7 @@ func TestCorrectResponse(t *testing.T) { t.Fatalf("Failed to load config file %s", test.ConfigFile) } - req := httptest.NewRequest("GET", "http://example.com/foo"+"?target="+target.URL+test.ServeFile, nil) + req := httptest.NewRequest("GET", "http://example.com/foo"+"?module=default&target="+target.URL+test.ServeFile, nil) recorder := httptest.NewRecorder() probeHandler(recorder, req, log.NewNopLogger(), c) @@ -145,15 +176,21 @@ func TestBasicAuth(t *testing.T) { })) defer target.Close() - req := httptest.NewRequest("GET", "http://example.com/foo"+"?target="+target.URL, nil) + req := httptest.NewRequest("GET", "http://example.com/foo"+"?module=default&target="+target.URL, nil) recorder := httptest.NewRecorder() - c := config.Config{} - auth := &pconfig.BasicAuth{ - Username: username, - Password: pconfig.Secret(password), + c := config.Config{ + Modules: map[string]config.Module{ + "default": { + HTTPClientConfig: pconfig.HTTPClientConfig{ + BasicAuth: &pconfig.BasicAuth{ + Username: username, + Password: pconfig.Secret(password), + }, + }, + }, + }, } - c.HTTPClientConfig.BasicAuth = auth probeHandler(recorder, req, log.NewNopLogger(), c) resp := recorder.Result() @@ -175,11 +212,16 @@ func TestBearerToken(t *testing.T) { })) defer target.Close() - req := httptest.NewRequest("GET", "http://example.com/foo"+"?target="+target.URL, nil) + req := httptest.NewRequest("GET", "http://example.com/foo"+"?module=default&target="+target.URL, nil) recorder := httptest.NewRecorder() - c := config.Config{} + c := config.Config{ + Modules: map[string]config.Module{"default": { + HTTPClientConfig: pconfig.HTTPClientConfig{ + BearerToken: pconfig.Secret(token), + }, + }}, + } - c.HTTPClientConfig.BearerToken = pconfig.Secret(token) probeHandler(recorder, req, log.NewNopLogger(), c) resp := recorder.Result() @@ -206,10 +248,15 @@ func TestHTTPHeaders(t *testing.T) { })) defer target.Close() - req := httptest.NewRequest("GET", "http://example.com/foo"+"?target="+target.URL, nil) + req := httptest.NewRequest("GET", "http://example.com/foo"+"?module=default&target="+target.URL, nil) recorder := httptest.NewRecorder() - c := config.Config{} - c.Headers = headers + c := config.Config{ + Modules: map[string]config.Module{ + "default": { + Headers: headers, + }, + }, + } probeHandler(recorder, req, log.NewNopLogger(), c) @@ -264,9 +311,15 @@ func TestBodyPostTemplate(t *testing.T) { w.WriteHeader(http.StatusOK) })) - req := httptest.NewRequest("POST", "http://example.com/foo"+"?target="+target.URL, strings.NewReader(test.Body.Content)) + req := httptest.NewRequest("POST", "http://example.com/foo"+"?module=default&target="+target.URL, strings.NewReader(test.Body.Content)) recorder := httptest.NewRecorder() - c := config.Config{Body: test.Body} + c := config.Config{ + Modules: map[string]config.Module{ + "default": { + Body: test.Body, + }, + }, + } probeHandler(recorder, req, log.NewNopLogger(), c) @@ -351,7 +404,7 @@ func TestBodyPostQuery(t *testing.T) { w.WriteHeader(http.StatusOK) })) - req := httptest.NewRequest("POST", "http://example.com/foo"+"?target="+target.URL, strings.NewReader(test.Body.Content)) + req := httptest.NewRequest("POST", "http://example.com/foo"+"?module=default&target="+target.URL, strings.NewReader(test.Body.Content)) q := req.URL.Query() for k, v := range test.QueryParams { q.Add(k, v) @@ -359,7 +412,13 @@ func TestBodyPostQuery(t *testing.T) { req.URL.RawQuery = q.Encode() recorder := httptest.NewRecorder() - c := config.Config{Body: test.Body} + c := config.Config{ + Modules: map[string]config.Module{ + "default": { + Body: test.Body, + }, + }, + } probeHandler(recorder, req, log.NewNopLogger(), c) diff --git a/config/config.go b/config/config.go index 2bb4208..c98012b 100644 --- a/config/config.go +++ b/config/config.go @@ -46,8 +46,13 @@ const ( ValueTypeUntyped ValueType = "untyped" ) -// Config contains metrics and headers defining a configuration +// Config contains multiple modules. type Config struct { + Modules map[string]Module `yaml:"modules"` +} + +// Module contains metrics and headers defining a configuration +type Module struct { Headers map[string]string `yaml:"headers,omitempty"` Metrics []Metric `yaml:"metrics"` HTTPClientConfig pconfig.HTTPClientConfig `yaml:"http_client_config,omitempty"` @@ -71,15 +76,17 @@ func LoadConfig(configPath string) (Config, error) { } // Complete Defaults - for i := 0; i < len(config.Metrics); i++ { - if config.Metrics[i].Type == "" { - config.Metrics[i].Type = ValueScrape - } - if config.Metrics[i].Help == "" { - config.Metrics[i].Help = config.Metrics[i].Name - } - if config.Metrics[i].ValueType == "" { - config.Metrics[i].ValueType = ValueTypeUntyped + for _, module := range config.Modules { + for i := 0; i < len(module.Metrics); i++ { + if module.Metrics[i].Type == "" { + module.Metrics[i].Type = ValueScrape + } + if module.Metrics[i].Help == "" { + module.Metrics[i].Help = module.Metrics[i].Name + } + if module.Metrics[i].ValueType == "" { + module.Metrics[i].ValueType = ValueTypeUntyped + } } } diff --git a/examples/config.yml b/examples/config.yml index b4b28f1..d81785a 100644 --- a/examples/config.yml +++ b/examples/config.yml @@ -1,44 +1,45 @@ --- -metrics: -- name: example_global_value - path: "{ .counter }" - help: Example of a top-level global value scrape in the json - labels: - environment: beta # static label - location: "planet-{.location}" # dynamic label +modules: + default: + metrics: + - name: example_global_value + path: "{ .counter }" + help: Example of a top-level global value scrape in the json + labels: + environment: beta # static label + location: "planet-{.location}" # dynamic label -- name: example_value - type: object - help: Example of sub-level value scrapes from a json - path: '{.values[?(@.state == "ACTIVE")]}' - labels: - environment: beta # static label - id: '{.id}' # dynamic label - values: - active: 1 # static value - count: '{.count}' # dynamic value - boolean: '{.some_boolean}' + - name: example_value + type: object + help: Example of sub-level value scrapes from a json + path: '{.values[?(@.state == "ACTIVE")]}' + labels: + environment: beta # static label + id: '{.id}' # dynamic label + values: + active: 1 # static value + count: '{.count}' # dynamic value + boolean: '{.some_boolean}' + headers: + X-Dummy: my-test-header -headers: - X-Dummy: my-test-header + # If 'body' 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. + # body: + # content: | + # {"time_diff": "1m25s", "anotherVar": "some value"} -# If 'body' 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. -# body: -# content: | -# {"time_diff": "1m25s", "anotherVar": "some value"} + # 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. + # body: + # content: | + # {"time_diff": "{{ duration `95` }}","anotherVar": "{{ .myVal | first }}"} + # templatize: true -# 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. -# body: -# content: | -# {"time_diff": "{{ duration `95` }}","anotherVar": "{{ .myVal | first }}"} -# templatize: true - -# For full http client config parameters, ref: https://pkg.go.dev/github.com/prometheus/common/config?tab=doc#HTTPClientConfig -# -# http_client_config: -# tls_config: -# insecure_skip_verify: true -# basic_auth: -# username: myuser -# #password: veryverysecret -# password_file: /tmp/mysecret.txt + # For full http client config parameters, ref: https://pkg.go.dev/github.com/prometheus/common/config?tab=doc#HTTPClientConfig + # + # http_client_config: + # tls_config: + # insecure_skip_verify: true + # basic_auth: + # username: myuser + # #password: veryverysecret + # password_file: /tmp/mysecret.txt diff --git a/examples/prometheus.yml b/examples/prometheus.yml index 4eb302e..140c8de 100644 --- a/examples/prometheus.yml +++ b/examples/prometheus.yml @@ -15,6 +15,8 @@ scrape_configs: ## gather the metrics from third party json sources, via the json exporter - job_name: json metrics_path: /probe + params: + module: [default] static_configs: - targets: - http://host-1.foobar.com/dummy/data.json diff --git a/exporter/util.go b/exporter/util.go index 76d6769..0bbad4c 100644 --- a/exporter/util.go +++ b/exporter/util.go @@ -62,7 +62,7 @@ func SanitizeValue(s string) (float64, error) { return value, fmt.Errorf(resultErr) } -func CreateMetricsList(c config.Config) ([]JSONMetric, error) { +func CreateMetricsList(c config.Module) ([]JSONMetric, error) { var ( metrics []JSONMetric valueType prometheus.ValueType @@ -127,17 +127,17 @@ func CreateMetricsList(c config.Config) ([]JSONMetric, error) { } type JSONFetcher struct { - config config.Config + module config.Module ctx context.Context logger log.Logger method string body io.Reader } -func NewJSONFetcher(ctx context.Context, logger log.Logger, c config.Config, tplValues url.Values) *JSONFetcher { - method, body := renderBody(logger, c.Body, tplValues) +func NewJSONFetcher(ctx context.Context, logger log.Logger, m config.Module, tplValues url.Values) *JSONFetcher { + method, body := renderBody(logger, m.Body, tplValues) return &JSONFetcher{ - config: c, + module: m, ctx: ctx, logger: logger, method: method, @@ -146,7 +146,7 @@ func NewJSONFetcher(ctx context.Context, logger log.Logger, c config.Config, tpl } func (f *JSONFetcher) FetchJSON(endpoint string) ([]byte, error) { - httpClientConfig := f.config.HTTPClientConfig + httpClientConfig := f.module.HTTPClientConfig client, err := pconfig.NewClientFromConfig(httpClientConfig, "fetch_json", pconfig.WithKeepAlivesDisabled(), pconfig.WithHTTP2Disabled()) if err != nil { level.Error(f.logger).Log("msg", "Error generating HTTP client", "err", err) @@ -161,7 +161,7 @@ func (f *JSONFetcher) FetchJSON(endpoint string) ([]byte, error) { return nil, err } - for key, value := range f.config.Headers { + for key, value := range f.module.Headers { req.Header.Add(key, value) } if req.Header.Get("Accept") == "" { diff --git a/test/config/good.yml b/test/config/good.yml index dd6017f..89cccf9 100644 --- a/test/config/good.yml +++ b/test/config/good.yml @@ -1,21 +1,22 @@ --- -metrics: -- name: example_global_value - path: "{ .counter }" - help: Example of a top-level global value scrape in the json - labels: - environment: beta # static label - location: "planet-{.location}" # dynamic label - -- name: example_value - type: object - help: Example of sub-level value scrapes from a json - path: '{.values[?(@.state == "ACTIVE")]}' - labels: - environment: beta # static label - id: '{.id}' # dynamic label - values: - active: 1 # static value - count: '{.count}' # dynamic value - boolean: '{.some_boolean}' +modules: + default: + metrics: + - name: example_global_value + path: "{ .counter }" + help: Example of a top-level global value scrape in the json + labels: + environment: beta # static label + location: "planet-{.location}" # dynamic label + - name: example_value + type: object + help: Example of sub-level value scrapes from a json + path: '{.values[?(@.state == "ACTIVE")]}' + labels: + environment: beta # static label + id: '{.id}' # dynamic label + values: + active: 1 # static value + count: '{.count}' # dynamic value + boolean: '{.some_boolean}'