What's new in v0.12.0
v0.12.0 is about what a finding is worth reporting, and about what happens to a finding once it exists. Connection and session management statements leave the repetition detectors, which removes a class of findings no remediation ever applied to. The live daemon gains an opt-in exporter that pushes findings to PerfSentinelHub instead of waiting to be polled. An unknown key in .perf-sentinel.toml now fails the load rather than being ignored, which is the breaking change behind the minor bump. And the pg-stat and mysql-stat Prometheus scrapes stop assuming the series they read.
Two of those change what you see on identical traffic, and both have a paragraph in the upgrade section below.
A COMMIT is not an N+1
A pooled driver emits one session statement per connection checkout. A request borrowing eight connections therefore showed eight identical SET statements, tripped the repetition detectors, and offered a remediation, WHERE ... IN (?) or "cache result", that cannot apply to a SET or a COMMIT. On one production trace, 39 SELECT set_config(?, ?, false) outranked the three genuine Hibernate N+1 of the same trace. Over a 30-minute window the pattern accounted for 10.8% of all findings emitted, which was enough to hold n_plus_one_sql_critical_max = 0 permanently red on a gate with nothing actionable behind it.
n_plus_one_sql and redundant_sql now skip these statements through normalize::sql::is_session_command, which is vendor-neutral: transaction control everywhere, including a trailing ; and the BEGIN TRANSACTION and START TRANSACTION spellings, then SET, RESET, DISCARD, set_config() and current_setting() on PostgreSQL, SET NAMES and USE on MySQL, ALTER SESSION on Oracle, VALUES(?) on DB2 and H2, and sp_reset_connection on SQL Server.
Four shapes are deliberately left visible, because the keyword alone does not identify them or because a remediation does apply. SELECT 1 normalizes to the same template as a select of any bare literal. Oracle's BEGIN pkg.proc(?); END; is a stored-procedure call, and an N+1 of those is real. SHOW COLUMNS FROM t is the schema introspection a dynamic-schema ORM runs once per entity, which both batching and caching can fix. And SELECT @@… covers @@IDENTITY and @@ROWCOUNT, emitted once per statement to fetch generated keys rather than once per checkout.
serialized_calls learned the same distinction. A BEGIN, reads, COMMIT sequence under one parent used to report every statement as a link, so the suggestion advised parallelizing transaction control, the one thing that cannot move off the chain, and their durations inflated the "total sequential" figure the finding advertises as recoverable. Both the link count and that total now cover application work only, so a chain that owed its length to transaction control drops below serialized_min_sequential and stops being reported.
The daemon can push, not only be polled
[daemon.hub_export] is off by default. When enabled, the live daemon sends a signature to PerfSentinelHub as soon as it is discovered or when its severity worsens, then at most once per hour while it recurs. Pending occurrences of the same signature coalesce to their latest value rather than queueing, so a busy signature costs one entry, not one request per detection.
Requests hold at most 100 findings and 2 MiB, and retry with bounded exponential backoff and jitter. The pending table and the recent-success cache are each capped at max_pending, 10 000 by default. Hub export never blocks trace analysis: it runs on its own task and the event loop only hands it findings. perf_sentinel_hub_export_pending exposes the backlog and perf_sentinel_hub_export_dropped_total counts what is lost, whether to a pending-table eviction, an oversized finding, or a batch the Hub rejects with an unretryable 4xx.
On shutdown the exporter is drained rather than aborted, bounded to 10 seconds. Without that bound an unreachable Hub would hold the daemon past the orchestrator's grace period, where SIGKILL follows and nothing is flushed at all. An expired budget logs a warning naming how many findings are lost. Size terminationGracePeriodSeconds above that budget.
The API key is read from a mounted file, never from the TOML value itself.
An unknown configuration key is a mistake, not a comment
Sixteen of the twenty-two configuration structures accepted anything serde did not recognize, silently. A deployment that wrote cpu_metrik, max_tracked_pairz or max_retaind_findings read its own file, saw the knob it meant to set, and ran on the default. The misconfiguration was invisible in the file, in the logs and on /metrics. [thresholds], [detection], [green], [daemon], [daemon.correlation], [daemon.ack], [daemon.cors], [reporting] and every energy-backend section were in that set, which is to say every section a deployment actually edits.
They now carry deny_unknown_fields, so the load fails with serde's own message, which names the offending key and lists the valid ones beside it. Keys removed in 0.6.0 keep their dedicated migration message, since that scan runs before serde.
A scrape should not assume the series it reads
pg-stat --prometheus assumed the postgres_exporter built-in query, pg_stat_statements_seconds_total with a query label. An exporter running hand-written SQL names its own columns, and the exporter derives the series from them, so --metric and --query-label now point the scrape at those names, on the pg-stat subcommand and on report alike. Without a matching label the report falls back to queryid and shows opaque identifiers instead of statements.
mysql-stat had no Prometheus path at all and only accepted a file, so a fleet already exporting Performance Schema digests to Prometheus had to dump them by hand. It gains --prometheus with --auth-header, plus the same --metric and --query-label pair, and report gains the matching --mysql-stat-prometheus family. Defaults follow the mysqld_exporter collector, mysql_perf_schema_events_statements_seconds_total with a digest_text label, falling back to digest. That collector is off by default on the exporter and needs --collect.perf_schema.eventsstatements.
The series name lands unencoded in the query string, so it is validated against the bare PromQL metric-name grammar rather than escaped: a label selector or a whole expression there is a mistake, and naming it beats a downstream "invalid URL". Endpoint validation, series validation and the instant-query transport are shared between the two scrapes, because an input guard that exists twice is an input guard that eventually differs.
One honest limit on the MySQL side: the exporter series carries cumulated seconds and no call count, so a scrape leaves calls at zero and the mean equal to the total rather than inventing one. Three of the four rankings are therefore degenerate on that input. A file export carries COUNT_STAR and stays the richer of the two.
Upgrade impact
Finding counts fall on identical traffic, and so does io_waste_ratio. Deployments seeing session-command findings lose them with no application change behind it. Those statements keep counting as I/O operations, so total_io_ops, per-service I/O and the total carbon figure hold, but their operations leave avoidable_io_ops, which is derived from the findings. io_waste_ratio and the disclosed avoidable energy both fall, which means a [thresholds] io_waste_ratio_max calibrated on a 0.11.2 run is now looser than intended. Re-baseline it against a 0.12.0 run of your own corpus before relying on the gate verdict.
A stale or misspelled configuration key now stops the daemon at startup. What used to run degraded now refuses to start. Load your file once with perf-sentinel analyze --config <file> before rolling it out, the error names the key and lists the valid ones. This is the breaking change behind the minor bump.
Acknowledgments covering a session-command finding will report as unmatched. A fresh batch analysis flags every ack that suppressed nothing as unmatched_acknowledgment, and when the endpoint still emitted I/O the message reads that the problem looks fixed and the entry can be removed. That is the wrong conclusion for an ack whose finding only vanished with the upgrade. Signatures themselves are untouched, so nothing else moves.
The minimum supported Rust version stays 1.97.1.
Validation
The full Rust suite passes (3208 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 simulation-lab release-gate records a PASS for v0.12.0. Worth stating plainly: that round validated the session-command work and the daemon streaming paths, measured directly rather than through replay after the round found that no committed fixture carries a session command. io_waste_ratio moved from 0.7368 to 0.6140 on a purpose-built input while total_io_ops held at 57, and each disappearing finding was accounted for. The strict-configuration change and the MySQL scrape landed after that round and rest on their unit and end-to-end tests instead. No lab scenario exercises the Hub exporter switched on.
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.12.0" \
--expected-issuer "https://token.actions.githubusercontent.com" \
--verify-binary ./perf-sentinel-linux-amd64gh CLI 2.49 or newer required for gh attestation verify.