Replication Observability & Metrics in CouchDB
When a sync pipeline silently falls hours behind, the first question in the incident channel is always “how do we even see it?” CouchDB does not surface replication health as a single dashboard number — you assemble it from several endpoints, each exposing a different slice: live per-job progress from GET /_active_tasks, authoritative scheduler state from /_scheduler/jobs and /_scheduler/docs, node-wide counters from /_node/_local/_stats, a ready-made scrape target at /_node/_local/_prometheus, and revision-tree growth from the database info document. This section of CouchDB Replication Architecture & Revision Fundamentals is the metrics reference: what each endpoint reports, how to turn raw fields into backlog, throughput, conflict-rate and disk-bloat gauges, and how to run a collector that feeds a time-series store and alerting without hammering a resource-constrained edge node.
Configuration Schema & Required Parameters
Observability configuration is the collector’s scrape plan plus the alert thresholds it enforces; the CouchDB side needs only that the metrics endpoints be reachable and, for the native Prometheus route, that a scrape identity exists. Deploy the replication jobs themselves through the standard _replicator document schema and monitor the checkpoints they emit as described in Monitoring CouchDB Replication Checkpoints via the API. A minimal collector configuration looks like this:
# collector.yaml — one instance targets one CouchDB node
couch_url: "http://127.0.0.1:5984" # the node this collector observes
node: "_local" # resolves to the node serving the request
databases: ["iot_telemetry"] # databases whose /db sizes to sample
scrape_interval_seconds: 15 # how often to poll the API endpoints
db_size_interval_seconds: 300 # /db sizes change slowly; sample less often
request_timeout_seconds: 10 # fail fast; a hung node must not stall the loop
max_retries: 3 # bounded exponential backoff per endpoint
thresholds:
backlog_changes_pending: 5000 # alert when summed pending changes exceed this
crashed_jobs: 1 # any crashed replication job is page-worthy
disk_bloat_ratio: 2.0 # sizes.file / sizes.active — compaction overdue
conflict_rate: 0.02 # conflicted docs / doc_count SLO ceiling
Every replication endpoint reports node-local state: _active_tasks and _stats describe only the node that answers the request, and a replication job runs on exactly one node in the target cluster, so a single collector per node is the correct topology. The table below maps each endpoint to the fields that matter and the signal they carry.
| Endpoint | Key fields | What it tells you |
|---|---|---|
GET /_active_tasks |
changes_pending, docs_written, docs_read, through_seq, doc_write_failures |
Live snapshot of every running job on this node; the fastest read for current backlog and throughput. |
GET /_scheduler/jobs |
info.changes_pending, info.docs_written, history[] |
Authoritative scheduler view including a per-job event history ring buffer; survives brief job restarts. |
GET /_scheduler/docs |
state, error_count, info.doc_write_failures, last_updated |
Lifecycle state (running, pending, crashing, failed, completed) keyed to each _replicator document. |
GET /_node/_local/_stats |
couch_replicator.jobs.{running,pending,crashed}, couch_replicator.checkpoints.{success,failure} |
Node-wide replicator counters and gauges; the source of job-health rollups. |
GET /_node/_local/_prometheus |
text exposition of all of the above | A ready-made scrape target in Prometheus format; zero custom parsing. |
GET /db |
sizes.file, sizes.active, doc_count, doc_del_count, update_seq |
Revision-tree and storage growth; sizes.file / sizes.active is the compaction-pressure ratio. |
The native /_node/_local/_prometheus endpoint was added in CouchDB 3.2 and requires admin authentication on the main 5984 port; enabling [prometheus] additional_port = true also exposes an unauthenticated listener on port 17986 intended for scrapers on a trusted network. Consult the Apache CouchDB metrics documentation for the exposed series. There is no first-class “conflict count” metric — that gauge is derived, covered under Core Implementation below and in depth in Conflict-Resolution Metrics & SLOs for CouchDB Sync.
Streaming Detection / Monitoring Setup
Before building the full collector, prove the signals are readable with a minimal poller. The snippet below reads /_scheduler/docs on a fixed interval and prints the lifecycle state and backlog of every replication job on the node — the single most useful “is anything falling behind?” view. It uses only the standard library plus requests.
import time
import requests
def poll_scheduler(couch_url: str, interval: int = 15):
"""Print state and backlog for every replication job on this node."""
session = requests.Session()
while True:
resp = session.get(f"{couch_url}/_scheduler/docs", timeout=10)
resp.raise_for_status()
for doc in resp.json()["docs"]:
info = doc.get("info") or {}
# changes_pending is None until the job has computed a backlog estimate
pending = info.get("changes_pending")
print(f"{doc['doc_id']:<28} state={doc['state']:<10} "
f"pending={pending} write_failures={info.get('doc_write_failures')}")
time.sleep(interval)
if __name__ == "__main__":
poll_scheduler("http://localhost:5984")
Watch the state column across a few cycles: a job that flips between running and crashing with a rising error_count is retrying against a failing target, while a changes_pending that only grows means the source is producing changes faster than the job drains them. changes_pending reads as null briefly after a job starts, before the scheduler has estimated the backlog — treat None as “unknown,” not zero, so a restart never masquerades as a drained queue.
Core Implementation
The production collector scrapes each endpoint with bounded retries, normalizes the heterogeneous field shapes, and emits a flat dictionary of gauges suitable for any time-series client. The class below computes four families of signal — backlog, throughput/failures, job health, and disk bloat — plus an optional conflict-rate sample, and logs each scrape with structured context so a failed endpoint is diagnosable from the log alone.
import logging
import time
from dataclasses import dataclass, field
from typing import Any, Optional
import requests
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s")
logger = logging.getLogger("metrics-collector")
@dataclass
class CollectorConfig:
couch_url: str
databases: list
request_timeout: int = 10
max_retries: int = 3
auth: Optional[tuple] = None
conflicts_view: Optional[str] = None # e.g. "_design/audit/_view/conflicted"
class MetricsCollector:
"""Scrape CouchDB replication endpoints and compute observability gauges.
One instance observes one node. All replication metrics except the
scheduler rollup are node-local, so run a collector alongside each node.
"""
def __init__(self, config: CollectorConfig):
self.cfg = config
self.session = requests.Session()
if config.auth:
self.session.auth = config.auth
def _get(self, path: str) -> Any:
"""GET a path with capped exponential backoff; raise on final failure."""
url = f"{self.cfg.couch_url}{path}"
for attempt in range(1, self.cfg.max_retries + 1):
try:
resp = self.session.get(url, timeout=self.cfg.request_timeout)
resp.raise_for_status()
return resp.json()
except requests.RequestException as exc:
backoff = min(2 ** attempt, 20)
logger.warning("scrape failed path=%s attempt=%d retry_in=%ss err=%s",
path, attempt, backoff, exc)
if attempt == self.cfg.max_retries:
raise
time.sleep(backoff)
def backlog_and_throughput(self) -> dict:
"""Sum changes_pending, docs_written and write failures across jobs."""
tasks = [t for t in self._get("/_active_tasks") if t.get("type") == "replication"]
# changes_pending can be absent/None before the estimate is ready.
backlog = sum(t.get("changes_pending") or 0 for t in tasks)
return {
"replication_backlog_changes_pending": backlog,
"replication_docs_written_total": sum(t.get("docs_written", 0) for t in tasks),
"replication_doc_write_failures_total":
sum(t.get("doc_write_failures", 0) for t in tasks),
"replication_jobs_active": len(tasks),
}
def job_health(self) -> dict:
"""Read node-wide replicator job gauges from _stats."""
stats = self._get("/_node/_local/_stats")
jobs = stats.get("couch_replicator", {}).get("jobs", {})
# each leaf is {"value": N, "type": "gauge"}; default missing to 0.
val = lambda k: jobs.get(k, {}).get("value", 0) or 0
return {
"replication_jobs_running": val("running"),
"replication_jobs_pending": val("pending"),
"replication_jobs_crashed": val("crashed"),
}
def disk_bloat(self) -> dict:
"""Compute sizes.file / sizes.active per database — compaction pressure."""
gauges = {}
for db in self.cfg.databases:
info = self._get(f"/{db}")
sizes = info.get("sizes", {})
active = sizes.get("active") or 1 # avoid divide-by-zero on an empty db
gauges[f"db_disk_bloat_ratio.{db}"] = round(sizes.get("file", 0) / active, 3)
gauges[f"db_doc_count.{db}"] = info.get("doc_count", 0)
gauges[f"db_doc_del_count.{db}"] = info.get("doc_del_count", 0)
return gauges
def conflict_rate(self) -> dict:
"""Derive a conflict gauge from a view that emits conflicted docs.
CouchDB has no native conflict counter, so this samples a design view
(map: emit(null) when doc._conflicts exists) and divides by doc_count.
Skipped unless a conflicts_view is configured, since it is more costly.
"""
if not self.cfg.conflicts_view:
return {}
gauges = {}
for db in self.cfg.databases:
view = self._get(f"/{db}/{self.cfg.conflicts_view}?reduce=false&limit=0")
conflicted = view.get("total_rows", 0)
info = self._get(f"/{db}")
total = info.get("doc_count", 0) or 1
gauges[f"conflict_rate.{db}"] = round(conflicted / total, 4)
gauges[f"conflicted_docs.{db}"] = conflicted
return gauges
def collect(self) -> dict:
"""Run every probe and merge into one flat gauge dict."""
gauges: dict = {}
for probe in (self.backlog_and_throughput, self.job_health,
self.disk_bloat, self.conflict_rate):
try:
gauges.update(probe())
except Exception:
# One failing probe must not blank the whole scrape.
logger.exception("probe failed: %s", probe.__name__)
logger.info("scrape complete gauges=%d backlog=%s crashed=%s",
len(gauges),
gauges.get("replication_backlog_changes_pending"),
gauges.get("replication_jobs_crashed"))
return gauges
if __name__ == "__main__":
collector = MetricsCollector(CollectorConfig(
couch_url="http://localhost:5984",
databases=["iot_telemetry"],
))
for name, value in sorted(collector.collect().items()):
print(f"{name} {value}")
Two design choices matter in production. First, each probe is isolated in collect, so a single failing endpoint — a database temporarily returning 500 mid-compaction — degrades one gauge family rather than blanking the whole scrape. Second, the collector never assumes changes_pending is present; treating its absence as 0 would report a freshly restarted job as fully drained. The conflict-rate probe is deliberately gated behind an explicit view name because counting conflicted documents is heavier than the other reads, and the resolution side of that signal belongs with Revision Tree Mechanics.
The gap the backlog gauge measures is the distance between the source’s head sequence and the checkpoint the job has durably written — the diagram below shows exactly which sequence numbers bound it.
Strategy Variants & Trade-offs
Four collection strategies dominate, and most mature deployments combine two: a lightweight poll for the gauges that need per-job labels, plus the native Prometheus endpoint for everything standard. Each trades coverage, freshness, and operational weight differently.
| Strategy | Coverage / fidelity | Freshness & cost | Complexity | Best fit |
|---|---|---|---|---|
Poll GET /_active_tasks |
Live per-job progress only; no crashed/pending jobs, no history | Freshest snapshot; cheap single call | Low | Quick backlog and throughput on one node |
Poll /_scheduler/jobs + /docs |
Authoritative lifecycle incl. crashed/pending + event history | Fresh; two calls per scrape | Low–medium | Job-health and stalled-state alerting |
Native /_node/_local/_prometheus |
Broad node counters in one scrape; coarse per-job labels | Pull model; near-zero code | Low | Cluster-standard dashboards and long retention |
| Custom sidecar exporter | Full control of labels, derived gauges, conflict rate | Extra process per node to run and patch | High | Bespoke SLOs and multi-signal correlation |
Polling _active_tasks is the fastest path to a backlog number but blind to a job that has crashed out of the running set entirely — for that you need /_scheduler/docs, whose state field distinguishes crashing from running and whose error_count reveals a retry loop. The native /_prometheus route is the least code and the natural backbone for standard dashboards, but it does not expose every derived gauge — notably the conflict rate and per-database bloat ratio, which is where a sidecar exporter earns its keep. That exporter is the subject of Prometheus Metrics & Exporter Integration for CouchDB Replication; the MetricsCollector above is precisely the core such an exporter wraps. Whatever the mix, keep the derived gauges consistent across strategies so an alert rule reads the same series whether it was scraped natively or computed by the collector.
Deployment & Orchestration
Run one collector alongside each CouchDB node, not one central collector polling the whole target cluster. Replication jobs are pinned to individual nodes and _active_tasks/_stats only ever report the answering node, so a single central poller would miss every job running elsewhere and double-count nothing usefully. Drive each instance from the environment so one container image serves every node:
# Container environment (one collector replica per CouchDB node)
COUCH_URL=http://127.0.0.1:5984 # sidecar talks to its co-located node
COUCH_DATABASES=iot_telemetry
SCRAPE_INTERVAL_SECONDS=15 # 15–30s balances freshness against load
DB_SIZE_INTERVAL_SECONDS=300 # /db sizes move slowly; sample sparingly
REQUEST_TIMEOUT_SECONDS=10
CONFLICTS_VIEW=_design/audit/_view/conflicted # optional, gated conflict rate
HEALTHCHECK_PORT=9105
Set the scrape interval between 15 and 30 seconds: below that you add measurable load to a resource-constrained edge node for little extra fidelity, since changes_pending itself is only re-estimated periodically. Sample /db sizes far less often than the job gauges — file and active sizes drift over minutes, not seconds, and reading them provokes disk I/O. Expose a /healthz endpoint that returns 200 only when the last scrape succeeded within one interval and the collector can still reach its node; a scrape loop that has silently died is worse than no monitoring because the dashboard freezes on stale-but-plausible values. Pin the collector’s own last-success timestamp so an orchestrator restarts a wedged instance rather than leaving it to report ghosts. Because the collector is stateless between scrapes, it restarts cleanly and needs no durable volume — its only state is the time-series store it writes to.
Troubleshooting & Common Errors
| Symptom / error | Likely cause | Remediation |
|---|---|---|
changes_pending is null |
Job just (re)started; backlog not yet estimated | Treat as unknown, not zero; wait one scrape interval before alerting |
| Backlog gauge flat at zero but sync is behind | Reading _active_tasks on the wrong node; job runs elsewhere |
Run a collector per node; confirm job placement via /_scheduler/jobs node field |
401 Unauthorized on /_node/_local/_prometheus |
Endpoint requires admin auth on port 5984 | Supply admin credentials, or enable [prometheus] additional_port and scrape port 17986 |
disk_bloat_ratio climbs steadily |
Resolution tombstones accumulating without compaction | Schedule POST /db/_compact; see revision-tree growth guidance |
Job shows state: crashing, error_count rising |
Target rejecting writes or unreachable | Inspect /_scheduler/docs info and logs; fix target auth/VDU before backlog explodes |
Conflict rate reads 0 despite known conflicts |
No conflicts view configured, or view stale | Configure conflicts_view; trigger a view build; verify with ?conflicts=true |
| Collector CPU spikes each cycle | Scrape interval too tight or conflict view unindexed | Raise scrape_interval_seconds; back the conflicts view with a proper index |
docs_written counter appears to reset |
Job restarted; _active_tasks counters are per-run, not cumulative |
Compute rates over the scheduler history or the monotonic _stats counters |
FAQ
What is the single best metric for "is replication falling behind"?
changes_pending — the estimated number of source changes a job has not yet processed — summed across the running jobs on a node. It is exposed on both GET /_active_tasks and /_scheduler/jobs. Alert on a sustained rise rather than an absolute value, because a large one-off import legitimately spikes it; a backlog that grows monotonically over several scrape intervals is the real signal that the source is outrunning the job. Treat a null reading as “unknown,” which happens briefly after a restart before the estimate is computed.
Should I poll the API endpoints or scrape the native Prometheus endpoint?
Both, for different jobs. The native /_node/_local/_prometheus endpoint (CouchDB 3.2+) is the least effort for broad node counters and standard dashboards. But it exposes coarse per-job detail and no derived gauges such as conflict rate or per-database disk-bloat ratio, so a lightweight API poller or a sidecar exporter fills those in. Most production setups scrape the native endpoint for the standard series and run a collector for the computed ones, keeping the derived gauge names identical so alert rules do not care which produced them.
Why does CouchDB have no direct conflict-count metric?
Conflicts are a property of individual documents’ revision trees, not a counter the replicator maintains, so there is nothing to expose as a first-class stat. You derive the signal by counting documents that carry a non-empty _conflicts array — most cheaply with a design view that emits a key only for conflicted documents — and dividing by doc_count. Because that scan costs more than the other reads, sample it on a slower cadence. The resolution workflow and service-level objectives around that number are covered in the conflict-resolution metrics and SLOs guide.
Do I need one collector per node or one for the whole target cluster?
One per node. GET /_active_tasks and GET /_node/_local/_stats report only the node that answers the request, and each replication job is pinned to a single node, so a central collector would silently miss every job running elsewhere. Co-locate a stateless collector with each CouchDB node, label its metrics with the node name, and aggregate in the time-series store, not in the collector. The scheduler endpoints are cluster-aware, but the per-job progress you alert on is still node-local.
How do I monitor revision-tree growth from these metrics?
Read GET /db and watch sizes.file against sizes.active: the ratio is how much of the on-disk file is superseded revisions and tombstones awaiting compaction. A ratio drifting above roughly 2.0 means compaction is overdue. Pair it with doc_del_count, which rises as your resolver tombstones losing leaves. Sample these on a multi-minute cadence — they move slowly and each read touches disk. The mechanics of what fills that space are detailed in Revision Tree Mechanics.
Related
- CouchDB Replication Architecture & Revision Fundamentals
- Prometheus Metrics & Exporter Integration for CouchDB Replication
- Monitoring CouchDB Replication Checkpoints via the API
- Conflict-Resolution Metrics & SLOs for CouchDB Sync
- Revision Tree Mechanics
Part of: CouchDB Replication Architecture & Revision Fundamentals