From 314d9d5c95ac9859add2611255d4f4303fb504ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Crevon?= Date: Fri, 10 Mar 2023 17:28:55 +0100 Subject: [PATCH] Add sampling capabilities to the tracing module (#2886) * Improve assertHasTraceIDMetadata robustness * Add Sampler interface and ProbabilisticSampler implementation This commit adds a Sampler interface defining a contract for implementing support for sampling. This means that any client of a sampler could go through the interface to decide whether or not to perform an action or keep some piece of data. This commit adds a ProbabilisticSampler implementation of Sampler, meant to be implemented by propagators to decide whether they should set their sampling flag to true or false. This commit also adds a chance function which returns true with a `percentage` of chance, to support the ProbabilisticSampler implem. * Make Propagators support sampling by implementing the Sampler interface This commit defines a SamplerPropagator interface, which established the contract for a propagator which bases its sampling flag on a Sampler implementation. It also makes the concrete implementations of Propagators implement the Sampler interface, and ensure the flags field of the generated trace context headers is set based on the Sampler's result. * Implement support for the sampling option * Add sampling support in the tracing Client * Add tracing module sampling example * Address PR feedback from @codebien --- cmd/tests/tracing_module_test.go | 143 +++++++++++++++++- js/modules/k6/experimental/tracing/client.go | 16 +- .../k6/experimental/tracing/encoding.go | 19 +++ js/modules/k6/experimental/tracing/module.go | 15 +- js/modules/k6/experimental/tracing/options.go | 39 ++++- .../k6/experimental/tracing/options_test.go | 115 +++++++++++++- .../k6/experimental/tracing/propagator.go | 65 +++++++- .../k6/experimental/tracing/sampling.go | 70 +++++++++ .../experimental/tracing/tracing-sampling.js | 31 ++++ 9 files changed, 482 insertions(+), 31 deletions(-) create mode 100644 js/modules/k6/experimental/tracing/sampling.go create mode 100644 samples/experimental/tracing/tracing-sampling.js diff --git a/cmd/tests/tracing_module_test.go b/cmd/tests/tracing_module_test.go index 00c36e18710..524f530329b 100644 --- a/cmd/tests/tracing_module_test.go +++ b/cmd/tests/tracing_module_test.go @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/require" "go.k6.io/k6/cmd" "go.k6.io/k6/lib/testutils/httpmultibin" + "go.k6.io/k6/metrics" ) func TestTracingModuleClient(t *testing.T) { @@ -58,7 +59,7 @@ func TestTracingModuleClient(t *testing.T) { jsonResults, err := afero.ReadFile(ts.FS, "results.json") require.NoError(t, err) - assertHasTraceIDMetadata(t, jsonResults) + assertHasTraceIDMetadata(t, jsonResults, 9, tb.Replacer.Replace("HTTPBIN_IP_URL/tracing")) } func TestTracingClient_DoesNotInterfereWithHTTPModule(t *testing.T) { @@ -100,6 +101,107 @@ func TestTracingClient_DoesNotInterfereWithHTTPModule(t *testing.T) { assert.Equal(t, int64(2), atomic.LoadInt64(&gotInstrumentedRequests)) } +func TestTracingModuleClient_HundredPercentSampling(t *testing.T) { + t.Parallel() + tb := httpmultibin.NewHTTPMultiBin(t) + + var gotRequests int64 + var gotSampleFlags int64 + + tb.Mux.HandleFunc("/tracing", func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt64(&gotRequests, 1) + + traceparent := r.Header.Get("traceparent") + require.NotEmpty(t, traceparent) + require.Len(t, traceparent, 55) + + if traceparent[54] == '1' { + atomic.AddInt64(&gotSampleFlags, 1) + } + }) + + script := tb.Replacer.Replace(` + import http from "k6/http"; + import { check } from "k6"; + import tracing from "k6/experimental/tracing"; + + export const options = { + // 100 iterations to make sure we get 100% sampling + iterations: 100, + } + + const instrumentedHTTP = new tracing.Client({ + propagator: "w3c", + + // 100% sampling + sampling: 1.0, + }) + + export default function () { + instrumentedHTTP.get("HTTPBIN_IP_URL/tracing"); + }; + `) + + ts := getSingleFileTestState(t, script, []string{"--out", "json=results.json"}, 0) + cmd.ExecuteWithGlobalState(ts.GlobalState) + + assert.Equal(t, int64(100), atomic.LoadInt64(&gotSampleFlags)) + assert.Equal(t, int64(100), atomic.LoadInt64(&gotRequests)) + + jsonResults, err := afero.ReadFile(ts.FS, "results.json") + require.NoError(t, err) + + assertHasTraceIDMetadata(t, jsonResults, 100, tb.Replacer.Replace("HTTPBIN_IP_URL/tracing")) +} + +func TestTracingModuleClient_ZeroPercentSampling(t *testing.T) { + t.Parallel() + tb := httpmultibin.NewHTTPMultiBin(t) + + var gotRequests int64 + var gotSampleFlags int64 + + tb.Mux.HandleFunc("/tracing", func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt64(&gotRequests, 1) + + traceparent := r.Header.Get("traceparent") + require.NotEmpty(t, traceparent) + require.Len(t, traceparent, 55) + + if traceparent[54] == '1' { + atomic.AddInt64(&gotSampleFlags, 1) + } + }) + + script := tb.Replacer.Replace(` + import http from "k6/http"; + import { check } from "k6"; + import tracing from "k6/experimental/tracing"; + + export const options = { + // 100 iterations to make sure we get 100% sampling + iterations: 100, + } + + const instrumentedHTTP = new tracing.Client({ + propagator: "w3c", + + // 0% sampling + sampling: 0.0, + }) + + export default function () { + instrumentedHTTP.get("HTTPBIN_IP_URL/tracing"); + }; + `) + + ts := getSingleFileTestState(t, script, []string{}, 0) + cmd.ExecuteWithGlobalState(ts.GlobalState) + + assert.Equal(t, int64(0), atomic.LoadInt64(&gotSampleFlags)) + assert.Equal(t, int64(100), atomic.LoadInt64(&gotRequests)) +} + func TestTracingInstrumentHTTP_W3C(t *testing.T) { t.Parallel() tb := httpmultibin.NewHTTPMultiBin(t) @@ -141,7 +243,7 @@ func TestTracingInstrumentHTTP_W3C(t *testing.T) { jsonResults, err := afero.ReadFile(ts.FS, "results.json") require.NoError(t, err) - assertHasTraceIDMetadata(t, jsonResults) + assertHasTraceIDMetadata(t, jsonResults, 9, tb.Replacer.Replace("HTTPBIN_IP_URL/tracing")) } func TestTracingInstrumentHTTP_Jaeger(t *testing.T) { @@ -185,7 +287,7 @@ func TestTracingInstrumentHTTP_Jaeger(t *testing.T) { jsonResults, err := afero.ReadFile(ts.FS, "results.json") require.NoError(t, err) - assertHasTraceIDMetadata(t, jsonResults) + assertHasTraceIDMetadata(t, jsonResults, 8, tb.Replacer.Replace("HTTPBIN_IP_URL/tracing")) } func TestTracingInstrumentHTTP_FillsParams(t *testing.T) { @@ -236,7 +338,7 @@ func TestTracingInstrumentHTTP_FillsParams(t *testing.T) { jsonResults, err := afero.ReadFile(ts.FS, "results.json") require.NoError(t, err) - assertHasTraceIDMetadata(t, jsonResults) + assertHasTraceIDMetadata(t, jsonResults, 8, tb.Replacer.Replace("HTTPBIN_IP_URL/tracing")) } func TestTracingInstrummentHTTP_SupportsMultipleTestScripts(t *testing.T) { @@ -288,14 +390,23 @@ func TestTracingInstrummentHTTP_SupportsMultipleTestScripts(t *testing.T) { require.NoError(t, err) assert.Equal(t, int64(1), atomic.LoadInt64(&gotRequests)) - assertHasTraceIDMetadata(t, jsonResults) + assertHasTraceIDMetadata(t, jsonResults, 1, tb.Replacer.Replace("HTTPBIN_IP_URL/tracing")) } // assertHasTraceIDMetadata checks that the trace_id metadata is present and has the correct format // for all http metrics in the json results file. -func assertHasTraceIDMetadata(t *testing.T, jsonResults []byte) { +// +// The `expectOccurences` parameter is used to check that the trace_id metadata is present for the +// expected number of http metrics. For instance, in a script with 2 http requests, the +// `expectOccurences` parameter should be 2. +// +// The onUrls parameter is used to check that the trace_id metadata is present for the data points +// with the expected URLs. Its value should reflect the URLs used in the script. +func assertHasTraceIDMetadata(t *testing.T, jsonResults []byte, expectOccurences int, onUrls ...string) { gotHTTPDataPoints := false + urlHTTPTraceIDs := make(map[string]map[string]int) + for _, jsonLine := range bytes.Split(jsonResults, []byte("\n")) { if len(jsonLine) == 0 { continue @@ -322,9 +433,28 @@ func assertHasTraceIDMetadata(t *testing.T, jsonResults []byte) { require.True(t, gotTraceID) assert.Len(t, traceID, 32) + + if _, ok := urlHTTPTraceIDs[line.Data.Tags["url"]]; !ok { + urlHTTPTraceIDs[line.Data.Tags["url"]] = make(map[string]int) + urlHTTPTraceIDs[line.Data.Tags["url"]][metrics.HTTPReqsName] = 0 + } + urlHTTPTraceIDs[line.Data.Tags["url"]][line.Metric]++ } assert.True(t, gotHTTPDataPoints) + + for _, url := range onUrls { + assert.Contains(t, urlHTTPTraceIDs, url) + assert.Equal(t, urlHTTPTraceIDs[url][metrics.HTTPReqsName], expectOccurences) + assert.Equal(t, urlHTTPTraceIDs[url][metrics.HTTPReqFailedName], expectOccurences) + assert.Equal(t, urlHTTPTraceIDs[url][metrics.HTTPReqDurationName], expectOccurences) + assert.Equal(t, urlHTTPTraceIDs[url][metrics.HTTPReqBlockedName], expectOccurences) + assert.Equal(t, urlHTTPTraceIDs[url][metrics.HTTPReqConnectingName], expectOccurences) + assert.Equal(t, urlHTTPTraceIDs[url][metrics.HTTPReqTLSHandshakingName], expectOccurences) + assert.Equal(t, urlHTTPTraceIDs[url][metrics.HTTPReqSendingName], expectOccurences) + assert.Equal(t, urlHTTPTraceIDs[url][metrics.HTTPReqWaitingName], expectOccurences) + assert.Equal(t, urlHTTPTraceIDs[url][metrics.HTTPReqReceivingName], expectOccurences) + } } // sampleEnvelope is a trimmed version of the struct found @@ -335,6 +465,7 @@ type sampleEnvelope struct { Type string `json:"type"` Data struct { Value float64 `json:"value"` + Tags map[string]string `json:"tags"` Metadata map[string]interface{} `json:"metadata"` } `json:"data"` } diff --git a/js/modules/k6/experimental/tracing/client.go b/js/modules/k6/experimental/tracing/client.go index 42f53bd204d..3a97e4524ca 100644 --- a/js/modules/k6/experimental/tracing/client.go +++ b/js/modules/k6/experimental/tracing/client.go @@ -37,10 +37,11 @@ type Client struct { asyncRequestFunc HTTPAsyncRequestFunc } -// HTTPRequestFunc is a type alias representing the prototype of -// k6's http module's request function type ( - HTTPRequestFunc func(method string, url goja.Value, args ...goja.Value) (*httpmodule.Response, error) + // HTTPRequestFunc is a type alias representing the prototype of k6's http module's request function + HTTPRequestFunc func(method string, url goja.Value, args ...goja.Value) (*httpmodule.Response, error) + + // HTTPAsyncRequestFunc is a type alias representing the prototype of k6's http module's asyncRequest function HTTPAsyncRequestFunc func(method string, url goja.Value, args ...goja.Value) (*goja.Promise, error) ) @@ -85,11 +86,16 @@ func (c *Client) Configure(opts options) error { return fmt.Errorf("invalid options: %w", err) } + var sampler Sampler = NewAlwaysOnSampler() + if opts.Sampling != 1.0 { + sampler = NewProbabilisticSampler(opts.Sampling) + } + switch opts.Propagator { case "w3c": - c.propagator = &W3CPropagator{} + c.propagator = NewW3CPropagator(sampler) case "jaeger": - c.propagator = &JaegerPropagator{} + c.propagator = NewJaegerPropagator(sampler) default: return fmt.Errorf("unknown propagator: %s", opts.Propagator) } diff --git a/js/modules/k6/experimental/tracing/encoding.go b/js/modules/k6/experimental/tracing/encoding.go index c1e8faa7a87..e9e68a7add4 100644 --- a/js/modules/k6/experimental/tracing/encoding.go +++ b/js/modules/k6/experimental/tracing/encoding.go @@ -17,3 +17,22 @@ func randHexString(n int) string { return string(b) } + +// chance returns true with a `percentage` chance, otherwise false. +// the `percentage` argument is expected to be +// within 0 <= percentage <= 100 range. +// +// The chance function works under the assumption that the +// go rand module has been seeded with a non-deterministic +// value. +func chance(r *rand.Rand, percentage float64) bool { + if percentage == 0.0 { + return false + } + + if percentage == 1.0 { + return true + } + + return r.Float64() < percentage +} diff --git a/js/modules/k6/experimental/tracing/module.go b/js/modules/k6/experimental/tracing/module.go index 5a5ea90b2d6..d7e2e474d51 100644 --- a/js/modules/k6/experimental/tracing/module.go +++ b/js/modules/k6/experimental/tracing/module.go @@ -4,6 +4,8 @@ package tracing import ( "errors" "fmt" + "math/rand" + "time" "github.com/dop251/goja" "go.k6.io/k6/js/common" @@ -19,6 +21,9 @@ type ( ModuleInstance struct { vu modules.VU + // random is a random number generator used by the module. + random *rand.Rand + // Client holds the module's default tracing client. *Client } @@ -40,6 +45,12 @@ func New() *RootModule { func (*RootModule) NewModuleInstance(vu modules.VU) modules.Instance { return &ModuleInstance{ vu: vu, + + // Seed the random number generator with the current time. + // This ensures that any call to rand.Intn() will return + // less-deterministic results. + //nolint:gosec // we don't need cryptographic randomness here + random: rand.New(rand.NewSource(time.Now().UTC().UnixNano())), } } @@ -66,8 +77,8 @@ func (mi *ModuleInstance) newClient(cc goja.ConstructorCall) *goja.Object { common.Throw(rt, errors.New("Client constructor expects a single configuration object as argument; none given")) } - var opts options - if err := rt.ExportTo(cc.Arguments[0], &opts); err != nil { + opts, err := newOptions(rt, cc.Arguments[0]) + if err != nil { common.Throw(rt, fmt.Errorf("unable to parse options object; reason: %w", err)) } diff --git a/js/modules/k6/experimental/tracing/options.go b/js/modules/k6/experimental/tracing/options.go index c8a55a2edf5..1d97ac4d2f1 100644 --- a/js/modules/k6/experimental/tracing/options.go +++ b/js/modules/k6/experimental/tracing/options.go @@ -3,19 +3,45 @@ package tracing import ( "errors" "fmt" + + "github.com/dop251/goja" ) // options are the options that can be passed to the // tracing.instrumentHTTP() method. type options struct { // Propagation is the propagation format to use for the tracer. - Propagator string `js:"propagator"` + Propagator string `json:"propagator"` - // Sampling is the sampling rate to use for the tracer. - Sampling *float64 `js:"sampling"` + // Sampling is the sampling rate to use for the + // tracer, expressed in percents within the + // bounds: 0.0 <= n <= 1.0. + Sampling float64 `json:"sampling"` // Baggage is a map of baggage items to add to the tracer. - Baggage map[string]string `js:"baggage"` + Baggage map[string]string `json:"baggage"` +} + +// defaultSamplingRate is the default sampling rate applied to options. +const defaultSamplingRate float64 = 1.0 + +// newOptions returns a new options object from the given goja.Value. +// +// Note that if the sampling field value is absent, or nullish, we'll +// set it to the `defaultSamplingRate` value. +func newOptions(rt *goja.Runtime, from goja.Value) (options, error) { + var opts options + + if err := rt.ExportTo(from, &opts); err != nil { + return opts, fmt.Errorf("unable to parse options object; reason: %w", err) + } + + fromSamplingValue := from.ToObject(rt).Get("sampling") + if fromSamplingValue == nil || isNullish(fromSamplingValue) { + opts.Sampling = defaultSamplingRate + } + + return opts, nil } func (i *options) validate() error { @@ -27,9 +53,8 @@ func (i *options) validate() error { return fmt.Errorf("unknown propagator: %s", i.Propagator) } - // TODO: implement sampling support - if i.Sampling != nil { - return errors.New("sampling is not yet supported") + if i.Sampling < 0.0 || i.Sampling > 1.0 { + return errors.New("sampling rate must be between 0.0 and 1.0") } // TODO: implement baggage support diff --git a/js/modules/k6/experimental/tracing/options_test.go b/js/modules/k6/experimental/tracing/options_test.go index b5b03eea637..c608aa33dc7 100644 --- a/js/modules/k6/experimental/tracing/options_test.go +++ b/js/modules/k6/experimental/tracing/options_test.go @@ -1,15 +1,84 @@ package tracing -import "testing" +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewOptionsWithoutSamplingProperty(t *testing.T) { + t.Parallel() + + ts := newTestSetup(t) + + _, err := ts.TestRuntime.VU.Runtime().RunString(` + const options = { + propagator: 'w3c', + } + `) + require.NoError(t, err) + optionsValue := ts.TestRuntime.VU.Runtime().Get("options") + gotOptions, gotOptionsErr := newOptions(ts.TestRuntime.VU.Runtime(), optionsValue) + + assert.NoError(t, gotOptionsErr) + assert.Equal(t, 1.0, gotOptions.Sampling) +} + +func TestNewOptionsWithSamplingPropertySetToNullish(t *testing.T) { + t.Parallel() + + ts := newTestSetup(t) + + _, err := ts.TestRuntime.VU.Runtime().RunString(` + const options = { + propagator: 'w3c', + sampling: null, + } + `) + require.NoError(t, err) + optionsValue := ts.TestRuntime.VU.Runtime().Get("options") + gotOptions, gotOptionsErr := newOptions(ts.TestRuntime.VU.Runtime(), optionsValue) + + assert.NoError(t, gotOptionsErr) + assert.Equal(t, 1.0, gotOptions.Sampling) +} + +func TestNewOptionsWithSamplingPropertySet(t *testing.T) { + t.Parallel() + + ts := newTestSetup(t) + + _, err := ts.TestRuntime.VU.Runtime().RunString(` + const options = { + propagator: 'w3c', + sampling: 0.5, + } + `) + require.NoError(t, err) + optionsValue := ts.TestRuntime.VU.Runtime().Get("options") + gotOptions, gotOptionsErr := newOptions(ts.TestRuntime.VU.Runtime(), optionsValue) + + assert.NoError(t, gotOptionsErr) + assert.Equal(t, 0.5, gotOptions.Sampling) +} func TestOptionsValidate(t *testing.T) { t.Parallel() - testFloat := 10.0 + // Note that we prefer variables over constants here + // as we need to be able to address them. + var ( + validSampling = 0.1 + lowerBoundSampling = 0.0 + upperBoundSampling = 1.0 + lowerOutOfBoundsSampling = -1.0 + upperOutOfBoundsSampling = 1.01 + ) type fields struct { Propagator string - Sampling *float64 + Sampling float64 Baggage map[string]string } testCases := []struct { @@ -39,16 +108,50 @@ func TestOptionsValidate(t *testing.T) { wantErr: true, }, { - name: "sampling is not yet supported", + name: "sampling is not rate is valid", fields: fields{ - Sampling: &testFloat, + Propagator: "w3c", + Sampling: validSampling, + }, + wantErr: false, + }, + { + name: "sampling rate = 0 is valid", + fields: fields{ + Propagator: "w3c", + Sampling: lowerBoundSampling, + }, + wantErr: false, + }, + { + name: "sampling rate = 1.0 is valid", + fields: fields{ + Propagator: "w3c", + Sampling: upperBoundSampling, + }, + wantErr: false, + }, + { + name: "sampling rate < 0 is invalid", + fields: fields{ + Propagator: "w3c", + Sampling: lowerOutOfBoundsSampling, + }, + wantErr: true, + }, + { + name: "sampling rater > 1.0 is invalid", + fields: fields{ + Propagator: "w3c", + Sampling: upperOutOfBoundsSampling, }, wantErr: true, }, { name: "baggage is not yet supported", fields: fields{ - Baggage: map[string]string{"key": "value"}, + Propagator: "w3c", + Baggage: map[string]string{"key": "value"}, }, wantErr: true, }, diff --git a/js/modules/k6/experimental/tracing/propagator.go b/js/modules/k6/experimental/tracing/propagator.go index 45aa054b5db..5d59f7f189b 100644 --- a/js/modules/k6/experimental/tracing/propagator.go +++ b/js/modules/k6/experimental/tracing/propagator.go @@ -28,15 +28,31 @@ const ( ) // W3CPropagator is a Propagator for the W3C trace context header -type W3CPropagator struct{} +type W3CPropagator struct { + // Sampler is used to determine whether or not a trace should be sampled. + Sampler +} + +// NewW3CPropagator returns a new W3CPropagator using the provided sampler +// to base its sampling decision upon. +// +// Note that we allocate the propagator on the heap to ensure we conform +// to the Sampler interface, as the [Sampler.SetSamplingRate] +// method has a pointer receiver. +func NewW3CPropagator(s Sampler) *W3CPropagator { + return &W3CPropagator{ + Sampler: s, + } +} // Propagate returns a header with a random trace ID in the W3C format func (p *W3CPropagator) Propagate(traceID string) (http.Header, error) { parentID := randHexString(16) + flags := pick(p.ShouldSample(), W3CSampledTraceFlag, W3CUnsampledTraceFlag) return http.Header{ W3CHeaderName: { - W3CVersion + "-" + traceID + "-" + parentID + "-" + W3CSampledTraceFlag, + W3CVersion + "-" + traceID + "-" + parentID + "-" + flags, }, }, nil } @@ -52,18 +68,57 @@ const ( // Its value is zero, which is described in the Jaeger documentation as: // "0 value is valid and means “root span” (when not ignored)" JaegerRootSpanID = "0" + + // JaegerSampledTraceFlag is the trace-flag value for an unsampled trace. + JaegerSampledTraceFlag = "0" + + // JaegerUnsampledTraceFlag is the trace-flag value for a sampled trace. + JaegerUnsampledTraceFlag = "1" ) // JaegerPropagator is a Propagator for the Jaeger trace context header -type JaegerPropagator struct{} +type JaegerPropagator struct { + // Sampler is used to determine whether or not a trace should be sampled. + Sampler +} + +// NewJaegerPropagator returns a new JaegerPropagator with the given sampler. +func NewJaegerPropagator(s Sampler) *JaegerPropagator { + return &JaegerPropagator{ + Sampler: s, + } +} // Propagate returns a header with a random trace ID in the Jaeger format func (p *JaegerPropagator) Propagate(traceID string) (http.Header, error) { spanID := randHexString(8) - // flags set to 1 means the span is sampled - flags := "1" + flags := pick(p.ShouldSample(), JaegerSampledTraceFlag, JaegerUnsampledTraceFlag) return http.Header{ JaegerHeaderName: {traceID + ":" + spanID + ":" + JaegerRootSpanID + ":" + flags}, }, nil } + +// Pick returns either the left or right value, depending on the value of the `decision` +// boolean value. +func pick[T any](decision bool, lhs, rhs T) T { + if decision { + return lhs + } + + return rhs +} + +var ( + // Ensures that W3CPropagator implements the Propagator interface + _ Propagator = &W3CPropagator{} + + // Ensures that W3CPropagator implements the Sampler interface + _ Sampler = &W3CPropagator{} + + // Ensures the JaegerPropagator implements the Propagator interface + _ Propagator = &JaegerPropagator{} + + // Ensures the JaegerPropagator implements the Sampler interface + _ Sampler = &JaegerPropagator{} +) diff --git a/js/modules/k6/experimental/tracing/sampling.go b/js/modules/k6/experimental/tracing/sampling.go new file mode 100644 index 00000000000..e3dbe641410 --- /dev/null +++ b/js/modules/k6/experimental/tracing/sampling.go @@ -0,0 +1,70 @@ +package tracing + +import ( + "math/rand" +) + +// Sampler is an interface defining a sampling strategy. +type Sampler interface { + // ShouldSample returns true if the trace should be sampled + // false otherwise. + ShouldSample() bool +} + +// ProbabilisticSampler implements the ProbabilisticSampler interface and allows +// to take probabilistic sampling decisions based on a sampling rate. +type ProbabilisticSampler struct { + // random is a random number generator used by the sampler. + random *rand.Rand + + // samplingRate is a chance value defined as a percentage + // value within 0.0 <= samplingRate <= 1.0 bounds. + samplingRate float64 +} + +// NewProbabilisticSampler returns a new ProbablisticSampler with the provided sampling rate. +// +// Note that the sampling rate is a percentage value within 0.0 <= samplingRate <= 1.0 bounds. +// If the provided sampling rate is outside of this range, it will be clamped to the closest +// bound. +func NewProbabilisticSampler(samplingRate float64) *ProbabilisticSampler { + // Ensure that the sampling rate is within the 0.0 <= samplingRate <= 1.0 bounds. + if samplingRate < 0.0 { + samplingRate = 0.0 + } else if samplingRate > 1.0 { + samplingRate = 1.0 + } + + return &ProbabilisticSampler{samplingRate: samplingRate} +} + +// ShouldSample returns true if the trace should be sampled. +// +// Its return value is probabilistic, based on the selected +// sampling rate S, there is S percent chance that the +// returned value is true. +func (ps ProbabilisticSampler) ShouldSample() bool { + return chance(ps.random, ps.samplingRate) +} + +// Ensure that ProbabilisticSampler implements the Sampler interface. +var _ Sampler = &ProbabilisticSampler{} + +// AlwaysOnSampler implements the Sampler interface and allows to bypass +// sampling decisions by returning true for all Sampled() calls. +// +// This is useful in cases where the user either does not provide +// the sampling option, or set it to 100% as it will avoid any +// call to the random number generator. +type AlwaysOnSampler struct{} + +// NewAlwaysOnSampler returns a new AlwaysSampledSampler. +func NewAlwaysOnSampler() *AlwaysOnSampler { + return &AlwaysOnSampler{} +} + +// ShouldSample always returns true. +func (AlwaysOnSampler) ShouldSample() bool { return true } + +// Ensure that AlwaysOnSampler implements the Sampler interface. +var _ Sampler = &AlwaysOnSampler{} diff --git a/samples/experimental/tracing/tracing-sampling.js b/samples/experimental/tracing/tracing-sampling.js new file mode 100644 index 00000000000..99781602806 --- /dev/null +++ b/samples/experimental/tracing/tracing-sampling.js @@ -0,0 +1,31 @@ +import http from "k6/http"; +import { check } from "k6"; +import tracing from "k6/experimental/tracing"; + +export const options = { + // As the number of sampled requests will converge towards + // the sampling percentage, we need to increase the number + // of iterations to get a more accurate result. + iterations: 10000, + + vus: 100, +}; + +tracing.instrumentHTTP({ + propagator: "w3c", + + // Only 10% of the requests made will have their trace context + // header's sample flag set to activated. + sampling: 0.1, +}); + +export default () => { + let res = http.get("http://httpbin.org/get", { + headers: { + "X-Example-Header": "instrumented/get", + }, + }); + check(res, { + "status is 200": (r) => r.status === 200, + }); +};