A snapshot of performance_schema every five minutes
sailfish:collect schedules itself with withoutOverlapping and onOneServer, reads the counters and stores what moved since the last run. An index nothing touched writes nothing.
Coming soon
performance_schema only ever tells you what happened since the server booted. Sailfish reads it every five minutes and stores what moved, so when was this index last used? and did scans on this table start after Tuesday's deploy? still have answers after a restart.
Not on sale yet. We will email you once, when it is.
| Index | Status | Last used | Size |
|---|---|---|---|
| orders.orders_customer_id_created_at_index | hot | 2 minutes ago | 184 MB |
| orders.orders_status_index | cold | 19 days ago | 96 MB |
| bookings.bookings_legacy_ref_index | never used | never | 1.2 GB |
| users.users_last_login_at_index | hot | just now | 41 MB |
sailfish:collect schedules itself and stores what moved since the last snapshot.
A reboot starts a new generation of counters instead of a history that goes negative.
The history lives in tables in your own database, on the connection you choose.
A dashboard served by your own app, behind a gate you define.
Every index costs you on each write. Sailfish keeps enough history to tell an index read every minute from one that stopped being read last month.
sailfish:collect schedules itself with withoutOverlapping and onOneServer, reads the counters and stores what moved since the last run. An index nothing touched writes nothing.
When uptime goes down, a new generation starts and every counter is measured from zero again. A truncated summary table, a dropped and recreated index and a reset digest table are each detected the same way.
Every index with its last use, its size, its columns and 30 days of reads and writes. PRIMARY and unique keys are hidden until you ask, because an unused constraint is still doing its job.
Per table and over time, broken down by index, with the time spent waiting for table locks. The five most scanned tables sit on the overview.
Errors raised over time, the most frequent and when each first appeared. Deadlocks (1213), lock wait timeouts (1205) and duplicate keys (1062) are called out.
Raw snapshots are kept for 7 days, then merged to one per hour. Hourly ones are kept for 90 days, then one per day, and daily ones for good. Charts read any window the same way.
The first collection only records a baseline, because counters running since boot describe no particular interval. From the second one on, each snapshot stores the difference, and every rate on the dashboard is that difference over the seconds it covers.
# Read performance_schema here, store sailfish_* here
SAILFISH_DB_CONNECTION=mysql
SAILFISH_SCHEMAS=app,billing
# Where the events go
SAILFISH_NOTIFY_CHANNELS=mail,slack
SAILFISH_NOTIFY_MAIL=db-team@example.com
SAILFISH_DAILY_DIGEST_AT=09:00
php artisan about gets a Sailfish section with
the connection, the schemas watched, the last snapshot and the retention.
Each collection ends by running the detectors. An event stays open while its condition holds and resolves once it clears. You can acknowledge it, or mute it until a date or for good.
| Event | When | Sent |
|---|---|---|
| Table-scan surge | Rows scanned without an index over the last hour are at least 3× the average for that hour over the previous 7 days, and at least 100,000. | Immediately |
| Index went cold | Read in the 14 days before the last 7, and not fetched since. | Daily digest |
| Never used | No fetch in 30 days of history. Says how big the index is, what its writes cost and whether another index covers it. | Daily digest |
| Schema change | A table or index was added or dropped. | Daily digest |
The first three need their window of history before they can fire, and until then the dashboard says how many more days each one needs. A muted event stays muted if it clears and comes back, and an immediate event that flaps notifies at most once every six hours.
Statement digests need emulated prepares.
MySQL 8.0 gives no digest to statements sent as native prepared statements, which is what Laravel's
MySQL connection does by default. Index and table counters see every statement either way. For
digests of your application's queries, set
PDO::ATTR_EMULATE_PREPARES to true on
the connection.
Sailfish is a Laravel package that keeps a long-term history of MySQL's performance_schema counters: index usage, full table scans, table lock waits, errors and statement digests. It keeps that history across the server restarts that reset those counters, serves a dashboard of index and table usage, and raises events when usage changes. It is made by Boring Observability, who also make Skyline and Requizon.
performance_schema only ever tells you what happened since the server booted. After a restart or a failover, "this index has zero reads" means nothing, and you cannot ask whether scans on a table started after last Tuesday's deploy. Sailfish stores what moved in each five-minute interval, so those questions still have answers.
Yes. An index with no fetch in 30 days of observed history opens a "never used" event, which says how big the index is, what its writes cost and whether another index covers it. An index that was read in the 14 days before the last 7 and not since opens an "index went cold" event. PRIMARY keys are left out of both.
No. It is a package running inside your application. It reads performance_schema over your own connection, writes to sailfish_* tables in your own database and serves its dashboard from your own domain. Notifications go only to the mail addresses and Slack webhook you configure.
MySQL 8.0, Laravel 12 or 13 and PHP 8.2 or newer, and a database user that can SELECT from performance_schema. An application user limited to its own schema cannot, and sailfish:collect names the grant it needs. SELECT on mysql.innodb_index_stats is optional and adds index sizes.
MySQL 8.0 gives no digest to statements sent through the binary prepared-statement protocol, and Laravel's MySQL connection uses native prepared statements by default. Index and table counters see every statement either way. For digests of application queries, set PDO::ATTR_EMULATE_PREPARES to true on the connection.
Whoever your viewSailfish gate allows. Outside the local environment nobody gets in until you publish the service provider and define that gate.
Not yet. It is in development and not on sale. Email tech@boring-observability.dev and we will write to you when it ships.
Send us an email and we will write back when it is ready to install. If there is something you would want it to catch in your own database, put that in the email too.
Tell me when it shipsIn the meantime, our other tools are available now.