Skip to content
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

JSON Checks for Heartbeat HTTP Monitors #8667

Merged
merged 3 commits into from
Nov 9, 2018
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
1 change: 1 addition & 0 deletions CHANGELOG.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ https://github.com/elastic/beats/compare/v6.4.0...master[Check the HEAD diff]
*Journalbeat*

- Add journalbeat. {pull}8703[8703]
- Add the ability to check against JSON HTTP bodies with conditions. {pull}8667[8667]

*Metricbeat*

Expand Down
8 changes: 8 additions & 0 deletions heartbeat/_meta/beat.reference.yml
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,14 @@ heartbeat.monitors:
# Required response contents.
#body:

# Parses the body as JSON, then checks against the given condition expression
#json:
#- description: Explanation of what the check does
# condition:
# equals:
# myField: expectedValue


# NOTE: THIS FEATURE IS DEPRECATED AND WILL BE REMOVED IN A FUTURE RELEASE
# Configure file json file to be watched for changes to the monitor:
#watch.poll_file:
Expand Down
7 changes: 6 additions & 1 deletion heartbeat/docs/heartbeat-options.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,7 @@ Under `check.response`, specify these options:
it's set to 0, any status code other than 404 is accepted.
*`headers`*:: The required response headers.
*`body`*:: A list of regular expressions to match the the body output. Only a single expression needs to match.
*`json`*:: A list of <<conditions,condition>> expressions executed against the body when parsed as JSON.

The following configuration shows how to check the response when the body
contains JSON:
Expand All @@ -459,7 +460,11 @@ contains JSON:
'X-API-Key': '12345-mykey-67890'
check.response:
status: 200
body: '{"status": "ok"}'
json:
- description: check status
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the purpose of the description? logging output?

As this is YAML I would more expect someone that wants to put details about it as a yaml comment.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's interpolated into the error field in the event when the check fails.

This is important because it means the user can associate a human readable error with the downtime message when they get, say, an SMS.

So, instead of getting "http check for elastic.co/foo failed" they can get one that says JSON body did not match condition '%s' for monitor. Received JSON %+v, where %s is the description.

When using multiple conditions this is even more important since it may not be apparent what caused the failure.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it. So the description is sent with every event or just errors (need to check the code).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ruflin no, it's only sent on error. There are multiple conditions checked, and the first one that fails is the only one that creates an error.

Now that I think about it, that's probably a mistake. We should run all the conditions and list all of the condition failures.

I'll improve the PR by making the error message include a list of error messages. JSON body did not match 2 conditions: "version greater than 1", "name must be valid". Received JSON {...}

condition:
equals:
status: ok
-------------------------------------------------------------------------------

The following configuration shows how to check the response for multiple regex
Expand Down
8 changes: 8 additions & 0 deletions heartbeat/heartbeat.reference.yml
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,14 @@ heartbeat.monitors:
# Required response contents.
#body:

# Parses the body as JSON, then checks against the given condition expression
#json:
#- description: Explanation of what the check does
# condition:
# equals:
# myField: expectedValue


# NOTE: THIS FEATURE IS DEPRECATED AND WILL BE REMOVED IN A FUTURE RELEASE
# Configure file json file to be watched for changes to the monitor:
#watch.poll_file:
Expand Down
64 changes: 62 additions & 2 deletions heartbeat/monitors/active/http/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,18 @@
package http

import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"strings"

pkgerrors "github.com/pkg/errors"

"github.com/elastic/beats/libbeat/common"
"github.com/elastic/beats/libbeat/common/match"
"github.com/elastic/beats/libbeat/conditions"
)

type RespCheck func(*http.Response) error
Expand All @@ -32,7 +38,7 @@ var (
errBodyMismatch = errors.New("body mismatch")
)

