Database Observability skill for Codex

Know which query, table or slot is hurting you. Codex runs a fixed read-only health pass: top queries by total time, live lock chains, dead tuples and wraparound age, bloat and unused indexes, replication lag and slots retaining WAL. Each finding cites its query output and gets a ranked next action, a bundled query catalog covers Postgres and MySQL, and nothing on the server is reset, killed or rewritten without your approval.

  • Databases
  • $database-observability
  • 203 lines
  • 3 files
  • ~2,200 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- database-observability

Installs to ~/.agents/skills/database-observability/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $database-observability, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Sets up and reads database health signals for PostgreSQL and MySQL - pg_stat_statements and performance_schema digests for top queries, slow query logs and auto_explain, lock waits, table and index bloat, autovacuum and transaction ID wraparound, replication lag and slots, and Prometheus exporters - then turns them into a ranked findings report. Use when asked why the database is slow or growing, to find the most expensive queries, to check vacuum and bloat, to set up slow query logging, or to add database metrics and alerts.

Use it when

  • The database is slow, busy or growing and nobody knows why
  • Setting up pg_stat_statements, slow query logs or exporters
  • Dead tuples, disk usage or wraparound age keep climbing
  • Writing a periodic database health report

Use something else when

How to check the result

Each finding quotes the query output and time window behind it, and a repeat snapshot after fixes shows lower total time for the targeted queries over the same interval.

The mistake it prevents

Resetting pg_stat_statements or running VACUUM FULL during an investigation, which destroys the evidence or locks a live table for the whole rewrite.

~/.agents/skills/database-observability/SKILL.md

--- name: database-observability description: Sets up and reads database health signals for PostgreSQL and MySQL - pg_stat_statements and performance_schema digests for top queries, slow query… license: MIT ---

Done well means: the expensive queries are ranked by total time with evidence, vacuum and wraparound risk are known numbers rather than guesses, every finding names the object, the metric and the next action, and nothing was changed on the server without the user agreeing first.

Ground rules

  • Observation is read-only. Changing settings, resetting statistics, killing sessions, VACUUM FULL, pg_repack or REINDEX all need explicit user confirmation, and the last three need a maintenance plan because of locks and I/O.
  • Read AGENTS.md for how to reach the database (bastion, read replica, connection string variable) and use a read-only role with pg_monitor (Postgres) or PROCESS plus SELECT on performance_schema and sys (MySQL).
  • Note when statistics were last reset; numbers since a reset last week mean something different from numbers since last year.

Workflow

- [ ] 1. Identify engine, version, hosting (self-managed, RDS/Aurora, Cloud SQL, Supabase)
- [ ] 2. Confirm which telemetry exists: pg_stat_statements, slow log, exporters, dashboards
- [ ] 3. Snapshot top queries by total time, mean time and I/O
- [ ] 4. Check current activity: long transactions, lock waits, idle in transaction
- [ ] 5. Check vacuum health: dead tuples, last autovacuum, wraparound age
- [ ] 6. Check bloat and unused or duplicate indexes
- [ ] 7. Check replication: lag, slots holding WAL
- [ ] 8. Write the findings report, ranked by impact

PostgreSQL: enable the basics

# postgresql.conf (restart needed for shared_preload_libraries)
shared_preload_libraries = 'pg_stat_statements,auto_explain'
pg_stat_statements.track = top
log_min_duration_statement = '500ms'      # slow query log
log_lock_waits = on                        # logs waits longer than deadlock_timeout
log_temp_files = 0                         # every spill to disk
log_autovacuum_min_duration = '10s'
auto_explain.log_min_duration = '2s'
auto_explain.log_analyze = off             # on adds timing overhead to every query
track_io_timing = on                       # I/O time in pg_stat_statements and EXPLAIN
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

On managed services the same settings live in the parameter group or flags; restarts are scheduled by the user. Use ALTER SYSTEM only with approval, then SELECT pg_reload_conf(); for reloadable settings.

Top queries

SELECT round(total_exec_time::numeric / 1000, 1)        AS total_s,
       calls,
       round(mean_exec_time::numeric, 2)                AS mean_ms,
       round((100 * total_exec_time / sum(total_exec_time) OVER ())::numeric, 1) AS pct,
       rows / nullif(calls, 0)                          AS rows_per_call,
       shared_blks_read + shared_blks_hit               AS blks,
       temp_blks_written,
       left(regexp_replace(query, '\s+', ' ', 'g'), 120) AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;

