skip to content

In k6, what happens if you construct a k6/metrics Trend inside the default function instead of at module scope?

level: middleimportance: must knowfreq 64%

answer

  1. constructor and add live apart
  2. no init environment inside VU code
  3. one run-wide registry keyed by name
  4. same name, different type is an error

basics

~10 s

Constructing a k6 custom metric outside the init context throws "metrics must be declared in the init context". Only .add() may run inside an iteration; the k6/metrics constructors must sit at module scope.

solid answer

~40 s

It throws. In k6 v2 the `k6/metrics` constructors ask the runtime for its init environment, and once VU code is running there is none, so `new Trend('checkout_duration')` inside the default function fails with `metrics must be declared in the init context`. The rule is two-sided: `.add()` is the opposite — calling it at module scope raises `Adding to metrics in the init context is not supported`. Construction registers the metric in a run-wide registry keyed by its **name**, so every VU re-running the init code gets the same metric back rather than a private copy. Re-declaring the same name with a different type is an error: `metric 'errors' already exists but with type counter, instead of gauge`. Grafana documents the reason as bounding memory and knowing the complete metric set before the test starts.

code

javascript · 11 lines
javascript
import { Trend, Rate } from 'k6/metrics';

// init context - constructors only
const checkoutDuration = new Trend('checkout_duration', true);
const payloadValid = new Rate('payload_valid');

export default function () {
  // VU code - add() only
  checkoutDuration.add(842);
  payloadValid.add(true);
}

go deeper

for a junior

Remember the shape: import from k6/metrics, construct at the top of the file, call .add() inside the exported function. Getting that layout right is most of the answer.

for a middle

Explain why each half fails where it does - the constructor needs an init environment that VU code no longer has, and .add() needs execution state that init does not have yet.

for a senior

Bring up the registry: identity is the name string, all VUs share one sink, and a typo silently splits your samples across two metrics nobody notices.

for a principal

Discuss what declaring the whole metric set up front buys a fleet - a validated configuration before a long run starts, and a bounded number of series to ingest and store.

## The rule, and it points both ways Custom metrics in k6 have a strict split between **where they are created** and **where they are fed**: - `new Counter(...)`, `new Gauge(...)`, `new Rate(...)` and `new Trend(...)` may only run in the **init context** — module scope, evaluated once per VU before any exported function runs. - `metric.add(value, [tags])` may only run once the VU has execution state — inside `default`, or in another exported function that runs as part of the test. Break either half and k6 tells you so, with a different message for each direction: | Call | At module scope (init) | Inside the default function | |---|---|---| | `new Trend('checkout_duration')` | registers the metric and returns its object | throws `metrics must be declared in the init context` | | `checkoutDuration.add(842)` | raises `Adding to metrics in the init context is not supported` | emits one sample and returns `true` | The mechanism is plain in the implementation: the constructor reads the VU's init environment and errors out when it is `nil`, while `add()` reads the VU's runtime state and errors out when *that* is `nil`. Exactly one of the two is available at any moment, which is why the two calls can never live in the same place. ## Why k6 enforces it Grafana's documentation gives two reasons, and both are practical rather than stylistic: 1. **It bounds memory.** If metrics could be created inside an iteration, a script could mint a new time series on every loop and grow the registry without limit for the length of the run. 2. **It makes the metric set known before the run starts.** With every metric declared up front, k6 can validate the rest of the configuration against a complete list instead of discovering a missing metric halfway through a two-hour test. ## One registry, keyed by the name The string you pass to the constructor — not the JavaScript variable you assign it to — is the metric's identity. k6 holds one registry for the whole test instance, and `new Trend('checkout_duration')` either creates that entry or hands back the existing one. Three consequences follow: - **Every VU re-runs the init code, and every VU ends up pointing at the same metric.** You do not get one trend per VU; the samples from all of them land in the same sink. - **Re-declaring an existing name with the same type is harmless.** You get the same object back, and `m.name` is identical. - **Re-declaring it with a different type is a hard error.** `new Counter('errors')` followed by `new Gauge('errors')` throws `metric 'errors' already exists but with type counter, instead of gauge`. The same happens if only the optional time flag differs, because that is part of the registration too. A related trap: because identity is the string, a typo silently creates a **second** metric. `new Trend('chekout_duration')` in one module and `new Trend('checkout_duration')` in another are two unrelated metrics, each with half your samples. ## Names k6 will accept The registry validates the name against `^[a-zA-Z_][a-zA-Z0-9_]{1,128}$`: ASCII letters, digits and underscores only, beginning with a letter or an underscore. A hyphen, a dot, a space or a leading digit is rejected at construction time with `Invalid metric name`, so `checkout-duration` and `2xx_count` both fail while `checkout_duration` and `_internal_step` are fine. ## Reading the four errors this area produces - `metrics must be declared in the init context` — a constructor ran from VU code. Move it to module scope. - `Adding to metrics in the init context is not supported` — an `.add()` ran at module scope. Move it inside the exported function. - `metric 'x' already exists but with type counter, instead of gauge` — two constructors, one name, different types. Rename one of them. - `Invalid metric name: 'x'` — the name string breaks the character rules, most often because it contains a hyphen or begins with a digit. ## What correct wiring looks like ```javascript import http from 'k6/http'; import { Trend, Rate } from 'k6/metrics'; // init context: constructors only const checkoutDuration = new Trend('checkout_duration', true); const payloadValid = new Rate('payload_valid'); export default function () { // VU code: add() only const started = Date.now(); const res = http.get('https://quickpizza.grafana.com/api/json'); checkoutDuration.add(Date.now() - started); payloadValid.add(res.status === 200 && res.body.length > 0); } ``` The usual mistake is the mirror image of this: moving the constructor inside `default` because "each iteration should measure its own step". That misunderstands what the object is. The metric object is a handle to a run-wide sink, not a per-iteration accumulator — the per-iteration part is the `.add()` call, and the tags you optionally pass with it. Keeping the constructor at module scope costs nothing and is the only arrangement k6 accepts.

  • What happens if a k6 script declares new Counter('errors') and later new Gauge('errors')?
    The second constructor throws. k6's metric registry is keyed by name and rejects a re-registration with a different type: `metric 'errors' already exists but with type counter, instead of gauge`. Re-declaring the same name with the *same* type is fine — you get the existing metric back, which is exactly what happens every time another VU re-runs the init code.
  • Which metric names does k6 accept?
    Names must match `^[a-zA-Z_][a-zA-Z0-9_]{1,128}$` — ASCII letters, digits and underscores, starting with a letter or an underscore. Anything else is rejected by the constructor with `Invalid metric name`, so `checkout-duration` fails on the hyphen and `2xx_count` fails on the leading digit.
  • If every VU runs the init code, does each VU get its own copy of the metric?
    No. The constructor looks the name up in a single run-wide registry and returns the existing entry when there is one, so all VUs write into the same sink. That is why a custom metric aggregates across the whole run rather than per VU, and why you cannot use construction order to separate VUs — use tags on `.add()` for that.

saying these in an interview costs you the question

  • Creates the metric inside the default function so each iteration is fresh
  • Believes each VU gets its own private copy of a custom metric
  • Calls .add() at module scope to seed a starting value
  • Assumes two metrics can share a name if their types differ
  • Thinks the JavaScript variable name, not the string, identifies the metric
  • Expects a hyphenated metric name such as checkout-duration to work