
Fifteen years building engineering platforms, currently focused on advanced AI infrastructure at yeet. I love turning the deeply complex topics into something everyone can understand. I relate deeply with the core yeet philosophy that you can just build things.
Last updated: October 2026
Quick answer. You do not have to choose between an agent that is useless on database problems and an agent holding a Postgres password. There are three levels of access and they are not a dial from weak to strong: a read-only SQL role lets the agent ask the database questions,
pg_stat_statementslets it see which statements are expensive, and a socket-level reader likesqlsnooplets it see the statements your application actually sent, with the bound values, without connecting to the database at all. The third one is the only one that works on a managed instance you cannot reconfigure, and it is the only one where the agent never receives a credential. Start by deciding which question you want answered, because the levels answer different ones.
I build eBPF tooling at yeet, and the question I get asked most often about agents and databases is some version of "how much do I have to hand over." I am not going to tell you what your company's policy on production database access should be, and I have not audited your ORM. What I can be useful on is a distinction that gets collapsed constantly: giving an agent database access and giving an agent query visibility are different grants, with different blast radii, and most of the debugging work people want only needs the second one.
Usually the statement text, how often it ran, and which process sent it. That is a smaller ask than it sounds, and noticing how small it is changes what you have to grant, because each of those three facts is observable outside the database. The instinct is to reach for a connection string first and work out the question afterwards, which reliably grants more than the problem needed.
Consider the bug that motivates most of these requests. An endpoint is slow. The slow query log is empty, because every individual query is fast and correctly indexed. An APM trace shows the endpoint taking two seconds with nothing obvious inside it. The actual cause is that an ORM emitted one query to fetch a list and then two hundred more to fetch each row's relation, a pattern called N+1, and the queries are only wrong together. No single one of them is slow enough to log.
To find that, an agent needs to count statements by shape over a window. It does not need to read your data, write to your database, or hold a role that could. The information that solves the problem is the traffic, not the contents.
That distinction is doing real work here, because the three common ways to give an agent database visibility differ mostly in how much they grant beyond the traffic itself. Two of them hand over a credential to answer a question that the traffic already contains, and the third does not.
It is a reasonable option and the one most people reach for first, and it solves a narrower set of problems than its popularity suggests. A read-only role is the right answer to a specific class of question, and the mistake is not using one; the mistake is assuming it covers the debugging work that brought you here.
A read-only role (GRANT SELECT, or pg_read_all_data in Postgres 14 and later) lets the agent run EXPLAIN ANALYZE, inspect schemas, check index definitions, and query pg_stat_activity to see what is running right now. For a question like "is this query using the index I think it is," that is exactly the right tool, and nothing in this post replaces it. Query plans are a database-side fact and only the database can tell you about them.
What it costs you is a credential that exists, has to be stored somewhere the agent can read it, and can read every row the role can see. "Read-only" bounds writes, not disclosure. If the concern that brought you here is the agent touching customer data, a read-only role does not address it, because reading customer data is precisely what the role permits.
It also does not answer the N+1 question well. pg_stat_activity is a snapshot of what is in flight, so a burst of two hundred fast queries is mostly gone by the time you look. You can sample it in a loop and approximate, but you are inferring a pattern from snapshots rather than observing it.
No, and this is the gap that surprises people, because pg_stat_statements is otherwise the right answer to "which statements are expensive." It is a good extension doing exactly what it was designed to do, and the limit described here is a consequence of that design rather than a defect in it. Knowing where the limit falls is what stops you from spending an afternoon trying to extract an answer the data does not contain.
It aggregates by normalized statement text and gives you call counts and total time, which means it will show you the N+1: one statement shape with a call count of two hundred thousand. That is useful, and if you have it enabled, use it.
What it does not carry is the sender. The extension records the statement, not the client, so the natural next question, which of my eleven services is emitting this, has to be answered somewhere else. The usual advice is to join against pg_stat_activity and read application_name, and that works right up until you put a connection pooler in front of the database. Behind PgBouncer, connections are shared, so application_name names the pooler or whichever service last borrowed the connection, not the one that sent the statement you are asking about. On a pooled setup the sender is not recoverable from the database side.
There is also a deployment cost: pg_stat_statements is a shared library that requires a configuration change and a restart to enable, and on a managed instance it may simply not be available to you. "Restart production Postgres" is a high price for a debugging session.
Yes, and this is the part that is badly underdocumented, so the mechanism is worth walking through rather than asserting. The claim sounds like a trick when stated baldly, and it is not: it follows from where the probe sits. Everything below is a consequence of reading the statement before it leaves your own process, rather than asking the database to report on itself afterwards.
Every SQL statement your application sends leaves the process as bytes on a socket before it reaches the database. A kernel probe on the socket write path can read those bytes, parse the wire protocol, and reconstruct the statement, without ever opening a connection to the database. sqlsnoop is the yeet script that does this, and its own description of the tradeoff is the clearest statement of why it matters:
"Where you would otherwise set
log_statement = 'all'and restart, enablepg_stat_statementson a database you may not own, or bisect an ORM until it confesses what it generated, this attaches to the socket. One run watches every Postgres and MySQL client on the host, nothing is installed in the application, and the database is never asked for anything."
Three properties follow from attaching at the socket rather than at the database, and each one maps directly onto something the other two levels could not do. They are worth reading as a set, because individually each sounds like a minor convenience and together they are the reason this approach survives a managed instance and a connection pooler at the same time:
pg_stat_statements cannot answer, including behind a pooler, is the one piece of context this gets for free.customer_id = 4471 is an observation rather than a reconstruction from a prepared-statement template.It is read-only by construction rather than by policy, which is a stronger guarantee than a GRANT. The tool reads bytes and has no channel through which to modify, delay or block a statement. The matching database-alerts prompt makes the same constraint explicit to the agent, instructing it to watch only and "never change my databases, their configuration or their users."
The three are complements rather than competitors, and the rule that keeps them straight is to match the level to the question you actually have rather than to grant the most access you can justify. Ranking them by strength is the error that leads to handing an agent a credential for a problem that never needed one:
| What you want to know | Level that answers it | What the agent receives |
|---|---|---|
| Which statements did my app really send, how often, from which process | Socket reader (sqlsnoop) | No credential; host traffic access |
| Which statements are expensive across all clients, over time | pg_stat_statements | A read role, plus a restart to enable |
| Is this query using an index, and what is the plan | Read-only SQL role, EXPLAIN ANALYZE | A credential that can read rows |
Read down the right-hand column and the tradeoff is visible: the question most debugging sessions start with is the one that needs the least handed over. That ordering is not an accident, because "what did my application send" is a fact about your application, and you do not need the database's permission to observe your own process.
A reasonable setup uses the socket reader to find what is happening and a read-only role to understand why the database is slow about it. Finding the N+1 does not require a credential; deciding whether the fix is an index does.
Four limits matter when deciding whether a socket reader fits your stack, and the sharpest of them is unavoidable rather than unimplemented. Any one of them can rule the approach out for a given service, so they are worth checking against your own runtimes before planning around the tool rather than after.
Go and Java clients over TLS are invisible. The TLS probes attach to SSL_read and SSL_write in OpenSSL. Go's crypto/tls is pure Go and Java's JSSE lives inside the JVM, so neither exposes a C symbol to hook. Plaintext connections from both are read normally, but if your services are Go talking to Postgres over TLS, this does not work for you, and no amount of configuration changes that.
Unix socket clients are invisible. The probes hook tcp_sendmsg, so a local client connected through /var/run/postgresql/.s.PGSQL.5432 never appears. Connect over TCP if you need to see it.
Query plans are out of scope. Whether an index was used is invisible from the wire. EXPLAIN owns that question and the read-only role is how you ask it. This is the clearest case for not treating the levels as a ranking.
Statements are captured to a fixed window, 256 bytes for the statement and 192 for the parameter block. A long statement shows its opening, enough to identify it, not enough to read whole. The record is marked truncated rather than silently cut.
Install yeet, then run the script against the host your application runs on. Nothing is installed into the application itself, no driver is wrapped, and the database is not touched or reconfigured at any point in the sequence below:
curl -fsSL https://yeet.cx | sh # install yeet, once
yeet run gh:yeet-src/sqlsnoop # clone, build and run
For encrypted connections the TLS probes need a target binary, passed after -- so the flag reaches the script rather than yeet:
yeet run gh:yeet-src/sqlsnoop -- --tls-binary libssl.so # dynamically-linked clients
yeet run gh:yeet-src/sqlsnoop -- --tls-binary "$(command -v node)" # a runtime bundling its own TLS
One ordering detail causes most of the confusion on first run: start it before your workload. The probes see traffic that happens while they are attached, and a uprobe only fires for processes that start afterwards, so a client already running when you attach stays invisible on the TLS path until it reconnects.
To have the agent set it up and keep the results somewhere you can return to, the database-alerts prompt is written for exactly that, including the instruction to build it as a service that keeps running rather than a script that prints once and exits.
It depends on what you are worried about, and the two common worries have different answers, so collapsing them produces a confident conclusion in the wrong direction. One of them this mechanism resolves structurally. The other it does not resolve at all, and anyone telling you otherwise is selling something.
If the worry is the agent changing something, reading bytes off a socket has no write path. There is no connection, no session, and no statement the tool can issue. That is a structural property rather than a promise about behavior.
If the worry is data disclosure, be careful here, because query traffic contains parameter values and those values are frequently personal data. A WHERE email = ... carries an email address. Capturing real bound values is what makes the tool useful for debugging and it is also what makes the captured traffic sensitive. You have moved the exposure rather than eliminated it: the agent no longer holds a credential that could read every row, but it does see the values flowing through the queries it watched. For most debugging that is a good trade. For a database where any row is regulated, treat the capture with the same care you would treat an application log that prints parameters.
Most agent database debugging does not need a database credential, because the question is usually about what the application sent rather than what the database contains. Reading the statements off the socket answers that question, names the sending process even behind a pooler, and asks the database for nothing. Keep a read-only role for query plans, where the database is the only source of truth, and stop treating the two as the same grant.
Yes, if you give it a connection string and a role, usually through an MCP server or a shell command. Whether you should depends on the question you want answered: plan analysis needs a connection, and statement counting does not.
No. A socket-level reader such as sqlsnoop parses the Postgres and MySQL wire protocols from the sending process, so no role, password or connection to the database is involved.
Grant the narrowest thing that answers your question. Reading query traffic from the host involves no database credential; a read-only role involves one that can read rows. Neither is safe in the abstract, and both are reasonable for different questions.
No. It aggregates by normalized statement text without recording the client. Joining to pg_stat_activity for application_name is the usual workaround, and it stops identifying the real sender once a connection pooler shares connections between services.
Yes. N+1 is a count of statements by shape over a window, which is visible in the traffic leaving the application. The database connection is not what makes the pattern detectable.
Yes, because it attaches to your application host rather than the database. That is the main practical advantage over pg_stat_statements, which needs a configuration change and a restart on an instance you may not control.
Over OpenSSL, yes, with a --tls-binary target. Go's crypto/tls and Java's JSSE do not expose a hookable C symbol, so TLS connections from those runtimes cannot be read.
It does not touch the database at all. The probes run on the application host and read bytes already being written, so the cost lands there rather than on the database server.
Not through a socket reader, which has no write path and issues no statements. That guarantee comes from the mechanism rather than from configuration.
No. The probes attach from outside the process, so no driver wrapper, no instrumentation library and no redeploy is involved.
sqlsnoop, the eBPF SQL statement monitor described here, including its supported databases and documented limitspg_read_all_data