Custom Metrics — Counters, Gauges, Trends, Rates

9 min read

K6's built-in metrics — http_req_duration, http_req_failed, http_reqs — measure HTTP behaviour. They tell you how fast requests are and how many fail. They do not tell you how many orders were placed, what the checkout success rate was, or how long a specific business flow took end-to-end. Custom metrics fill that gap.

The four metric types

Importing and creating custom metrics

All four types are imported from k6/metrics and instantiated outside the default function — in init code, so each VU shares the same metric definition:

import { Counter, Gauge, Trend, Rate } from 'k6/metrics';
 
// Instantiate once in init context — one instance per metric name
const ordersPlaced    = new Counter('orders_placed');
const checkoutTime    = new Trend('checkout_duration_ms');
const paymentSuccess  = new Rate('payment_success_rate');
const activeCheckouts = new Gauge('active_checkouts');

The string passed to the constructor ('orders_placed') is the metric name that appears in the K6 output and in Grafana. Use snake_case for consistency with built-in K6 metrics.

Counter — counting events

import { Counter } from 'k6/metrics';
import http from 'k6/http';
import { check } from 'k6';
 
const orderCreated  = new Counter('orders_created');
const orderFailed   = new Counter('orders_failed');
 
export default function () {
  const res = http.post('https://api.example.com/orders',
    JSON.stringify({ productId: 1, quantity: 2 }),
    { headers: { 'Content-Type': 'application/json' } }
  );
 
  if (check(res, { 'order created': (r) => r.status === 201 })) {
    orderCreated.add(1);
  } else {
    orderFailed.add(1);
  }
}

The output shows cumulative counts and rates:

orders_created...: 847    14.117/s
orders_failed....: 23     0.383/s

Trend — timing business flows

Trend records a series of numeric values and reports the full statistical distribution — the same percentile output as http_req_duration. Use it for multi-step flows where built-in metrics would only capture individual request times:

import { Trend } from 'k6/metrics';
import http from 'k6/http';
import { check } from 'k6';
 
const checkoutFlow = new Trend('checkout_flow_ms');
 
export default function (data) {
  const flowStart = Date.now();
 
  // Step 1: add to cart
  http.post('https://api.example.com/cart/items',
    JSON.stringify({ productId: 42, quantity: 1 }),
    { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${data.token}` } }
  );
 
  // Step 2: apply promo code
  http.post('https://api.example.com/cart/promo',
    JSON.stringify({ code: 'LOAD10' }),
    { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${data.token}` } }
  );
 
  // Step 3: complete checkout
  const checkoutRes = http.post('https://api.example.com/orders',
    JSON.stringify({ paymentMethod: 'card_test' }),
    { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${data.token}` } }
  );
 
  checkoutFlow.add(Date.now() - flowStart);
 
  check(checkoutRes, { 'checkout succeeded': (r) => r.status === 201 });
}

The output shows the checkout flow's own percentile distribution:

checkout_flow_ms........: avg=1.23s  min=0.85s  med=1.18s  max=3.12s  p(90)=1.82s  p(95)=2.10s

Rate — tracking success ratios

Rate.add() takes a boolean (or 0/1). K6 computes the proportion of true values:

import { Rate } from 'k6/metrics';
 
const cacheHitRate = new Rate('cache_hit_rate');
 
export default function () {
  const res = http.get('https://api.example.com/products/1');
 
  // X-Cache: HIT means the CDN or app cache served this
  const isHit = res.headers['X-Cache'] === 'HIT';
  cacheHitRate.add(isHit);
}

Output:

cache_hit_rate...: 0.87   ✓ 870  ✗ 130

Gauge — tracking current state

Gauge stores the most recently set value. It is useful for tracking in-flight state, though in practice most K6 users reach for Counter or Trend more often:

import { Gauge } from 'k6/metrics';
 
const activeUsers = new Gauge('active_user_sessions');
 
export default function () {
  activeUsers.add(1);    // VU starts a session
  // ... run the test flow ...
  activeUsers.add(-1);   // VU ends the session — Gauge can decrease
}

Thresholds on custom metrics

Custom metrics support the same threshold syntax as built-in metrics:

export const options = {
  thresholds: {
    'checkout_flow_ms':      ['p(95)<3000'],   // checkout must complete in 3s at p95
    'payment_success_rate':  ['rate>0.98'],    // 98%+ payments succeed
    'orders_failed':         ['count<10'],     // fewer than 10 failed orders total
  },
};

This is the key reason to use custom metrics: you can write CI gates on business outcomes, not just HTTP timings. A load test that meets http_req_duration p(95)<500 but has payment_success_rate rate=0.72 is still a business failure.

⚠️ Common mistakes

  • Instantiating metrics inside the default function. If you write const myCounter = new Counter('events') inside the default function, K6 creates a new metric object on every iteration. The metric name is the same, but the churn is wasteful. Create metric instances once in init code.
  • Using Gauge when you mean Trend. If you want to track latency distribution (p95, p99), use Trend — it records every value and computes percentiles. Gauge only keeps the last value set, so you lose all distribution information.
  • Adding boolean conditions to Trend instead of Rate. myTrend.add(isSuccess) adds 1 or 0 as a numeric data point — the p(95) of a boolean series is meaningless. Use Rate for proportions.

🎯 Practice task

Add business-level metrics to a CRUD test. 35 minutes.

Use https://jsonplaceholder.typicode.com.

  1. Create four custom metrics in init code: new Counter('posts_created'), new Counter('posts_failed'), new Rate('post_success_rate'), new Trend('create_and_fetch_ms').
  2. In the default function:
    • POST to /posts with JSON.stringify({ title: 'K6 test', body: 'test body', userId: 1 }).
    • If status is 201, call postsCreated.add(1) and postSuccessRate.add(true). Otherwise, call postsFailed.add(1) and postSuccessRate.add(false).
    • Record Date.now() before the POST. After a follow-up GET /posts/1, record the elapsed time with createAndFetch.add(Date.now() - start).
  3. Add thresholds: post_success_rate: ['rate>0.95'] and create_and_fetch_ms: ['p(95)<2000'].
  4. Run with vus: 5, duration: '30s'. Find all four custom metrics in the output.
  5. Deliberately break the success rate threshold by checking for status 999 instead of 201 in the condition. Confirm the test exits with code 108.

// tip to track lessons you complete and pick up where you left off across devices.