Grafana & InfluxDB for QA
A practical guide to wiring up Grafana + InfluxDB for the metrics testers actually care about — k6 load runs, CI/CD trends, flaky-test tracking, and alerting.
InfluxDB Basics
InfluxDB is a time-series database. Every row has a timestamp; columns split into tags (indexed metadata) and fields (the actual numeric values you graph).
Concepts
| Concept | What it is | Example |
|---|---|---|
| Database (1.x) / Bucket (2.x) | Top-level container for time-series data | k6, ci_metrics |
| Measurement | Equivalent to a table; groups related points | http_req_duration, test_run |
| Tag | Indexed metadata (string only). Fast to filter / group by | method=GET, status=200, env=staging |
| Field | The numeric value(s) you graph. Not indexed | value=247.3, count=42 |
| Timestamp | When the point happened (auto if omitted) | 2026-05-03T10:00:00Z |
| Series | Unique combo of measurement + tag set | http_req_duration,method=GET,status=200 |
| Retention policy | How long data is kept before being dropped | 7d, 30d, inf |
Tags vs Fields — the rule
Tag it if you'll filter or group by it. Field it if you'll do math on it.
Tags are indexed; fields aren't. Putting a high-cardinality value (UUIDs, full URLs) in a tag explodes the index — that's the most common InfluxDB performance bug.
| Use | Tag | Field |
|---|---|---|
WHERE method='GET' | ✓ | ✗ slow |
GROUP BY status | ✓ | ✗ slow |
MEAN(value) | ✗ tags are strings | ✓ |
> 500 threshold | ✗ | ✓ |
Writing data with curl — Line Protocol
Line Protocol is the wire format: measurement,tag1=v,tag2=v field1=v,field2=v timestamp.
# InfluxDB 1.x
curl -i -XPOST 'http://localhost:8086/write?db=k6' --data-binary \
'http_req_duration,method=GET,status=200,env=staging value=247 1714723200000000000'
# InfluxDB 2.x — bucket + org + token
curl -i -XPOST "http://localhost:8086/api/v2/write?org=qa&bucket=k6&precision=ns" \
-H "Authorization: Token $INFLUX_TOKEN" \
--data-raw \
'http_req_duration,method=GET,status=200,env=staging value=247'Querying — InfluxQL (1.x and 2.x compatibility)
-- Mean response time, 1-minute buckets, last hour
SELECT mean("value")
FROM "http_req_duration"
WHERE time > now() - 1h
GROUP BY time(1m), "method"
fill(null);
-- p95 latency by endpoint
SELECT percentile("value", 95) AS p95
FROM "http_req_duration"
WHERE time > now() - 6h
GROUP BY "name";
-- Error rate (errors / total)
SELECT count("value") AS errors
FROM "http_req_failed"
WHERE "error" = '1' AND time > now() - 1h
GROUP BY time(1m);
-- Top 5 slowest endpoints
SELECT max("value") AS max_ms
FROM "http_req_duration"
WHERE time > now() - 1h
GROUP BY "name"
ORDER BY max_ms DESC
LIMIT 5;Querying — Flux (InfluxDB 2.x native)
from(bucket: "k6")
|> range(start: -1h)
|> filter(fn: (r) => r._measurement == "http_req_duration")
|> filter(fn: (r) => r.method == "GET")
|> aggregateWindow(every: 1m, fn: mean, createEmpty: false)
|> yield(name: "mean")
// p95 by endpoint
from(bucket: "k6")
|> range(start: -6h)
|> filter(fn: (r) => r._measurement == "http_req_duration")
|> group(columns: ["name"])
|> quantile(q: 0.95, method: "estimate_tdigest")Flux is more powerful (joins, pivots, alerts in-line) but verbose. Most QA dashboards stick to InfluxQL — it's enough for 95% of cases.
Retention policies
-- 1.x — 30-day retention on database 'k6'
CREATE RETENTION POLICY "thirty_days" ON "k6" DURATION 30d REPLICATION 1 DEFAULT;
-- Drop after 7 days for a busy bucket
CREATE RETENTION POLICY "one_week" ON "ci_metrics" DURATION 7d REPLICATION 1;In 2.x, retention is set per-bucket at create time:
influx bucket create --name k6 --retention 30d --org qaLocal InfluxDB via Docker
# 2.x — easiest for new setups
docker run -d --name influxdb -p 8086:8086 influxdb:2.7
# Open http://localhost:8086 — first-run setup wizard creates org, bucket, token
# 1.x — still common in older k6 examples
docker run -d --name influxdb -p 8086:8086 -e INFLUXDB_DB=k6 influxdb:1.8K6 → InfluxDB Integration
k6 ships with an InfluxDB output. Pipe results into a bucket; Grafana reads the same bucket for dashboards.
Run with the InfluxDB output
# InfluxDB 1.x
k6 run --out influxdb=http://localhost:8086/k6 script.js
# InfluxDB 2.x (xk6 extension — built into recent k6 versions)
k6 run --out influxdb=http://localhost:8086 \
-e K6_INFLUXDB_ORGANIZATION=qa \
-e K6_INFLUXDB_BUCKET=k6 \
-e K6_INFLUXDB_TOKEN=$INFLUX_TOKEN \
script.jsWhat ends up in InfluxDB
k6 writes one measurement per built-in metric:
| Measurement | What it holds |
|---|---|
http_req_duration | Request total duration (ms) |
http_req_blocked | Time spent waiting for a free TCP connection |
http_req_connecting | TCP connect time |
http_req_tls_handshaking | TLS handshake time |
http_req_sending | Time sending the request |
http_req_waiting | Server processing time (TTFB) |
http_req_receiving | Time downloading the response |
http_req_failed | Rate metric — 1 for failed, 0 for OK |
http_reqs | Counter — total requests |
vus | Currently active virtual users |
vus_max | Max VUs reached |
iterations | Counter — total iterations completed |
iteration_duration | Time per iteration (ms) |
data_sent, data_received | Bytes |
checks | Pass/fail rate of check() calls |
Custom k6 metrics also write to InfluxDB:
import { Counter, Trend } from 'k6/metrics';
export const errors = new Counter('app_errors');
export const dbTime = new Trend('app_db_time');→ measurements app_errors and app_db_time appear automatically.
Tags k6 attaches by default
Every point carries these tags — use them to filter and group:
| Tag | Example | Useful for |
|---|---|---|
method | GET, POST | Per-method dashboards |
url | https://api.example.com/users/42 | Specific endpoint analysis |
name | https://api.example.com/users/{id} | Templated URL grouping (set via tags: { name: ... }) |
status | 200, 404, 500 | Error breakdown |
group | login flow | k6 group() blocks |
scenario | peak_load | When using k6 scenarios |
expected_response | true, false | Whether the response counted as expected |
Always set
nameexplicitly on requests with dynamic IDs —/users/42,/users/43would otherwise create a series per user.
http.get(`/users/${id}`, { tags: { name: '/users/{id}' } });Grafana Setup
Standalone Grafana
docker run -d --name grafana -p 3000:3000 grafana/grafana
# Visit http://localhost:3000 — default login: admin / adminDocker Compose — InfluxDB + Grafana stack
The setup most teams actually use. Saves credentials, dashboards, and dashboards survive restarts.
services:
influxdb:
image: influxdb:2.7
ports:
- "8086:8086"
environment:
DOCKER_INFLUXDB_INIT_MODE: setup
DOCKER_INFLUXDB_INIT_USERNAME: admin
DOCKER_INFLUXDB_INIT_PASSWORD: admin12345
DOCKER_INFLUXDB_INIT_ORG: qa
DOCKER_INFLUXDB_INIT_BUCKET: k6
DOCKER_INFLUXDB_INIT_RETENTION: 30d
DOCKER_INFLUXDB_INIT_ADMIN_TOKEN: dev-token-please-rotate
volumes:
- influxdb-data:/var/lib/influxdb2
- influxdb-config:/etc/influxdb2
grafana:
image: grafana/grafana:latest
ports:
- "3000:3000"
environment:
GF_SECURITY_ADMIN_PASSWORD: admin
GF_AUTH_ANONYMOUS_ENABLED: "true" # public dashboards
GF_AUTH_ANONYMOUS_ORG_ROLE: Viewer
GF_INSTALL_PLUGINS: "grafana-clock-panel"
volumes:
- grafana-data:/var/lib/grafana
- ./provisioning:/etc/grafana/provisioning # see below
- ./dashboards:/var/lib/grafana/dashboards
depends_on:
- influxdb
volumes:
influxdb-data:
influxdb-config:
grafana-data:docker compose up -dAdd InfluxDB as a data source
In the Grafana UI: Connections → Data sources → Add → InfluxDB.
| Field | Value |
|---|---|
| Query language | Flux (2.x) or InfluxQL (1.x or compat) |
| URL | http://influxdb:8086 (Compose service name) or http://localhost:8086 |
| Organization | qa |
| Default Bucket | k6 |
| Token | the admin token from the env vars |
Click Save & Test — should report data source is working.
For automated provisioning, drop a YAML in provisioning/datasources/:
apiVersion: 1
datasources:
- name: InfluxDB-k6
type: influxdb
access: proxy
url: http://influxdb:8086
isDefault: true
jsonData:
version: Flux
organization: qa
defaultBucket: k6
secureJsonData:
token: ${INFLUX_TOKEN}K6 Performance Dashboard
The panels every k6 dashboard should have. Build them in this order — each answers a different question.
1. Request rate (req/s)
SELECT count("value") / 5 AS rps
FROM "http_reqs"
WHERE $timeFilter
GROUP BY time(5s) fill(0)Visualisation: Time series. Shows traffic shape — ramp-up, steady state, ramp-down.
2. Response time percentiles — p50 / p90 / p95 / p99
SELECT percentile("value", 50) AS "p50",
percentile("value", 90) AS "p90",
percentile("value", 95) AS "p95",
percentile("value", 99) AS "p99"
FROM "http_req_duration"
WHERE $timeFilter
GROUP BY time(10s) fill(null)Visualisation: Time series, multiple lines. The most important latency view — averages hide the tail.
3. Error rate gauge
SELECT mean("value") * 100 AS "error_rate_pct"
FROM "http_req_failed"
WHERE $timeFilterVisualisation: Gauge with thresholds: 0–1% green, 1–5% yellow, > 5% red.
4. Active VUs
SELECT max("value")
FROM "vus"
WHERE $timeFilter
GROUP BY time(5s) fill(null)Visualisation: Time series, often overlaid with the request-rate panel to correlate user load with latency.
5. Response time by endpoint
SELECT percentile("value", 95)
FROM "http_req_duration"
WHERE $timeFilter
GROUP BY time(30s), "name" fill(null)Visualisation: Time series with one line per name tag. Surfaces which endpoint regresses first under load.
6. Throughput by method
SELECT count("value") / 60 AS "req/s"
FROM "http_reqs"
WHERE $timeFilter
GROUP BY time(1m), "method" fill(0)Visualisation: Time series, stacked area. Quick view of read vs write traffic mix.
7. Status code distribution
SELECT count("value")
FROM "http_reqs"
WHERE $timeFilter
GROUP BY time(30s), "status" fill(0)Visualisation: Stat for current values + time series for trend. Color-code: 2xx green, 3xx blue, 4xx yellow, 5xx red.
Dashboard variables
Variables let one dashboard cover multiple environments / scenarios.
Name: env
Type: Query
Query: SHOW TAG VALUES FROM "http_reqs" WITH KEY = "env"
Name: endpoint
Type: Query
Query: SHOW TAG VALUES FROM "http_req_duration" WITH KEY = "name"
Multi-value: ✓
Include All: ✓
Then in queries:
WHERE $timeFilter AND "env" =~ /^$env$/ AND "name" =~ /^$endpoint$/The dashboard now has dropdowns for env and endpoint at the top.
Time range selector
Grafana's built-in $__timeFilter() (Flux) and $timeFilter (InfluxQL) bind to the time picker in the top-right. Set sensible defaults at the dashboard level: now-1h to now for live ops, now-7d to now for trend dashboards.
Test Result Dashboards
Beyond k6 — InfluxDB + Grafana also work for CI/CD test result trends. Push a single point per CI run.
Schema
| Field / tag | Goes in | Notes |
|---|---|---|
passed (count) | field | Number of passing tests |
failed (count) | field | Number of failing tests |
duration_ms | field | Total run time |
coverage_pct | field | Code coverage |
flaky (count) | field | Tests that retried |
branch | tag | main, pr/123 |
suite | tag | e2e, unit, smoke |
commit | tag (LOW cardinality only — careful) | Often better as a field |
Posting from CI
PASSED=42
FAILED=3
DURATION=187432
curl -i -XPOST "http://influxdb:8086/api/v2/write?org=qa&bucket=ci_metrics&precision=ms" \
-H "Authorization: Token $INFLUX_TOKEN" \
--data-raw \
"test_run,branch=main,suite=e2e passed=${PASSED}i,failed=${FAILED}i,duration_ms=${DURATION}i"Add to your GitHub Actions workflow as a step right after the test run.
Useful panels
| Panel | Query | What it shows |
|---|---|---|
| Pass/fail rate over time | SELECT sum(passed)/(sum(passed)+sum(failed))*100 FROM "test_run" GROUP BY time(1d) | Trend of suite health |
| Run duration trend | SELECT mean(duration_ms)/1000 FROM "test_run" GROUP BY time(1d) | Catches slow-creep regressions |
| Failures by suite | SELECT sum(failed) FROM "test_run" GROUP BY time(1d), "suite" | Stacked area — which suite is unhealthy? |
| Coverage trend | SELECT last(coverage_pct) FROM "test_run" GROUP BY time(1d) | Coverage direction |
| Flaky-test count | SELECT sum(flaky) FROM "test_run" GROUP BY time(1d) | Track flake-fix campaigns |
| Top flaky tests | Push per-test flake counts as flaky_test,test=login_test count=3 | Identify which tests need fixing |
Annotations for releases
Push a deployment marker so dashboards show "we deployed at this point":
curl -XPOST "http://grafana:3000/api/annotations" \
-H "Authorization: Bearer $GRAFANA_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"text\": \"Deploy v1.2.3 to staging\",
\"tags\": [\"deploy\", \"staging\"],
\"time\": $(date +%s)000
}"A vertical line + label appears on every panel — great for spotting regressions caused by a specific deploy.
Alerting
Grafana 9+ ships unified alerting that works for any data source.
Defining a rule
- Create alert rule on a panel → "More → New alert rule".
- Pick the query — typically a function over the same data the panel shows.
- Set the condition — threshold or math expression.
Query A: p95 latency for /checkout (last 5 min)
Reduce: last()
Condition: A > 2000
For: 5 minutes ← only fire if condition holds for 5 min
Common alert conditions for QA
| Alert | Condition | Severity |
|---|---|---|
| Test suite failed | failed > 0 from test_run measurement | high |
| Pass rate dropped | pass_rate < 95 for last 6 runs | high |
| p95 latency > 2 s | percentile("value", 95) > 2000 from http_req_duration | warning |
| Error rate > 5 % | mean("http_req_failed") > 0.05 for 5 min | warning |
| Throughput dropped | count("http_reqs") < expected_baseline * 0.5 | warning |
| Flaky-test count rising | day-over-day delta > 10 in flaky field | low |
| Synthetic check failed | check_status field == 0 for 2 consecutive runs | high |
Notification channels
Grafana calls them contact points. Common ones:
| Channel | Setup notes |
|---|---|
| Slack | Incoming webhook URL → channel; supports message templates |
| SMTP configured at server level | |
| PagerDuty | Integration key from a PagerDuty service |
| Microsoft Teams | Incoming webhook URL |
| Webhook | Posts JSON to any URL — useful for custom routing or to your own bot |
| Opsgenie | API key |
Notification policies
Group alerts by labels (severity=high → PagerDuty, severity=warning → Slack, severity=low → email-digest) so you don't page someone for a flaky-test counter ticking up.
Silences
Maintenance window or known issue? Create a silence — matches by label, valid for a time window. Alerts in the silenced range still evaluate but don't notify.
Silence: env="staging" AND alertname="p95 latency"
From: 2026-05-03 08:00 UTC
To: 2026-05-03 12:00 UTC
Comment: Staging upgrade — expect latency spikes
Prometheus Alternative
Prometheus is the other common back end. Many teams use it instead of (or alongside) InfluxDB for metrics.
k6 → Prometheus integration
# Remote-write to Prometheus
K6_PROMETHEUS_RW_SERVER_URL=http://prometheus:9090/api/v1/write \
k6 run --out experimental-prometheus-rw script.js
# Or scrape mode — k6 exposes /metrics, Prometheus pulls
k6 run --out experimental-prometheus-rw script.jsPromQL vs InfluxQL
# PromQL — p95 latency over 5 min
histogram_quantile(0.95, rate(http_req_duration_bucket[5m]))
# Error rate
sum(rate(http_req_failed_total[5m])) / sum(rate(http_reqs_total[5m]))
# Top 5 endpoints by request rate
topk(5, sum by (name) (rate(http_reqs_total[5m])))-- Equivalent InfluxQL
SELECT percentile("value", 95) FROM "http_req_duration" WHERE time > now() - 5m
SELECT mean("value") FROM "http_req_failed" WHERE time > now() - 5m
SELECT count("value") FROM "http_reqs" WHERE time > now() - 5m GROUP BY "name" ORDER BY count DESC LIMIT 5When to use which
| Choose Prometheus when… | Choose InfluxDB when… |
|---|---|
| The rest of your stack already uses it | You need long-term retention (months / years) |
| You want pull-based scraping | You want push-based writes from CI |
| Your team already knows PromQL | You need flexible event/metadata querying |
| You're running Kubernetes (prom-operator is standard) | You're storing test runs (low frequency, high cardinality on tags like commit) |
| Short-to-medium retention is fine (days–weeks) | You want a single TSDB for both real-time and historical |
For pure load-test result storage, InfluxDB is usually a better fit — Prometheus is optimised for short-lived high-frequency metrics, not for keeping every k6 run for a year.
Useful Grafana Features for QA
Annotations for deployments and test runs
Vertical markers on every panel — answers "did the regression start at the deploy, or before?"
| Source | How |
|---|---|
| Manual | Click on a panel timeline → Add annotation |
| API from CI | POST /api/annotations with bearer token |
| Auto from Git tags | Grafana annotation query against your CI database (SELECT … FROM deploys) |
Dashboard variables
Build one dashboard, reuse across environments / scenarios / branches. Variable types:
- Query — populated from a data source query (
SHOW TAG VALUES FROM "x" WITH KEY = "env") - Custom — static dropdown
- Constant — hidden, used for templating
- Datasource — switch the dashboard between dev / staging / prod data sources
- Interval — pick the GROUP BY time bucket (
1m,5m,1h)
Snapshots
Share a dashboard at a moment in time, with the data baked in — no need to grant data-source access. Useful for incident postmortems and PR comments.
Dashboard menu → Share → Snapshot → Local snapshot
Playlists
Auto-cycle through dashboards on an office screen — Smoke health → Performance → Coverage → CI flakiness on a 30-second loop. Set up under Dashboards → Playlists.
Embedding panels
<iframe> a single panel into wikis, runbooks, or the team intranet:
Panel menu → Share → Embed
Set the time range and theme (light/dark) in the URL params:
https://grafana.example.com/d-solo/abc/load-tests?panelId=4&from=now-7d&to=now&theme=dark
Provisioning dashboards as JSON in Git
The most important practice for any team beyond a single dashboard: dashboards should be version-controlled.
provisioning/
├── dashboards/
│ └── dashboards.yml ← tells Grafana where to look
└── datasources/
└── influxdb.yml
dashboards/
├── k6-performance.json
├── ci-test-trends.json
└── flaky-tests.json
provisioning/dashboards/dashboards.yml:
apiVersion: 1
providers:
- name: 'qa-dashboards'
folder: 'QA'
type: file
options:
path: /var/lib/grafana/dashboardsIn docker-compose.yml, mount both directories (shown in the Compose example above). Now the dashboards live in Git, deploy with the rest of your infra, and survive a fresh Grafana container.
Importing community dashboards
Grafana hosts a public dashboard library. For k6, use:
- Dashboard ID 2587 — k6 Load Testing Results (InfluxDB 1.x)
- Dashboard ID 14801 — k6 + Prometheus
Grafana UI → Dashboards → Import → paste ID → pick data source
Use these as a starting template; clone, edit panels for your tag schema, then commit the JSON to your repo.