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.
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. When something on the server changes, it tells you in Slack.
$ composer require boring-o11y/my-sailfish
Then migrate. It schedules its own collection, and the first snapshot lands within five minutes.
or read the docs first
| 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, a reset digest table and FLUSH STATUS are each handled 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.
The Activity page reads row locks and metadata locks live: an ALTER TABLE stuck behind an idle transaction, and the queries queued behind the ALTER. It also lists open transactions, oldest first.
Connections against max_connections, row lock waits, buffer pool misses, temporary tables spilling to disk and InnoDB's history list, charted over any window.
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.
Route each severity to its own channels. An urgent event is sent when it opens, again every six hours until someone acknowledges it, and once more when it clears.
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_SLACK_WEBHOOK_URL=#database
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. The ones that need someone now are sent as they open. The rest wait for the daily digest.
| Event | When | Sent |
|---|---|---|
| Connections running out critical | Any connection refused at max_connections in the last hour, or 80% of them open at a collection. | Immediately |
| Row-lock surge critical | Time waiting for InnoDB row locks over the last hour is at least 3× the usual for that hour, and at least a minute. | Immediately |
| Slow-query surge warning | Statements over long_query_time in the last hour are at least 3× the usual for that hour, and at least 60. | Immediately |
| Error surge warning | One MySQL error, such as a deadlock (1213) or a lock wait timeout (1205), raised at least 3× as often as usual for that hour, and at least 100 times. An error the server never raised before counts from none. | Immediately |
| History list growing warning | InnoDB's history list is at least 100,000 and longer than an hour ago, which means a transaction was left open. Needs the PROCESS privilege. | Immediately |
| Buffer pool misses warning | Over the last hour, under 99% of reads served from memory, with 100,000 pages or more read from disk. Quiet after a restart, while the pool warms up. | Daily digest |
| Temp tables on disk warning | Internal temporary tables written to disk over the last hour are at least 3× the usual for that hour, and at least 1,000. | Daily digest |
| Event | When | Sent |
|---|---|---|
| Table-scan surge critical | 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 warning | Read in the 14 days before the last 7, and not fetched since. | Daily digest |
| Never used warning | No fetch in 30 days of observed history. Says how big the index is, what its writes cost and whether another index covers it. | Daily digest |
| Redundant index warning | Another index makes it redundant: a duplicate, a left prefix of a wider one, or on InnoDB one spelling out the primary key every index already ends with. The index page shows how to drop it. | Daily digest |
| Table growth warning | Grew at least 1 GiB in the last 7 days, and at least 2× what it gained in an average week over the 4 before. | Daily digest |
| Unindexed reference info | *_id columns no index starts with, on a table of at least 10,000 rows that was read by full scans in the last 7 days. A heuristic aimed at foreignId() without constrained(). | Daily digest |
| No primary key info | A table without one. | Daily digest |
| Reclaimable space info | An InnoDB table's tablespace is at least 25% free, and at least 1 GiB. | Daily digest |
| Schema change info | A table or index was added or dropped. | Daily digest |
Every threshold, severity and "sent" column is a key in config/sailfish.php.
Detectors that compare against history stay quiet until it covers their window, and the dashboard says
how many more days each one needs.
All events in the docs
Route each severity to the channels it deserves. A critical scan surge can go to Slack and a pager webhook while the digest of cold indexes goes to email.
'routes' => [
'critical' => ['slack', 'webhook'],
'warning' => ['slack'],
'digest' => ['mail'],
],
{
"state": "firing",
"repeat": false,
"event": {
"type": "scan_surge",
"severity": "critical",
"title": "Full-table scans on app.orders surged",
"url": "https://example.com/sailfish/events?…"
}
}
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.
Why
The payment includes twelve months of upgrades, and what you receive in that year is yours permanently, whether or not you ever renew.
Secure checkout by Anystack, with cards and VAT invoices.
Sailfish is a Laravel package that keeps a long-term history of MySQL's performance_schema counters: index usage, full table scans, lock waits, errors, statement digests and the server's global status. It keeps that history across the server restarts that reset those counters, serves a dashboard of index, table and server activity, and sends events by mail, Slack or webhook when something 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.
Sixteen, in two groups. On the server: connections running out, surges in row-lock waits, slow queries, errors and temporary tables on disk, a growing InnoDB history list, and buffer pool misses. On tables and indexes: table-scan surges, indexes that went cold, were never used or are redundant, unusual table growth, unindexed *_id columns, tables without a primary key, reclaimable space and schema changes. The urgent ones are sent as they open; the rest go in a daily digest.
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, Slack channel and 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 adds index sizes, and PROCESS adds InnoDB's history list length. Both are optional.
Sailfish is a one-time purchase of $99 per application. The purchase includes twelve months of upgrades. Everything published during those twelve months is yours permanently: you can keep running it and reinstall it whenever you rebuild. Renewing is optional and buys only the next year of releases. The checkout is hosted by Anystack, which also issues the license key.
Yes. Every purchase comes with a 30-day money-back guarantee. Email tech@boring-observability.dev within 30 days of being charged and we refund it in full, with no questions asked.
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. A second gate, viewSailfishQueryText, decides who sees running statements with their values rather than with each literal replaced by "?".
The cold and never-used index checks need weeks of history before they can say anything, and that clock starts with the first snapshot. Checkout ends with your license key.
Secure checkout by Anystack. 30 days to change your mind.
Questions first? Email us and one of the people who builds it will answer.