grafana/k6 · error · TypeError

invalid value for metric tag '%s': only String, Boolean and

Error message

invalid value for metric tag '%s': only String, Boolean and Number types are accepted as a metric tag values

What it means

The execution module exposes vu.tags (and scenario-level tags) as a dynamic object whose Set trap pipes through ApplyCustomUserTag. That helper only accepts strings, booleans, and numbers, because metric tags must serialize into the samples every output receives; assigning an object, array, null, or undefined raises the TypeError shown, naming the offending tag key.

Source

Thrown at internal/js/modules/k6/execution/execution.go:374

	state   *lib.State
}

// Get a property value for the key. May return nil if the property does not exist.
func (o *tagsDynamicObject) Get(key string) sobek.Value {
	tcv := o.state.Tags.GetCurrentValues()
	if tag, ok := tcv.Tags.Get(key); ok {
		return o.runtime.ToValue(tag)
	}
	return nil
}

// Set a property value for the key. It returns true if succeed. String, Boolean
// and Number types are implicitly converted to the Sobek's relative string
// representation. An exception is raised in case a denied type is provided.
func (o *tagsDynamicObject) Set(key string, val sobek.Value) bool {
	o.state.Tags.Modify(func(tagsAndMeta *metrics.TagsAndMeta) {
		if err := common.ApplyCustomUserTag(tagsAndMeta, key, val); err != nil {
			panic(o.runtime.NewTypeError(err.Error()))
		}
	})
	return true
}

// Has returns true if the property exists.
func (o *tagsDynamicObject) Has(key string) bool {
	ctv := o.state.Tags.GetCurrentValues()
	if _, ok := ctv.Tags.Get(key); ok {
		return true
	}
	return false
}

// Delete deletes the property for the key. It returns true on success (note,
// that includes missing property).
func (o *tagsDynamicObject) Delete(key string) bool {
	o.state.Tags.Modify(func(tagsAndMeta *metrics.TagsAndMeta) {

View on GitHub (pinned to 93accf6570)

Solutions

  1. Assign a primitive: vu.tags['user'] = String(obj.id) or JSON.stringify(obj) when you need the whole structure as one tag
  2. Beware tag cardinality: prefer concise IDs over stringified JSON blobs, which bloat every sample
  3. Whitelist-spread only scalar keys: for (const k of ['name','env']) vu.tags[k] = cfg[k]
  4. Guard values: vu.tags[k] = v ?? 'unknown' to avoid null/undefined

Example fix

// before
vu.tags['claims'] = jwtPayload; // object -> TypeError: invalid value for metric tag 'claims'

// after
vu.tags['claims'] = JSON.stringify(jwtPayload);
// better (low cardinality):
vu.tags['tenant'] = String(jwtPayload.tenant_id);
Defensive patterns

Strategy: validation

Validate before calling

function setTag(tags, key, value) {
  if (value == null) return; // skip null/undefined
  const t = typeof value;
  tags[key] = (t === 'string' || t === 'number' || t === 'boolean') ? value : JSON.stringify(value);
}
setTag(vu.tags, 'tenant', payload.tenant);

Type guard

const isPrimitiveTagValue = v => v != null && ['string','number','boolean'].includes(typeof v);

Try / catch

try { vu.tags[key] = value; } catch (e) { if (/metric tag/.test(e.message)) vu.tags[key] = JSON.stringify(value); else throw e; }

Prevention

When it happens

Trigger: vu.tags['user'] = { id: 1 } or vu.tags.envs = ['a','b']; also assigning the result of a function that returns undefined, or null from a lookup. Reads (Get) and existence checks (Has) are unaffected — only writes with non-primitive values fail, and the failure names the key in '%s'.

Common situations: Tagging with structured data (session objects, JWT payload claims parsed to an object) instead of a scalar field; spreading a config object into vu.tags where some values are nested; porting from k6 iterations where tags were set on individual HTTP calls with pre-stringified values; null flowing in after a failed JSON parse.

Related errors


AI-assisted analysis of grafana/k6@93accf6570 (2026-08-15). Data as JSON: /api/errors/3345bd17819a651b. Report an issue: GitHub.