func makeValidateResponse(config *responseParameters) RespCheck {
func makeValidateResponse(config *responseParameters) (RespCheck, error) {
var checks []RespCheck

if config.Status > 0 {
Expand All @@ -49,7 +55,15 @@ func makeValidateResponse(config *responseParameters) RespCheck {
checks = append(checks, checkBody(config.RecvBody))
}

return checkAll(checks...)
if len(config.RecvJSON) > 0 {
jsonChecks, err := checkJSON(config.RecvJSON)
if err != nil {
return nil, err
}
checks = append(checks, jsonChecks)
}

return checkAll(checks...), nil
}

func checkOK(_ *http.Response) error { return nil }
Expand Down Expand Up @@ -115,3 +129,49 @@ func checkBody(body []match.Matcher) RespCheck {
return errBodyMismatch
}
}

func checkJSON(checks []*jsonResponseCheck) (RespCheck, error) {
type compiledCheck struct {
description string
condition conditions.Condition
}

var compiledChecks []compiledCheck

for _, check := range checks {
cond, err := conditions.NewCondition(check.Condition)
if err != nil {
return nil, err
}
compiledChecks = append(compiledChecks, compiledCheck{check.Description, cond})
}

return func(r *http.Response) error {
decoded := &common.MapStr{}
err := json.NewDecoder(r.Body).Decode(decoded)

if err != nil {
body, _ := ioutil.ReadAll(r.Body)
return pkgerrors.Wrapf(err, "could not parse JSON for body check with condition. Source: %s", body)
}

var errorDescs []string
for _, compiledCheck := range compiledChecks {
ok := compiledCheck.condition.Check(decoded)
if !ok {
errorDescs = append(errorDescs, compiledCheck.description)
}
}

if len(errorDescs) > 0 {
return fmt.Errorf(
"JSON body did not match %d conditions '%s' for monitor. Received JSON %+v",
len(errorDescs),
strings.Join(errorDescs, ","),
decoded,
)
}

return nil
}, nil
}
71 changes: 71 additions & 0 deletions heartbeat/monitors/active/http/check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ import (
"net/http/httptest"
"testing"

"github.com/elastic/beats/libbeat/common"

"github.com/stretchr/testify/require"

"github.com/elastic/beats/libbeat/common/match"
"github.com/elastic/beats/libbeat/conditions"
)

func TestCheckBody(t *testing.T) {
Expand Down Expand Up @@ -125,3 +130,69 @@ func TestCheckBody(t *testing.T) {
})
}
}

func TestCheckJson(t *testing.T) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests are a bit redundant given the python integration tests. However, it's a nicer dev/test cycle when using these. I wrote these first.

I'm +1 to just leave them in, they could be useful in the future.

fooBazEqualsBar := common.MustNewConfigFrom(map[string]interface{}{"equals": map[string]interface{}{"foo": map[string]interface{}{"baz": "bar"}}})
fooBazEqualsBarConf := &conditions.Config{}
err := fooBazEqualsBar.Unpack(fooBazEqualsBarConf)
require.NoError(t, err)

fooBazEqualsBarDesc := "foo.baz equals bar"

var tests = []struct {
description string
body string
condDesc string
condConf *conditions.Config
result bool
}{
{
"positive match",
"{\"foo\": {\"baz\": \"bar\"}}",
fooBazEqualsBarDesc,
fooBazEqualsBarConf,
true,
},
{
"Negative match",
"{\"foo\": 123}",
fooBazEqualsBarDesc,
fooBazEqualsBarConf,
false,
},
{
"unparseable",
`notjson`,
fooBazEqualsBarDesc,
fooBazEqualsBarConf,
false,
},
}

for _, test := range tests {
t.Run(test.description, func(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, test.body)
}))
defer ts.Close()

res, err := http.Get(ts.URL)
if err != nil {
log.Fatal(err)
}

checker, err := checkJSON([]*jsonResponseCheck{{test.condDesc, test.condConf}})
require.NoError(t, err)
checkRes := checker(res)

