turbolytics/dbhealth

Go

1

29 commits

updated Oct 10, 2026

See the code

See what people are saying

SourceMessageScoreDate

Show HN: Monitor Postgres Health

2

Oct 13, 2026

README

dbhealth

Docker Pulls

Monitor your database's health. Is it serving? How close is it to its limits? Are its tables current? One container, one connection string, answers every minute in control, Prometheus, Datadog, or anything that speaks OpenTelemetry.

Quick start

docker run -d --name dbhealth \
  -e DBHEALTH_DSN='postgres://user:password@pg.internal:5432/billing' \
  -e DBHEALTH_KEY='sfc_...' \
  turbolytics/dbhealth

DBHEALTH_KEY is your org's credential from control. A minute later control shows billing: serving, 3 ms; 18 of 100 connections; 2.1 GB; public.events newest row 12 s ago, 48,213,904 rows.

No control yet? Point it at your own tools instead:

docker run -d -p 8000:8000 \
  -e DBHEALTH_DSN='postgres://user:password@pg.internal:5432/billing' \
  -e DBHEALTH_METRICS=prometheus \
  turbolytics/dbhealth

curl -s localhost:8000/metrics | grep dbhealth_probe_ok
dbhealth_probe_ok{db="billing"} 1

-e DBHEALTH_METRICS=otlp -e DBHEALTH_OTLP=http://datadog-agent:4318 pushes the same to the Datadog agent, Grafana Alloy, or any OpenTelemetry collector. /healthz is on the same port for your supervisor.

What you get

Facts, every minute, per database. Control, Prometheus and OTLP carry the same names; StatsD the same with dots.

metricfrom
serving?dbhealth_probe_ok, dbhealth_probe_latency_ms, dbhealth_probe_consecutive_failuresone SELECT 1, timed
how busy?dbhealth_load_sessions_active_now / _waiting_now / _idle_in_transaction_now, dbhealth_load_longest_query_seconds; dbhealth_load_transactions_per_second, _rows_read_per_second, _rows_written_per_second, _cache_hit_ratio, _temp_bytes_per_second, _deadlocks_per_secondpg_stat_activity now; pg_stat_database counters, two readings apart
near its limits?dbhealth_connections_used / _max / _waiting, dbhealth_size_bytes, dbhealth_oldest_transaction_seconds, dbhealth_memory_shared_buffers_bytespg_stat_activity, pg_database_size
how many tables?dbhealth_tables, dbhealth_partitions — every table in the database's own schemas, and how many are partitions; a partition count that keeps climbing is retention that stoppedpg_class
tables current?dbhealth_table_newest_at_seconds, dbhealth_table_rows, dbhealth_table_rows_exact, dbhealth_table_size_bytes, dbhealth_table_dead_rows, _seq_scans_per_second / _index_scans_per_second — labelled tablemax(timestamp column), n_live_tup or count(*), pg_stat_user_tables
tables written?dbhealth_table_rows_inserted_per_second / _updated_ / _deleted_ — the table's volume as its own counters say it, not an estimatepg_stat_user_tables, two readings apart
schema changed?dbhealth_table_schema_changes — columns added, dropped, retyped or changed in nullability since the last report; control shows which, with the type before and afterpg_attribute, in the same read
replicationdbhealth_replication_lag_seconds, per-replica lagpg_stat_replication
what it costdbhealth_collection_queries, _duration_ms, _errorscounted

Every series carries db. A field the database could not give is absent, not zero: a role without pg_read_all_stats gets an error naming the grant, not a connection count that is wrong. A _per_second rate is the interval's, from the database's own counters two readings apart; the first interval after a start or a stats reset sends none. Query text is never read.

What it costs the database: one SELECT 1, seven catalog reads, and per table one estimate and one max(), every interval. count(*) only where you ask, once an hour. load: {enabled: false} drops two of the reads.

Config

The quick start is the defaults: every table, up to 50, probed once a minute, rows estimated. A file is the same with more than one database, chosen tables, or exact counts:

databases:
  - kind: postgres
    dsn: "{{ DBHEALTH_PRIMARY_DSN }}"   # from the environment; control sees host:port/db only
    name: billing-primary
    cluster: billing
  - kind: postgres
    dsn: "{{ DBHEALTH_REPLICA_A_DSN }}"
    name: billing-replica-a
    cluster: billing

probe:
  interval_seconds: 60
  timeout_seconds: 5

