What goes in a ClickHouse monitoring dashboard in Metabase?
A ClickHouse monitoring dashboard tracks queries per second and duration percentiles alongside the storage-engine numbers that actually decide them — active parts, merge backlog, and replication delay. Metabase connects to ClickHouse natively, so every card is plain SQL over the cluster's own system tables — no exporter or metrics agent in between.
For: the team running ClickHouse as a serving layer, and whoever gets paged when p99 moves. Grain: one row per query, plus point-in-time engine state.
Source:
system.query_log, system.parts, system.merges, system.replicas (query_log TTL is typically 30 days — roll up nightly for more).
What does a ClickHouse monitoring dashboard look like?
Here’s the layout this guide builds. Cluster-level numbers sit at the top; the middle section is what clients experience — throughput, latency percentiles, read volume, and failures; the bottom section is the engine underneath, where parts, merges, and replication explain why the middle section moved.

An example ClickHouse monitoring dashboard in Metabase, built from the cluster’s system tables. Figures are illustrative.
Which cards belong on a ClickHouse monitoring dashboard?
Eight cards, in two halves: what clients see, and what the storage engine is doing. The second half is the one most dashboards omit and the one that explains almost every latency regression.
- Queries per second by kind — SELECT vs. INSERT, by hour (area)
- Query duration — p50, p95, p99 by day (line)
- Data read from disk and rows read per second (combo)
- Failed queries by exception — memory limit, too many parts, timeout (donut)
- Active parts against
parts_to_throw_inserton the busiest partition (line with goal) - Merge backlog — merges running and mutations queued (bar)
- Replication delay by replica (row)
- Top queries by peak memory, with calls, p99, and rows read (table)
What data does the dashboard need?
system.query_log—type,query_duration_ms,read_rows,read_bytes,memory_usage,exception_code,is_initial_query, and the normalized query hash.system.partsfor active part counts per table and partition, plusdata_compressed_bytesanddata_uncompressed_bytesfor the compression ratio.system.mergesandsystem.mutationsfor merges in flight, progress, and anything stuck in the queue.system.replicas—absolute_delay,queue_size,inserts_in_queue,is_readonly— for the replication card.system.asynchronous_metrics,system.metrics, andsystem.errorsfor host-level memory, disk space, cache hit counters, and error totals.- Your configured
parts_to_delay_insertandparts_to_throw_insertvalues, so the parts card has a real ceiling rather than a guessed one.
How do you build it?
- Connect ClickHouse to Metabase with a read-only user that can see the
systemdatabase, and give it a settings profile with modestmax_memory_usageso a monitoring query can never be the thing that takes the cluster down. - Wrap cluster-wide reads in
clusterAllReplicas(...)and always filterWHERE is_initial_query, or distributed queries get counted once per shard. - Schedule a nightly rollup of
query_loginto your own MergeTree table — the log has a TTL, and the trend cards need more history than it keeps. - Build the latency and parts cards first, side by side: they are the pair that turns “the dashboard is slow” into a specific fix. Then add merges, replication, and the memory table.
- Add filters for host or shard, table, and date range, and alert when active parts cross a fraction of
parts_to_throw_insertor a replica’s delay exceeds its budget.
Example card SQL
SELECT
toStartOfHour(event_time) AS hour,
countIf(type = 'QueryFinish') AS queries,
countIf(type IN ('ExceptionBeforeStart',
'ExceptionWhileProcessing')) AS failed,
round(quantile(0.50)(query_duration_ms)) AS p50_ms,
round(quantile(0.95)(query_duration_ms)) AS p95_ms,
round(quantile(0.99)(query_duration_ms)) AS p99_ms,
round(sum(read_rows) / 3600) AS rows_per_sec,
formatReadableSize(sum(read_bytes)) AS bytes_read,
formatReadableSize(max(memory_usage)) AS peak_memory
FROM clusterAllReplicas('default', system.query_log)
WHERE event_time >= now() - INTERVAL 24 HOUR
AND is_initial_query
AND query_kind = 'Select'
GROUP BY hour
ORDER BY hour; Related
Metrics
Integrations
Dashboards
FAQ
What is a ClickHouse monitoring dashboard?
system database. No exporter, no separate time-series store.Which system tables should the cards read?
system.query_log carries the query half: one row per query with type, query_duration_ms, read_rows, read_bytes, memory_usage, exception_code, and the query text. The storage half comes from system.parts (active parts, rows and bytes per part, compressed vs. uncompressed size), system.merges and system.mutations (what is merging right now and what is queued), and system.replicas (absolute_delay, queue_size, inserts_in_queue). system.asynchronous_metrics, system.metrics, and system.events fill in host-level counters — memory, cache hits, disk space — and system.errors gives a running count per error code.Why do active parts deserve their own card?
parts_to_delay_insert (default 1,000) ClickHouse starts slowing inserts down; at parts_to_throw_insert (default 3,000) it rejects them with TOO_MANY_PARTS. Charting active parts on the busiest partition against your configured ceiling turns a sudden outage into two weeks of warning. The usual causes are small, frequent inserts (batch them, or use async inserts) and too many partitions — monthly partitioning is right far more often than daily.Why does read latency rise when merges back up?
SELECT has to read every active part that matches the query's partitions and primary-key range, so more parts means more files, more marks, and more merge work competing for the same disk and CPU. That is why the merge-backlog card sits beside the latency card in this dashboard: in the example p99 doubles from 190ms to 412ms while merges in flight go from 6 to 18, with no increase in query volume at all. If you see latency climbing and throughput flat, look at parts and merges before you look at the queries.How do I query system.query_log on a multi-node cluster?
clusterAllReplicas('your_cluster', system.query_log), because each node only logs its own queries, and filter WHERE is_initial_query so a distributed query is counted once rather than once per shard. Skipping the second is the classic mistake: query counts inflate by roughly the shard count and duration percentiles get dragged down by the fast per-shard sub-queries. Note also that query_log is written asynchronously — rows appear after flush_interval_milliseconds, 7.5 seconds by default — so the “last minute” is always slightly incomplete.Does keeping query_log around cost anything?
system.query_log ships with a TTL — typically 30 days — and older rows disappear. If you want quarter-over-quarter trends, schedule a nightly rollup into your own MergeTree table with the aggregates the dashboard needs, and point the trend cards at that. It also keeps the dashboard cheap: a card that re-scans raw query_log on every refresh is itself a query in query_log, and on a busy cluster that table is not small.