if result := checkRes == nil; result != test.result {
if test.result {
t.Fatalf("Expected condition: '%s' to match body: %s. got: %s", test.condDesc, test.body, checkRes)
} else {
t.Fatalf("Did not expect condition: '%s' to match body: %s. got: %s", test.condDesc, test.body, checkRes)
}
}
})
}

}
15 changes: 12 additions & 3 deletions heartbeat/monitors/active/http/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import (
"strings"
"time"

"github.com/elastic/beats/libbeat/conditions"

"github.com/elastic/beats/libbeat/common/match"
"github.com/elastic/beats/libbeat/common/transport/tlscommon"

Expand Down Expand Up @@ -68,9 +70,15 @@ type requestParameters struct {

type responseParameters struct {
// expected HTTP response configuration
Status uint16 `config:"status" verify:"min=0, max=699"`
RecvHeaders map[string]string `config:"headers"`
RecvBody []match.Matcher `config:"body"`
Status uint16 `config:"status" verify:"min=0, max=699"`
RecvHeaders map[string]string `config:"headers"`
RecvBody []match.Matcher `config:"body"`
RecvJSON []*jsonResponseCheck `config:"json"`
}

type jsonResponseCheck struct {
Description string `config:"description"`
Condition *conditions.Config `config:"condition"`
}

type compressionConfig struct {
Expand All @@ -93,6 +101,7 @@ var defaultConfig = Config{
Status: 0,
RecvHeaders: nil,
RecvBody: []match.Matcher{},
RecvJSON: nil,
},
},
}
Expand Down
5 changes: 4 additions & 1 deletion heartbeat/monitors/active/http/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,10 @@ func create(
body = buf.Bytes()
}

validator := makeValidateResponse(&config.Check.Response)
validator, err := makeValidateResponse(&config.Check.Response)
if err != nil {
return nil, 0, err
}

jobs = make([]monitors.Job, len(config.URLs))

Expand Down
1 change: 1 addition & 0 deletions heartbeat/monitors/active/http/simple_transp.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ func (t *SimpleTransport) readResponse(
) (*http.Response, error) {
reader := bufio.NewReader(conn)
resp, err := http.ReadResponse(reader, req)
resp.Body = comboConnReadCloser{conn, resp.Body}
if err != nil {
return nil, err
}
Expand Down
8 changes: 8 additions & 0 deletions heartbeat/tests/system/config/heartbeat.yml.j2
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ heartbeat.monitors:
{% endfor %}
{% endif -%}


{%- if monitor.check_response_json is defined %}
check.response.json:
{%- for check in monitor.check_response_json %}
- {{check}}
{% endfor %}
{% endif -%}

{%- if monitor.fields is defined %}
{% if monitor.fields_under_root %}fields_under_root: true{% endif %}
fields:
Expand Down
37 changes: 37 additions & 0 deletions heartbeat/tests/system/test_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,43 @@ def test_http(self, status_code):
raise SkipTest
self.assert_fields_are_documented(output[0])

@parameterized.expand([
("up", '{"foo": {"baz": "bar"}}'),
("down", '{"foo": "unexpected"}'),
("down", 'notjson'),
])
def test_http_json(self, expected_status, body):
"""
Test JSON response checks
"""
server = self.start_server(body, 200)
try:
self.render_config_template(
monitors=[{
"type": "http",
"urls": ["http://localhost:{}".format(server.server_port)],
"check_response_json": [{
"description": "foo equals bar",
"condition": {
"equals": {"foo": {"baz": "bar"}}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does the YAML output look like here? I assume if you use "foo.baz": "bar" things will not work? What if the response actually has this exact key with a dot inside?

Sorry to keep poking on this one. Agree it's a potential condition issue if it breaks but it shows up here very prominent.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did some investigation and determined that dotted paths and nested maps are equivalent here due to the use of common.MapStr. This means that you can use either in your condition and it will work.

I think we should move further discussion of this to a separate issue since this has more to do with the beats condition evaluation engine than this specific PR.

}
}]
}]
)

try:
proc = self.start_beat()
self.wait_until(lambda: self.log_contains("heartbeat is running"))

self.wait_until(
lambda: self.output_has(lines=1))
finally:
proc.check_kill_and_wait()

self.assert_last_status(expected_status)
finally:
server.shutdown()

@parameterized.expand([
(lambda server: "localhost:{}".format(server.server_port), "up"),
# This IP is reserved in IPv4
Expand Down