Barrage is a load testing tool built to answer one question: when an application slows down, is the cause the application itself, or the database and cache underneath it?
It drives concurrent HTTP, database, and Redis load on a single clock, records every layer's latencies into the same time buckets, and compares them bucket by bucket. Instead of one latency curve that hides where the time went, you get a correlated view of the API, the database, and the cache — and a report that flags exactly which bucket a storage layer spiked in, and whether the application was affected or not.
- Quickstart
- Commands
- The report
- Concepts
- Configuration
- Demo stack
- When to use Barrage (and when not)
- Development
- Agents
curl -fsSL https://raw.githubusercontent.com/codetesla51/barrage/main/install.sh | bashThat grabs a prebuilt binary from GitHub releases — no Go toolchain needed.
Pin a version or change the target dir with
bash -s -- --version v0.6.3 --dir ~/.local/bin, or build from source with
--from-source (requires Go 1.25 or later). See ./install.sh --help for all
flags. Or build from source directly:
git clone https://github.com/codetesla51/barrage && cd barrage
go build -o barrage ./cmd/barrage
# fallback without a checkout:
# go install github.com/codetesla51/barrage/cmd/barrage@latestThe DB runner supports Postgres, MySQL, and SQLite out of the box;
because it sits on database/sql, any other driver can be linked in by adding
a blank import and registering its name. HTTP-only runs require no backing
services.
Then point config.yaml at your targets (see Configuration)
and run:
barrage run # runs config.yaml, writes report.html
barrage run -o # ...and opens the report in your browser
barrage run --no-report --json results.json # for CI, no browser needed
barrage compare --baseline base.json --current new.json # diff two runsThe report is self-contained: Chart.js loads from a CDN, but all run data is embedded in the page.
A 15s run against local targets:
$ barrage run -c config.yaml
barrage v0.6.3
duration 15s · bucket 1s · concurrency 10 · ramp 3s
rates http 10/s · db 5/s · redis 20/s
[barrage] done · │ http 135 0 err │ db 67 0 err │ redis 269 0 err
RUNNER REQUESTS SUCCESS RATE MEAN P50 P95 P99 MAX STATUS
http 135 100.0% 9.5/s 927µs 509µs 2.5ms 4.4ms 6.6ms 200×135
db 67 100.0% 4.5/s 12.9ms 5.5ms 69.0ms 136.2ms 136.2ms
redis 269 100.0% 17.9/s 797µs 396µs 2.3ms 3.5ms 10.6ms
correlated spikes
TIME RUNNER HTTP_P99 STORAGE_P99 NOTE
20:52:22 db <100ms 136.2ms db-only
Report written to report.html
$ barrage run --help
Flags:
-b, --bucket-width duration override the bucket width from the config
--capacity sweep concurrency (double, then fine fill) to find the break point
--capacity-max-concurrency int cap for the capacity sweep
--capacity-step-duration duration per-level burst time for the capacity sweep (default 10s)
--concurrency int worker count for the db/redis pools and http attackers
-c, --config string path to the config file (default "config.yaml")
--db-threshold duration DB spike threshold for correlation (default 100ms)
-d, --duration duration override the run duration from the config
--http-threshold duration HTTP spike threshold for correlation (default 100ms)
--json string also write a JSON summary of the run to this path
--no-progress disable the live progress view (plain log lines instead)
--no-report skip writing the HTML report
-o, --open open the report in a browser after the run
--ramp duration ramp the rate from 0 up to full over this duration
--redis-threshold duration Redis spike threshold for correlation (default 100ms)
--report string path for the HTML report (default "report.html")
-v, --verbose print per-bucket detail
Examples:
barrage run -c staging.yaml --duration 1m --ramp 10s --concurrency 50
barrage run --http-threshold 150ms --db-threshold 250ms --redis-threshold 80ms # adjust spike thresholds
barrage run --no-report --json results.json # for CI pipelinesEvery -- flag overrides its config counterpart.
A scenario run looks like this: the rates line names each journey with its
step count and weight, the runner column carries the scenario name, and there
is no STATUS column content (scenarios record success as 2xx-per-step, not
status histograms) — the header still prints, the cells are empty.
$ barrage run -c examples/scenario-login.yaml
barrage v0.6.3
duration 10s · bucket 1s · concurrency 10 · ramp 0s
rates login-flow 3 steps w=1
[barrage] done · │ scen 32,989 3 err
RUNNER REQUESTS SUCCESS RATE MEAN P50 P95 P99 MAX STATUS
login-flow 32989 100.0% 3298.9/s 3.030392ms 2.272002ms 8.125266ms 11.957243ms 35.747684ms
Report written to report.html
barrage compare diffs two runs produced by barrage run --json, so an earlier
baseline can be checked against a later run — the missing piece for CI gating
and regression checking across releases.
Runners present only in one side are labeled NEW (or counted as fixed) — they never show as regressions just because the baseline didn't have them, so renaming or adding scenarios mid-project doesn't produce false alarms. Spike diffs match by ordinal position per runner rather than wall-clock timestamps, since two runs never share a clock.
$ barrage compare --baseline base.json --current new.json --fail-on 100ms
comparing base.json -> new.json (fail-on 100ms)
RUNNER BASELINE_P99 CURRENT_P99 CHANGE VERDICT
DB 80ms 100ms +25% ok
HTTP 30ms 70ms +133% REGRESSION
Redis 20ms 22ms +10% ok
Flags:
--baseline string path to the baseline JSON report
--current string path to the current JSON report
--fail-on duration fail (exit non-zero) if a runner regresses above this latency budget (default 100ms)
-o, --open open the report in a browser after comparing
--report string path for the HTML comparison report (default "compare.html", empty skips it)
How it works:
- Per-runner diff. Each runner's P99 is compared baseline→current with a
percentage change. A runner is flagged REGRESSION when its current P99
exceeds the
--fail-onbudget while its baseline was at or under it, so a runner that was already slow isn't re-flagged every run. Any regression makesbarrage compareexit non-zero — the signal CI uses to gate a deployment. - Spike diff. Each correlated spike in both runs is classified as new, fixed, worsened, improved, or unchanged, so you can see both newly-introduced storage bottlenecks and ones that were resolved.
- Overlaid timeline. Both runs' per-bucket P99 latencies are aligned onto one label axis in the HTML report (baseline dashed, current solid), so you can see where in the run latency drifted.
- Exit code as CI gate. Combined with
barrage run --no-report --json baseline.jsonand--fail-on, you can make an unstaged regression fail a pipeline before it ships.
barrage version # print the version (from the git tag; `go run` builds show the dev default)
barrage help run # full flags for a subcommand; --help works on all of themShell completion (generated by cobra, nothing custom):
barrage completion bash > /etc/bash_completion.d/barrage
barrage completion zsh > "${fpath[1]}/_barrage"The capacity question, answered by experiment instead of guessing. A capacity sweep raises concurrency level by level, running a short burst at each, until latency breaks:
concurrency: 5 # sweep start
capacity:
max_concurrency: 160 # search cap
step_duration: 10s # burst per level, not the full durationbarrage run -c examples/capacity-pg.yaml
barrage run -c config.yaml --capacity --capacity-max-concurrency 160 --capacity-step-duration 10sHow it works: coarse doubling finds the rough zone fast (5→10→20→40…),
then a fine linear fill pins it down between the last ok level and the first
broken one (80→160 becomes 100, 120, 140). Each level runs a short burst;
DB and Redis connections open once and stay warm across levels so early
buckets measure strain, not reconnect cost. Paced runners (http/db/
redis) scale their rate with concurrency so bigger crowds push more load;
scenario load comes from the VUs themselves. A level breaks when any runner's
P99 crosses its threshold or success drops under 95% — the verdict names the
culprit (CAUSE db, http,redis, …), and the report charts concurrency vs
P99 so you see a cliff or a slope, not just one number. ramp: and
duration: are ignored while a sweep runs (set them 0s/anything; the
loader still requires the keys). The same mode is available as
--capacity / --capacity-max-concurrency / --capacity-step-duration
flags; the pre-0.6 names auto_ramp: and --auto-ramp/--ramp-max-concurrency/
--ramp-step-duration still load with a rename warning.
Same-machine caveat: running the generator on the same box as the app, database, or Redis means all of them fight for the same CPU — the break point you find is the machine's, not the system's. Good enough for comparing configs and finding the culprit layer; not a production capacity number. For a real one, generate load from a separate machine.
report.html contains:
- Verdict — one plain-words line (broke at N users / clean run), the bottleneck, and next steps. Always rendered; empty runs explain why.
- Run summary — requests, success %, P50/P95/P99/max/mean, rate, throughput, and the HTTP status-code histogram for each runner.
- Correlated spikes — a table of flagged buckets (runner, per-bucket P99 values, bottleneck verdict) and an overall "bottleneck lean" readout.
- Latency timeline — every runner's per-bucket P99 on a shared x-axis so storage and HTTP latency can be compared directly.
- Capacity sweep (sweep runs only) — concurrency vs P99 with the break point, per-level verdicts, and the error buckets naming what failed.
- Export JSON button — in the top bar; downloads the run as the same JSON
the
--jsonflag writes, so a report opened in a browser can still feed a dashboard or a CI comparison.
What those two screenshots show: a 3-minute heavy run against the TodoAPI stack (Gin + Postgres + Redis) — GET /api/todos over HTTP at 120/s, a weighted read/write query mix against Postgres at 80/s, and Redis commands at 300/s, with a 60s ramp and concurrency 50, generator and stack on the same machine. With the app's rate limiter at production settings it absorbed nearly the whole HTTP burst as 429s — the API stayed flat at ~5ms p50 while the real load landed on the data stores. With the limiter boosted, every request reached the backend: Postgres saturates and drags HTTP P99 to multi-second territory, while Redis stays under 100ms P99. One bottleneck, three correlated curves.
The JSON export mirrors this structure: generated_at, duration, ramp,
concurrency, per-runner metrics (latencies in milliseconds), correlated spikes
(each with runner, http_p99_ms, storage_p99_ms, and masked), the
timeline, and — when the run was a capacity sweep — the capacity_search
curve (per-level concurrency/requests/p99/success, break_at, last_ok).
Capacity steps also carry an errors map when scenario steps failed at that
level (e.g. {"dial_timeout": 5120, "5xx": 1800}), so the breaking run's
story answers what failed, not just how much. In
the timeline's p99_ms series, -1 marks a bucket where that runner had no
request (e.g. before the ramp produced its first hit); the report chart renders
these as gaps, not as a latency of -1ms.
- All runners' buckets are aligned by their unix start time
(
HTTPBucket.Start.Unix()== storageBucket.Start). - Each storage runner — DB and Redis — is checked independently against the
HTTP run. A bucket is flagged when the storage runner's P99 exceeds its
threshold, and the spike is either correlated (HTTP also crossed
http-threshold, labeled with a bottleneck verdict) or masked (storage spiked while HTTP stayed under its own). - Masked spikes are still reported so a storage bottleneck that does not yet
back up the application is surfaced. The CLI marks them
db-only/redis-only, the HTML report tags themDB (masked)/Redis (masked), and the JSON export setsmasked: true. A bucket where both DB and Redis spike produces two rows.
Thresholds default to 100ms each and apply per runner (--http-threshold,
--db-threshold, --redis-threshold). In scenario mode the app-side
reference is synthesized from the worst per-bucket journey latency.
HTTP-only buckets are deliberately not flagged: a slow endpoint that leaves the data stores idle is an application problem, not a storage problem.
scenario: runs sequential HTTP steps per virtual user — it is your HTTP
load, in journey form instead of single shots. Each VU picks one scenario
once at launch (weighted by weight), then loops it until duration
expires. extract maps a var name to a JSON path ($.token,
$.user.id via gjson); the value is stored per VU and {{var}} is
interpolated into later step url, body, and headers. Missing vars stay
as {{var}} so misconfig is visible; non-JSON or missing paths leave the
var unset.
scenario: cannot be combined with http: — and that is deliberate, not a
limitation. Correlation needs exactly one app-side reference timeline per
bucket: either the http runner's P99, or the worst journey P99 synthesized
from scenarios. Two app curves would double-count rates, progress, and every
verdict. To mix plain hits with flows, model the plain hit as a one-step
scenario. Note rate also means different things per runner (http/db/
redis are paced per-second targets; scenario throughput emerges from VUs
looping). Scenarios can run alongside db/redis — buckets use the same
Start.Unix()/bucket_width scheme so timelines align.
rateis the target rate. Ifconcurrencyis too small to keep up, the pool backs up and throughput settles below target. This is intentional: a real load test should expose the target's limits rather than silently serializing requests.concurrencyfor HTTP maps to vegeta'sMaxWorkers; unset (0) lets vegeta scale workers on its own. For DB and Redis it is the pool size; 0 selects the default of 10 workers. The run header reports which mode is in effect.rampschedules hits so the rate grows linearly from 0 to full across the window (a 3s ramp at 2000/s fires roughly 3000 requests during the ramp, then holds 2000/s). With noramp, the full rate applies from the first request.- Weighted mixed queries. One query is picked per request, weighted, so a config can mix reads and writes the way real traffic does. The cumulative weight table is built once per run (v0.6.4+): building it per request cost O(n) alloc+scan each time, so a 10k-entry list at 2k req/s melted the generator and the timeouts were misread as database failures.
- Read/write routing. Each DB query's
typefield is authoritative (readruns throughQuery,writethroughExec); untyped queries fall back to a heuristic on the SQL text. - Real parallelism. Requests are submitted to a worker pool, so
rateis not a serial request stream.
Engines per runner:
| Runner | Engine | Parallelism |
|---|---|---|
| HTTP | vegeta attacker | vegeta workers (bounded by concurrency) |
| DB | database/sql + pond worker pool |
concurrency workers |
| Redis | go-redis client + pond worker pool | concurrency workers |
The DB and Redis runners pace requests at rate per second, submitting each to
a pool capped at concurrency workers. Results carry the submission timestamp,
so buckets reflect when load was generated, not when responses completed.
A command still in flight when the run ends is aborted rather than counted
against the target: DB reads abort cleanly (no side effects) and are not
failures, while DB write errors still surface since the write may have
executed server-side. Redis commands aborted at shutdown before the server
answers (context canceled) are likewise not failures; any other Redis error
is a real failure.
Runs are no longer silent. Barrage prints one structured status line every 5s:
00:45/03:00 │ http 3,900 · 0 err │ db 900 · 0 err │ redis 1,350 · 0 err
with thousands separators, an mm:ss clock, and semantic colors (amber counts,
red error counts). A totals line lands when the run completes. Pass
--no-progress for plain log lines (CI default in the reference workflows).
The default config file is config.yaml. Any subset of http, db, and
redis is valid; at least one section is required. Durations use Go's
time.ParseDuration format (10s, 1m30s, 500ms). Unknown keys are
rejected so a typo fails loudly instead of being silently ignored.
duration: 15s # how long to run
bucket_width: 1s # correlation/timeline bucket size
ramp: 3s # ramp rate from 0 to full over this window (0 = no ramp)
concurrency: 10 # in-flight requests per runner (db/redis pools, http workers)
http:
rate: 10 # requests per second
target:
method: POST # default GET
url: http://localhost:8080/api/orders
body: '{"customer": 42}' # optional request body
header: # optional; value is a string or list
content-type: [application/json]
authorization: [Bearer some-token]
db:
rate: 5 # queries per second (total, across the weighted list)
target:
driver: postgres # postgres | mysql | sqlite (aliases accepted, e.g. postgresql, sqlite3)
conn: postgres://user:pass@localhost:5432/mydb?sslmode=disable
queries: # one query is picked per request, weighted
- query: SELECT id, customer, amount FROM orders WHERE customer = 'customer-4242' ORDER BY id DESC LIMIT 20
weight: 20
type: read
- query: SELECT customer, amount FROM orders LIMIT 10
weight: 20
type: read
- query: SELECT amount FROM orders WHERE id = 1
weight: 15
type: read
- query: INSERT INTO orders (customer, amount) VALUES ('load', 1)
weight: 25
type: write
- query: UPDATE orders SET amount = amount + 1 WHERE id = 1
weight: 20
type: write
redis:
rate: 20 # commands per second
target:
addr: localhost:6379
password: "" # optional
db: 0
queries: # one command is picked per request, weighted
- query: PING
weight: 1
# scenario: sequential user journeys (list) (alternative to http/db/redis, can run with db/redis)
# NOTE: scenario replaces the http runner — you cannot combine `scenario:` with
# an `http:` section in the same config. A scenario is your HTTP load: to mix
# plain hits with flows, model the plain hit as a one-step scenario.
scenario:
- name: login-flow
weight: 1 # pick weight, default 1
steps:
- method: POST
url: http://localhost:8080/api/login
body: '{"user":"alice"}'
headers:
Content-Type: application/json
extract:
token: $.token # pull $.token from JSON response into Vars
- method: GET
url: http://localhost:8080/api/me
headers:
Authorization: Bearer {{token}} # {{var}} interpolated per virtual user
- method: GET
url: http://localhost:8080/api/checkout?token={{token}}typeon each DB query is authoritative for read/write routing:readruns throughQuery,writethroughExec. If omitted, routing falls back to detecting the SQL text (SELECT / SHOW / EXPLAIN / WITH → read; any query containing aRETURNINGclause → write). Prefer an explicittype; detection is a heuristic. The distinction also shapes shutdown accounting: when the run ends mid-query, a cancelled read counts as cleanly aborted (no side effects), while a write's error is surfaced — it may already have executed server-side.args(optional) is scoped per query, not global: it binds parameters for that query only. Omit it entirely when the query has no placeholders.driverselects the database backend:postgres,mysql, orsqlite(pure-Go, no CGO). Common aliases are normalized (postgresql/pg→postgres,sqlite3→sqlite), and an unsupported name fails loudly with the list of compiled-in drivers. Each driver expects its own connection DSN: Postgrespostgres://..., MySQLuser:pass@tcp(host:3306)/db, SQLite a file path such as/tmp/test.db.max_open_conns,max_idle_conns,conn_max_lifetime,conn_max_idle_time(all optional, underdb.target) tune thedatabase/sqlconnection pool. Unset counts default to the run'sconcurrencyso the tool never holds more connections than it has workers; unset lifetimes leave the driver default.max_open_conns: -1removes the open-connection cap entirely (database/sqltreats 0 as unlimited); with an unlimited cap an unsetmax_idle_connsdefaults toconcurrency, becauseSetMaxIdleConns(0)means zero idle connections, not unlimited. Values below-1are rejected. Otherwise, setmax_open_connsat or below the database'smax_connectionsor the errors you measure are the tool's, not the target's.capacity:replaces a single run with a search:max_concurrencycaps it,step_duration(default 10s) sizes each level's burst. Start is the run'sconcurrency. While it runs,ramp:andduration:are ignored — bursts force the inner ramp off and usestep_durationinstead.auto_ramp:is accepted as a deprecated alias forcapacity:.
A reference backend + load stack for exercising every runner on one clock without touching a real service. Everything lives in this repo:
cmd/demoserver— an HTTP app on:8080written to behave like a real backend:POST /api/login— verifies a bcrypt password hash against a seededuserstable and returns an HMAC-signed token (AUTH_SECRET, 15 min expiry); repeat logins reuse the live session (bcrypt once per session, then a fast HMAC password check — no recompute)GET /api/me— validates the Bearer token (signature + expiry)GET /api/products/GET /api/orders— read routes served from a short-TTL Redis cache (REDIS_ADDR) with an indexed Postgres fallback; the orders list scans only the primary key for the newest 20 rows — no full-table count over the seeded 1M rowsPOST /api/orders— INSERTs one row, invalidates the cached listGET /api/checkout,GET /health
cmd/seeddb— bulk-seeds anorderstable (COPY, 100k-row chunks), adds the indexes the read paths rely on (customer, id DESC,created_at), and seeds login users (alice/bob/carol, passwordsecret) with real bcrypt hashes:
go run ./cmd/seeddb -conn "postgres://user:pass@localhost:5432/mydb?sslmode=disable" -n 1000000docker-compose.yml+docker/configs/— the whole stack containerized.docker compose up --buildstarts postgres + redis, seeds, brings up the app, then runsdocker/configs/demo.yaml; results land in./reports/. Capacity profiles (lifecycle.yaml,full-app.yaml,real-app.yaml) sweep concurrency 5→200 with journeys, DB, and Redis all on one clock. The same stack runs on a GitHub runner via.github/workflows/demo-stack.yml(manual dispatch,profileinput selects the config,./reports/uploaded as an artifact).
Ready-to-run profiles live in examples/, all targeting the demo
server on :8080:
| File | What it shows |
|---|---|
light.yaml |
gentle baseline: HTTP + Redis at ~15 req/s |
heavy.yaml |
stress profile: HTTP + SQLite + Redis at ~4x light, higher concurrency |
scenario-login.yaml |
single journey: login, extract token, interpolate into later steps |
scenarios-weighted.yaml |
multiple journeys with weights (browse vs checkout traffic mix) |
scenario-full.yaml |
full stack: weighted journeys + SQLite (with pool caps) + Redis on one clock |
capacity-pg.yaml |
capacity sweep against Postgres |
docker/configs/demo.yaml |
containerized 15s flat run: HTTP + Postgres + Redis |
docker/configs/flat-full.yaml |
containerized 20s flat run: journeys + DB + Redis (per-runner tables) |
docker/configs/full-app.yaml |
containerized sweep: journeys + DB + Redis, 5→200 |
docker/configs/lifecycle.yaml |
containerized sweep: journeys + DB + Redis, lifecycle mix |
docker/configs/real-app.yaml |
containerized sweep: journeys only, zero synthetic load |
docker/configs/scenario.yaml |
containerized 15s flat run: journeys only |
Run any of them against the demo stack:
barrage run -c examples/scenarios-weighted.yamlFlat profiles (demo.yaml, flat-full.yaml, scenario.yaml) produce
per-runner tables, correlated spikes, and the latency timeline. Sweep profiles
(full-app.yaml, lifecycle.yaml, real-app.yaml) produce the verdict plus
the concurrency-vs-P99 chart — no per-runner aggregates by design.
Four progressive sweeps of the same journey mix (browse / account-check / checkout-flow / login / health) against the same 2-vCPU GitHub runner. The lesson is not the numbers — it is that each fix moved the boundary and changed who was blamed:
| Stack state | Knee (users) | First victim | Why |
|---|---|---|---|
original (stub login, SELECT count(*) over 1M rows) |
0–1 | DB | the full-table scan parked DB P99 at the 100ms line doing nothing |
| + Redis cache on the read routes | 3 | DB | reads offloaded, but the synthetic DB runner still blasted the store |
| + real schema, indexes, real bcrypt login | 10 | app | the silly query is gone; the app's own CPU (bcrypt + writes) is now the wall |
| same, VUs only (zero synthetic db/redis load) | 7–8 | app | removes out-of-band load; the app is still the bottleneck |
| + session reuse on repeat logins (v0.6.0+) | 12–13 | app | bcrypt once per session, not per login; one observed run held 12, broke at 13 |
The tails at the last good level tell the same story in one line: 103ms →
75–89ms → 22–96ms → 41–64ms → 74ms, with success held at 98–100% throughout —
every break was a latency crossing, never an error storm. The v0.6.1 error
buckets on the 12–13 run back that up: zero dial_timeout/connection_refused/
conn_reset on any level — the app stayed reachable from first level to last.
Every run above happened on a GitHub Actions runner: 2 vCPUs, the whole stack (load generator + app + Postgres + Redis) sharing one box, on a VM whose resource allocation is not guaranteed between runs. Consequences:
- Knees wobble run to run — the same profile measured 7 users one day and 10 the next. Causes include transient VM allocation, cache-expiry timing (the 5s TTL thundering-herd collapses isolated levels), and bursty VU firehose throughput.
- Absolute numbers are relative. "~7K req/s ceiling" and "holds ~10 users" describe that box on that day. They are for comparing configs and finding the culprit layer — not a capacity spec.
- "Slow" is a budget you set. A P99 that crossed this run's 100ms bar might be perfectly acceptable under a 200ms SLO. The tool finds where your line gets crossed; it does not decree the line.
- For a real capacity figure: load from a separate machine on stable, pinned hardware, and repeat. Same-machine sweeps prove shape and culprit — not scale.
The single-box workflow proves shape and culprit; this removes the last
shared-box doubt. .github/workflows/demo-stack-separated.yml
runs the stack on one runner VM and the load on a second runner VM, joined
by a free cloudflared quick tunnel (no account, no inbound port):
gh workflow run demo-stack-separated.yml -f profile=real-app.yamlRead the demo-reports artifact exactly like the single-box runs — the
capacity_search.steps[].errors buckets name what broke each level. Two
caveats keep the numbers honest:
- VM-level, not node-level, separation. GitHub schedules the jobs on distinct runner VMs; the underlying hardware is still shared cloud capacity, so results stay relative.
- The tunnel adds a latency floor — every request round-trips through
cloudflare's edge — that applies evenly to every level, so the sweep
shape still reads. If the app job's hold window ever runs out mid-sweep,
the errors buckets show the cut unmistakably: a
dial_timeout/conn_resetflood instead of a5xx/latency story.
Use it for:
- Investigating why an API is slow (is it the app, the database, or the cache?).
- Testing database bottlenecks: missing indexes, connection-pool limits, query plans.
- Comparing infrastructure changes before/after a migration or tuning pass.
- Performance regression testing across releases: run a baseline, change the
code or infra, run again, and
barrage comparethe two JSON exports — with--fail-on, a regression fails the pipeline.
Not the right tool for:
- Browser/E2E testing — no browser, no DOM, no UI assertions.
- WebSocket / streaming traffic.
- Distributed cloud load — it runs from one process; scale vertically, not across regions.
Those tools excel at generating load. Barrage is built around interpreting it:
- k6, JMeter, Locust — script complex user journeys and report rich metrics, but each generator runs independently. Correlating an API slowdown with the database or cache behind it is left to you.
- Vegeta — a focused, high-performance HTTP load generator. It tells you how the endpoint behaved, not why.
Barrage is narrower on purpose: it generates HTTP, database, and Redis load in one process and aligns every layer onto one timeline. Where a typical load tester reports a single latency curve, Barrage reports three — and tells you which layer spiked.
| Feature | Barrage | Typical Load Tester |
|---|---|---|
| HTTP load | Yes | Yes |
| DB load | Yes | Usually no |
| Redis load | Yes | Usually no |
| Scenario user journeys | Yes | Varies |
| Correlate latency | Yes | No |
| Compare runs / CI gate | Yes | No |
| HTML report | Yes | Varies |
go test ./... # unit + integration (miniredis for Redis, httptest for HTTP)
go vet ./...Tests cover the ramp schedule, pool pacing, read/write detection, config
parsing (including unknown-key rejection), correlation, report rendering, and
JSON export. report.html is a build artifact and is intentionally not
committed. The report template (templates/report.html) is embedded in the
binary via go:embed, so reports render from any working directory; a template
file at templates/report.html alongside the binary overrides the embedded one.
Cutting a release is one command — no hunting for the version string:
./scripts/release.sh v0.6.3It bumps the version everywhere (the internal/version source of truth, the
install pin in install.sh, and the README/SKILL examples), runs the
build/vet/test/gofmt gate, commits as chore(release), tags, and pushes. CI
then cross-compiles the platform binaries and publishes the GitHub release
with generated notes. The binary's embedded version always comes from the git
tag (ldflags), so the package-var default only shows for local go run builds.
AI agents: read SKILL.md first — bottleneck-hunting workflow
(inspect project, ask user, never assume), YAML construction, result
interpretation, and repo house rules. It is the single skill for barrage;
the landing page (../barrage-landing) points back here too.

