Guide

How to audit what an agent ran against your database

Published . Updated . Every config value and every log line on this page was produced on a PostgreSQL 17.11 instance with pgaudit and pg_stat_statements loaded, on 2026-09-03.

Turn on statement logging, put the identity in the log prefix, and give the agent a role nothing else uses. log_statement = 'all' records every statement before it runs, and a log_line_prefix carrying %u, %d, %a and %h puts the role, the database, the client-declared application name, and the host on every line. Add pgaudit if you want a record of which objects each statement actually touched rather than just the SQL text. The full config and real output from both are in the next two sections, and neither costs anything but disk.

The identity has to be the role. Verified on 17.11: with %a in the prefix, an agent connected as analytics-bot, ran two queries, issued one SET application_name = 'totally-a-human', and every line after that carried the new name. application_name has pg_settings.context = 'user', so it is a hint the client supplies and revises, not an identity you assigned. log_statement and every pgaudit.* setting are superuser-context, so a plain agent role cannot switch the logging off, which is the good news in the other direction.

Then the part worth knowing before you build a process on top of it. A Postgres log answers what was asked. It does not record what came back, it has no idea what an agent is, it writes your literal values into a second file you now have to protect, and it is a file rather than a chain, so nothing about it demonstrates that it has not been edited. Each of those is covered below with the command that shows it.

The honest first answer
log_statement = 'all', a log_line_prefix with %u and %h, one role per agent.
For object-level records
pgaudit, in read,write,ddl with log_relation.
Not an identity
application_name. Its pg_settings.context is user, so the client sets it.
Not an audit log
pg_stat_statements. It normalises literals away and keeps no per-execution row.
Postgres version tested
17.11, with pgaudit and pg_stat_statements.
What the log cannot say
What came back, which agent asked, or that the file is unedited.

Turn on statement logging

Both of these need a reload, and log_line_prefix is where most of the value is. A log of statements with no prefix is a pile of SQL; a log with a prefix is a record.

postgresql.conf
log_statement = 'all'
log_line_prefix = '%m [%p] %q%u@%d app=%a host=%h '

# Optional, and usually the better default on a busy database: log everything
# slower than 250ms instead of everything. Set to 0 to time every statement.
log_min_duration_statement = 250
The prefix escapes worth having
EscapeWhat it gives you
%mTimestamp with milliseconds. Use this rather than %t.
%pBackend process id, which is what ties lines from one session together.
%uThe role. This is the identity you assigned, and the only one here you control.
%dDatabase name.
%aApplication name. Useful as a hint, worthless as identity. See below.
%hClient host.
%qStop point for non-session output. Everything after it is omitted for background processes, which keeps startup and checkpoint lines readable.

log_statement = 'all' logs the statement before it executes, so a statement that is then refused still appears. That is worth knowing, because it means the log records attempts and not only successes. Verified on 17.11, a DELETE from a read-only role produced three lines: the statement, the error, and the statement repeated as context.

An attempt that never ran
2026-09-03 09:30:11.823 UTC [89] agent_reader@app app=analytics-bot host=127.0.0.1 LOG:  statement: DELETE FROM public.users WHERE id = 1;
2026-09-03 09:30:11.823 UTC [89] agent_reader@app app=analytics-bot host=127.0.0.1 ERROR:  permission denied for table users
2026-09-03 09:30:11.823 UTC [89] agent_reader@app app=analytics-bot host=127.0.0.1 STATEMENT:  DELETE FROM public.users WHERE id = 1;

Do not use application_name as the identity

It is the obvious idea: have each agent set application_name, put %a in the prefix, and filter the log by it. It does not survive contact with an agent, and the reason is one line in the catalog.

Ask Postgres who is allowed to change what
SELECT name, context
  FROM pg_settings
 WHERE name IN ('application_name', 'log_statement', 'pgaudit.log');

On 17.11 that returns user for application_name and superuser for the other two. user means any session can change it, including the one you are trying to identify. Here is a real session doing exactly that, with %a in the prefix the whole time.

The same connection, before and after one SET
... [67] agent_reader@app app=analytics-bot host=127.0.0.1 LOG:  statement: SELECT count(*) FROM public.users WHERE id = 42;
... [67] agent_reader@app app=analytics-bot host=127.0.0.1 LOG:  statement: SELECT count(*) FROM public.users WHERE id = 99;
... [67] agent_reader@app app=analytics-bot host=127.0.0.1 LOG:  statement: SET application_name = 'totally-a-human';
... [67] agent_reader@app app=totally-a-human host=127.0.0.1 LOG:  statement: SELECT email FROM public.users WHERE id = 1;

