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.