Correlating k6 Load Tests With Production Telemetry
EngineeringOctober 22, 20269 min read

Correlating k6 Load Tests With Production Telemetry

Tag k6 traffic with a test run ID so load test impact shows up next to production traces, errors and latency.

Picture a deploy that takes production down for 47 minutes (a hypothetical, but an easy one to imagine). The load tests passed. 500 virtual users, 10-minute ramp, P95 under 200ms the whole time. Green lights everywhere.

Then the code ships.

Real traffic hits a code path the load test didn't cover. The 95th percentile goes from 180ms to 9 seconds in about four minutes. And here's the frustrating part: there's no before/after comparison anywhere. k6 results sit in one tab. Production APM is in another. Error tracking is in a third.

Load testing without APM correlation is mostly theater. You generate numbers, but you never connect them to anything that matters.

This tutorial wires k6 load tests to JustAnalytics so you can see load test impact next to production telemetry. The approach is deliberately vendor-neutral: OpenTelemetry span attributes carry a test run ID from k6 through your backend, and the APM dashboard lets you filter on it.

What you'll have by the end

A setup where every k6 run:

  • Sends a unique test run ID on every request
  • Tags backend traces with that ID, so you can filter load test traffic in or out
  • Gives you a time window to compare P95/P99 latency and errors against your pre-test baseline

Prerequisites

  • k6 installed locally or in CI (brew install k6 on macOS, or the Docker image)
  • A JustAnalytics account on Pro or Enterprise. The free tier is fine for poking around, but APM, tracing and error tracking are Pro features.
  • A backend instrumented for tracing. The Node.js SDK is @justanalyticsapp/node; other languages go through OpenTelemetry or one of the 10 SDKs.
  • A CI environment (GitHub Actions, GitLab CI, CircleCI) or a local shell

If your backend isn't instrumented yet, the Django middleware tutorial or the Next.js App Router guide will get you started.

Step 1: Generate a test run ID

One ID per run, created before k6 starts and recorded somewhere you can find it later:

export TEST_RUN_ID="k6-$(date +%s)"
echo "Run $TEST_RUN_ID started at $(date -u +%Y-%m-%dT%H:%M:%SZ)"

k6 run --env TEST_RUN_ID="$TEST_RUN_ID" tests/load/checkout.js

echo "Run $TEST_RUN_ID ended at $(date -u +%Y-%m-%dT%H:%M:%SZ)"

Those two timestamps are your test window. Always UTC. A local-time window is the most common reason a load test "doesn't show up" next to the latency graph.

Step 2: Wire it into your CI pipeline

Here's a GitHub Actions workflow that runs k6 and prints the window to the job log:

# .github/workflows/load-test.yml
name: Load Test

on:
  workflow_dispatch:
    inputs:
      vus:
        description: "Number of virtual users"
        default: "100"
      duration:
        description: "Test duration"
        default: "5m"

jobs:
  load-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install k6
        run: |
          sudo gpg -k
          sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
          echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
          sudo apt-get update
          sudo apt-get install k6

      - name: Run k6
        id: k6
        run: |
          TEST_RUN_ID="k6-${{ github.run_id }}"
          echo "TEST_RUN_ID=$TEST_RUN_ID" >> "$GITHUB_ENV"
          echo "START=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV"
          k6 run \
            --env TEST_RUN_ID="$TEST_RUN_ID" \
            --vus ${{ github.event.inputs.vus }} \
            --duration ${{ github.event.inputs.duration }} \
            tests/load/checkout.js
        continue-on-error: true

      - name: Print test window
        if: always()
        run: |
          echo "Run:    $TEST_RUN_ID"
          echo "Window: $START to $(date -u +%Y-%m-%dT%H:%M:%SZ)"
          echo "k6:     ${{ steps.k6.outcome }}"

continue-on-error: true keeps the window printed even when the load test fails. A failed run is often more interesting to analyze than a passing one.

Step 3: Add trace context to k6 requests

This is the step that earns the setup its keep. k6 sends the run ID on every request, which means your APM can group all load test traffic together.

// tests/load/checkout.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { randomIntBetween } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';

const TEST_RUN_ID = __ENV.TEST_RUN_ID || `k6-${Date.now()}`;

export const options = {
  stages: [
    { duration: '2m', target: 100 },  // ramp up
    { duration: '5m', target: 100 },  // steady state
    { duration: '2m', target: 0 },    // ramp down
  ],
  thresholds: {
    http_req_duration: ['p(95)<500'],
    http_req_failed: ['rate<0.01'],
  },
};

export default function () {
  const headers = {
    'Content-Type': 'application/json',
    'X-Test-Run-Id': TEST_RUN_ID,
    // W3C baggage; propagated if your SDK has the baggage propagator enabled
    'baggage': `test.run_id=${TEST_RUN_ID},test.type=load`,
  };

  const payload = JSON.stringify({
    cart_id: `cart-${randomIntBetween(1, 10000)}`,
    payment_method: 'card',
  });

  const res = http.post('https://your-api.com/api/checkout', payload, { headers });

  check(res, {
    'status is 200': (r) => r.status === 200,
    'response time < 500ms': (r) => r.timings.duration < 500,
  });

  sleep(randomIntBetween(1, 3));
}