The backend pid [67] is constant, so the session is traceable if you happen to be reading the whole file in order. Nothing you filter or group by is. Use application_name as a debugging convenience and put the identity in the role, which the agent cannot change and which appears as %u on every line.

The superuser context on the other two is the reassuring half. A plain agent role cannot turn statement logging off, and it cannot turn pgaudit off. What it means in practice is that your logging survives the agent and does not survive whoever holds a superuser or platform-admin role, which on most managed Postgres platforms is the account your team uses every day.

Add pgaudit for object-level records

log_statement records SQL text. pgaudit records classified events against the relations involved, which is what an auditor generally wants and what makes the log searchable by table rather than by string matching.

postgresql.conf, then restart
shared_preload_libraries = 'pgaudit'
pgaudit.log = 'read,write,ddl'
pgaudit.log_relation = on
Then, once per database
CREATE EXTENSION pgaudit;
What it writes, from the same session as above
... agent_reader@app app=analytics-bot host=127.0.0.1 LOG:  AUDIT: SESSION,1,1,READ,SELECT,TABLE,public.users,SELECT count(*) FROM public.users WHERE id = 42,<not logged>
... agent_reader@app app=analytics-bot host=127.0.0.1 LOG:  AUDIT: SESSION,2,1,READ,SELECT,TABLE,public.users,SELECT count(*) FROM public.users WHERE id = 99,<not logged>
... agent_reader@app app=totally-a-human host=127.0.0.1 LOG:  AUDIT: SESSION,3,1,READ,SELECT,TABLE,public.users,SELECT email FROM public.users WHERE id = 1,<not logged>

The fields are the audit type, a statement counter, a substatement counter, the class, the command, the object type, the object name, the statement, and the parameters. <not logged> is pgaudit.log_parameter, which is off by default and which you should think about carefully before turning on, since it is the same data-copying problem as statement logging with none of the ambiguity.

Two details from the run above. The SET application_name produced no AUDIT line at all, because it is a MISC statement and the configuration only asked for read,write,ddl. And the refused DELETE produced no AUDIT line either, because pgaudit records statements that execute; the attempt is in the plain statement log instead. If you want both attempts and object-level records you need both mechanisms, which is one of the reasons this ends up being more moving parts than it first looks.

Every pgaudit.* setting reports context = superuser on 17.11, so the settings are as durable as the rest of your server configuration and cannot be adjusted by the role being audited.

pg_stat_statements is not an audit log

It is the first thing people reach for, because it is already installed and it is genuinely excellent at the job it does. That job is performance aggregation, and the design choice that makes it good at that makes it unusable as a record of what happened.

Two queries were run in the session above: WHERE id = 42 and WHERE id = 99. This is everything the view retained about them.

The whole record of two distinct queries
     role     |                      query                      | calls | rows
--------------+-------------------------------------------------+-------+------
 agent_reader | SELECT count(*) FROM public.users WHERE id = $1 |     2 |    2
 agent_reader | SELECT email FROM public.users WHERE id = $1    |     1 |    1
  • **The literals are gone.** 42 and 99 both became $1, and the two executions collapsed into one row with calls = 2. You can see that the agent looked up a user; you cannot see which one.
  • **There is no timestamp per execution.** The view carries stats_since for the entry as a whole and nothing per call, so ordering, correlating with an incident, or answering "what did it run between 2 and 3am" are all out of reach.
  • **There is no client and no application name.** The columns are userid and dbid. Two agents on the same role are one row.
  • **Entries are evicted.** pg_stat_statements.max defaults to 5000 and is postmaster context, so a busy database silently drops the least-used entries, and an agent's one-off exploratory query is exactly the shape that gets dropped.

One reassurance, since it comes up: a plain agent role cannot erase it. SELECT pg_stat_statements_reset() as the agent role returned permission denied for function pg_stat_statements_reset on 17.11. Reading other roles' entries is also restricted, and their query text comes back as <insufficient privilege>. Use it to find the query that is hurting your database. Do not use it to answer what an agent did.

Give every agent its own role

This is the step that makes the two mechanisms above worth having, and it is the one people skip because it looks like grant management. Do it through a group role and it is two statements per agent.

One shared privilege set, one identity per agent
-- The privileges live here, once.
CREATE ROLE agent_readers NOLOGIN;
GRANT CONNECT ON DATABASE app TO agent_readers;
GRANT USAGE ON SCHEMA public TO agent_readers;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO agent_readers;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
  GRANT SELECT ON TABLES TO agent_readers;

