CapyDB/ docs
Guides

Observability

Live storage, connection, and query metrics per project. Enough to answer "why is it slow" without a separate APM.

What you get

The Observability tab (and GET /v1/projects/{id}/observability) reports live metrics for a project:

  • Storage gauge - database size in bytes against the plan's storage limit, with usage percent
  • Connection gauge - current connections against the plan's connection budget
  • Active queries - what is running right now, with elapsed time
  • Slow queries - statements with high mean execution time (sampled via pg_stat_statements; statements with mean time ≥ 1s over at least 5 calls surface here)
  • Wake latency - how often the cell was resumed from scale-to-zero over the last 7 days and how long the resumes took (wake, below)
  • Schema lint - missing primary keys, unindexed foreign keys, duplicate or unused indexes and bloated tables, each with the statement that fixes it (below)

When a gauge crosses its threshold, a usage alert opens - warning at 80%, critical at 95% - and is delivered to the dashboard, your webhooks, and by email to the org billing email and any alert recipients you added. The dashboard renders open alerts inline with the gauges.

From the CLI

capydb metrics            # human-readable gauges + query samples
capydb metrics --json     # raw observability payload

Wake latency

A cell that scales to zero resumes on the next connection. The observability payload's wake block summarizes those resumes over the last 7 days:

{
  "wake": {
    "wakes": 42,
    "timed_wakes": 40,
    "window_hours": 168,
    "p50_ms": 180,
    "p95_ms": 410,
    "max_ms": 950
  }
}

wakes counts every resume in the window; timed_wakes the ones whose duration was measured, which are the only ones the percentiles and max_ms cover. With no measured resume in the window the three durations are null.

The Observability tab shows the same block as a Resumes from pause panel: the number of resumes (with how many were timed, when not all were), the median and p95 resume time, and the slowest one. A duration with no measured resume behind it shows as a dash, never as 0 ms, and a cell that did not resume in the window says so. If resumes show up in your request latency, either keep the cell always on or make the first connection after an idle period tolerate the delay - see paused cells.

Reading the gauges

  • Storage creeping toward the limit: delete unused data, VACUUM, or move up a plan. Imports are blocked when the source exceeds the plan limit, and the preflight warns at 80%.
  • Connections pegged at the budget: app traffic belongs on the pooled URL; check for clients with oversized local pools or long-idle direct sessions. See Connections.
  • Slow queries: the samples include the statement text - EXPLAIN ANALYZE them over a direct connection, then add the index you already suspect is missing.

Query statistics in SQL

Everything above is also available from inside the database, which is where you want it when you are already in psql or debugging a query rather than in a browser tab:

SELECT * FROM capydb.capydb_slow_queries();

Top 20 statements in your database by mean execution time, normalized by pg_stat_statements. Columns:

ColumnWhat it tells you
calls, rowsHow often it ran, and how many rows it returned in total
total_exec_time, mean_exec_timeMilliseconds, cumulative and per call
queryThe normalized statement text (parameters replaced with $1, $2, …)
queryidIts pg_stat_statements id, matching query_id in the index advisor
shared_blks_hit, shared_blks_readBlocks served from cache vs read from disk, for this statement
temp_blks_read, temp_blks_writtenBlocks written to and read back from temporary files. Anything above zero means the statement spilled to disk - see below
jit_timeTotal JIT compilation time. Normally 0, because CapyDB runs with jit = off

Why not pg_stat_statements directly?

The view itself is instance-wide - it holds the normalized query text of every database on the node, so reading it would expose other tenants' queries to you and yours to them. It is revoked, and SELECT * FROM extensions.pg_stat_statements returns permission denied for view pg_stat_statements.

capydb.capydb_slow_queries() is the way in: it runs with the privileges to read the view and returns only rows whose dbid is your database. EXECUTE on it is granted to everyone, including the role in your connection string.

Spilling to disk

work_mem is the memory one sort, hash join or materialized CTE may use before Postgres writes it to a temporary file instead. CapyDB leaves it at the Postgres default of 4MB across every plan, and that is deliberate rather than an oversight: work_mem is per operation, not per connection, so the worst case is roughly pool size × sort nodes × (parallel workers + 1) copies of it. At business sizing that already commits the whole memory budget outside shared_buffers, and the memory ceiling on a cell is a hard limit - raising the global would trade a slow query for a restarted database.