The X-Test-Run-Id header is custom, so your backend has to read it and attach it to spans. That's the part people forget. The baggage header is part of the W3C spec, but baggage isn't copied onto span attributes by default, so don't rely on it alone. The explicit middleware below is the dependable route.

On the backend (Node.js example):

// middleware/test-context.js
import JA from '@justanalyticsapp/node';

export function testContextMiddleware(req, res, next) {
  const testRunId = req.headers['x-test-run-id'];

  if (testRunId) {
    const span = JA.getActiveSpan();
    if (span) {
      span.setAttribute('test.run_id', testRunId);
      span.setAttribute('test.type', 'load');
    }
  }

  next();
}

Register it after app.use(JA.expressMiddleware()) and before your routes. If you instrument with the OpenTelemetry SDK instead, use trace.getActiveSpan() from @opentelemetry/api in the same place. Now every trace generated during the load test carries the run ID. In JustAnalytics, filter traces by test.run_id to see only load test traffic, or exclude it to see what your real users experienced during the test.

Step 4: Read errors in the test window

Load tests that pass k6 thresholds can still cause problems. Maybe checkout returned 200 OK but logged an error. Maybe a downstream service started queueing. Maybe you're writing to a dead letter queue k6 never sees.

With the SDK installed, unhandled exceptions and rejections land in JustAnalytics error tracking on their own; errors you catch and log need JA.captureException. To tie them to the run, set your dashboard's time range to the window from Step 2 and look at what's new compared with the hour before. Filter traces by the test.run_id attribute to separate load test errors from production issues. New issue groups that spike during the load window are the ones to prioritize before shipping.

Step 5: Compare against a baseline

Pick a quiet window of equal length from before the test, then compare three numbers for the load test window:

  • Server-side P95 and P99 latency
  • Error count and the top new issue groups
  • Throughput

Paste them into the PR or the CI summary. Fancy dashboards are nice but a pasted table is forever.

The k6 summary only knows what k6 measured. The APM knows what the backend actually did. You need both.

Common problems and how to fix them

Traces don't show the test.run_id attribute. Your backend isn't extracting the header. Check that the middleware runs inside an active span (after the HTTP instrumentation), that it reads the span from the same tracer you instrumented with, and that a proxy or CDN isn't stripping custom headers.

The load test window looks empty. You're probably filtering in local time. Use UTC for the window, e.g. date -u +%Y-%m-%dT%H:%M:%SZ, or (Get-Date).ToUniversalTime().ToString("o") on PowerShell.

k6 thresholds pass but production P95 spiked. k6 measures round-trip latency from the runner. APM measures server-side latency. If the runner is far from your servers, k6 sees network overhead the APM doesn't. Compare server-side P95 to server-side P95.

Error spikes don't line up with the test window. Check your time zones. Also check that your backend is actually instrumented: errors that are logged but not captured by the SDK won't show up.

What this doesn't cover

There's no synthetic traffic from multiple regions here. k6 Cloud does that, or you can run k6 on CI runners in different provider regions.

It also doesn't give you database-level detail. If your load test makes Postgres fall over, you'll see it in latency and errors, and infrastructure monitoring can help, but pair this with your database's own tooling for query-level analysis. DB observability is still messy across the industry.

And it won't help if the load test target looks nothing like production. If you're hitting staging, correlate with staging telemetry.

Next steps

Once correlation works, you may want a way to watch scheduled jobs too; the cron monitoring comparison covers that. If your APIs are GraphQL or tRPC, the resolver-level APM guide goes further on instrumentation.

The core idea applies beyond k6. Playwright, Cypress, Artillery, Locust: if it can send a header from CI, it can tag traces. I'd argue this matters more than which load testing tool you pick. Artillery versus k6 is a taste debate. Correlating tests with production data is what catches the problem before Saturday afternoon.

Frequently Asked Questions

Can I run k6 tests against staging and still correlate with production telemetry?

Yes, but keep staging and production data apart. Use a separate site for staging, or at least a distinct environment tag, so your baseline comparisons don't mix load test traffic with real users.

Does this work with k6 Cloud or only the open-source CLI?

Both. The correlation lives in your backend, which reads a test run ID off each request. k6 doesn't care whether it runs locally, in Docker, in CI or on k6 Cloud, as long as it sends the same header.

How do I find the load test window in the dashboard?

Write down the start and end time of the run (your CI log has both) and set the dashboard's time range to match. Then filter traces by the test.run_id attribute to separate load test traffic from real users.

Can I correlate multiple k6 test scenarios?

Yes. Add a scenario attribute alongside the run ID, or use a different run ID per scenario. The tradeoff is granularity versus noise: fifteen scenarios a day with separate IDs gets cluttered fast.


Try JustAnalytics

All-in-one observability in one under-5KB script: cookieless analytics + error tracking + APM + session replay + uptime + structured logs. Replaces GA4 + Sentry + Datadog + Pingdom + LogRocket. Free tier (100K events/mo), Pro $49/month ($39/month billed annually).

Start free → · AI Command Center MCP

JP
JustAnalytics Platform TeamContributor

Author at JustAnalytics.

Related posts