diff --git a/core/local/local_test.go b/core/local/local_test.go
index 382dd1a0028..7f9f2388367 100644
--- a/core/local/local_test.go
+++ b/core/local/local_test.go
@@ -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"
diff --git a/js/modules/k6/http/request.go b/js/modules/k6/http/request.go
index d4a27b71b68..f8604c894ee 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: state.HTTPResponseCallback,
}
+
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 dc7cb518b30..6d6dd05291f 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) {
+ 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) {
diff --git a/js/modules/k6/http/response_callback.go b/js/modules/k6/http/response_callback.go
new file mode 100644
index 00000000000..10053ed7588
--- /dev/null
+++ b/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 .
+ *
+ */
+
+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
+ }
+}
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..9dec629d0e4
--- /dev/null
+++ b/js/modules/k6/http/response_callback_test.go
@@ -0,0 +1,307 @@
+/*
+ *
+ * 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"
+ "testing"
+
+ "github.com/dop251/goja"
+ "github.com/loadimpact/k6/js/common"
+ "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(), &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 a 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 number for argument number 3",
+ },
+ "float status code": { // TODO probably should not work
+ expected: expectedStatuses{exact: []int{200, 300}, minmax: [][2]int{{200, 300}}},
+ code: `(http.expectedStatuses(200, 300.5, {min: 200, max:300}))`,
+ },
+
+ "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, state, samples, rt, _ := 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,
+ }
+
+ HTTPMetricsWithoutFailed := []*stats.Metric{
+ metrics.HTTPReqs,
+ metrics.HTTPReqBlocked,
+ metrics.HTTPReqConnecting,
+ metrics.HTTPReqDuration,
+ metrics.HTTPReqReceiving,
+ metrics.HTTPReqWaiting,
+ metrics.HTTPReqSending,
+ metrics.HTTPReqTLSHandshaking,
+ }
+ 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": "",
+ "passed": "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": "",
+ "passed": "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": "",
+ "passed": "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": "",
+ "passed": "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": "",
+ "passed": "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": "",
+ "passed": "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) {
+ state.HTTPResponseCallback = DefaultHTTPResponseCallback()
+
+ _, 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)
+ }
+ })
+ }
+}
diff --git a/js/runner.go b/js/runner.go
index 1d1afbe3a7d..ed72dfeeb88 100644
--- a/js/runner.go
+++ b/js/runner.go
@@ -43,6 +43,7 @@ import (
"golang.org/x/time/rate"
"github.com/loadimpact/k6/js/common"
+ k6http "github.com/loadimpact/k6/js/modules/k6/http"
"github.com/loadimpact/k6/lib"
"github.com/loadimpact/k6/lib/consts"
"github.com/loadimpact/k6/lib/netext"
@@ -179,7 +180,7 @@ func (r *Runner) newVU(id int64, samplesOut chan<- stats.SampleContainer) (*VU,
}
tlsConfig := &tls.Config{
- InsecureSkipVerify: r.Bundle.Options.InsecureSkipTLSVerify.Bool,
+ InsecureSkipVerify: r.Bundle.Options.InsecureSkipTLSVerify.Bool, //nolint:gosec
CipherSuites: cipherSuites,
MinVersion: uint16(tlsVersions.Min),
MaxVersion: uint16(tlsVersions.Max),
@@ -217,19 +218,20 @@ func (r *Runner) newVU(id int64, samplesOut chan<- stats.SampleContainer) (*VU,
}
vu.state = &lib.State{
- Logger: vu.Runner.Logger,
- Options: vu.Runner.Bundle.Options,
- Transport: vu.Transport,
- Dialer: vu.Dialer,
- TLSConfig: vu.TLSConfig,
- CookieJar: cookieJar,
- RPSLimit: vu.Runner.RPSLimit,
- BPool: vu.BPool,
- Vu: vu.ID,
- Samples: vu.Samples,
- Iteration: vu.Iteration,
- Tags: vu.Runner.Bundle.Options.RunTags.CloneTags(),
- Group: r.defaultGroup,
+ Logger: vu.Runner.Logger,
+ Options: vu.Runner.Bundle.Options,
+ Transport: vu.Transport,
+ Dialer: vu.Dialer,
+ TLSConfig: vu.TLSConfig,
+ CookieJar: cookieJar,
+ RPSLimit: vu.Runner.RPSLimit,
+ BPool: vu.BPool,
+ Vu: vu.ID,
+ Samples: vu.Samples,
+ Iteration: vu.Iteration,
+ Tags: vu.Runner.Bundle.Options.RunTags.CloneTags(),
+ Group: r.defaultGroup,
+ HTTPResponseCallback: k6http.DefaultHTTPResponseCallback(), // TODO maybe move it to lib after all :sign:
}
vu.Runtime.Set("console", common.Bind(vu.Runtime, vu.Console, vu.Context))
@@ -244,6 +246,7 @@ func (r *Runner) newVU(id int64, samplesOut chan<- stats.SampleContainer) (*VU,
return vu, nil
}
+// Setup runs the setup function if there is one and sets the setupData to the returned value
func (r *Runner) Setup(ctx context.Context, out chan<- stats.SampleContainer) error {
setupCtx, setupCancel := context.WithTimeout(ctx, r.getTimeoutFor(consts.SetupFn))
defer setupCancel()
diff --git a/lib/metrics/metrics.go b/lib/metrics/metrics.go
index cb7eda3b0b2..a47aa156eba 100644
--- a/lib/metrics/metrics.go
+++ b/lib/metrics/metrics.go
@@ -24,7 +24,7 @@ import (
"github.com/loadimpact/k6/stats"
)
-//TODO: refactor this, using non thread-safe global variables seems like a bad idea for various reasons...
+// TODO: refactor this, using non thread-safe global variables seems like a bad idea for various reasons...
//nolint:gochecknoglobals
var (
@@ -42,6 +42,7 @@ var (
// HTTP-related.
HTTPReqs = stats.New("http_reqs", stats.Counter)
+ HTTPReqFailed = stats.New("http_req_failed", stats.Rate)
HTTPReqDuration = stats.New("http_req_duration", stats.Trend, stats.Time)
HTTPReqBlocked = stats.New("http_req_blocked", stats.Trend, stats.Time)
HTTPReqConnecting = stats.New("http_req_connecting", stats.Trend, stats.Time)
diff --git a/lib/netext/httpext/request.go b/lib/netext/httpext/request.go
index 5642f0235d5..7800c9d3333 100644
--- a/lib/netext/httpext/request.go
+++ b/lib/netext/httpext/request.go
@@ -103,18 +103,19 @@ type Request struct {
// ParsedHTTPRequest a represantion of a request after it has been parsed from a user script
type ParsedHTTPRequest struct {
- URL *URL
- Body *bytes.Buffer
- Req *http.Request
- Timeout time.Duration
- Auth string
- Throw bool
- ResponseType ResponseType
- Compressions []CompressionType
- Redirects null.Int
- ActiveJar *cookiejar.Jar
- Cookies map[string]*HTTPRequestCookie
- Tags map[string]string
+ URL *URL
+ Body *bytes.Buffer
+ Req *http.Request
+ Timeout time.Duration
+ Auth string
+ Throw bool
+ ResponseType ResponseType
+ ResponseCallback func(int) bool
+ Compressions []CompressionType
+ Redirects null.Int
+ ActiveJar *cookiejar.Jar
+ Cookies map[string]*HTTPRequestCookie
+ Tags map[string]string
}
// Matches non-compliant io.Closer implementations (e.g. zstd.Decoder)
@@ -139,7 +140,7 @@ func (r readCloser) Close() error {
}
func stdCookiesToHTTPRequestCookies(cookies []*http.Cookie) map[string][]*HTTPRequestCookie {
- var result = make(map[string][]*HTTPRequestCookie, len(cookies))
+ result := make(map[string][]*HTTPRequestCookie, len(cookies))
for _, cookie := range cookies {
result[cookie.Name] = append(result[cookie.Name],
&HTTPRequestCookie{Name: cookie.Name, Value: cookie.Value})
@@ -249,7 +250,7 @@ func MakeRequest(ctx context.Context, preq *ParsedHTTPRequest) (*Response, error
}
}
- tracerTransport := newTransport(ctx, state, tags)
+ tracerTransport := newTransport(ctx, state, tags, preq.ResponseCallback)
var transport http.RoundTripper = tracerTransport
// Combine tags with common log fields
@@ -381,7 +382,7 @@ func MakeRequest(ctx context.Context, preq *ParsedHTTPRequest) (*Response, error
// SetRequestCookies sets the cookies of the requests getting those cookies both from the jar and
// from the reqCookies map. The Replace field of the HTTPRequestCookie will be taken into account
func SetRequestCookies(req *http.Request, jar *cookiejar.Jar, reqCookies map[string]*HTTPRequestCookie) {
- var replacedCookies = make(map[string]struct{})
+ replacedCookies := make(map[string]struct{})
for key, reqCookie := range reqCookies {
req.AddCookie(&http.Cookie{Name: key, Value: reqCookie.Value})
if reqCookie.Replace {
diff --git a/lib/netext/httpext/transport.go b/lib/netext/httpext/transport.go
index bc754125f8b..2cd1671f801 100644
--- a/lib/netext/httpext/transport.go
+++ b/lib/netext/httpext/transport.go
@@ -29,6 +29,7 @@ import (
"sync"
"github.com/loadimpact/k6/lib"
+ "github.com/loadimpact/k6/lib/metrics"
"github.com/loadimpact/k6/lib/netext"
"github.com/loadimpact/k6/stats"
)
@@ -36,9 +37,10 @@ import (
// transport is an implementation of http.RoundTripper that will measure and emit
// different metrics for each roundtrip
type transport struct {
- ctx context.Context
- state *lib.State
- tags map[string]string
+ ctx context.Context
+ state *lib.State
+ tags map[string]string
+ responseCallback func(int) bool
lastRequest *unfinishedRequest
lastRequestLock *sync.Mutex
@@ -76,17 +78,20 @@ func newTransport(
ctx context.Context,
state *lib.State,
tags map[string]string,
+ responseCallback func(int) bool,
) *transport {
return &transport{
- ctx: ctx,
- state: state,
- tags: tags,
- lastRequestLock: new(sync.Mutex),
+ ctx: ctx,
+ state: state,
+ tags: tags,
+ responseCallback: responseCallback,
+ lastRequestLock: new(sync.Mutex),
}
}
// Helper method to finish the tracer trail, assemble the tag values and emits
// the metric samples for the supplied unfinished request.
+//nolint:nestif,funlen
func (t *transport) measureAndEmitMetrics(unfReq *unfinishedRequest) *finishedRequest {
trail := unfReq.tracer.Done()
@@ -101,7 +106,6 @@ func (t *transport) measureAndEmitMetrics(unfReq *unfinishedRequest) *finishedRe
}
enabledTags := t.state.Options.SystemTags
-
urlEnabled := enabledTags.Has(stats.TagURL)
var setName bool
if _, ok := tags["name"]; !ok && enabledTags.Has(stats.TagName) {
@@ -164,8 +168,33 @@ func (t *transport) measureAndEmitMetrics(unfReq *unfinishedRequest) *finishedRe
tags["ip"] = ip
}
}
+ var failed float64
+ if t.responseCallback != nil {
+ var statusCode int
+ if unfReq.response != nil {
+ statusCode = unfReq.response.StatusCode
+ }
+ passed := t.responseCallback(statusCode)
+ if enabledTags.Has(stats.TagPassed) {
+ if passed {
+ failed = 0
+ tags[stats.TagPassed.String()] = "true"
+ } else {
+ tags[stats.TagPassed.String()] = "false"
+ failed = 1
+ }
+ }
+ }
- trail.SaveSamples(stats.IntoSampleTags(&tags))
+ finalTags := stats.IntoSampleTags(&tags)
+ trail.SaveSamples(finalTags)
+ if t.responseCallback != nil {
+ trail.Samples = append(trail.Samples,
+ stats.Sample{
+ Metric: metrics.HTTPReqFailed, Time: trail.EndTime, Tags: finalTags, Value: failed,
+ },
+ )
+ }
stats.PushIfNotDone(t.ctx, t.state.Samples, trail)
return result
diff --git a/lib/state.go b/lib/state.go
index 63c6f93a83f..eb428caee88 100644
--- a/lib/state.go
+++ b/lib/state.go
@@ -69,6 +69,8 @@ type State struct {
Vu, Iteration int64
Tags map[string]string
+
+ HTTPResponseCallback func(int) bool
}
// CloneTags makes a copy of the tags map and returns it.
diff --git a/stats/system_tag.go b/stats/system_tag.go
index c549e074509..258f3e86161 100644
--- a/stats/system_tag.go
+++ b/stats/system_tag.go
@@ -97,6 +97,7 @@ const (
TagTLSVersion
TagScenario
TagService
+ TagPassed
// System tags not enabled by default.
TagIter
@@ -109,7 +110,7 @@ const (
// Other tags that are not enabled by default include: iter, vu, ocsp_status, ip
//nolint:gochecknoglobals
var DefaultSystemTagSet = TagProto | TagSubproto | TagStatus | TagMethod | TagURL | TagName | TagGroup |
- TagCheck | TagCheck | TagError | TagErrorCode | TagTLSVersion | TagScenario | TagService
+ TagCheck | TagCheck | TagError | TagErrorCode | TagTLSVersion | TagScenario | TagService | TagPassed
// Add adds a tag to tag set.
func (i *SystemTagSet) Add(tag SystemTagSet) {
diff --git a/stats/system_tag_set_gen.go b/stats/system_tag_set_gen.go
index a8ccc3d223d..7efa4271127 100644
--- a/stats/system_tag_set_gen.go
+++ b/stats/system_tag_set_gen.go
@@ -7,26 +7,27 @@ import (
"fmt"
)
-const _SystemTagSetName = "protosubprotostatusmethodurlnamegroupcheckerrorerror_codetls_versionscenarioserviceitervuocsp_statusip"
+const _SystemTagSetName = "protosubprotostatusmethodurlnamegroupcheckerrorerror_codetls_versionscenarioservicepasseditervuocsp_statusip"
var _SystemTagSetMap = map[SystemTagSet]string{
- 1: _SystemTagSetName[0:5],
- 2: _SystemTagSetName[5:13],
- 4: _SystemTagSetName[13:19],
- 8: _SystemTagSetName[19:25],
- 16: _SystemTagSetName[25:28],
- 32: _SystemTagSetName[28:32],
- 64: _SystemTagSetName[32:37],
- 128: _SystemTagSetName[37:42],
- 256: _SystemTagSetName[42:47],
- 512: _SystemTagSetName[47:57],
- 1024: _SystemTagSetName[57:68],
- 2048: _SystemTagSetName[68:76],
- 4096: _SystemTagSetName[76:83],
- 8192: _SystemTagSetName[83:87],
- 16384: _SystemTagSetName[87:89],
- 32768: _SystemTagSetName[89:100],
- 65536: _SystemTagSetName[100:102],
+ 1: _SystemTagSetName[0:5],
+ 2: _SystemTagSetName[5:13],
+ 4: _SystemTagSetName[13:19],
+ 8: _SystemTagSetName[19:25],
+ 16: _SystemTagSetName[25:28],
+ 32: _SystemTagSetName[28:32],
+ 64: _SystemTagSetName[32:37],
+ 128: _SystemTagSetName[37:42],
+ 256: _SystemTagSetName[42:47],
+ 512: _SystemTagSetName[47:57],
+ 1024: _SystemTagSetName[57:68],
+ 2048: _SystemTagSetName[68:76],
+ 4096: _SystemTagSetName[76:83],
+ 8192: _SystemTagSetName[83:89],
+ 16384: _SystemTagSetName[89:93],
+ 32768: _SystemTagSetName[93:95],
+ 65536: _SystemTagSetName[95:106],
+ 131072: _SystemTagSetName[106:108],
}
func (i SystemTagSet) String() string {
@@ -36,7 +37,7 @@ func (i SystemTagSet) String() string {
return fmt.Sprintf("SystemTagSet(%d)", i)
}
-var _SystemTagSetValues = []SystemTagSet{1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536}
+var _SystemTagSetValues = []SystemTagSet{1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536, 131072}
var _SystemTagSetNameToValueMap = map[string]SystemTagSet{
_SystemTagSetName[0:5]: 1,
@@ -52,10 +53,11 @@ var _SystemTagSetNameToValueMap = map[string]SystemTagSet{
_SystemTagSetName[57:68]: 1024,
_SystemTagSetName[68:76]: 2048,
_SystemTagSetName[76:83]: 4096,
- _SystemTagSetName[83:87]: 8192,
- _SystemTagSetName[87:89]: 16384,
- _SystemTagSetName[89:100]: 32768,
- _SystemTagSetName[100:102]: 65536,
+ _SystemTagSetName[83:89]: 8192,
+ _SystemTagSetName[89:93]: 16384,
+ _SystemTagSetName[93:95]: 32768,
+ _SystemTagSetName[95:106]: 65536,
+ _SystemTagSetName[106:108]: 131072,
}
// SystemTagSetString retrieves an enum value from the enum constants string name.