Database monitoring (Postgres)
Sentrinel watches Postgres the way pganalyze and Datadog do: a small collector
runs next to the database, polls the pg_stat_* views, and pushes what it finds
to the ingest API.
Sentrinel never connects to your database. The collector holds the credentials and lives on your side of the network, so a Sentrinel compromise is not a database compromise, and no connection string is ever stored here.
Setup
1. A read-only role
CREATE ROLE sentrinel_monitor LOGIN PASSWORD 'a-strong-password';
GRANT pg_monitor TO sentrinel_monitor;
pg_monitor covers every statement the collector runs. It exists on RDS,
Aurora, Cloud SQL and Azure, where superuser is not on offer.
Two optional grants, each unlocking one feature. Neither is required, and the collector says which one is missing rather than showing an empty page:
-- Sequence exhaustion checks. pg_monitor cannot read sequence values, so
-- without this they are reported as "could not be read", never as healthy.
-- SELECT on a sequence permits reading its value and nothing else โ nextval
-- needs USAGE or UPDATE, so this cannot advance anything.
GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO sentrinel_monitor;
-- Error collection from the server log. Think before granting these: they let
-- the role read files as the Postgres OS user.
GRANT pg_read_server_files TO sentrinel_monitor;
GRANT EXECUTE ON FUNCTION pg_read_file(text, bigint, bigint) TO sentrinel_monitor;
GRANT EXECUTE ON FUNCTION pg_stat_file(text) TO sentrinel_monitor;
GRANT EXECUTE ON FUNCTION pg_current_logfile(text) TO sentrinel_monitor;
Log collection also needs Postgres writing a parseable log:
logging_collector = on
log_destination = 'csvlog' # or 'jsonlog' on PG15+
Managed Postgres does not offer pg_read_file at all. Everything else works
there; only the Errors tab is unavailable.
2. pg_stat_statements โ optional, but it is the good part
# postgresql.conf โ requires a restart
shared_preload_libraries = 'pg_stat_statements'
pg_stat_statements.track = all
CREATE EXTENSION pg_stat_statements;
Without it you still get activity sampling, wait events, blocking chains, connection and cache metrics, and table statistics. You lose per-query aggregates. The collector says so at startup rather than failing โ needing a restart scheduled is not a reason to collect nothing in the meantime.
3. Install the collector
curl -fsSL https://sentrinel.dev/install-collector.sh | sudo bash
That installs the sentrinel-collector command, a systemd service running as its
own unprivileged user, and starts it on boot. Bun is installed too if the machine
does not already have it.
To configure as you install โ the service starts immediately:
Issue a Database collector key for this (API Keys โ Generate โ Database collector). It lives on the database host and can send Postgres statistics and nothing else โ a leaked one cannot post application telemetry or read your data.
curl -fsSL https://sentrinel.dev/install-collector.sh | sudo -E env \
DATABASE_URL=postgres://sentrinel_monitor:pw@localhost/orders \
SENTRINEL_URL=https://api.sentrinel.dev \
SENTRINEL_KEY=snt_db_... \
SENTRINEL_INSTANCE=orders-primary \
bash
Or install first and configure after:
sentrinel-collector config set \
DATABASE_URL=postgres://sentrinel_monitor:pw@localhost/orders \
SENTRINEL_KEY=snt_db_... \
SENTRINEL_INSTANCE=orders-primary
sentrinel-collector check # does the database let us in?
sentrinel-collector start
The database registers itself on first contact and appears under Databases
within seconds. SENTRINEL_KEY is an ordinary ingest key โ it establishes which
organization owns the data, nothing more.
Re-running the installer upgrades in place and leaves your configuration alone.
TLS
The collector connects the way psql does โ it tries TLS and falls back to
plaintext, so a server that requires encryption and one that does not both work
without you configuring anything.
Set sslmode yourself when you want something stricter than the default, and it
is honoured as given:
sentrinel-collector config set \
DATABASE_URL='postgres://sentrinel_monitor:[email protected]/orders?sslmode=verify-full'
verify-full checks the server's certificate against a CA and is worth setting
for a database the collector reaches over a network you do not control. The
default encrypts but does not verify, which is the same guarantee psql gives.
The command
sentrinel-collector status is it running, and what is it watching
sentrinel-collector start|stop|restart
sentrinel-collector logs -f follow the service output
sentrinel-collector check connect and report what it can see
sentrinel-collector config show settings, secrets redacted
sentrinel-collector config set K=V change a setting and restart
sentrinel-collector run run in the foreground, for debugging
sentrinel-collector ls every instance on this machine
sentrinel-collector update fetch the latest collector, keep settings
sentrinel-collector uninstall remove this instance, keep the config
-i, --instance NAME which collector to act on
More than one Postgres on a machine
A server running a 17 on :5432 and an 18 on :5433 has two clusters, and
each needs its own collector. That is not packaging pedantry: pg_stat_statements
counters are per-cluster, and the collector ships deltas between snapshots โ mix
two clusters into one process and every number is nonsense.
The service is a systemd template, so instances are independent:
sentrinel-collector -i pg17 config set \
DATABASE_URL=postgres://sentrinel_monitor:pw@localhost:5432/app \
SENTRINEL_KEY=snt_db_... \
SENTRINEL_INSTANCE=orders-pg17
sentrinel-collector -i pg17 start
sentrinel-collector -i pg18 config set \
DATABASE_URL=postgres://sentrinel_monitor:pw@localhost:5433/app \
SENTRINEL_KEY=snt_db_... \
SENTRINEL_INSTANCE=orders-pg18
sentrinel-collector -i pg18 start
sentrinel-collector ls
pg17 running postgres://mon:****@localhost:5432/app
pg18 running postgres://mon:****@localhost:5433/app
config set creates the instance if it does not exist yet, so there is no
separate "add" step. Each instance keeps its own /etc/sentrinel/collector-<name>.env
and its own unit, sentrinel-collector@<name>.
Without -i every command acts on the instance named default, which is what a
single-database machine gets and never has to think about. Upgrading from an
older install migrates collector.env to collector-default.env and retires
the old unit.
uninstall removes one instance. The binary, the unit template and the CLI stay
until the last instance is gone โ removing them earlier would silently stop the
collectors you kept.
Replicas count as separate instances too. The collector reports role: replica, and replication lag only means something measured from the primary.
One cluster, many databases
Within a single cluster the picture is different, because most of the pg_stat_*
views are cluster-wide. One collector covers every database on that server
for queries, wait events, blocking, connections, cache hit ratio and replication
lag โ the rows carry datname, so you can see which database a query came from.
The exception is table statistics. pg_stat_user_tables only ever shows the
database you are connected to, so the Tables tab and its bloat / missing-index
advisories cover the database named in DATABASE_URL and no other. To watch
tables in a second database on the same server, give it its own instance.
check is the one to reach for when a chart is empty. Without pg_monitor most
of pg_stat_activity still returns rows โ just with the query text and wait
event blanked out for every backend but your own, which looks like an idle
database rather than a permissions problem:
Postgres 16.14 (primary)
Role holds pg_monitor
Activity 14 backend(s) with visible query text
Statements pg_stat_statements installed
Configuration lives in /etc/sentrinel/collector.env, owned 0640 root:sentrinel โ it holds a database password and an ingest key, and nothing
else on the box needs to read it.
uninstall removes the service and the CLI but keeps that file: deleting
someone's credentials is not an uninstaller's decision.
What is collected, and how often
| Source | Interval | Powers |
|---|---|---|
pg_stat_statements |
10s, delta'd | Query list: calls, total/mean time, rows, cache hit, WAL |
pg_stat_activity |
1s | Wait events, long transactions, blocking chains |
pg_stat_database |
10s | Connections, cache hit ratio, deadlocks, temp spill |
pg_stat_replication |
10s | Replication lag |
pg_stat_user_tables |
5m | Bloat, sequential scans, vacuum lag, write mix |
pg_stat_user_indexes + pg_index |
5m | Index size against index usage |
pg_attribute |
5m | Column types, nullability, defaults |
pg_database, pg_class |
1m | Transaction ID wraparound age |
pg_sequences |
1m | Sequence exhaustion |
pg_replication_slots |
1m | Inactive slots retaining WAL |
pg_stat_bgwriter |
1m, delta'd | Checkpoint pressure |
pg_stat_progress_vacuum |
1m | Vacuums in flight |
pg_settings, pg_roles |
15m | Configuration and security audit |
| server log | 15s | Errors, with SQLSTATE and statement |
Activity is sampled every second on purpose. The lock you care about is held for 200ms; at a ten-second cadence it is invisible.
pg_stat_statements counters are cumulative since the last reset, so the
collector ships the delta between consecutive snapshots. The first snapshot
only primes the baseline โ otherwise months of traffic would land as one spike.
A negative delta means a reset happened, and that row is dropped rather than
clamped.
Query text: full by default, maskable
By default you see the statement Postgres actually ran, values included:
SELECT * FROM users WHERE email = '[email protected]'
That is the point. A query you can paste into psql gives you the same plan;
a masked one does not, and "which value was slow" is often the whole answer.
To strip literals before anything leaves the database host:
sentrinel-collector config set SENTRINEL_MASK_QUERIES=true
SELECT * FROM users WHERE email = ?
String literals, numbers, dollar-quoted bodies and IN-list contents are all
replaced. The same applies to error text, where the DETAIL line of a constraint
violation quotes the offending value verbatim โ Key (email)=(?) already exists., keeping the column name and dropping the value.
Masking has to happen in the collector, not the dashboard. A display-time
toggle would mean the raw values had already been sent and stored, which is the
one thing masking exists to prevent. So it is a setting on the machine next to
your database, and the dashboard shows which mode each database is in โ without
that label a ? is ambiguous between a masked literal and a bind parameter.
Turning it on affects new data only. Text already collected stays as it was stored; the reverse is also true, so switching it off does not un-mask history.
Grouping does not change
Query identity is always computed from the literal-free form, in both modes.
Fingerprinting raw text would make WHERE id = 1 and WHERE id = 2 two
different queries, so a statement called a million times would scatter into a
million rows of one call each โ and the Queries page, which exists to rank by
share of database time, would rank nothing.
Errors group the same way: a thousand duplicate-key failures across a thousand different emails are one problem, and they stay one row whether or not the emails are shown.
When to turn it on
Consider it where query text carries data you are not permitted to store
outside its original system โ regulated personal data, payment details, health
records โ or where the people who can read the dashboard are not the people
allowed to read the database. pg_stat_activity is the sharpest case: it
carries the fully substituted text of whatever is running right now, so a client
that interpolates rather than binds puts real values in front of anyone with
dashboard access.
Reading the pages
Queries ranks by share of total database time, not by mean duration. A 4ms query called a million times outranks a 900ms query called twice, and only the "% of total" column makes that visible. The collector's own polling queries appear here too โ that is deliberate; you should be able to see what the observer costs.
Activity shows wait events over time, the longest-running backends, and
blocking chains. A row with Lock / transactionid and a blocking PID is one
transaction waiting on another.
Metrics covers connections against max_connections, cache hit ratio,
rollback rate and deadlocks.
Tables lists sizes and bloat, and derives advisories from them:
| Advisory | Fires when |
|---|---|
bloat |
โฅ20% dead tuples and >10,000 of them |
missing_index |
>1,000 sequential scans, zero index scans, >50,000 rows |
index_overhead |
Indexes exceed twice the table size on a table >10MB |
Small tables are excluded from missing_index โ a sequential scan of 200 rows
is the right plan.
Clicking a table
Indexes is the reason the drill-down exists. Postgres will tell you an
index's size, and it will tell you its scan count, but never side by side โ and
the pair is the whole judgement. Size is a permanent tax on every insert, update
and delete on the table; idx_scan is what it earns back.
Each index shows its definition, access method, whether it is unique, primary or invalid, its columns in order, its share of the table's index space, and:
| Column | Means |
|---|---|
| Scans (lifetime) | Since the last statistics reset |
| Scans (7d) | Movement in the last week โ separates "never used" from "went quiet" |
| Rows read per scan | Entries returned per scan. High means the index narrows poorly |
| Heap fetch ratio | Share of index entries that produced a heap row |
The index advisors:
| Advisory | Fires when |
|---|---|
invalid_index |
indisvalid is false โ a CREATE INDEX CONCURRENTLY failed |
unused_index |
Zero scans, not a constraint, and over 1MB |
idle_index |
Used historically, but not once in the last 7 days, and over 10MB |
redundant_index |
Its columns are the leading columns of a wider btree index |
low_hot_updates |
Over 100k updates with under 30% HOT โ most rewrite every index |
Primary keys and unique indexes are never reported as unused: they enforce a
constraint, which is worth their size whether or not anything reads them. And
redundant_index stays quiet when the wider index is itself unused โ "drop this
one, that one covers it" is not advice when nobody uses either.
Columns lists types (with lengths โ character varying(255), not varchar),
nullability and defaults, and marks which indexes lead on each column versus
merely include it. Only a leading column can serve a lookup on that column
alone.
Activity shows the read mix (index versus sequential scans, and rows read per sequential scan), the write mix, HOT update share, and size over the last seven days.
Health โ the four ways a Postgres database stops
Every one of these is knowable days or weeks ahead, and none of them shows up on a dashboard of averages. That is the entire reason the Health tab exists.
Transaction ID wraparound. Transaction IDs are 32-bit and wrap. Autovacuum
freezes old rows to prevent it; when something stops it doing that โ a long
transaction, an idle-in-transaction client, an abandoned replication slot, a
prepared transaction nobody committed โ the age climbs, and at 2 billion
Postgres refuses all writes to protect the data. Recovery is a single-user
VACUUM measured in hours. Warned at half the limit and again at 90%.
MultiXact age is tracked separately, because it wraps on its own counter and is
driven by row-share locking and foreign keys โ it can be critical while
transaction age looks fine.
Sequence exhaustion. An integer primary key backed by a serial stops at
2,147,483,647, and every insert fails at once. Widening the column to bigint
rewrites the table under an exclusive lock, so it is a scheduled migration if
you see it coming and an outage if you do not โ which is why the warning starts
at 50% rather than 90%.
Abandoned replication slots. A slot with no consumer retains every WAL segment it has not confirmed, without limit, until the volume is full. The same slot pins the transaction horizon, so it drives wraparound at the same time. One forgotten slot, two outages.
Checkpoint pressure. A requested checkpoint means WAL hit max_wal_size
before the scheduled one came round, so checkpoints fire on write volume rather
than the clock. Users see periodic write stalls that no individual query
explains.
The tab also shows vacuums in flight with their phase and progress โ without which a long vacuum and a stuck one look identical โ and the tables holding the cluster's freeze horizon back, which is where to look once the wraparound alarm rings.
Errors
pg_stat_database counts deadlocks; it does not say which two transactions, on
which table, running what. That only exists in the server log, so the collector
reads it where permitted and groups what it finds by SQLSTATE and message.
Errors are shown as Postgres wrote them, including the value a constraint violation quotes back at you โ which is usually the fastest route to the bug.
Key (email)=([email protected]) already exists.
With SENTRINEL_MASK_QUERIES=true the value is removed on your database host
before anything is sent:
Key (email)=(?) already exists.
Column names survive either way, because they are schema and they are the useful
part. The same handling applies to Failing row contains (โฆ) and to any value
quoted after a colon. A relation or constraint name in double quotes is always
kept โ relation "users" does not exist is useless without it.
Errors are capped at both ends: bounded bytes per read, bounded rows per send. A database in a crash loop writes megabytes a second, and an agent that forwards all of it faithfully turns one incident into two. What gets dropped is counted and reported rather than silently discarded.
Config & Security
A configuration and security audit, refreshed every fifteen minutes. Findings are computed on the server, not in the collector, so a rule can be corrected with a deploy rather than a fleet upgrade of agents on customer database hosts.
| Category | Examples |
|---|---|
| Security | TLS off, TLS available but unused, md5 passwords, multiple login superusers, BYPASSRLS, PUBLIC grants |
| Durability | fsync off, full_page_writes off, async commit, no data checksums |
| Availability | autovacuum off, no idle-in-transaction timeout, settings pending restart |
| Observability | no pg_stat_statements, no slow-query log, no lock-wait log |
Three rules govern what appears there:
Nothing is claimed clean that was not checked. Reading whether a role has a
password needs pg_authid, which is superuser-only โ the correct result for a
properly scoped monitoring role. The page says "could not be checked" rather
than "no passwordless roles", because the second would be fiction.
Every finding names a consequence and a fix. "ssl is off" is an observation. "Every query and every password crosses the network in plaintext โ set ssl = on and require it with hostssl" is something to act on.
Enabled is not the same as used. ssl = on means the server offers TLS.
Whether any client takes it is a separate question, and a server that looks
compliant on a settings page while every connection is plaintext is the case
worth catching.
Nothing about credentials is ever collected: no password hashes, no pg_hba
lines, no connection strings. The role check reads whether a password exists,
never what it is.
Disk space and backups
Two more ways a database stops, both of which it cannot tell you about itself.
Free space
Postgres tracks its own size, the WAL a replication slot is holding, and how fast each table is growing โ every input to running out of disk โ while having no idea how much room is left. "This slot is retaining 40 GB" means something very different on a 2 TB volume than on a 50 GB one.
So it is measured from the filesystem, using the data_directory the server
reports. That works because the collector runs on the database host, which is
what the installer sets up. Where it does not โ a collector pointed at a remote
database โ free space is reported as unavailable with the reason, rather than
measuring the collector's own volume and calling it the database's. A number
from the wrong filesystem reads as perfectly fine while the real disk fills.
The projection is the useful half:
Disk free 3% 28 GB of 926 GB
Growth 1.2 GB/day 23 days of room
"82% full" is a number people learn to ignore. "82% full, and at this rate that is nine days" is a date. Growth is measured from the observed change in database size over the last week, and the projection is omitted entirely when the database is not measurably growing โ extrapolating from noise would produce a confident number built on nothing. A short runway is its own finding, so a disk at 40% free and filling fast is caught before a free-space threshold would notice.
WAL archiving
archive_mode = on appears in the config audit and says only that archiving is
configured. Whether it works is a different question, and the one that matters:
a failing archive_command leaves the database running normally, serving traffic,
retrying the same segment forever, while the backup chain has been broken since
Tuesday. Nobody finds out until they need to restore.
It is also a disk problem. WAL that has not been archived cannot be recycled, so a broken archive fills the volume on its own schedule.
| State | Means |
|---|---|
working |
The most recent attempt succeeded |
failing |
The most recent attempt failed โ no recovery point beyond the last base backup |
idle |
Enabled, but nothing archived or failed yet |
off |
Not configured, and not nagged about |
The check is "did the most recent attempt fail", not "has anything ever failed". The lifetime failure counter never decreases, so a rule built on it could never clear โ and an alert that cannot clear is one people mute.
Alerts
Detecting a wraparound outage two weeks ahead is only half a feature if finding out requires someone to have the Health tab open. Every database gets four rules the moment it is created โ from the dashboard or by a collector registering itself โ with no configuration:
| Rule | Fires at | Why there |
|---|---|---|
| Transaction wraparound | > 50% of the limit | Recovery is a single-user VACUUM measured in hours, so the warning has to arrive with weeks to spare |
| Inactive replication slot | any | One slot with no consumer fills the disk and pins the freeze horizon |
| Connections near the limit | > 85% | Past max_connections new connections are refused, including the one you would use to log in and fix it |
| Sequence running out | > 70% | Widening an int4 column rewrites the table under an exclusive lock โ a maintenance window, not a hotfix |
| Disk filling up | < 15% free | A full disk stops Postgres accepting writes, and it cannot always restart cleanly afterwards |
| WAL archiving failing | any | Until it succeeds there is no recovery point beyond your last base backup |
Defaults exist because a rule you have to know to configure prevents very few outages. They arrive with no notification channel, so they show on the Health tab immediately but only reach a person once you attach one โ notifications should start because someone chose that, not because a database was added.
Deleting a default is permanent. The rules are created once, by existence rather than by name, so a rule you removed does not come back on the collector's next restart.
Every metric you can alert on
wraparound_percent, mxid_wraparound_percent, sequence_percent,
inactive_slots, slot_retained_bytes, connection_saturation,
replication_lag_bytes, cache_hit_ratio, deadlocks,
checkpoints_requested, db_size_bytes, bloat_percent, error_count,
disk_free_percent, archive_failing.
Each carries a one-line statement of what breaching it costs, shown in the rule editor โ a threshold without a consequence is a number nobody can calibrate.
A silent collector is not a healthy database
This is the trap worth naming. A database whose collector has stopped reports
zero for every metric, and zero on a > rule reads as perfectly healthy. So
when a collector has not reported for two minutes the rule is not evaluated at
all: it goes to not reporting rather than resolving to ok.
Silence is not evidence of health, and an alert that turns green because its input disappeared is worse than one that never existed.
Live
The Activity tab streams pg_stat_activity at the collector's own one-second
cadence over Server-Sent Events. It is pushed, not polled โ the collector
already performs that read to store it, and the same sample is fanned out to
whatever dashboards are open, so ten people watching costs the database nothing
extra.
One second is the point. The lock you care about is held for 200ms and is invisible at any slower refresh.
Retention
| Table | Kept |
|---|---|
db_activity_samples |
14 days |
db_query_stats |
90 days |
db_instance_metrics |
90 days |
db_table_stats |
90 days |
db_index_stats |
90 days |
db_columns |
90 days |
db_vitals, db_slots, db_sequences |
90 days |
db_table_ages |
90 days |
db_vacuum_progress |
14 days |
db_log_errors |
30 days |
db_audit |
latest only |
Activity is the highest-volume table by far โ one row per active backend per second โ which is why it expires first.