Real-time, tiered PostgreSQL wait event tracer.
pg_wait_tracer attaches to a running PostgreSQL cluster and observes wait events across all backends. It requires no PostgreSQL patches, no extensions, and no restarts.
It runs in two tiers (default --mode tiered):
- An always-on, low-overhead sampled tier (ASH-style, pure userspace via
process_vm_readv— ~0% impact on PostgreSQL) that you leave running 24/7. - On-demand or anomaly-triggered escalation to the exact
hardware-watchpoint tier, which captures every
wait_event_infotransition with nanosecond precision during bounded, budgeted windows.
This gives you Active Session History continuously, plus exact, every-transition detail exactly when you need it — without paying the watchpoint overhead all the time. The 10 Hz sampler agrees with the exact tier to within ~1 percentage point (top-5 events match) on the same window, so tiered-by-default is trustworthy.
Key capabilities:
- Tiered capture (
--mode tiered, default): always-on userspace sampler- on-demand/anomaly escalation to exact hardware-watchpoint tracing; also
available as pure
--mode sampledor always-on exact--mode full
- on-demand/anomaly escalation to exact hardware-watchpoint tracing; also
available as pure
- Escalation: bounded, budgeted full-fidelity windows, triggered manually (web UI "Escalate" button / control socket) or automatically by anomaly rules (AAS-vs-baseline and lock-class-fraction, with cooldown/hysteresis)
- Control socket:
{trace_dir}/pgwt.sockJSON-linestatus/metrics/escalate/deescalate, proxied bypgwt-serverfor the web client, with Prometheus-ready self-observability metrics - Diagnostic views: time model, events, sessions, queries, histogram, timeline, transition graph, concurrency, per-execution waterfall, execution latency scatter, and transition matrix — each tagged with its data fidelity (sampled / exact / mixed); exact-only views prompt to escalate over sampled-only windows instead of silently showing nothing
- Web investigation client (
pgwt): browser UI with ECharts AAS chart, color-coded drill-down tables, percentage bars, summary metrics, concurrency peak overlay, burst detection markers — connects to DB server over SSH - Live mode: auto-refreshing AAS chart with sliding time window, per-session event cache for immutable trace files
- Text dump (
pgwt-server --dump): quick CLI summary of trace files (time model, top events, top sessions, top queries) — no TUI needed - Advanced analysis: wait event transitions + fingerprinting, lock chain detection (waiter→blocker inference), cross-session interference scoring
- Multi-window analysis: compare wait profiles across time horizons (e.g. last 5s vs 1m vs 5m)
- Daemon mode: persistent monitoring with automatic re-attach on PostgreSQL restarts
- Trace recording: columnar LZ4-compressed event files with hourly rotation and configurable retention
- Offline replay: analyze historical trace files without root or a running PostgreSQL instance
- Dynamic event names: auto-discovers wait event names from PostgreSQL
(PG17+) via
pg_wait_events, forward-compatible with PG19+
In the default tiered mode the always-on sampler has ~0% impact on PostgreSQL (the daemon itself uses ~0.6% of one core at 10 Hz). The exact watchpoint tier — used only during bounded escalation windows — costs ~6% on write-heavy OLTP, up to ~30% on read-heavy workloads with high buffer miss rates. See Performance for details.
Requirements — Linux only. pg_wait_tracer relies on Linux-specific facilities (eBPF,
process_vm_readv,perf_event_open, CPU hardware watchpoints) and cannot run on macOS, Windows, or *BSD. Minimum environment:
- Linux kernel >= 5.8 with BTF enabled (
/sys/kernel/btf/vmlinuxpresent), or EL8 (RHEL/Rocky/Alma/Oracle Linux 8.10, kernel 4.18 with backported BTF + BPF ring buffer). EL9 (kernel 5.14) and Ubuntu are also supported. The build auto-bundles a pinned libbpf/bpftool when the system ones are too old (e.g. EL8's libbpf 0.5.0), so older distros build cleanly. BTF is always required; on kernels without the BPF ring buffer the tool degrades to sampled-only (the full tier needs ringbuf).- Architecture: x86_64 or aarch64
- Privileges: root, or
CAP_BPF+CAP_PERFMON+CAP_SYS_PTRACE(older kernels:CAP_SYS_ADMIN+CAP_SYS_PTRACE)- PostgreSQL: 17 or 18 (full); 13 (supported — query attribution requires
pg_stat_statements); 14-16 not yet (see INSTALL.md)The web client (
pgwt) and offline replay (pg_wait_tracer --replay,pgwt-server --dump) do not require root or Linux on the client side — only the DB server where tracing runs must meet the requirements above.
Demo
Captured from a 60-second pgbench TPC-B workload (8 clients, scale 10) on a
tiny VM (2 vCPUs, Rocky 9 + PostgreSQL 18), with a few injected LOCK TABLE
statements to add lock contention. ~7M wait-event transitions captured. See
demos/README.md for the recording tooling (one
make all reproduces both gifs from a fresh VM).
Web investigation client (pgwt) — drag-zoom AAS chart → drill into
Events / Sessions / Queries → wait-event transition graph:
Text dump (pgwt-server --dump) — time model + top events + top sessions
- top queries from any trace file, no TUI required:
Quick Start
# Build (see INSTALL.md for prerequisites) make # Run (auto-discovers single PG instance, must be root). # Default capture tier is "tiered": a low-overhead always-on sampler that # escalates to exact watchpoint tracing on demand (safe to leave running 24/7). sudo ./pg_wait_tracer # Force always-on exact watchpoint tracing (the original behavior, 6-30% overhead) sudo ./pg_wait_tracer --mode full # One-shot: collect one 10-second interval and exit sudo ./pg_wait_tracer --mode full --count 1 --interval 10 # Multi-window: compare wait profiles across time horizons sudo ./pg_wait_tracer --window 5s,1m,5m # Daemon mode: persistent monitoring with trace recording sudo ./pg_wait_tracer --daemon -T /var/lib/pgwt/traces # Offline replay: analyze last hour from trace files (no root needed) pg_wait_tracer --replay -T /var/lib/pgwt/traces --from 1h # Web client: browser UI from your laptop over SSH (no root needed) pgwt root@db-server # Quick summary from trace files (no root needed) pgwt-server --dump /var/lib/pgwt/traces
Capture Tiers (--mode)
Orthogonal to the run modes below, --mode selects how wait events are
captured. The default is tiered.
| Mode | Fidelity | PG overhead | What it does |
|---|---|---|---|
tiered (default) |
sampled, escalates to exact | ~0% baseline, bounded during escalation | Always-on userspace sampler (process_vm_readv); escalates to full watchpoint tracing for bounded, budgeted windows — on demand (control socket / web UI) or on anomaly. The "leave it running 24/7" posture. |
sampled |
sampled | ~0% on PostgreSQL | Pure-userspace ASH-style sampling at a fixed rate. No watchpoints, never escalates. |
full |
exact | 6-30% (workload-dependent) | Always-on hardware watchpoints — every wait_event_info transition captured exactly. The original behavior, now opt-in. |
coop |
exact | — | Cooperative (PostgreSQL extension) tier — stub only: the interface is frozen but the provider advertises itself and then returns "not available in this build". The real implementation ships in the separate PG-extension track. |
# Default — low-overhead always-on, escalate on demand sudo ./pg_wait_tracer --daemon -T /var/lib/pgwt/traces # Force exact watchpoint tracing for a focused investigation sudo ./pg_wait_tracer --mode full --count 1 # Pure sampling at 50 Hz sudo ./pg_wait_tracer --mode sampled --sample-rate 50
In tiered mode, EXACT-required views (histogram, transitions, fingerprints,
lock chains, interference, concurrency, executions/waterfall, execution
scatter, and transition matrix) report requires full-fidelity data
over windows with no escalation, rather than returning silently empty results
— the web client renders an explicit "escalate to capture" state.
Escalation
A full-fidelity window can be opened two ways:
- Manually — the web UI's "Escalate" button, or the control socket
(
{"cmd":"escalate","duration_s":N}).{"cmd":"deescalate"}closes it early. - Automatically — anomaly rules evaluated on the sampled stream:
AAS exceeding a multiple of its rolling baseline (
--anomaly-aas-factor, default 3.0× sustained--anomaly-aas-ticksticks, default 3), or the Lock-class share of active samples exceeding--anomaly-lock-fraction(default 0.30) and the absolute Lock-class AAS exceeding--anomaly-lock-min-aas(default 2.0). The lock floor stops a single backend's routine row-lock wait — a lock-fraction of 1.0 over an AAS of 1 — from burning a whole window on OLTP noise; a genuine lock convoy still fires. Independently, a CPU-demand CUSUM detects sustained demand near the target postmaster's effective CPU capacity:S=max(0,S+dt*(min(cpu_AAS/C,cap)-(k+margin)))fires atS>=honly whencpu_AAS>=cpu_min_aas(defaultsk=0.80, affinity-onlymargin=0.02,cpu_min_aas=2.0,h=1.50,cap=1.25). Quota and explicit override capacities use zero margin.dtis the nominal sample period, so a delayed tick cannot fabricate evidence. Failed read coverage and unknown capacity never advance it; a material capacity change or return from a partial-read gap resets it. Each continuous saturation episode fires once, then re-arms only after a complete, capacity-known tick falls belowk. CPU fires have the distinctcpu_saturationescalation reason and use the same window, cooldown, and budget path. If discovered capacity is known to be uncertain, set the deployment's known value with--anomaly-cpu-capacity. A cooldown (--anomaly-cooldown-s, default 120) prevents flapping.
The rolling AAS baseline is frozen while a metric is anomalous so a sustained incident cannot silently raise the bar and silence the rule. To keep a legitimate load regime change from re-firing forever, the baseline resumes learning — at 1/10 the normal EWMA rate — only after the metric has stayed continuously over the threshold for 15 minutes (the "slow learn-through"): short incidents never move the bar, a real new normal is eventually adopted.
Every window is bounded: per-window length is set by the request (or
--anomaly-window-s, default 60, for auto-triggers), and total escalation time
is capped by --escalation-budget per rolling hour:
300(default) — 300 full-fidelity seconds per rolling hour. A request larger than the remaining budget is clamped to what's left (the window runs up to the cap), and mid-window enforcement closes a window the instant cumulative consumption reaches the budget, so worst-case full-fidelity time per hour never exceeds the budget even under adversarial repeated extensions.0— escalation disabled (every escalate request is denied). The self-metrics honestly report0budget remaining.unlimited— no cap (the old0behavior). Reported asescalation_budget_unlimited: true.
When the budget is exhausted, further escalations are denied (and counted in the
self-metrics). Note the duty-cycle floor: the cooldown is measured from the
start of each auto-escalation, so a sustained incident re-fires every
--anomaly-cooldown-s (default 120s) for a --anomaly-window-s (default 60s)
window — a 50% full-fidelity duty cycle on a persistent incident, and still
bounded above by the budget (which will curtail it sooner). Widen the cooldown
or shrink the window to lower the duty cycle; the sampled tier keeps full
coverage throughout regardless. On very high backend counts the synchronous
watchpoint attach_all at escalation start can briefly stall the sampler; the
lost ticks are weight-compensated (each tick is billed by measured elapsed
time), so the accounting stays correct — only time resolution dips for that
instant.
# Open a 120s full-fidelity window on a running daemon via the control socket echo '{"cmd":"escalate","duration_s":120}' | nc -U /var/lib/pgwt/traces/pgwt.sock # Inspect current tier / budget echo '{"cmd":"status"}' | nc -U /var/lib/pgwt/traces/pgwt.sock echo '{"cmd":"metrics"}' | nc -U /var/lib/pgwt/traces/pgwt.sock
Control Socket
When recording is enabled (--trace-dir), the daemon listens on a unix-domain
socket at {trace_dir}/pgwt.sock (mode 0600). It speaks a newline-delimited
JSON protocol — one request object per line, one response object per line.
pgwt-server proxies it through to the web client as a single control
command, so the browser UI drives escalation and reads self-metrics without a
direct connection.
| Command | Request | Response (summary) |
|---|---|---|
status |
{"cmd":"status"} |
mode, tier (sampled/escalated), escalation_supported, escalation_seconds_remaining, escalation_budget_remaining_s, escalation_reason, uptime_s, backends, pg_pid, version |
metrics |
{"cmd":"metrics"} |
self-observability counters (below) |
escalate |
{"cmd":"escalate","duration_s":N} |
escalated, granted_s, seconds_remaining, budget_remaining_s — or ok:false + error + budget_remaining_s when over budget / not tiered |
deescalate |
{"cmd":"deescalate"} |
closes the window now (idempotent); errors if not --mode tiered |
Self-observability metrics (metrics command — Prometheus-ready names):
events_total,events_per_sec,lifecycle_events_totalsamples_total,samples_per_sec,sample_read_faults_totalwp_attach_failures_total,ringbuf_drops_total,backends_trackedtrace_events_written_total,trace_bytes_written_totaltier,escalation_active,escalation_seconds_remaining,escalation_budget_remaining_s,escalation_windows_total,escalation_denied_totalanomaly_fires_total,anomaly_near_total,anomaly_dropped_budget_total,anomaly_dropped_cooldown_total,anomaly_baseline_aas
Operating Modes
pg_wait_tracer has three operating modes:
Interactive (default)
Attaches to PostgreSQL, displays live views, and exits when interrupted or
when --count/--duration is reached. This is the original mode — useful
for quick investigations.
sudo ./pg_wait_tracer --view system_event --count 3
Daemon (--daemon)
Runs persistently. When PostgreSQL restarts (crash, upgrade, pg_ctl restart),
the daemon detects the terminated postmaster, waits for a new one to appear,
and re-attaches automatically. BPF programs are destroyed and reloaded on each
cycle because BPF rodata is immutable after load.
Cannot be combined with --count or --duration.
# Daemon with trace recording and 48-hour retention sudo ./pg_wait_tracer --daemon -T /var/lib/pgwt/traces -R 48 # Daemon with live output to terminal sudo ./pg_wait_tracer --daemon --view active
How restart detection works:
- Every interval tick, the daemon calls
kill(postmaster_pid, 0)to check if the postmaster is alive. - When the postmaster dies, the daemon prints a message and enters a wait loop, checking for a new PostgreSQL instance every 5 seconds.
- PGDATA is inferred from
/proc/<pid>/cwdat startup (or provided via--pgdata). On restart, the daemon reads the newpostmaster.pidfrom PGDATA to discover the new PID. - On re-attach, all BPF state is reset — accumulators start fresh.
Replay (--replay)
Reads completed trace files and produces the same views as live mode, but from historical data. Does not require root or a running PostgreSQL instance.
# Replay last hour, time_model view pg_wait_tracer --replay -T /var/lib/pgwt/traces --from 1h # Replay specific time range pg_wait_tracer --replay -T /var/lib/pgwt/traces \ --from "2025-02-25T14:00:00" --to "2025-02-25T14:30:00" \ --view system_event # Replay with multi-window pg_wait_tracer --replay -T /var/lib/pgwt/traces --from 2h \ --view query_event --window 5s,1m,5m
Limitations:
- The
activeview is not available in replay mode (it requires the live BPF state_map to read current wait states). current.trace(the file being written by the daemon) has no footer and cannot be read by replay. Only rotated.trace.lz4files are readable. Only rotated.trace.lz4files are readable by replay andpgwt-server.- Output format is always
textin replay mode.
Web Investigation Client (pgwt)
A browser-based investigation tool that connects to a remote DB server over SSH. No database credentials needed — it reads trace files directly on the server.
# From your laptop — opens browser automatically pgwt root@db-server # Custom trace directory and server binary path pgwt --trace-dir /var/lib/pgwt/traces \ --server-path /usr/local/bin/pgwt-server \ root@db-server
Architecture:
[Your laptop] [DB server]
pgwt (Go binary) pgwt-server (C binary)
├─ spawns: ssh user@host ├─ reads trace files
│ pgwt-server <trace-dir> ├─ computes aggregates
├─ localhost:8384 HTTP server └─ JSON lines on stdin/stdout
└─ browser UI (ECharts)
Features:
- AAS stacked area chart (ECharts) — 11 wait class colors, interactive zoom with drag-to-select, tooltip with per-class AAS breakdown and percentages. Fidelity shading: sampled-only spans are washed amber and mixed spans indigo, so you can see at a glance which parts of the window are estimated vs exact; escalation windows are annotated (cyan for manual, red for anomaly).
- Escalate control — an "Escalate" button in the header opens a full-fidelity window (and shows the seconds left / a "Stop" button while one is open), plus a budget readout of full-fidelity seconds remaining this hour.
- Daemon self-metrics panel — live tier, events/s, samples/s, ringbuf drops, sample faults, estimated overhead, anomaly counters, baseline AAS, and remaining escalation budget (read over the control socket).
- 4 table views: Overview (time model), Events, Sessions, Queries
- Drill-down navigation: click a wait class → see its events → click an event → see sessions → click a session → see queries. Breadcrumb trail with colored dots shows filter stack with click-to-go-back.
- Color-coded dots: wait class colors shown next to event/class names in all 4 tables
- Percentage bars: visual bar fills behind
%DB,CPU%,Wait%columns, colored by wait class - Summary metrics: DB Time, Wall clock, AAS, Idle time, CPUs shown above the overview table
- Sortable columns: click any column header to sort ascending/descending
- Auto-reconnect: WebSocket reconnects with exponential backoff on connection loss
- Zero config: auto-discovers trace files, auto-opens browser
Prerequisites on DB server: pgwt-server binary and trace files from the
daemon. See INSTALL.md for build instructions.
Flags:
| Flag | Default | Description |
|---|---|---|
--port |
8384 | Local HTTP port |
--trace-dir |
/var/lib/pgsql/18/data/pg_wait_tracer/ |
Trace directory on the remote host |
--server-path |
pgwt-server |
Path to pgwt-server binary on the remote host |
Text Dump (pgwt-server --dump)
Quick CLI summary of trace files — time model, top events, top sessions, top queries. No TUI, no browser, no root. Useful for SSH one-liners.
# Summary of all trace files pgwt-server --dump /var/lib/pgwt/traces # Pipe-friendly (combine with standard tools) pgwt-server --dump /var/lib/pgwt/traces | grep "IO:"
See INSTALL.md for build instructions and docs/ROADMAP_AND_STATUS.md for the development roadmap.
Planned features (see roadmap for details):
- Query text capture from
st_activityin shared memory - Plan identifier capture (
st_plan_id, PG18+) for plan regression detection - Live mode (connect web client to running daemon for real-time streaming)
CLI Reference
Target Selection
| Flag | Short | Default | Description |
|---|---|---|---|
--pid <PID> |
-p |
auto-detect | Postmaster PID |
--pgdata <DIR> |
-D |
auto-detect | PGDATA directory (reads postmaster.pid) |
Auto-discovery: When neither --pid nor --pgdata is given, pg_wait_tracer
scans /proc for running PostgreSQL instances. If exactly one is found, it
attaches automatically. If multiple are found, it lists them and exits.
Output Control
| Flag | Short | Default | Description |
|---|---|---|---|
--view <VIEW> |
-V |
time_model |
Output view (see Views section) |
--sort <MODE> |
-S |
wait_time |
Sort for active view: wait_time, db_time, pid, event |
--format <FMT> |
-f |
auto-detect | Output format: tui (terminal), text (pipe) |
--interval <SEC> |
-i |
5 | Refresh interval in seconds (minimum 1) |
--duration <SEC> |
-d |
unlimited | Stop after N seconds |
--count <N> |
-n |
unlimited | Print N intervals then exit |
--window <W1,W2,W3> |
-w |
— | Time windows, e.g. 5s,1m,5m (first must equal interval) |
Format auto-detect: When stdout is a terminal, output uses TUI mode (screen clearing). When piped or redirected, output switches to text mode (timestamps per interval, no screen clearing).
Filters
| Flag | Short | Default | Description |
|---|---|---|---|
--event <NAME> |
-e |
— | Event filter (required for histogram; optional for query_event) |
--pid-filter <PID> |
-P |
— | Per-event detail for one backend (session_event view) |
--query-id <ID> |
-Q |
— | Filter query_event to one query |
Trace Recording
| Flag | Short | Default | Description |
|---|---|---|---|
--trace-dir <DIR> |
-T |
disabled | Directory for trace files (enables recording) |
--trace-retention <H> |
-R |
24 | Keep trace files for H hours |
--retention-gb <G> |
— | off | Also cap total trace-dir size at G GiB (fractional allowed); oldest archives deleted first, the live current files never |
--trace-group <GROUP> |
— | dba |
Unix group for trace file access |
Replay
| Flag | Short | Default | Description |
|---|---|---|---|
--replay |
— | off | Replay mode (read trace files instead of live tracing) |
--from <TIME> |
— | start of files | Start time for replay |
--to <TIME> |
— | end of files | End time for replay |
Time formats for --from/--to:
- ISO 8601:
2025-02-25T14:30:00or2025-02-25 14:30:00 - Relative:
1h,30m,90s,2h30m(that much time ago from now) - Special:
now
Daemon
| Flag | Short | Default | Description |
|---|---|---|---|
--daemon |
— | off | Persistent mode with PostgreSQL restart detection |
Capture Tier
| Flag | Short | Default | Description |
|---|---|---|---|
--mode <MODE> |
— | tiered |
Capture tier: tiered (always-on sampler + on-demand escalation), sampled, full (exact watchpoints), coop (stub). See Capture Tiers |
--sample-rate <HZ> |
— | 10 |
Sampling rate for sampled/tiered (1-1000 Hz) |
--escalation-budget <S> |
— | 300 |
tiered: full-fidelity seconds allowed per rolling hour. 0 = escalation disabled (deny all); unlimited = no cap. Over-budget requests are clamped to the remainder; enforced mid-window. |
Anomaly Triggers (tiered mode)
Auto-escalation rules evaluated on the sampled stream. Ignored outside
--mode tiered.
| Flag | Short | Default | Description |
|---|---|---|---|
--anomaly-aas-factor <K> |
— | 3.0 |
Fire when AAS > K × rolling baseline |
--anomaly-aas-ticks <N> |
— | 3 |
...sustained for N consecutive ticks |
--anomaly-lock-fraction <F> |
— | 0.30 |
Fire when Lock-class share of active samples > F (sustained N ticks) |
--anomaly-lock-min-aas <A> |
— | 2.0 |
...and absolute Lock-class AAS ≥ A (min-activity floor: a lone lock waiter can't escalate) |
--anomaly-cooldown-s <S> |
— | 120 |
Minimum seconds between auto-escalations |
--anomaly-window-s <S> |
— | 60 |
Full-fidelity window length per auto-trigger |
--anomaly-cpu-capacity <C> |
— | discovered | Override target effective logical CPU capacity; use when discovery is known to misrepresent backend capacity |
--anomaly-cpu-min-aas <A> |
— | 2.0 |
Absolute CPU-class AAS required for a CPU CUSUM fire |
--anomaly-cpu-margin <M> |
— | 0.02 |
Extra utilization slack for affinity-only capacity; quota and override capacity use zero margin |
--anomaly-cpu-cusum-k <K> |
— | 0.80 |
CPU-demand CUSUM slack/reference utilization |
--anomaly-cpu-cusum-h <H> |
— | 1.50 |
CPU-demand CUSUM fire threshold |
--anomaly-cpu-cusum-cap <U> |
— | 1.25 |
Cap cpu_AAS / effective_capacity per tick |
--anomaly-cpu-coverage-gap-s <S> |
— | 2.0 |
Consecutive blind duration before held CPU evidence is discarded; converted to ticks using --sample-rate |
--anomaly-cpu-cusum-disable |
— | off | Disable only the CPU saturation guard |
For compatibility with existing pure-sampled and manual-window deployments,
--anomaly-aas-factor 1000000 (or larger) disables both the multiplicative AAS
rule and the CPU saturation guard. The lock rule retains its own configuration.
Performance Tuning
| Flag | Short | Default | Description |
|---|---|---|---|
--lightweight |
— | off | BPF accumulator only (no ringbuf). ~5.5% overhead, loses histogram/session/query views |
--skip-query-id |
— | off | Skip query_id reads in BPF. Saves ~1.5% overhead, disables query_event view |
Other
| Flag | Short | Default | Description |
|---|---|---|---|
--verbose |
-v |
off | Print diagnostic info to stderr |
--help |
-h |
— | Show usage |
Views
time_model (default)
System-wide time accounting with event hierarchy. Shows how backends spend their time, broken down by CPU and each wait class, with the top individual events shown as indented subcategories under each class. This is the starting point for any investigation.
Columns:
| Column | Description |
|---|---|
| Stat Name | Time category (class or individual event) |
| Time (ms) | Duration in the interval |
| % DB Time | Fraction of total DB Time |
Event hierarchy: Each wait class shows the top 3 individual events that contribute >= 1% of DB Time, indented beneath the class total. CPU has no sub-events (it is not a wait class). Classes with 0 time are hidden.
Example:
════════════════════════════════════════════════════════════════════════════════
pg_wait_tracer — Time Model Backends: 12 Interval: 5s
════════════════════════════════════════════════════════════════════════════════
Stat Name Time (ms) % DB Time
────────────────────────────────────────────────────────────────────────────
DB Time 25088.6 100.0%
CPU* 5498.6 21.9%
IO 3262.8 13.0%
IO:DataFileRead 2534.1 10.1%
IO:DataFileWrite 312.4 1.2%
Lock 2104.3 8.4%
Lock:Transaction 1980.1 7.9%
LWLock 1823.2 7.3%
LWLock:WALInsert 1312.3 5.2%
LWLock:BufferContent 410.8 1.6%
Timeout 450.0 1.8%
(Activity/Idle — excluded from DB Time) 13442.5 —
Client:ClientRead (a backend waiting for the next query from the client) is
treated as idle — like Oracle's "SQL*Net message from client" — so it is
excluded from DB Time. It still shows up in event lists, but with — in the
% DB column; only the Activity class is hidden entirely.
Reading this output:
- DB Time is the total non-idle time across all backends. With 12 backends over a 5-second interval, the maximum is 60,000 ms (12 x 5000).
- CPU* means time when no PostgreSQL wait event was set. This is mostly CPU execution, but may include uninstrumented code paths. The asterisk indicates it is an approximation.
- IO at 13.0% — and you can immediately see that DataFileRead at 10.1%
is the dominant IO event. No need to switch to
system_eventview. - Lock at 8.4% — the sub-event shows it's almost entirely Transaction locks (7.9%), meaning queries are blocked by other uncommitted transactions.
- Small events below 1% of DB Time are hidden to avoid clutter. Use
system_eventview for the full event list. - Activity/Idle is shown separately and excluded from DB Time. Idle backends contribute nothing to DB Time.
Multi-window mode
With --window, the time_model view shows side-by-side columns — one per window —
so you can see how wait profiles change across time horizons:
sudo ./pg_wait_tracer --window 5s,1m,5m
════════════════════════════════════════════════════════════════════════════════
pg_wait_tracer — Time Model Backends: 12 Interval: 5s
════════════════════════════════════════════════════════════════════════════════
Stat Name Last 5s % DB Last 1m % DB Last 5m % DB
-------------------------------- --------- ------- --------- ------- --------- -------
DB Time 5088.6 100.0% 62340.1 100.0% 312450.8 100.0%
CPU* 1498.6 29.4% 13712.3 22.0% 72312.1 23.1%
IO 862.3 16.9% 12340.5 19.8% 98234.2 31.4%
IO:DataFileRead 621.2 12.2% 9823.1 15.8% 82123.4 26.3%
IO:DataFileWrite 198.4 3.9% 2012.3 3.2% 12345.6 4.0%
LWLock 423.1 8.3% 4923.4 7.9% 21234.5 6.8%
LWLock:WALInsert 312.3 6.1% 3812.1 6.1% 16234.2 5.2%
(Activity/Idle) 12560.4 - 62340.1 - 312450.8 -
Reading this output:
- Each column shows the delta for that time window. "Last 5s" is the most recent 5 seconds, "Last 5m" covers the last 5 minutes.
- Compare columns to spot trends: IO rising from 16.9% (5s) to 31.4% (5m) means IO has been decreasing recently — the system is improving.
- Progressive population: On startup, shorter windows fill first. Longer windows
show
-until enough history accumulates (e.g., "Last 5m" needs 5 minutes of data). - The first window must equal the
--intervalvalue. Windows must be increasing. - Without
--window, the default single-column cumulative view is shown (see above).
system_event
Top wait events across all backends, ranked by total duration. Use this to identify which specific events consume the most time.
Columns:
| Column | Description |
|---|---|
| Wait Event | Event name as Class:Event (e.g. IO:DataFileRead) or CPU* |
| Total Waits | Number of occurrences |
| Total (ms) | Cumulative duration |
| Avg (us) | Average duration per occurrence |
| Max (us) | Longest single occurrence |
| % DB | Fraction of DB Time |
Example:
════════════════════════════════════════════════════════════════════════════════
pg_wait_tracer — System Events (cumulative) Backends: 12
════════════════════════════════════════════════════════════════════════════════
Wait Event Total Waits Total (ms) Avg (us) Max (us) % DB
────────────────────────────────────────────────────────────────────────────────────────
CPU* 43460 5498.6 126.5 10630.2 21.9%
IO:DataFileRead 8234 3262.8 396.2 45623.1 13.0%
Lock:Transaction 567 2104.3 3712.2 892100.5 8.4%
LWLock:WALInsert 4812 1823.2 378.9 8934.2 7.3%
Client:ClientRead 1203 882.1 733.1 25410.3 —
Timeout:PgSleep 3 450.0 150000.0 200012.1 1.8%
IO:DataFileWrite 1056 312.4 295.8 5612.0 1.2%
LWLock:BufferContent 892 198.7 222.8 3451.0 0.8%
Reading this output:
- Events are sorted by
Total (ms), so the top rows are the biggest bottlenecks. - High Avg with low count (like
Lock:Transaction) means long individual waits — a few queries are blocking. - Low Avg with high count (like
LWLock:WALInsert) means frequent short waits — high concurrency on the WAL. - Large Max vs Avg gap suggests outlier events. Use
histogramview to see the full distribution.
Multi-window mode
With --window, the system_event view shows vertically stacked sections — one per
window — so you can see how the top events change across time horizons:
sudo ./pg_wait_tracer --view system_event --window 5s,1m,5m
════════════════════════════════════════════════════════════════════════════════
pg_wait_tracer — System Events Backends: 12 Interval: 5s
════════════════════════════════════════════════════════════════════════════════
---- Last 5s -----------------------------------------------------------------
Wait Event Total Waits Total (ms) Avg (us) % DB
-------------------------- ------------ -------------- ---------- ---------
CPU* 4346 549.9 126.5 21.9%
IO:DataFileRead 823 326.3 396.2 13.0%
Lock:transactionid 57 210.4 3712.2 8.4%
...
---- Last 1m -----------------------------------------------------------------
Wait Event Total Waits Total (ms) Avg (us) % DB
-------------------------- ------------ -------------- ---------- ---------
CPU* 52143 6598.7 126.5 22.0%
IO:DataFileRead 9882 3915.4 396.2 13.0%
...
---- Last 5m -----------------------------------------------------------------
(waiting for data)
Reading this output:
- Each section shows the delta for that time window. Events are sorted by total duration within each section, so the ranking can differ between windows.
- Max (us) is not shown in multi-window mode because delta snapshots track cumulative count and total — not per-window maximums. Avg (us) is still valid.
- Shorter windows fill first. Longer windows show "(waiting for data)" until enough history accumulates.
- Without
--window, the default single-column cumulative view is shown (see above), which includes the Max (us) column.
session_event
Per-backend summary. Shows each backend's DB Time, CPU/Wait ratio, and top
wait event. Use --pid-filter to drill into a specific backend.
Columns (summary):
| Column | Description |
|---|---|
| PID | Backend process ID |
| Type | Backend type (client, bgwriter, checkpointer, walwriter, etc.) |
| User | Connected user (or -) |
| DB | Database (or -) |
| DB Time(ms) | Non-idle time for this backend |
| CPU% | Time spent on CPU |
| Wait% | Time spent waiting |
| Top Wait | Highest-duration wait event |
Example:
════════════════════════════════════════════════════════════════════════════════
pg_wait_tracer — Session Summary Backends: 12
════════════════════════════════════════════════════════════════════════════════
PID Type User DB DB Time(ms) CPU% Wait% Top Wait
──────────────────────────────────────────────────────────────────────────────────
12345 client app mydb 4096.2 42.1% 57.9% IO:DataFileRead
12346 client app mydb 3820.5 38.4% 61.6% Lock:Transaction
12347 client admin mydb 1024.0 71.2% 28.8% LWLock:WALInsert
12400 bgwriter - - 512.3 62.1% 37.9% IO:DataFileWrite
12401 checkpointer - - 256.1 15.3% 84.7% IO:DataFileSync
12402 walwriter - - 128.0 44.2% 55.8% IO:WALWrite
With --pid-filter 12345, an additional detail table appears below the
summary, showing the full event breakdown for that backend (same columns as
system_event).
Reading this output:
- CPU% + Wait% = 100% for each backend's active time.
- A backend with high Wait% and
Lock:Transactionas top wait is blocked by another transaction — check for long-running queries or deadlocks. - System processes (bgwriter, checkpointer, walwriter) normally show IO waits.
active
A "top-like" view of currently active backends, showing each backend's current state, wait event, and cumulative DB Time. This is the first view a DBA opens to see what's happening right now.
sudo ./pg_wait_tracer --view active sudo ./pg_wait_tracer --view active --sort db_time
Columns:
| Column | Description |
|---|---|
| PID | Backend OS process ID |
| State | on cpu, waiting, or idle (from BPF tracing state) |
| Wait Event | Current wait event name (if waiting), — otherwise |
| Wait (ms) | Duration in current wait state, — if not waiting |
| DB Time (ms) | Cumulative non-idle time for this backend |
| Backend Type | From /proc/PID/cmdline parsing |
Example:
════════════════════════════════════════════════════════════════════════════════
pg_wait_tracer — Active Sessions Backends: 12 Uptime: 32m 15s
════════════════════════════════════════════════════════════════════════════════
PID State Wait Event Wait (ms) DB Time (ms) Backend Type
------- ---------- ------------------------ ------------ -------------- ------------------
34521 waiting Lock:Transaction 8923.1 12450.3 client
34587 waiting IO:DataFileRead 3.2 8234.1 client
34602 on cpu — — 5123.4 client
34534 waiting LWLock:WALInsert 0.8 4892.1 client
34498 waiting Client:ClientRead 1234.5 3421.2 client
34612 idle — — — client
34701 idle — — — autovac_launcher
34702 idle — — — walwriter
Sorting (--sort flag):
| Mode | Description |
|---|---|
wait_time |
Sort by current wait duration, longest first (default) |
db_time |
Sort by cumulative DB Time, highest first |
pid |
Sort by PID ascending |
event |
Sort by wait event name alphabetically |
Reading this output:
- waiting means the backend is blocked on a wait event. The Wait (ms) column
shows how long it has been stuck. A backend waiting on
Lock:Transactionfor 8923 ms is blocked by another transaction. - on cpu means no PostgreSQL wait event is set — the backend is executing.
- idle means the backend is in an Activity wait (waiting for a new query). Idle backends contribute no DB Time.
- The active view requires live BPF — it is not available in replay mode.
histogram
Latency distribution for a single event, using 16 log2 buckets from <1 us
to >=16 ms. Requires the --event flag.
Columns:
| Column | Description |
|---|---|
| Bucket(us) | Duration range in microseconds |
| Waits | Count of occurrences in this bucket |
| % Waits | Percentage of total occurrences |
| Cumulative | Running cumulative percentage |
| (bar) | ASCII visualization (each # ~ 2%) |
Example:
sudo ./pg_wait_tracer --view histogram --event IO:DataFileRead
════════════════════════════════════════════════════════════════════════════════
pg_wait_tracer — Event Histogram
════════════════════════════════════════════════════════════════════════════════
Event: IO:DataFileRead | Total Waits: 8234 | Total: 3262.8 ms
Bucket(us) Waits % Waits Cumulative
────────────────────────────────────────────────────────────────────────────
<1 123 1.5% 1.5% #
1- 2 456 5.5% 7.0% ###
2- 4 1832 22.3% 29.3% ###########
4- 8 2105 25.6% 54.8% #############
8- 16 1543 18.7% 73.6% #########
16- 32 987 12.0% 85.5% ######
32- 64 534 6.5% 92.0% ###
64-128 312 3.8% 95.8% ##
128-256 178 2.2% 98.0% #
256-512 89 1.1% 99.0%
512-1K 42 0.5% 99.5%
1K- 2K 18 0.2% 99.7%
2K- 4K 8 0.1% 99.8%
4K- 8K 4 0.0% 99.9%
8K-16K 2 0.0% 99.9%
>=16K 1 0.0% 100.0%
Reading this output:
- A tight peak in low buckets (2-16 us) means reads are hitting the OS page cache — fast.
- A bimodal distribution with peaks at both low and high buckets means some reads hit cache while others go to disk.
- A heavy tail (significant counts in 1K+ us buckets) indicates storage latency spikes — check disk I/O saturation.
Multi-window mode shows side-by-side columns per window, without the ASCII bar and cumulative columns.
query_event
Wait events grouped by PostgreSQL query ID. Shows which queries cause which
waits. Requires compute_query_id = on (or auto) in postgresql.conf.
PG13: the sampled tier has no in-core numeric query ID. It strips comments and literal constants from
pg_stat_activitytext and emits a versioned (pg13-synth-v1) database/user-scoped synthetic grouping key. That key is not apg_stat_statements.queryidand cannot be joined to pgss. During full or escalated capture, statements that start after probe attachment use the real pgss-populated numeric ID; an already-running straddler keeps its synthetic key and exposesattribution_quality: "pg13-synth-v1"from the durable sourced text sidecar until the next statement edge. PG14–18 sample the native numeric ID.Sampled PG14–18 text is resolved asynchronously from the extension's actual schema via
pg_stat_statements(showtext => true). It is normalized and may be absent when the extension is unavailable, permission-restricted, evicted, or has not recorded the statement yet. Exact/full raw first-seen text takes precedence if both sources exist.
Three modes are available depending on flags:
Mode A (default) — Top query-event combinations
Shows all query/event pairs sorted by total duration. This is the starting point for identifying which queries contribute most to wait time.
query_id Wait Event Total Waits Total (ms) Avg (us) Max (us) % DB
─────────────────────────────────────────────────────────────────────────────────────────────────────────────
5678234567890123 IO:DataFileRead 2340 1024.5 437.8 45623.1 4.1%
1234567890123456 Lock:Transaction 145 892.3 6153.8 892100.5 3.6%
Cross-reference a native or exact numeric query_id with
pg_stat_statements (not a PG13 pg13-synth-v1 key):
SELECT query FROM pg_stat_statements WHERE queryid = 5678234567890123;
Mode B (--event) — Top queries for a specific event
Answers: "Which queries are responsible for this wait event?"
sudo ./pg_wait_tracer --view query_event --event IO:DataFileRead
Mode C (--query-id) — Wait profile for one query
Answers: "What does this query spend its time waiting on?"
sudo ./pg_wait_tracer --view query_event --query-id 5678234567890123
All three modes support --window with vertically stacked sections per window.
Advanced Analysis (Web Client)
The web client (pgwt) provides additional analysis views beyond the CLI.
These all need exact transition data (old→new ordering, real overlap
intervals), so they are full-fidelity only: over a sampled-only window they
show a "requires full-fidelity data — escalate to capture" state rather than an
empty or misleading result.
Transitions (Sankey Diagram)
Visualizes wait event state transitions as a Sankey flow diagram. Shows which events commonly follow each other (e.g., CPU → IO:DataFileRead → CPU).
The server also computes per-query fingerprints — compact signatures like
IO:30%|CPU:22%|LWLock:21%|Lock:13% that characterize each query's wait profile.
Useful for detecting query plan changes (same query_id, different fingerprint).
Concurrency Peaks + Burst Detection
Overlaid on the AAS chart as a dashed "Peak Concurrency" line showing the maximum number of sessions simultaneously in the same wait state per time bucket.
Burst markers (red triangles) highlight moments when 4+ sessions entered the same wait event within 10ms — "thundering herd" detection.
Lock Chains
Infers waiter→blocker relationships from trace data. For each Lock-class wait,
identifies which other PID was most likely holding the lock (on CPU during the
same time interval). Available via lock_chains server endpoint.
Interference Scoring
Scores PID pairs by how much time they spend waiting on the same event
simultaneously. High-scoring pairs are "noisy neighbors" contending on the
same resources. Available via interference server endpoint.
Trace File Format
When --trace-dir is specified, the daemon writes raw events to disk in a
columnar, LZ4-compressed format (trace format v2: typed blocks — exact
TRANSITIONS from the watchpoint tier, SAMPLES from the always-on sampler).
The full byte-level spec — endianness/packing, block types and columnar
layouts, the footer, the current.trace.meta committed-block protocol, and
the durability guarantees — lives in
docs/TRACE_FORMAT.md.
File lifecycle
- Active file:
current.trace— being written. Readable while live via thecurrent.trace.metahigh watermark (this is howpgwt-serverserves the current hour). - Rotation: every calendar hour the current file is finalized (footer
written, fsynced) and renamed to
YYYY-MM-DD_HH.trace.lz4. If that name already exists (restart within the hour, DST fold), a collision-safeYYYY-MM-DD_HH.N.trace.lz4is used — an existing archive is never clobbered. - Crash recovery: on startup, a leftover
current.trace/current.summary(crash,kill -9, or previous clean shutdown) is finalized — torn tail dropped, footer appended — and archived under a collision-safe name. Existing data is never truncated: a daemon crash loses at most the unflushed tail (< 1 block of transitions, < ~1 s of samples), never a committed hour. - Durability: archives are fsynced at rotation/recovery. The in-progress hour is committed to the OS page cache per block (survives daemon death; an OS crash / power loss can lose it). See docs/TRACE_FORMAT.md for the measured trade-off.
- Retention: archives older than
--trace-retentionhours are deleted, and--retention-gbadditionally caps total directory size (oldest archives deleted first). Cleanup runs every 60 ticks. - Block size: 4096 events per block; SAMPLES ticks are batched into ~1 s blocks. Blocks are flushed when full, on batch timeout, and on rotation.
- Compression ratio: typical ~36x (36 bytes/event raw, ~1 byte compressed).
PII note: with recording enabled,
query_texts.jsonlcan store raw SQL from full/exact capture (inline literals included) as well as normalized pg_stat_statements/PG13-synthetic text.query_sources.jsonlrecords PG13 synthetic keys, andbackends.jsonlstores usernames, database names and client addresses. The files get the trace files'0640/--trace-grouppermissions; there is no redaction option. Treat the trace directory as sensitive.
Wait Event Classes
| Class | Description | Included in DB Time |
|---|---|---|
| CPU* | No wait event set (mostly CPU execution, see note below) | Yes |
| IO | Storage I/O: reads, writes, syncs, extends | Yes |
| LWLock | Lightweight locks: WAL, buffers, proc array, etc. | Yes |
| Lock | Heavy locks: row, table, transaction, advisory | Yes |
| BufferPin | Waiting to pin a shared buffer | Yes |
| Client | Waiting for client: reading query, sending results | Partial† |
| IPC | Inter-process communication: parallel workers, replication | Yes |
| Timeout | Timed waits: pg_sleep(), statement timeouts |
Yes |
| Extension | Extension-generated events (e.g. pg_wait_sampling) | Yes |
| Activity | Idle states: waiting for next query | No (hidden) |
† Client is included except Client:ClientRead, which is treated as idle
(a backend waiting for the next query — Oracle's "SQL*Net message from client")
and excluded from DB Time / AAS. Unlike the Activity class, Client:ClientRead
stays visible in event lists and graphs — only its % DB shows —. Only
the Activity class is hidden entirely.
Understanding DB Time
DB Time is the total non-idle wall-clock time across all backends:
DB Time = CPU*
+ IO
+ LWLock
+ Lock
+ BufferPin
+ Client (except Client:ClientRead)
+ IPC
+ Timeout
+ Extension
Idle events are explicitly excluded: the entire Activity class and
Client:ClientRead (a backend waiting for the next query). A backend
sitting idle between queries does not contribute to DB Time.
With N backends over an interval of T seconds, the theoretical maximum DB Time
is N x T x 1000 ms. For example, 12 backends over 5 seconds = 60,000 ms max.
If DB Time is much lower than this maximum, most backends are idle.
CPU* is the time when wait_event_info = 0 (NULL) in PostgreSQL — no wait
event was set. This is mostly CPU execution time, but may also include code
paths that PostgreSQL does not instrument with wait events. The asterisk is
a reminder that this is not a precise CPU measurement.
CPU% = CPU* / DB Time x 100
A healthy OLTP workload typically shows CPU% between 30-70%. Below 30% means the system is wait-bound. Above 80% on a saturated system means CPU is the bottleneck.
Interpreting Results
High IO%
Storage is the bottleneck. Check which IO events dominate:
- DataFileRead — Sequential or index scans reading from disk. Consider
adding indexes, increasing
shared_buffers, or using faster storage. - DataFileWrite — Dirty page writes. May indicate checkpoint pressure
(
checkpoint_completion_target,max_wal_size). - DataFileSync — fsync calls. Check
wal_sync_methodand storage write cache settings. - WALWrite/WALSync — WAL is the bottleneck. Consider faster WAL storage
or tuning
wal_buffers.
PG18 asynchronous I/O caveat. Under PG18's default
io_method=worker, a single logical read is split across processes: the requesting backend showsIO:AioIoCompletion(or no wait at all if the read completed in the background), while the actualIO:DataFileReadhappens on an io_worker that carries no requesting-query context. As a result, per-query I/O attribution is partial underworker— the read latency is captured, but it cannot always be tied back to the originating query. This is a known limitation; system-wide IO totals remain correct.
High Lock%
Transaction-level contention. Common causes:
- Lock:Transaction — Waiting for another transaction to commit/rollback. Look for long-running transactions holding row locks.
- Lock:Relation — Table-level lock conflicts (DDL vs DML).
- Lock:Tuple — Row-level lock contention. Multiple sessions updating the same rows.
High LWLock%
Internal PostgreSQL contention:
- WALInsert — Many concurrent sessions writing WAL. Normal under heavy
write load. Consider
wal_bufferstuning. - BufferContent — Contention on shared buffer pages. May indicate hot pages.
- LockManager — Lock table contention under very high concurrency.
High Client%
The application is slow to consume results or send queries:
- ClientRead — Backend is waiting for the client to send the next query. If this is high during active workload, check network latency or connection pooler configuration.
- ClientWrite — Backend is waiting for the client to accept result data. The client may be processing results too slowly.
High IPC%
Inter-process communication waits, typically from parallel query:
- ParallelWorkerSync/ParallelQueryDSM — Parallel workers synchronizing.
Normal during parallel queries. If excessive, consider tuning
max_parallel_workers_per_gather.
Using Histogram
The histogram view reveals latency distribution patterns:
- Unimodal peak at low values (2-16 us for IO) — reads from OS page cache, healthy.
- Bimodal (peaks at both low and high buckets) — mix of cache hits and disk reads. The ratio shows effective cache hit rate.
- Heavy right tail (many events in ms+ buckets) — storage latency problems, possible I/O saturation.
- Uniform spread — unpredictable latency, possible noisy-neighbor or swap.
Using query_event
Workflow for finding problematic queries:
- Start with Mode A (default) to identify the top query/event combinations:
sudo ./pg_wait_tracer --view query_event
- Look up the SQL in pg_stat_statements:
SELECT queryid, calls, mean_exec_time, query FROM pg_stat_statements ORDER BY total_exec_time DESC;
- Drill down with Mode B to see which queries are responsible for a specific
bottleneck event:
sudo ./pg_wait_tracer --view query_event --event IO:DataFileRead
- Or use Mode C to get the full wait profile of a specific query:
sudo ./pg_wait_tracer --view query_event --query-id 5678234567890123
- Correlate: a query with high
IO:DataFileReadtime may need better indexes; a query with highLock:Transactiontime is hitting contention.
Testing
The test suite has 5 layers. Running the full suite requires root access and a running PostgreSQL instance. Individual layers can be run independently.
Quick start
# Run everything (root + running PG required for integration tests) sudo tests/run_all.sh # Auto-detect a specific PG version when multiple are installed sudo tests/run_all.sh --pg-version 18
Test layers
| Layer | Tests | Requires | What it proves |
|---|---|---|---|
| C unit tests | test_wait_event, test_cmdline, test_bucket |
make -C tests |
Event ID parsing, CLI parser, histogram buckets |
| Synthetic data | test_data_*.py (11 suites) |
pgwt-server + gen_test_traces |
Compute math: time model, events, sessions, queries, filters, idle, edge cases, transitions, fingerprints, lock chains, interference (91 checks, 0% tolerance) |
| CLI integration | test_cli.sh, test_lifecycle.sh |
Root + PG | CLI flags, daemon lifecycle |
| Live correctness | test_accuracy.py, test_percentage.py, test_aas_accuracy.py, etc. (14 suites) |
Root + PG + pgbench | BPF → file → compute pipeline against real workloads |
| Web UI | test_web_ui.py |
Python + playwright + websockets | 108 checks: tabs, tables, sorting, drill-down, breadcrumbs, Sankey transitions, concurrency overlay, auto-refresh, reconnection |
| Performance | bench_server.py + gen_bench_traces |
pgwt-server |
Compute throughput: 10.8M events/sec on 10M event trace |
The blocking sampled-overhead A/B gate, its shared-runner noise data, and the
dedicated-box precision profile are documented in
docs/SAMPLED_OVERHEAD_GATE.md.
Running individual layers
# C unit tests (no root needed) make -C tests tests/test_wait_event tests/test_cmdline tests/test_bucket # Synthetic data tests (no root, no PG — needs pgwt-server built) make # builds pgwt-server make -C tests # builds gen_test_traces python3 tests/test_data_time_model.py python3 tests/test_data_aas.py # ... etc. # Live integration tests (root + running PG) sudo python3 tests/test_accuracy.py sudo python3 tests/test_aas_accuracy.py --pid <POSTMASTER_PID> # Web UI tests (no root, no PG, no SSH) python3 tests/test_web_ui.py
Web UI test setup
The web UI tests use a mock server (tests/mock_server.py) that serves
static files and canned WebSocket responses — no database or pgwt binary
needed.
Dependencies (install on the test machine only):
pip install playwright websockets playwright install chromium
What it tests:
- Page load and WebSocket connection
- All 11 tabs, including Waterfall, Scatter, and Matrix
- Summary bar metrics (DB Time, AAS, CPUs)
- Table rendering with correct row counts and data
- Column sorting with direction indicators
- Drill-down flow: Overview → Events → Queries (with breadcrumb)
- Breadcrumb navigation and filter clear
- Session → Timeline drill-down
- Query → Events drill-down
- Query → Waterfall and Scatter → selected Waterfall execution pivots
- Waterfall row selection, resident-data drag zoom, double-click restore, and bar inspection; Scatter 2-D box selection; Matrix target-event drill
- Structured full-fidelity refusal panels and URL deep links for all three exact-only B6 tabs
- Time picker and zoom out
- ECharts canvas rendering (AAS chart, heatmap, timeline)
- WebSocket reconnection after server restart
Running:
python3 tests/test_web_ui.py
The mock server starts automatically on port 18799 (HTTP) and 18800 (WS), then
shuts down. Override the isolated port pair with PGWT_TEST_PORT=18975.
Pure web builders run without a browser:
node --test
Visual baselines are selector-scoped. Regenerate only the B6 panes, their gallery cells, and the Queries table changed by its new Waterfall affordance:
python3 tests/test_web_ui_snapshots.py --update-snapshots \
--only=table_queries,waterfall_execution,execution_scatter,transition_matrix,\
gallery/waterfall-dense-plan-3lanes,gallery/exec-scatter-dense-downsampled,\
gallery/matrix-dense-top20,gallery/table-configs-queries-hostile-sql
# Compare the same reviewed baseline set without rewriting it:
python3 tests/test_web_ui_snapshots.py \
--only=table_queries,waterfall_execution,execution_scatter,transition_matrix,\
gallery/waterfall-dense-plan-3lanes,gallery/exec-scatter-dense-downsampled,\
gallery/matrix-dense-top20,gallery/table-configs-queries-hostile-sqlBaselines live in tests/web_snapshots/; deterministic gallery data lives in
web/static/dev/fixtures/ with Node re-export shims in tests/fixtures/.
How It Works
pg_wait_tracer captures the same field — each backend's wait_event_info —
two ways, and the default tiered mode uses both:
Sampled tier (always-on, default). A userspace sampler reads each backend's
wait_event_info directly with process_vm_readv at a fixed rate
(--sample-rate, default 10 Hz). There is no watchpoint, no debug exception,
and nothing injected into PostgreSQL — the cost falls entirely on the daemon
(~0.6% of one core at 10 Hz), so PostgreSQL sees ~0% impact. This produces
ASH-style estimates of where time is spent. The estimates are accurate: at
10 Hz the per-event time share agrees with the exact tier to within ~1
percentage point and the top-5 events match (see Cross-validation below).
Full tier (exact, during escalation). A CPU hardware debug register
(watchpoint) traps every write to wait_event_info in each backend. PostgreSQL
updates this field on every wait event transition (entering a wait, leaving a
wait, changing wait type), so this captures every transition exactly — at the
cost of a CPU debug exception per write (see Performance). In
tiered mode this runs only during bounded escalation windows; --mode full
runs it always-on.
When the watchpoint fires, a BPF program runs in kernel context:
- Reads the previous and new wait event values
- Computes the duration of the previous state using
bpf_ktime_get_ns() - Emits a raw event to a BPF ring buffer (timestamp, pid, old/new event, duration, query_id)
- Updates per-backend state for the next transition
Userspace consumes ring buffer events and accumulates them into per-view
statistics. When --trace-dir is enabled, events are also written to disk
in columnar LZ4-compressed trace files.
The server (pgwt-server) reads trace files and computes all views using
O(n) hash-table-based algorithms (10.8M events/sec throughput). Immutable
.trace.lz4 files are cached per session; current.trace (the file being
written by the daemon) is read with a streaming block reader for live mode.
This design means:
- No PostgreSQL patches or extensions required — works with stock binaries
- ~0% impact by default — the always-on sampled tier never touches a backend
- Exact when you need it — escalate to the full tier to capture every transition (histograms, transitions, lock chains) during a bounded window
- No lock contention — ring buffer is lock-free
- Historical analysis — trace files enable offline replay of past events
- Live analysis —
current.tracereadable while daemon writes it - Forward-compatible — event names discovered dynamically from PG17+
Performance
Overhead
The default sampled tier has ~0% impact on PostgreSQL. It reads each
backend's wait_event_info from userspace (process_vm_readv) at 10 Hz; the
cost (~0.6% of one core at 10 Hz) falls entirely on the daemon, never on a
backend. The numbers in this section apply to the full hardware-watchpoint
tier — used always-on with --mode full, or only during bounded escalation
windows in the default tiered mode.
The full tier uses CPU hardware debug registers (watchpoints) to trap every
write to wait_event_info. Its overhead is proportional to the wait event
transition rate — how many times per second backends change wait state. The
cost is almost entirely in the CPU's hardware debug exception handler, not in
BPF or userspace processing.
Benchmark environment: Hetzner cx43 (8 vCPU, 16 GB RAM), Rocky 9.7, PostgreSQL 18, pgbench scale 100, 8 clients, 60-second runs, 5 repetitions.
Write-heavy OLTP (pgbench TPC-B, shared_buffers = 2 GB)
Baseline: ~5,000 TPS, ~40K transitions/sec.
| Mode | TPS Overhead |
|---|---|
Full trace (--mode full) |
6% |
Full trace + --skip-query-id |
5% |
--lightweight |
4% |
| Sampled (default tier) | ~0% |
Read-heavy with high buffer miss rate (pgbench SELECT-only, shared_buffers = 128 MB)
Baseline: ~108,000 TPS, ~220K transitions/sec. Data fits in OS page cache so each IO:DataFileRead completes in microseconds, generating a very high transition rate.
| Mode | TPS Overhead |
|---|---|
Full trace (--mode full) |
29% |
Full trace + --skip-query-id |
26% |
--lightweight |
28% |
| Sampled (default tier) | ~0% |
The --lightweight and --skip-query-id flags save only 1-3 percentage points
because the bottleneck is the hardware debug exception itself (~200-300 ns per
fire), not the BPF work done inside it. This worst case is exactly why the full
tier is gated behind escalation by default — you only pay it during the bounded
window you asked for.
Cross-validation: sampling vs exact
Because the default tier is sampled, its accuracy is verified against the exact
tier over the same window. The cross_validate tool compares per-event time
shares both ways and the top-N event overlap. At 10 Hz the worst-case event-
share disagreement is ~0.9 percentage points with 5/5 top-5 events
matching — well inside the test's ±10pp / top-5-≥4 pass gate. This is why
tiered-by-default is trustworthy: the always-on sampler reproduces the exact
profile closely enough to drive investigation, and you escalate only when you
need per-transition detail.
What drives overhead
Overhead scales linearly with transitions/sec. Workloads with more transitions per second see higher overhead:
- Low overhead (<10%): write-heavy OLTP, long-running queries, workloads where data fits in shared_buffers (buffer hits don't trigger wait events)
- Medium overhead (10-20%): mixed read/write with moderate buffer miss rates
- High overhead (20-30%): very high TPS with small shared_buffers relative to working set, where most reads miss shared_buffers but hit OS page cache (fast IO completions = rapid transitions)
Where the cost comes from:
- ~70% — hardware debug exception: CPU enters debug exception handler on every write to the watched address. This is unavoidable — it happens before any BPF code runs. Context switch save/restore of debug registers adds further cost.
- ~25% — lock amplification: the debug exception fires while a backend holds LWLocks or other internal locks. The extra ~200-300 ns extends lock hold times, causing cascading contention under high concurrency.
- ~5% — BPF + userspace: ring buffer output, query_id probe reads, and userspace consumption are a negligible fraction.
Performance tuning flags
| Flag | Effect | Trade-off |
|---|---|---|
--skip-query-id |
Skips 1 bpf_probe_read_user per event (~100-200 ns) |
query_event view unavailable |
--lightweight |
BPF accumulates in per-CPU hash map, no ring buffer | Only time_model and system_event views. No per-PID, per-query, histogram, or trace recording |
These flags provide marginal improvement (1-3 percentage points) because the dominant cost is the hardware debug exception, not BPF processing. They are most useful for write-heavy OLTP where transitions/sec is moderate.
Recommendations:
- Default
tieredmode is appropriate for almost all production use: the always-on sampler costs PostgreSQL ~0%, and you escalate to the full tier on demand (or let anomaly rules do it) when you need exact, per-transition detail. - Use
--mode fullfor a focused investigation where you want every transition captured for the whole run and the overhead is acceptable. - The performance-tuning flags below apply to the full tier. Use
--lightweightwhen you only need time_model and system_event views, or--skip-query-idif you don't need per-query wait attribution. - Monitor full-tier overhead on your workload — if baseline TPS is very high
(>50K) with high buffer miss rates, escalation windows can be costly; keep
--escalation-budgetconservative.
BPF optimizations
The following optimizations are applied automatically:
- PERCPU hash map for lightweight mode accumulator — eliminates atomic operations and cache-line bouncing
BPF_RB_NO_WAKEUP— daemon polls the ring buffer every 10ms instead of being woken per event, eliminating ~75K/seceventfd_signal()callsbpf_ringbuf_output— single BPF helper call instead of reserve+submit (two calls)- Cached
PgBackendStatuspointer — query_id reads use 1probe_readinstead of 2 (double-dereference only on first event per PID) exclude_kernel=1,exclude_hv=1on perf_event — skips kernel/hypervisor writes to reduce false watchpoint fires- Compact state_map (512 entries) — better cache utilization
Future: PostgreSQL-native wait event timing
The hardware watchpoint overhead is fundamentally limited by the CPU debug
exception cost (~200-300 ns per fire). A
proposed PostgreSQL patch
adds Oracle-style internal wait event instrumentation — two VDSO
clock_gettime() calls per event (~70-100 ns total) — achieving near-zero
measurable overhead.
What the patch provides
Compile-time option: ./configure --enable-wait-event-timing (or
meson setup -Dwait_event_timing=true). When compiled without the flag,
the binary is identical to stock PostgreSQL — zero overhead by construction.
When compiled with the flag, two runtime GUCs control the features:
| GUC | Scope | What it enables |
|---|---|---|
wait_event_timing |
PGC_SUSET |
Per-event count, total nanoseconds, max, log2 histogram, per-(query_id, event) attribution |
wait_event_trace |
PGC_USERSET |
Per-session ring buffer of individual wait events (Oracle 10046 equivalent) |
Oracle equivalents achieved:
| Oracle feature | PostgreSQL function |
|---|---|
V$SYSTEM_EVENT (per-event count/time) |
pg_stat_get_wait_event_timing() |
V$EVENT_HISTOGRAM (latency distribution) |
pg_stat_get_wait_event_timing() — histogram column |
V$SESSION_EVENT (per-backend breakdown) |
pg_stat_get_wait_event_timing() — backend_id column |
V$SQL wait stats (per-query attribution) |
pg_stat_get_wait_event_timing_by_query() |
| 10046 trace (per-event stream) | pg_stat_get_wait_event_trace(backend_id) |
Benchmark methodology
All benchmarks: Hetzner cx43 (8 shared vCPU, 16 GB RAM), Rocky 9.7,
PG 19devel built from source, pgbench scale 100, 8 clients, SELECT-only
with shared_buffers = 128 MB (data in OS page cache) — worst case at
~220K wait event transitions/sec, 60-second runs.
Test 1: Compile-time flag overhead — stock PG vs patched (without
--enable-wait-event-timing) on the same data directory, alternating A/B
runs to eliminate warmup effects, 5 rounds:
| Round | Stock TPS | Patched (no flag) TPS |
|---|---|---|
| 1 | 116,398 | 118,427 |
| 2 | 115,850 | 116,609 |
| 3 | 116,141 | 117,669 |
| 4 | 117,699 | 118,136 |
| 5 | 117,634 | 118,879 |
Result: no measurable difference. Hot-path object code is byte-identical
(verified via size and objcopy -O binary -j .text comparison).
Test 2: GUC overhead — same binary (compiled WITH flag), same data directory, toggling GUCs between server restarts, 5 rounds:
| Round | GUC off | timing ON | all ON (timing+trace) |
|---|---|---|---|
| 1 | 114,768 | 116,058 | 115,911 |
| 2 | 116,726 | 115,789 | 116,502 |
| 3 | 115,972 | 115,977 | 115,170 |
| 4 | 116,405 | 113,924 | 115,242 |
| 5 | 115,781 | 116,556 | 114,603 |
| Mean | 115,930 | 115,661 | 115,486 |
Result: < 0.5% difference between GUC off and all features enabled. All three configurations are within run-to-run variance — indistinguishable.
Comparison with hardware watchpoints (from earlier benchmarks, same environment):
| Approach | Worst-case overhead | Per-event cost |
|---|---|---|
| PG patch (all features ON) | < 0.5% | ~70-100 ns (VDSO, no kernel trap) |
| pg_wait_tracer (hardware watchpoint) | 29% | ~200-300 ns (CPU #DB exception) |
The reason the patch overhead is near-zero despite ~220K events/sec: VDSO
clock_gettime(CLOCK_MONOTONIC) reads a memory-mapped kernel page without
leaving userspace — no syscall, no context switch, no trap. Counter updates
hit hot L1 cache lines owned by the backend. Hardware watchpoints, by
contrast, trigger a CPU debug exception on every event, requiring full
kernel entry/exit with pipeline flush and KPTI page table switch.
All 243 PostgreSQL regression tests pass with the patch
(make check — both with and without the compile flag).
When the patch is available, pg_wait_tracer can read accumulated stats
directly instead of using hardware watchpoints. For the investigation
clients (pgwt), hardware watchpoint mode remains available
for full per-event trace recording.
Requirements
- Linux kernel >= 5.8 with BTF, or EL8 (RHEL/Rocky/Alma/Oracle Linux 8.10, kernel 4.18 with backported BTF + BPF ring buffer). EL9 (kernel 5.14) and Ubuntu also supported. The build auto-bundles a pinned libbpf/bpftool when the system ones are too old (e.g. EL8's libbpf 0.5.0).
- PostgreSQL 17 or 18 (full); 13 (supported — query attribution needs
pg_stat_statements); 14-16 not yet (see INSTALL.md) - Root privileges (or CAP_SYS_ADMIN + CAP_SYS_PTRACE)
- x86_64 or aarch64
See INSTALL.md for build dependencies and setup instructions.
License
pg_wait_tracer is released under the PostgreSQL License.

