Skip to content

Add support for HTTP POST body content #123

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Oct 3, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,38 @@ TLS configuration supported by this exporter can be found at [exporter-toolkit/w
make build
```

## Sending body content for HTTP `POST`

If `body` paramater is set in config, 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.
```yaml
body:
content: |
My static information: {"time_diff": "1m25s", "anotherVar": "some value"}
```

The body content can also be a [Go Template](https://golang.org/pkg/text/template). All the functions from the [Sprig library](https://masterminds.github.io/sprig/) can be used in the template.
All the query parameters sent by prometheus in the scrape query to the exporter, are available as values while rendering the template.

Example using template functions:
```yaml
body:
content: |
{"time_diff": "{{ duration `95` }}","anotherVar": "{{ randInt 12 30 }}"}
templatize: true
```

Example using template functions with values from the query parameters:
```yaml
body:
content: |
{"time_diff": "{{ duration `95` }}","anotherVar": "{{ .myVal | first }}"}
templatize: true
```
Then `curl "http://exporter:7979/probe?target=http://scrape_target:8080/test/data.json&myVal=something"`, would result in sending the following body as the HTTP POST payload to `http://scrape_target:8080/test/data.json`:
```
{"time_diff": "1m35s","anotherVar": "something"}.
```

## Docker

```console
Expand Down
2 changes: 1 addition & 1 deletion cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ func probeHandler(w http.ResponseWriter, r *http.Request, logger log.Logger, con
return
}

data, err := exporter.FetchJson(ctx, logger, target, config)
data, err := exporter.FetchJson(ctx, logger, target, config, r.URL.Query())
if err != nil {
http.Error(w, "Failed to fetch JSON response. TARGET: "+target+", ERROR: "+err.Error(), http.StatusServiceUnavailable)
return
Expand Down
154 changes: 154 additions & 0 deletions cmd/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ package cmd

import (
"encoding/base64"
"io"
"io/ioutil"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/go-kit/kit/log"
Expand Down Expand Up @@ -218,3 +220,155 @@ func TestHTTPHeaders(t *testing.T) {
t.Fatalf("Setting custom headers failed unexpectedly. Got: %s", body)
}
}

// Test is the body template is correctly rendered
func TestBodyPostTemplate(t *testing.T) {
bodyTests := []struct {
Body config.ConfigBody
ShouldSucceed bool
Result string
}{
{
Body: config.ConfigBody{Content: "something static like pi, 3.14"},
ShouldSucceed: true,
},
{
Body: config.ConfigBody{Content: "arbitrary dynamic value pass: {{ randInt 12 30 }}", Templatize: false},
ShouldSucceed: true,
},
{
Body: config.ConfigBody{Content: "arbitrary dynamic value fail: {{ randInt 12 30 }}", Templatize: true},
ShouldSucceed: false,
},
{
Body: config.ConfigBody{Content: "templatized mutated value: {{ upper `hello` }} is now all caps", Templatize: true},
Result: "templatized mutated value: HELLO is now all caps",
ShouldSucceed: true,
},
{
Body: config.ConfigBody{Content: "value should be {{ lower `All Small` | trunc 3 }}", Templatize: true},
Result: "value should be all",
ShouldSucceed: true,
},
}

for _, test := range bodyTests {
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
expected := test.Body.Content
if test.Result != "" {
expected = test.Result
}
if got, _ := io.ReadAll(r.Body); string(got) != expected && test.ShouldSucceed {
t.Errorf("POST request body content mismatch, got: %s, expected: %s", got, expected)
}
w.WriteHeader(http.StatusOK)
}))

req := httptest.NewRequest("POST", "http://example.com/foo"+"?target="+target.URL, strings.NewReader(test.Body.Content))
recorder := httptest.NewRecorder()
c := config.Config{Body: test.Body}

probeHandler(recorder, req, log.NewNopLogger(), c)

resp := recorder.Result()
respBody, _ := ioutil.ReadAll(resp.Body)

if resp.StatusCode != http.StatusOK {
t.Fatalf("POST body content failed. Got: %s", respBody)
}
target.Close()
}
}

// Test is the query parameters are correctly replaced in the provided body template
func TestBodyPostQuery(t *testing.T) {
bodyTests := []struct {
Body config.ConfigBody
ShouldSucceed bool
Result string
QueryParams map[string]string
}{
{
Body: config.ConfigBody{Content: "pi has {{ .piValue | first }} value", Templatize: true},
ShouldSucceed: true,
Result: "pi has 3.14 value",
QueryParams: map[string]string{"piValue": "3.14"},
},
{
Body: config.ConfigBody{Content: `{ "pi": "{{ .piValue | first }}" }`, Templatize: true},
ShouldSucceed: true,
Result: `{ "pi": "3.14" }`,
QueryParams: map[string]string{"piValue": "3.14"},
},
{
Body: config.ConfigBody{Content: "pi has {{ .anotherQuery | first }} value", Templatize: true},
ShouldSucceed: true,
Result: "pi has very high value",
QueryParams: map[string]string{"piValue": "3.14", "anotherQuery": "very high"},
},
{
Body: config.ConfigBody{Content: "pi has {{ .piValue }} value", Templatize: true},
ShouldSucceed: false,
QueryParams: map[string]string{"piValue": "3.14", "anotherQuery": "dummy value"},
},
{
Body: config.ConfigBody{Content: "pi has {{ .piValue }} value", Templatize: true},
ShouldSucceed: true,
Result: "pi has [3.14] value",
QueryParams: map[string]string{"piValue": "3.14", "anotherQuery": "dummy value"},
},
{
Body: config.ConfigBody{Content: "value of {{ upper `pi` | repeat 3 }} is {{ .anotherQuery | first }}", Templatize: true},
ShouldSucceed: true,
Result: "value of PIPIPI is dummy value",
QueryParams: map[string]string{"piValue": "3.14", "anotherQuery": "dummy value"},
},
{
Body: config.ConfigBody{Content: "pi has {{ .piValue }} value", Templatize: true},
ShouldSucceed: true,
Result: "pi has [] value",
},
{
Body: config.ConfigBody{Content: "pi has {{ .piValue | first }} value", Templatize: true},
ShouldSucceed: true,
Result: "pi has <no value> value",
},
{
Body: config.ConfigBody{Content: "value of pi is 3.14", Templatize: true},
ShouldSucceed: true,
},
}

for _, test := range bodyTests {
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
expected := test.Body.Content
if test.Result != "" {
expected = test.Result
}
if got, _ := io.ReadAll(r.Body); string(got) != expected && test.ShouldSucceed {
t.Errorf("POST request body content mismatch (with query params), got: %s, expected: %s", got, expected)
}
w.WriteHeader(http.StatusOK)
}))

req := httptest.NewRequest("POST", "http://example.com/foo"+"?target="+target.URL, strings.NewReader(test.Body.Content))
q := req.URL.Query()
for k, v := range test.QueryParams {
q.Add(k, v)
}
req.URL.RawQuery = q.Encode()

recorder := httptest.NewRecorder()
c := config.Config{Body: test.Body}

probeHandler(recorder, req, log.NewNopLogger(), c)

resp := recorder.Result()
respBody, _ := ioutil.ReadAll(resp.Body)

if resp.StatusCode != http.StatusOK {
t.Fatalf("POST body content failed. Got: %s", respBody)
}
target.Close()
}
}
6 changes: 6 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ type Config struct {
Headers map[string]string `yaml:"headers,omitempty"`
Metrics []Metric `yaml:"metrics"`
HTTPClientConfig pconfig.HTTPClientConfig `yaml:"http_client_config,omitempty"`
Body ConfigBody `yaml:"body,omitempty"`
}

type ConfigBody struct {
Content string `yaml:"content"`
Templatize bool `yaml:"templatize,omitempty"`
}

func LoadConfig(configPath string) (Config, error) {
Expand Down
11 changes: 11 additions & 0 deletions examples/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ metrics:
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"}

# 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:
Expand Down
42 changes: 38 additions & 4 deletions exporter/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,12 @@ import (
"io/ioutil"
"math"
"net/http"
"net/url"
"strconv"
"strings"
"text/template"

"github.com/Masterminds/sprig/v3"
"github.com/go-kit/kit/log"
"github.com/go-kit/kit/log/level"
"github.com/prometheus-community/json_exporter/config"
Expand Down Expand Up @@ -110,21 +113,27 @@ func CreateMetricsList(c config.Config) ([]JsonMetric, error) {
return metrics, nil
}

func FetchJson(ctx context.Context, logger log.Logger, endpoint string, config config.Config) ([]byte, error) {
httpClientConfig := config.HTTPClientConfig
func FetchJson(ctx context.Context, logger log.Logger, endpoint string, c config.Config, tplValues url.Values) ([]byte, error) {
httpClientConfig := c.HTTPClientConfig
client, err := pconfig.NewClientFromConfig(httpClientConfig, "fetch_json", pconfig.WithKeepAlivesDisabled(), pconfig.WithHTTP2Disabled())
if err != nil {
level.Error(logger).Log("msg", "Error generating HTTP client", "err", err) //nolint:errcheck
return nil, err
}
req, err := http.NewRequest("GET", endpoint, nil)

var req *http.Request
if c.Body.Content == "" {
req, err = http.NewRequest("GET", endpoint, nil)
} else {
req, err = http.NewRequest("POST", endpoint, renderBody(logger, c.Body, tplValues))
}
req = req.WithContext(ctx)
if err != nil {
level.Error(logger).Log("msg", "Failed to create request", "err", err) //nolint:errcheck
return nil, err
}

for key, value := range config.Headers {
for key, value := range c.Headers {
req.Header.Add(key, value)
}
if req.Header.Get("Accept") == "" {
Expand Down Expand Up @@ -153,3 +162,28 @@ func FetchJson(ctx context.Context, logger log.Logger, endpoint string, config c

return data, nil
}

// Use the configured template to render the body if enabled
// Do not treat template errors as fatal, on such errors just log them
// and continue with static body content
func renderBody(logger log.Logger, body config.ConfigBody, tplValues url.Values) io.Reader {
br := strings.NewReader(body.Content)
if body.Templatize {
tpl, err := template.New("base").Funcs(sprig.TxtFuncMap()).Parse(body.Content)
if err != nil {
level.Error(logger).Log("msg", "Failed to create a new template from body content", "err", err, "content", body.Content) //nolint:errcheck
return br
}
tpl = tpl.Option("missingkey=zero")
var b strings.Builder
if err := tpl.Execute(&b, tplValues); err != nil {
level.Error(logger).Log("msg", "Failed to render template with values", "err", err, "tempalte", body.Content) //nolint:errcheck

// `tplValues` can contain sensitive values, so log it only when in debug mode
level.Debug(logger).Log("msg", "Failed to render template with values", "err", err, "tempalte", body.Content, "values", tplValues, "rendered_body", b.String()) //nolint:errcheck
return br
}
br = strings.NewReader(b.String())
}
return br
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/prometheus-community/json_exporter
go 1.14

require (
github.com/Masterminds/sprig/v3 v3.2.2
github.com/go-kit/kit v0.11.0
github.com/prometheus/client_golang v1.11.0
github.com/prometheus/common v0.30.0
Expand Down
Loading