diff --git a/internal/cmd/config_consolidation_test.go b/internal/cmd/config_consolidation_test.go index db9cfc881d..dd9159ebfe 100644 --- a/internal/cmd/config_consolidation_test.go +++ b/internal/cmd/config_consolidation_test.go @@ -359,7 +359,7 @@ func getConfigConsolidationTestCases() []configConsolidationTestCase { }, }, - // Test-wide Tags + // Test-wide Tags: CLI and file tags are merged; CLI wins on key collision { opts{ fs: defaultConfig(`{"tags": { "codeTagKey": "codeTagValue"}}`), @@ -367,7 +367,10 @@ func getConfigConsolidationTestCases() []configConsolidationTestCase { }, exp{}, func(t *testing.T, c Config) { - exp := map[string]string{"clitagkey": "clitagvalue"} + exp := map[string]string{ + "codeTagKey": "codeTagValue", + "clitagkey": "clitagvalue", + } assert.Equal(t, exp, c.RunTags) }, }, diff --git a/lib/options.go b/lib/options.go index 634c3ab6d1..1c764c6bca 100644 --- a/lib/options.go +++ b/lib/options.go @@ -7,6 +7,7 @@ import ( "encoding/pem" "errors" "fmt" + "maps" "net" "reflect" "slices" @@ -490,7 +491,15 @@ func (o Options) Apply(opts Options) Options { o.SystemTags = opts.SystemTags } if len(opts.RunTags) > 0 { - o.RunTags = opts.RunTags + // Tags are merged across config layers rather than replaced wholesale, + // so that each layer can contribute its own keys. When the same key + // appears in multiple layers the higher-priority layer wins, consistent + // with the order of precedence documented at: + // https://grafana.com/docs/k6/latest/using-k6/k6-options/how-to/#order-of-precedence + merged := make(map[string]string, len(o.RunTags)+len(opts.RunTags)) + maps.Copy(merged, o.RunTags) + maps.Copy(merged, opts.RunTags) + o.RunTags = merged } if opts.MetricSamplesBufferSize.Valid { o.MetricSamplesBufferSize = opts.MetricSamplesBufferSize diff --git a/lib/options_test.go b/lib/options_test.go index fe74eab693..8cf165f407 100644 --- a/lib/options_test.go +++ b/lib/options_test.go @@ -550,6 +550,18 @@ func TestOptions(t *testing.T) { opts := Options{}.Apply(Options{RunTags: tags}) assert.Equal(t, tags, opts.RunTags) }) + t.Run("RunTagsMerge", func(t *testing.T) { + t.Parallel() + base := Options{RunTags: map[string]string{"env": "staging", "team": "backend"}} + // higher-priority layer adds a new key and overrides an existing one + override := Options{RunTags: map[string]string{"env": "prod", "region": "us-east"}} + opts := base.Apply(override) + assert.Equal(t, map[string]string{ + "env": "prod", // higher-priority layer wins on collision + "team": "backend", // lower-priority key preserved + "region": "us-east", // higher-priority new key added + }, opts.RunTags) + }) t.Run("DiscardResponseBodies", func(t *testing.T) { t.Parallel() opts := Options{}.Apply(Options{DiscardResponseBodies: null.BoolFrom(true)})