What's new in v0.13.1
v0.13.1 is about what /api/export/report tells the clients that read it. The snapshot had one size, fixed at the cap of a different endpoint, it said nothing about the two populations it carries, and when it grew past what those clients accept it came back looking like a daemon that never answered. The HTML dashboard rendered from that snapshot gets the same treatment on its own terms: two bars that had grown past reading.
The export slice had no size of its own
/api/export/report was pinned to MAX_FINDINGS_LIMIT, the constant bounding /api/findings. The two answer different questions. One paginates a browsing API, the other sizes a deliberate export, and sharing a constant meant that widening the export widened every page of the browsing API with it. A daemon retaining tens of thousands of findings shipped 1000 of them with no way to ask for more.
[daemon] max_export_findings sizes the export alone, with watch --max-export-findings overriding it for one run. /api/config and the Config tab of query monitor report the effective value, since the CLI override means the file is not authoritative. The default is the previous hardcoded value, so an operator who sets nothing sees the snapshot they saw before.
The ceiling is 100,000 rather than the store's 10,000,000. A finding serializes to a few KB, so the knob bounds a response body and the HTML rendered from it, not retention, and the two run out at very different orders of magnitude. The CLI override goes through the same validation as the file, so a typo past the ceiling is refused at startup rather than at the first request.
0 exports the envelope alone. The gate's three finding-count rules then pass whatever the daemon detected, since they count from a slice that holds nothing. io_waste_ratio_max reads green_summary, which no cap empties, so the verdict does not become a constant, it stops reflecting the findings. That is enough to make 0 suit a liveness probe and not an alerting one, and an advisory says so at startup.
A snapshot too large read as a daemon that never answered
Past the 8 MiB body limit query inspect and query monitor fetch the snapshot with, fetch_json reduced the read to None. That is indistinguishable from an unreachable daemon, and it sends the operator looking at the network for what is a setting on the peer.
FetchError::BodyTooLarge separates the two. query monitor prints the reason beside its [STALE] marker rather than the bare marker, so an oversized body, a malformed payload and a daemon that is not there now read as three different failures where they used to read as one. The energy scrapers map the new variant onto the body-read outcome they already report, so no Prometheus label was added and their label sets stay bounded.
The limit is also advised on before it is hit. max_export_findings and max_retained_traces fill the same response body, but each was comfort-checked alone, so a pair sitting inside both zones, 2000 findings and 400 traces, projected past the limit with neither knob looking wrong. Startup now warns on the projected total and names both knobs and both values, from per-item weights measured on a production daemon. The span-tree term is clamped at the byte budget the export already enforces, so the advisory never blames a knob that cannot overflow on its own.
Two populations in one payload
findings is a capped slice of the most recent detections. green_summary is the latest per-batch summary the event loop wrote. Read as one population, a daemon retaining 46 000 findings and exporting 2 % of them gets its carbon figures wrong by orders of magnitude.
The endpoint states both facts in warning_details under the snapshot_scope kind, which the HTML dashboard renders in its banner: what the cap left out, and that the green figures describe one batch rather than the exported set. quality_gate counts finding-based rules on the exported slice and reads io_waste_ratio from that batch, which the scope note now makes explicit. Batch output carries neither warning, since there every number comes from the same pass over the input.
Twenty-six filter pills in one row
The Findings toolbar listed every filter flat, 26 of them across two wrapping rows on the demo dataset, mixing three families that do not combine the same way with nothing to say which was which. Service, type and the grouping attribute now collapse into one menu each, ordered Type, Service, then the attribute, and the collapsed trigger keeps the pill's look. The severity row and its count are untouched, they were the one part that already scanned well.
Those three families also accept several values at once, where a click used to replace the previous one. Values inside a menu are OR'd, the menus AND with each other, and the count shows on the trigger as Type · 2. Both halves land in the URL, so Copy link still reproduces the view, and a value the report no longer holds is dropped on restore rather than applied.
Sort moves out of that row onto the one above the findings column it orders, since it ranks the list rather than narrowing it. In the top bar the search box is centred, the density and theme buttons fold behind one gear so the bar carries report state rather than preferences, and the HTML REPORT line under the logo is gone.
Long SQL statements wrap in the pg_stat and mysql_stat tables instead of scrolling their card sideways. The template cell carried a class no rule ever defined, so a 760px card overflowed by 416px on the PostgreSQL table and 798px on the MySQL one, both now at zero.
Upgrade impact
Nothing moves unless you set the new knob. max_export_findings defaults to the value the export was pinned to, so an untouched deployment produces the same snapshot it produced on 0.13.0.
/api/export/report gains warning_details entries under a new snapshot_scope kind. A client that enumerates warning kinds sees one it does not know. The existing entries are unchanged, and a cold-start daemon still reports only cold_start.
A dashboard link shared from 0.13.0 still restores. The filter families now carry a comma-separated list where they carried one value, and a single value parses as a list of one.
query monitor prints a reason next to [STALE]. Anything parsing that header line for the bare marker should expect the marker followed by a reason.
The Prometheus label sets are unchanged, the file-based --input paths are unchanged, and the minimum supported Rust version stays 1.97.1.
Validation
The full Rust suite passes (3251 tests), clippy is clean at -D warnings across all targets, cargo fmt --check passes, and the default and --no-default-features builds both pass.
The dashboard work rests on the Playwright suite, 45 specs against a real browser, covering the multi-value filters, the URL round trip, and the disclosure menus closing on Escape without clearing the filters underneath. The export cap and the scope note are covered by unit and end-to-end tests, the startup advisories and the refusal past the ceiling included.
A scoped simulation-lab run validated this revision on seven targets, with the export cap enforced under production conditions: a daemon holding 2457 findings shipped exactly 1000 and said so.
Verifying this release
# Binary integrity via SLSA Build L3 attestation
gh attestation verify perf-sentinel-linux-amd64 \
--repo robintra/perf-sentinel
# A periodic disclosure produced by this binary
perf-sentinel verify-hash --report perf-sentinel-report.json \
--expected-identity "https://github.com/robintra/perf-sentinel/.github/workflows/release.yml@refs/tags/v0.13.1" \
--expected-issuer "https://token.actions.githubusercontent.com" \
--verify-binary ./perf-sentinel-linux-amd64gh CLI 2.49 or newer required for gh attestation verify.