Raise it for the one statement that needs it instead:

BEGIN;
SET LOCAL work_mem = '64MB';   -- applies to this transaction only
SELECT ... ORDER BY ...;
COMMIT;

SET LOCAL is reset at COMMIT, so it is safe on a pooled connection. If a statement spills on every request, the better fix is usually an index that lets Postgres return rows already ordered, so there is no sort to spill.

When a database spills persistently, a temp_spill advisory opens on its own.

Index hygiene

The index advisor suggests indexes to add. The other half is finding the ones already there that nothing reads - every one of them is paid for on every INSERT, UPDATE and DELETE to its table, and in storage:

capydb advisor index-hygiene

It reports two kinds:

  • Never scanned - no recorded reads over the whole statistics window.
  • Covered by a wider index - the index's columns are the leading columns of another index on the same table, so that one can serve every scan this one can.

Each row comes with a ready-to-run DROP INDEX CONCURRENTLY statement (CONCURRENTLY matters: the plain form locks the table against writes until the drop finishes).

Two things it will not do. It never lists UNIQUE, primary-key or exclusion indexes, whatever their read count - those are constraints, and a read count says nothing about whether dropping one would let bad data in. And it reports nothing at all until a week of statistics has accumulated, because Postgres does not record when an index was created: over a shorter window, an index a monthly job uses looks exactly like a dead one. Check for that job anyway before you drop something.

No extension is needed, unlike the suggestion side.

Schema lint

GET /v1/projects/{projectID}/lint

checks the database for problems that are cheap to fix early and expensive later, and returns the same report capydb db lint --json prints:

RuleSeverityFinds
missing_primary_keywarningTables without a primary key
unindexed_foreign_keywarningForeign keys whose columns lead no index
duplicate_indexwarningIdentical indexes on the same table
redundant_indexwarningIndexes covered by a wider one
unused_indexinfoIndexes with no scans over the observation window - only after a week of statistics, as above
table_bloatinfoTables where dead rows are a large share
{
  "lint": {
    "findings": [
      {
        "rule": "unindexed_foreign_key",
        "severity": "warning",
        "object": "public.orders (orders_customer_id_fkey)",
        "message": "foreign key on (customer_id) has no index leading with those columns; deletes and key updates on the referenced table scan orders",
        "fix": "CREATE INDEX CONCURRENTLY ON public.orders (customer_id);"
      }
    ],
    "skipped": []
  }
}

fix is a statement to review and run yourself, when there is one - CapyDB never changes the schema for you. A check that cannot run is listed in skipped with the reason, and the others still report.

The Observability tab shows the report in a Schema lint panel: one card per finding with its rule, the object, the message and the fix statement, and a collapsed list of skipped checks. A clean database shows No findings. Each ready preview has the same panel on the Previews page - see Lint a preview.

The checks are a fixed set of catalog queries in a read-only transaction that read no table data, so the endpoint needs only schema:read (or projects:read) - the same scope as type generation, so a CI key can lint without write access. GET /v1/preview-databases/{previewID}/lint lints a preview the same way, minus the unused- and redundant-index checks.

Database logs

The project's Logs tab, capydb logs, and the API show the Postgres log of the project's cell. Logs outlive the process, so a paused database's logs are readable too.

GET /v1/projects/{projectID}/logs?hours=1&severity=error
GET /v1/projects/{projectID}/logs/stream          # server-sent events, live tail

Each entry carries its timestamp, a severity parsed from the Postgres log format (continuation lines - STATEMENT, DETAIL, HINT, CONTEXT - report detail) and the message. Entries also carry, when the line has them:

FieldWhat it is
sqlstateThe five-character SQLSTATE of the condition the line reports, such as 42P01 (undefined table). Absent on plain informational lines
pidProcess id of the backend that wrote the line
userDatabase role of the session. Absent for background processes and before authentication
databaseDatabase of the session, with the same exceptions as user

These fields are being rolled out with a newer log format; lines a database wrote before it picked that format up carry none of them. sqlstate is the quickest way to tell whose error it is.