-- Two lines per agent, and now %u means something.
CREATE ROLE analytics_bot LOGIN PASSWORD 'replace-me';
GRANT agent_readers TO analytics_bot;

CREATE ROLE support_copilot LOGIN PASSWORD 'replace-me-too';
GRANT agent_readers TO support_copilot;

Now grep 'analytics_bot@app' is a real answer to what one agent did, and revoking one agent is DROP ROLE analytics_bot rather than a password rotation that takes every consumer down with it. The cost is that every agent's privileges are now the group's privileges, so per-agent scoping means per-agent grants and you are back to managing grants.

What log-based auditing cannot answer

Four questions, in the order they come up during an actual incident.

The questionWhy the log cannot answer it
How much data left the database?The log records statements, not results. There is no row count and no byte count on a LOG: statement: line. pgaudit has a log_rows setting, off by default, which adds a row count for statements it records; nothing in either mechanism records the bytes on the wire, and neither records what was in them.
Did the response contain personal data?Nothing inspects the result set. You can infer it from the SQL if the SQL names columns, and you cannot infer it from SELECT *, which is what agents write.
Which agent was this, and was it acting on its own?The database knows a role and a backend pid. If two agents share a role, or one agent opens ten connections, the log has no concept that ties those together. Session identity is per connection, and agent identity is not a thing Postgres has.
Can I show this log has not been edited?No. It is a text file on a filesystem, rotated and pruned by the same operations tooling as everything else. It has no sequence, no chaining, and no signature, so it is evidence in the same sense that any file is evidence.

Two more practical ones. Volume: log_statement = 'all' logs your application's traffic too, which on a busy database is the overwhelming majority of the lines and can be a real fraction of your write throughput. And searchability: everything above is text, so answering a question means shipping the log somewhere that can index it, which is a pipeline you now own.

None of that makes log-based auditing wrong. It makes it an operational record rather than an accountability record, and the difference matters exactly when someone outside your team asks the question.

Where PgBeam fits

PgBeam sits in the PostgreSQL wire protocol between the agent and your database, so it sees the statement, the policy decision, and the response, which is the combination the database log structurally cannot have. Every statement on an agent credential produces one entry, whether it was allowed, masked, truncated, blocked, or stopped by a budget.

What an entry carries
FieldValue
IdentityThe credential id. One credential per agent, with its own Postgres username and its own MCP token, so attribution does not depend on anything the agent sets.
Eventquery, masked, truncated, blocked, budget_exhausted, rejected, approval_requested, approved, migration_flagged, or canary_tripped.
Decision and reasonA machine-readable rule tag such as read_only or budget_daily, plus the human-readable reason the agent was given.
StatementThe normalised SQL and its hash, so identical shapes group. Statement text is truncated at 2000 characters.
ResponseRows forwarded to the agent after masking and row caps, bytes out, latency, and cache status.
ContextClient IP, session id, region, timestamp, and the source: a wire connection, the hosted MCP endpoint, or the REST surface.

Blocked statements are recorded the same way allowed ones are, with the rule that blocked them and the reason, so the log answers what an agent tried as well as what it ran. That is the half a database log gets by accident and a policy layer gets on purpose.

The chain, and how to check it

Entries are hash-chained per project. Each entry's hash covers a canonical encoding of every stored field plus the previous entry's hash, so changing any value, deleting a row, or reordering the sequence breaks the link at that point and at every point after it. A verify endpoint walks the chain and reports the first broken sequence number rather than a yes or no.

Read it, export it, verify it
pgbeam audit list --credential agt_xxx --event blocked --limit 50
pgbeam audit export --decision blocked > blocked.csv
pgbeam audit verify

# Or over the API
curl "https://api.pgbeam.com/v1/projects/{projectId}/audit-logs?event=blocked" \
  -H "Authorization: Bearer pbo_..."

curl "https://api.pgbeam.com/v1/projects/{projectId}/audit-logs/verify" \
  -H "Authorization: Bearer pbo_..."

The CSV export carries each entry's sequence number, previous hash, and entry hash, so an auditor can recompute the chain outside PgBeam rather than taking our word that it verifies. Export is capped at 100,000 rows per request.

Getting it into your own systems

The audit stream can be delivered live in four formats: PgBeam's own JSON, Splunk HEC, Datadog Logs, and Elastic ECS. The JSON and Elastic deliveries are HMAC-SHA256 signed, with the signature in X-PgBeam-Signature over the body and X-PgBeam-Signature-V2 over the timestamp and body together. Splunk HEC and Datadog deliveries carry their own vendor auth headers instead of an HMAC signature, which is worth knowing if your verification step assumes every delivery is signed.

