Mert Özen Further With Every Line
Testing & Quality Performance and Load Testing — #04

Putting k6 Into the Process: Performance Gates in CI, OpenTelemetry Output, Distributed Tests and Browser Scenarios

Mert Özen Nov 14, 2026 13 min 14 views
Putting k6 Into the Process: Performance Gates in CI, OpenTelemetry Output, Distributed Tests and Browser Scenarios

Putting k6 into the process means running a short performance gate on PRs and a full load test every night, sending metrics next to application telemetry with OpenTelemetry, splitting large loads across pods with the k6 Operator and measuring what the user sees with the browser module. We show Bookstore's GitHub Actions workflow, the metrics arriving at the Collector, a two-runner distributed test and the trap of k6 archive freezing options, all with real runs.

Version note: The code in this post was built and run with .NET 10; the load tests were run with k6 2.4.0 against Bookstore.Api and SQL Server 2025 on Docker Compose. OpenTelemetry Collector 0.165.0, Kubernetes 1.37 on kind 0.33 with k6 Operator 1.7.0, and actionlint 1.7.12 for validating the workflow were used.

Guess First

In post 03 the load test for Bookstore, the online bookstore used as the shared example across this site's series, became a single command. Now we put that command into the process. Three questions. Which kind of regression can a one-minute test on GitHub's shared runner catch on every pull request (PR), and which can it not? When you split the test across two pods in Kubernetes, who evaluates the thresholds and who reports the overall result? Finally: if you package the k6 script with k6 archive and pass -e DURATION=1m when running it, does the test last one minute? I learned the answer to the third question myself when a run went to waste.

Which Test Runs Where?

Recall the plan from post 01: smoke and a short average load on every PR, the full average load nightly, stress, spike and soak on a schedule. The reason for that split is time and noise. A PR gate must finish in a few minutes, otherwise developers do not wait and switch the test off. A one-minute test catches big, obvious regressions: an endpoint getting twice as slow, a rising error rate, an endpoint starting to return 500. It cannot catch a 10 percent slowdown or a leak that shows up after hours. Those are the job of the nightly and soak tests. Details of jobs, matrices and concurrency in GitHub Actions are in the GitHub Actions series post "Jobs in GitHub Actions: needs, matrix, concurrency, timeouts and Service Containers". Here I only cover the parts specific to load testing.

A Performance Gate in GitHub Actions

The workflow runs the same job briefly on PRs and at full length at night. The environment is the Compose file from post 01; the SQL Server password is generated per run and thrown away, so no secret is needed:

name: load-test

on:
  pull_request:
    paths: ["src/**", "load/**", "compose.yaml"]
  schedule:
    # nightly, 02:00 UTC
    - cron: "0 2 * * *"
  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: load-test-${{ github.ref }}
  cancel-in-progress: true

jobs:
  load-test:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    env:
      API_PORT: "5080"
      # PR: short gate (1 min, half rate). Nightly: full average load (10 min).
      DURATION: ${{ github.event_name == 'pull_request' && '1m' || '10m' }}
      SCALE: ${{ github.event_name == 'pull_request' && '0.5' || '1' }}
    steps:
      - uses: actions/checkout@v7

      - name: Generate a throwaway SQL Server password
        run: echo "SA_PASSWORD=Lt$(openssl rand -hex 16)!" >> "$GITHUB_ENV"

      - name: Start SQL Server and Bookstore.Api
        run: |
          docker compose up -d --build --wait
          # The API applies migrations on startup; wait until it answers.
          for i in $(seq 1 30); do curl -sf "http://localhost:$API_PORT/api/categories" > /dev/null && break; sleep 2; done

      - name: Seed the load-test catalog
        run: |
          docker compose cp load/data/seed-catalog.sql sql:/tmp/seed-catalog.sql
          docker compose exec -T sql /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P "$SA_PASSWORD" \
            -d BookstoreDb -b -i /tmp/seed-catalog.sql

      - uses: grafana/setup-k6-action@v1
        with:
          k6-version: "2.4.0"

      - name: Smoke test
        run: k6 run load/k6/smoke.js

      - name: Average load test (performance gate)
        run: |
          mkdir -p load/results
          k6 run -e DURATION="$DURATION" -e SCALE="$SCALE" \
            --new-machine-readable-summary --summary-export load/results/summary.json \
            load/k6/bookstore.ts

      - name: Publish p95 per endpoint to the job summary
        if: always() && hashFiles('load/results/summary.json') != ''
        run: |
          {
            echo "### k6: p95 per endpoint (${DURATION}, scale ${SCALE})"
            echo "| endpoint | p95 (ms) |"
            echo "|---|---|"
            jq -r '.results.metrics[] | select(.name | startswith("http_req_duration{name:"))
              | "| \(.name | ltrimstr("http_req_duration{name:") | rtrimstr("}")) | \(.values.p95 * 10 | round / 10) |"' \
              load/results/summary.json
          } >> "$GITHUB_STEP_SUMMARY"

      - uses: actions/upload-artifact@v7
        if: always()
        with:
          name: k6-results-${{ github.run_id }}
          path: load/results/
          retention-days: 30

      - name: API logs on failure
        if: failure()
        run: docker compose logs api --tail 200