The Logs tab's Errors in this window card groups the error, fatal and panic lines of the window so repeats read as one line with a count, busiest first. Lines that carry a SQLSTATE group by it - every 42P01 is one group however many different tables it names, with the newest message shown and a "+N more messages" note for the other variants. Lines without a code group by their message, with numbers folded so positions and ids do not split a group. Selecting a group's SQLSTATE starts a log search for that code over the last 7 days.

Searching logs

Log search is rolling out. Where it is not enabled yet, the endpoint answers 503.

GET /v1/projects/{projectID}/logs/search?sqlstate=23&severity=error&q=orders&since=2026-09-29T00:00:00Z

searches the project's archived logs, newest first. In the dashboard, the Logs tab has a Search logs panel below the live view: a SQLSTATE field (codes or classes, comma-separated), Message contains, a severity choice (all, warnings and errors, errors only) and a window of 1, 7 or 30 days. Results come 100 at a time, newest first, with Load more for older ones; a page the scan budget cut short says so. Where log search is not enabled yet, the panel says that instead. From the CLI: capydb logs --sqlstate 23 --severity error --search orders --since 24h (see capydb logs).

The parameters:

ParameterMeaning
sqlstateComma-separated. A five-character code matches exactly (42P01); a two-character class matches every code in it (23, integrity constraint violations). Lines without a code never match
severityComma-separated severities; empty means all
qCase-insensitive substring of the message, up to 200 characters
since / untilRFC 3339 window; until defaults to now and since to 24 hours before it. At most 720 hours, within the last 30 days
limit1-500 entries, default 100
cursornext_cursor from the previous page

The response is { "search": { "entries": [...], "next_cursor": "...", "truncated": false } }. Each request scans a bounded amount of archive, so page with next_cursor - repeating the other parameters unchanged - until it is absent. truncated: true means the scan budget ended the page before it filled: it may be short or even empty although older matches exist, so keep paging. Logs are archived in batches, so the newest few minutes are not searchable yet; use the live endpoints above for those.

JIT is off by default

Postgres can compile a query plan to machine code when the planner's estimated cost crosses jit_above_cost. CapyDB sets jit = off, because that threshold is an estimate - the statements that cross it are disproportionately the ones whose row estimates are already wrong, and the database then spends real CPU compiling a plan that was mis-costed. On a measured cell a page query took 750ms, 334ms of which was JIT compilation; the same statement took 404ms with JIT off, and 0.58ms once the underlying plan was fixed.

If you genuinely do run analytical queries and want it back:

ALTER SYSTEM SET jit = on;
SELECT pg_reload_conf();

CapyDB will not change it back. The default is only applied to databases where jit has never been set explicitly.

Keeping derived data fresh

Materialized views and rollup tables need something to refresh them. Rather than a cron job in your application that has to hold a connection open and be deployed somewhere, use pg_cron, which runs the schedule inside the database itself:

SELECT cron.schedule(
  'refresh-daily-rollups',
  '17 3 * * *',
  $$REFRESH MATERIALIZED VIEW CONCURRENTLY daily_rollups$$
);

CONCURRENTLY keeps the view readable while it rebuilds (it needs a unique index on the view).

pg_cron and scale-to-zero

pg_cron runs inside your database, so it only fires while the database is running.

Today, a database with pg_cron enabled does not scale to zero. The scheduler keeps it counted as busy, so its jobs fire on schedule and the database stays running - on a project with steady traffic that changes nothing, on one that idles it means the database never pauses.

Cron-aware scale-to-zero is rolling out. Once enabled, a database with pg_cron pauses like any other when idle and is resumed shortly before its next scheduled run. There is nothing to opt into: scheduling a job is the request to run it.

With cron-aware scale-to-zero, when the database pauses CapyDB reads its active cron.job schedules and cron.timezone, and resumes it about 2 minutes before the next run. After the run it pauses again once it has been idle for the usual window. The rules:

  • At most one cron resume per database every 15 minutes. A job due more often (every minute, say) runs whenever the database is awake, but does not keep waking it.
  • A run more than a minute overdue is skipped, not woken for - pg_cron does not catch up on missed runs either.
  • @reboot schedules are not woken for.
  • A database never pauses while a job is running.
  • A suspended organization's databases stay paused once it reaches the offline step of suspension.

If a job must run at an exact minute on a database that idles, keep it in a scheduler outside the database (your host's cron, a CI schedule, a Vercel cron route) that connects and runs the statement.