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

/sailfish/indexes 30 days
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
An illustration of the Indexes screen, not a screenshot. PRIMARY and unique keys are hidden until you ask for them.
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.

Which indexes earn their keep

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.

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.

Deltas that survive a restart

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.

Index status: hot, cold, never used, dropped

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.

Who is blocking whom, as a tree

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.

Server counters against their limits

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 counted by number

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.

Mail, Slack or a webhook, with reminders and recoveries

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.

Hourly and daily rollups

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.

Counters that reset, turned into a history that does not.

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.

  • ▸ Server restart: uptime went down, so a new generation starts and old cursors are measured from zero
  • ▸ TRUNCATE of a summary table: any counter going backwards resets its whole row
  • ▸ A dropped and recreated table or index: the old cursor is discarded
  • ▸ A reset digest table: a new FIRST_SEEN resets that digest
  • ▸ FLUSH STATUS: each global counter that went backwards counts from zero on its own
.env
# 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.

What it tells you about

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.

The server

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

Tables and indexes

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

Sent to mail, Slack or your own webhook

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.

  • ▸ An urgent event is sent as it opens, and again every six hours while it stays open, until someone acknowledges it
  • ▸ Once it clears, a recovery says so, and whether it cleared or its table or index was dropped
  • ▸ Mute an event until a date or for good, and it stays muted if it clears and comes back
  • ▸ One channel failing does not stop the others, and a message no channel took is retried on the next run
  • ▸ sailfish:notify:test sends a test over the channels a severity is routed to
config/sailfish.php
'routes' => [
    'critical' => ['slack', 'webhook'],
    'warning' => ['slack'],
    'digest' => ['mail'],
],
POST your webhook
{
  "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

MySQL 8.0 · Laravel 12 or 13 · PHP 8.2+ · 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 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?

+

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.

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

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.