tables:
  discover:                           # every table in these schemas, up to max_tables
    schemas: [public]
    exclude: ["sqlflow_*", "*_tmp"]
    freshness_columns: [bucket, minute, ts, created_at, updated_at]   # the first one a table has; event time before write time
    max_tables: 50
  # static:                           # or name them, so a schema change cannot add
  #   - name: public.usage_per_minute #   a two-billion-row table by accident
  #     freshness_column: minute
  #     rows: exact
  rows: estimate                      # estimate (free) or exact (count(*), once an hour)
  freshness_interval_seconds: 60
  rows_exact_interval_seconds: 3600

load:
  enabled: true                       # sessions and rates; two catalog reads an interval

report:
  to: https://ingest.turbolytics.io/v1/turbostats
  credential: "{{ TURBOSTATS_CREDENTIAL }}"
  # metrics: prometheus               # /metrics and /healthz on :8000
  # metrics: otlp                     # or pushed every interval
  # otlp: http://collector:4318
  # statsd: localhost:8125
dbhealth validate -c dbhealth.yml     # ok: 2 databases
dbhealth run -c dbhealth.yml

Each replica is its own entry: a primary that answers says nothing about the replica your application reads from. cluster groups them in control.

A partitioned table is one table: discovery lists the parent and skips its partitions, and the parent reports the sum of its partitions' rows, size, scans and writes, with the newest vacuum of any of them. A table partitioned by day stays one entry in max_tables however many days it holds. A dropped partition takes its counters with it, so the parent sends no write or scan rates for that interval.

environment (no file)
DBHEALTH_DSNthe connection string; required
DBHEALTH_KEYthe control credential
DBHEALTH_NAME, DBHEALTH_CLUSTERdefault to the database's name
DBHEALTH_METRICSprometheus or otlp
DBHEALTH_LOADfalse to leave the load facts out
DBHEALTH_OTLP, DBHEALTH_LISTEN, DBHEALTH_STATSDthe collector; the listen address (:8000); StatsD

The connection string never leaves the process. What is sent is host:port/database.

Build it yourself

make build && ./bin/dbhealth version
make test-short            # unit, no Docker
make test-integration      # against postgres:18 in testcontainers
make release-test          # the image, run against Postgres

turbolytics/dbhealth

Go

1

29 commits

updated Oct 10, 2026

See the code

See what people are saying

SourceMessageScoreDate

Show HN: Monitor Postgres Health

2

Oct 13, 2026

README

dbhealth

Docker Pulls

Monitor your database's health. Is it serving? How close is it to its limits? Are its tables current? One container, one connection string, answers every minute in control, Prometheus, Datadog, or anything that speaks OpenTelemetry.

Quick start

docker run -d --name dbhealth \
  -e DBHEALTH_DSN='postgres://user:password@pg.internal:5432/billing' \
  -e DBHEALTH_KEY='sfc_...' \
  turbolytics/dbhealth

DBHEALTH_KEY is your org's credential from control. A minute later control shows billing: serving, 3 ms; 18 of 100 connections; 2.1 GB; public.events newest row 12 s ago, 48,213,904 rows.

No control yet? Point it at your own tools instead:

docker run -d -p 8000:8000 \
  -e DBHEALTH_DSN='postgres://user:password@pg.internal:5432/billing' \
  -e DBHEALTH_METRICS=prometheus \
  turbolytics/dbhealth

curl -s localhost:8000/metrics | grep dbhealth_probe_ok
dbhealth_probe_ok{db="billing"} 1

-e DBHEALTH_METRICS=otlp -e DBHEALTH_OTLP=http://datadog-agent:4318 pushes the same to the Datadog agent, Grafana Alloy, or any OpenTelemetry collector. /healthz is on the same port for your supervisor.

What you get

Facts, every minute, per database. Control, Prometheus and OTLP carry the same names; StatsD the same with dots.