The gate itself is not a separate step but k6's exit code. If a threshold is crossed, k6 exits with 99, the step turns red, and branch protection can block the PR from being merged. The other steps run in every case with if: always(): even if the test fails, the JSON summary is kept as an artifact and a p95 table per endpoint is written to the job summary. I call k6 directly instead of Grafana's run-k6-action, because that action is set by default to upload results to Grafana Cloud k6, which requires an account. setup-k6-action only installs k6; pinning the version makes every run use the same tool.

I validated the workflow with actionlint and ran its steps locally with the same commands, in PR mode (1 minute, SCALE=0.5). I did not run it on GitHub's own runner; timings there will be different. The job summary of the local run:

### k6: p95 per endpoint (1m, scale 0.5)
| endpoint | p95 (ms) |
|---|---|
| GET /api/books?search | 192.4 |
| POST /api/orders | 17.2 |
| GET /api/books/{isbn} | 5.5 |

Starting the containers took 15 seconds, the whole workflow 133 seconds. That is acceptable for a PR. The order p95 is 17 ms here because there is no payment stub in CI and the order stays "Placed". If you want to see real payment latency, start the stub in a step as well (post 02).

Account for the noise of shared runners. GitHub's hosted runners are virtual machines; they may share physical hardware with other work, and the CPU model can change from run to run. So do not keep the PR gate's limits tight: the goal is not to say "p95 is 5 percent worse than the previous commit" but "the endpoint doubled" or "the error rate went up". To see small differences, collect nightly results from the artifacts and follow them over time, or use a self-hosted runner dedicated to this job.

OpenTelemetry Output: Seeing Test Metrics Next to Telemetry

The terminal summary arrives at the end of the test and gives a single number. To see what broke when during the test, you need k6 metrics on the same time axis as the application's own metrics and traces. k6 2.x does this with its native OpenTelemetry output: it sends metrics over OTLP to a Collector. Setting up the Collector and connecting it to Prometheus and Grafana is the topic of the upcoming Observability series. Here I used the smallest Collector configuration to see what arrives:

# Minimal OpenTelemetry Collector for local load tests: receives OTLP and prints what arrives.
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

exporters:
  debug:
    verbosity: normal

service:
  pipelines:
    metrics:
      receivers: [otlp]
      exporters: [debug]
docker run -d --name otel -p 4317:4317 -p 4318:4318 `
    -v ${PWD}/load/otel/collector.yaml:/etc/otelcol/config.yaml:ro `
    otel/opentelemetry-collector:0.165.0 --config /etc/otelcol/config.yaml

$env:K6_OTEL_GRPC_EXPORTER_INSECURE = "true"
$env:K6_OTEL_METRIC_PREFIX = "k6_"
$env:K6_OTEL_SERVICE_NAME = "bookstore-load-test"
k6 run -o opentelemetry --tag testid=pr-1234 -e DURATION=30s -e SCALE=0.5 load/k6/bookstore.ts