Read it three ways:

Sort byFindsTypical fix
total_exec_timeWhere the server spends its time overallIndex, rewrite, or cache the top 3
mean_exec_time with calls > 100Individually slow queries$postgres-query-tuning
callsChatty access, N+1$n-plus-one-queries
temp_blks_writtenSorts and hashes spilling to diskIndex for the sort, or raise work_mem for that role

The column names are total_exec_time and mean_exec_time since Postgres 13 (older: total_time). Resetting with pg_stat_statements_reset() erases history; ask first and record a snapshot before doing it.

Live activity and locks

SELECT pid, usename, state, wait_event_type, wait_event,
       now() - xact_start  AS xact_age,
       now() - query_start AS query_age,
       pg_blocking_pids(pid) AS blocked_by,
       left(query, 100) AS query
FROM pg_stat_activity
WHERE state <> 'idle' AND pid <> pg_backend_pid()
ORDER BY xact_start NULLS LAST;
  • idle in transaction for minutes holds locks and blocks vacuum cleanup.
  • A chain where one pid appears in many blocked_by arrays is the head blocker.
  • Terminating a backend (pg_terminate_backend(pid)) rolls back its work: show the user the session and get a yes first.

Vacuum, bloat and wraparound

-- tables autovacuum is losing against
SELECT relname, n_live_tup, n_dead_tup,
       round(100.0 * n_dead_tup / nullif(n_live_tup + n_dead_tup, 0), 1) AS dead_pct,
       last_autovacuum, last_autoanalyze
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC LIMIT 15;

-- transaction ID wraparound headroom
SELECT datname, age(datfrozenxid) AS xid_age,
       round(100.0 * age(datfrozenxid) / 2147483647, 1) AS pct_to_wraparound
FROM pg_database ORDER BY xid_age DESC;
SignalThresholdAction
dead_pctAbove 20% on a large table and risingLower autovacuum_vacuum_scale_factor for that table (for example 0.02)
xid_ageAbove autovacuum_freeze_max_age (200M default) and climbingFind what blocks freezing (below)
pct_to_wraparoundAbove 50%Treat as an incident; at about 3M XIDs left Postgres refuses writes

Vacuum can only remove rows older than the oldest snapshot anyone still needs. When dead tuples will not go away, check, in order: long-running or idle-in-transaction sessions, replication slots with an old xmin or catalog_xmin, hot_standby_feedback from a replica running long queries, and forgotten prepared transactions (pg_prepared_xacts).

For table and index bloat estimates, unused and duplicate index queries, replication slot checks and the MySQL equivalents, read references/diagnostic-queries.md when step 6, 7 or a MySQL server is in scope.

Fixing bloat: VACUUM reclaims space for reuse but rarely shrinks files. REINDEX INDEX CONCURRENTLY (Postgres 12+) rebuilds a bloated index online. pg_repack rewrites a table online but needs free disk equal to the table plus indexes and a brief exclusive lock at the end. VACUUM FULL blocks all access for the whole rewrite: never on a live table without a maintenance window and explicit approval.

MySQL quick view

SELECT schema_name, digest_text, count_star,
       round(sum_timer_wait / 1e12, 1) AS total_s,
       round(avg_timer_wait / 1e9, 1)  AS avg_ms,
       sum_rows_examined, sum_rows_sent, sum_no_index_used
FROM performance_schema.events_statements_summary_by_digest
ORDER BY sum_timer_wait DESC LIMIT 20;

Slow log: slow_query_log = ON, long_query_time = 0.5, then summarize with pt-query-digest /var/log/mysql/slow.log. A rows_examined / rows_sent ratio above about 100 signals a missing index ($mysql-tuning). Long-running transactions show in information_schema.innodb_trx; history list length (SHOW ENGINE INNODB STATUS) is the MySQL analogue of dead tuples piling up.

Metrics and alerts

Export continuously instead of running these queries by hand:

  • Prometheus: postgres_exporter or mysqld_exporter, dashboards in Grafana ($prometheus-metrics, $grafana-dashboards).
  • Managed: RDS Performance Insights / Database Insights, Cloud SQL Query Insights.
  • Commercial query analytics: pganalyze, Datadog DBM.

Alert on symptoms with clear actions:

AlertCondition
Connections near limitUsed above 80% of max_connections for 5 min
Wraparoundage(datfrozenxid) above 1 billion
Replication lagReplay lag above the app’s staleness budget for 5 min
Slot retaining WALRetained WAL above 20% of free disk
Long transactionAny transaction older than 30 min (excluding known batch roles)
DiskFree space below 15% or projected full within 7 days