metricfrom
serving?dbhealth_probe_ok, dbhealth_probe_latency_ms, dbhealth_probe_consecutive_failuresone SELECT 1, timed
how busy?dbhealth_load_sessions_active_now / _waiting_now / _idle_in_transaction_now, dbhealth_load_longest_query_seconds; dbhealth_load_transactions_per_second, _rows_read_per_second, _rows_written_per_second, _cache_hit_ratio, _temp_bytes_per_second, _deadlocks_per_secondpg_stat_activity now; pg_stat_database counters, two readings apart
near its limits?dbhealth_connections_used / _max / _waiting, dbhealth_size_bytes, dbhealth_oldest_transaction_seconds, dbhealth_memory_shared_buffers_bytespg_stat_activity, pg_database_size
how many tables?dbhealth_tables, dbhealth_partitions — every table in the database's own schemas, and how many are partitions; a partition count that keeps climbing is retention that stoppedpg_class
tables current?dbhealth_table_newest_at_seconds, dbhealth_table_rows, dbhealth_table_rows_exact, dbhealth_table_size_bytes, dbhealth_table_dead_rows, _seq_scans_per_second / _index_scans_per_second — labelled tablemax(timestamp column), n_live_tup or count(*), pg_stat_user_tables
tables written?dbhealth_table_rows_inserted_per_second / _updated_ / _deleted_ — the table's volume as its own counters say it, not an estimatepg_stat_user_tables, two readings apart
schema changed?dbhealth_table_schema_changes — columns added, dropped, retyped or changed in nullability since the last report; control shows which, with the type before and afterpg_attribute, in the same read
replicationdbhealth_replication_lag_seconds, per-replica lagpg_stat_replication
what it costdbhealth_collection_queries, _duration_ms, _errorscounted

Every series carries db. A field the database could not give is absent, not zero: a role without pg_read_all_stats gets an error naming the grant, not a connection count that is wrong. A _per_second rate is the interval's, from the database's own counters two readings apart; the first interval after a start or a stats reset sends none. Query text is never read.

What it costs the database: one SELECT 1, seven catalog reads, and per table one estimate and one max(), every interval. count(*) only where you ask, once an hour. load: {enabled: false} drops two of the reads.

Config

The quick start is the defaults: every table, up to 50, probed once a minute, rows estimated. A file is the same with more than one database, chosen tables, or exact counts:

databases:
  - kind: postgres
    dsn: "{{ DBHEALTH_PRIMARY_DSN }}"   # from the environment; control sees host:port/db only
    name: billing-primary
    cluster: billing
  - kind: postgres
    dsn: "{{ DBHEALTH_REPLICA_A_DSN }}"
    name: billing-replica-a
    cluster: billing

probe:
  interval_seconds: 60
  timeout_seconds: 5

tables:
  discover:                           # every table in these schemas, up to max_tables
    schemas: [public]
    exclude: ["sqlflow_*", "*_tmp"]
    freshness_columns: [bucket, minute, ts, created_at, updated_at]   # the first one a table has; event time before write time
    max_tables: 50
  # static:                           # or name them, so a schema change cannot add
  #   - name: public.usage_per_minute #   a two-billion-row table by accident
  #     freshness_column: minute
  #     rows: exact
  rows: estimate                      # estimate (free) or exact (count(*), once an hour)
  freshness_interval_seconds: 60
  rows_exact_interval_seconds: 3600

load:
  enabled: true                       # sessions and rates; two catalog reads an interval

report:
  to: https://ingest.turbolytics.io/v1/turbostats
  credential: "{{ TURBOSTATS_CREDENTIAL }}"
  # metrics: prometheus               # /metrics and /healthz on :8000
  # metrics: otlp                     # or pushed every interval
  # otlp: http://collector:4318
  # statsd: localhost:8125
dbhealth validate -c dbhealth.yml     # ok: 2 databases
dbhealth run -c dbhealth.yml

Each replica is its own entry: a primary that answers says nothing about the replica your application reads from. cluster groups them in control.

A partitioned table is one table: discovery lists the parent and skips its partitions, and the parent reports the sum of its partitions' rows, size, scans and writes, with the newest vacuum of any of them. A table partitioned by day stays one entry in max_tables however many days it holds. A dropped partition takes its counters with it, so the parent sends no write or scan rates for that interval.

environment (no file)
DBHEALTH_DSNthe connection string; required
DBHEALTH_KEYthe control credential
DBHEALTH_NAME, DBHEALTH_CLUSTERdefault to the database's name
DBHEALTH_METRICSprometheus or otlp
DBHEALTH_LOADfalse to leave the load facts out
DBHEALTH_OTLP, DBHEALTH_LISTEN, DBHEALTH_STATSDthe collector; the listen address (:8000); StatsD

The connection string never leaves the process. What is sent is host:port/database.

Build it yourself

make build && ./bin/dbhealth version
make test-short            # unit, no Docker
make test-integration      # against postgres:18 in testcontainers
make release-test          # the image, run against Postgres