What arrives in the Collector's debug output (lines shortened):

ResourceMetrics #0 [https://opentelemetry.io/schemas/1.43.0] service.name=bookstore-load-test service.version=2.4.0 ...
k6_http_req_duration{expected_response=true,flow=checkout,group=::checkout,method=POST,name=POST /api/orders,proto=HTTP/1.1,scenario=checkout,status=201,testid=pr-1234,url=POST /api/orders} count=11 sum=189.02 min=6.411 max=65.221 le0=0 le5=0 le10=1 le25=9 le50=0 le75=1 le100=0 ...
k6_http_req_failed.total{condition=zero,expected_response=true,flow=browse,group=::browse,method=GET,name=GET /api/books/{isbn},...,testid=pr-1234,...} 630
k6_checks.total{check=book 200,condition=nonzero,flow=browse,group=::browse,scenario=browse,testid=pr-1234} 630
k6_vus{testid=pr-1234} 1
k6_vus_max{testid=pr-1234} 127

Pay attention to three things. All tags in the script (name, flow, scenario, status) become OpenTelemetry attributes. The value you pass with --tag testid=... is added to every metric too; that is how you single out one run on a dashboard. Rate metrics (http_req_failed, checks) arrive as a single counter with a condition attribute. That format changed in 2.0; if old dashboards expect two other metric names, they need updating. Finally, trend metrics become histograms; the bucket boundaries here (5, 10, 25, 50 ms…) are the OpenTelemetry SDK defaults and are coarse for millisecond endpoints. For finer resolution, look at k6's experimental native-histograms feature flag.

The Prometheus remote write output still exists, but the k6 2.4.0 release notes deprecate the InfluxDB and Prometheus remote write outputs in favor of the OpenTelemetry output. In a new setup start with OpenTelemetry and write to Prometheus through the Collector if needed. 2.4.0 also brought an experimental native-tracing feature that emits the test run, scenarios and iterations as spans. I did not try it in this post.

Distributed Tests with the k6 Operator

There is a limit to the load one machine can generate (the CPU experiment in post 03). Beyond that you need to split the load across several machines. The k6 Operator does this in Kubernetes: you create a TestRun resource, the operator starts the same script in parallelism runner pods and gives each pod a slice of the load. In the k6 2.0 announcement Grafana stressed that the operator became stable with 1.0. At the time of writing the latest release is 1.7.0, from October 15, 2026. Kubernetes itself is covered in the Kubernetes series. I ran the experiment on a local cluster created with kind; the runner pods reached the API through host.docker.internal:

# Distributed run with the k6 Operator: the same test split across 2 runner pods.
# The archive fixes the options (scenarios, rates, duration), so pass -e values that shape them here:
#   k6 archive -e DURATION=1m -e SCALE=0.5 load/k6/bookstore.ts -O archive.tar
#   kubectl create configmap bookstore-load --from-file=archive.tar
apiVersion: k6.io/v1alpha1
kind: TestRun
metadata:
  name: bookstore-load
spec:
  parallelism: 2
  script:
    configMap:
      name: bookstore-load
      file: archive.tar
  # Read at run time by the script itself (not by the options).
  arguments: -e BASE_URL=http://host.docker.internal:5080
  runner:
    image: grafana/k6:2.4.0
kubectl apply --server-side -f https://raw.githubusercontent.com/grafana/k6-operator/v1.7.0/bundle.yaml
kubectl apply -f load/k8s/testrun.yaml
kubectl get testrun bookstore-load -o jsonpath='{.status.stage}'
kubectl logs bookstore-load-1-2ctmk

The answer to the third guess question came out of my first attempt. I had created the archive without -e and put DURATION=1m and SCALE=0.5 into the TestRun's arguments field. The test ran for five and a half minutes instead of one, at full rate instead of half. The reason: k6 computes the options when it creates the archive and writes them into it. Because the script builds its scenarios from __ENV, the defaults in effect when the archive was created were frozen. -e values given at run time still reach values the script reads during iterations, such as BASE_URL; they do not reach the options. After recreating the archive with -e DURATION=1m -e SCALE=0.5, the two runners reported this:

== pod/bookstore-load-1-2ctmk
    ✓ 'p(95)<300' p(95)=213.51ms
    ✓ 'p(95)<100' p(95)=9.28ms
    ✓ 'p(95)<500' p(95)=33.4ms
    ✓ 'rate<0.005' rate=0.00%
    http_reqs..........................: 1093   10.290183/s
    iterations.........................: 376    3.539898/s
== pod/bookstore-load-2-fk5c7
    ✓ 'p(95)<300' p(95)=218.09ms
    ✓ 'p(95)<100' p(95)=7.8ms
    ✓ 'p(95)<500' p(95)=37.86ms
    ✓ 'rate<0.005' rate=0.00%
    http_reqs..........................: 1084   10.627476/s
    iterations.........................: 373    3.656871/s

The load was split in two: 376 and 373 sessions, 749 in total, which is the number expected from one machine with the same settings. But the answer to the second guess question is here as well: each pod prints its own summary and evaluates the thresholds only against the requests it saw itself. There is no overall p95, and the average of two pods' p95 values is not the overall p95 either (we will see why in post 06). So in a distributed test you have to collect the metrics in one place: send them to a Collector with the OpenTelemetry output above and compute p95 over a single distribution. Grafana Cloud k6 does this aggregation itself; under this series' rules I did not open a cloud account, so that part was not run.

The Browser Module: The Time the User Sees

A protocol-level test measures the server's response time. A user, however, waits for the page to render, the JavaScript to run and the images to load. k6's browser module opens a real Chromium, through an interface that moved even closer to the Playwright API with 2.0, and collects Core Web Vitals metrics. In Bookstore's Angular client I wrote the catalog, book details and add-to-cart flow like this:

import { browser } from 'k6/browser';
import { expect } from 'https://jslib.k6.io/k6-testing/0.6.1/index.js';

// Browser test: a few real browsers next to the protocol-level load, measuring what the user sees.
// Usage: k6 run browser.ts   (the Angular client must be running: cd web/bookstore-web; npm start)
const WEB_URL = __ENV.WEB_URL || 'http://localhost:4200';

export const options = {
    scenarios: {
        ui: {
            executor: 'shared-iterations',
            vus: 1,
            iterations: 5,
            options: { browser: { type: 'chromium' } },
        },
    },
    thresholds: {
        browser_web_vital_lcp: ['p(75)<2500'],
        browser_web_vital_cls: ['p(75)<0.1'],
    },
};

export default async function () {
    const page = await browser.newPage();
    try {
        await page.goto(WEB_URL);
        // Auto-retrying assertions: wait until the catalog has loaded.
        await expect(page.getByTestId('book-count')).toBeVisible();
        await page.locator('.book-card a').first().click();
        await expect(page.locator('h1')).toBeVisible();
        await page.getByRole('button', { name: 'Add to cart' }).click();
        await expect(page.getByText('Added.')).toBeVisible();
    } finally {
        await page.close();
    }
}
  █ THRESHOLDS

    browser_web_vital_cls
    ✓ 'p(75)<0.1' p(75)=0

    browser_web_vital_lcp
    ✓ 'p(75)<2500' p(75)=148ms
...
    WEB_VITALS
    browser_web_vital_cls.......: avg=0        min=0        med=0        max=0        p(90)=0       p(95)=0
    browser_web_vital_fcp.......: avg=85.6ms   min=76ms     med=80ms     max=96ms     p(90)=96ms    p(95)=96ms
    browser_web_vital_inp.......: avg=16ms     min=16ms     med=16ms     max=16ms     p(90)=16ms    p(95)=16ms
    browser_web_vital_lcp.......: avg=166.4ms  min=124ms    med=144ms    max=284ms    p(90)=229.6ms p(95)=256.79ms
    browser_web_vital_ttfb......: avg=2.06ms   min=1.5ms    med=1.9ms    max=3.19ms   p(90)=2.71ms  p(95)=2.95ms

