From b7463294248583dd4a91d7ce49c7e8dd0b94622a Mon Sep 17 00:00:00 2001 From: Mihail Stoykov Date: Tue, 9 Feb 2021 18:46:18 +0200 Subject: [PATCH] Add a way to mark http requests as failed or not(passed) 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 --- core/local/local_test.go | 1 + js/modules/k6/http/request.go | 21 +- js/modules/k6/http/request_test.go | 24 ++ js/modules/k6/http/response_callback.go | 110 +++++++ js/modules/k6/http/response_callback_test.go | 307 +++++++++++++++++++ js/runner.go | 31 +- lib/metrics/metrics.go | 3 +- lib/netext/httpext/request.go | 31 +- lib/netext/httpext/transport.go | 47 ++- lib/state.go | 2 + stats/system_tag.go | 3 +- stats/system_tag_set_gen.go | 48 +-- 12 files changed, 560 insertions(+), 68 deletions(-) create mode 100644 js/modules/k6/http/response_callback.go create mode 100644 js/modules/k6/http/response_callback_test.go 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.