A load test measures your app from the outside. It can tell you that joins got
slow at 40,000 users, but not whether that was the CPU, the database or one
overloaded process. With --probe, Stampede watches the app from the inside
during the test, and puts both views in the same report.
Your app doesn't need any changes or dependencies. All it takes is a running BEAM node that Stampede can connect to.
Connecting to your app
Start the app with a node name and a cookie, then run the test from a node with the same cookie:
$ elixir --name app@127.0.0.1 --cookie secret -S mix phx.server
$ elixir --name loader@127.0.0.1 --cookie secret -S mix stampede.run loadtest/lobby.exs --probe app@127.0.0.1
From code, it's an option, with one node or a list of them:
Stampede.run(MyApp.Load.Lobby, probe: :"app@127.0.0.1")A release gets its name and cookie from the environment:
$ RELEASE_DISTRIBUTION=name RELEASE_NODE=app@10.0.0.5 RELEASE_COOKIE=secret bin/my_app start
Nodes find each other through epmd on port 4369, then talk on a port the VM
picks at random. Behind a firewall or in a container, fix those ports and open
them to the load test, as in the
guide to spreading the load.
A node that knows the cookie can run any code on your app, so only do this on a private network, with a long random cookie. If you're probing production, be sure that's what you want.
What it does to your app
While the run lasts, a small agent runs on the app's node:
- Stampede loads the agent's code onto the node, and removes it afterwards.
- It turns on the VM's scheduler statistics, and attaches telemetry handlers for Phoenix, LiveView and any Ecto repos it finds.
- Every second, it reads the node's numbers. Every 10 seconds, it looks for long message queues.
If the load test crashes or the network goes away, the agent stops by itself.
Watching costs a little. Against one Phoenix server holding hundreds of thousands of connections, the server's CPU was about 6 points higher with the probe on. If you need the exact maximum, run it once without.
What you get
The summary gets a server section with the node at its busiest, and the
dashboard and the HTML report chart it second by second:
server
app@10.0.0.5 at its busiest: schedulers 87% busy, 27.85 GB of memory, 1,200,484 processes, run queue 717And there are new metrics, timed by the app itself:
| Metric | What it is |
|---|---|
server.http | HTTP requests, from Plug.Telemetry |
server.channel.join, server.channel.event | Channel joins and events |
server.live.mount, server.live.event | LiveView mounts and events |
server.ecto.query, server.ecto.queue | Ecto queries, and how long they waited for a connection |
They work like any other metric, so you can set thresholds on them:
thresholds: [{"server.ecto.queue", p95: 50}]Your database
When your app has an Ecto repo, the probe also sends how long queries waited for a connection from the pool, every second. When that repo uses Postgres, it reads Postgres's own statistics every second too: commits and rows written per second, rows read, the cache hit rate, connections, and what active queries are waiting on.
You don't give it a database URL. The agent uses your repo's own settings to open one connection of its own, outside your app's pool, so it doesn't take a connection your app needs. It's closed when the run ends.
The summary gets two more lines, and the dashboard and the HTML report chart the pool wait, commits and writes per second, and active queries:
database at its busiest: 2,454 commits/s, 1,156 rows written/s, 34,000 rows read/s, 11 connections
database pool of 10: queries waited up to 300.00ms for a connectionWhen a --find-limit step fails, its verdict says what the database was
doing, from what it measured:
limit
Held 250 VUs. At 350 VUs, iteration.errors count was 64, over 0. Queries waited up to
171.94ms for one of the database pool's 2 connections. Postgres was mostly busy running
queries. The app's schedulers were only 16% busy, so the database was the limit, not the app.It names the wait Postgres spent most of its time on, such as WALWrite,
writing its log to disk before each commit, or DataFileRead, reading table
data from disk, or locks held by other queries. It only says so when queries
were really waiting on it, and pool waits under 50ms aren't mentioned.
The probe connects as your app's database user, so it sees what your app's
queries are waiting on. Sessions of other database users only show their waits
to a superuser or a member of pg_read_all_stats. Databases other than
Postgres only get the pool wait for now.
In LiveDashboard
If your app has Phoenix LiveDashboard, it can show the run too. Add Stampede to the app's dependencies, in the environments where you load test it, and its page to the dashboard's route:
{:stampede, "~> 1.4", only: [:dev, :test]}live_dashboard "/dashboard",
additional_pages: [stampede: Stampede.LiveDashboard]A run that probes the app shows in the dashboard's Stampede tab while it runs, with the same charts and server numbers as Stampede's own dashboard. When the run is over, it stays there with its full results, even after the load test's node goes away. The tab keeps the last 20 runs.
That puts the load next to everything else the dashboard shows. Watch the Processes page sorted by message queue while the users climb, and you can see which process falls behind first.
Finding the bottleneck
| What you see | What it usually means |
|---|---|
| Schedulers near 100%, run queue climbing | The node is out of CPU |
| Memory climbing with users, then a crash | The node is out of memory. Work out the memory per connection, and size for it |
| One process with a long message queue | That process can't keep up, and everything waiting on it slows down |
server.ecto.queue growing while server.ecto.query stays flat | The pool is too small, or queries hold connections too long |
Pool waits climbing while Postgres waits on WALWrite | Every commit waits on the disk. A bigger pool lets more commits share a flush, and a faster disk helps too |
| Postgres mostly running queries, with few waits | The database is out of CPU, or a query needs an index |
| The app's own timings are fast, but the load test's are slow | The time is going somewhere else, such as the network, a proxy, or the load test itself |
When a --find-limit step fails, its verdict says if the server's schedulers
ran out, if the server went down, or if the database pool or Postgres was the
limit, and warns you if the load test was the one too busy to trust.
Past 20,000 processes, the agent only checks the named processes for long queues, plus a random sample of a thousand others. Asking a process for its queue wakes it, and waking every connection's process would change what you're measuring. Most real bottlenecks are named anyway, such as a PubSub or a pool.
If your app can't accept a distribution connection at all, --probe won't work
for now. That's on the roadmap.