The script uses the auto-retrying form of expect(): it waits until the element is visible, no fixed sleep needed. k6 finds an installed Chromium-based browser on its own; to use a specific build, give its path with K6_BROWSER_EXECUTABLE_PATH. The numbers come from the development server running with ng serve; a production build has different sizes and timings. What LCP, CLS and INP mean and how to improve them is the topic of the upcoming Web Performance series. What you need to know here: every browser VU is a real browser, which means hundreds of MB of memory and serious CPU. Do not generate load with browsers. Protocol-level scenarios generate the load, and one or two browser VUs measure what the user sees at the same time.

Alternatives: NBomber, Crank and k6's AI Commands

For a .NET team the closest alternative is NBomber: scenarios are written in C#, the test code lives in the same language and IDE as the application, and writing your own client for protocols beyond HTTP is easy. The thing to watch is the license: NBomber is free for personal and academic use and requires a paid license for organizational use. Crank, used by the .NET team, is not a load-testing tool but benchmarking infrastructure: an agent deploys and runs the application on a given machine, applies load with bombardier or wrk and collects the results as JSON. It is built to track the framework's own performance commit by commit; for testing business flows, k6 is the better fit. Where JMeter is still the right tool is the topic of the next post.

The k6 x subcommands that came with k6 2.0 are commands that extensions add to the CLI: k6 x docs reads the documentation from the terminal, k6 x explore lists the extension catalog, k6 x mcp offers AI assistants tools to validate and run scripts over MCP, and k6 x agent sets up ready-made configuration for assistants. Approach them with care. These commands are downloaded separately as extensions. Having an AI assistant write a script is fine, but if you give it the right to run load tests, a human should check which target gets how much load. A wrong BASE_URL means sending load to a system you do not own. I only looked at these commands through their help output and did not run them.

Production Notes

A gate is only as reliable as it is repeatable. Every run should use an environment built from scratch, the same seed data and the same k6 version. Leftover caches or grown tables from a previous run shift the result. Building the environment from scratch with Compose on every run is a good choice for that reason. The price you pay is a 15-second startup on every PR.

Agree with the team on when the test turns red. A red gate combined with a "that's noise, rerun it" habit ends with the gate being ignored altogether. Keep the limits loose in the first weeks, learn the normal range from nightly data, then tighten them. Automatically collecting the API logs and the JSON summary when the gate fails (the last two steps of the workflow) speeds up diagnosis.

Watch the tags in distributed tests and in the OpenTelemetry output: low-cardinality tags such as testid are fine, high-cardinality tags such as a user ID or the full URL bloat the metrics backend. That is why the name tag from post 03 is required here too.

Common Pitfalls

  • Gating on 5 percent differences on a shared CI runner.
  • Using run-k6-action without noticing its default cloud upload.
  • Not keeping the JSON summary and logs when the test fails.
  • Assuming you can change options with -e after k6 archive.
  • Treating each runner's own threshold result as the overall result in a distributed test, or averaging p95 values.
  • Trying to generate load with browser VUs.
  • Giving an AI assistant the right to run a load test whose target nobody checks.

A Small Experiment

★☆☆ Add the Markdown table you produced with handleSummary() in post 03 to the workflow and show it in the job summary. Which permission (permissions) is needed to write it as a PR comment?

★★☆ Add a Prometheus exporter to the OpenTelemetry Collector, run k6 twice with different testid values, and compute the p95 of each run separately in Prometheus with histogram_quantile. See the effect of the bucket boundaries by comparing with the p95 in the terminal summary.

★★★ Run the TestRun with parallelism: 4, collect the four runners' metrics through OpenTelemetry and compute a single p95. Is p95 different when a single k6 applies the same total load?

k6 is a good tool for Bookstore's HTTP API, but not the only one. In the next post we look at JMeter: where it is still the right choice thanks to its broad protocol support, the anatomy of a test plan, running in CLI mode and reading the HTML dashboard.