Sailfish

Your MySQL index history, kept past the restart.

performance_schema only ever tells you what happened since the server booted. Sailfish reads it every five minutes from inside your Laravel app and stores what moved, so when was this index last used? still has an answer after a restart. It charts how each index and table is read, and sends mail or Slack when something changes.

$ composer require boring-o11y/my-sailfish

Then migrate. It schedules its own collection, and the first snapshot lands within five minutes.

The Sailfish overview: 8 tables, 17 indexes, one never used, one cold, 39 days of history, and open events led by a critical table-scan surge on shop.orders, long transactions by reports@10.0.2.17, a slow-query surge and audit_log growing faster than usual.
The overview of a demo shop's database, an hour after a migration dropped an index.
Every 5 min

sailfish:collect schedules itself and stores what moved since the last snapshot.

Restarts

A reboot starts a new generation of counters instead of a history that goes negative.

sailfish_*

The history lives in tables in your own database, on the connection you choose.

/sailfish

A dashboard served by your own app, behind a gate you define.

Alerts

A message when MySQL starts doing something it did not do yesterday.

Every collection ends by running the detectors over the stored history. The surges compare the last hour with the same hour on each of the previous seven days, so a nightly batch that always scans at 03:00 is its own baseline and pages nobody. Slower findings, like an index nothing reads, wait for the morning digest.

Urgent events open, remind and recover
A critical event is sent as it opens, again every six hours until someone acknowledges it, and once more when it clears, saying how long it lasted.
Mute one until a date, or for good
A muted event stays muted if it clears and comes back. Every threshold and severity is a key in config/sailfish.php.
.env
SAILFISH_NOTIFY_CHANNELS=mail,slack
SAILFISH_NOTIFY_MAIL=db-team@example.com
SAILFISH_SLACK_WEBHOOK_URL="https://hooks.slack.com/..."
A Sailfish email: Table-scan surge, critical. Full-table scans on shop.orders surged: 4M rows an hour scanned without an index over the last hour, 66 times the 60.5k usual for that hour. An Open in Sailfish button. A Sailfish recovery email: Row-lock surge, critical, resolved. 2 minutes spent waiting for row locks over the last hour, in 5,865 waits, 4.1 times the 29 seconds usual for that hour. It lasted 2 hours 40 minutes.
Two emails as the package sends them: a scan surge as it opens, and the recovery for a lock incident two days earlier. Slack and the JSON webhook carry the same text.

Sent as they open

  • ▸ Connections running out
  • ▸ Row-lock surge
  • ▸ Slow-query surge
  • ▸ Error surge
  • ▸ History list growing
  • ▸ Rollback surge
  • ▸ Table-scan surge

In the daily digest

  • ▸ Buffer pool misses
  • ▸ Temp tables on disk
  • ▸ Long transaction
  • ▸ Index went cold
  • ▸ Never used
  • ▸ Redundant index
  • ▸ Table growth
  • ▸ Unindexed reference
  • ▸ No primary key
  • ▸ Reclaimable space
  • ▸ Schema change

What the dashboard keeps.

Indexes

The last time each index was read, kept through a restart

Every index with its status (hot, cold, never used or dropped), last use, size and 30 days of reads and writes. One read in the 14 days before last week and not since is flagged cold. One with no read in 30 days is flagged never used, with what its writes cost and whether another index covers it.

The page for orders_status_index: status Cold, last used a week ago, 96 MiB, and a reads and writes chart where reads stop on Oct 11 while writes carry on.
An index that stopped being read twelve days ago. The dashed line is a MySQL restart, and the history runs straight through it.

Tables

Rows read through an index against rows read by full scan

When a migration drops an index, or a deploy ships a query that cannot use one, the scanned line climbs as the index line falls. Each table page splits its reads by index and charts the time spent waiting for table locks and its size, read once a day.

