omarmhaimdat/pepe
Find why an HTTP endpoint is slow and prove a fix: ping it by phase, load-test it, see which operation inside the service the time goes to (from its OpenTelemetry spans), and compare before and after. A skill and pepe's MCP server, under guardrails.
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
0.51.0 - 2026-10-18
Added
-
(slack) a quiet thread: what changed goes in the message, and @pepe answers questions there (#213)
Feedback from using it: a reply in the thread for every anomaly was too many messages. The data now lives in one denser message, and the thread is for questions.
The live message
It's still edited every 10 s, and now also immediately when something changes or someone steers the run.
Six fields:
- Throughput: current value, the run's peak, and which way it has moved lately.
- Latency: p50 · p95 · p99, with a sparkline.
- Errors: by cause, with the first body the server sent.
- Where the time goes: waiting for the first byte versus reading the body.
- Sent: requests and bytes received.
- Load: connections in flight and the rate cap, plus who changed it last.
What changed: each anomaly is one timestamped line in the message, latest five, e.g.
`1m26s` 📉 Throughput dropped 52% · 5.9k req/s. The verdict lists them too.The thread gets nothing for anomalies. The only unprompted thread message left is the single result line at the end. It's also shown in the channel and carries the
mentionwhen a run fails.Ask in the thread
With
pepe slack serverunning, mentioning@pepein the thread of a run it started gets an answer there. Topics: p99, errors (what failed, what the server said, when it began), slowest, where does the time go, codes, compare (against the test's last recorded run), status, help.- While the run goes, the run answers from its live numbers.
servepasses the question over the control channel; the run toldserveits message id on stderr when it posted the message. - After it ends,
serveanswers from the recorded report, compared with the run before it. - A mention anywhere else gets a note on where to ask.
The manifest adds
app_mentions:readand theapp_mentionevent. An existing app needspepe slack connectagain.Checked
- 407 tests pass; clippy 1.98 is clean. New tests cover:
- the format of the "What changed" lines;
- the live message's six fields and its "What changed" list;
- matching a question to its topic, and each topic's answer;
- the
askcommand on the control channel.
- Against the fake Slack (which rejects repeated action ids) and a fake Socket Mode server:
- "why are errors up?" and "p99?" mid-run were answered in the live
message's thread;
- the live edits were accepted;
- "compare" after the stop was answered from the recorded report;
- a mention in another thread got the note;
- no anomaly replies were posted.
0.50.0 - 2026-10-18
Added
-
(export) the report as a page, a summary, text or CSV: F4 on any run screen, --export FILE, pepe export (#211)
The report in the shape someone needs it, from the screen, the command line, or a saved report. Built to the design bar for user-facing output.
pepe -z 5m -c 50 --fail-if 'p99 > 300ms' --export checkout.html --export checkout.md https://api.example.com/checkout pepe export report.json --to allOn any run screen (dashboard, ramp, ping), F4 opens the export page, during the run or after it.
The five shapes
The file's extension picks the shape.
.html: one self-contained page, about 15 KB. It loads nothing and runs no script, and it follows the reader's light or dark setting. It contains:- Pepe's face for the verdict;
- the run summed up in a sentence, and the key numbers;
- the run's curves second by second, with each anomaly marked on them;
- the latency spread from p50 to the max;
- where the time went;
- each
--fail-ifwith the measured value; - for a saved test, a before/now table against its last recorded run;
- findings with the first body of each kind of failure;
- status codes, endpoints or steps, and the slowest requests.
A ramp's page plots throughput and p99 against concurrency, with a table of steps.
.json: the report itself..md: a summary for a pull request or an issue: verdict, a table of numbers, thresholds, comparison, findings, command..txt: what pepe prints when you quit..csv: one row per second for a run, per step for a ramp, per target for a ping.
Where you get them
- F4 on the dashboard, ramp and ping screens, during the run or after:
- save any one shape, or all of them, to
exports/(Tab changes the location);- optionally open the page in the browser;
- copy the Markdown summary;
- send the verdict to the run's webhooks now.
It exports the run as it stands at that moment. The footer shows "F4 export" on those screens.
--export FILE, repeatable, orexport = [...]inpepe.toml. The file is written when the run ends, at the same point as the Slack verdict, so it has the same thresholds and comparison.pepe export REPORT.json [-o FILE] [--to html|json|md|txt|csv|all] [--open]for a report already written. Saved reports only keep per-minute points, and they have no thresholds or comparison.
--htmlandpepe htmlfrom the first commit here became--exportandpepe export. Neither was ever released.Also
A ramp's findings are named
title, nottext, so the Slack verdict for a ramp was dropping them. Fixed and tested.Checked
- 403 tests pass; clippy 1.98 is clean. New tests cover the page (run and ramp), the Markdown summary, the text fallback, the CSV, format-from-extension, file names, and the export page's keys (save one, save all, Where, close).
- Pages from real runs against the bench server, rendered in headless
Chrome in light and dark. Fixed after looking:
- an empty request-id column;
- the version wrapping mid-number;
- "against" said twice in the comparison heading;
- no title on the ramp page.
- The F4 page driven on the real dashboard in a pseudo-terminal:
- opened mid-run;
- "Everything" wrote all five files;
- after the run ended, the Markdown summary saved correctly.
--exportwith three files,pepe export --to all, and unknown extensions, which are refused with the reason.
0.49.1 - 2026-10-18
Fixed
-
(slack) the live message is posted again: − 1 and + 1 had the same action id, which Slack refuses (#209)
Found by running #207 against a real workspace.
The bug
A run's controls had two buttons with the action id
pepe:size(− 1 and- 1). Slack refuses a block whose buttons repeat an id
(
invalid_blocks). The consequences:
- the run's live message and all of its updates were rejected;
- its thread replies are plain text, so they went to the channel on their own.
Nothing told you about it: the rejection went to the run's stderr, which
serveonly showed when a run failed to start.Fixes
- Distinct ids: − 1 is
pepe:lessand + 1 ispepe:more.pepe:sizeis still accepted. A test checks that each id appears once in the row. - Warnings in the terminal:
pepe slack servestreams a run's warnings as they happen (a message Slack refused, a hook that failed). - The card follows the run:
- after ▶ Run, the test card turns into the run's controls (Pause, Stop);
- Run on a test that's already going answers with those controls instead
of "stop it first";
- the edit form's run answers with the card too.
- Trend and wording: the trend shows only with three runs or more, and a single run reads "1 run, just now".
Checked
- 397 tests pass; clippy 1.98 is clean.
- The fake Slack now refuses repeated action ids, as Slack does. With
it, the full scenario passes:
- the card turned into the controls after Run;
- the live message posted with − 1 (3) and + 1 (5);
- every edit was accepted: pause, a concurrency change, resume, stop;
- the verdict followed.
- 1). Slack refuses a block whose buttons repeat an id
(
0.49.0 - 2026-10-18
Added
-
(slack) pause, resize and stop a run from its message, and /pepe as a dropdown of the saved tests (#207)
Builds on #205: richer commands, and control over a run while it goes.
While a run goes
A run started from Slack gets these controls on its message:
- ⏸ Pause / ▶ Resume. While paused, the message turns grey and says who paused it.
- − 1 / + 1 concurrency. Each button shows the value it sets, e.g. "+ 1 (51)". At 1 there is no − button. A ramp sets its own concurrency, so it gets Pause only.
- ■ Stop, with a confirmation.
Every change shows in the message at once ("<@maya> raised it to 51 at once"), instead of at the next 10 s update.
Shorter thread replies
Each change during a run is one short line in the thread, saying what moved and which way:
📉 Throughput dropped 52% · 5.9k req/s · at 1m26s📈 p99 up 4.5× · 45ms · at 26s🔥 Errors up to 12% · at 2m(posted with Pepe's on-fire face)
How it works:
servesends commands over the stdin of the run it started (src/control.rs:pause U1,concurrency 63 U1,stop U1).- Every
--jsonrun loop listens for them whenservestarted it: run, flow, API, replay and ramp. They're applied the same way as the dashboard's keys. - A stop the run can't hear falls back to a ctrl-c signal.
/pepe
/pepe,/pepe list, or/pepe runwith no name opens a card: the saved tests in a dropdown, plus what's running with its controls.- Picking a test, or
/pepe NAME, shows that test's card in place:- what it sends;
- its last run;
- its trend over the last eight runs, as sparklines;
- Run, Edit and run, History.
- History is one aligned row per run: outcome, when, req/s, p99, errors.
/pepe statusshows what's running.- Runs started from Slack are now recorded, so the history and trend stay current.
Checked
- 397 tests pass; clippy 1.98 is clean.
- Against the fake Slack and fake Socket Mode, with a 20 s saved test:
/pepeanswered with the dropdown card.- Picking a test replaced the card in place.
- Run started the test.
- Pause: the message turned grey with Resume and "<@U1> paused it".
- +1 raised concurrency and recomputed both buttons. (Exercised with the earlier 25% steps; the ±1 buttons and the thread lines are covered by unit tests.)
- Resume restarted it.
/pepe statusshowed the running card.- Stop ended it: the verdict appeared with its buttons, followed by the result line.
- History listed the run, recorded.
Not checked against a real Slack workspace.
0.48.0 - 2026-10-18
Added
-
(slack) Stop, Run again and Edit and run on the messages, and /pepe, answered by pepe slack serve (#205)
Part 3 of the Slack work: run saved tests from the channel.
pepe slack connect # steps 5 and 6: the app token, and who may press the buttons pepe slack serve # in the project, left runningWhat it adds
- On the messages:
- a saved test's verdict gets ↻ Run again, ✎ Edit and run and History;
- a run started from Slack gets ■ Stop while it goes, with a
confirmation;
- the line under the title says who started it.
/pepe:run NAME,stop,history NAMEandlist, in any channel the app is in.pepe slack serve: the one listener. Slack sends every click of an app to one of its Socket Mode connections, not to the run that posted the message, so a single process answers them all. It:- runs this directory's saved tests as
pepe --load NAME --slack=<channel>child processes, under this directory'spepe.tomlguardrails; - stops the runs it started the way ctrl-c does, so the verdict still says what was measured;
- opens the edit form, filled from the saved test, and shows a bad value
beside its field;
- logs each click, run and result in its terminal;
- reconnects with backoff.
- Who may use it:
pepe slack allow @a @b(or*) sets the list; names are looked up in the workspace;- anyone else gets a refusal only they can see;
- nothing can be run from Slack that isn't a saved test in that directory.
pepe slack connectgains two steps:- 5, the app-level token, checked by opening a Socket Mode connection,
or skipped with Enter;
- 6, who may press the buttons.
The manifest turns on Socket Mode, interactivity and
/pepe, and adds thecommandsandusers:readpermissions. An app made by an earlier pepe needspepe slack connectrun again.--slackreplaces a saved test's own Slack hook, keeping itseveryandmention, so a run started from Slack still gets one message, not two.src/ws.rs: pepe's own websocket client. It handles the handshake over the same rustls, masked text frames, fragments, pings and close. No new dependency.
Checked
- Rust tests (395 pass):
- the websocket client against a test server: messages both ways, a
message split in three, a ping answered, close;
- how clicks and commands are read;
- the allow list;
- turning the form's values into flags, and the errors for bad values;
- the form filled from a saved test;
- buttons only where something answers them.
- Lint: clippy 1.98 is clean, and the docs and avatar checks pass.
pepe slack serveagainst a fake Socket Mode server (Python, standard library), which scripted:
- Run again from an allowed user. The child posted its message with Stop.
/pepe stop. The run ended "Stopped", its verdict showed the three buttons, and the result line followed.- A click from someone not allowed. The refusal went to
response_urlonly. - The edit form with concurrency 0. The acknowledgement carried a field error.
- Edit and run. The form opened (
views.open). /pepe list.
When the fake closed, serve reconnected with backoff.
- The six-step connect checklist was driven in a pseudo-terminal.
slack.tomlkept the app token and the allowed id.
Not checked against a real workspace:
- Socket Mode against Slack's own servers;
- the slash command registered from the manifest;
- how the form and the buttons look in Slack.
1.0
Adds
pepe slack serve,pepe slack allowandPEPE_SLACK_APP_TOKEN. That restarts the freeze clock. The report schema is unchanged.
0.47.0 - 2026-10-18
Added
-
(slack) stylish, informative messages with Pepe's face, a checklist for pepe slack connect, and webhook cards (#203)
A redesign of everything Slack-facing, plus the F3 page.
Slack messages (
src/webhook/layout.rs)Each message has a colour down its side, and Pepe the pepper is its avatar.
While a run goes (edited every 10 s):
- what is being sent, how hard, from which machine and by whom;
- a progress bar with time left;
- throughput and p50 · p99, each with a sparkline of the last minute;
- errors by status and cause;
- a count of anomalies, each also posted as a reply in the thread.
At the end, the same message becomes the verdict:
- a summary sentence: how much was sent, how fast, what failed, and the first thing that changed;
- latency p50, p95, p99 and max;
- sparklines over the whole run;
- where the time went: waiting, connecting, reading;
- the answers by status code;
- each
--fail-ifwith ✓ or ✗ and the measured value; - for a saved test, the significant changes against its last recorded run, with a warning when the two runs weren't set up alike;
- findings, worst first, with the first body of each kind of failure.
Under the verdict: a thread reply that's also shown in the channel. It carries the result in one line with Pepe proud, worried or on fire, plus the
mentionwhen the run failed.Pepe's faces
site/build-avatars.pydraws 512 px PNGs straight from the mascot's sprite insrc/ui/mascot.rs. It uses only the Python standard library, and CI checks the images are current.- They're served from the site, which already publishes
site/img/*.png. - The app manifest now asks for
chat:write.customize, which Slack requires for custom avatars. Apps made with 0.46 needpepe slack connectrun again; the docs say so.
pepe slack connectAt a terminal, it's a checklist redrawn in place:
- Pepe's sprite beside the title.
- The prefilled app page opens in the browser. If no browser opens, the link is copied to the clipboard and shown as a clickable link instead of a wall of encoded text.
- The token is shown as dots and checked with a spinner. A rejected token gets a clear error and the field clears for another try.
- The channel is picked with the arrow keys from a list filtered as you type, showing member counts, whether it's private, and whether pepe is already in it.
- At the end:
- the test message's permalink;
- Pepe's icon saved for the app;
- commands to try.
Outside a terminal, or with
--tokenand--channel, it still asks nothing and reports one step per line.F3 webhook page
Each webhook is a framed card. Its title shows where it goes (Slack and the channel, Discord, Teams, Google Chat, or JSON and the host). Its corner shows the last delivery: sent and how long ago, or the error.
Checked
cargo test: 389 pass, including new tests for sparklines, the progress bar, the time split and status codes. Clippy is clean, and the docs and avatar checks pass.- Against a fake Slack API, two saved-test runs with
--slack(one with--fail-ifcrossed, one passing). Each produced, in order:- a start post with Pepe's waiting face;
- a live edit with the bar and sparklines;
- the verdict, with thresholds, the comparison and findings;
- the result reply with the proud or worried face, shown in the channel.
- The connect wizard and the F3 cards were driven in a pseudo-terminal and the screens replayed: wrong token, then right token, filtered channel pick, summary.
Not checked against a real Slack workspace: whether the avatars, the colour bars and the
<!date>timestamps look in Slack the way the payload intends.Next, in its own PR on top of this once it's merged: buttons to stop, run again and edit and run, through
pepe slack servewith Socket Mode, for saved tests only and allowed users.
0.46.0 - 2026-10-17
Added
-
(slack) --slack keeps one Slack message up to date as a run goes, and pepe slack connect sets it up from the terminal (#201)
Part 2 of the webhooks work: Slack, live, set up from the terminal. Nothing is hosted; the app belongs to the user's own workspace.
pepe slack connect # once per machine pepe -z 10m -c 50 --slack https://api.example.com/healthpepe slack connect- Opens Slack's "create an app" page with the manifest in the link:
name, permissions (
chat:write,chat:write.public,channels:read,channels:join,groups:read). The user picks a workspace, then clicks Create, Install and Allow. - Takes the pasted bot token, shown as dots, and checks it right away
with
auth.test. - Picks the channel by name or part of one, joins it, posts a test
message, and saves the token and channel to
slack.tomlbeside the settings. The file is0600, readable only by the user.
- Opens Slack's "create an app" page with the manifest in the link:
name, permissions (
0.45.0 - 2026-10-17
Added
-
(webhooks) --webhook sends the verdict to Slack, Discord, Teams, Google Chat or any URL, and F3 sets them on any screen (#199)
Part 1 of the webhooks work: easy by default, customisable in every place a run is configured.
pepe -z 30s -c 50 --fail-if 'p99 > 300ms' --webhook "$SLACK_WEBHOOK_URL" https://api.example.com/healthWhat it does
-
The URL picks the layout.
hooks.slack.com: Slack blocks.discord.com/api/webhooks: a Discord embed coloured by the outcome.
-
Teams (
*.webhook.office.com, workflows on*.logic.azure.com): an Adaptive Card.chat.googleapis.com: Google Chat text.- Anything else: JSON with the event, the run and the whole report.
formatcan force any of these, ortext. -
Events:
start,progress(a bar, requests, req/s, p50, p99 and errors, everyevery),anomaly(sent as it happens),end, andfail.failfires when a--fail-ifwas crossed, a ping's SLO was broken, or nothing answered. The default isend. -
Config:
webhook = "URL"or[[webhook]]tables inpepe.toml. The same key works under the settings'[defaults](machine-wide) and in saved tests (--keep, ctrl-s). Each table takeson,format,headers,every,mention, and abodytemplate with{{holes}}(status, p99, rps, report, …). -
Secrets:
${NAME}is read from the environment. An unset name, a bad URL or an unknown hole is refused before anything is sent. URLs are masked in reports and in the recorded command (https://hooks.slack.com/••••). -
F3 on every screen opens a webhook page next to F2's settings.
-
On the setup form, it sets the hooks of the run you're about to start.
- During a run, a hook added or changed is sent to from then on.
-
Paste or type a URL;
1–5tick events; ← → step the format;tsends a test and shows the answer; del removes the hook. -
Edits are kept for restarts, ctrl-s and the command printed at the end.
-
The dashboard, ramp and ping screens now pass pasted text to the pages.
-
Every mode sends: run, flow, api and replay (on screen, headless,
--json), plus ramp and ping. -
Delivery:
-
Each webhook has its own in-order queue, with three tries on a dropped connection, a 429 or a 5xx, following
Retry-After. -
The run never waits on webhooks. pepe waits up to 15 s at the end and reports failures on stderr; the exit code is unchanged.
--dry-runlists the webhooks and sends nothing.
-
MCP: the tools take
webhookandwebhook_on.
Checked
- Unit tests:
- format detection;
- the file forms (URL, table, list) round-trip;
${ENV}and masking;- each layout;
- templates;
- the progress line;
- in-order delivery with a retried 503;
- the page's keys and drawing;
- saved tests keep hooks and mask them in the command.
- By hand against a local receiver: flags with start/end/fail,
pepe.tomltables with headers, progress, Slack layout and a template. - Driving the real TUI in a pseudo-terminal: F3 mid-run, paste a URL, and the run's end arrives there. Also F3 on the setup form, then start. The recorded command shows the URL masked.
- Speed is unchanged (bench server, 200k at 64, three runs each): master 6.10–6.20 CPU ms per 1k, this branch 6.05–6.10.
Not in this PR (the next two)
- A Slack bot token: one message edited in place as the run goes, with anomalies threaded under it.
- Socket Mode: stop, rerun and edit-and-rerun buttons, and
/pepe. These act on saved tests only, for allowed users.
1.0
Adds
--webhook,--webhook-on, thewebhookkey and the MCP arguments, which restarts the freeze clock. The report schema is unchanged. -
0.44.0 - 2026-10-17
Added
-
(vars) --vars users.csv fills the {{holes}} in a request, a row a request (#197)
The data-driven requests from the roadmap (section 4), and the last 1.0 item that could shape
pepe.toml's keys.pepe -z 30s -c 20 --vars users.csv \ -H 'Authorization: Bearer {{token}}' -m POST -d '{"email": "{{email}}"}' \ 'https://api.example.com/users/{{id}}' pepe flow checkout.toml -z 1m --vars users.csvWhat it does
{{name}}holes in the URL (host included), headers and body take their values from a CSV file whose first line names them (RFC 4180 quoting, a BOM and CRLF are fine).- Run and ramp: one request per row, in the file's order, round again.
- Flow: each chain starts with the next row's values, over
[vars]; captures go over both. Columns count as known when the flow is checked. - Checked before anything is sent: every hole must be a column, and
the request must have at least one hole (both are errors that name the
cause). Every row's host is held to
--allow-host.--dry-runsays how many rows there are and what the columns are.api,replayandpingrefuse--vars. - Config, saved tests and MCP:
vars = "users.csv"inpepe.toml(and so in--keeptests and--write-config); avarsargument on the MCPrun,rampandflowtools. It is not taken from the machine's[defaults]. - Without
--vars, nothing changes:{{in a URL is sent as written.
Cost
Each row becomes a prepared request before the run, on the direct path like any target, so sending is unchanged (bench server, 200k requests at 64, three runs each, CPU ms per 1k):
CPU ms / 1k Peak RSS master 6.10 – 6.45 10.1 – 10.6 MB this branch, no --vars6.20 – 6.30 9.6 – 10.2 MB --vars10,000 rows6.25 – 6.40 18.3 – 18.6 MB Memory is about 1.6 KB per row (190 MB for 100k rows, 1.6 GB for a million, which is the cap). A million rows take about 2 s to prepare. Two fixes keep it at that size:
- Each row keeps only the headers that have holes.
- Targets with equal weights are sent in turn. The weighted round-robin cost the square of their number to compute.
The schedule index went from
u16tou32, so more than 65,535 targets now fit. Recorded inbench/README.md.1.0
This adds a flag (
--vars), apepe.tomlkey (vars) and an MCP argument. That restarts the month-without-a-flag-change clock. It is also the 1.0 prerequisite ticked in ROADMAP.md. The report schema is unchanged: the command line the report already records includes--vars.Tests
- CSV parsing and error locations.
- Filling a request row by row.
- A live run checked request by request: rows in order, templated and static headers, body.
- Validation, including the modes that refuse
--vars. - The config round-trip.
- Checked by hand: flows, ramps, guardrails, dry runs.
site/docs/*.htmlis rebuilt here from master's generator. It will conflict with omarmhaimdat/pepe#196; rebuild withpython3 site/build-docs.pyafter whichever lands second.
Other
-
(site) titles, descriptions and structured data a search engine reads, a sitemap and robots.txt (#196)
The documentation site and the install page, made easier to find.
- Titles: each docs page's
<title>carries the words someone would search for (`pepe ping · HTTP ping with latency split by phase`); the sidebar keeps the short names. - Descriptions: the page's opening, cut at a sentence end near 160 characters instead of mid-word at 300.
- Cards: Open Graph (
og:url,og:site_name, image size and alt) and Twitter large-image cards on every page. - Structured data:
TechArticleandBreadcrumbListJSON-LD on docs pages; the install page'sSoftwareApplicationnow names the npm and PyPI packages, the install and docs pages, plus aWebSiteentry. - Sitemap and robots.txt:
build-docs.pywritessite/sitemap.xml(no dates, so it isn't stale after every merge);robots.txtpoints to it;publish.shuploads both, andpublish-site.ymlchecks they answer 200 after publishing.
No change to the binary, flags or reports.
python3 site/build-docs.py --checkpasses. - Titles: each docs page's
0.43.1 - 2026-10-17
Fixed
-
(docker) the from-source image copies the schemas and the plugin marketplace it embeds (#192)
The from-source
Dockerfilecopiedsrcandcontribonly, but the binary embedsschema/*.schema.jsonand.claude-plugin/marketplace.jsonwithinclude_str!, sodocker build .failed withcouldn't read src/../schema/run.schema.json(seen on #185's "The from-source image" check). It now copies both. -
(ui) the screen no longer freezes when the terminal falls behind (#191)
The dashboard could freeze, for example in the request inspector while scrolling a response with the trackpad.
Why
Every screen wrote its frames on the same loop that reads keys and the mouse. When the terminal stopped reading for a moment, that write blocked and pepe stopped reading input. A terminal that was itself waiting to hand pepe input (a flood of wheel events, and since v0.38 every pointer move) then never read again. A stack sample of the frozen process shows the main thread blocked in
write(). a3fae55 made Ctrl-C work in that state; this removes the state itself.What changes
- Frames are written on a thread of their own (
terminal::Screen). A screen skips drawing while the terminal hasn't taken the last frame, then draws again as soon as it has (caught_up()), so input is always read. Leaving the screen waits up to a second for the last frames, so none lands on the shell. All eight screens use it. - Only clicks and the wheel are requested (modes 1000, 1015, 1006).
crossterm's
EnableMouseCapturealso turned on 1002 and 1003 (every pointer move), which no screen uses and which redrew the whole screen on each move. - Split mouse reports no longer act as keys. When a read ended right
after the ESC of a mouse report, crossterm reported Esc, and
[<65;80;25Marrived as typed keys: the inspector closed, the digits changed tabs, and a second Esc quit. Now an Esc followed within 30ms by[is dropped up to the end of the sequence (terminal::input()).
Testing
cargo test: 361 pass, including new tests for the split-sequence filter.cargo clippy --all-targetsis clean.- Each harness drove the release binary in a pty against a local server returning a 27 KiB JSON body, with the inspector open.
- A terminal that writes input before reading output: master froze in 7 of 7 runs; this branch froze in none of 6, with about 20,000 events in 8 seconds each.
- A flood of motion and wheel events: master quit on a stray Esc; this branch kept running and still answered a key afterwards.
- Quitting still leaves the alternate screen before the verdict is printed.
- Not yet tried by hand in Ghostty or iTerm2.
- Frames are written on a thread of their own (
Other
-
(roadmap) the versions that shipped each item, and the saved tests (#186)
- Every "next release" now names the version that shipped it (v0.17.0 to v0.36.0).
- A new section, 3c. Tests worth keeping: saved tests, the library,
--keep, recorded runs, a ping as a saved test, and the settings page (v0.37.0 to v0.42.0), and three open items: finding the spec from a base URL,pepe init URL, and Server-Timing recipes. - Agents: the Claude Code plugin, npm and PyPI, and the MCP registry.
- 1.0:
cargo auditgets its version; the month without a flag change restarted with v0.42.0, so 2026-11-13 at the earliest. - Not now: themes shipped, so they're out of the dashboard extras.
-
bump the github-actions group across 1 directory with 3 updates (#185)
Bumps the github-actions group with 3 updates in the / directory: actions/cache, actions/setup-node and actions/setup-python.
Updates
actions/cachefrom 4 to 6Release notes
Updates
actions/setup-nodefrom 6 to 7Release notes
Updates
actions/setup-pythonfrom 6 to 7Release notes
0.43.0 - 2026-10-16
Added
-
(agents) --otlp says which operation inside the service the time went to, and the loop to make it faster (#189)
Why
Every load tool, pepe included, stops at "p99 is 480 ms". The next question is always which part of the code that is, and answering it means leaving the tool. This PR answers it in the same command, and gives an agent a loop it can close on its own: measure, find the slow operation, change the code, measure again, compare.
What it does
--otlp :4318(plain runs for now)- Each request carries a
traceparentof pepe's making. The run id is the trace id's high half and the request number, mixed, is the low half. The sampled flag is set, so a parent-based sampler keeps every request. - pepe runs an OTLP/HTTP receiver at
/v1/tracesthat takes protobuf or JSON. The protobuf reader is about 150 lines, hand-written, so there's no new dependency. It keeps only spans from its own traces. /v1/metricsand/v1/logsget a 200 and are ignored. Gzip gets a 415, and the verdict says to setOTEL_EXPORTER_OTLP_COMPRESSION=none.- When the run ends, pepe waits for the exporters' last batch. It stops early once every request that got a response has its root span, and gives up after 15 s at most.
spansin the report: coverage, the server's own time (median, p90, p99), andoperations[]. Each operation has calls per request, median and p99 call time,share_percent(its own time, less its children's, over all requests) andslowest_share_percent(the same over requests at or above the server's p99).summary.slowest_requests[]gainstrace_id,server_msandspent(the top 3 operations).- Verdict findings: where the time went; an operation that's small at the median but large in the tail (it waits on a lock, a pool or a queue); the gap between pepe's latency and the server's own time; and what to set when no spans arrive.
- The text report (headless, or after the dashboard closes) prints a "Where the server's time went" table.
pepe compare: two reports with spans giveoperations[](each operation before and after) and a finding for each one that moved. The verdict still comes from pepe's own measurements.MCP
runtakesotlp.run,ping,ramp,api,flowandreplaytakesave, so an agent has a file to pass tocompare.- A
speed-upprompt (/pepe:speed-up URL [goal]in Claude Code) walks the loop. - Every tool has annotations:
compareis read-only and the others reach outside the machine. - The server instructions now lead with the tasks pepe is for. They load at session start, while tool definitions may only be looked up later.
Discoverability
- The skill's description puts its trigger words first ("slow", "faster", "benchmark", "OpenTelemetry"), since that text is what Claude and Codex match on. It also has a section on the loop.
- Codex: a root
plugin.jsonandmcp.json(the portable format OpenAI's docs describe), plus.agents/plugins/marketplace.json. Codex also reads.claude-plugin/marketplace.jsonas a legacy marketplace. - The docs now cover
codex mcp add,~/.codex/config.toml, a Cursor install link, andnpx skills add omarmhaimdat/pepe. AGENTS.md,llms.txtand the Claude plugin manifests mention the loop.- This PR includes the marketplace
sourcefix from omarmhaimdat/pepe#188 (same lines), so it works whichever merges first.
Tested
- End to end against a real service: a Python service instrumented
with the official OpenTelemetry SDK (1.45.1, http/protobuf exporter).
Its
/checkoutmakes a cache read and a query that holds a lock. - 2,000 requests at 16 concurrent: 100% coverage.
SELECT orderswas 98% of the slowest requests' server time, and the slowest requests'spentagreed. - With the lock removed:
pepe comparesaid "SELECT orders down 95%: 47.74ms → 2.57ms a call". It also showed the bottleneck moving to the cache read, which is real: at 1.6k req/s the Python service is GIL-bound. - The same loop through
pepe mcp(guardrails--allow-host 127.0.0.1 --max-requests 5000, thenrunwithotlpandsave). - JSON export accepted, gzip refused with the message, metrics and logs answered.
- Unit tests: the breakdown (shares, tail, self time with
overlapping children, warm-up excluded), the protobuf reader, requests
framed by length and by chunks, compare by operation, MCP
prompt/
save/annotations, schema fit.cargo +1.98 test: 367 passed.clippy -D warningspasses on 1.98. - Performance:
bench/compare.sh master branch 3, with--otlpoff. Results below; no regression.
Workload CPU ms/1k, master branch Peak MB, master branch tiny-c64 5.55 5.45 10.1 10.2 body16k-c64 6.40 6.40 10.0 10.0 slow20ms-c1000 6.80 7.00 13.1 13.0 tls-c64 5.90 5.90 13.6 13.2 Not verified
- Codex: Codex isn't installed here, so the plugin files follow OpenAI's documentation but haven't been loaded by Codex. The MCP and AGENTS.md routes are standard.
- Cursor: the install link hasn't been clicked.
For 1.0
This adds a flag (
--otlp), report fields (spans, and the newslowest_requestsfields), a compare field (operations), MCP inputs (otlp,save) and a prompt. All of them are additions, andschema/is updated, but per ROADMAP.md they restart the month-without-changes clock. - Each request carries a
0.42.2 - 2026-10-16
Fixed
-
(agents) the plugin marketplace is one Claude Code takes (#188)
What was wrong
The install line in the docs fails on master:
$ claude plugin marketplace add omarmhaimdat/pepe ✘ Failed to add marketplace: … marketplace.json: Invalid schema: … plugins.0.source: Invalid inputClaude Code wants a plugin's
sourceto be a path that starts with./, and ours was".". Since the marketplace can't be added,claude plugin install pepe@pepefails too. I checked it with Claude Code 2.1.63, adding a checkout of master as a marketplace under a throwawayHOME.The fix
"source": "./".claude plugin validate .passes now, and the same throwaway-HOMEtest adds the marketplace and installspepe@pepe.metadata.descriptionon the marketplace, which the validator asks for.- A test (
the_plugin_marketplace_is_one_claude_code_takes) that holds every plugin source to./, so this can't come back unnoticed.
It's separate from the agent-loop PR so it can ship on its own as a patch release.
0.42.1 - 2026-10-15
Fixed
-
(deps) bump toml from 0.9.6 to 1.1.8+spec-1.1.0 (#183)
Bumps toml from 0.9.6 to 1.1.8+spec-1.1.0.
Commits
Dependabot compatibility score
Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting
@dependabot rebase.
0.42.0 - 2026-10-13
Added
-
(ui) themes, and a settings page on every screen that keeps this machine's defaults (#181)
What
Themes. Seven true colour themes beside
terminal,lightandnone:pepe,nord,dracula,gruvbox,tokyo-night,catppuccin, andpaperfor a light ground. Each is a dozen colours insrc/ui/theme.rs; the rest of its palette is mixed from those, so the screens still draw in one dark palette andadjustrepaints the frame.A settings page on every screen.
F2opens it over the setup, saved tests, history, API plan, dashboard, ramp, ping and logs screens, and each screen's?help lists it.- The first line is the theme:
←→step through them and the screen repaints at once. - Below it: the defaults a run starts from (requests, duration, concurrency, timeout, threads, rate, warm-up, user agent, proxy, keep-alive, compression, redirects, TLS verification), a ramp's steps, the guardrails, and whether pepe looks for new releases.
entertypes a value, which is checked;delgoes back to pepe's own;esccloses. Every change is saved as it's made.
Kept on the host.
~/.config/pepe/settings.toml($XDG_CONFIG_HOME/pepe,%APPDATA%\pepeon Windows, or the directoryPEPE_CONFIG_DIRnames):theme = "nord" update-check = false [defaults] # what a pepe.toml can say, for every run on this machine concurrency = 50 max-rate = 500Why
The theme could only be picked with
PEPE_THEME, between pepe's palette and the terminal's, and a default could only be changed per directory in apepe.toml.For the reviewer
- Precedence: flags, then
pepe.toml, then the settings'[defaults], then pepe's own. The defaults are read when pepe starts, so only the theme changes mid-session. - Caps: where a
pepe.tomland the settings both setmax-requests,max-rateormax-concurrency, the lower holds, so a file in a directory can't loosen a machine-wide cap. This is a choice, and the place to push back if the project file should simply win. - Every mode: the saved defaults apply with
--jsonand underpepe mcptoo, as apepe.tomldoes. - A settings file that doesn't parse stops pepe with exit 1 and names the file, rather than dropping a cap it holds.
PEPE_THEMEstill wins over the saved theme at startup (the page says so when it's set);NO_COLORwins over everything. A theme name this pepe doesn't know is not an error.update-check = falsealso stops the setup screen mentioning a cached newer release.- Where the page covers the mascot, the page shows: the kept pixel-art area is cut around it.
- A saved test (
--load,pepe rerun) sits over the defaults like apepe.toml; its mode is read from the test alone, so a[ramp]in the settings doesn't make every test a ramp. - While the page is up, the mouse is no screen's.
- No
--themeflag and nopepe settingssubcommand.
Checked
- After merging master:
cargo test(357 pass),cargo clippy --all-targets -- -D warnings,cargo fmt --check. UPDATE_CONTRIB=1 cargo test contribfor the man page's environment section.python3 site/build-docs.pynot run:site/had unrelated uncommitted changes locally, so the built pages are not in this PR.- By hand, in tmux with
PEPE_CONFIG_DIRpointed at a scratch directory: opened the page from the setup screen and from a finished dashboard (40 requests at a localpython3 -m http.server), stepped the themes and read the escape codes for the new palette, typed and cleared defaults, read the file back, and confirmed with--dry-runthat a savedconcurrencyreaches a run and that-cwins over it. - The palettes are held to contrast floors by a test; the shades themselves were not judged by eye in a terminal.
- The first line is the theme:
0.41.0 - 2026-10-13
Added
-
(ping) a ping is a saved test too: --keep, --load, --record, ctrl-s and r on its screen (#179)
What
A ping is a saved test like the others.
-
The saved-test file gains a
[ping]section: targets,every,name,slo,tcp,port,cmd,keep-alive,http1,ipv4,ipv6,all-ips,interface.pepe ping … --keep NAMEwrites one;pepe --load NAMEruns it again, the file deciding the mode, and a ping ends on the file's-nwhere it would otherwise run forever.
$ pepe ping https://api.example.com/health -n 30 --every 2s --slo total=500 --keep prod-health --record --json saved .pepe/tests/prod-health.toml ·
pepe --load prod-healthruns it recorded .pepe/runs/prod-health/2026-10-12T23-29-20Z.json · pepe history prod-health- `--record` keeps the ping's report under the test, and `pepe rerun`, `pepe history NAME` and `pepe compare NAME` work on pings, phase by phase, as `pepe compare` already knew how to read them. - On the ping screen, `ctrl-s` saves the test (offering the first target as the name) and `r` records the pings so far. The footer says where they went. - The library lists a ping with its targets, interval, count or "until stopped", and SLO. A report's `command` renders a ping as a `pepe ping …` line that parses back, several targets included. - A report recorded from a screen right after the test was saved there now names the test explicitly, since the settings were not loaded from the file. The dashboard and the ramp had the same gap. The ping and load-test pages document it. ## Why A ping is the test most worth keeping over time: "prod health, every two seconds, total under 500ms" is what you'd run again next month and hold against today's phases. With v0.40's recorded runs in place, the pieces fit the existing design without new concepts. ## Checked - `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` - `UPDATE_CONTRIB=1 cargo test contrib` and `python3 site/build-docs.py` - Against a local HTTP server: `pepe ping … --keep --record --json`, `--load` running the ping and ending on its count, `--record` twice, `pepe rerun`, `pepe history NAME`, `pepe compare NAME`, the recorded report's `test` and `command`, and `--load … --dry-run` showing the ping as kept. -
0.40.0 - 2026-10-12
Added
-
(store) recorded runs: --record, w on the dashboard, pepe history, pepe rerun, pepe compare NAME (#177)
What
A saved test keeps its results too, when asked, and pepe can read them back.
-
--recordon a run of a saved test writes the report when it ends, as.pepe/runs/NAME/<moment>.json, in every path:--json, headless, the dashboard and the ramp screen. It refuses a run that is not a saved test (--load NAME, or--keep NAME), and modes other than a run or a ramp. -
won the dashboard and the ramp screen records the run once it has ended, with a notice saying where. -
Every report carries
testandcommand, the command with header secrets masked, so a recorded run can be run again from the file alone. Both JSON schemas gained the two fields.
$ pepe -n 1000 -c 10 https://api.example.com/health --keep api-health --record --json saved .pepe/tests/api-health.toml ·
pepe --load api-healthruns it recorded .pepe/runs/api-health/2026-10-12T22-55-04Z.json · pepe history api-health- `pepe history` lists the saved tests with their run counts and last run; `pepe history NAME` lists a test's runs, each against the one before. Both take `--json`. - `pepe rerun NAME` is `--load NAME --record` followed by the comparison with the previous run; with `--json` the comparison is what gets printed, the run's own report being on disk; `--gate` exits 1 on a regression. - `pepe compare NAME` holds a test's last two recorded runs against each other. Two runs recorded in the same second are told apart and kept in order. - In the library, each test shows its last run (rps, p99, failures, age) when the terminal is wide enough, and `h` opens its history: the runs newest first, with a card saying what moved since the run before. Mouse and wheel work there as everywhere. - The plan screen's parameters pane takes clicks: one picks a parameter, a second edits it. The reference, completions and man pages know the two commands and the flag; the load-test, compare, dashboard and agents pages document them. ## Why v0.37 to v0.39 gave pepe saved tests, a library to pick from, and a way to keep any run. The payoff of the store is results next to the test they came from: a trend over time instead of two files on a desk, and "did my change regress" as one command that CI can gate on. The report carrying its own invocation means no result is ever an orphan. ## Checked - `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` - `UPDATE_CONTRIB=1 cargo test contrib` and `python3 site/build-docs.py` - Against a local HTTP server: `--keep … --record --json` (report carries `test` and the masked command), a headless `--load … --record`, `pepe history` in both forms and as JSON, `pepe compare NAME`, `pepe rerun NAME` as text and as JSON, the refusals for an unsaved run and an unknown name, and two runs in one second kept in order. -
0.39.1 - 2026-10-12
Fixed
-
(dashboard) the filter bar over the requests is clickable, part by part (#174)
What
The requests tab's filter bar, the line with STATUS, LATENCY and SEARCH over the table, takes the mouse like the rest of the dashboard:
- a group's name or its key letter (
f,l,/) presses that key, so STATUS cycles the status filter and SEARCH opens the search for typing; - a status or a latency choice sets that filter outright, rather than cycling to it;
- the ENDPOINT chip, when an endpoint filter is on, lets it go.
Why
v0.38.0 made every screen clickable through regions registered as they are drawn. The filter bar drew its hints and choices as plain text and registered nothing, so the three controls a user reaches for most on that tab were the ones that took no click.
Checked
cargo test,cargo clippy --all-targets -- -D warnings,cargo fmt --checkpython3 site/build-docs.py- A test renders the requests tab, clicks each part and checks the filter it sets, then checks the regions are gone on another tab.
- a group's name or its key letter (
0.39.0 - 2026-10-12
Added
-
(setup) any run is kept, updated or copied: --keep, ctrl-s on the dashboard and ramp, c in the library (#173)
What
Any run can be kept as a saved test, updated in place, or copied: from the command line and from every screen that runs one.
-
pepe -n 1000 -c 10 https://example.com --keep NAMEwrites the settings as given to.pepe/tests/NAME.toml, says so on stderr, then runs. With--loadand no name,--keepupdates the loaded test in place, sopepe --load api-health -c 20 --keepkeeps the new concurrency; with another name it copies it. A curl command is kept too, body included. Ping, flow, replay and the rest refuse with exit code 2, since a saved test is a load test.$ pepe -n 1000 -c 10 https://api.example.com/health --keep api-health saved .pepe/tests/api-health.toml · `pepe --load api-health` runs it -
ctrl-son the dashboard and the ramp screen saves the run's settings the same way, with the concurrency as dialed in with+and-. A run started from a saved test offers its own name, so enter updates it without asking; another name makes a copy; a taken name asks once. The result shows as a notice in the footer. -
cin the library copies the selected test under another name, offeringNAME-copy, and selects the new file. -
One "save as" prompt now serves the setup form, the dashboard, the ramp and the library.
The flag is
--keeprather than--savebecausepepe ping --save FILEalready writes a report and a global flag reaches every subcommand, so--savestays the word for reports. The reference, completions and man page know--keep; the load-test, dashboard and agents pages document it.Why
v0.37.0 saved a test from the setup form and v0.38.0 gave the saved tests a library. A run worked out on the command line, or dialed in on the dashboard, deserves keeping just as much, and a saved test has to be updatable and copyable without going through the form.
Checked
cargo test,cargo clippy --all-targets -- -D warnings,cargo fmt --checkUPDATE_CONTRIB=1 cargo test contribandpython3 site/build-docs.py- By hand from a scratch directory:
--keepof a run, an update in place with--load … --keep, a copy under another name, a ramp kept as a ramp, a curl command kept with its body, and the refusals for a missing name, nothing to keep, and a ping;pepe ping --savestill takes its file.
-
0.38.0 - 2026-10-12
Added
-
(setup) a library of the saved tests to pick from, and the mouse on every screen (#171)
What
The second step of the saved tests that v0.37.0 began.
- With saved tests in the project,
pepealone opens on the library: each test with its mode, target, load and age, and the command it runs as. Enter puts one in the setup form, where it can still be changed before the start;nstarts from an empty form;ctrl-oon the form goes back to the library. A file that doesn't read is listed with its error rather than hidden. - A saved test's file decides the mode when the command line names none,
so
pepe --load my-rampruns the ramp and the library opens a ramp into the ramp form. A typed mode still wins. - Every screen takes the mouse: a click on a row selects it and a second click opens it, a tab shows it, a key chip in the footer presses its key, and the wheel scrolls. Screens register what they draw as clickable regions and look a click up against them, so what is clickable is exactly what is on screen. Mouse capture is on for the whole session and off again on every way out. The keyboard stays the primary path; with the mouse captured, selecting text in the terminal needs shift held.
The load-test and dashboard pages document the library and the mouse.
Why
Saving tests under names is only half of it: there has to be a place to see them and pick one, and a list to pick from has to be clickable. A mouse that works on one screen only is a surprise, so it works on all of them, through one shared mechanism rather than seven.
Checked
cargo test,cargo clippy --all-targets -- -D warnings,cargo fmt --checkpython3 site/build-docs.py(no flag changed, so the completions and man page are as they were)- By hand from a scratch directory with four saved tests, one of them
broken: the library at 80 and 170 columns, clicks and a double click on
its rows, and
--loadof a saved ramp running as a ramp with--dry-run --json.
- With saved tests in the project,
Other
-
(nix) update flake.lock (#170)
Automated changes by the update-flake-lock GitHub Action.
Flake lock file updates: • Updated input 'nixpkgs': 'github:NixOS/nixpkgs/a7868a727837f3c09cee2ce0ca671c76b1589fed?narHash=sha256-KgItSKML8Xvte0B7/uGnBDsYzSnnKHcOaiUbgWBXLXw%3D' (2026-10-03) → 'github:NixOS/nixpkgs/6510408d147d8c5e6d76043e899e9251756f6a75?narHash=sha256-XK4KceonkwD8LuMAt/vNrz982mXJM5jeHAxMwsUXXAQ%3D' (2026-10-10)Running GitHub Actions on this PR
GitHub Actions will not run workflows on pull requests which are opened by a GitHub Action.
To run GitHub Actions workflows on this PR, close and re-open this pull request.
0.37.0 - 2026-10-12
Added
-
(setup) ctrl-s saves the form as a named test, run again with --load (#168)
What
A project keeps more than one load test.
ctrl-son the setup screen now asks for a name and writes the form as.pepe/tests/NAME.toml, the same format aspepe.toml, meant to be committed with the code. The name it offers comes from the URL (api.example.com-v1-health); a taken name asks once before it is replaced, and a bad name is explained in the same line.pepe --load NAMEruns a saved test, flags on the command line winning as usual. It conflicts with--config, and a missing name says what is saved:$ pepe --load nope error: no test named `nope` (saved: api-health, checkout)The one-line text editor the plan screen used moves into the ui kit, so both screens share it. The reference, the completions and the man page know the new flag; the load-test page has a "Saved tests" section.
Why
A test worked out on the setup screen had one place to go,
pepe.toml, so a second one overwrote the first and reports piled up as nameless files. This is the first step of a project-local store under.pepe/: named tests now, a library screen in front of the form and results kept per test next.Checked
cargo test,cargo clippy --all-targets -- -D warnings,cargo fmt --checkUPDATE_CONTRIB=1 cargo test contribandpython3 site/build-docs.py- By hand from a scratch directory:
--loadwith--dry-run --json, a flag overriding the file, the--configconflict (exit 2), a bad name and the missing-name message with and without saved tests; the setup screen at 80 columns with the prompt open.
Other
-
(agents) the skill, llms.txt and the install page know the MCP server (#167)
What changed
AGENTS.md and the agents page already described
pepe mcp, the MCP registry listing and the Claude Code plugin; the skill,llms.txtand the install page did not. Each now says what the others say.skills/pepe/SKILL.md: an "As an MCP server" section with the seven tools, theclaude mcp addline, the two ways the registry starts it (uvx pepe-http mcp, theghcr.io/omarmhaimdat/pepeimage) and the plugin's caps. The install block gainsuvxand Docker.llms.txt: one paragraph on the MCP server, the registry nameio.github.omarmhaimdat/pepeand the plugin; the install line gainsuvx pepe-http.docs/install.md:uvx pepe-httpunder PyPI, with a note that the package carries apepe-httpcommand for it, and a pointer from the Docker section to the registry.site/docs/install.htmlandsite/docs/search.json: regenerated withsite/build-docs.py;--checkpasses.
Why
A model or an agent reads whichever of these it reaches first. The MCP server and the registry listing (#162, #165) were only in two of the five.
For the reviewer
Every fact was checked against the code: the tool names in
src/mcp.rs, thepepe-httpscript inpackaging/pypi/pyproject.toml, the image's entrypoint. No code changes.
0.36.1 - 2026-10-11
Fixed
-
(agents) the registry listing names the image by its canonical reference alone (#165)
The v0.36.0 release's
publish-mcp-registryjob was refused by the registry: an OCI package must not carryregistryBaseUrl, since the identifier (ghcr.io/omarmhaimdat/pepe:0.36.0) already says where the image is. The field is removed from the OCI entry; the PyPI entry keeps its, which the registry wants.server.jsonstill validates against the registry's schema. Everything else in that release shipped.A
fix, so the release it brings makes the first registry publish.
Other
-
(smoke) the npm and PyPI packages, tested after each release like the installers (#163)
pepe is on npm (
pepe-http, cargo-dist's package) and on PyPI (pepe-http, the wrapper inpackaging/pypi). The post-release smoke workflow covered the shell and PowerShell installers and the Homebrew formula; this adds the two packages, so a release that publishes but doesn't install or run shows up within minutes, like the others.What the jobs do
Both run on ubuntu, macos and windows, after the
expectedjob, soworkflow_dispatchwith a version still works for them.npm (
actions/setup-node@v6, node 22)npx -y pepe-http@$EXPECTED --versionmust print the expected version. The registry can take a minute to serve a just-published version, so the call is retried up to 10 times, 30s apart.npx -y pepe-http@$EXPECTED self-update --checkmust exit 0 and say "installed with npm" (the update path from #160).- On ubuntu only:
npm install -g pepe-http@$EXPECTED, thenpepe --version, to cover the global install and thepepecommand.
PyPI (
actions/setup-python@v6, python 3.x)pipx install pepe-http==$EXPECTED, with the same retry for PyPI's propagation delay; pipx is installed with pip first if the runner lacks it, and pipx's bin directory is added toGITHUB_PATHin case a runner doesn't have it on PATH.pepe --versionmust print the expected version (the first run downloads and checks the release binary), andpepe self-update --checkmust exit 0 and say "installed with pip".pipx run --spec pepe-http==$EXPECTED pepe --json --dry-run -n 10 https://example.commust reportsent: 0with that target.--specbecause the package ispepe-httpand the command ispepe; a plainpipx run pepe-http==Xlooks for apepe-httpapp and fails.
Windows steps use
shell: bash, as the workflow does elsewhere.Verified
The workflow itself could not be run from here; it runs on the next release or by hand with
workflow_dispatch. What was checked locally, on macOS, against 0.35.1:- The YAML parses (Ruby's YAML) and the job graph is the one above.
npx -y pepe-http@0.35.1 --versionprintspepe 0.35.1, andself-update --checksays "installed with npm".pip install pepe-http==0.35.1in a fresh venv, thenpepe --version: downloads the binary, printspepe 0.35.1.pipx run --spec pepe-http==0.35.1 pepe --json --dry-run -n 10 https://example.comprints the dry-run report with"sent": 0.- The new steps' shell, run verbatim with
EXPECTED=0.35.1: every assertion passes, and the retry loop gives up as it should on a version that doesn't exist.
Not checked here: the Windows and Linux runners themselves (pipx's bin dir on PATH,
npxunder Git Bash).
0.36.0 - 2026-10-11
Added
-
(agents) pepe in the MCP registry, as the PyPI package and the Docker image (#162)
server.jsonat the root describespepe mcpfor the official MCP registry (registry.modelcontextprotocol.io) asio.github.omarmhaimdat/pepe, with two packages:- PyPI
pepe-http, run asuvx pepe-http mcp. uvx runs the command named like the package, so the package gains apepe-httpcommand alongsidepepe(same entry point). The README carries the registry's ownership mark,<!-- mcp-name: io.github.omarmhaimdat/pepe -->. - Docker
ghcr.io/omarmhaimdat/pepe:<version>, run with--network hostso the tools reach services on the host. The image gains theio.modelcontextprotocol.server.namelabel, which is how the registry checks ownership of an image.
The npm package is not listed, because its
package.jsonis generated by cargo-dist and cannot carry themcpNamefield the registry requires there.Both packages take the guardrail flags as arguments after
mcp, described in the listing with their defaults.Publishing is a new
publish-mcp-registryjob at the end ofrelease.yml(a hand edit thatallow-dirtykeeps), afterpublish-pypiand the Docker job, since the registry checks the live package and image. It puts the release's version intoserver.jsonwith jq, authenticates with the job's OIDC token (theio.githubnamespace needs no secret), and publishes.announcewaits for it like the other publish jobs. - PyPI
0.35.1 - 2026-10-11
Fixed
-
(update) a pepe from npm or PyPI is told to update through them (#160)
The daily update notice and
pepe self-updatetold every copy the installer had not put in place to runpepe self-update, which is the wrong tool for the npm and PyPI packages: each version of those runs the pepe of that version, so the package is what gets updated. The install method detection now recognises both by where the binary lives (undernode_modules, or in thepepe-httpcache the PyPI package keeps on each platform) and saysnpm update -g pepe-httporpipx upgrade pepe-http, with a test for the paths on macOS, Linux and Windows.The skill file and
llms.txtlist the two packages among the ways to install, as the install page already does.This is a
fix, so release-plz opens the release pull request; merging that is what publishes pepe-http to PyPI for the first time (through the plainpublish-pypijob from #159) and validates npm's trusted publisher.
Other
-
(pypi) the publish as a plain job in release.yml, so trusted publishing can match it (#159)
-
npm through trusted publishing, and the PyPI publisher's workflow name (#158)
Two fixes from the v0.35.0 release run, whose npm and PyPI jobs failed while everything else shipped.
npm. The change moving
publish-npmto npm's trusted publishing was pushed to the packaging branch after #154 had been merged, so master still had the token-based job and noNPM_TOKEN, henceENEEDAUTH. This lands it: the job getsid-token: write, updates npm (trusted publishing wants 11.5.1 or newer), and publishes with no secret. The trusted publisher on npmjs.com already namesrelease.yml.PyPI.
publish-pypi.ymlis a reusable workflow called fromrelease.yml, and PyPI reads the calling workflow's name off the OIDC token, so a publisher set topublish-pypi.ymlcan never match (the run's error says exactly that). The publisher on pypi.org must namerelease.yml; the workflow's comment now says so. No code change is needed for that job: once the publisher is corrected, the failed job can be rerun on its own. -
(roadmap) what 1.0 means and what comes before it (#155)
A section in ROADMAP.md naming what 1.0 freezes: the reports'
schema_versionand fields, the exit codes, every flag's name and meaning,pepe.toml's keys, the MCP tools' names and inputs, and the install lines. Then the boxes to tick before the tag: a month without a schema or flag change on master, the data-driven requests flag (the one still likely to shape the config file), and a release note that states the freeze.
0.35.0 - 2026-10-11
Added
-
(install) pepe-http on npm and PyPI (#154)
pepe installs from npm and from PyPI, both as
pepe-http, both giving the commandpepe.npm install -g pepe-http # or: npx pepe-http -n 100 https://example.com pipx install pepe-http # or: pip install pepe-httpThe name
pepe-cliis taken on both registries (on npm by ttoss/pepe, on PyPI by a protein-embedding tool), andpepeitself is taken on PyPI.pepe-httpis free on both, says what pepe is, and reads the same everywhere:npx pepe-http,pipx install pepe-http, the commandpepe. The Python module ispepe_httpto match.npm
dist's built-in npm installer:
installersgains"npm",npm-package = "pepe-http"(no scope), andpublish-jobsgains"npm".dist generate(0.33.0, the pinned version) regenerated.github/workflows/release.yml; sinceallow-dirty = ["ci"]keeps dist from touching the file, I ran it with that line off and then put back the two hand edits the setting protects: Dependabot'sactions/checkout@v7(dist writes v6) andcontents: writeon the action job. The diff of release.yml is additions only: thepublish-npmjob, thecustom-publish-pypijob, and both inannounce.dist planlistspepe-npm-package.tar.gz. The npm package takesdescriptionandhomepagefrom Cargo.toml.PyPI
There is no dist installer for PyPI, so
packaging/pypi/is a small pure-Python package:pepe_http/__init__.py, with__main__.py,pyproject.toml,README.mdand the rootLICENSEcopied. Standard library only, Python 3.9+. On first run it maps the platform to a release target (macOS arm64/x86_64, Linux x86_64/aarch64 musl, Windows x86_64; Windows on ARM gets the x86_64 build), downloadspepe-<triple>.tar.xz/.zipand its.sha256from the GitHub release of the package's own version, refuses a mismatch, reads only thepepe/pepe.exemember out of the archive, writes it next to its place and moves it in, under~/.cache/pepe-http(~/Library/Caches/pepe-httpon macOS,%LOCALAPPDATA%\pepe-http\Cacheon Windows;PEPE_HTTP_CACHE_DIRoverrides), thenos.execvs it (on Windows, a subprocess whose exit code is passed on). Every later run only runs it.The version lives in
pepe_http.__version__(whatpyproject.tomlreads throughdynamic), so it works from a checkout too;.github/workflows/publish-pypi.yml, a custom dist publish job likepublish-homebrew.yml, sets it from the release tag, builds withpython -m build, installs the wheel and runspepe --versionagainst the release the host job has just created, and publishes withpypa/gh-action-pypi-publish@release/v1underid-token: write(trusted publishing, no token).
Other
-
main.rs split by mode, the help in order of use, usage errors that say what to do (#153)
Three things in one pass over the same files.
The split.
src/main.rswas 2,100 lines with every mode's runner in it. It is now the dispatch alone (270 lines); the modes are insrc/modes/, one file each (run, ping, ramp, compare, api, flow, replay, logs), what they share inmodes/mod.rs, and the terminal handling (raw mode, Ctrl-C, the way out when the terminal stops reading) insrc/terminal.rs. A pure move: no behaviour changed.The help.
pepe --helplists the commands in the order people reach for them: ping, ramp, compare, api, flow, replay, logs, then schema and mcp, then self-update and completions. Man pages, completions and the reference page regenerated.The errors. Every usage error names the flag and says what to give instead. Before and after:
Duration must have a unit (s, m, h). Examples: 10s, 5m, 2h -z "10" is not a duration: a number and a unit, as in 10s, 5m or 2h Concurrency cannot be greater than the number of requests. -c 50 -n 10 -c 50 is more than the 10 requests of -n: lower -c, raise -n, or run for a time instead with -z 30s Invalid header "Nope": expected 'Name: value' header "Nope": a header is Name: value, as in -H 'Accept: application/json'Same for the timeout, the method, the proxy, the rate,
--trace-header,--everyand--warmup. A pepe with nothing to run and no terminal says so in one line instead of printing the whole help.cargo test, clippy with-D warnings, and the site check pass. -
(site) the front page in the docs' style (#152)
pepe.mhaimdat.com was a page of its own, a terminal in monospace, beside a documentation site that looked nothing like it. It is now one site in one style: the front page opens like the docs' overview (logo, name, one sentence, three buttons), then the install line with a tab per platform, the home recording, a card per mode, the three benchmark numbers and a footer, using the docs' stylesheet and script, light and dark.
The four dashboard screenshots the old page showed are removed with it (the recordings show the same); the publish job's URL check no longer lists them. Checked in the browser at phone and desktop widths.
0.34.0 - 2026-10-11
Added
-
(agents) pepe as a Claude Code plugin (#151)
The repository installs as a Claude Code plugin.
claude plugin marketplace add omarmhaimdat/pepe claude plugin install pepe@pepeWhat the plugin brings:
- The
pepeskill (skills/pepe/SKILL.md, unchanged): how to call pepe and what to read in each report. - The MCP server, from
.mcp.json:pepe mcp --max-requests 10000 --max-rate 500 --max-concurrency 64. No--allow-hostis shipped, since a placeholder would refuse every target; a project names its hosts inpepe.toml, which the server's subprocess reads from the working directory like every other mode. - The binary is not installed by the plugin; it must be on
PATH.
- The
Other
-
cargo audit on every PR, and the bench server past its time advisory (#150)
Dependabot's alert 13 is the
timecrate pinned bybench/server/Cargo.lock(GHSA-r6v5-fh4h-64xc, stack exhaustion). It is now 0.3.55, which wants Rust 1.88; the bench server is built with stable and is not under the MSRV job, so that is fine.An
Auditjob in CI runscargo audit --deny warningson both lock files, so the next advisory fails a pull request instead of arriving by email. -
the overview opens like a landing page (#149)
The docs' first page now opens like a landing page: the logo, the name, one sentence and three buttons (Get started, Read the docs, View on GitHub), then the install lines, the home recording and the cards. The site's generator learns a
:::heroblock for it and takes the page description from the hero's sentence; the CSS adds a few lines for the hero. The rest of the site is unchanged.Checked with
python3 site/build-docs.py --checkand in the browser at phone and desktop widths, light and dark. -
the leftover scripts out, and the standard files in (#148)
What
- Leftovers removed.
quick_check.shandtest_simple.shwere from v0.3 and checked for files that no longer exist (release.yaml, the old install script);.vscode/was one editor's launch config. All gone;.gitignorealready kept new copies out, these were just tracked from before. SECURITY.md: how to report a vulnerability privately (GitHub's private advisories, or email), what is in scope (the binary and its inputs, the installers and release artifacts, the action, the MCP server), what isn't, and the guardrails for what pepe is pointed at.- Issue templates: a bug form (version, platform, the exact command
with a note that
--dry-runis safe to paste, expected, actual, the terminal's size for a screen) and a feature form that asks for the situation before the feature, with a pointer to the roadmap. The chooser links to the docs, discussions and private security reporting. - A pull request template that explains the conventional title the changelog and the release are built from, and asks what was checked, including the generated files.
Also worth doing, by hand
Two repository settings are off and don't come from a file: Dependabot security updates and secret scanning with push protection (Settings → Code security). Both are free for a public repository and both are things listing reviewers look at.
Checked
- The three YAML templates parse; the forms render in GitHub's chooser once merged
- Nothing referenced the removed scripts
- Leftovers removed.
-
(readme) a front page, with the manual on the site (#146)
What
The README was 769 lines: the whole manual, which since #133 and #135 lives on the docs site. This makes it a front page: the pitch, the recording, install, a quick start, a table of the modes with a line and a link each, what makes pepe usable from scripts, CI and agents (the JSON report and its schema, the exit codes, the guardrails, the action, the metrics, the MCP server), the benchmark tables, and where the documentation, the changelog and the roadmap are. 100 lines.
What was stale went with it: "160+ tests" (there are 300), the roadmap paragraph that still listed things that shipped, and the
-d @fileexample fixed in #137.CONTRIBUTING.mdholds what the Contributing section had, and more: the build and the checks, the generated files and how to regenerate them, the recordings, the benchmarks, the Windows checklist, commits and releases, and a map of the source tree.- The install page's "docs" links point at the docs site rather than the README's old Usage section.
- The modes table names the long flags, as the review in #13 asked; that issue is answered point by point and closed.
Nothing in the manual is lost: every section the README had is a page on the site, written from it.
Checked
- Every file the README links to exists; the docs-site check is clean
- No source or workflow linked into README sections (the action's listing shows the README's front page, which is the intent)
0.33.0 - 2026-10-10
Added
- (mcp) pepe mcp serves the modes as tools for an agent (#145)
0.32.0 - 2026-10-10
Added
-
(ping) HTTP/2 when the server offers it (#142)
What
A ping takes HTTP/2 when the server offers it through ALPN, which most do over TLS, and says so:
HTTP/2 200in the--onceline and the pings list,"http_version": "HTTP/2"in the report,h2under the TLS details. The phases are the same, the first byte being the response's headers.--http1stays on HTTP/1.1, to compare the two. A server that offers only HTTP/1.1 is noted among the findings.--keep-alivekeeps the HTTP/2 connection, and the pings after the first reuse it.$ pepe ping https://example.com --once dns 2.02ms → connect 58.16ms → tls 60.32ms → first byte 67.05ms → download 3.04ms · 190.8ms in all HTTP/2 200 · text/html · 334 B · 104.20.23.154:443 from 192.168.0.164 · TLS 1.3 TLS13_AES_256_GCM_SHA384Why
Ping makes the protocol gap visible: curl and httpstat show
HTTP/2 200where pepe showedHTTP/1.1, and the phase breakdown is the most useful thing pepe has for a single request, so it should see what the server actually speaks.How
- The ping's TLS config offers
h2thenhttp/1.1(unless--http1); after the handshake, ifh2was negotiated,h2::client::handshaketakes the stream and its connection is driven by a task of its own. The kept connection has a third kind, the h2 sender, so--keep-aliveworks as it does over HTTP/1.1. h2_exchangebuilds the request from the run's headers lessHostandConnection(HTTP/2 has:authorityand no connection headers), times the first byte at the response's headers and the download to the end of the body, releasing flow-control capacity as it reads. Redirects go through the same loop as before.- The dependency is hyper's
h2crate (andhttp, already in the tree); onlyh2andtokio-utilare new in the lock file. The load-testing modes still speak HTTP/1.1, where pepe's engine is fastest; the roadmap's "not now" entry says so.
Checked
cargo test --locked: 296 passed, including an in-process h2 server the exchange runs against twice on one connection; clippy with-D warningsandcargo fmt --checkclean- By hand against example.com:
--onceover HTTP/2 and with--http1, and a--keep-alive --jsonping showingHTTP/2,h2, and two of three pings on the kept connection
- The ping's TLS config offers
0.31.0 - 2026-10-10
Added
-
the verdict says where the time went, and the JSON report carries it (#140)
What
- Findings about where the time went, in the verdict of a run, when it is somewhere worth knowing:
- connecting being a quarter or more of each request when every request
opens a connection (
--disable-keepalive); - connections opened three times over the concurrency or more, so the
server closes them and keep-alive isn't holding;
- DNS lookups over 50 ms on average;
- downloading large bodies (32 KiB and up) being a quarter or more of each request;
- half or more of the responses answered by a cache, by their headers, so the origin wasn't measured for those.
These are the rules
pepe ping's findings use, read from what a load run measures anyway (the connect histogram, the first-byte and download phases, the DNS probe, the cache headers).-
The verdict in the JSON report.
--jsonreports of a run, API mode, a flow and a replay now carryverdict:level(healthy, degraded, failing) andfindings, each with a level and its text, what the dashboard would have said. Until now the JSON had the numbers and the anomalies but not the judgement. The schema and the docs have it; the agents page says to read it first. -
A trend is no longer read off the empty seconds that trail the last request, which could call a short run's throughput "fallen 100%".
Where
src/insights.rs:phase_notes, and the trailing-seconds trim inverdictsrc/ui/mod.rs: the dashboard appends the phase notes to its verdictsrc/json_report.rs,src/main.rs:with_verdict, built byverdict_ofin every--jsonrunnerschema/run.schema.json,docs/output.md,docs/dashboard.md,docs/agents.md
Checked
cargo test --locked: 296 passed, including each phase rule and the keep-alive reconnect rule; clippy with-D warningsandcargo fmt --checkclean; the docs site current- A real
--jsonrun and a piped run of example.com: the cache finding appears in both, and the short run no longer says its throughput fell
0.30.0 - 2026-10-10
Added
-
guardrails for scripts and agents, and what an agent reads first (#138)
What
Two roadmap items under Agents.
Guardrails
Where a run may be pointed and how much it may send, checked in every mode (a run, a ramp, API mode, a flow, a replay, a ping) before anything is sent. A refusal is exit code 2 with the reason on stderr.
Flag Holds --allow-host .example.comEvery target's host must be example.comor under it;api.example.comis that host exactly;repeat for more. A flow step whose host comes from a capture, and a ping of a command ( --cmd), are refused under it.--max-requests N-nmust be under it;-zneeds a--ratethatbounds it; a ping needs -nor-z; a ramp is refused, having nobound. --max-rate N--ratemust be under it, and must be given: withoutit a run sends as fast as the target answers. A ping's targets over its interval count. --max-concurrency N-c, and a ramp's top step.--dry-runWhat would be sent, to where and how much, with secrets in headers masked, and nothing sent; --jsongives it as JSON with"sent": 0.The same four keys work in
pepe.toml(allow-host,max-requests,max-rate,max-concurrency), where leaving a flag off the command line can't loosen them.$ pepe --allow-host .example.com --max-requests 100 --max-concurrency 8 -n 50 -c 4 --dry-run -H 'Authorization: Bearer abc' https://api.example.com/health pepe · dry run · nothing sent mode run target https://api.example.com/health method GET headers Authorization: Bearer •••• load 50 requests, 4 at a time, each sent as soon as the last answered settings timeout 20s · connections kept alive · redirects followed guard hosts .example.com · at most 100 requests · at most 8 in flightWhat an agent reads first
AGENTS.mdin the repository,llms.txtat the site's root (published with the site), the agents page on the docs site, and the skill file, all saying the same: the flags to always pass, the fields to read in each report, the exit codes, and the guardrails.How
src/guard.rsholds the checks (Guard) and the plan (Plan); each mode's runner calls them after loading what it needs (a spec's base URL, a flow's steps rendered with its[vars], a replay's URLs, a ping's targets) and before starting. Host matching strips ports and brackets and ignores case;.example.comand*.example.commean the same.Checked
cargo test --locked: 299 passed, including host matching, each cap, and the plan's masking; clippy with-D warningsandcargo fmt --checkclean- By hand: a URL outside the allowed hosts refused,
--max-requestsagainst a timed run without--rate, a ping over its cap, a ramp over--max-concurrency, and dry runs as text and JSON for a run, a ramp and a ping
Other
-
(readme) -d takes the body itself; only a curl command reads @file (#137)
The README's example sent
-d @payload.json, which pepe's own-ddoesn't read as a file (a curl command's-d @fileis read, through--curl). The docs site already had it right; the README now does too.Looked at the install page's four screenshots while here and left them: they are hand-made 2896×1836 captures of a worked scenario with matching captions and alt text, and frames cut from the 1280×720 recordings would be a step down.
-
every recording made again, one for the home page, and the site published on its own (#136)
What
- Every recording, again, with the current screens: the run, the setup screen, the ramp, API mode and the logs, and four new ones:
ping.gif: two targets on the graph (example.com and a local service), then the phases of one with its TLS session, certificate and findings, then the pings with one opened;flow.gif: a three-step flow againstassets/demo/checkout.toml, each step fed by the one before, each a row on the dashboard;compare.gif: two reports under the same load, the second of a slower endpoint, then what moved (Slower, with the p99, the median and the throughput);home.gif, for the docs' home page:pepe ping URL --oncesaying where the time goes, then a run with the dashboard, ended with the verdict in the shell.assets/record.shrecords them all fromassets/tapes/, against the local target it starts, as before.- The site goes out on its own. It was published only by a release,
and a docs change never makes one, so the documentation site merged in
#133 and #135 had not been deployed.
publish-site.ymluploads the install page, the docs, the recordings and the schemas whenever any of them change on master (and on request), checks the built site is current first, and verifies the public URLs afterwards. The release workflow uploads the same files through the same script,site/publish.sh, so the two can't drift. - The ping, flow and compare pages and the README's ping section show their recordings; the home page shows its own.
Checked
- Each recording's frames looked at: the home page's two halves, the ping's graph and phases, the flow's three rows all answering, the compare's verdict; the compare tape re-recorded once with longer pauses after its runs overlapped the typing
- The demo flow completes its chains against the local server (5 of 5, every step 200)
python3 site/build-docs.py --checkclean; the contrib and docs-reference tests pass- Recordings total 13 MB, in line with the five there were
Merging this triggers the first deployment of the documentation site.
-
the site as a documentation site, not a terminal (#135)
What
The docs pages were the install page's terminal look stretched over long prose: one monospace face for everything, dark only, dense, hard to scan. This rebuilds them as a documentation site.
- Type and colour. Prose in a proportional face, code in a monospace one. Light by default, dark when the system or the switch in the top bar says so; the choice is remembered. The accent stays the dashboard's ember.
- Finding things. A top bar with search over every page (an index is
written beside the pages at build time, loaded on first use;
/focuses the box, arrows pick a result, results say which page and section), a sidebar grouped into Start here, Modes, Integrate and Reference, an "on this page" column that follows the scroll, and previous/next links at the bottom of each page. - Reading. A 76-character measure, headings with room around them and anchors on hover, code blocks with the language and a copy button, tables with borders and a hover, callouts, framed images, an edit-on-GitHub link.
- The landing page. A card per page with what it is for, and where to start.
- Phones. The sidebar behind a menu button, the search in the bar, tables that wrap, no horizontal scroll.
How
Same Markdown sources under
docs/.site/build-docs.pygets the new frame, two small blocks of its own (:::cardsand:::note), the search index (search.json), and the sidebar groups.site/docs/docs.cssis the new stylesheet andsite/docs/docs.jsthe page's few behaviours (theme, menu, copy, search, the scroll-following list). No dependency anywhere. The release workflow publishes the script and the index with the pages, and the Lint job's check of the built site is unchanged.Checked
python3 site/build-docs.py --checkclean; the built site is committed- Served locally and checked in the browser: the landing page and its
cards, a long page with its tables and code blocks, search (results for
sloacross pages), the theme switch and that it is remembered, the phone layout with its menu, and no horizontal scroll at 375px
0.29.0 - 2026-10-10
Added
-
a report without a terminal, --fail-if, a versioned report with its schema, and the documentation site (#133)
What
Two things, the second built on the first.
Three roadmap items that make pepe safe to call from a script
- Without a terminal, a report. Piped, redirected or run by a script
without
--json, pepe used to try to open the dashboard and fail. Now the run goes to its end and the report the dashboard would have left is printed: the same verdict and findings. A plain run, a ramp, API mode, a flow and a replay all do it;--snapshotand--metricswork as on the dashboard. API mode needs its endpoints picked on the command line, since there is no plan screen. --fail-if 'p99 > 300ms', in the ramp's condition language, repeatable, in every mode that measures requests, with or without--json. The report is still printed, each crossed condition is said on stderr with the number it saw (✖ --fail-if p99 > 300ms: p99 was 412.0ms), and the exit code is 4, the same as a ping's--slo.pepe pingrefuses it and points at--slo.- A versioned report. Every JSON report starts with
"schema_version": 1. The JSON Schema of each report kind is inschema/, embedded and printed bypepe schema run|ramp|ping|compare, shipped in every release archive, and published atpepe.mhaimdat.com/schema/. A test holds a real report of each kind against its schema.
pepe -z 30s -c 20 https://example.com > run.txt pepe --json -n 2000 --fail-if 'p99 > 300ms' --fail-if 'errors > 1%' https://staging.example.com/api pepe schema ping | jq '.properties.targets.items.required'The documentation site, at pepe.mhaimdat.com/docs
Every feature of pepe, page by page, with examples: overview, install, load testing (every option, the load's shape, the config file, the curl command, the setup screen), the dashboard and its keys, ramp, OpenAPI, flows, replay, ping, nginx logs, compare, output and exit codes, CI and Docker, agents and scripts, benchmarks, and a command reference.
- The sources are Markdown files under
docs/, readable on GitHub as they are. site/build-docs.pybuilds them intosite/docs/with no dependency: the install page's look, a sidebar, an "on this page" list, anchors on every heading, and a phone layout. The built site is committed, and the Lint job checks it is current.docs/reference.md, every command's--help, is generated from the command definition bycargo test(UPDATE_CONTRIB=1rewrites it), so it can't drift from the binary. It documentspepe schemaand--fail-if, which is why the two parts share a branch.- The release workflow publishes
site/docs/with the rest of the site and verifies two of its pages; the install page's "docs" link and the README point at it.
How
Dashboard::run_headlesscollects results on the same pump as the dashboard, writes snapshots, and leavesreport()andmetrics()to read afterwards. Each mode's runner gains a headless branch. The ramp's climb is factored out of--json(climb) and shared with the piped path.exit_if_failedrunsCondition::crossedagainst the run's final metrics; the interactive sessions return their metrics with their report so it runs after the screen closes.src/schema.rsembeds the four schema files and carries a small checker (required fields, types,$refinto$defs, items) used by the tests.
Checked
cargo test --locked: 295 passed; clippy with-D warnings,cargo fmt --checkandpython3 site/build-docs.py --checkclean- By hand: a piped plain run and a piped ramp print their reports;
--fail-ifexits 4 with and without--json;pepe schema pingprints,pepe schema nopesays what exists - The site served locally and checked in the browser at desktop and
phone widths: sidebar, tables, code blocks, images from
assets/, anchors; no horizontal scroll on a phone
- Without a terminal, a report. Piped, redirected or run by a script
without
0.28.0 - 2026-10-10
Added
-
(compare) two ping reports compare, phase by phase (#129)
What
pepe comparereads ping reports (pepe ping --json, or--save report.json) and holds them against each other: the median, the p99, the pings lost, and the median of each phase, so a slower connect or a slower handshake is told from a slower server.pepe · compare before.json → after.json ▲ Slower · 600 → 600 pings · p99 50.00ms → 72.00ms ▲ Median up 50%: 40.00ms → 60.00ms ▲ Connect up 150%: 10.00ms → 25.00ms ▲ TLS handshake up 60%: 25.00ms → 40.00ms ✔ First byte within the usual spread: 6.00ms → 6.00ms (±5%)Why
Ping reports were the one kind of report
pepe comparerefused. A before-and-after of a deploy, a DNS change or a CDN move is exactly what they are for, and the phases say which part moved.How
Side::readrecognises"mode": "ping"and reads the first target:sent,lost, the latencies, and each phase's median (dns,connect,tls_full,tls_resumed,ttfb,download), plusevery_s.- A lost ping is a failed request, so failures outrank speed as before; the head and the findings say "lost" and "pings" for a ping report.
- A ping report has no latency spread, so the jitter (the mean difference between one ping and the next) stands in for it, scaled to a standard deviation, and the usual spread-based threshold applies. Small runs get more room, as they do for load reports.
- Each phase is a
Changeof its own in the JSON, named in the badge when it moved most (slower · connect +150%), and drawn on the SVG card. - A different target or a different interval is said first, as different concurrency is.
Where
src/compare.rs:Side::of_ping, the phase changes, the names, the tests- README: the compare section and a line in the ping section
Checked
cargo test --locked: 285 passed; clippy with-D warningsandcargo fmt --checkclean- Two real ping reports of example.com compared (https against http), and a report against itself ("about the same")
0.27.0 - 2026-10-10
Added
-
--metrics :9100 serves the live numbers for Prometheus (#130)
What
--metrics :9100serves the live numbers athttp://localhost:9100/metricswhile the run goes, in Prometheus's text exposition form, so a soak run or a long ping shows up in Grafana next to the server's own metrics.pepe -z 6h -c 50 --metrics :9100 https://example.com pepe ping https://example.com --metrics 127.0.0.1:9100 curl -s localhost:9100/metricsWhy
The roadmap's "Prometheus metrics" item: a soak run's numbers belong on the same board as the server's. With
pepe pingin, a long ping belongs there too.How
src/exporter.rs: a server of a few dozen lines (tokioTcpListener, the request line parsed by hand, one text answered) and a page writer for gauges, counters and histograms. No new dependency. The page is rendered once a second at most, whatever scrapes it; the scrape is served from the last one.--metricstakes:9100,9100orhost:port.- Where it hooks in. The dashboard publishes after each drain (so
every mode on the dashboard is covered: plain runs, API mode, flows,
replays); the
--jsonloops of those modes publish on their pump tick; a ping publishes from a task once a second. A ramp isn't served, its steps being the point of it; said in the README. - A load run has requests finished, succeeded, timed out and
errored, responses by status, failures by cause, bytes, cache hits,
throughput and errors over the last second, latency quantiles (0.5, 0.9,
0.95, 0.99) and first-byte quantiles,
pepe_request_duration_secondswith fixed buckets from 1 ms to 30 s so two runs' histograms add up, the concurrency and the elapsed time. Every line carriestarget="GET https://…"; API mode, flows and replays have the same again perrow. - A ping has sent, answered, lost, timed out, SLO breaks,
pepe_ping_up, the last ping's time, the loss ratio, latency quantiles, jitter, each phase's median, responses by status, resumed TLS sessions,pepe_ping_cert_not_after_secondsand a histogram, each pertarget. - The address is printed to stderr at the start (
metrics: http://127.0.0.1:9100/metrics), so--jsonoutput stays valid JSON.
Where
src/exporter.rs: the server, the page, the two renderers, and their testssrc/main.rs: starting it, and publishing from the headless loops and the pingsrc/ui/mod.rs: publishing from the dashboardsrc/cli.rs: the flag, andtarget_label()shared by the runners- README (options table and a section), ROADMAP, man page, completions
Checked
cargo test --locked: 288 passed, including the page format (cumulative buckets, escaping, one declaration per family), a run page with rows, and a served page fetched over TCP; clippy with-D warningsandcargo fmt --checkclean- Scraped a live
--jsonrun of example.com three seconds in, and a live ping of example.com and a dead port, withcurl
0.26.0 - 2026-10-10
Added
-
(ping) what the numbers mean, said in a few lines, and --once for a quick diagnosis (#127)
What
pepe pingnow says what to look at, next to the numbers: in the summary, in the phases view (a "look at" block), and underfindingsin the JSON report. Andpepe ping URL --onceis the quick version: three pings 200 ms apart, the last one's phases, what answered, the findings, and out.pepe ping · https://example.com/ · 3 pings dns 2.40ms → connect 69.7ms → tls 40.4ms → first byte 44.2ms → download 47µs · 157ms in all HTTP/1.1 200 · text/html · 334 B · 104.20.23.154:443 from 192.168.0.164 · TLS 1.3 TLS13_AES_256_GCM_SHA384 certificate for example.com by SSL Corporation, expires in 76 days ▲ connecting takes 69.7ms of 157ms: a round trip of about 69.7ms; the server is far, and a kept connection would skip it ✔ TLS sessions are resumed: 30.1ms after the first handshake's 78.0ms · answered by a cache (cf-cache-status: HIT): the server itself wasn't measuredWhy
Ping measures everything needed to say why a URL is slow, but until now only reported numbers. These few lines are the verdict's voice applied to a single request: the thing to paste into a channel.
How
src/diagnose.rsholds the rules, each reading what ping already has (the medians of each phase, the TLS sessions, the last response's headers, the losses, the tail):- nothing answered, and the cause; pings lost and timed out; 5xx; 401, 403, 404 and 429 on every ping
- every request redirected, with the final URL to point at and what the chain costs
- a slow DNS lookup; connecting as a share of the request, read as a round trip; the TLS session never resumed (every connection pays the full handshake) or resumed and how much it saves; the server closing every connection
- the server's own share of the time, with its
Server-Timingsegments when it sends them; a large or slow download; a text body over 4 KB that isn't compressed though gzip and br were offered; a cache answering (and so the server itself not measured); HTTP/1.0 - a certificate expired or expiring within 30 days; a long tail (p99 three times the median or more)
Findings are ordered most serious first, with a glyph and a level (
bad,warn,note,good) in the JSON.Also fixed: the connect phase was counted twice in a ping's breakdown (once by
connect, once more when the phases were summed), so the phases added up to more than the total. A test now checks that the parts never exceed the whole.Where
src/diagnose.rs: the rules,--once's output, and their testssrc/ping.rs: findings in the text report and the JSON; a sample keeps the URL its redirects ended at; the connect fixsrc/ui/ping.rs: the "look at" block under the phasessrc/cli.rs,src/main.rs:--once- README, man page and completions
Checked
cargo test --locked: 284 passed; clippy with-D warningsandcargo fmt --checkclean--onceand--jsonagainst example.com over https and http; the phases now add up to the total
0.25.0 - 2026-10-10
Added
-
(ping) pepe ping, a request a second split into DNS, connect, TLS, first byte and download, on a graph (#124)
What
A new subcommand,
pepe ping: ping, with HTTP instead of ICMP, and a graph. One request a second to one target or several, each split into where its time went, with the loss, jitter and percentiles ofping, the phase breakdown ofhttpstat, and the graph ofgping.pepe ping https://example.com pepe ping api.example.com cdn.example.com --name api,cdn --every 500ms pepe ping https://api.example.com/health --slo total=300,ttfb=100 --bell pepe ping example.com --all-ips -4 pepe ping db.internal --tcp --port 5432 pepe ping 10.0.0.0/29:8080 pepe ping --cmd 'dig example.com' pepe ping https://example.com --jsonl > pings.jsonlWhy
A load test is run now and then; a latency check is run all day. This is the same engine at one request a second, and the reason to have pepe open. It was scoped from what gping and httpstat have and what their issue trackers ask for: packet loss, text output when piped, stats over the whole run, a fixed y axis, named and coloured targets, CSV, thresholds with an alert, every address of a name, CIDR ranges, TCP pings, SLO limits, certificate details, redirect chains, JSON, the IPs at both ends, and full against resumed TLS handshakes.
How
- Every phase, every ping. Each ping opens its own connection, so
DNS, connect, TLS, first byte and download are measured every time. The
TLS session is still resumed when the server allows, and the report
gives full and resumed handshake medians apart.
--keep-alivekeeps the connection, as a browser would. Redirects are followed, each hop with its status and time. - Three views. The graph (braille or dots, zoom, whole run, a floor at zero, a log scale, failures and SLO breaks marked on top); the phases of the picked target's last and median ping as a stacked bar, with curl's running totals, the TLS version, cipher and ALPN, and the certificate's subject, issuer and expiry; and the pings themselves, each openable to its hops, headers, TLS and body.
- The table. last, min, avg, max, jitter, p95, p99, loss, timeouts and sent, over what the graph shows or the whole run.
- Targets. URLs, bare hosts, host:port,
aws:REGION, address ranges,--all-ips,--tcpwith--tcp-rst,--cmd;-4,-6and--interface. - Limits and output.
--slo total=500,ttfb=200,...marks a ping and sets the exit code to 4;--bellrings the terminal; exit 1 when nothing ever answered. Piped, or with--jsonl,--csvor--json, there is no screen.--savewrites the JSON report,--save-bodythe last body. - A small X.509 reader (
src/cert.rs) for the subject, issuer, validity and DNS names, with no new dependency. - Man page, completions, README section, roadmap entry, and an agent
skill under
skills/pepe/(as httpstat ships).
Where
src/ping.rs: targets, the engine, stats, and the text, JSON, JSON Lines and CSV outputssrc/ui/ping.rs: the screensrc/cert.rs: the certificate readersrc/cli.rs,src/main.rs: the subcommand and its wiringsrc/direct.rs:tls_configmadepub(crate), nothing else
Checked
cargo test: 261 passed, including a live ping against a local server with a redirect, a closed port, a TCP ping and a command; the screen rendered at several sizes throughTestBackendcargo clippy --locked --all-targets -- -D warningsandcargo fmt --checkclean- The release binary against example.com in text,
--jsonl,--csvand--jsonmodes; exit codes 4 (--slo) and 1 (nothing answered);--saveand--save-body; the live screen recorded in a pseudo-terminal
Left out on purpose: packet size (meaningless for HTTP;
-dsends a body), shell completion of hostnames, an HTML report (on the roadmap already), gping's--clear(pepe leaves only the summary), and HTTP/2 (the roadmap says not now). - Every phase, every ping. Each ping opens its own connection, so
DNS, connect, TLS, first byte and download are measured every time. The
TLS session is still resumed when the server allows, and the report
gives full and resumed handshake medians apart.
0.24.0 - 2026-10-10
Added
-
(compare) the verdict as an SVG card and as a badge (#119)
Third of three, on top of #118 (the action's baseline and comment), which is on top of #117 (
pepe compare). Merge in that order.pepe compare --svg card.svgdraws the verdict the way the dashboard draws: the word in its colour, then p99, median, throughput, capacity and failures before and after, each with what moved or the spread it stayed within. Self-contained SVG in pepe's palette, for a README, a site or a report. The README shows one:
- The JSON gains
badge: the verdict as a badge's three parts (pepe,slower · p99 +38%, a colour) for shields.io and the like. - The action's comment shows that badge under its heading, since a
comment can only show an image by URL and nothing in the action hosts a
file. The card goes to the run's artifacts, with a
cardoutput for its path; a repository that wants the card itself on the comment can push it to a branch and link it.
Squash-merge with the title as it is.
0.23.0 - 2026-10-09
Added
-
(action) a pull request is held against its base branch, in a comment (#118)
Second of three, on top of #117 (
pepe compare). Merge #117 first and let its release go out: the action runs the released binary, so merged before that,baselinewould fail with "needs pepe 0.21 or newer". The CI on this PR doesn't wait for it: the action's test now builds pepe from the branch, which is what the action prefers to installing a release.Three inputs on the action:
baseline: autokeeps every branch's last report in the Actions cache and holds a pull request against its base branch's withpepe compare; a path names a report file instead. A pull request only reads the cache; a branch's own runs write it, so the first run on the base branch after this is added makes the baseline, and until then the comment says so.comment: trueposts the result on the pull request as one comment, found again by its marker and updated on every push: the verdict in the heading, a table of p99, median, throughput, capacity and failures before and after, each with its change or "within the usual spread", then the findings. Needspull-requests: write; warns rather than fails without it, as on a fork.gate: truefails the step on Slower or Worse.
Two new outputs,
verdictandcompare. The README's action section shows the comment.The action's test runs it twice, the second time against the first as its baseline, gated, and commenting on this very pull request, so the comment should appear below.
Squash-merge with the title as it is.
0.22.1 - 2026-10-09
Fixed
-
(logs) the traffic chart fills its panel instead of half of it (#121)
The bug in the screenshot: the dashboard's traffic chart has its bars in the right half of the panel and the left half blank.
The chart took the finest round grain whose bars fit the panel, then widened the bars only when they covered less than half of it. An hour at 30s a bar is 120 bars, so on a terminal 230 cells wide the bars sat in the right half; ten minutes at 5s a bar did the same on anything wider than 240 cells, and the README's own recording (#114) shows it too.
Now there is a bar per column, each over its share of the seconds, and a bar holds a rate rather than a count, so bars of 4 and of 5 seconds stand level; the title says "~4s a bar" when the share isn't whole. Short spans still get wider bars. A test draws the dashboard at six widths, 80 to 300 columns, and wants the bars over at least three quarters of the panel; before the fix it failed at 240 and 300.
Once this and #114 are both in,
assets/record.sh assets/tapes/logs.tapeshould be run again so the README's logs recording shows a full chart.Squash-merge with the title as it is.
0.22.0 - 2026-10-09
Added
-
(compare)
pepe compare before.json after.jsonsays what moved (#117)First of three: this, then the action's baseline and PR comment (#2), then the card (#3). Each is based on master and contains the one before; merge them in order, and a release has to carry this one before the action PR is merged, because the action runs the released binary.
Two runs of the same build never give the same p99.
pepe compare before.json after.jsonholds a report against an earlier one and calls a number a change only when it moved more than two runs like these wobble on their own: the run's own latency spread, scaled by how many requests sit past the percentile. Measured against a local server, the p99 of 2,000-request runs sat ±25% apart and that of 20,000-request runs ±1%; the model is fitted to that, with a 5% floor, and 10% for a ramp's capacity fit.- The verdict is Faster, About the same, Slower, or, when failures appeared or rose, Worse (Better when they fell): failures outrank speed.
- Findings in the end-of-run report's shape, coloured in the shell:
p99 up 38%: 120.0ms → 166.0ms,Median within the usual spread: 30.00ms → 31.00ms (±5%),A long tail is new: p99 is 5.4× the median, was 4.0×. Different targets or concurrency are said first. - Run, API, flow and replay reports compare their summary; ramp reports their capacity estimate and the level that held.
--gateexits 1 on Slower or Worse, for CI.--jsongives the verdict, every number with its change and the spread it was held against, and the findings.- Every report now carries a
targetblock (mode, method, URL or source, concurrency), so the comparison can tell two tests apart.
Tried on real reports: two identical 2,000-request runs read "About the same (p99 ±24%)"; the same run against a slower endpoint reads "Slower", and
--gateexits 1.Squash-merge with the title as it is.
0.21.0 - 2026-10-09
Added
-
(api)
pepe apialone asks for the spec, which can be pasted whole (#114)pepe apiwith nothing after it printed an error. Now, at a terminal, it opens the setup screen in API mode with the Spec field focused, the waypepealone opens it for a URL. The field takes a file, a URL, or the OpenAPI document itself: paste the whole spec anywhere on the form (multi-line text, or text opening a JSON object, is the document; one line is a name) and the field says "the pasted spec, 312 lines"; backspace or typing starts over with a name. Piped,pepe apistill errors as before.- The loader takes the document as a source too, so
pepe api "$(cat spec.json)"works, and a pasted spec that names servers with://is no longer taken for a URL origin. The command printed on quit saysapi '<the pasted spec>'where there is no file to name. - The README's recordings are redone for the panel UI: run, setup, ramp
and API, which now shows
pepe apiasking for the spec, and a new one forpepe logsin the nginx logs section.record.shwrites a day of nginx logs for it and keeps appending to them while recording, so "now" is a live number rather than a log that stopped. - Completions and man page regenerated for the new help text.
Squash-merge with the title as it is.
- The loader takes the document as a source too, so
0.20.2 - 2026-10-09
Fixed
-
(action) a description short enough for the Marketplace (#113)
The Marketplace refuses to publish the action: its description must be under 125 characters, and the one in action.yml was 137. Now 124, saying the same thing.
The Marketplace reads action.yml at the release's tag, so this needs a release before the "Publish this Action to the GitHub Marketplace" box can be ticked. Squash-merge with the title as it is so release-plz opens the release PR; publish from that release.
Other
-
(action) every release moves the v0 tag, so the action is pinned like others (#111)
The repository is also a GitHub Action, and the README told people to use it at
@master. Actions are pinned to a floating major tag (actions/checkout@v7), and the Marketplace lists releases, so pepe should have both.publish-action.ymlis a dist publish job like the Homebrew, R2 and Docker ones: once the GitHub Release for vX.Y.Z exists, it moves thevXtag (v0today,v1after 1.0) to the same commit, withGITHUB_TOKEN, which starts no further workflow.- The README and the site now say
omarmhaimdat/pepe@v0. - Added to
release.ymlby hand:dist generaterefuses to rewrite it whileallow-dirty = ["ci"]is set (it is, so Dependabot can bump the actions in it).
Not in this PR, because GitHub has no API for it: the Marketplace listing takes a one-time click on one release's edit page, "Publish this Action to the GitHub Marketplace", after accepting the Marketplace Developer Agreement. After that, the listing follows the releases by itself.
Squash-merge with the title as it is. It's a
ci:commit, so it rides along with the next feat/fix release; that release is the first one to movev0.
0.20.1 - 2026-10-09
Fixed
-
(ui) a card's name and the numbers beside it no longer run together (#110)
What
On the load test's dashboard (the tall layout with the four number cards), the first card's name and the text at its right overlapped once the rate reached three digits:
requests / savg 249 · peak 268 before requests / s avg 249 afterThe card is 30 cells wide inside;
requests / sis 12 andavg 249 · peak 268is 18, so they touched with nothing between. At four digits and up (avg 12.3k · peak 15.6k) the numbers were drawn over the end of the name.Fix
In
render_cards(src/ui/view.rs), what is beside a card's name gets the room the name leaves, less a two-cell gap, through the existingfit_parts: the parts that fit are shown, and the rest give way from the right. So at three digits and up the card saysavg 249and dropspeak 268; the peak is still on the Live view's throughput chart and in the Stats view. The other three cards have one part each and are unchanged at any value seen so far.For the reviewer
- It is the peak that goes, not the average, only because it is second. If the peak is the one worth keeping, swap the two.
- Found while making the site's pictures:
site/img/api.pngshows the overlap ("requests / savg 249"). It can be redrawn once this is in; I have not redrawn it here.
Tested
- New test
a_card_keeps_a_gap_between_its_name_and_what_is_beside_it: the header drawn at 40, 240 and 12,000 requests a second. Both parts at two digits, a gap and no "savg" at three, a gap at thousands. - 249 tests pass; clippy 1.98
-D warningsandcargo fmt --checkclean. - Not looked at in a terminal; the test reads the drawn row.
0.20.0 - 2026-10-09
Added
-
(logs) a dashboard opens first, and every view is on panels (#108)
The same change as #105, this time into
master.#105 was opened on top of #104's branch (
fix/logs-live) so that its diff would show only the screen. #104 was then squash-merged, its branch stayed, and #105 was merged into that branch, not intomaster. So the dashboard never reachedmasterand release-plz had nothing to release. This is #105's one commit cherry-picked onto today'smaster; nothing else is in it.Squash-merge it with the title as it is (
feat(logs): …), so release-plz opens the release PR. The site merged in #107 already shows this dashboard in its Logs picture, so the release that carries the site should carry this too.What (from #105)
pepe logsopened on a table of minutes. It now opens on a Dashboard, and Traffic, Paths, Errors and Log are views 2 to 5.- Three numbers drawn large: the rate now with a sparkline and how it stands against a usual minute; the share answering 5xx; the request time's p50 with p90 and p99.
- A verdict beside them, in the colour of the server's health and never of its load: Steady, Busy, Quiet, Degraded, Failing, or Ended, with the path answering the most 5xx and the error log's most frequent message.
- Traffic, a bar for every few seconds as far back as is known, up to an hour, with 4xx and 5xx in yellow and red in proportion, and a mark under bars whose 5xx are too few to show.
- Top paths, status codes, the error log by message, and the newest lines.
- The other views are each on a panel, with the numbers in one line above them; bars that say how much are one colour everywhere, so green, yellow and red mean health.
- The number keys are now 1 to 5: Traffic was
1and is2.
Tested
- 248 tests pass on this branch; clippy 1.98
-D warningsandcargo fmt --checkclean. - As in #105: every view was rendered through pepe's theme to an image and looked at, and the release binary was driven through all five views on a live log in a pseudo-terminal. That was on #105's branch; on this one only the tests and lints were run, the code being the same commit.
Other
-
(site) pepe.mhaimdat.com as a terminal, drawn the way pepe draws (#107)
What
site/index.htmlwas an install box and a GIF. It is now a site that looks and behaves like pepe itself. (The first commit here is a conventional landing page; the second replaces it, after it was rightly called generic. Squash them.)- One typeface, flat panels on the warm ground, no gradients: the
palette is
src/ui/theme.rs's, and the tabs, the key chips on the bottom line and the bar on the hovered table row are drawn as the dashboard draws them. - Pepe and the big digits are pixels: Pepe is taken from the cells
the dashboard draws him in, and
398,2.4,4.0are set inbigtext.rs's block face, both as inline SVG. - Four of pepe's screens, in the page without a frame: Run, Ramp, API and Logs, each with the command that makes it. The pictures have the page's own background, so they read as part of it.
- The keys work:
1-4,taband the arrows switch screens;igoes to the install line andccopies it;g,d,bopen GitHub, the docs and the benchmarks;?lists them. - What it does as a table of eleven commands, and against the others as bars: the README's Linux figures for pepe, wrk and oha, with a link to the benchmark notes for the method and for where pepe is level rather than ahead.
- The install box as before (opens on the visitor's platform, copy button), now with a Docker tab.
- Title, description, canonical, Open Graph and Twitter tags, JSON-LD,
and
img/og.pngfor links to unfold into.
One static file, no build step, no dependencies, no tracking. It stacks down to phone widths, where the table drops its last column.
The pictures
site/img/{run,ramp,api,logs}.pngare pepe's own drawing code, rendered to cells through the pepe theme (as thepreviewtest insrc/ui/view.rsdoes) and drawn at 2× by headless Chrome. The data is made up: the tests' sample run, a simulated ramp that saturates, ten invented endpoints, a generated nginx log.Deploy
publish-r2.ymluploadssite/img/*.pngto/img/and checks they answer 200. The site goes out with the next release;docs:doesn't make one on its own.For the reviewer
logs.pngshows the dashboard from #105, which is not merged. If #105 doesn't land, that picture wants replacing.https://pepe.mhaimdat.com/answers 404 today; only/index.htmlanswers. The bucket has no index for the root, and the repo's website link, the canonical andog:urlall point at the root. It needs a rule on the Cloudflare side.- A bug the API picture shows: in the dashboard's first card,
"requests / s" and "avg 249 · peak 268" overlap ("requests / savg") when
the numbers are three digits. That is in
src/ui/view.rs, not here. - The verdict panel beside the numbers is an example of the wording.
og.pngis still the first design's (headline, command, the run screen); it suits either.- Single-key shortcuts are ignored while a modifier is held or a field
has focus.
tabswitches screens only when nothing on the page has focus, so keyboard navigation of the links still works once you have tabbed in.
Tested
- Looked at in headless Chrome at 1400 and 520 px wide. Two things found that way and fixed: the hero Pepe had picked up the edge of the card beside him, and a table header wrapped.
- The keys and the copy button are not exercised by anything automatic, and I have not pressed them in a real browser: headless Chrome only took pictures. Safari and Firefox are unchecked.
- One typeface, flat panels on the warm ground, no gradients: the
palette is
0.19.3 - 2026-10-09
Fixed
-
(logs) at a terminal a log being written is shown live, not read from its start (#104)
What
pepe logson a host read the whole log before the screen had anything to say about now, and the numbers on it were the log's history rather than the server's present. At a terminal, a log that is being written is now shown live.- Live by default. If the last line of any of the files is from the
last five minutes, the screen starts five minutes back (or
--windowback, if that is longer) and follows from there. Five minutes rather than none, so that "now" is right from the first frame and the chart has bars in it. The title says where the counts start:● live · from 15:33:40. --since 24hstarts further back, as before;--all(new) reads everything first. The two conflict.- A log nobody is writing is read whole, as before: it has no now, and starting five minutes before the clock would show nothing.
- Unchanged: piped out or
--json(a report of the whole log, or of--since), and what is piped in.
How
seek_sincefinds where a time starts in a file by halving it, a log being in order of time, and reading 64 KB at each step. What it can't tell (no dated line in the 64 KB) it settles toward the top of the file, so more is read, never less. Lines before the time that are still read are left out by the filter that was already there.- This serves
--sinceeverywhere, not only the live default: on a two-day, 193 MB log--since 1htakes 0.03 s where the whole log takes 0.11 s, and the gap grows with the file. - The multi-threaded read takes a place to start from, so
--since 30dof a large log still uses every core. being_writtenreads the last 64 KB of each file for its last timestamp.
For the reviewer
- This changes what
pepe logs access.logshows at a terminal on a live host: the cards (a usual minute, the busiest) now describe the last minutes until the screen has been open longer, and "an hour ago" / "a day ago" are empty without--sinceor--all. - A file whose lines aren't in time order can be taken up at the wrong
place;
--allreads it whole. - The error log's times are this machine's, so on a machine in another
zone than the server it can be judged written or not wrongly;
--sinceand--allsay it outright. - Titled
fixso it is a patch release.
Tested
- New test: a six-hour log with undated lines, taken up 1 s, 5 min, 1 h
and nearly 6 h back; the place is a line's start, at most 128 KB before
the first line wanted, the counts are exactly the requests since, and
every byte is accounted for in the progress. Also before the log's
start, after its end, a missing file, and
being_writteneither side of five minutes. - 247 tests pass; clippy 1.98
-D warningsandcargo fmt --checkclean; man page and completions regenerated for--all. - By hand: a generated two-day log ending now, with its error log, on
the screen in a pseudo-terminal: it opens five minutes back (11,058
requests of 1.3M), shows
● live, and counts lines appended while it runs. Reading a whole 3.45 GB log takes the same time as on master, within the noise of alternating runs. - Not run against a real nginx host, nor on Linux or Windows.
- Live by default. If the last line of any of the files is from the
last five minutes, the screen starts five minutes back (or
0.19.2 - 2026-10-09
Fixed
-
(logs) the screen survives what else is written to the terminal, and reads compose's colour (#102)
What
docker compose logs -f nginx | pepe logsleft the screen in ruins (doubled header rows, blank path names, shifted columns,nginx_twitterlisted as a client). Two causes, both outside pepe's drawing code:- compose's stderr still points at the terminal; its
WARN[0000] … version is obsoleteline landed at the bottom row, where the hidden cursor sat, and scrolled the alternate screen up one row. ratatui only redraws what changed, so every later frame was one row off the truth. - compose colours the
nginx_twitter |prefix even into a pipe; the escapes went into the parse (client = container name) and into drawn cells, where the terminal interpreted them and shifted columns.
Changes
- Lines are cleaned at ingest (
logs::clean): CSI/OSC escapes stripped, tabs → spaces, other control characters dropped. Nothing read from a log can move the cursor. - The screen repairs itself: the hidden cursor is parked at the
top-left after every frame, so stray output overwrites a row rather than
scrolling; and the whole frame is rewritten cell-for-cell every second
(
REPAINT), so damage heals. Verified against a fake coloured compose stream writing to stderr every few seconds. - A pipe is caught up with from its first chunk — it has no end to
be short of. The title no longer says
readingforever; an empty pipe says Waiting for the first line. - Design: number cards are fixed-width panels packed from the left (they were stretched across all 200 columns); chart bars cap at 4 wide with the picked one in the accent colour, and the pick's marker no longer stamps over the date label; the paths list is capped at 96 columns so the numbers sit next to the names.
- CLI: a bare word given as the URL (
pepe logson a 0.16 binary gaveInvalid URL "logs": relative URL without a base) now says it isn't a URL nor a command this pepe has, lists the commands from clap, and points atpepe self-update; a host with no scheme is shown withhttps://in front.
Notes for review
- The full repaint is ~15 KB/s over SSH on a 200×60 terminal;
REPAINTis one constant if that ever needs slowing. view::panelandview::insetare nowpub(super)so the logs screen shares the dashboard's cards.- Tests: colour/control stripping, the piped caught-up state, the empty-pipe wording, and the three URL messages. 246 pass; clippy and fmt clean.
🤖 Generated with Claude Code
- compose's stderr still points at the terminal; its
0.19.1 - 2026-10-08
Fixed
-
(logs) a log is read by every core, ten times as fast (#100)
What
pepe logsread a file on one thread. What a file already has is now read by every core, and the per-line work is cheaper for every way of reading. No flag, no change to what is shown: the--jsonreport is the same to the byte as before.| 862 MB, 5.86M lines, M4 Pro (10P + 4E) | time | lines/s | MB/s | memory | | --- | ---: | ---: | ---: | ---: | | master, a file | 1.99 s | 2.9M | 433 | 16 MB | | this PR, a file | 0.20 s | 29M | 4,300 | 119 MB | | master, piped in | 2.00 s | 2.9M | 431 | 16 MB | | this PR, piped in (one thread) | 1.54 s | 3.8M | 560 | 16 MB | |
wc -l, for scale | 0.65 s | | 1,330 | |Best of five, file in the page cache. A 3.45 GB file takes 0.84 s. Details and the list of what was found are in
bench/README.md.This is ten times, not a hundred. A hundred times the starting point would be 43 GB/s, which is more than this machine can copy out of the page cache, let alone parse. What is left is spread evenly over reading from the kernel, finding the fields and looking up three names a line.
How
- Every core (
sprintinsrc/logs.rs): threads take the file a megabyte at a time withpread, count the lines that start in their megabyte into counts of their own, and add those to the shared ones once a second, so the screen fills in while a long log is read. The file's last partial megabyte, and everything appended after, goes to the one thread that follows the file, as before. - Same result in any order (
Stats::merge): sums, minima and maxima. The one thing a cut can split is the count of the second it falls in, which the "busiest second" figures need whole; each stretch's first and last runs of one second are kept and put together again when merged. The first unread line and each error message's example are chosen by place in the file, and the last 2,000 lines by time and then place. - A keyed hash in place of SipHash for the maps of paths, clients,
user agents and parameter names: eight bytes at a time through a folded
multiply, under a key drawn from
RandomStatewhen pepe starts. The names come from whoever sends requests, which is why it is keyed. - Less per line: a request in the same second as the one before it counts into the slots already held, with no lookup; a timestamp written as the last one was isn't worked out again; a path with an id in it is rewritten into a kept buffer; lines are copied for the log view only within 32 MB of a file's end.
For the reviewer
- Memory while a file is read goes from 16 MB to about 120 MB with 14 threads on this log (each thread's megabyte, and its own counts of up to 50,000 client addresses between merges). It doesn't grow with the file. Piped input is unchanged at 16 MB.
- Files under 16 MB, stdin and followed appends take the single-threaded path.
- Once a log has more distinct names than a cap (20,000 paths, 50,000 clients), which names are kept past the cap can differ from run to run, since threads merge in no fixed order. Totals don't change.
- A log whose lines aren't in time order can report a different busiest second per slot than one thread would: both are approximations there.
- The hash is not SipHash. It is keyed per process, but it has had no
cryptanalysis; if that trade isn't wanted,
Keyedis one type to swap back. seek_readis used on Windows in place ofpread. That path is compiled and tested only by CI.- The title says
fixso release-plz makes this a patch release; by the changelog's own groups it isperf.
Tested
- New:
every_core_reading_counts_what_one_wouldreads one log (seconds of 0 to 40 requests, error log lines, unreadable lines, a line longer than a stretch, bytes that aren't UTF-8, an unfinished last line,--since) on one thread and then with stretches of 1 KB, 3 KB, 64 KB and 1 MB on 2 to 8 threads, and compares the JSON report, the kept lines, the busiest second and the first unread line.names_are_found_by_a_keyed_hashcovers the hash and merging at a cap. - 245 tests pass;
cargo clippy --all-targets -- -D warningson 1.98 andcargo fmt --checkare clean. - The release binary's
--jsonfor the 862 MB access log plus its error log is identical (cmp) to master's. - The live screen was driven in a pseudo-terminal on the same files, with lines appended while it ran.
- Not measured on Linux or Windows, and not against another log reader.
- Every core (
0.19.0 - 2026-10-08
Added
-
pepe logs, nginx's traffic now against each minute, hour and day (#98)
What
A new subcommand,
pepe logs, that reads nginx's access and error logs and says how busy the server is now against how busy it has been.pepe logs /var/log/nginx/access.log /var/log/nginx/error.log zcat access.log.*.gz | pepe logs - docker compose logs -f -n 1000 nginx | pepe logs pepe logs access.log --since 24h --json > traffic.json- Now is the request rate over the last minute (
--window) of the log's own timestamps. A log whose last line is older than five minutes is held at its last line instead of the clock. - Each minute, hour and day has its requests, req/s, busiest second,
4xx and 5xx shares, mean request time and error log lines, and how now
compares (
+12%,×3.4,÷2.5). Cards say what a usual slot sees (the median), which was the busiest, and what the same slot an hour, a day or a week ago saw. - Read as a stream: lines are folded into counts of a fixed size (an hour of seconds, a day of minutes, ninety days of hours, ten years of days; capped maps of paths, clients, user agents), so memory doesn't grow with the log.
- Four views: Traffic (a bar per slot with the rate now drawn
across, and the table;
mhd), Paths (by requests, 5xx, 4xx or mean time, with statuses, clients, user agents, methods and query parameter names), Errors (the error log grouped by cause, and the paths answering 5xx and 4xx), Log (the last 2,000 lines of all files in time order;xerrors only,/search,entereverything read from a line). - Formats: nginx
combined(andrt=/urt=after it), a--format '<log_format>'as nginx.conf has it, JSON lines under nginx's or Caddy's names, and the error log. A line that fits none is still searched for a time, a request and a status; what can't be read is counted and the first such line shown. - At a terminal the files are followed, through rotation. Piped out, or
with
--json, they are read to the end and a report is printed. With no file named and nothing piped in,/var/log/nginx/access.loganderror.logare read if they are there.
Where
src/logs.rs: timestamps, the line readers, the counts, the file follower, the text and JSON reports.src/ui/logs.rs: the screen.src/cli.rs,src/main.rs:LogsArgsandrun_logs.- README section "Reading nginx logs", a ROADMAP entry,
pepe-logs(1)and the completions regenerated.
No new dependency: dates are worked out by hand, and the local offset comes from
libc, which is already there on Unix.For the reviewer
- Paths are grouped by default: numbers, UUIDs and long hex ids in a
path count as one (
/items/*);--exact-pathsturns that off. - Piped input with the screen (Unix): the pipe is put aside with
dupand the terminal, opened by its own name fromttyname_r(stdout), takes descriptor 0, so crossterm reads keys as usual./dev/ttyitself can't be polled on macOS. On Windows piped input gets the report, not the screen. - The error log names no time zone; its times are taken to be this machine's.
- No
.gz: that would need a dependency;zcat … | pepe logs -does it. - Only
/var/log/nginx/is looked in by default; nginx.conf isn't read for other paths or for thelog_format. - The global load flags (
-n,-c, …) show inpepe logs --helpand do nothing there, as withcompletionsandself-update.
Tested
- 14 new tests (243 in all pass;
cargo clippy --all-targets -- -D warningsandcargo fmt --checkclean): every timestamp form, combined / custom format / JSON / error log lines, cause grouping, slot rates and now-versus, flat memory over three days of lines, files read oldest first, followed and reopened after rotation, and every view drawn at 60×16 up to 200×60 with its keys. - By hand on a generated three-day log (1.96M lines, 288 MB, plus an
error log): read in about 1.4 s with 17 MB resident on an M-series Mac.
This is one run, not a
bench/record. - The live screen was driven in a pseudo-terminal, with lines appended
to the file and with input piped in the shape
docker compose logsgives. It has not been run against a real nginx or a real Docker container, nor on Linux or Windows.
- Now is the request rate over the last minute (
0.18.0 - 2026-10-08
Added
-
(ui) pepe's own theme, a new Pepe, and the dashboard on panels (#96)
What
- pepe's own palette, the default in true-colour terminals: warm
darks on a painted ground, one ember accent, a heatmap that glows from
embers to flame.
PEPE_THEME=pepe|terminal|light|nonepicks a theme; otherwiseCOLORTERM(truecolor,24bit) chooses pepe's palette andCOLORFGBGstill picks light.NO_COLORwins over all. - A new Pepe, with arms that pose with his mood, in two sizes: 16×16 everywhere, 26×26 in the big header. The update notice prints true-colour cells too.
- The dashboard on panels. On terminals 146×46 and up the header has the big Pepe, the four numbers on cards in a new 5×7 face, and a run panel: progress, where p99 sits on a heat scale from bell to ghost, and what the run says so far. The Live charts, latest requests, errors, stats column and each Stats card sit on panels; outside pepe's palette panels keep only their spacing.
- An ignored test,
preview, writes each tab as HTML for screenshots.
Review fixes
- The big header started at 120 columns, where the run panel had 14 cells and its text collided; it now starts at 146, and the size sweep renders 120×46 and 146×46.
- The p99 heat is coloured by where it sits rather than always yellow.
inspector_says_the_list_is_paused_while_livefailed withCOLORTERM=truecolor; it no longer depends on the theme.- Unreachable big-mascot branches in the small header are removed.
load: the shard-peak test reads the peak once the first shard's early requests are answered.
Testing
cargo fmt --check,cargo clippy --locked --all-targets -- -D warningscargo test(230 pass) withCOLORTERM=truecolor,COLORTERMunset,PEPE_THEME=lightandNO_COLOR=1previewrendered at 146×46 in pepe's theme and the run panel checked
🤖 Generated with Claude Code
- pepe's own palette, the default in true-colour terminals: warm
darks on a painted ground, one ember accent, a heatmap that glows from
embers to flame.
0.17.1 - 2026-10-08
Fixed
Other
-
bump the github-actions group across 1 directory with 5 updates (#94)
Bumps the github-actions group with 5 updates in the / directory:
Package From To actions/checkout 67docker/setup-buildx-action 34docker/build-push-action 67docker/setup-qemu-action 34docker/login-action 34Updates
actions/checkoutfrom 6 to 7Release notes
Updates
docker/setup-buildx-actionfrom 3 to 4Release notes
Updates
docker/build-push-actionfrom 6 to 7Release notes
Updates
docker/setup-qemu-actionfrom 3 to 4Release notes
Updates
docker/login-actionfrom 3 to 4Release notes
Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting
@dependabot rebase.
0.17.0 - 2026-10-07
Added
-
pepe's own HTTP/1.1 sender, --threads auto, a Rust bench server (#91)
Roadmap section 6, Speed on Linux. Through v0.16 pepe spent as much CPU per request as oha on Linux, twice wrk's, and reached half oha's throughput. This measures why and removes it.
What
- pepe reads and writes its connections itself (
src/direct.rs,src/wire.rs). Each worker keeps one HTTP/1.1 connection per origin. The request is bytes made before the run and sent with one write; the response is parsed where it was read (httparse for the head, a chunked decoder of pepe's own); nothing is allocated for a request, and a thread's connections share one 128 KB read buffer. reqwest is left what only it does: proxies, redirects (the first redirect a target answers with hands that target to reqwest), URLs with credentials, and flows. - Less per result and per connection: results cross to the counting thread in 32 bytes instead of 184; reqwest and the TLS configuration are built when first needed; one timer per worker instead of one per request; the response's headers are read in one pass instead of twenty lookups; a worker keeps the body of four failures per status, not of every one.
--threads auto(alsothreads = "auto"inpepe.toml): adds a sending thread when those sending are all past 90% of a core, and takes it back if it brought less than a third of what a thread is worth. The default stays one thread.- Benchmarks: the target server is Rust (
bench/server, its own crate;bench/server.gois gone).bench/run.shmeasures wrk and k6 when installed, has amillion-c64workload andMILLIONS=N. The CI Linux record builds pepe for musl, as the releases are.
Numbers
Linux (4-vCPU arm64 VM, musl build), one thread each, CPU ms per 1,000 requests / peak MB / req/s, better of two runs:
Workload v0.16.0 this wrk -t1oha GET, 64 connections 7.8 / 7.8 / 127k 2.4 / 4.0 / 398k 3.5 / 4.5 / 282k 6.8 / 67 / 305k GET, 1,000 connections 13.3 / 51.8 / 73k 3.2 / 5.8 / 296k 4.4 / 7.7 / 228k 5.6 / 73 / 183k 16 KB bodies 9.2 / 9.8 / 99k 4.2 / 3.9 / 198k 5.8 / 4.5 / 172k 9.6 / 25 / 196k HTTPS 7.6 / 9.3 / 129k 3.3 / 6.0 / 284k 4.5 / 10.8 / 218k 8.1 / 42 / 250k 10M requests, 256 connections — 2.9 / 4.5 / 343k 3.3 / 4.6 / 304k 6.5 / 2,404 / 316k macOS (M4 Pro): 9.8 / 14.0 / 102k → 6.2 / 8.8 / 160k for plain GETs; oha 23.0 / 77.5 / 161k.
Every workload, each step's own measurement, the profiles and the CSVs are in
bench/README.mdunder "The direct path".Where pepe is not first
- A slow target at 1,000 connections, and 16 KB bodies over TLS: level with wrk on CPU, inside each other's run-to-run range.
- Throughput against all-core tools: one pepe thread trails oha's four
on three rows;
--threads 2leads or is level. - Memory on a glibc build: 6.0 MB against wrk's 4.5 at 64 connections (the binary's code pages and libc). On the musl build pepe holds less.
For the reviewer
- Behaviour change: a header given twice with
-Hnow goes out twice. Through reqwest's default headers only the last was sent. - A request on a kept connection that turns out closed is sent again once on a new one, only for GET, HEAD, OPTIONS and TRACE.
- The Linux numbers are from an arm64 VM on a laptop that was in use; single rows moved by about 20% between runs. The x86 numbers come from the Benchmarks workflow on this PR ("bench" in the title runs it).
- Not run on Windows beyond what CI does; the direct path has no platform-specific code.
site/index.htmlstill quotes the old figures.- clippy on Rust 1.99 reports
fetch_updateas deprecated (four uses already on master, one added here); CI pins 1.98.
Checked
- 226 tests on macOS, Linux glibc and Linux musl;
cargo checkon 1.85;cargo fmt --check,cargo clippy --all-targets -- -D warnings. - 10M and 50M request runs on Linux and four concurrent 5M runs on macOS held against the server's own count: exact, none failed.
- Same outcomes and messages as v0.16.0 on refused connections,
timeouts, bad certificates, DNS failures, 503s, redirects,
--disable-keepalive, and real HTTPS sites with chunked bodies.
- pepe reads and writes its connections itself (
0.16.1 - 2026-10-05
Fixed
-
(deps) bump crossterm from 0.28.1 to 0.29.0 (#23)
Bumps crossterm from 0.28.1 to 0.29.0.
Release notes
Other
-
(nix) update flake.lock (#86)
Automated changes by the update-flake-lock GitHub Action.
Flake lock file updates: • Added input 'nixpkgs': 'github:NixOS/nixpkgs/a7868a727837f3c09cee2ce0ca671c76b1589fed?narHash=sha256-KgItSKML8Xvte0B7/uGnBDsYzSnnKHcOaiUbgWBXLXw%3D' (2026-10-03)Running GitHub Actions on this PR
GitHub Actions will not run workflows on pull requests which are opened by a GitHub Action.
To run GitHub Actions workflows on this PR, close and re-open this pull request.
0.16.0 - 2026-10-04
Added
-
colour means health (#84)
Colour on pepe's screens used to rank: percentiles ran green to red by position, methods had traffic-light colours, redirects were accent cyan, the maximum was always red. None of it said whether anything was wrong. This makes colour mean one thing: whether a number is healthy.
What changes on screen
- Latency is amber only when it is ten times the median, the threshold at which the verdict calls the tail degraded; otherwise it is in the terminal's own colour. The maximum and the slowest-requests list are the tail by definition, so they stay plain.
- Methods are magenta everywhere, as the dashboard drew them. A 3xx is neither good nor bad. Labels and faint text move to indexes that read at 4.8:1 or better, on the selected line too.
- Every screen has a
?overlay with all its keys (F1 on the setup screen, where?is a character you can type), and footer chips that drop from the left and always keep help and quit. - Keys: edit is
eon every screen (Estill works); the failed-only filter moves frometox;home/endjoing/G. README, man page and the key tables are updated. - Charts say why they are empty during the first second, and the request log says what ● means.
Themes and
NO_COLORA theme layer adjusts each drawn frame after rendering, so no drawing code knows about it:
- light (
PEPE_THEME=light, orCOLORFGBGwhere the terminal sets it): the same roles mapped to a light background; the gray ramp turns around so the heatmap's busiest band is the darkest. - none (
NO_COLOR): no colour at all; what had a background is drawn in reverse video, the heatmap in shades ░▒▓█, and the mascot stays home. - The shell report, the update notice and
pepe self-updatefollow the same rule: the verdict comes out coloured when stdout is a terminal andNO_COLORis unset, plain otherwise.
Checked
- Theme detection from
NO_COLOR,PEPE_THEMEandCOLORFGBG; the light mapping keeps the heat ramp ordered and leaves the mascot's pixels alone;NO_COLORreverses fills and shades the heatmap; the report colouring puts each glyph in its colour and leaves the rest alone. - Latency colouring: healthy ratios stay plain, a degraded tail is amber, no median means no judgment.
- The suite passes plainly and with
NO_COLOR=1 PEPE_THEME=lightset, 198 tests each way: the mascot's tests draw the sprite directly, so nothing in the tests depends on the environment. cargo clippy --all-targets,cargo fmt --check. No engine code changes, so no benchmark gate.
Other
-
the roadmap gains speed on Linux and agents (#83)
Two themes and two items for what pepe is meant to become: the fastest load generator, and the one an agent reaches for.
What
- Output and integration gains two items after thresholds: a report
when there's no terminal (without
--json, a piped pepe still tries to open the dashboard), and a versioned report with a published JSON Schema. - 6. Speed on Linux: the reqwest re-parse fix sent upstream, naming
the rest of the CPU gap with oha,
--threads auto, a fixed machine for the numbers with wrk and k6 in the table, and a lean HTTP/1.1 path if the gap is still there. - 7. Agents: agent docs, guardrails (
--allow-host, caps and--dry-run), an MCP server, and the engine as a crate. - The README's "Next up" sentence named work that has shipped; it now names what is open, in this order.
bench/README.mdpointed at "Not now" for the lean HTTP path, which was never listed there; it points at "Speed on Linux".
Notes
- Speed comes before agents, since themes are in the order they're worth doing.
- The flag names are proposals; none of them exist yet.
- Docs only, no code changes.
🤖 Generated with Claude Code
- Output and integration gains two items after thresholds: a report
when there's no terminal (without
-
the roadmap says what shipped, and in which release (#82)
ROADMAP.md with check boxes: every item that landed is ticked with the release that carried it, from the completions in v0.8.0 to the config keys in v0.15.0. What remains keeps its size and its order within the theme.
Shipped, by theme:
- Project health: completions and man pages (v0.8.0, out of the box in v0.9.0); installer smoke tests, the benchmark gate with the Linux record, the Linux profile, the recordings, the install page and the Windows checklist (all v0.10.0). The scheduled benchmark run is struck through as set aside.
- Scale and operations: Docker image and GitHub Action, soak mode
(v0.10.0);
--rate(v0.10.2);--warmup(v0.11.0);pepe.toml(v0.12.0, with the three newer keys in v0.15.0). Distributed runs stay open. - Analysis and insights: all five shipped, error clustering in v0.10.0, latency by phase in v0.10.1, anomaly notes, Server-Timing and the capacity estimate in v0.10.2.
- Scenarios and realism: flows (v0.13.0) and replay (v0.14.0);
data-driven requests stay open, with a note that flows already have the
{{holes}}it would fill. - Output and integration: all seven still open;
--fail-ifnotes that the Action's input covers CI today.
Arrival rate, config file and warm-up were listed under scenarios and are moved to scale and operations, which is the theme they were prioritised and shipped under. A
docs:commit, so no release is triggered.
0.15.0 - 2026-10-04
Added
-
rate, warmup and trace-header in pepe.toml (#78)
The follow-up noted in #68:
pepe.tomlwas written beside--rate(#67),--warmup(#69) and--trace-header(#65), so its schema didn't include them.What
rate,warmupandtrace-headerkeys, with the same precedence as every other key: a typed flag wins, the file fills in what was left unsaid.--write-configandctrl-son the setup screen write them when set, and they read back to the same command line.- README's example gains a
rateline and names the other two.
Checked
- The config tests cover the three keys both ways: read from the file into an untyped run, and written from a command line that sets all three, then parsed back and compared.
cargo test,cargo clippy --all-targets,cargo fmt --check.
0.14.0 - 2026-10-04
Added
-
pepe replay, an access log's URLs in their real proportions (#71)
Roadmap item 13 (scenarios and realism): read an nginx, Caddy or ALB log and send its URLs in their real proportions.
What
pepe replay LOG [--base-url URL] [--include-writes] [--rows N]with the usual-c,-n,-z,-H,-t. Each distinct (method, URL) becomes a target weighted by how often the log had it, and the engine's smooth weighted schedule mixes them evenly, so the mix holds at every moment of the run rather than only on average.- Formats: nginx and Apache common and combined logs and AWS ALB
logs (the quoted
"GET /path HTTP/1.1"), Caddy's JSON access lines (request.method,request.host,request.uri,httpswhentlsis present) and flat JSON withmethodandurl/path, and plain lists of/path,GET /pathor a full URL per line. Blank lines and#comments are skipped; anything else is counted as unparsed. --base-urlgoes in front of paths and replaces the host of full URLs, so production's log runs against staging. Without it, full URLs go where they point and paths can't go anywhere:its 2000 requests have paths but no host: say where to send them with --base-url. A log with nothing readable, or only writes, says that instead.- Writes (POST, PUT, PATCH, DELETE) are left out unless
--include-writes; access logs have no bodies, so they go without one. The 5,000 most frequent URLs are kept; a longer tail is counted and dropped, and the report says so. - Dashboard: the first tab is URLs: the top
--rows(20) with their share of the log, throughput, p50, p99, errors and statuses, then one row4.9% 1,204 other URLs. The title readsREPLAY access.log. - JSON:
replay: { log, requests_in_log, replayed_from_log, distinct_urls, left_out: {unparsed_lines, writes, no_host, rare_urls}, urls: [{method, url, share_in_log, requests, failed_requests, requests_per_second, median_ms, p99_ms, status_codes}] }. - Man page
pepe-replay(1)and completions regenerated; README section "Replaying an access log". New dependency:regex-lite.
Checked
- Unit tests: a combined-log line, an ALB line with a full URL and port, a Caddy JSON line (host and TLS assembled into an https URL), plain forms, and lines that are nothing; counting with writes left out or kept, paths with and without a base, a base replacing a host; the dashboard's rows and the shared tail row, and targets with the right weights and row tags.
- End to end: a generated log of 2,000 GETs over four paths
(60/25/10/5%), 40 POSTs and a garbage line, replayed against the bench
server with
-n 4000 -c 16 --json: shares sent 0.589 / 0.263 / 0.100 / 0.049 against 0.589 / 0.263 / 0.100 / 0.049 in the log, writes and the garbage line reported as left out;--include-writessends the POSTs; no--base-urlexits with the message above; the dashboard (recorded in a pty) shows the URLs tab with shares and the "other URLs" row. cargo test,cargo clippy --all-targets,cargo fmt --check.
0.13.0 - 2026-10-04
Added
-
pepe flow, a sequence of requests where each step feeds the next (#70)
Roadmap item 12 (scenarios and realism): request chaining, a run that is a sequence where a value from one response feeds the next, each step a row on the dashboard.
What
pepe flow FILEwith the usual-c,-n,-z,-H(sent with every step),-tand friends. Each unit of concurrency is one user: it walks the steps in order with its own variables, then starts a new chain.-ncounts chains; a chain that has begun finishes when the plan ends (a stop still cuts it).- The file: TOML,
[vars]for starting values and[[step]]s withname,method,url,headers,body,expectandcapture. Captures arejson:$.path.to[0].value(dotted and bracketed, numbers and booleans as text, null as nothing),header:Name,regex:pattern(first group, viaregex-lite) orbody.{{name}}holes in the url, headers and body are filled per chain; a hole that no[vars]entry or earlier capture fills is rejected when the file is read:step "cart" uses {{token}}, which no earlier step captures and [vars] doesn't set. Unknown keys are rejected too. - Failing steps. A step passes on a 2xx or the status
expectnames. Otherwise, or when a capture finds nothing, the chain ends and the step counts as a failed request with the reason as its cause (nothing for {{token}} in the response,HTTP 401 where 200 was expected), so the verdict's failure clustering says what went wrong. - Dashboard: the Endpoints tab becomes Steps, one row per step
with its throughput, p50, p99, errors and statuses; the title reads
FLOW checkout.Erestarts liker: there is no setup screen for a flow, the file is the setup. - JSON: the usual report plus
flow: { name, chains_started, chains_completed, steps: [{step, requests, failed_requests, requests_per_second, median_ms, p99_ms, status_codes}] }. - Engine: a flow worker beside the target worker;
ResponseStats::with_bodykeeps the headers and up to 1 MiB of body only for steps that capture something, everything else streams and counts as before. Clients are built around the first step's URL so the shared headers and settings apply to every step. - Man page
pepe-flow(1)and completions regenerated; the README has a "Flows" section with a checkout example.
Checked
- Unit tests: templates and the names they miss; captures from JSON (nested, indexed, quoted keys, non-strings, null), headers, regexes and bodies, with bad specs named; flow parsing with every check; a step building its request from captured values.
- Engine test against an in-process token server: five chains of two
steps give ten successes with the token carried in a header and two
values in the URL; a capture that finds nothing fails step one and never
runs step two;
expect = 200against a 401 fails with the message above. - End to end: a three-step login → cart → checkout flow against a local
server,
-n 40 -c 4 --json: 40 chains started and completed, 120 requests,login 200 ×40, cart 200 ×40, checkout 201 ×40. A file with an unfilled hole exits 1 with the step and variable named. The dashboard (recorded in a pty) shows the Steps tab with the three rows and the flow's name in the title. cargo test,cargo clippy --all-targets,cargo fmt --check.
0.12.0 - 2026-10-04
Added
-
pepe.toml, a load test that lives next to the code it tests (#68)
Roadmap item 10 (scale and operations): a config file, which the setup screen can write.
What
pepe.tomlin the current directory is read by every run;--config FILEreads another (and must exist). Precedence is command line > file > defaults. The parser's value sources tell typed flags from defaults, so-c 100typed wins overconcurrency = 50in the file even though 100 is the default. On/off settings can only be switched on from the command line, so the file says them the positive way (keep-alive = false) and can't undo a flag.- Schema:
url,method,headers,body,requests,duration,concurrency,timeout,threads,user-agent,proxy,insecure,compression,keep-alive,redirects,snapshot, plus[ramp](from,to,step,every,until) and[api](spec,server,auth,all,tag,only,skip,set,include-writes) as defaults for those modes. Unknown keys are rejected with the key named and the known ones listed. (rateandtrace-headerjoin the schema once #67 and #65 are in.) - Writing it:
--write-config FILEwrites the settings as given and exits, with the line to run it.ctrl-son the setup screen writes the form to the file that was read, or./pepe.toml, and the status line says✔ saved pepe.toml · pepe here runs it. The file carries two comment lines on how it's used, and leaves defaults out so it says only what was chosen (a ramp's file says the ramp, not a concurrency). The setup header showsfrom pepe.tomlwhen one was read. pepe apiwith no spec on either side saysapi needs a spec: pepe api openapi.yaml, or spec under [api] in pepe.tomlinstead of a parse error.- New dependency:
toml0.8.
Checked
- Unit tests: the file fills in what the command line left unsaid and
typed flags win (including ones equal to the default);
[ramp]and[api]feed their subcommands with typed values winning; settings written with--write-configread back to the same command line, for a plain run and a ramp; a typo is named with its position. - End to end:
--write-configthenpepe --jsonin that directory runs the file's 30 POSTs;-n 7on top sends 7; a typo'd file, a missing--configfile andpepe apiwithout a spec each exit with the message above. The setup screen, driven in a pty: typing a URL andctrl-swrites the file and shows the message; reopening showsfrom pepe.tomlwith the URL prefilled. cargo test,cargo clippy --all-targets,cargo fmt --check; contrib completions and man page regenerated.
0.11.0 - 2026-10-04
Added
-
--warmup, sending before counting (#69)
Roadmap item 11 (scale and operations): a warm-up, so a run's first seconds don't set its numbers.
What
--warmup <TIME>(and a Warm-up field on the setup screen): for that long from the start, requests go out at the run's concurrency but count for nothing. The engine marks each request it starts during the warm-up; a Count plan doesn't claim them, so-n 50 --warmup 2sstill sends 50 counted requests; a Duration plan's deadline moves back by the warm-up, so-z 3s --warmup 2smeasures 3 s and runs 5. A pause during the warm-up extends it, like it does the deadline.- On screen: the title says
◌ warming upwith the time left in place of the clock, the progress line reads0% warming up · 1,204 sent, not counted, and the Stats tab's test card has awarm-up 5s · 1,204 not countedrow. The run's clock, timeline and verdict start when the warm-up ends; nothing from it reaches the metrics, the request log or the report. - JSON:
generator.warmup_sandgenerator.warmup_requests, in--json, API mode and the dashboard's report. - A ramp has no warm-up: its first step is one. A value without a unit
is rejected like
-zwould.
Checked
- Engine tests: a Count plan of 10 with a 200 ms warm-up yields exactly 10 unmarked results plus some marked ones; a 200 ms Duration plan with a 200 ms warm-up runs at least 390 ms and has both kinds.
- End to end against the bench server:
--warmup 2s -n 50reports 50 requests over 174 ms with 668 warm-up requests, in 2.8 s of wall time;--warmup 2s -z 3sreports 3,026 ms measured in 5.0 s of wall time; the dashboard (recorded in a pty) shows the warming-up title, the time left and the progress line, then a verdict over the measured part only. cargo test,cargo clippy --all-targets,cargo fmt --check; contrib completions and man page regenerated.
0.10.2 - 2026-10-04
Fixed
-
(ci) release-plz saw only merge commits, so no release came (#74)
Since v0.10.1, four feature PRs merged (#66, #63, #65, #67) and every release-plz run ended with
pepe: no commit matches the release_commits regex, so no release PR appeared.Why
Reproduced locally with the same release-plz (0.3.169) on a clone of master. It walks
git login commit-date order, checking each commit out until it reaches one that is an ancestor of the last release tag. The feature commits were authored on Oct 3 and merged on Oct 4, after the v0.10.1 tag (Oct 4, 09:27), so the walk reaches the tag first and never sees them. The commits it does see are the merge commits, whose subjects start withMerge pull requestorMerge master into, whichrelease_commits = "^(feat|fix|perf)[(:!]"rejects. v0.10.1 only happened because #72 was a single commit newer than the v0.10.0 tag, which git's path simplification walks straight into.Widening the regex is not a fix: git skips two of the four merge commits as tree-same with their branch, so the result was a patch bump with a changelog of "Merge master into…" lines.
Fix
- Squash-merge pull requests from now on, with the PR title as the
commit subject. That gives master one conventional commit per PR, dated
when it lands, which is what release-plz expects. This is a repository
setting, not something in this PR:
gh api -X PATCH repos/omarmhaimdat/pepe -F allow_squash_merge=true -f squash_merge_commit_title=PR_TITLE -f squash_merge_commit_message=PR_BODY -F allow_merge_commit=false
2. This PR makes the changelog skip `Merge …` commits, so a merge commit that does get counted doesn't become an entry. 3. The four features that already merged are written under `Unreleased` in CHANGELOG.md by hand. Verified locally: release-plz folds that block into the next release's section, so 0.11.0 lists them alongside whatever lands squashed. Merging this PR (squashed, or as it is: it is one commit newer than the tag) is itself what makes release-plz open the next release PR. - Squash-merge pull requests from now on, with the PR title as the
commit subject. That gives master one conventional commit per PR, dated
when it lands, which is what release-plz expects. This is a repository
setting, not something in this PR:
Added
-
a capacity estimate from the ramp's curve (#66)
Once four clean steps are in, a saturation curve is fitted to throughput against concurrency and the result states what it reads off it: "Capacity about 3.0k req/s · reached around 30 concurrent · median latency doubles around 34", or that the curve points past the ramp.
--jsonhascapacity. -
anomaly notes during the run (#63)
Each second is judged against the median of the thirty before it; a p99 jump, a throughput fall or errors appearing are said in the footer as they happen, repeated in the verdict and listed in the JSON report under
summary.anomalies. -
Server-Timing and request ids, to read what the server says (#65)
Server-Timingheaders are added up and held against the latency measured here, and the five slowest responses are listed with the id their backend gave them (X-Request-Id,traceparent,CF-Ray, … or--trace-header), in the Stats tab, the inspector and the JSON report. -
--rate, an arrival rate instead of a closed loop (#67)Start a fixed number of requests a second, spread evenly across every sending thread; the footer and the verdict say when
-ccan't carry the rate and what would. A paused run resumes on schedule. Paced runs keep up to their concurrency of idle connections.
0.10.1 - 2026-10-04
Fixed
-
(ci) the Docker publish job asked for more than the release grants
The v0.10.0 release failed at startup: release.yml calls publish-docker.yml with
packages: writeandid-token: write, and a called workflow may only request a subset of that. The job asked forcontents: readas well, which made the whole workflow invalid before any job ran.The job now inherits the caller's permissions, like the Homebrew and R2 publish jobs do. Checkout of this public repository works without
contents: read, as those two jobs have shown on every release.
Other
-
Merge pull request #61 from omarmhaimdat/feat/latency-phases
-
Merge master into feat/latency-phases
0.10.0 - 2026-10-03
Added
-
failures grouped by cause, with what the target said
The verdict now names the main causes of failure with the first response body seen for each, so a run that failed on 503s says what the 503 said without opening the log. Failures are counted by cause in the metrics ("HTTP 503", "Connection refused (os error 61)"), which the dashboard's errors panel reads too, and the JSON report lists them under summary.failures with the example body. Failed responses keep the start of their body and their error's words in --json mode as well, where previews are otherwise off.
Other
-
Merge pull request #62 from omarmhaimdat/feat/error-clustering
-
what the Linux profile found
The Linux CPU gap to oha is reqwest's per-request plumbing, not syscalls: its follow-redirect layer re-parses the URL on every response, the connector is cloned per request, and so on. A reqwest patched to skip the re-parse is 3.5-4% cheaper on Linux and is kept as a patch file for an upstream change; mimalloc was 1-4% cheaper for 50-90% more memory and is dropped. The record says so, and the profile workflow keeps the reqwest variant for re-measuring.
-
the Linux profile also measures mimalloc and a reqwest without the per-response URL re-parse
-
a Linux profile of pepe on demand
-
Merge pull request #53 from omarmhaimdat/docs/recordings
-
the Linux numbers, and what they say
The Linux record from the Benchmarks workflow goes into bench/README.md: pepe keeps its memory advantage there, but spends about 30% more CPU per request than oha and reaches half its throughput, the reverse of the M4 Pro, while oha costs the same on both. So the CPU claims are stated as macOS measurements until the Linux cost is understood, in both READMEs.
-
build the target before starting it; find oha's JSON flag
-
oha 1.16 has no -j; use --json
-
say why a tool gave no result; raise the file limit on the runner
-
a benchmark gate on release PRs, and a Linux benchmark record
bench/compare.sh runs the plain, 16 KB body, 1,000 slow connections and TLS workloads with two binaries taking turns, three rounds each, and compares the best round of each: it fails when CPU per 1,000 requests grew by more than 15% or peak memory by more than 25%. The Benchmarks workflow runs it on release PRs, candidate against the last released tag built with the same toolchain, so a regression can't ship unnoticed; it also runs the full suite for pepe and oha on the Linux runner and keeps the CSV, which is where bench/README.md's Linux numbers come from.
bench/measure.sh reports CPU per 1,000 requests in milliseconds, like run.sh does.
0.9.0 - 2026-10-03
Added
-
tab completion and man pages that work out of the box
The completions and man pages shipped in 0.8.0's archives, but nothing put them in place. Now:
- pepe completions --install sets up tab completion for the shell you're
in (bash, zsh, fish or PowerShell) and installs the man pages: it
writes the files under ~/.local/share and appends the line the shell's
startup file needs, once. --dry-run says what it would change;
pepe completions zshprints the script for packagers. The files are built into the binary, so this works however pepe was installed. - The install script at pepe.mhaimdat.com runs it after installing, and pepe self-update runs it again for the shells it was set up for, so completions never fall behind the binary.
- The Homebrew formula installs the completions and man pages where Homebrew activates them, through a publish job of our own in place of dist's.
- pepe completions --install sets up tab completion for the shell you're
in (bash, zsh, fish or PowerShell) and installs the man pages: it
writes the files under ~/.local/share and appends the line the shell's
startup file needs, once. --dry-run says what it would change;
0.8.0 - 2026-10-02
Added
-
shell completions and man pages, shipped with every release
- Completions for bash, zsh, fish and PowerShell, and man pages for pepe and each subcommand, generated from the command definition so they can't drift from the flags. The main page also has the keys of every screen, examples and the environment variables, which --help doesn't.
- They live under contrib/, go into every release archive, and Homebrew puts them under share/pepe. contrib/README.md says where each shell wants them.
- cargo test fails when the files are out of date; UPDATE_CONTRIB=1 cargo test contrib rewrites them. The pages carry no version and no machine-specific default, so they're the same on every machine and don't change on a release PR.
Other
-
the setup-screen test no longer depends on the runner's core count
The command card leaves out -c when it equals the default, which is the machine's core count; the test chose 5, and GitHub's macOS runners now have five cores.
0.7.0 - 2026-10-02
Added
-
pepe says when a newer release is out, and self-update shows what's new
- Once a day, pepe looks for a newer release while a run is going, and when there is one Pepe says so after the report: the version, what changed since yours (from the changelog), and the command that updates this copy, for however it was installed. It used to ask GitHub every time it quit, and quitting waited for the answer; now the look starts with the run and quitting never waits more than a moment for it. In CI, or with PEPE_NO_UPDATE_CHECK set, nothing is asked or said. The setup screen mentions a newer release the last look found.
- pepe self-update shows what's new before installing, keeps the installer's output behind a spinner (--verbose shows it), and says where the new pepe went. For copies installed with Homebrew, Nix or cargo it says so and gives the command that updates them.
- pepe self-update --check only reports, with exit code 1 when a newer release exists, so scripts can ask.
Other
-
one changelog, newest first
CHANGELOG.md had two changelogs in it, an old header halfway down with the newest releases under it, so 0.6.0 and 0.6.1 sat below 0.5.1. It is one file again, newest release first. The update notice reads what's new from here, so the order matters to more than readers.
0.6.1 - 2026-10-02
Performance
-
share-nothing load engine, 4× less CPU for the same requests
pepe spent most of its CPU coordinating 14 tokio threads rather than sending requests: every request crossed threads through the connection pool's mutex and the timer lock, re-parsed its URL and ran as its own spawned task. Measured before and after (bench/README.md), 200,000 requests at concurrency 64 now cost 1.9 s of CPU instead of 8.5 s with the same throughput, which is 2.5× less than oha spends on them.
What makes it faster:
- Requests go out from shard threads, one by default, each with its own single-threaded tokio runtime, its own reqwest client (so its own connection pool and timer wheel) and long-lived worker tasks, one per unit of concurrency. Nothing on the hot path is shared between threads, so there is no lock to queue on and no task spawned per request.
- The URL and method are parsed once, when the run starts, instead of on every request; that parse ran IDNA on the host and formatted the IP address back to text each time. A bad URL is now a clean error before the run instead of a dashboard full of failures.
- --json mode collects results on a 25 ms timer, as the dashboard already did, instead of waking for each one: a send to a waiting receiver goes through the kernel, and at 100k results a second that was a fifth of the run's CPU.
- The main runtime is single-threaded too; it only runs the screens.
- The client keeps at most four idle connections per host. Whenever a request finds no idle connection, the pool races a new one against waiting for one and keeps the loser as a spare, so a run at -c 1000 held 1,999 connections. It now holds 1,003, and peak memory there fell from 116 MB to 68 MB.
So that one thread is a safe default, pepe now says when it is the bottleneck: each sending thread measures its own CPU time once a second, and past 90% of a core the dashboard's footer shows it, the end-of-run verdict adds a note, and the JSON report carries it under "generator". --threads N adds threads; it is also a field on the setup screen.
The suite behind the numbers is in bench/: a Go target server, run.sh (pepe against oha and vegeta on fixed workloads), tui.py (the dashboard in a pseudo-terminal) and the raw results.
Other
-
Merge pull request #41 from omarmhaimdat/perf/share-nothing-engine
-
release notes carry each commit's explanation
The changelog, and so the GitHub release that is built from it, listed only commit subjects. Each entry is now the subject followed by the commit's body, and perf commits get their own Performance section instead of landing under Other.
0.6.0 - 2026-10-01
Added
- ramp mode, and one setup screen for every mode
- API mode, load-testing the endpoints of an OpenAPI spec
- setup screen to fill in every option before a run
Other
- the setup screen, ramp mode and API mode
0.5.1 - 2026-09-30
Fixed
- Windows curl pastes keep backslashes; clippy 1.98 lint
- parse curl commands the way curl and a shell do
Other
- stop release-plz from opening duplicate and empty release PRs
- release v0.5.0
0.5.0 - 2026-09-30
Added
- format and highlight JSON, HTML and XML in the request inspector
- inspect any request in full from the Requests tab
Fixed
- pause stops a timed run's clock; the inspector scrolls like a pager
0.4.0 - 2026-09-30
Added
- rework the Stats view into cards over a full-height distribution
- richer Live view, compact monochrome heatmap
- request filters, calmer colors, redrawn mascot
- latency heatmap, end-of-run verdict and a chili mascot
- interactive dashboard with live charts, and a much faster load path
Fixed
- dashboard freezing when the terminal falls behind, slow resizes
Other
- bump the minor version for feature releases before 1.0
0.3.1 - 2026-09-29
Other
- release v0.3.1
- fix false failure in R2 publish verification
[0.3.0] - 2026-09-29
Added
--duration/-z: run for a fixed time (10s,5m,2h) instead of a fixed request count--json: run without the dashboard and print a JSON summary (latency min/max/avg/std dev and P50/P90/P95/P99, status codes, error breakdown, throughput) to stdout. Exits when the run completes; Ctrl-C stops early and reports"interrupted": truepepe self-updatefor installs made with the shell or PowerShell installer- Prebuilt binaries for macOS (Apple Silicon, Intel), Linux (x86_64, ARM64, statically linked) and Windows (x86_64), published automatically with checksums and build attestations
- New install methods:
irm https://pepe.mhaimdat.com/install.ps1 | iex(Windows) and a Nix flake (nix run github:omarmhaimdat/pepe) - Dashboard: "Errors" (connection failures, now separate from timeouts) and "Error Rate"
Fixed
- Crash in duration mode once more requests completed than
-n(default 100), which also left the terminal in raw mode - The terminal is restored if pepe ever panics
- Restart (
r) and stop (i) now cancel the running load;istops and keeps the results on screen instead of restarting - Statistics cover every request; the JSON report previously only used the last 100 and counted failures twice
- Request body (
-d) is sent for every method, not only POST --disable-keepalivenow opens a new connection per request- Connection errors are no longer reported as timeouts
- Data transferred counts the bytes actually received (previously 0 for chunked or compressed responses)
- Standard deviation used the previous average
- Invalid
-Hheaders are rejected with an error instead of crashing; repeated headers are kept - No crash on narrow terminals; memory no longer grows during long runs
- Update notice no longer suggests "updating" to an older version, goes to stderr, and can be disabled with
PEPE_NO_UPDATE_CHECK=1 - Fixed the hosted
install.sh
Changed
- DNS is resolved with the system resolver (reqwest
hickory-dnsremoved), matching the DNS timing pepe reports - Latency is measured from sending the request, excluding pepe's own DNS timing lookup
- Latency is shown with sub-millisecond precision
- Security updates for quinn-proto, rustls-webpki, ring, bytes and tokio
0.2.9 - 2025-02-22
0.2.8 - 2025-02-18
0.2.7 - 2025-02-15
0.2.6 - 2025-02-15
0.2.5 - 2025-02-15
0.2.4 - 2025-02-14
Added
- System hostname display in header section
- Author information in CLI output
- Nginx-style log format for Recent Requests
- Redesigned progress bar UI
Changed
- Updated header information layout
- Enhanced request log visualization
- Improved progress tracking display
[0.2.3] - 2025-02-13
Added
- Generated reqwest client from CLI configuration
Changed
- Improved event handling structure
- Break large functions into smaller ones
- Use default_value_t instead of default_value
- Remove redundant short and long attributes in clap
- Replace #[clap] with #[arg]
- Improve validation by returning errors instead of exit
Removed
- Redundant comments