diff --git a/core/engine.go b/core/engine.go
index daf5bed3505..1c527357281 100644
--- a/core/engine.go
+++ b/core/engine.go
@@ -108,6 +108,20 @@ func NewEngine(
e.submetrics[parent] = append(e.submetrics[parent], sm)
}
+ // TODO: refactor this out of here when https://github.com/loadimpact/k6/issues/1832 lands and
+ // there is a better way to enable a metric with tag
+ if opts.SystemTags.Has(stats.TagExpectedResponse) {
+ for _, name := range []string{
+ "http_req_duration{expected_response:true}",
+ } {
+ if _, ok := e.thresholds[name]; ok {
+ continue
+ }
+ parent, sm := stats.NewSubmetric(name)
+ e.submetrics[parent] = append(e.submetrics[parent], sm)
+ }
+ }
+
return e, nil
}
diff --git a/core/local/local_test.go b/core/local/local_test.go
index 382dd1a0028..c86f687b8cd 100644
--- a/core/local/local_test.go
+++ b/core/local/local_test.go
@@ -329,12 +329,13 @@ func TestExecutionSchedulerSystemTags(t *testing.T) {
}()
expCommonTrailTags := stats.IntoSampleTags(&map[string]string{
- "group": "",
- "method": "GET",
- "name": sr("HTTPBIN_IP_URL/"),
- "url": sr("HTTPBIN_IP_URL/"),
- "proto": "HTTP/1.1",
- "status": "200",
+ "group": "",
+ "method": "GET",
+ "name": sr("HTTPBIN_IP_URL/"),
+ "url": sr("HTTPBIN_IP_URL/"),
+ "proto": "HTTP/1.1",
+ "status": "200",
+ "expected_response": "true",
})
expTrailPVUTagsRaw := expCommonTrailTags.CloneTags()
expTrailPVUTagsRaw["scenario"] = "per_vu_test"
diff --git a/js/modules/k6/http/http.go b/js/modules/k6/http/http.go
index d0be75b8ec5..83aa592734d 100644
--- a/js/modules/k6/http/http.go
+++ b/js/modules/k6/http/http.go
@@ -73,6 +73,8 @@ func (g *GlobalHTTP) NewModuleInstancePerVU() interface{} { // this here needs t
OCSP_REASON_REMOVE_FROM_CRL: netext.OCSP_REASON_REMOVE_FROM_CRL,
OCSP_REASON_PRIVILEGE_WITHDRAWN: netext.OCSP_REASON_PRIVILEGE_WITHDRAWN,
OCSP_REASON_AA_COMPROMISE: netext.OCSP_REASON_AA_COMPROMISE,
+
+ responseCallback: defaultExpectedStatuses.match,
}
}
@@ -97,6 +99,8 @@ type HTTP struct {
OCSP_REASON_REMOVE_FROM_CRL string `js:"OCSP_REASON_REMOVE_FROM_CRL"`
OCSP_REASON_PRIVILEGE_WITHDRAWN string `js:"OCSP_REASON_PRIVILEGE_WITHDRAWN"`
OCSP_REASON_AA_COMPROMISE string `js:"OCSP_REASON_AA_COMPROMISE"`
+
+ responseCallback func(int) bool
}
func (*HTTP) XCookieJar(ctx *context.Context) *HTTPCookieJar {
diff --git a/js/modules/k6/http/request.go b/js/modules/k6/http/request.go
index 91fc9a73c88..22f18ab9dac 100644
--- a/js/modules/k6/http/request.go
+++ b/js/modules/k6/http/request.go
@@ -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: h.responseCallback,
}
+
if state.Options.DiscardResponseBodies.Bool {
result.ResponseType = httpext.ResponseTypeNone
} else {
@@ -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")
+ }
}
}
}
diff --git a/js/modules/k6/http/request_test.go b/js/modules/k6/http/request_test.go
index 2c2d4af1a1c..878bf1f9005 100644
--- a/js/modules/k6/http/request_test.go
+++ b/js/modules/k6/http/request_test.go
@@ -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
@@ -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, callback func(sample stats.Sample)) {
+ 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
+ assert.EqualValues(t, expectedTags, tags, "%s", tags)
+ if callback != nil {
+ callback(sample)
+ }
+ }
+ 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) {
@@ -1945,26 +1969,28 @@ func TestRedirectMetricTags(t *testing.T) {
checkTags := func(sc stats.SampleContainer, expTags map[string]string) {
allSamples := sc.GetSamples()
- assert.Len(t, allSamples, 8)
+ assert.Len(t, allSamples, 9)
for _, s := range allSamples {
assert.Equal(t, expTags, s.Tags.CloneTags())
}
}
expPOSTtags := map[string]string{
- "group": "",
- "method": "POST",
- "url": sr("HTTPBIN_URL/redirect/post"),
- "name": sr("HTTPBIN_URL/redirect/post"),
- "status": "301",
- "proto": "HTTP/1.1",
+ "group": "",
+ "method": "POST",
+ "url": sr("HTTPBIN_URL/redirect/post"),
+ "name": sr("HTTPBIN_URL/redirect/post"),
+ "status": "301",
+ "proto": "HTTP/1.1",
+ "expected_response": "true",
}
expGETtags := map[string]string{
- "group": "",
- "method": "GET",
- "url": sr("HTTPBIN_URL/get"),
- "name": sr("HTTPBIN_URL/get"),
- "status": "200",
- "proto": "HTTP/1.1",
+ "group": "",
+ "method": "GET",
+ "url": sr("HTTPBIN_URL/get"),
+ "name": sr("HTTPBIN_URL/get"),
+ "status": "200",
+ "proto": "HTTP/1.1",
+ "expected_response": "true",
}
checkTags(<-samples, expPOSTtags)
checkTags(<-samples, expGETtags)
diff --git a/js/modules/k6/http/response_callback.go b/js/modules/k6/http/response_callback.go
new file mode 100644
index 00000000000..cdcb7f74663
--- /dev/null
+++ b/js/modules/k6/http/response_callback.go
@@ -0,0 +1,117 @@
+/*
+ *
+ * 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 .
+ *
+ */
+
+package http
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/dop251/goja"
+ "github.com/loadimpact/k6/js/common"
+)
+
+//nolint:gochecknoglobals
+var defaultExpectedStatuses = expectedStatuses{
+ minmax: [][2]int{{200, 399}},
+}
+
+// expectedStatuses is specifically totally unexported so it can't be used for anything else but
+// SetResponseCallback and nothing can be done from the js side to modify it or make an instance of
+// it except using ExpectedStatuses
+type expectedStatuses struct {
+ minmax [][2]int
+ exact []int
+}
+
+func (e expectedStatuses) match(status int) bool {
+ for _, v := range e.exact {
+ if v == status {
+ return true
+ }
+ }
+
+ for _, v := range e.minmax {
+ if v[0] <= status && status <= v[1] {
+ return true
+ }
+ }
+ return false
+}
+
+// ExpectedStatuses returns expectedStatuses object based on the provided arguments.
+// The arguments must be either integers or object of `{min: , max: }`
+// kind. The "integer"ness is checked by the Number.isInteger.
+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
+
+ jsIsInt, _ := goja.AssertFunction(rt.GlobalObject().Get("Number").ToObject(rt).Get("isInteger"))
+ isInt := func(a goja.Value) bool {
+ v, err := jsIsInt(goja.Undefined(), a)
+ return err == nil && v.ToBoolean()
+ }
+
+ errMsg := "argument number %d to expectedStatuses was neither an integer nor an object like {min:100, max:329}"
+ for i, arg := range args {
+ o := arg.ToObject(rt)
+ if o == nil {
+ common.Throw(rt, fmt.Errorf(errMsg, i+1))
+ }
+
+ if isInt(arg) {
+ result.exact = append(result.exact, int(o.ToInteger()))
+ } else {
+ min := o.Get("min")
+ max := o.Get("max")
+ if min == nil || max == nil {
+ common.Throw(rt, fmt.Errorf(errMsg, i+1))
+ }
+ if !(isInt(min) && isInt(max)) {
+ common.Throw(rt, fmt.Errorf("both min and max need to be integers for argument number %d", i+1))
+ }
+
+ result.minmax = append(result.minmax, [2]int{int(min.ToInteger()), int(max.ToInteger())})
+ }
+ }
+ return &result
+}
+
+// SetResponseCallback sets the responseCallback to the value provided. Supported values are
+// expectedStatuses object or a `null` which means that metrics shouldn't be tagged as failed and
+// `http_req_failed` should not be emitted - the behaviour previous to this
+func (h *HTTP) SetResponseCallback(ctx context.Context, val goja.Value) {
+ if val != nil && !goja.IsNull(val) {
+ // This is done this way as ExportTo exports functions to empty structs without an error
+ if es, ok := val.Export().(*expectedStatuses); ok {
+ h.responseCallback = es.match
+ } else {
+ //nolint:golint
+ common.Throw(common.GetRuntime(ctx), fmt.Errorf("unsupported argument, expected http.expectedStatuses"))
+ }
+ } else {
+ h.responseCallback = nil
+ }
+}
diff --git a/js/modules/k6/http/response_callback_test.go b/js/modules/k6/http/response_callback_test.go
new file mode 100644
index 00000000000..735fc7ff05b
--- /dev/null
+++ b/js/modules/k6/http/response_callback_test.go
@@ -0,0 +1,562 @@
+/*
+ *
+ * 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 .
+ *
+ */
+
+package http
+
+import (
+ "context"
+ "fmt"
+ "sort"
+ "testing"
+
+ "github.com/dop251/goja"
+ "github.com/loadimpact/k6/js/common"
+ "github.com/loadimpact/k6/lib"
+ "github.com/loadimpact/k6/lib/metrics"
+ "github.com/loadimpact/k6/stats"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestExpectedStatuses(t *testing.T) {
+ t.Parallel()
+ rt := goja.New()
+ rt.SetFieldNameMapper(common.FieldNameMapper{})
+ ctx := context.Background()
+
+ ctx = common.WithRuntime(ctx, rt)
+ rt.Set("http", common.Bind(rt, new(GlobalHTTP).NewModuleInstancePerVU(), &ctx))
+ cases := map[string]struct {
+ code, err string
+ expected expectedStatuses
+ }{
+ "good example": {
+ expected: expectedStatuses{exact: []int{200, 300}, minmax: [][2]int{{200, 300}}},
+ code: `(http.expectedStatuses(200, 300, {min: 200, max:300}))`,
+ },
+
+ "strange example": {
+ expected: expectedStatuses{exact: []int{200, 300}, minmax: [][2]int{{200, 300}}},
+ code: `(http.expectedStatuses(200, 300, {min: 200, max:300, other: "attribute"}))`,
+ },
+
+ "string status code": {
+ code: `(http.expectedStatuses(200, "300", {min: 200, max:300}))`,
+ err: "argument number 2 to expectedStatuses was neither an integer nor an object like {min:100, max:329}",
+ },
+
+ "string max status code": {
+ code: `(http.expectedStatuses(200, 300, {min: 200, max:"300"}))`,
+ err: "both min and max need to be integers for argument number 3",
+ },
+ "float status code": {
+ err: "argument number 2 to expectedStatuses was neither an integer nor an object like {min:100, max:329}",
+ code: `(http.expectedStatuses(200, 300.5, {min: 200, max:300}))`,
+ },
+
+ "float max status code": {
+ err: "both min and max need to be integers for argument number 3",
+ code: `(http.expectedStatuses(200, 300, {min: 200, max:300.5}))`,
+ },
+ "no arguments": {
+ code: `(http.expectedStatuses())`,
+ err: "no arguments",
+ },
+ }
+
+ for name, testCase := range cases {
+ name, testCase := name, testCase
+ t.Run(name, func(t *testing.T) {
+ val, err := rt.RunString(testCase.code)
+ if testCase.err == "" {
+ require.NoError(t, err)
+ got := new(expectedStatuses)
+ err = rt.ExportTo(val, &got)
+ require.NoError(t, err)
+ require.Equal(t, testCase.expected, *got)
+ return // the t.Run
+ }
+
+ require.Error(t, err)
+ exc := err.(*goja.Exception)
+ require.Contains(t, exc.Error(), testCase.err)
+ })
+ }
+}
+
+type expectedSample struct {
+ tags map[string]string
+ metrics []*stats.Metric
+}
+
+func TestResponseCallbackInAction(t *testing.T) {
+ t.Parallel()
+ tb, _, samples, rt, ctx := newRuntime(t)
+ defer tb.Cleanup()
+ sr := tb.Replacer.Replace
+ httpModule := new(GlobalHTTP).NewModuleInstancePerVU().(*HTTP)
+ rt.Set("http", common.Bind(rt, httpModule, ctx))
+
+ HTTPMetricsWithoutFailed := []*stats.Metric{
+ metrics.HTTPReqs,
+ metrics.HTTPReqBlocked,
+ metrics.HTTPReqConnecting,
+ metrics.HTTPReqDuration,
+ metrics.HTTPReqReceiving,
+ metrics.HTTPReqWaiting,
+ metrics.HTTPReqSending,
+ metrics.HTTPReqTLSHandshaking,
+ }
+
+ allHTTPMetrics := append(HTTPMetricsWithoutFailed, metrics.HTTPReqFailed)
+
+ testCases := map[string]struct {
+ code string
+ expectedSamples []expectedSample
+ }{
+ "basic": {
+ code: `http.request("GET", "HTTPBIN_URL/redirect/1");`,
+ expectedSamples: []expectedSample{
+ {
+ tags: map[string]string{
+ "method": "GET",
+ "url": sr("HTTPBIN_URL/redirect/1"),
+ "name": sr("HTTPBIN_URL/redirect/1"),
+ "status": "302",
+ "group": "",
+ "expected_response": "true",
+ "proto": "HTTP/1.1",
+ },
+ metrics: allHTTPMetrics,
+ },
+ {
+ tags: map[string]string{
+ "method": "GET",
+ "url": sr("HTTPBIN_URL/get"),
+ "name": sr("HTTPBIN_URL/get"),
+ "status": "200",
+ "group": "",
+ "expected_response": "true",
+ "proto": "HTTP/1.1",
+ },
+ metrics: allHTTPMetrics,
+ },
+ },
+ },
+ "overwrite per request": {
+ code: `
+ http.setResponseCallback(http.expectedStatuses(200));
+ res = http.request("GET", "HTTPBIN_URL/redirect/1");
+ `,
+ expectedSamples: []expectedSample{
+ {
+ tags: map[string]string{
+ "method": "GET",
+ "url": sr("HTTPBIN_URL/redirect/1"),
+ "name": sr("HTTPBIN_URL/redirect/1"),
+ "status": "302",
+ "group": "",
+ "expected_response": "false", // this is on purpose
+ "proto": "HTTP/1.1",
+ },
+ metrics: allHTTPMetrics,
+ },
+ {
+ tags: map[string]string{
+ "method": "GET",
+ "url": sr("HTTPBIN_URL/get"),
+ "name": sr("HTTPBIN_URL/get"),
+ "status": "200",
+ "group": "",
+ "expected_response": "true",
+ "proto": "HTTP/1.1",
+ },
+ metrics: allHTTPMetrics,
+ },
+ },
+ },
+
+ "global overwrite": {
+ code: `http.request("GET", "HTTPBIN_URL/redirect/1", null, {responseCallback: http.expectedStatuses(200)});`,
+ expectedSamples: []expectedSample{
+ {
+ tags: map[string]string{
+ "method": "GET",
+ "url": sr("HTTPBIN_URL/redirect/1"),
+ "name": sr("HTTPBIN_URL/redirect/1"),
+ "status": "302",
+ "group": "",
+ "expected_response": "false", // this is on purpose
+ "proto": "HTTP/1.1",
+ },
+ metrics: allHTTPMetrics,
+ },
+ {
+ tags: map[string]string{
+ "method": "GET",
+ "url": sr("HTTPBIN_URL/get"),
+ "name": sr("HTTPBIN_URL/get"),
+ "status": "200",
+ "group": "",
+ "expected_response": "true",
+ "proto": "HTTP/1.1",
+ },
+ metrics: allHTTPMetrics,
+ },
+ },
+ },
+ "per request overwrite with null": {
+ code: `http.request("GET", "HTTPBIN_URL/redirect/1", null, {responseCallback: null});`,
+ expectedSamples: []expectedSample{
+ {
+ tags: map[string]string{
+ "method": "GET",
+ "url": sr("HTTPBIN_URL/redirect/1"),
+ "name": sr("HTTPBIN_URL/redirect/1"),
+ "status": "302",
+ "group": "",
+ "proto": "HTTP/1.1",
+ },
+ metrics: HTTPMetricsWithoutFailed,
+ },
+ {
+ tags: map[string]string{
+ "method": "GET",
+ "url": sr("HTTPBIN_URL/get"),
+ "name": sr("HTTPBIN_URL/get"),
+ "status": "200",
+ "group": "",
+ "proto": "HTTP/1.1",
+ },
+ metrics: HTTPMetricsWithoutFailed,
+ },
+ },
+ },
+ "global overwrite with null": {
+ code: `
+ http.setResponseCallback(null);
+ res = http.request("GET", "HTTPBIN_URL/redirect/1");
+ `,
+ expectedSamples: []expectedSample{
+ {
+ tags: map[string]string{
+ "method": "GET",
+ "url": sr("HTTPBIN_URL/redirect/1"),
+ "name": sr("HTTPBIN_URL/redirect/1"),
+ "status": "302",
+ "group": "",
+ "proto": "HTTP/1.1",
+ },
+ metrics: HTTPMetricsWithoutFailed,
+ },
+ {
+ tags: map[string]string{
+ "method": "GET",
+ "url": sr("HTTPBIN_URL/get"),
+ "name": sr("HTTPBIN_URL/get"),
+ "status": "200",
+ "group": "",
+ "proto": "HTTP/1.1",
+ },
+ metrics: HTTPMetricsWithoutFailed,
+ },
+ },
+ },
+ }
+ for name, testCase := range testCases {
+ testCase := testCase
+ t.Run(name, func(t *testing.T) {
+ httpModule.responseCallback = defaultExpectedStatuses.match
+
+ _, err := rt.RunString(sr(testCase.code))
+ assert.NoError(t, err)
+ bufSamples := stats.GetBufferedSamples(samples)
+
+ reqsCount := 0
+ for _, container := range bufSamples {
+ for _, sample := range container.GetSamples() {
+ if sample.Metric.Name == "http_reqs" {
+ reqsCount++
+ }
+ }
+ }
+
+ require.Equal(t, len(testCase.expectedSamples), reqsCount)
+
+ for i, expectedSample := range testCase.expectedSamples {
+ assertRequestMetricsEmittedSingle(t, bufSamples[i], expectedSample.tags, expectedSample.metrics, nil)
+ }
+ })
+ }
+}
+
+func TestResponseCallbackBatch(t *testing.T) {
+ t.Parallel()
+ tb, _, samples, rt, ctx := newRuntime(t)
+ defer tb.Cleanup()
+ sr := tb.Replacer.Replace
+ httpModule := new(GlobalHTTP).NewModuleInstancePerVU().(*HTTP)
+ rt.Set("http", common.Bind(rt, httpModule, ctx))
+
+ HTTPMetricsWithoutFailed := []*stats.Metric{
+ metrics.HTTPReqs,
+ metrics.HTTPReqBlocked,
+ metrics.HTTPReqConnecting,
+ metrics.HTTPReqDuration,
+ metrics.HTTPReqReceiving,
+ metrics.HTTPReqWaiting,
+ metrics.HTTPReqSending,
+ metrics.HTTPReqTLSHandshaking,
+ }
+
+ allHTTPMetrics := append(HTTPMetricsWithoutFailed, metrics.HTTPReqFailed)
+ // IMPORTANT: the tests here depend on the fact that the url they hit can be ordered in the same
+ // order as the expectedSamples even if they are made concurrently
+ testCases := map[string]struct {
+ code string
+ expectedSamples []expectedSample
+ }{
+ "basic": {
+ code: `
+ http.batch([["GET", "HTTPBIN_URL/status/200", null, {responseCallback: null}],
+ ["GET", "HTTPBIN_URL/status/201"],
+ ["GET", "HTTPBIN_URL/status/202", null, {responseCallback: http.expectedStatuses(4)}],
+ ["GET", "HTTPBIN_URL/status/405", null, {responseCallback: http.expectedStatuses(405)}],
+ ]);`,
+ expectedSamples: []expectedSample{
+ {
+ tags: map[string]string{
+ "method": "GET",
+ "url": sr("HTTPBIN_URL/status/200"),
+ "name": sr("HTTPBIN_URL/status/200"),
+ "status": "200",
+ "group": "",
+ "proto": "HTTP/1.1",
+ },
+ metrics: HTTPMetricsWithoutFailed,
+ },
+ {
+ tags: map[string]string{
+ "method": "GET",
+ "url": sr("HTTPBIN_URL/status/201"),
+ "name": sr("HTTPBIN_URL/status/201"),
+ "status": "201",
+ "group": "",
+ "expected_response": "true",
+ "proto": "HTTP/1.1",
+ },
+ metrics: allHTTPMetrics,
+ },
+ {
+ tags: map[string]string{
+ "method": "GET",
+ "url": sr("HTTPBIN_URL/status/202"),
+ "name": sr("HTTPBIN_URL/status/202"),
+ "status": "202",
+ "group": "",
+ "expected_response": "false",
+ "proto": "HTTP/1.1",
+ },
+ metrics: allHTTPMetrics,
+ },
+ {
+ tags: map[string]string{
+ "method": "GET",
+ "url": sr("HTTPBIN_URL/status/405"),
+ "name": sr("HTTPBIN_URL/status/405"),
+ "status": "405",
+ "error_code": "1405",
+ "group": "",
+ "expected_response": "true",
+ "proto": "HTTP/1.1",
+ },
+ metrics: allHTTPMetrics,
+ },
+ },
+ },
+ }
+ for name, testCase := range testCases {
+ testCase := testCase
+ t.Run(name, func(t *testing.T) {
+ httpModule.responseCallback = defaultExpectedStatuses.match
+
+ _, err := rt.RunString(sr(testCase.code))
+ assert.NoError(t, err)
+ bufSamples := stats.GetBufferedSamples(samples)
+
+ reqsCount := 0
+ for _, container := range bufSamples {
+ for _, sample := range container.GetSamples() {
+ if sample.Metric.Name == "http_reqs" {
+ reqsCount++
+ }
+ }
+ }
+ sort.Slice(bufSamples, func(i, j int) bool {
+ iURL, _ := bufSamples[i].GetSamples()[0].Tags.Get("url")
+ jURL, _ := bufSamples[j].GetSamples()[0].Tags.Get("url")
+ return iURL < jURL
+ })
+
+ require.Equal(t, len(testCase.expectedSamples), reqsCount)
+
+ for i, expectedSample := range testCase.expectedSamples {
+ assertRequestMetricsEmittedSingle(t, bufSamples[i], expectedSample.tags, expectedSample.metrics, nil)
+ }
+ })
+ }
+}
+
+func TestResponseCallbackInActionWithoutPassedTag(t *testing.T) {
+ t.Parallel()
+ tb, state, samples, rt, ctx := newRuntime(t)
+ defer tb.Cleanup()
+ sr := tb.Replacer.Replace
+ allHTTPMetrics := []*stats.Metric{
+ metrics.HTTPReqs,
+ metrics.HTTPReqFailed,
+ metrics.HTTPReqBlocked,
+ metrics.HTTPReqConnecting,
+ metrics.HTTPReqDuration,
+ metrics.HTTPReqReceiving,
+ metrics.HTTPReqSending,
+ metrics.HTTPReqWaiting,
+ metrics.HTTPReqTLSHandshaking,
+ }
+ deleteSystemTag(state, stats.TagExpectedResponse.String())
+ httpModule := new(GlobalHTTP).NewModuleInstancePerVU().(*HTTP)
+ rt.Set("http", common.Bind(rt, httpModule, ctx))
+
+ _, err := rt.RunString(sr(`http.request("GET", "HTTPBIN_URL/redirect/1", null, {responseCallback: http.expectedStatuses(200)});`))
+ assert.NoError(t, err)
+ bufSamples := stats.GetBufferedSamples(samples)
+
+ reqsCount := 0
+ for _, container := range bufSamples {
+ for _, sample := range container.GetSamples() {
+ if sample.Metric.Name == "http_reqs" {
+ reqsCount++
+ }
+ }
+ }
+
+ require.Equal(t, 2, reqsCount)
+
+ tags := map[string]string{
+ "method": "GET",
+ "url": sr("HTTPBIN_URL/redirect/1"),
+ "name": sr("HTTPBIN_URL/redirect/1"),
+ "status": "302",
+ "group": "",
+ "proto": "HTTP/1.1",
+ }
+ assertRequestMetricsEmittedSingle(t, bufSamples[0], tags, allHTTPMetrics, func(sample stats.Sample) {
+ if sample.Metric.Name == metrics.HTTPReqFailed.Name {
+ require.EqualValues(t, sample.Value, 1)
+ }
+ })
+ tags["url"] = sr("HTTPBIN_URL/get")
+ tags["name"] = tags["url"]
+ tags["status"] = "200"
+ assertRequestMetricsEmittedSingle(t, bufSamples[1], tags, allHTTPMetrics, func(sample stats.Sample) {
+ if sample.Metric.Name == metrics.HTTPReqFailed.Name {
+ require.EqualValues(t, sample.Value, 0)
+ }
+ })
+}
+
+func TestDigestWithResponseCallback(t *testing.T) {
+ t.Parallel()
+ tb, _, samples, rt, ctx := newRuntime(t)
+ defer tb.Cleanup()
+
+ httpModule := new(GlobalHTTP).NewModuleInstancePerVU().(*HTTP)
+ rt.Set("http", common.Bind(rt, httpModule, ctx))
+
+ urlWithCreds := tb.Replacer.Replace(
+ "http://testuser:testpwd@HTTPBIN_IP:HTTPBIN_PORT/digest-auth/auth/testuser/testpwd",
+ )
+
+ allHTTPMetrics := []*stats.Metric{
+ metrics.HTTPReqs,
+ metrics.HTTPReqFailed,
+ metrics.HTTPReqBlocked,
+ metrics.HTTPReqConnecting,
+ metrics.HTTPReqDuration,
+ metrics.HTTPReqReceiving,
+ metrics.HTTPReqSending,
+ metrics.HTTPReqWaiting,
+ metrics.HTTPReqTLSHandshaking,
+ }
+ _, err := rt.RunString(fmt.Sprintf(`
+ var res = http.get(%q, { auth: "digest" });
+ if (res.status !== 200) { throw new Error("wrong status: " + res.status); }
+ if (res.error_code !== 0) { throw new Error("wrong error code: " + res.error_code); }
+ `, urlWithCreds))
+ require.NoError(t, err)
+ bufSamples := stats.GetBufferedSamples(samples)
+
+ reqsCount := 0
+ for _, container := range bufSamples {
+ for _, sample := range container.GetSamples() {
+ if sample.Metric.Name == "http_reqs" {
+ reqsCount++
+ }
+ }
+ }
+
+ require.Equal(t, 2, reqsCount)
+
+ urlRaw := tb.Replacer.Replace(
+ "http://HTTPBIN_IP:HTTPBIN_PORT/digest-auth/auth/testuser/testpwd")
+
+ tags := map[string]string{
+ "method": "GET",
+ "url": urlRaw,
+ "name": urlRaw,
+ "status": "401",
+ "group": "",
+ "proto": "HTTP/1.1",
+ "expected_response": "true",
+ "error_code": "1401",
+ }
+ assertRequestMetricsEmittedSingle(t, bufSamples[0], tags, allHTTPMetrics, func(sample stats.Sample) {
+ if sample.Metric.Name == metrics.HTTPReqFailed.Name {
+ require.EqualValues(t, sample.Value, 0)
+ }
+ })
+ tags["status"] = "200"
+ delete(tags, "error_code")
+ assertRequestMetricsEmittedSingle(t, bufSamples[1], tags, allHTTPMetrics, func(sample stats.Sample) {
+ if sample.Metric.Name == metrics.HTTPReqFailed.Name {
+ require.EqualValues(t, sample.Value, 0)
+ }
+ })
+}
+
+func deleteSystemTag(state *lib.State, tag string) {
+ enabledTags := state.Options.SystemTags.Map()
+ delete(enabledTags, tag)
+ tagsList := make([]string, 0, len(enabledTags))
+ for k := range enabledTags {
+ tagsList = append(tagsList, k)
+ }
+ state.Options.SystemTags = stats.ToSystemTagSet(tagsList)
+}
diff --git a/js/modules/k6/http/response_test.go b/js/modules/k6/http/response_test.go
index dbce89e0a5b..6b346e11754 100644
--- a/js/modules/k6/http/response_test.go
+++ b/js/modules/k6/http/response_test.go
@@ -55,6 +55,7 @@ const testGetFormHTML = `