Conversation
Reads every statement any process on the host sends Postgres or MySQL off the socket, with the parameter values the driver actually bound, and opens on a ranked list of the ones worth looking at. One BPF program covers both wire protocols. They frame messages differently but both are length-prefixed and tag-then-payload, with the statement text at a shallow offset, which is the shared seam. The kernel decides only "is this a SQL frame and where does the text start", copies a fixed window, and stops; all parsing, normalization and parameter decoding happens in JS. Three views: findings (what the tool noticed, ranked by cost) opens first, then the feed (one line per statement, with values inline and repeated shapes folded) and the aggregate (collapsed by shape, ranked by total time). Three facts about real client behaviour shaped the design, each found by running against live traffic rather than reading the specs: - Clients prepare once and execute many times, so most executions carry only values. Attribution goes through the prepared-statement name the protocol puts in the Bind message. A workload issuing 84 lookups sent 36 statements and 99 parameter blocks, and those unattributed executions are the N+1: a model needing a statement per execution cannot see what it exists to find. - Clients do not wait for replies, so the kernel keeps a per-socket ring of send timestamps rather than one slot. With one slot, 120 of 135 replies had no timestamp to pair against and were dropped in the kernel. - Pipelining clients send values before their statement, so orphaned parameter blocks are held briefly, guarded on placeholder count and age. Verified against PostgreSQL 17 and MySQL 8 with psycopg2, psycopg3, MySQLdb and both command-line clients, on plaintext and inside TLS. Includes 43 unit tests over the analysis layer (no kernel or database needed), a demo workload shaped like an application rather than a loop, and the kernel-matrix CI verifying every BPF program on 6.1, 6.6, 6.12 and bpf-next.
CI's kernel matrix caught a real portability bug: 6.6, 6.12 and bpf-next passed while 6.1 rejected two of the six programs. The two rejected are exactly the two that call `iter_base()`, and one of them is 38 instructions long, which is what ruled out complexity as the cause. The bug is in reading a `msghdr`'s iov_iter, and two things changed in 6.4: - `iov_iter` gained a `ubuf` member for the single-buffer case. On 6.1 the field does not exist, so `BPF_CORE_READ(msg, msg_iter.ubuf)` has no relocation target and the verifier rejects the whole program at load. Not a wrong value: a rejected program. - The `iter_type` values SHIFTED. 6.4+ has `ITER_UBUF = 0, ITER_IOVEC = 1`; before that `ITER_IOVEC = 0`. So the enum constants clang bakes in are wrong on the other side of that boundary even where the fields exist, and the comparison silently misreads the iterator type. That second half would have been a data bug rather than a load failure, and much harder to find. Fixed with CO-RE feature detection: `bpf_core_field_exists()` guards the `ubuf` read so it is dropped entirely on a kernel that lacks the field, and `bpf_core_enum_value()` resolves the type constants against the running kernel's BTF rather than the one the compiler saw. `bpf_core_field_exists()` only defers the CHECK to load time, so the field still has to typecheck at compile time, and there is no `msg_iter.iov` member in a 6.12 vmlinux.h. The pre-6.4 layout is therefore declared locally as a `preserve_access_index` struct, which is the standard idiom for referencing a field the compile-time BTF does not carry. Verified after the change: builds with no warnings, all 6 programs verify on 6.12 (on_sendmsg 345,450 insns, 35% of the ceiling), and live capture is unchanged at 207 statements, 675 parameter blocks and 842 replies with values and decimals decoding correctly.
Kernel matrix: green on all fourThe first run failed on 6.1 while 6.6, 6.12 and bpf-next passed, which turned out to be a real portability bug rather than a flake. Fixed in 30cdba5 and re-run:
What was wrongTwo of the six programs were rejected on 6.1, and they were exactly the two that call Both problems live in reading a
The fixCO-RE feature detection. One wrinkle worth recording: Verified after the change
This also grounds the README's "Verified on 6.1, 6.6, 6.12 and bpf-next" claim, which was the one open flagged item. It can be ticked as measured rather than aspirational. |
Adds
sqlsnoop, an eBPF SQL statement monitor for Postgres and MySQL. It reads every statement any process on the host sends off the socket, with the parameter values the driver actually bound, and opens on a ranked list of the ones worth looking at.Reviewer action items
Apply the About description— applied at repo creationApply the topic tags— applied, all 15, verified unmangledAdd a LICENSE file— Apache-2.0, byte-identical to exectop'sWhat it does
Three views. findings opens first, because a live feed at forty statements a second is twenty rows of routine lookups with one real problem somewhere in them, and spotting it is work the tool should do rather than delegate:
feed is one line per statement with values inline and repeated shapes folded; top collapses by shape and ranks by total database time.
An N+1 is the bug this exists to find, and it is invisible to almost everything else: each of the two hundred lookups is fast and correctly indexed, so a slow-query log stays empty and an APM shows a slow endpoint with nothing to blame.
How it works
One BPF program covers both wire protocols. They frame messages differently but both are length-prefixed and tag-then-payload with the statement text at a shallow offset, which is the shared seam. The kernel decides only "is this a SQL frame and where does the text start", copies a fixed window, and stops. All parsing, normalization and parameter decoding happens in JS, where it can be unit tested.
Three facts about real client behaviour shaped the design, each found by running against live traffic rather than reading the specs:
Verification
node --test test/lib.test.mjs). They caught two real bugs on their first run:verbOfclassifiedWITH x AS (…) DELETE FROM tas a read, so a CTE-wrapped write was never counted as a write nor checked for a missingWHERE; and the leading-wildcardLIKEcheck could never fire, because its condition reduced to a test of the normalized shape where the%has already been replaced.on_sendmsgsits at 345,443 instructions, 35% of the 1M ceiling — down from 686,652 (69%) after cutting the Postgres frame loop from six frames to four, which is the change this PR's CI matrix is most worth watching.make clean && makeis silent. No warnings.Note for anyone reproducing locally: run
make veristatwithsudo. Unprivileged it reports every program asfailurewith zero instructions, which is-EPERMfrom the loading probe rather than a verifier rejection, and it is a convincing false alarm.Known limits, stated in the README
tcp_sendmsg. This is the most common cause of an empty first screen.int8/float8/timestamp) because the Bind message does not carry the type. Ambiguous values are marked and the runnable-statement form refuses to build rather than handing you SQL that runs against the wrong row.Reviewer notes with the full flagged-claims table live in
~/code/yeet-scripts-readmes/sqlsnoop/notes.md.