Skip to content

Commit

Permalink
Add a way to mark http requests as failed or not(passed)
Browse files Browse the repository at this point in the history
This is done through running a callback on every request before emitting
the metrics. Currently only a built-in metric looking at good statuses
is possible, but a possibility for future JS based callbacks is left
open.

The implementation specifically makes it hard to figure out anything
about the returned callback from JS and tries not to change any other
code so it makes it easier for future implementation, but instead tries
to do the bare minimum without imposing any limitations on the future
work.

Additionally because it turned out to be easy, setting the callback to
null will make the http library to neither tag requests as passed nor
emit the new `http_req_failed` metric, essentially giving users a way to
go back to the previous behaviour.

The cloud output is also not changed as the `http_req_li` already is
aggregated based on tags so if an `http_req_li` is received that has tag
`passed:true` then the whole `http_req_li` is about requests that have
"passed".

part of #1828
  • Loading branch information
mstoykov committed Feb 15, 2021
1 parent 1181117 commit b746329
Show file tree
Hide file tree
Showing 12 changed files with 560 additions and 68 deletions.
1 change: 1 addition & 0 deletions core/local/local_test.go
Expand Up @@ -335,6 +335,7 @@ func TestExecutionSchedulerSystemTags(t *testing.T) {
"url": sr("HTTPBIN_IP_URL/"),
"proto": "HTTP/1.1",
"status": "200",
"passed": "true",
})
expTrailPVUTagsRaw := expCommonTrailTags.CloneTags()
expTrailPVUTagsRaw["scenario"] = "per_vu_test"
Expand Down
21 changes: 16 additions & 5 deletions js/modules/k6/http/request.go
Expand Up @@ -134,12 +134,14 @@ func (h *HTTP) parseRequest(
URL: reqURL.GetURL(),
Header: make(http.Header),
},
Timeout: 60 * time.Second,
Throw: state.Options.Throw.Bool,
Redirects: state.Options.MaxRedirects,
Cookies: make(map[string]*httpext.HTTPRequestCookie),
Tags: make(map[string]string),
Timeout: 60 * time.Second,
Throw: state.Options.Throw.Bool,
Redirects: state.Options.MaxRedirects,
Cookies: make(map[string]*httpext.HTTPRequestCookie),
Tags: make(map[string]string),
ResponseCallback: state.HTTPResponseCallback,
}

if state.Options.DiscardResponseBodies.Bool {
result.ResponseType = httpext.ResponseTypeNone
} else {
Expand Down Expand Up @@ -349,6 +351,15 @@ func (h *HTTP) parseRequest(
return nil, err
}
result.ResponseType = responseType
case "responseCallback":
v := params.Get(k).Export()
if v == nil {
result.ResponseCallback = nil
} else if c, ok := v.(*expectedStatuses); ok {
result.ResponseCallback = c.match
} else {
return nil, fmt.Errorf("unsupported responseCallback")
}
}
}
}
Expand Down
24 changes: 24 additions & 0 deletions js/modules/k6/http/request_test.go
Expand Up @@ -81,6 +81,7 @@ func TestRunES6String(t *testing.T) {
})
}

// TODO replace this with the Single version
func assertRequestMetricsEmitted(t *testing.T, sampleContainers []stats.SampleContainer, method, url, name string, status int, group string) {
if name == "" {
name = url
Expand Down Expand Up @@ -130,6 +131,29 @@ func assertRequestMetricsEmitted(t *testing.T, sampleContainers []stats.SampleCo
assert.True(t, seenReceiving, "url %s didn't emit Receiving", url)
}

func assertRequestMetricsEmittedSingle(t *testing.T, sampleContainer stats.SampleContainer, expectedTags map[string]string, metrics []*stats.Metric) {
t.Helper()

metricMap := make(map[string]bool, len(metrics))
for _, m := range metrics {
metricMap[m.Name] = false
}
for _, sample := range sampleContainer.GetSamples() {
tags := sample.Tags.CloneTags()
v, ok := metricMap[sample.Metric.Name]
assert.True(t, ok, "unexpected metric %s", sample.Metric.Name)
assert.False(t, v, "second metric %s", sample.Metric.Name)
metricMap[sample.Metric.Name] = true
for k, v := range expectedTags {
assert.Equal(t, v, tags[k], "wrong tag value for %s", k)
}
assert.Equal(t, len(expectedTags), len(tags))
}
for k, v := range metricMap {
assert.True(t, v, "didn't emit %s", k)
}
}

func newRuntime(
t testing.TB,
) (*httpmultibin.HTTPMultiBin, *lib.State, chan stats.SampleContainer, *goja.Runtime, *context.Context) {
Expand Down
110 changes: 110 additions & 0 deletions js/modules/k6/http/response_callback.go
@@ -0,0 +1,110 @@
/*
*
* k6 - a next-generation load testing tool
* Copyright (C) 2021 Load Impact
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

package http

import (
"context"
"errors"
"fmt"

"github.com/dop251/goja"
"github.com/loadimpact/k6/js/common"
"github.com/loadimpact/k6/lib"
)

//nolint:gochecknoglobals
var defaultExpectedStatuses = expectedStatuses{
minmax: [][2]int{{200, 399}},
}

// DefaultHTTPResponseCallback ...
func DefaultHTTPResponseCallback() func(int) bool {
return defaultExpectedStatuses.match
}

type expectedStatuses struct {
minmax [][2]int
exact []int // this can be done with the above and vice versa
}

func (e expectedStatuses) match(status int) bool {
for _, v := range e.exact { // binary search
if v == status {
return true
}
}

for _, v := range e.minmax { // binary search
if v[0] <= status && status <= v[1] {
return true
}
}
return false
}

// ExpectedStatuses is ...
func (*HTTP) ExpectedStatuses(ctx context.Context, args ...goja.Value) *expectedStatuses { //nolint: golint
rt := common.GetRuntime(ctx)

if len(args) == 0 {
common.Throw(rt, errors.New("no arguments"))
}
var result expectedStatuses

for i, arg := range args {
o := arg.ToObject(rt)
if o == nil {
//nolint:lll
common.Throw(rt, fmt.Errorf("argument number %d to expectedStatuses was neither an integer nor a an object like {min:100, max:329}", i+1))
}

if o.ClassName() == "Number" {
result.exact = append(result.exact, int(o.ToInteger()))
} else {
min := o.Get("min")
max := o.Get("max")
if min == nil || max == nil {
//nolint:lll
common.Throw(rt, fmt.Errorf("argument number %d to expectedStatuses was neither an integer nor a an object like {min:100, max:329}", i+1))
}
if !(checkNumber(min, rt) && checkNumber(max, rt)) {
common.Throw(rt, fmt.Errorf("both min and max need to be number for argument number %d", i+1))
}

result.minmax = append(result.minmax, [2]int{int(min.ToInteger()), int(max.ToInteger())})
}
}
return &result
}

func checkNumber(a goja.Value, rt *goja.Runtime) bool {
o := a.ToObject(rt)
return o != nil && o.ClassName() == "Number"
}

// SetResponseCallback ..
func (h HTTP) SetResponseCallback(ctx context.Context, es *expectedStatuses) {
if es != nil {
lib.GetState(ctx).HTTPResponseCallback = es.match
} else {
lib.GetState(ctx).HTTPResponseCallback = nil
}
}

0 comments on commit b746329

Please sign in to comment.