Verification

  • Every finding cites the query output it came from and the time window it covers.
  • Re-run the top-queries snapshot after a fix and compare totals for the same interval.
  • After autovacuum tuning, n_dead_tup trends down over the next day.
  • Alerts fire in a test (for example a deliberately long transaction on staging).

Report template

**Server:** Postgres 16.4 on RDS db.r6g.2xlarge; stats since 2026-10-01 (27 days)
**Top time consumers:** 1) orders search 41% (mean 220 ms, 1.9M calls) 2) ...
**Locks:** 2 sessions idle in transaction > 1 h from reporting role (pids 812, 9931)
**Vacuum:** events table 38% dead tuples; last autovacuum 6 days ago
**Wraparound:** max xid_age 412M (19%); blocked by slot "old_cdc" (inactive, 180 GB WAL)
**Ranked actions:**
1. Drop inactive slot old_cdc after the owner confirms (frees 180 GB, unblocks freezing)
2. Index for orders search (see postgres-query-tuning)
3. events: autovacuum_vacuum_scale_factor 0.02
**Changed on server:** nothing (all read-only)

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Database Observability"
  short_description: "Know which query, table or slot is hurting you"
  default_prompt: "$database-observability Our Postgres CPU is at 90%. Find the queries responsible and rank what to fix"
references/diagnostic-queries.md216 lines, 6.8 KB
# Diagnostic queries

All queries are read-only. Run them on the primary unless noted; replica statistics
(`pg_stat_user_tables`, `pg_stat_statements`) describe only the replica's own workload.

## PostgreSQL

### Table bloat (measured)

`pgstattuple` ships with Postgres contrib. `CREATE EXTENSION` is a change: ask first.
`pgstattuple_approx` samples the visibility map and is far cheaper than `pgstattuple`,
but still reads pages; run it on one table at a time, off-peak for very large tables.

```sql
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT table_len, approx_tuple_percent, dead_tuple_percent, approx_free_percent
FROM pgstattuple_approx('public.events');
```

`approx_free_percent` above about 30% on a large table means real bloat worth
`pg_repack`; under 20% is normal headroom for updates.

### Index bloat (measured, B-tree)

```sql
SELECT avg_leaf_density, leaf_fragmentation,
       pg_size_pretty(pg_relation_size('events_created_at_idx')) AS size
FROM pgstatindex('events_created_at_idx');
```

`avg_leaf_density` below about 50% on an index that should be dense suggests
`REINDEX INDEX CONCURRENTLY`.

### Largest relations

```sql
SELECT c.relname, c.relkind,
       pg_size_pretty(pg_total_relation_size(c.oid)) AS total,
       pg_size_pretty(pg_relation_size(c.oid))       AS heap,
       pg_size_pretty(pg_indexes_size(c.oid))        AS indexes
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND c.relkind IN ('r', 'm')
ORDER BY pg_total_relation_size(c.oid) DESC LIMIT 20;
```

### Unused indexes

```sql
SELECT s.relname AS table, s.indexrelname AS index, s.idx_scan,
       pg_size_pretty(pg_relation_size(s.indexrelid)) AS size
FROM pg_stat_user_indexes s
JOIN pg_index i ON i.indexrelid = s.indexrelid
WHERE s.idx_scan = 0
  AND NOT i.indisunique AND NOT i.indisprimary
ORDER BY pg_relation_size(s.indexrelid) DESC;
```

Check every replica too: an index unused on the primary may serve replica reads. Counters
reset on stats reset and, on some managed services, on failover. Hand drop decisions to
`postgres-indexing`.

### Duplicate indexes (same columns, same predicate)

```sql
SELECT indrelid::regclass AS table,
       array_agg(indexrelid::regclass) AS indexes
FROM pg_index
GROUP BY indrelid, indkey, indclass, indexprs::text, indpred::text
HAVING count(*) > 1;
```

### Cache hit ratio per table

```sql
SELECT relname,
       heap_blks_hit, heap_blks_read,
       round(100.0 * heap_blks_hit / nullif(heap_blks_hit + heap_blks_read, 0), 2) AS hit_pct
FROM pg_statio_user_tables
ORDER BY heap_blks_read DESC LIMIT 15;
```

A hot OLTP table below about 99% hit rate is reading from disk on every few queries.

### Autovacuum settings per table

