Read/write splits: which server did the audit read?
If your connection configures a read/write split, SQLens will not run until it knows which server it is reading. That is deliberate, and it is the one place the package refuses something Laravel is happy to do.
What Laravel does
A connection with a read block listing more than one host does not name a server —
it names a set. Laravel resolves that set at connect time, not at config time:
foreach (Arr::shuffle($this->parseHosts($config)) as $host) {
$config['host'] = $host;
try {
return $this->createConnector($config)->connect($config);
} catch (PDOException $e) {
$exception = $e;
}
}
Two things follow. The order is random, so two runs of an unchanged project can reach different servers. And a host that does not answer is silently skipped, so a replica that is briefly unreachable promotes the next one with nothing logged.
For your application that is exactly right — any replica will serve the query. For an audit it is not. A primary and its replica disagree about server settings, about replication lag, and sometimes about the schema itself. A report that cannot name the database it read is a report about an unknown database, and the difference between two such runs looks like drift.
What SQLens does instead
Either the configuration leaves exactly one possibility, or you name one, or the run is refused before anything connects:
php artisan sqlens:audit --host=replica-2.internal
// config/sqlens.php
'host' => env('SQLENS_HOST'),
A connection that names no host at all — a Unix socket, a DSN — is not ambiguous and needs no pin. Neither does a split with a single read host: you already said which server answers.
A host the connection does not offer is refused rather than dialed. Pinning a server outside the configured topology is a typo far more often than an intention, and a report about a database your project never configured is worse than no report, because it reads exactly like a real one.
What happens after connecting
Writing a host onto a connection is not the same as reaching it, so SQLens asks the server who it is and compares the answer to the pin. There are three outcomes.
Confirmed. You pinned a literal address and the server named the same one. The report can state which database it read.
Diverged. The server named a different address. The run stops — every finding it went on to produce would describe a database nobody chose. Look for a pooler or a proxy between your application and the server.
Unverified. The two cannot be compared, and this is the ordinary case rather than
an edge. You pinned db2.internal; PostgreSQL answered 10.0.0.7. Those are very
probably the same machine, and SQLens will not resolve the name to find out: a DNS
lookup would put a network call and a second source of truth inside a run whose whole
contract is that the same state produces the same result.
So it reports the check it could not make, as undetermined with the reason
pinned_host_unverifiable, and continues. Pin a literal address if you want this
confirmed rather than reported.
The engines differ here, and the difference is not SQLens'. PostgreSQL's
inet_server_addr() reports the address the client dialed, so a pin can be confirmed.
MySQL's @@hostname is the server's own machine name and has nothing to do with what
was dialed, so a MySQL pin is reported as unverified on essentially every run.
Transaction poolers
A read/write split is one way the connection you configure is not the server you reach. A transaction pooler is the other, and it is quieter.
PgBouncer in transaction mode — and ProxySQL, MaxScale or RDS Proxy doing the same for
MySQL — hands each statement whichever server connection happens to be free. That is fine for
an application, which is why the topology exists. For an audit it means the global half of a
settings reading may come from one machine and the session half from another, SQLens' own
session timeouts may not be attached to the backend running the next query, and
inet_server_addr() names whichever backend answered.
Nothing fails. The values come back looking entirely ordinary.
So SQLens measures the topology before it reads anything, on the connection it built, outside
any transaction — a transaction pooler's whole guarantee is one backend per transaction, so a
probe run inside one would find a perfectly stable connection every time and report direct
with complete confidence. Two tells decide it:
- the backend process id (
pg_backend_pid(), MySQL'sCONNECTION_ID()) across two separate statements, and - a namespaced session setting, read back in a following statement.
The conventional ports — 6432 for PgBouncer, 6033 for ProxySQL — and host names containing
pgbouncer or proxysql are recorded as signals but never decide on their own. A direct server
is free to listen on 6432, and a pooler is free to sit on 5432.
What a detected pooler does. Every check whose subject is the server — the settings
family — reports undetermined with the reason transaction_pooled instead of a verdict, plus
one run-level finding explaining the whole band. Schema findings are unaffected, and
deliberately so: a schema is the same on every backend of one database, so silencing those too
would throw away a reading that is perfectly valid.
A topology that could not be established is treated the same way as a detected pooler. "I could not check" is not rounded down to "direct" — those two produce identical findings otherwise, and the reader has to be able to tell them apart.
To have the server checked as well, point the audit at a direct connection: --host at the
server rather than the pooler, or a second Laravel connection that bypasses it.
Your application's connection is never touched
SQLens builds its own named connection from a copy of your configuration and writes the pinned host onto the copy. Your application's connection — its config and its PDO handle — is byte-for-byte what it was before the audit, including which server it talks to. A tool that left your app pointed at a replica would have done something worse than the problem it was looking for.