Observability¶
pg2iceberg has four observability surfaces:
- Prometheus metrics and health checks on an HTTP endpoint, served by every long-running subcommand.
- Traces, exported over OTLP when you point it at a collector: where the time in a flush or a materializer cycle goes, down to each request.
- Structured logs on stdout, as text or JSON.
- Iceberg control-plane meta tables, when
sink.meta_namespaceis set. See metadata-tables.md.
Use the metrics for live dashboards and alerts, traces to see why one cycle was slow or failed, and the meta tables for history, such as a per-commit audit trail you can query with SQL.
The endpoint¶
run, stream-only, materializer-only and snapshot serve three paths:
| Path | Answers |
|---|---|
/metrics |
Every metric below, in the Prometheus text format. |
/healthz |
200 ok while the process is making progress; 503 once it's stuck (see Health checks). |
/readyz |
200 while snapshotting or running; 503 while starting up or draining. |
The one-shot subcommands (compact, maintain, verify, cleanup, ...) don't serve the endpoint.
| Setting | Env var | Default | Meaning |
|---|---|---|---|
metrics_addr |
PG2ICEBERG_METRICS_ADDR |
:9090 |
host:port, or :port for every interface. off serves nothing. |
liveness_timeout |
PG2ICEBERG_LIVENESS_TIMEOUT |
5m |
How long nothing may complete before /healthz reports the process stuck. |
A metrics_addr you set must be free, or pg2iceberg won't start. If you leave it at the default and port 9090 is already taken, for example by a second pg2iceberg on the same host, pg2iceberg logs a warning and runs without the endpoint.
Health checks¶
pg2iceberg records a metric only when it finishes something: a request to the catalog, object store or coordinator, a flush, an ack, a materializer cycle, or a watcher tick. Nothing records on a timer of its own.
So if nothing has been recorded for liveness_timeout, the process is stuck, not idle. Typical causes are a request that never returns or a deadlock. /healthz then answers 503, and restarting the process is the fix. Replication and the materializer work side by side in run, so a request that never returns counts even while the other keeps recording: /healthz fails once one has been in flight for liveness_timeout.
A process that is merely slow keeps passing. For example, a materializer cycle can take many minutes against a distant catalog, but its requests keep completing in the meantime. An idle run or stream-only still records every 30 seconds through its watcher.
materializer-only completes nothing between cycles, so its liveness timeout is at least three sink.materializer_intervals.
Kubernetes probes:
livenessProbe:
httpGet: { path: /healthz, port: 9090 }
periodSeconds: 30
failureThreshold: 2
readinessProbe:
httpGet: { path: /readyz, port: 9090 }
periodSeconds: 10
/readyz reports ready during the initial snapshot, which can take hours. Rollouts don't need to wait for it to finish.
Metrics¶
Every metric pg2iceberg emits is listed below. The table label holds the Iceberg table, qualified with its namespace (public.orders). Every duration is a histogram in seconds, with buckets from 5 ms to 5 min.
Process¶
| Metric | Type | Labels | Meaning |
|---|---|---|---|
pg2iceberg_build_info |
gauge | version, revision |
Always 1; the labels name the build. |
pg2iceberg_phase |
gauge | phase |
1 for what the process is doing: starting, snapshotting, running or stopping. |
pg2iceberg_last_success_timestamp_seconds |
gauge | stage |
When flush, ack (acking the slot), materialize or watch last completed. |
These come next to the standard process_cpu_seconds_total, process_resident_memory_bytes, process_virtual_memory_bytes, process_open_fds, process_max_fds, process_threads and process_start_time_seconds. On anything but Linux, only the start time is reported.
Replication¶
Read from pg_replication_slots every watcher tick (30 s).
| Metric | Type | Labels | Meaning |
|---|---|---|---|
pg2iceberg_replication_lag_bytes |
gauge | WAL the source has written past the slot's confirmed position: what pg2iceberg has yet to stage and ack. | |
pg2iceberg_slot_retained_wal_bytes |
gauge | WAL the slot keeps the source from removing. | |
pg2iceberg_slot_safe_wal_size_bytes |
gauge | WAL the source can still write before the slot passes max_slot_wal_keep_size. Absent when that's unlimited. |
|
pg2iceberg_slot_wal_status |
gauge | status |
1 for the slot's wal_status: reserved, extended, unreserved (WAL is about to be removed) or lost. |
pg2iceberg_slot_ack_lag_bytes |
gauge | WAL staged but not yet acked to the slot. | |
pg2iceberg_replication_reconnects_total |
counter | outcome |
Attempts to reopen a dropped replication stream: ok or error. |
pg2iceberg_replication_buffered_messages |
gauge | Decoded messages waiting for the main loop. Near 1000, the main loop is the bottleneck, not Postgres. |
Staging¶
| Metric | Type | Labels | Meaning |
|---|---|---|---|
pg2iceberg_pipeline_changes_total |
counter | table, op |
Row changes received: insert, update, delete or truncate. A reconnect can deliver some again. |
pg2iceberg_pipeline_transactions_total |
counter | Source transactions received. | |
pg2iceberg_pipeline_flush_total |
counter | Flushes recorded in the coordinator. | |
pg2iceberg_pipeline_rows_staged_total |
counter | table |
Rows staged as Parquet, snapshot rows included. |
pg2iceberg_pipeline_staged_bytes_total |
counter | table |
Bytes of staged Parquet. |
pg2iceberg_pipeline_flushed_lsn |
gauge | Highest source LSN staged and recorded. |
Materializing¶
| Metric | Type | Labels | Meaning |
|---|---|---|---|
pg2iceberg_materializer_backlog_rows |
gauge | table |
Rows staged but not yet materialized. Gauged by the watcher, so in distributed mode by stream-only. |
pg2iceberg_materializer_source_commit_timestamp_seconds |
gauge | table |
Source commit time of the newest change materialized. |
pg2iceberg_materializer_pk_index_bytes |
gauge | table |
Memory the table's primary-key index takes, about: at most sink.pk_index_memory. |
pg2iceberg_materializer_pk_index_evicted_files |
gauge | table |
Data files whose keys the index evicted to keep to its budget: a change to one of their rows may read them. |
pg2iceberg_materializer_rows_total |
counter | table |
Rows written to Iceberg, deletes included: each key once per step it changed in. |
pg2iceberg_materializer_commits_total |
counter | table |
Iceberg commits of materialized rows. |
pg2iceberg_materializer_files_written_total |
counter | table, kind |
Files those commits added: data or delete. |
pg2iceberg_materializer_bytes_written_total |
counter | table |
Bytes of those files. |
pg2iceberg_materializer_commit_duration_seconds |
histogram | How long a commit took. | |
pg2iceberg_materializer_cycle_duration_seconds |
histogram | How long a cycle over every assigned table took. | |
pg2iceberg_materializer_cycle_total |
counter | table |
Times the materializer looked for staged rows for a table. |
pg2iceberg_materializer_cycle_failures_total |
counter | Cycles that failed. In run that stops the process; materializer-only retries. |
|
pg2iceberg_unfilled_column_defaults_total |
counter | table, column, reason |
Columns added with a default Postgres no longer had for existing rows, which read NULL in Iceberg. |
pg2iceberg_distributed_workers |
gauge | materializer-only: workers in the consumer group. |
|
pg2iceberg_distributed_assigned_tables |
gauge | materializer-only: tables assigned to this worker. |
Compaction¶
| Metric | Type | Labels | Meaning |
|---|---|---|---|
pg2iceberg_compaction_runs_total |
counter | table, outcome |
Compactions that rewritten files, or failed. |
pg2iceberg_compaction_files_rewritten_total |
counter | table |
Data and delete files compaction replaced. |
pg2iceberg_compaction_duration_seconds |
histogram | How long a compaction of one table took. |
Requests¶
Every request to the Iceberg catalog, the object store and the coordinator.
| Metric | Type | Labels | Meaning |
|---|---|---|---|
pg2iceberg_catalog_request_duration_seconds |
histogram | op |
Catalog requests, by operation (load_table, commit_snapshots, ...). |
pg2iceberg_catalog_request_errors_total |
counter | op, kind |
Failed catalog requests: conflict, not_found or other. |
pg2iceberg_blob_request_duration_seconds |
histogram | op |
Object store requests: put, get, list, delete, register_table. |
pg2iceberg_blob_request_errors_total |
counter | op |
Failed object store requests. |
pg2iceberg_blob_bytes_total |
counter | op |
Bytes uploaded (put) and downloaded (get). |
pg2iceberg_coord_request_duration_seconds |
histogram | op |
Coordinator requests (claim_offsets, set_cursor, ...). |
pg2iceberg_coord_request_errors_total |
counter | op |
Failed coordinator requests. |
The materializer reads staged files ahead of the one it's applying, up to eight at once, while it uploads and commits what it has read: its gets overlap its other requests. A response that arrives while it's folding or encoding waits until it next awaits a request, so a get's duration and span can include that time.
Invariants¶
| Metric | Type | Labels | Meaning |
|---|---|---|---|
pg2iceberg_invariant_violations_total |
counter | invariant |
Runtime invariant violations the watcher found. Any is worth a look; slot_ahead_of_record, slot_wal_lost and slot_conflicting also stop the process. |
What to alert on¶
example/single/monitoring/alerts.yml has Prometheus rules for these. The essentials:
| Alert | Expression |
|---|---|
| Stuck | time() - max by (instance) (pg2iceberg_last_success_timestamp_seconds) > 600 |
| Falling behind | pg2iceberg_replication_lag_bytes > 1e9 for 15 minutes |
| About to lose WAL | pg2iceberg_slot_wal_status{status=~"unreserved\|lost"} == 1 |
| A table is stale | time() - pg2iceberg_materializer_source_commit_timestamp_seconds > 900 and on (table) pg2iceberg_materializer_backlog_rows > 0 |
| An invariant broke | increase(pg2iceberg_invariant_violations_total[10m]) > 0 |
How stale a table is only means something while it has a backlog. A table nobody writes to keeps its last source commit time but is fully up to date, which is why the stale-table alert also checks the backlog.
The replication lag counts every write on the source, including writes to tables pg2iceberg doesn't replicate. pg2iceberg acks those writes along with its own.
Traces¶
pg2iceberg exports its spans as OpenTelemetry traces over OTLP/HTTP (protobuf) once OTEL_EXPORTER_OTLP_ENDPOINT says where: an OpenTelemetry Collector, Grafana Tempo, Jaeger, or a vendor's OTLP endpoint. Unset, nothing is exported.
Each unit of work is a trace of its own, and every request it makes to the Iceberg catalog, the object store or the coordinator is a span within it, with its store, operation, duration and, if it failed, the error:
| Trace | What it covers |
|---|---|
startup |
Validation, slot and table setup, before replicating |
snapshot.table, snapshot.chunk |
Resuming one table's snapshot, and each chunk it stages — a snapshot can take hours, so each chunk is its own trace |
pipeline.flush |
Staging committed changes and recording them (rows, lsn) |
pipeline.spill |
Staging part of a transaction too large to hold in memory |
slot.ack |
Recording the flushed LSN and acking the slot |
materializer.cycle |
A materializer cycle: materializer.table per table, materializer.commit per Iceberg commit (rows, data_files, delete_files, bytes, snapshot) |
compaction.cycle |
A compaction pass: compaction.table per table. In run, prepared beside the materializer |
compaction.commit |
In run, committing the passes prepared beside the materializer, as a tick starts |
watcher.check |
The invariant checks, with the slot read |
replication.reconnect |
An attempt to reopen a dropped replication stream |
shutdown |
The final flush, cycle and ack |
compact, maintain, snapshot |
The one-shot subcommands |
Requests are exported as <store>.<operation>, e.g. catalog.commit_snapshots, object_store.put, coordinator.claim_offsets.
The standard OpenTelemetry variables apply:
| Variable | Default | Meaning |
|---|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT |
unset: no export | The collector's base URL; traces go to /v1/traces under it |
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT |
The full traces URL, instead | |
OTEL_EXPORTER_OTLP_HEADERS |
key=value,..., e.g. an API key |
|
OTEL_SERVICE_NAME |
pg2iceberg |
The service the traces belong to; with more than one pg2iceberg, name each |
OTEL_RESOURCE_ATTRIBUTES |
More attributes, e.g. deployment.environment=prod |
|
OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARG |
every trace | Keep a share, e.g. traceidratio and 0.1 |
OTEL_SDK_DISABLED |
true turns the export off |
pg2iceberg exports over HTTP only — a collector's port 4318. gRPC (OTEL_EXPORTER_OTLP_PROTOCOL=grpc, port 4317) is refused at startup. An idle run makes a few traces a minute (acks, materializer cycles, watcher checks); under load, one per flush and per cycle. An export that fails is logged.
Try it¶
example/single runs Prometheus with these rules, Tempo for the traces, and Grafana with a pg2iceberg dashboard:
cd example/single
OTEL_EXPORTER_OTLP_ENDPOINT=http://tempo:4318 docker compose --profile monitoring up -d --wait
docker compose --profile workload up -d workload
Grafana is at http://localhost:3000 (no login), Prometheus at http://localhost:9090, and pg2iceberg's own endpoint at http://localhost:9091/metrics. The dashboard's Traces row lists recent materializer cycles, failed traces and slow catalog requests; open one for its waterfall, or search them in Explore with TraceQL, e.g. { name = "materializer.commit" && span.table = "rideshare.rides" }.
Logs¶
The binary logs through tracing to stdout, as plain text, or one JSON object a line with PG2ICEBERG_LOG_FORMAT=json — for a log collector to parse. A message logged during a unit of work names it and its fields, e.g. materializer.cycle:materializer.table{table=public.orders}: .... Tune verbosity with RUST_LOG:
# Default
RUST_LOG=info,pg2iceberg=debug pg2iceberg run --config config.yaml
# Quiet
RUST_LOG=warn pg2iceberg run --config config.yaml
# Maximum verbosity for one crate
RUST_LOG=info,pg2iceberg_logical=trace pg2iceberg run --config config.yaml
Useful log targets¶
| Target | What |
|---|---|
pg2iceberg::run |
Subcommand dispatch + lifecycle messages |
pg2iceberg_validate::runtime |
Startup invariants, slot health, snapshot phase progress, reconnects |
pg2iceberg_logical::materializer |
Per-cycle materializer events incl. compaction outcomes |
pg2iceberg_logical::pipeline |
Per-flush staging events |
pg2iceberg_pg::prod::stream |
pgoutput decode + slot interaction |
pg2iceberg_iceberg::prod::vended |
Vended-credential refresh events |
pg2iceberg_iceberg::compact |
Compaction runs |
Iceberg control-plane meta tables¶
When sink.meta_namespace is set, pg2iceberg writes operational telemetry to Iceberg tables you can query from any Iceberg-compatible engine: one row per commit, compaction and maintenance run. See metadata-tables.md for the schema and example queries.