Retention is 7 days on Starter, 30 on Pro, and 90 on Scale, with custom retention on Enterprise. The full breakdown is on pricing.

PgBeam's limits, stated up front

The audit-specific ones first, because they are the ones that would change a compliance answer.

  • **The chain is unkeyed SHA-256 by default.** That makes it tamper-evident against anyone who cannot recompute the whole chain. Keyed hashing exists and is not enabled by default, so do not describe the default configuration as tamper-proof against an actor who can write to the control-plane database.
  • **Statement text is normalised by default and truncated at 2000 characters.** Literal values are replaced with placeholders, which is deliberate (it keeps your data out of a second store) and means the audit log does not tell you which row an agent looked at, only which shape of query it ran.
  • **Shipping is best-effort, not fail-closed.** The proxy buffers entries and ships them on an interval. If the buffer overflows, entries are dropped and a marker entry is written recording that a gap exists, so a gap is visible rather than silent. It is still a gap.
  • **A tier's queryable window equals its retention window.** Long-term archival to object storage is built and is not enabled, so treat 7, 30, and 90 days as the real numbers.
  • **Chain verification is API and CLI, not a dashboard button.** The dashboard filters, reads, and exports; pgbeam audit verify and the verify endpoint do the checking.
  • **Session ids can collide.** Attribution is reliable at the credential level. Grouping by session is a convenience and not an identity.
  • **No SOC 2 Type II.** The chain and the retention are real. The certification is on the roadmap and is not claimed.

The general limits apply too: relation-level allowlists do not see through views, SET search_path is blocked for agent credentials, binary-format masked columns come back NULL, and query budgets are per-region approximations while row caps and timeouts are exact.

When the database log is the right answer

  • **You are debugging, not accounting.** If the question is what a query did last Tuesday and you trust everyone who can reach the log, log_statement and pgaudit are the whole answer and they are free.
  • **One agent, one role, one operator.** The attribution argument is about telling agents apart. With one, %u already does it.
  • **You already run a log pipeline.** If statements are shipped, indexed, and retained by something you built for your application logs, adding an agent role to that is much less work than adding a component.
  • **Regulatory requirements you have already satisfied with pgaudit.** pgaudit is what auditors recognise for database access. If your process is built on it and passes, adding a second record is not obviously an improvement.

Common questions

Can I just log at the MCP server or in the agent framework?

It works for the calls that go through it, and it is worth having for the reasoning context, which nothing below the wire can see. It is not an audit trail, because the component doing the logging is the component under scrutiny and is trivially bypassed by anything that opens a connection directly. Log there for the story and lower down for the record.

Does logging every statement slow the database down?

It costs a write per statement and it is usually the log volume rather than the latency that bites. On a busy database, log_min_duration_statement with a threshold is the common compromise, and the tradeoff is explicit: you stop recording the fast statements, and an agent's exploratory SELECT is usually fast.

What about the audit features my managed provider ships?

Most of them are pgaudit plus a log pipeline, presented well, and that is a reasonable thing to use. They inherit the four limits in the table above, because those limits come from where the recording happens rather than from how it is packaged.

How do I know an agent did not run something I never saw?

You cannot answer that from a record alone, whoever produces it. Absence of an entry is only meaningful if the recording path cannot be skipped, which means the record has to be produced by something the connection cannot go around. That is an argument for putting the recorder in the connection path rather than beside it, and it is the same argument as for putting the policy there.

What we could not verify

Everything above is checkable. These are not, so they are listed instead of asserted.

  • How much overhead log_statement = 'all' adds on your workload. We did not benchmark it, and the honest answer is that it depends on statement rate, log destination, and whether the log is on the same disk as the data.
  • Whether pgaudit.log_rows records the row count for every statement class or only some. We confirmed it exists and is off by default on 17.11 and did not exercise it, so we are not describing its output.
  • What each managed Postgres platform actually gives you. pgaudit availability, whether you can set shared_preload_libraries, and who holds the superuser-equivalent role all vary, and the context values quoted here come from a stock PostgreSQL 17.11 instance rather than any provider.
  • The auth_failed event exists in PgBeam's event vocabulary, and we did not confirm which code path emits it, so we are not claiming failed authentications produce an audit entry.

Sources

Give your agent Postgres it can't wreck

Connect a database, issue a credential, and watch the audit log fill up. No credit card. 14-day trial.