diff --git a/api/v1/group_routes_test.go b/api/v1/group_routes_test.go index 88a082d2525..e4fa47039d0 100644 --- a/api/v1/group_routes_test.go +++ b/api/v1/group_routes_test.go @@ -17,6 +17,7 @@ import ( "go.k6.io/k6/lib/testutils/minirunner" "go.k6.io/k6/metrics" "go.k6.io/k6/metrics/engine" + "go.k6.io/k6/usage" ) func getTestPreInitState(tb testing.TB) *lib.TestPreInitState { @@ -27,6 +28,7 @@ func getTestPreInitState(tb testing.TB) *lib.TestPreInitState { RuntimeOptions: lib.RuntimeOptions{}, Registry: reg, BuiltinMetrics: metrics.RegisterBuiltinMetrics(reg), + Usage: usage.New(), } } diff --git a/cmd/outputs.go b/cmd/outputs.go index 4875adff589..84e19547eaf 100644 --- a/cmd/outputs.go +++ b/cmd/outputs.go @@ -118,6 +118,7 @@ func createOutputs( ScriptOptions: test.derivedConfig.Options, RuntimeOptions: test.preInitState.RuntimeOptions, ExecutionPlan: executionPlan, + Usage: test.preInitState.Usage, } outputs := test.derivedConfig.Out @@ -136,6 +137,12 @@ func createOutputs( outputType, getPossibleIDList(outputConstructors), ) } + if _, builtinErr := builtinOutputString(outputType); builtinErr == nil { + err := test.preInitState.Usage.Strings("outputs", outputType) + if err != nil { + gs.Logger.WithError(err).Warnf("Couldn't report usage for output %q", outputType) + } + } params := baseParams params.OutputType = outputType diff --git a/cmd/report.go b/cmd/report.go index 6dafc807617..8f8e3a45429 100644 --- a/cmd/report.go +++ b/cmd/report.go @@ -6,92 +6,34 @@ import ( "encoding/json" "net/http" "runtime" - "strings" "go.k6.io/k6/execution" "go.k6.io/k6/lib/consts" + "go.k6.io/k6/usage" ) -type report struct { - Version string `json:"k6_version"` - Executors map[string]int `json:"executors"` - VUsMax int64 `json:"vus_max"` - Iterations uint64 `json:"iterations"` - Duration string `json:"duration"` - GoOS string `json:"goos"` - GoArch string `json:"goarch"` - Modules []string `json:"modules"` - Outputs []string `json:"outputs"` -} - -func createReport(execScheduler *execution.Scheduler, importedModules []string, outputs []string) report { +func createReport(u *usage.Usage, execScheduler *execution.Scheduler) map[string]any { + execState := execScheduler.GetState() + m := u.Map() + + m["k6_version"] = consts.Version + m["duration"] = execState.GetCurrentTestRunDuration().String() + m["goos"] = runtime.GOOS + m["goarch"] = runtime.GOARCH + m["vus_max"] = uint64(execState.GetInitializedVUsCount()) + m["iterations"] = execState.GetFullIterationCount() executors := make(map[string]int) for _, ec := range execScheduler.GetExecutorConfigs() { executors[ec.GetType()]++ } + m["executors"] = executors - // collect the report only with k6 public modules - publicModules := make([]string, 0, len(importedModules)) - for _, module := range importedModules { - // Exclude JS modules extensions to prevent to leak - // any user's custom extensions - if strings.HasPrefix(module, "k6/x") { - continue - } - // Exclude any import not starting with the k6 prefix - // that identifies a k6 built-in stable or experimental module. - // For example, it doesn't include any modules imported from the file system. - if !strings.HasPrefix(module, "k6") { - continue - } - publicModules = append(publicModules, module) - } - - builtinOutputs := builtinOutputStrings() - - // TODO: migrate to slices.Contains as soon as the k6 support - // for Go1.20 will be over. - builtinOutputsIndex := make(map[string]bool, len(builtinOutputs)) - for _, bo := range builtinOutputs { - builtinOutputsIndex[bo] = true - } - - // collect only the used outputs that are builtin - publicOutputs := make([]string, 0, len(builtinOutputs)) - for _, o := range outputs { - // TODO: - // if !slices.Contains(builtinOutputs, o) { - // continue - // } - if !builtinOutputsIndex[o] { - continue - } - publicOutputs = append(publicOutputs, o) - } - - execState := execScheduler.GetState() - return report{ - Version: consts.Version, - Executors: executors, - VUsMax: execState.GetInitializedVUsCount(), - Iterations: execState.GetFullIterationCount(), - Duration: execState.GetCurrentTestRunDuration().String(), - GoOS: runtime.GOOS, - GoArch: runtime.GOARCH, - Modules: publicModules, - Outputs: publicOutputs, - } + return m } func reportUsage(ctx context.Context, execScheduler *execution.Scheduler, test *loadedAndConfiguredTest) error { - outputs := make([]string, 0, len(test.derivedConfig.Out)) - for _, o := range test.derivedConfig.Out { - outputName, _ := parseOutputArgument(o) - outputs = append(outputs, outputName) - } - - r := createReport(execScheduler, test.moduleResolver.Imported(), outputs) - body, err := json.Marshal(r) + m := createReport(test.preInitState.Usage, execScheduler) + body, err := json.Marshal(m) if err != nil { return err } diff --git a/cmd/report_test.go b/cmd/report_test.go index c28fe816739..3980ef58567 100644 --- a/cmd/report_test.go +++ b/cmd/report_test.go @@ -12,25 +12,12 @@ import ( "go.k6.io/k6/lib/consts" "go.k6.io/k6/lib/executor" "go.k6.io/k6/lib/testutils" + "go.k6.io/k6/usage" "gopkg.in/guregu/null.v3" ) func TestCreateReport(t *testing.T) { t.Parallel() - importedModules := []string{ - "k6/http", - "my-custom-module", - "k6/experimental/webcrypto", - "file:custom-from-file-system", - "k6", - "k6/x/custom-extension", - } - - outputs := []string{ - "json", - "xk6-output-custom-example", - } - logger := testutils.NewLogger(t) opts, err := executor.DeriveScenariosFromShortcuts(lib.Options{ VUs: null.IntFrom(10), @@ -51,12 +38,12 @@ func TestCreateReport(t *testing.T) { time.Sleep(10 * time.Millisecond) s.GetState().MarkEnded() - r := createReport(s, importedModules, outputs) - assert.Equal(t, consts.Version, r.Version) - assert.Equal(t, map[string]int{"shared-iterations": 1}, r.Executors) - assert.Equal(t, 6, int(r.VUsMax)) - assert.Equal(t, 170, int(r.Iterations)) - assert.NotEqual(t, "0s", r.Duration) - assert.ElementsMatch(t, []string{"k6", "k6/http", "k6/experimental/webcrypto"}, r.Modules) - assert.ElementsMatch(t, []string{"json"}, r.Outputs) + m := createReport(usage.New(), s) + require.NoError(t, err) + + assert.Equal(t, consts.Version, m["k6_version"]) + assert.EqualValues(t, map[string]int{"shared-iterations": 1}, m["executors"]) + assert.EqualValues(t, 6, m["vus_max"]) + assert.EqualValues(t, 170, m["iterations"]) + assert.NotEqual(t, "0s", m["duration"]) } diff --git a/cmd/runtime_options_test.go b/cmd/runtime_options_test.go index 7ca20d5d940..de484839de7 100644 --- a/cmd/runtime_options_test.go +++ b/cmd/runtime_options_test.go @@ -15,6 +15,7 @@ import ( "go.k6.io/k6/lib/fsext" "go.k6.io/k6/loader" "go.k6.io/k6/metrics" + "go.k6.io/k6/usage" ) type runtimeOptionsTestCase struct { @@ -70,6 +71,7 @@ func testRuntimeOptionsCase(t *testing.T, tc runtimeOptionsTestCase) { RuntimeOptions: rtOpts, Registry: registry, BuiltinMetrics: metrics.RegisterBuiltinMetrics(registry), + Usage: usage.New(), }, } @@ -89,6 +91,7 @@ func testRuntimeOptionsCase(t *testing.T, tc runtimeOptionsTestCase) { RuntimeOptions: rtOpts, Registry: registry, BuiltinMetrics: metrics.RegisterBuiltinMetrics(registry), + Usage: usage.New(), }, } } diff --git a/cmd/test_load.go b/cmd/test_load.go index 89f5bed04f0..f022e415576 100644 --- a/cmd/test_load.go +++ b/cmd/test_load.go @@ -22,6 +22,7 @@ import ( "go.k6.io/k6/lib/fsext" "go.k6.io/k6/loader" "go.k6.io/k6/metrics" + "go.k6.io/k6/usage" ) const ( @@ -77,6 +78,7 @@ func loadLocalTest(gs *state.GlobalState, cmd *cobra.Command, args []string) (*l val, ok := gs.Env[key] return val, ok }, + Usage: usage.New(), } test := &loadedTest{ diff --git a/execution/scheduler_ext_exec_test.go b/execution/scheduler_ext_exec_test.go index 94e635418d0..aa111bf851c 100644 --- a/execution/scheduler_ext_exec_test.go +++ b/execution/scheduler_ext_exec_test.go @@ -16,6 +16,7 @@ import ( "go.k6.io/k6/lib/testutils" "go.k6.io/k6/loader" "go.k6.io/k6/metrics" + "go.k6.io/k6/usage" ) // TODO: rewrite and/or move these as integration tests to reduce boilerplate @@ -74,6 +75,7 @@ func TestExecutionInfoVUSharing(t *testing.T) { Logger: logger, BuiltinMetrics: builtinMetrics, Registry: registry, + Usage: usage.New(), }, &loader.SourceData{ URL: &url.URL{Path: "/script.js"}, @@ -187,6 +189,7 @@ func TestExecutionInfoScenarioIter(t *testing.T) { Logger: logger, BuiltinMetrics: builtinMetrics, Registry: registry, + Usage: usage.New(), }, &loader.SourceData{ URL: &url.URL{Path: "/script.js"}, @@ -269,6 +272,7 @@ func TestSharedIterationsStable(t *testing.T) { Logger: logger, BuiltinMetrics: builtinMetrics, Registry: registry, + Usage: usage.New(), }, &loader.SourceData{ URL: &url.URL{Path: "/script.js"}, @@ -404,6 +408,7 @@ func TestExecutionInfoAll(t *testing.T) { Logger: logger, BuiltinMetrics: builtinMetrics, Registry: registry, + Usage: usage.New(), }, &loader.SourceData{ URL: &url.URL{Path: "/script.js"}, diff --git a/execution/scheduler_ext_test.go b/execution/scheduler_ext_test.go index 3fbffdf0ed9..b512245ff83 100644 --- a/execution/scheduler_ext_test.go +++ b/execution/scheduler_ext_test.go @@ -32,6 +32,7 @@ import ( "go.k6.io/k6/lib/types" "go.k6.io/k6/loader" "go.k6.io/k6/metrics" + "go.k6.io/k6/usage" ) func getTestPreInitState(tb testing.TB) *lib.TestPreInitState { @@ -41,6 +42,7 @@ func getTestPreInitState(tb testing.TB) *lib.TestPreInitState { RuntimeOptions: lib.RuntimeOptions{}, Registry: reg, BuiltinMetrics: metrics.RegisterBuiltinMetrics(reg), + Usage: usage.New(), } } @@ -1112,6 +1114,7 @@ func TestDNSResolverCache(t *testing.T) { Logger: logger, BuiltinMetrics: builtinMetrics, Registry: registry, + Usage: usage.New(), }, &loader.SourceData{ URL: &url.URL{Path: "/script.js"}, Data: []byte(script), @@ -1399,6 +1402,7 @@ func TestNewSchedulerHasWork(t *testing.T) { Logger: logger, Registry: registry, BuiltinMetrics: metrics.RegisterBuiltinMetrics(registry), + Usage: usage.New(), } runner, err := js.New(piState, &loader.SourceData{URL: &url.URL{Path: "/script.js"}, Data: script}, nil) require.NoError(t, err) diff --git a/js/bundle.go b/js/bundle.go index 028e2d01076..39368aa40b3 100644 --- a/js/bundle.go +++ b/js/bundle.go @@ -106,7 +106,8 @@ func newBundle( } c := bundle.newCompiler(piState.Logger) - bundle.ModuleResolver = modules.NewModuleResolver(getJSModules(), generateFileLoad(bundle), c, bundle.pwd) + bundle.ModuleResolver = modules.NewModuleResolver( + getJSModules(), generateFileLoad(bundle), c, bundle.pwd, piState.Usage, piState.Logger) // Instantiate the bundle into a new VM using a bound init context. This uses a context with a // runtime, but no state, to allow module-provided types to function within the init context. diff --git a/js/bundle_test.go b/js/bundle_test.go index 8aa2df1da1c..ee7853a1f5d 100644 --- a/js/bundle_test.go +++ b/js/bundle_test.go @@ -26,6 +26,7 @@ import ( "go.k6.io/k6/lib/types" "go.k6.io/k6/loader" "go.k6.io/k6/metrics" + "go.k6.io/k6/usage" ) const isWindows = runtime.GOOS == "windows" @@ -43,6 +44,7 @@ func getTestPreInitState(tb testing.TB, logger logrus.FieldLogger, rtOpts *lib.R RuntimeOptions: *rtOpts, Registry: reg, BuiltinMetrics: metrics.RegisterBuiltinMetrics(reg), + Usage: usage.New(), } } diff --git a/js/console_test.go b/js/console_test.go index fbb2b2cf8bb..a99cdcb23b8 100644 --- a/js/console_test.go +++ b/js/console_test.go @@ -21,6 +21,7 @@ import ( "go.k6.io/k6/lib/testutils" "go.k6.io/k6/loader" "go.k6.io/k6/metrics" + "go.k6.io/k6/usage" ) func TestConsoleContext(t *testing.T) { @@ -73,6 +74,7 @@ func getSimpleRunner(tb testing.TB, filename, data string, opts ...interface{}) BuiltinMetrics: builtinMetrics, Registry: registry, LookupEnv: func(_ string) (val string, ok bool) { return "", false }, + Usage: usage.New(), }, &loader.SourceData{ URL: &url.URL{Path: filename, Scheme: "file"}, @@ -110,6 +112,7 @@ func getSimpleArchiveRunner(tb testing.TB, arc *lib.Archive, opts ...interface{} RuntimeOptions: rtOpts, BuiltinMetrics: builtinMetrics, Registry: registry, + Usage: usage.New(), }, arc) } diff --git a/js/init_and_modules_test.go b/js/init_and_modules_test.go index d7ef1c737f0..257644aae23 100644 --- a/js/init_and_modules_test.go +++ b/js/init_and_modules_test.go @@ -19,6 +19,7 @@ import ( "go.k6.io/k6/lib/testutils" "go.k6.io/k6/loader" "go.k6.io/k6/metrics" + "go.k6.io/k6/usage" ) type CheckModule struct { @@ -64,6 +65,7 @@ func TestNewJSRunnerWithCustomModule(t *testing.T) { BuiltinMetrics: builtinMetrics, Registry: registry, RuntimeOptions: rtOptions, + Usage: usage.New(), }, &loader.SourceData{ URL: &url.URL{Path: "blah", Scheme: "file"}, @@ -101,6 +103,7 @@ func TestNewJSRunnerWithCustomModule(t *testing.T) { BuiltinMetrics: builtinMetrics, Registry: registry, RuntimeOptions: rtOptions, + Usage: usage.New(), }, arc) require.NoError(t, err) assert.Equal(t, checkModule.initCtxCalled, 3) // changes because we need to get the exported functions diff --git a/js/modules/k6/marshalling_test.go b/js/modules/k6/marshalling_test.go index 70326ded19f..6932c65e67e 100644 --- a/js/modules/k6/marshalling_test.go +++ b/js/modules/k6/marshalling_test.go @@ -16,6 +16,7 @@ import ( "go.k6.io/k6/lib/types" "go.k6.io/k6/loader" "go.k6.io/k6/metrics" + "go.k6.io/k6/usage" ) func TestSetupDataMarshalling(t *testing.T) { @@ -103,6 +104,7 @@ func TestSetupDataMarshalling(t *testing.T) { Logger: testutils.NewLogger(t), BuiltinMetrics: builtinMetrics, Registry: registry, + Usage: usage.New(), }, &loader.SourceData{URL: &url.URL{Path: "/script.js"}, Data: script}, diff --git a/js/modules/resolution.go b/js/modules/resolution.go index 04d6269ed36..dd166813636 100644 --- a/js/modules/resolution.go +++ b/js/modules/resolution.go @@ -7,8 +7,10 @@ import ( "github.com/grafana/sobek" "github.com/grafana/sobek/ast" + "github.com/sirupsen/logrus" "go.k6.io/k6/js/compiler" "go.k6.io/k6/loader" + "go.k6.io/k6/usage" ) const notPreviouslyResolvedModule = "the module %q was not previously resolved during initialization (__VU==0)" @@ -32,6 +34,8 @@ type ModuleResolver struct { locked bool reverse map[any]*url.URL // maybe use sobek.ModuleRecord as key base *url.URL + usage *usage.Usage + logger logrus.FieldLogger } // NewModuleResolver returns a new module resolution instance that will resolve. @@ -39,6 +43,7 @@ type ModuleResolver struct { // loadCJS is used to load commonjs files func NewModuleResolver( goModules map[string]any, loadCJS FileLoader, c *compiler.Compiler, base *url.URL, + u *usage.Usage, logger logrus.FieldLogger, ) *ModuleResolver { return &ModuleResolver{ goModules: goModules, @@ -47,6 +52,8 @@ func NewModuleResolver( compiler: c, reverse: make(map[any]*url.URL), base: base, + usage: u, + logger: logger, } } @@ -66,6 +73,13 @@ func (mr *ModuleResolver) requireModule(name string) (sobek.ModuleRecord, error) if !ok { return nil, fmt.Errorf("unknown module: %s", name) } + // we don't want to report extensions and we would have hit cache if this isn't the first time + if !strings.HasPrefix(name, "k6/x/") { + err := mr.usage.Strings("modules", name) + if err != nil { + mr.logger.WithError(err).Warnf("Error while reporting usage of module %q", name) + } + } k6m, ok := mod.(Module) if !ok { return &basicGoModule{m: mod}, nil diff --git a/js/modulestest/runtime.go b/js/modulestest/runtime.go index 2738e9b3762..6251fd752b5 100644 --- a/js/modulestest/runtime.go +++ b/js/modulestest/runtime.go @@ -15,6 +15,7 @@ import ( "go.k6.io/k6/lib" "go.k6.io/k6/lib/testutils" "go.k6.io/k6/metrics" + "go.k6.io/k6/usage" ) // Runtime is a helper struct that contains what is needed to run a (simple) module test @@ -40,6 +41,7 @@ func NewRuntime(t testing.TB) *Runtime { TestPreInitState: &lib.TestPreInitState{ Logger: testutils.NewLogger(t), Registry: metrics.NewRegistry(), + Usage: usage.New(), }, CWD: new(url.URL), } @@ -74,7 +76,8 @@ func (r *Runtime) SetupModuleSystem(goModules map[string]any, loader modules.Fil goModules["k6/timers"] = timers.New() } - r.mr = modules.NewModuleResolver(goModules, loader, c, r.VU.InitEnvField.CWD) + r.mr = modules.NewModuleResolver( + goModules, loader, c, r.VU.InitEnvField.CWD, r.VU.InitEnvField.Usage, r.VU.InitEnvField.Logger) return r.innerSetupModuleSystem() } diff --git a/js/runner.go b/js/runner.go index 56dae12768a..2a21eab6ef6 100644 --- a/js/runner.go +++ b/js/runner.go @@ -242,6 +242,7 @@ func (r *Runner) newVU( Tags: lib.NewVUStateTags(vu.Runner.RunTags), BuiltinMetrics: r.preInitState.BuiltinMetrics, TracerProvider: r.preInitState.TracerProvider, + Usage: r.preInitState.Usage, } vu.moduleVUImpl.state = vu.state _ = vu.Runtime.Set("console", vu.Console) diff --git a/js/tc39/tc39_test.go b/js/tc39/tc39_test.go index f1e24a0716f..28ff83d8f3b 100644 --- a/js/tc39/tc39_test.go +++ b/js/tc39/tc39_test.go @@ -30,6 +30,7 @@ import ( "go.k6.io/k6/lib" "go.k6.io/k6/lib/testutils" "go.k6.io/k6/loader" + "go.k6.io/k6/usage" "gopkg.in/yaml.v3" ) @@ -714,7 +715,7 @@ func (ctx *tc39TestCtx) runTC39Module(name, src string, includes []string, vm *s func(specifier *url.URL, _ string) ([]byte, error) { return fs.ReadFile(os.DirFS("."), specifier.Path[1:]) }, - ctx.compiler(), base) + ctx.compiler(), base, usage.New(), testutils.NewLogger(ctx.t)) ms := modules.NewModuleSystem(mr, moduleRuntime.VU) moduleRuntime.VU.InitEnvField.CWD = base diff --git a/lib/test_state.go b/lib/test_state.go index 9bc5e3a79e7..28a27885483 100644 --- a/lib/test_state.go +++ b/lib/test_state.go @@ -9,6 +9,7 @@ import ( "go.k6.io/k6/event" "go.k6.io/k6/lib/trace" "go.k6.io/k6/metrics" + "go.k6.io/k6/usage" ) // TestPreInitState contains all of the state that can be gathered and built @@ -22,6 +23,7 @@ type TestPreInitState struct { LookupEnv func(key string) (val string, ok bool) Logger logrus.FieldLogger TracerProvider *trace.TracerProvider + Usage *usage.Usage } // TestRunState contains the pre-init state as well as all of the state and diff --git a/lib/vu_state.go b/lib/vu_state.go index cb66a8a840e..a59b39cf38f 100644 --- a/lib/vu_state.go +++ b/lib/vu_state.go @@ -13,6 +13,7 @@ import ( "golang.org/x/time/rate" "go.k6.io/k6/metrics" + "go.k6.io/k6/usage" ) // DialContexter is an interface that can dial with a context @@ -81,6 +82,9 @@ type State struct { // Tracing instrumentation. TracerProvider TracerProvider + + // Usage is a way to report usage statistics + Usage *usage.Usage } // VUStateTags wraps the current VU's tags and ensures a thread-safe way to diff --git a/output/cloud/output.go b/output/cloud/output.go index d981a897f56..40531a416e6 100644 --- a/output/cloud/output.go +++ b/output/cloud/output.go @@ -16,6 +16,7 @@ import ( "go.k6.io/k6/metrics" "go.k6.io/k6/output" cloudv2 "go.k6.io/k6/output/cloud/expv2" + "go.k6.io/k6/usage" "gopkg.in/guregu/null.v3" ) @@ -61,6 +62,8 @@ type Output struct { client *cloudapi.Client testStopFunc func(error) + + usage *usage.Usage } // Verify that Output implements the wanted interfaces @@ -135,6 +138,7 @@ func newOutput(params output.Params) (*Output, error) { executionPlan: params.ExecutionPlan, duration: int64(duration / time.Second), logger: logger, + usage: params.Usage, }, nil } @@ -341,6 +345,11 @@ func (out *Output) startVersionedOutput() error { } var err error + usageErr := out.usage.Strings("cloud/test_run_id", out.testRunID) + if usageErr != nil { + out.logger.Warning("Couldn't report test run id to usage as part of writing to k6 cloud") + } + // TODO: move here the creation of a new cloudapi.Client // so in the case the config has been overwritten the client uses the correct // value. diff --git a/output/cloud/output_test.go b/output/cloud/output_test.go index fc3edb00d6d..578d7b7ee05 100644 --- a/output/cloud/output_test.go +++ b/output/cloud/output_test.go @@ -20,6 +20,7 @@ import ( "go.k6.io/k6/metrics" "go.k6.io/k6/output" cloudv2 "go.k6.io/k6/output/cloud/expv2" + "go.k6.io/k6/usage" "gopkg.in/guregu/null.v3" ) @@ -121,6 +122,7 @@ func TestOutputCreateTestWithConfigOverwrite(t *testing.T) { SystemTags: &metrics.DefaultSystemTagSet, }, ScriptPath: &url.URL{Path: "/script.js"}, + Usage: usage.New(), }) require.NoError(t, err) require.NoError(t, out.Start()) @@ -147,6 +149,7 @@ func TestOutputStartVersionError(t *testing.T) { "K6_CLOUD_API_VERSION": "99", }, ScriptPath: &url.URL{Path: "/script.js"}, + Usage: usage.New(), }) require.NoError(t, err) @@ -170,6 +173,7 @@ func TestOutputStartVersionedOutputV2(t *testing.T) { AggregationPeriod: types.NullDurationFrom(1 * time.Hour), MetricPushInterval: types.NullDurationFrom(1 * time.Hour), }, + usage: usage.New(), } o.client = cloudapi.NewClient( @@ -190,6 +194,7 @@ func TestOutputStartVersionedOutputV1Error(t *testing.T) { config: cloudapi.Config{ APIVersion: null.IntFrom(1), }, + usage: usage.New(), } err := o.startVersionedOutput() @@ -217,6 +222,7 @@ func TestOutputStartWithTestRunID(t *testing.T) { SystemTags: &metrics.DefaultSystemTagSet, }, ScriptPath: &url.URL{Path: "/script.js"}, + Usage: usage.New(), }) require.NoError(t, err) require.NoError(t, out.Start()) diff --git a/output/types.go b/output/types.go index a4223acb856..e4064eec839 100644 --- a/output/types.go +++ b/output/types.go @@ -13,6 +13,7 @@ import ( "go.k6.io/k6/lib" "go.k6.io/k6/lib/fsext" "go.k6.io/k6/metrics" + "go.k6.io/k6/usage" ) // Params contains all possible constructor parameters an output may need. @@ -30,6 +31,7 @@ type Params struct { ScriptOptions lib.Options RuntimeOptions lib.RuntimeOptions ExecutionPlan []lib.ExecutionStep + Usage *usage.Usage } // TODO: make v2 with buffered channels? diff --git a/usage/usage.go b/usage/usage.go new file mode 100644 index 00000000000..78d2539b30c --- /dev/null +++ b/usage/usage.go @@ -0,0 +1,109 @@ +// Package usage implements usage tracking for k6 in order to figure what is being used within a given execution +package usage + +import ( + "fmt" + "strings" + "sync" +) + +// Usage is a way to collect usage data for within k6 +type Usage struct { + l *sync.Mutex + m map[string]any +} + +// New returns a new empty Usage ready to be used +func New() *Usage { + return &Usage{ + l: new(sync.Mutex), + m: make(map[string]any), + } +} + +// Strings appends the provided value to a slice of strings that is the value. +// Appending to the slice if the key is already there. +// It also works out level of keys +func (u *Usage) Strings(originalKey, value string) error { + u.l.Lock() + defer u.l.Unlock() + m, newKey, err := u.createLevel(originalKey) + if err != nil { + return err + } + oldV, ok := m[newKey] + if !ok { + m[newKey] = []string{value} + return nil + } + switch oldValue := oldV.(type) { + case []string: + m[newKey] = append(oldValue, value) + default: + return fmt.Errorf("value of key %s is not []string as expected but %T", originalKey, oldValue) + } + return nil +} + +// Uint64 adds the provided value to a given key. Creating the key if needed and working out levels of keys +func (u *Usage) Uint64(originalKey string, value uint64) error { + u.l.Lock() + defer u.l.Unlock() + m, newKey, err := u.createLevel(originalKey) + if err != nil { + return err + } + + oldValue, ok := m[newKey] + if !ok { + m[newKey] = value + return nil + } + switch oldVUint64 := oldValue.(type) { + case uint64: + m[newKey] = oldVUint64 + value + default: + return fmt.Errorf("value of key %s is not uint64 as expected but %T", originalKey, oldValue) + } + return nil +} + +func (u *Usage) createLevel(key string) (map[string]any, string, error) { + levelKey, subLevelKey, found := strings.Cut(key, "/") + if !found { + return u.m, key, nil + } + if strings.Contains(subLevelKey, "/") { + return nil, "", fmt.Errorf("only one level is permitted in usages: %q", key) + } + + level, ok := u.m[levelKey] + if !ok { + level = make(map[string]any) + u.m[levelKey] = level + } + levelMap, ok := level.(map[string]any) + if !ok { + return nil, "", fmt.Errorf("new level %q for key %q as the key was already used for %T", levelKey, key, level) + } + return levelMap, subLevelKey, nil +} + +// Map returns a copy of the internal map +func (u *Usage) Map() map[string]any { + u.l.Lock() + defer u.l.Unlock() + + return deepClone(u.m) +} + +func deepClone(m map[string]any) map[string]any { + result := make(map[string]any, len(m)) + for k, v := range m { + if newM, ok := v.(map[string]any); ok { + v = deepClone(newM) + } + result[k] = v + } + return result +} diff --git a/usage/usage_test.go b/usage/usage_test.go new file mode 100644 index 00000000000..6661b44cc96 --- /dev/null +++ b/usage/usage_test.go @@ -0,0 +1,39 @@ +package usage + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestErrors(t *testing.T) { + t.Parallel() + u := New() + require.NoError(t, u.Uint64("test/one", 1)) + require.NoError(t, u.Uint64("test/two", 1)) + require.NoError(t, u.Uint64("test/two", 1)) + require.NoError(t, u.Strings("test/three", "three")) + require.NoError(t, u.Strings("test2/one", "one")) + + require.ErrorContains(t, u.Strings("test/one", "one"), + "test/one is not []string as expected but uint64") + require.ErrorContains(t, u.Uint64("test2/one", 1), + "test2/one is not uint64 as expected but []string") + + require.NoError(t, u.Strings("test3", "some")) + require.ErrorContains(t, u.Strings("test3/one", "one"), + `new level "test3" for key "test3/one" as the key was already used for []string`) + + m := u.Map() + require.EqualValues(t, map[string]any{ + "test": map[string]any{ + "one": uint64(1), + "two": uint64(2), + "three": []string{"three"}, + }, + "test2": map[string]any{ + "one": []string{"one"}, + }, + "test3": []string{"some"}, + }, m) +}