The shop.orders table page over 24 hours: rows read by index follow the day, and rows scanned jump from near zero to over a thousand a second at 1pm, beside a dashed schema-change marker.
shop.orders over 24 hours. The dashed line is the migration that dropped orders_shipped_at_index.

Server

Connections against max_connections, and row-lock time, over weeks

The Server page charts open and running connections against the limit, statements by type, time waiting for row locks, buffer pool misses, temporary tables on disk and InnoDB's history list. Every figure is a rate over the seconds the snapshots cover, so a gap in collection does not draw a dip.

The Server page: MySQL 8.0.39, 214 of 300 connections at the busiest collection, a 99.99% buffer pool hit ratio, 1.1 hours spent waiting for row locks, and a seven-day chart of open and running connections under the max_connections line.
Seven days of one server, with open connections drawn against the max_connections line.

Transactions

The share rolled back, and every transaction over five seconds

Every collection reads the transactions that finished since the last one. The page charts committed against rolled back, with the share rolled back as its own line, and lists the long ones slowest first: how long each ran, whether it committed, its statements, and the account and program that ran it. Never their text.

The Transactions page over seven days: 3 million finished, 1.4% rolled back, 91 over five seconds. The share rolled back holds near 1.2% and jumps to almost 9% on the afternoon of Oct 21, and the long transactions are led by one of 1.8 hours from ops@10.0.4.12 in the mysql client, then checkouts that rolled back after 50.9 seconds.
A week of transactions. The spike is a lock incident: a transaction left open in the mysql client, and the checkouts that gave up waiting for it.

Activity

Who is blocking whom, and what the blocker ran

Read live when opened, never stored. Blocking trees start from the session everyone waits for, and each open transaction lists the statements it has run, with the one that took the row locks marked. Running statements show what they wait on, and an ALTER TABLE how far it has got.

The Activity page: a blocking tree rooted at ops, idle in a transaction for 35 seconds, whose four statements end with the UPDATE that took row locks on shop.orders. Two checkouts wait on that row lock beneath it. Below, a running ALTER TABLE on audit_log at 18% with about a minute and a half left.
A refund left open in the mysql client, two checkouts waiting on its row lock, and a migration building an index.

Daily digest

A 09:00 email with every finding that can wait

Cold and never-used indexes, an index a wider one makes redundant, a table growing twice as fast as usual, *_id columns no index starts with, tables without a primary key and reclaimable space. Each line links to its event, where it can be acknowledged or muted.

A Sailfish daily digest email grouped by type: an index that went cold, one never used, a redundant index, table growth, an unindexed reference, a table without a primary key and reclaimable space.
The digest for the same demo database.

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

MySQL 8.0 · Laravel 12 or 13 · PHP 8.4+ · SELECT on performance_schema

One payment. $99.

The payment includes twelve months of upgrades, and what you receive in that year is yours permanently, whether or not you ever renew.

$99 one time
per app
  • ▸ Your license key straight after checkout
  • ▸ Twelve months of upgrades
  • ▸ Every version from that year, yours to reinstall forever
  • ▸ 30 days to change your mind, refunded in full

Secure checkout by Anystack, with cards and VAT invoices.

Frequently asked questions

What is Sailfish?

+

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, finished transactions 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.

Why not just query performance_schema directly?

+

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.

What events does Sailfish send?

+

Eighteen, in three 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 transactions: a surge in the share rolled back, and transactions that ran long. 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.

Can Sailfish tell me which indexes are unused?

+

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.

Does Sailfish send data anywhere?

+

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.

What does Sailfish require?

+

MySQL 8.0, Laravel 12 or 13 and PHP 8.4 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.

How much does Sailfish cost?

+

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.

Do you offer a refund?

+

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.

Why are my statement digests empty?

+

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.

Who can see the dashboard?

+

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 "?".

Start the history today.

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.

Buy Sailfish

Secure checkout by Anystack. 30 days to change your mind.

Questions first? Email us and one of the people who builds it will answer.