Observability data is some of the highest-volume data a production system generates. A modest microservices platform might produce millions of log lines per minute, thousands of metric data points per second, and hundreds of distributed traces per request. Most teams eventually hit the wall with Elasticsearch: high memory requirements, expensive storage, slow aggregations, and a query language that punishes complex analytics.
ClickHouse is a columnar OLAP database that changes the economics entirely. It ingests billions of rows per second, compresses data 10-50x better than row-oriented stores, and executes analytical queries that would take minutes in Elasticsearch in milliseconds. Grafana, Signoz, Highlight.io, and Cloudflare all run ClickHouse as their observability backbone.
This guide builds a complete ClickHouse-based observability stack from scratch.
Why ClickHouse for Observability
The Columnar Advantage
Row-oriented databases (Postgres, MySQL) store each row together on disk. When you query SELECT avg(duration_ms) FROM traces WHERE service = 'api', the database reads every column of every row that matches — including trace_id, span_id, parent_id, attributes, and dozens of other fields you don’t need.
ClickHouse stores each column separately. The same query reads only duration_ms and service columns off disk. For wide tables with 50+ columns, this means reading 2-5% of the data a row store would. The speedup is multiplicative: less I/O, better CPU cache utilization, and dramatically better compression (similar values compress better together).
Compression That Actually Works
Logs are highly repetitive: the same hostnames, service names, log levels, and message patterns appear millions of times. ClickHouse’s LZ4 and ZSTD codecs combined with delta encoding and dictionary compression routinely achieve 10-20x compression ratios on log data.
A log pipeline generating 100GB/day uncompressed lands around 5-10GB/day stored. That’s the difference between a 10TB Elasticsearch cluster and a 500GB ClickHouse node.
The MergeTree Engine
Everything in ClickHouse runs on variants of the MergeTree engine. Data is written in parts, sorted by a primary key, and merged in the background. For observability:
ReplacingMergeTree: deduplicates rows with the same key — useful for metrics that get re-sent
SummingMergeTree: auto-aggregates numeric columns during merges — perfect for counters
AggregatingMergeTree: stores partially aggregated states, enabling incremental materialized views
TTL: automatic data expiration without manual housekeeping jobs
Deploying ClickHouse
Single Node with Docker Compose
For a homelab or small production deployment handling up to ~50GB/day of observability data:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
|
# docker-compose.yml
version: '3.8'
services:
clickhouse:
image: clickhouse/clickhouse-server:24.3-alpine
container_name: clickhouse
ports:
- "8123:8123" # HTTP interface
- "9000:9000" # Native protocol (for clients and replication)
volumes:
- clickhouse-data:/var/lib/clickhouse
- clickhouse-logs:/var/log/clickhouse-server
- ./config/clickhouse/users.xml:/etc/clickhouse-server/users.xml
- ./config/clickhouse/config.xml:/etc/clickhouse-server/config.d/custom.xml
environment:
CLICKHOUSE_DB: observability
CLICKHOUSE_USER: default
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
ulimits:
nofile:
soft: 262144
hard: 262144
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8123/ping"]
interval: 10s
timeout: 5s
retries: 5
# ClickHouse Keeper (replaces ZooKeeper for coordination)
# Only needed for replicated setups — skip for single node
grafana:
image: grafana/grafana:10.4.0
container_name: grafana
ports:
- "3000:3000"
environment:
GF_SECURITY_ADMIN_PASSWORD: changeme
GF_INSTALL_PLUGINS: grafana-clickhouse-datasource
volumes:
- grafana-data:/var/lib/grafana
- ./config/grafana/provisioning:/etc/grafana/provisioning
depends_on:
clickhouse:
condition: service_healthy
vector:
image: timberio/vector:0.39.0-alpine
container_name: vector
volumes:
- ./config/vector/vector.toml:/etc/vector/vector.toml:ro
- /var/log:/var/log:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
depends_on:
clickhouse:
condition: service_healthy
volumes:
clickhouse-data:
clickhouse-logs:
grafana-data:
|
ClickHouse Configuration
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
<!-- config/clickhouse/config.xml -->
<clickhouse>
<!-- Allow external connections -->
<listen_host>0.0.0.0</listen_host>
<!-- Memory settings -->
<max_server_memory_usage_to_ram_ratio>0.8</max_server_memory_usage_to_ram_ratio>
<!-- MergeTree defaults optimized for observability workloads -->
<merge_tree>
<!-- Write parts are smaller — better for high-ingest workloads -->
<min_rows_for_wide_part>0</min_rows_for_wide_part>
<min_bytes_for_wide_part>0</min_bytes_for_wide_part>
</merge_tree>
<!-- Disable query complexity limits for analytics -->
<max_query_size>1073741824</max_query_size>
</clickhouse>
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
<!-- config/clickhouse/users.xml -->
<clickhouse>
<users>
<default>
<password>your_password_here</password>
<networks>
<ip>::/0</ip>
</networks>
<profile>default</profile>
<quota>default</quota>
</default>
<readonly>
<password>readonly_password</password>
<networks>
<ip>::/0</ip>
</networks>
<profile>readonly</profile>
<quota>default</quota>
</readonly>
</users>
<profiles>
<default>
<max_memory_usage>10000000000</max_memory_usage>
<use_uncompressed_cache>0</use_uncompressed_cache>
<load_balancing>random</load_balancing>
</default>
<readonly>
<readonly>1</readonly>
</readonly>
</profiles>
</clickhouse>
|
Designing the Schema
Schema design is where ClickHouse observability lives or dies. The key decisions are ordering key (the physical sort order), partition key (how data is split across files), and TTL (when data expires).
Logs Table
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
|
CREATE DATABASE IF NOT EXISTS observability;
CREATE TABLE observability.logs
(
-- Temporal fields first — always filter by time
timestamp DateTime64(9, 'UTC'),
timestamp_date Date DEFAULT toDate(timestamp),
-- Identity
service LowCardinality(String),
environment LowCardinality(String),
host LowCardinality(String),
container_id String,
container_name LowCardinality(String),
-- Log body
level LowCardinality(String),
message String,
-- Structured fields stored as JSON map for flexibility
attributes Map(String, String),
-- Trace correlation
trace_id FixedString(32),
span_id FixedString(16),
-- Indexing hints
severity_number UInt8
)
ENGINE = MergeTree()
PARTITION BY (toYYYYMM(timestamp), service)
ORDER BY (service, level, timestamp)
TTL timestamp_date + INTERVAL 30 DAY
DELETE,
timestamp_date + INTERVAL 7 DAY
TO VOLUME 'cold'
SETTINGS
index_granularity = 8192,
ttl_only_drop_parts = 1;
-- Bloom filter index for fast full-text search on message
ALTER TABLE observability.logs
ADD INDEX idx_message message TYPE tokenbf_v1(32768, 3, 0)
GRANULARITY 4;
-- Bloom filter for trace correlation
ALTER TABLE observability.logs
ADD INDEX idx_trace_id trace_id TYPE bloom_filter(0.001)
GRANULARITY 4;
|
Key decisions explained:
LowCardinality(String): for fields with fewer than 10,000 unique values (service names, log levels, environments). ClickHouse builds a dictionary, dramatically improving compression and query speed.
FixedString(32): trace IDs are always 32 hex characters. Fixed-length strings compress better than String.
Map(String, String): stores arbitrary structured fields (JSON attributes) without schema changes. Query with attributes['key'].
PARTITION BY toYYYYMM: each month is a separate set of files. Old partitions can be dropped instantly without affecting queries.
ORDER BY (service, level, timestamp): most queries filter by service first, then maybe level, then time range. This order makes those queries scan minimum data.
TTL: data automatically expires after 30 days. No cron jobs needed.
Metrics Table
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
CREATE TABLE observability.metrics
(
timestamp DateTime64(3, 'UTC'),
timestamp_date Date DEFAULT toDate(timestamp),
metric_name LowCardinality(String),
service LowCardinality(String),
environment LowCardinality(String),
host LowCardinality(String),
-- Metric dimensions as a sorted map for consistent ordering
labels Map(String, String),
value Float64,
metric_type LowCardinality(String) -- gauge, counter, histogram, summary
)
ENGINE = MergeTree()
PARTITION BY (toYYYYMM(timestamp), metric_name)
ORDER BY (metric_name, service, labels, timestamp)
TTL timestamp_date + INTERVAL 90 DAY DELETE
SETTINGS index_granularity = 8192;
-- For Prometheus remote_write compatibility, create a materialized view
-- that pre-aggregates to 1-minute resolution after 7 days
CREATE MATERIALIZED VIEW observability.metrics_1m_mv
TO observability.metrics_1m
AS SELECT
toStartOfMinute(timestamp) AS timestamp,
timestamp_date,
metric_name,
service,
environment,
host,
labels,
avg(value) AS value,
metric_type
FROM observability.metrics
GROUP BY
toStartOfMinute(timestamp),
timestamp_date,
metric_name, service, environment, host, labels, metric_type;
|
Traces Table
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
|
CREATE TABLE observability.traces
(
timestamp DateTime64(9, 'UTC'),
timestamp_date Date DEFAULT toDate(timestamp),
-- OpenTelemetry span fields
trace_id FixedString(32),
span_id FixedString(16),
parent_span_id FixedString(16),
operation_name LowCardinality(String),
service_name LowCardinality(String),
service_version LowCardinality(String),
-- Timing
start_time_unix_nano UInt64,
end_time_unix_nano UInt64,
duration_ns UInt64 MATERIALIZED (end_time_unix_nano - start_time_unix_nano),
-- Status
status_code LowCardinality(String), -- OK, ERROR, UNSET
status_message String,
-- Span kind: SERVER, CLIENT, PRODUCER, CONSUMER, INTERNAL
span_kind LowCardinality(String),
-- Attributes
attributes Map(String, String),
resource_attributes Map(String, String),
-- HTTP-specific (common enough to be top-level for query performance)
http_method LowCardinality(String),
http_url String,
http_status_code UInt16,
-- Events (errors, exceptions) stored as JSON array
events String
)
ENGINE = MergeTree()
PARTITION BY (toYYYYMMDD(timestamp), service_name)
ORDER BY (service_name, timestamp, trace_id)
TTL timestamp_date + INTERVAL 14 DAY DELETE
SETTINGS index_granularity = 8192;
-- Index for trace ID lookups (finding all spans in a trace)
ALTER TABLE observability.traces
ADD INDEX idx_trace_id trace_id TYPE bloom_filter(0.001)
GRANULARITY 4;
-- Index for slow query analysis
ALTER TABLE observability.traces
ADD INDEX idx_duration duration_ns TYPE minmax()
GRANULARITY 4;
|
Ingesting Data
Vector: The Swiss Army Knife
Vector is a high-performance observability data pipeline that natively supports ClickHouse as a sink. It collects from Docker, files, journald, Kafka, HTTP, and dozens of other sources.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
|
# config/vector/vector.toml
# ── Sources ────────────────────────────────────────────────────────────────
[sources.docker_logs]
type = "docker_logs"
docker_host = "unix:///var/run/docker.sock"
# Collect from all containers except vector itself
exclude_containers = ["vector"]
[sources.journald]
type = "journald"
current_boot_only = true
[sources.syslog]
type = "syslog"
mode = "tcp"
address = "0.0.0.0:5140"
# ── Transforms ─────────────────────────────────────────────────────────────
[transforms.parse_docker]
type = "remap"
inputs = ["docker_logs"]
source = '''
# Parse JSON log messages if possible
parsed, err = parse_json(.message)
if err == null {
.attributes = {}
for_each(object!(parsed)) -> |k, v| {
.attributes[k] = to_string(v) ?? ""
}
# Extract common fields
if exists(parsed.level) { .level = to_string!(parsed.level) }
if exists(parsed.msg) { .message = to_string!(parsed.msg) }
if exists(parsed.trace_id) { .trace_id = to_string!(parsed.trace_id) }
if exists(parsed.span_id) { .span_id = to_string!(parsed.span_id) }
}
# Normalize log level
.level = upcase(string!(.level ?? "INFO"))
.level = if !includes(["DEBUG","INFO","WARN","WARNING","ERROR","FATAL"], .level) { "INFO" } else { .level }
.level = if .level == "WARNING" { "WARN" } else { .level }
# Extract service name from Docker label or container name
.service = string!(.label."com.docker.compose.service" ?? .container_name ?? "unknown")
.environment = string!(.label."environment" ?? "production")
.host = get_hostname!()
.container_id = string!(.container_id ?? "")
'''
[transforms.add_timestamps]
type = "remap"
inputs = ["parse_docker", "journald"]
source = '''
# Ensure timestamp is set
if !exists(.timestamp) {
.timestamp = now()
}
'''
# ── Sinks ──────────────────────────────────────────────────────────────────
[sinks.clickhouse_logs]
type = "clickhouse"
inputs = ["add_timestamps"]
endpoint = "http://clickhouse:8123"
database = "observability"
table = "logs"
auth.strategy = "basic"
auth.user = "default"
auth.password = "${CLICKHOUSE_PASSWORD}"
# Batch for efficiency — ClickHouse loves large inserts
batch.max_bytes = 10485760 # 10MB
batch.timeout_secs = 5
# Retry on transient failures
request.retry_attempts = 5
request.retry_initial_backoff_secs = 1
request.retry_max_duration_secs = 30
encoding.timestamp_format = "unix"
[sinks.clickhouse_logs.buffer]
type = "disk"
max_size = 268435456 # 256MB on-disk buffer for durability
when_full = "block"
|
OpenTelemetry Collector
For traces and metrics, the OpenTelemetry Collector exports directly to ClickHouse via the community exporter:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
|
# config/otel-collector/config.yaml
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
prometheus:
config:
scrape_configs:
- job_name: otel-collector
scrape_interval: 15s
static_configs:
- targets: [localhost:8888]
processors:
batch:
timeout: 5s
send_batch_size: 10000
send_batch_max_size: 50000
memory_limiter:
check_interval: 1s
limit_mib: 512
spike_limit_mib: 128
# Add resource attributes as labels
resourcedetection:
detectors: [env, docker, system]
timeout: 5s
override: false
# Tail-based sampling: keep 100% of error traces, 1% of healthy traces
tail_sampling:
decision_wait: 10s
num_traces: 100000
expected_new_traces_per_sec: 1000
policies:
- name: errors-policy
type: status_code
status_code: {status_codes: [ERROR]}
- name: slow-traces-policy
type: latency
latency: {threshold_ms: 1000}
- name: probabilistic-policy
type: probabilistic
probabilistic: {sampling_percentage: 1}
exporters:
clickhouse:
endpoint: tcp://clickhouse:9000
database: observability
logs_table_name: logs
traces_table_name: traces
metrics_table_name: metrics
ttl: 720h # 30 days
compress: lz4
dial_timeout: 5s
connection_params: {}
username: default
password: ${CLICKHOUSE_PASSWORD}
async_insert: true
# Also export metrics to Prometheus for Grafana
prometheus:
endpoint: 0.0.0.0:8889
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, resourcedetection, tail_sampling, batch]
exporters: [clickhouse]
metrics:
receivers: [otlp, prometheus]
processors: [memory_limiter, resourcedetection, batch]
exporters: [clickhouse, prometheus]
logs:
receivers: [otlp]
processors: [memory_limiter, resourcedetection, batch]
exporters: [clickhouse]
|
Prometheus Remote Write
If you have an existing Prometheus setup, configure remote_write to ClickHouse via the Prometheus ClickHouse adapter or Grafana Mimir:
1
2
3
4
5
6
7
8
|
# prometheus.yml
remote_write:
- url: http://clickhouse-adapter:9201/write
queue_config:
capacity: 100000
max_shards: 10
max_samples_per_send: 10000
batch_send_deadline: 5s
|
Writing Observability Queries
Log Analysis
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
|
-- Recent errors from the API service
SELECT
timestamp,
level,
message,
attributes['error'] AS error_detail,
trace_id
FROM observability.logs
WHERE
service = 'api'
AND level = 'ERROR'
AND timestamp >= now() - INTERVAL 1 HOUR
ORDER BY timestamp DESC
LIMIT 100;
-- Error rate by service over the last hour (1-minute buckets)
SELECT
toStartOfMinute(timestamp) AS minute,
service,
countIf(level = 'ERROR') AS errors,
count() AS total,
round(countIf(level = 'ERROR') / count() * 100, 2) AS error_pct
FROM observability.logs
WHERE timestamp >= now() - INTERVAL 1 HOUR
GROUP BY minute, service
ORDER BY minute DESC, errors DESC;
-- Full-text search using the tokenbf index
SELECT timestamp, service, message
FROM observability.logs
WHERE
timestamp >= now() - INTERVAL 24 HOUR
AND hasToken(message, 'connection')
AND hasToken(message, 'refused')
ORDER BY timestamp DESC
LIMIT 50;
-- Top log patterns by frequency (poor man's log clustering)
SELECT
service,
level,
-- Strip numbers and UUIDs to normalize messages
replaceRegexpAll(
replaceRegexpAll(message, '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}', '<uuid>'),
'[0-9]+', '<num>'
) AS pattern,
count() AS occurrences
FROM observability.logs
WHERE timestamp >= now() - INTERVAL 1 HOUR
GROUP BY service, level, pattern
ORDER BY occurrences DESC
LIMIT 20;
|
Trace Analysis
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
|
-- Find all spans for a specific trace ID
SELECT
span_id,
parent_span_id,
operation_name,
service_name,
round(duration_ns / 1e6, 2) AS duration_ms,
status_code,
http_method,
http_url,
http_status_code
FROM observability.traces
WHERE trace_id = '4bf92f3577b34da6a3ce929d0e0e4736'
ORDER BY start_time_unix_nano;
-- P50/P95/P99 latency per operation over the last hour
SELECT
service_name,
operation_name,
count() AS requests,
round(quantile(0.50)(duration_ns) / 1e6, 1) AS p50_ms,
round(quantile(0.95)(duration_ns) / 1e6, 1) AS p95_ms,
round(quantile(0.99)(duration_ns) / 1e6, 1) AS p99_ms,
countIf(status_code = 'STATUS_CODE_ERROR') AS errors
FROM observability.traces
WHERE
timestamp >= now() - INTERVAL 1 HOUR
AND span_kind = 'SPAN_KIND_SERVER'
GROUP BY service_name, operation_name
HAVING count() > 10
ORDER BY p95_ms DESC
LIMIT 25;
-- Trace ID to log correlation: find logs for all spans in a slow trace
SELECT
l.timestamp,
l.service,
l.level,
l.message,
l.trace_id
FROM observability.logs l
WHERE l.trace_id IN (
SELECT DISTINCT trace_id
FROM observability.traces
WHERE
service_name = 'checkout'
AND duration_ns > 5000000000 -- > 5 seconds
AND timestamp >= now() - INTERVAL 1 HOUR
)
ORDER BY l.timestamp;
-- Service dependency map: who calls whom
SELECT
client.service_name AS caller,
server.service_name AS callee,
count() AS calls,
round(avg(server.duration_ns) / 1e6, 1) AS avg_ms
FROM observability.traces server
INNER JOIN observability.traces client
ON server.span_id = client.parent_span_id
AND server.trace_id = client.trace_id
WHERE
server.timestamp >= now() - INTERVAL 1 HOUR
AND server.span_kind = 'SPAN_KIND_SERVER'
AND client.span_kind = 'SPAN_KIND_CLIENT'
GROUP BY caller, callee
ORDER BY calls DESC;
|
Metrics Queries
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
-- CPU usage across all hosts (Prometheus-style metric)
SELECT
toStartOfMinute(timestamp) AS minute,
host,
avg(value) AS cpu_percent
FROM observability.metrics
WHERE
metric_name = 'node_cpu_usage_percent'
AND timestamp >= now() - INTERVAL 3 HOUR
GROUP BY minute, host
ORDER BY minute DESC;
-- Memory usage trend
SELECT
toStartOfFiveMinutes(timestamp) AS five_min,
service,
max(value) AS max_memory_bytes
FROM observability.metrics
WHERE
metric_name = 'process_resident_memory_bytes'
AND timestamp >= now() - INTERVAL 24 HOUR
GROUP BY five_min, service
ORDER BY five_min DESC;
-- Request rate using counter delta
SELECT
toStartOfMinute(timestamp) AS minute,
service,
sum(value) AS requests
FROM observability.metrics
WHERE
metric_name = 'http_requests_total'
AND timestamp >= now() - INTERVAL 1 HOUR
GROUP BY minute, service
ORDER BY minute DESC;
|
Materialized views in ClickHouse are triggered on insert and maintain a continuously-updated result table. They’re the key to making dashboards fast without pre-aggregation lag.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
-- Pre-aggregate logs to 1-minute error rate summary
CREATE TABLE observability.log_error_rate_1m
(
minute DateTime,
service LowCardinality(String),
environment LowCardinality(String),
total UInt64,
errors UInt64,
warns UInt64
) ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(minute)
ORDER BY (minute, service, environment)
TTL minute + INTERVAL 90 DAY DELETE;
CREATE MATERIALIZED VIEW observability.log_error_rate_1m_mv
TO observability.log_error_rate_1m
AS SELECT
toStartOfMinute(timestamp) AS minute,
service,
environment,
count() AS total,
countIf(level = 'ERROR') AS errors,
countIf(level = 'WARN') AS warns
FROM observability.logs
GROUP BY minute, service, environment;
-- Query the materialized view instead of the raw logs table
-- (returns in milliseconds instead of seconds for large datasets)
SELECT
minute,
service,
errors,
round(errors / total * 100, 2) AS error_pct
FROM observability.log_error_rate_1m
WHERE minute >= now() - INTERVAL 6 HOUR
ORDER BY minute DESC;
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
-- Pre-aggregate trace latency percentiles
CREATE TABLE observability.trace_latency_1m
(
minute DateTime,
service_name LowCardinality(String),
operation_name LowCardinality(String),
total_count UInt64,
error_count UInt64,
duration_sum UInt64,
-- Quantile state stored for accurate merging
p95_state AggregateFunction(quantile(0.95), UInt64),
p99_state AggregateFunction(quantile(0.99), UInt64)
) ENGINE = AggregatingMergeTree()
PARTITION BY toYYYYMM(minute)
ORDER BY (minute, service_name, operation_name)
TTL minute + INTERVAL 90 DAY DELETE;
CREATE MATERIALIZED VIEW observability.trace_latency_1m_mv
TO observability.trace_latency_1m
AS SELECT
toStartOfMinute(timestamp) AS minute,
service_name,
operation_name,
count() AS total_count,
countIf(status_code = 'STATUS_CODE_ERROR') AS error_count,
sum(duration_ns) AS duration_sum,
quantileState(0.95)(duration_ns) AS p95_state,
quantileState(0.99)(duration_ns) AS p99_state
FROM observability.traces
WHERE span_kind = 'SPAN_KIND_SERVER'
GROUP BY minute, service_name, operation_name;
-- Query with accurate quantile merging
SELECT
minute,
service_name,
sum(total_count) AS requests,
round(quantileMerge(0.95)(p95_state) / 1e6, 1) AS p95_ms,
round(quantileMerge(0.99)(p99_state) / 1e6, 1) AS p99_ms
FROM observability.trace_latency_1m
WHERE minute >= now() - INTERVAL 24 HOUR
GROUP BY minute, service_name
ORDER BY minute DESC;
|
Grafana Integration
Install the Grafana ClickHouse datasource plugin:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
# config/grafana/provisioning/datasources/clickhouse.yml
apiVersion: 1
datasources:
- name: ClickHouse
type: grafana-clickhouse-datasource
isDefault: true
jsonData:
host: clickhouse
port: 9000
username: readonly
defaultDatabase: observability
protocol: native
# Enable query logging for debugging
enableLogging: true
secureJsonData:
password: readonly_password
|
Example Grafana Dashboard Panels
Error rate over time (time series):
1
2
3
4
5
6
7
8
9
10
|
SELECT
toStartOfMinute(timestamp) AS time,
service,
round(countIf(level = 'ERROR') / count() * 100, 2) AS error_pct
FROM observability.logs
WHERE
$__timeFilter(timestamp)
AND service IN ($service)
GROUP BY time, service
ORDER BY time
|
P99 latency heatmap:
1
2
3
4
5
6
7
8
9
10
|
SELECT
toStartOfMinute(timestamp) AS time,
round(quantile(0.99)(duration_ns) / 1e6) AS p99_ms
FROM observability.traces
WHERE
$__timeFilter(timestamp)
AND service_name = '$service'
AND span_kind = 'SPAN_KIND_SERVER'
GROUP BY time
ORDER BY time
|
The Grafana ClickHouse plugin supports $__timeFilter(column) as a macro that expands to a proper WHERE clause based on the dashboard time range.
Tiered Storage
ClickHouse supports moving cold data to cheaper storage automatically:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
<!-- config/clickhouse/storage.xml -->
<clickhouse>
<storage_configuration>
<disks>
<hot_disk>
<type>local</type>
<path>/var/lib/clickhouse/hot/</path>
</hot_disk>
<cold_disk>
<type>local</type>
<path>/var/lib/clickhouse/cold/</path>
<!-- Or use S3-compatible object storage: -->
<!-- <type>s3</type> -->
<!-- <endpoint>https://s3.amazonaws.com/my-bucket/clickhouse/</endpoint> -->
</cold_disk>
</disks>
<policies>
<tiered>
<volumes>
<hot>
<disk>hot_disk</disk>
<max_data_part_size_bytes>1073741824</max_data_part_size_bytes>
</hot>
<cold>
<disk>cold_disk</disk>
</cold>
</volumes>
<move_factor>0.1</move_factor>
</tiered>
</policies>
</storage_configuration>
</clickhouse>
|
Then reference the policy in your table:
1
2
3
4
|
CREATE TABLE observability.logs
( ... )
ENGINE = MergeTree()
SETTINGS storage_policy = 'tiered';
|
Insert Tuning
ClickHouse performs best with large, infrequent inserts (not one row at a time). The sweet spot is 100K–1M rows per insert, or inserts every 1–5 seconds. Both Vector and the OTel Collector handle batching automatically.
If you must do small inserts (from application code), enable async inserts:
1
2
3
4
5
|
-- Per-session setting
SET async_insert = 1;
SET wait_for_async_insert = 0;
SET async_insert_max_data_size = 10485760; -- 10MB
SET async_insert_busy_timeout_ms = 1000; -- Flush every second
|
Query Tuning
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
-- See what queries are running
SELECT
query_id,
user,
elapsed,
read_rows,
formatReadableSize(memory_usage) AS memory,
substring(query, 1, 100) AS query_preview
FROM system.processes
ORDER BY elapsed DESC;
-- Find slow queries in history
SELECT
normalized_query_hash,
count() AS cnt,
round(avg(query_duration_ms)) AS avg_ms,
round(max(query_duration_ms)) AS max_ms,
formatReadableSize(avg(memory_usage)) AS avg_memory,
any(query) AS sample_query
FROM system.query_log
WHERE
type = 'QueryFinish'
AND event_time >= now() - INTERVAL 24 HOUR
GROUP BY normalized_query_hash
ORDER BY avg_ms DESC
LIMIT 20;
-- Check part counts (too many small parts = slow queries)
SELECT
table,
count() AS parts,
sum(rows) AS total_rows,
formatReadableSize(sum(bytes_on_disk)) AS size_on_disk
FROM system.parts
WHERE active AND database = 'observability'
GROUP BY table
ORDER BY parts DESC;
|
Compression Codecs
Add column-specific compression codecs for better ratios:
1
2
3
4
5
6
7
8
9
10
11
|
-- Delta codec works great for monotonically increasing timestamps
timestamp DateTime64(9) CODEC(Delta, ZSTD(3)),
-- DoubleDelta for metrics that change slowly
value Float64 CODEC(Gorilla, ZSTD),
-- T64 for integer columns with small deltas
duration_ns UInt64 CODEC(T64, ZSTD(3)),
-- High compression for low-entropy strings
service LowCardinality(String) CODEC(ZSTD(9))
|
ClickHouse vs Elasticsearch
| Dimension |
ClickHouse |
Elasticsearch |
| Ingest speed |
500K-1M rows/s per node |
50K-100K docs/s per node |
| Storage efficiency |
5-20x better |
Baseline |
| Analytical queries |
Sub-second on billions |
Slow on complex aggregations |
| Full-text search |
Good (tokenbf, ngrambf) |
Excellent (inverted index) |
| Schema flexibility |
Fixed columns + Map type |
Fully dynamic |
| Operations complexity |
Low |
High (shards, replicas, JVM tuning) |
| Memory per node |
8GB adequate for most |
32GB+ typical |
| Cost (self-hosted) |
Very low |
High |
| Managed offering |
ClickHouse Cloud |
Elastic Cloud |
Choose Elasticsearch when: you need full-text search across free-form documents, or you need the rich Kibana UI with minimal SQL knowledge.
Choose ClickHouse when: you have high-volume structured logs/metrics/traces, you want fast aggregations, or you need aggressive cost control.
Complete Stack Summary
Application → OTel SDK
↓
OTel Collector
↙ ↓ ↘
traces metrics logs
↘ ↓ ↙
ClickHouse
↓
Grafana
Docker Logs / Systemd → Vector → ClickHouse → Grafana
Prometheus targets → Vector → ClickHouse → Grafana
With this setup you get:
- Logs: 30-day retention, full-text search, structured field queries
- Metrics: 90-day retention, pre-aggregated for fast dashboards
- Traces: 14-day retention, p99 latency analysis, cross-service correlation
- Correlation: trace IDs link logs to traces; timestamps link metrics to both
A single ClickHouse node with 8 CPU cores and 32GB RAM comfortably handles 50GB/day of observability data — data that would require a 5+ node Elasticsearch cluster with 256GB of RAM to handle equivalently.
Conclusion
ClickHouse is not just “Elasticsearch but faster.” It represents a different philosophy: analytical workloads belong on columnar stores. Observability data — structured, timestamped, and analyzed in aggregate — is a perfect fit.
The schema design phase matters more than with Elasticsearch: get the ORDER BY and PARTITION BY right and queries fly. Get them wrong and you’re scanning the whole table. But the payoff is a system that handles billions of events per day on hardware that costs a fraction of the Elastic stack, with queries that return in milliseconds instead of seconds.
Start with the single-node Docker Compose setup, instrument one service with the OpenTelemetry Collector, and run the latency percentile query against your trace data. The result will make a compelling case for migrating the rest of your observability stack.
Comments