```sql
SELECT relname, reloptions FROM pg_class
WHERE reloptions IS NOT NULL AND relkind = 'r';
```

### Running autovacuum workers and progress

```sql
SELECT p.pid, p.relid::regclass AS table, p.phase,
       p.heap_blks_scanned, p.heap_blks_total,
       now() - a.xact_start AS running_for
FROM pg_stat_progress_vacuum p JOIN pg_stat_activity a USING (pid);
```

### Oldest snapshot holders (what blocks vacuum)

```sql
SELECT pid, usename, state, backend_xmin, age(backend_xmin) AS xmin_age,
       now() - xact_start AS xact_age, left(query, 80) AS query
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY age(backend_xmin) DESC LIMIT 10;

SELECT slot_name, slot_type, active, xmin, catalog_xmin,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal
FROM pg_replication_slots;

SELECT gid, prepared, owner FROM pg_prepared_xacts;
```

An inactive slot retaining WAL fills the disk and blocks freezing. Dropping a slot
(`pg_drop_replication_slot`) breaks whatever consumer owns it (CDC, a replica): identify
the owner and get confirmation first.

### Replication lag (on the primary)

```sql
SELECT application_name, client_addr, state,
       write_lag, flush_lag, replay_lag,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)) AS replay_bytes
FROM pg_stat_replication;
```

On a replica: `SELECT now() - pg_last_xact_replay_timestamp();` (grows on an idle primary
too, so read it alongside write activity).

### I/O and checkpoints

```sql
-- Postgres 16+: I/O by backend type and context
SELECT backend_type, object, context, reads, writes, extends, fsyncs
FROM pg_stat_io WHERE reads > 0 OR writes > 0 ORDER BY reads + writes DESC LIMIT 15;

-- Postgres 17+: checkpoint stats moved out of pg_stat_bgwriter
SELECT num_timed, num_requested, write_time, sync_time, buffers_written
FROM pg_stat_checkpointer;
```

Requested checkpoints far outnumbering timed ones means `max_wal_size` is too small for
the write rate.

### Settings that differ from defaults

```sql
SELECT name, setting, unit, source FROM pg_settings
WHERE source NOT IN ('default', 'override') ORDER BY name;
```

## MySQL 8

### Statements doing full scans

```sql
SELECT query, db, exec_count, total_latency, no_index_used_count, rows_examined_avg
FROM sys.statements_with_full_table_scans
ORDER BY no_index_used_count DESC LIMIT 15;
```

### Unused and redundant indexes

```sql
SELECT * FROM sys.schema_unused_indexes WHERE object_schema = 'app';
SELECT table_name, redundant_index_name, dominant_index_name, sql_drop_index
FROM sys.schema_redundant_indexes WHERE table_schema = 'app';
```

`sql_drop_index` is a suggestion string; do not execute it without review and approval.

### Long transactions and lock waits

```sql
SELECT trx_id, trx_state, trx_started, now() - trx_started AS age,
       trx_rows_locked, trx_mysql_thread_id, left(trx_query, 100) AS query
FROM information_schema.innodb_trx ORDER BY trx_started LIMIT 10;

SELECT * FROM sys.innodb_lock_waits\G
SELECT * FROM sys.schema_table_lock_waits\G     -- metadata locks (DDL waiting)
```

### Table size and fragmentation

```sql
SELECT table_name,
       round((data_length + index_length) / 1024 / 1024) AS size_mb,
       round(data_free / 1024 / 1024) AS free_mb
FROM information_schema.tables
WHERE table_schema = 'app' ORDER BY data_length + index_length DESC LIMIT 20;
```

`information_schema.tables` statistics are cached (`information_schema_stats_expiry`,
default 86400 s); run `ANALYZE TABLE` first for current numbers.

### Replication

```sql
SHOW REPLICA STATUS\G      -- 8.0.22+; Seconds_Behind_Source, Replica_IO_Running, Replica_SQL_Running
```

`Seconds_Behind_Source` is coarse and reads 0 when the SQL thread is idle waiting for
events; `pt-heartbeat` gives true lag.

### InnoDB health

```sql
SHOW ENGINE INNODB STATUS\G     -- History list length, deadlocks, semaphores
SELECT name, count FROM information_schema.innodb_metrics
WHERE name IN ('trx_rseg_history_len', 'lock_deadlocks', 'lock_timeouts');
```

A history list length that keeps growing into the millions means purge is blocked by a
long-running transaction or snapshot.

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem