Migrate Observability from TimescaleDB to ClickHouse
Temps stores all of its observability and analytics data in PostgreSQL/TimescaleDB by default — no extra services, single binary. When your traces, metrics, proxy logs, or analytics events outgrow TimescaleDB, you can route them to a ClickHouse cluster at runtime by setting a few environment variables on temps serve. There is no rebuild, no cargo feature flag, and no data-loss window: the ClickHouse backends are always compiled in and stay dormant until you configure them.
This is a runtime backend switch, not a one-way migration. You enable ClickHouse by setting environment variables and restarting temps serve; you roll back by unsetting them and restarting again. PostgreSQL/TimescaleDB is never dropped.
When to migrate
TimescaleDB is the right default for the vast majority of self-hosted deployments and needs no tuning. Reach for ClickHouse when you hit columnar-scale workloads that a row store struggles with:
- Millions of traces where the list view's per-request
GROUP BY trace_idand duration sort over the spans hypertable becomes slow. - High-cardinality metrics with long retention and heavy dashboard fan-out.
- High-volume proxy/request logs (hundreds of thousands to millions of rows) queried with wide time windows and many filters.
- Large analytics-event volumes where
uniq()-based approximate counts and columnar scans beat exact PostgreSQL aggregation.
If you are not hitting these limits, stay on TimescaleDB — it is one less service to run.
What changes
Four independent domains can be backed by ClickHouse. They all activate from the same four TEMPS_CLICKHOUSE_* variables and share one ClickHouse database (temps by default), but each owns its own tables and migration-tracking table so they never collide.
| Domain | ClickHouse table(s) | Migration-tracking table | Write model |
|---|---|---|---|
| Analytics events | events, events_5m_mv, sessions | _temps_ch_migrations | Dual-write — PostgreSQL is source of truth, fan-out worker replicates |
| Traces / spans | spans | _temps_ch_otel_migrations | Backend swap — writes go to ClickHouse instead of TimescaleDB |
| Resource metrics | service_metrics | _temps_ch_metrics_migrations | Backend swap |
| Proxy / request logs | proxy_logs | _temps_ch_proxy_logs_migrations | Backend swap |
The write model is the most important distinction for planning your migration, because it determines whether your existing data comes along automatically — see Historical data & backfill.
Each domain is independent. Because they all key off the same four variables, configuring ClickHouse enables all four backends at once. There is no per-domain on/off switch — if you want only some domains on ClickHouse, that is not currently a supported configuration.
Prerequisites
- A reachable ClickHouse instance or cluster (self-hosted or managed) speaking the HTTP protocol (default port
8123). - A ClickHouse user with permission to
CREATE DATABASE,CREATE TABLE, and read/write within the target database. Temps bootstraps its own schema on first connect. - Network reachability from the host running
temps serveto the ClickHouse HTTP endpoint. - Your existing PostgreSQL/TimescaleDB instance, untouched — it remains the system of record for analytics and the fallback for all four domains.
Step 1 — Stand up ClickHouse
Run a ClickHouse server reachable over HTTP. A minimal Docker example for evaluation:
docker
docker run -d --name temps-clickhouse \
-p 8123:8123 -p 9000:9000 \
-e CLICKHOUSE_USER=temps \
-e CLICKHOUSE_PASSWORD=your-clickhouse-password \
-e CLICKHOUSE_DB=temps \
clickhouse/clickhouse-server:latest
For production, run ClickHouse on persistent storage with backups and access controls of its own. The Docker one-liner above is for evaluation only. Temps treats ClickHouse as an analytical store — protect it like any other database holding your telemetry.
You do not need to create any tables by hand. On first connect, each Temps backend issues CREATE DATABASE IF NOT EXISTS for the target database and applies its own idempotent ClickHouse schema migrations — tracked per-domain in its own _temps_ch_*_migrations table.
This is separate from your PostgreSQL/TimescaleDB schema. ClickHouse tables are created automatically here; there is no manual ClickHouse migration step. The Postgres schema is upgraded by temps serve on boot, or explicitly with temps migrate on large/production installs — see Upgrade Temps. The two are independent.
Step 2 — Configure Temps
Set the ClickHouse variables on the process running temps serve:
environment
export TEMPS_CLICKHOUSE_URL=http://clickhouse:8123
export TEMPS_CLICKHOUSE_USER=temps
export TEMPS_CLICKHOUSE_PASSWORD=your-clickhouse-password
# Optional — defaults to "temps" when TEMPS_CLICKHOUSE_URL is set:
export TEMPS_CLICKHOUSE_DATABASE=temps
| Variable | Required | Default | Description |
|---|---|---|---|
TEMPS_CLICKHOUSE_URL | Yes | — | ClickHouse HTTP endpoint (e.g. http://host:8123) |
TEMPS_CLICKHOUSE_USER | Yes | — | ClickHouse username |
TEMPS_CLICKHOUSE_PASSWORD | Yes | — | ClickHouse password |
TEMPS_CLICKHOUSE_DATABASE | No | temps | Target database; auto-resolves to temps when the URL is set |
Fail-closed activation
The ClickHouse backends activate only when the URL, user, and password are all present and non-empty (the database name fills in automatically). A variable that is set but empty counts as unset, so a half-configured operator never silently loses data — Temps simply stays in the default TimescaleDB-only mode. There is no partial-ClickHouse state.
All four domains share one database. Keeping the default name (temps) is recommended so analytics events, spans, service_metrics, and proxy_logs all live together and are easy to inspect. Override TEMPS_CLICKHOUSE_DATABASE only if you have a naming policy that requires it.
Step 3 — Restart and verify
Restart temps serve so it reads the new environment. On boot, for each enabled domain Temps will:
CREATE DATABASE IF NOT EXISTSfor the target database (run against the always-presentdefaultdatabase so the session never depends on a database that doesn't exist yet).- Apply the embedded ClickHouse schema migrations, tracked per-domain in its own
_temps_ch_*_migrationstable. - Swap the read (and, for the swap-model domains, write) path to the ClickHouse backend.
Migrations run on background tasks so plugin init does not block on remote calls. If a migration fails, the error surfaces on the first query for that domain rather than crashing startup.
Confirm it's live
Check the server logs for the activation lines, for example:
ClickHouse OTel backend enabled (ADR-016) — applying migrations url=http://clickhouse:8123 database=temps
ClickHouse OTel migrations applied applied=["0001_spans"] skipped_count=0
Proxy/request logs: ClickHouse backend enabled (TEMPS_CLICKHOUSE_* configured)
Then query ClickHouse directly to confirm the schema and that new data is flowing:
clickhouse-client
# Tables exist in the shared database
clickhouse-client -q "SHOW TABLES FROM temps"
# Expect: events, events_5m_mv, sessions, spans, service_metrics, proxy_logs,
# and the four _temps_ch_*_migrations tracking tables.
# New telemetry is landing (run after generating some traffic)
clickhouse-client -q "SELECT count() FROM temps.spans"
clickhouse-client -q "SELECT count() FROM temps.proxy_logs"
clickhouse-client -q "SELECT count() FROM temps.service_metrics"
clickhouse-client -q "SELECT count() FROM temps.events FINAL"
Finally, open the console — the Observe, Analytics, Monitoring, and traces views now read from ClickHouse. They should render identically; the data just comes from the columnar store.
Historical data & backfill
This is where the two write models diverge. Read this section before you migrate so you know what comes along.
Analytics events — history replicates automatically
Analytics events dual-write: record_event always writes to PostgreSQL synchronously (the system of record), then enqueues the event id into an events_ch_outbox table (INSERT ... ON CONFLICT (event_id) DO NOTHING). A background fan-out worker drains that outbox into ClickHouse, claiming batches with FOR UPDATE SKIP LOCKED.
Because the outbox is a queue, existing analytics events replicate to ClickHouse as the worker catches up after you enable the backend. Retries are safe — the events table uses ReplacingMergeTree(_version) and dedupes over its sort key, so re-delivering an event collapses to one row.
The fan-out worker also runs an hourly retention sweep that deletes delivered outbox rows older than 7 days, and a 5-minute dead-letter scan that warn-logs the count of rows that hit the retry ceiling (attempts >= max_attempts, default 10) without deleting them. If you see a growing dead-letter count, check ClickHouse connectivity.
Traces, metrics, and proxy logs — new data only
These three domains do not dual-write. When ClickHouse is enabled, the backend is chosen at construction time: new writes go to ClickHouse instead of TimescaleDB. There is no automatic backfill of the spans, service_metrics, or proxy_logs rows already sitting in TimescaleDB.
In practice this means:
- After you switch on ClickHouse, the traces / metrics / proxy-log views show data ingested from that point forward.
- Your historical TimescaleDB data is not deleted — it stays in the hypertables and ages out under its existing 90-day retention policy. It is simply no longer the backend the console reads from for those domains.
If you depend on continuity of historical traces, metrics, or proxy logs in the UI, plan the cutover for a low-traffic window and keep in mind there will be a visible gap until enough new data accumulates in ClickHouse. There is currently no built-in one-shot copier for these three domains; treat enabling ClickHouse as a forward-looking change for them.
TimescaleDB trace summaries
Even after the ClickHouse trace backend is enabled, Temps continues to write trace summary rows — root-span or per-trace aggregates — into TimescaleDB alongside the full span payload going to ClickHouse. This is intentional and serves two purposes:
- Rollback continuity. If you revert to the TimescaleDB backend, the summary rows give the Observe page enough anchor data to render trace correlation columns without a cold-start gap.
- Observe page correlation. The unified Observe page joins events, errors, and traces using summary-level columns (trace ID, duration, root service, status). During the mixed-backend period, those join columns are sourced from TimescaleDB summaries regardless of where the full span payload lives.
The summary rows are lightweight — they do not duplicate full span attributes or events. Full-detail trace inspection always queries whichever backend holds the span payload (ClickHouse when enabled). You can safely ignore the TimescaleDB summary rows in terms of storage cost; they are pruned under the same 90-day retention policy as the rest of the TimescaleDB hypertables.
The mixed-backend period is any window where ClickHouse has been enabled but not enough new data has accumulated for the Observe page correlation to be entirely self-contained. Summary rows bridge that gap automatically — no operator action is required.
Settings page
The Temps console exposes a Settings surface (under Settings → Observability or your instance's equivalent path) that shows the effective storage backend for each observability domain at a glance. It is the canonical place to confirm that your environment-variable configuration was picked up correctly after a restart.
The Settings page surfaces:
- Effective backend per domain — whether each of the four domains (analytics events, traces, resource metrics, proxy logs) is currently reading and writing from TimescaleDB or ClickHouse.
- Misconfiguration warnings — if one or more of the required ClickHouse variables (
TEMPS_CLICKHOUSE_URL,_USER,_PASSWORD) are present but incomplete, the Settings page shows a warning banner explaining which variable is missing. This surfaces the fail-closed activation state described in Step 2 without requiring you to read server logs. - Mixed-backend indicator — during the window where trace summaries are being sourced from TimescaleDB while full spans land in ClickHouse, the Settings page marks the traces domain as "mixed" so operators know the split is expected and intentional, not a configuration error.
The Settings page reflects the runtime state — what the running temps serve process actually loaded from its environment. If you update environment variables but have not yet restarted, the Settings page still shows the previous state. Always restart after changing TEMPS_CLICKHOUSE_* variables.
Data-model hardening
The ClickHouse backends shipped with several schema hardening changes that were applied alongside the initial implementation. You do not need to run these manually — they are idempotent migrations applied by temps serve on boot — but knowing about them is useful when inspecting the ClickHouse tables directly or when troubleshooting query anomalies.
Nullable column fixes
Several columns that were initially declared NOT NULL in the ClickHouse DDL carried implicit zero/empty-string defaults that masked missing telemetry fields. Those columns were corrected to be nullable where the originating OTLP schema permits absent values. Affected examples include optional span attributes and resource label columns in the spans table. If you are querying these columns directly, use IS NOT NULL guards rather than checking for empty strings.
Index additions
The spans, service_metrics, and proxy_logs tables received additional secondary indexes (ClickHouse INDEX ... TYPE bloom_filter or minmax where appropriate) on high-cardinality filter columns such as service name, HTTP status code, and deployment ID. These indexes are picked up automatically by the query planner and reduce scan sizes for the filtered queries issued by the console.
ReplacingMergeTree key corrections
The events and spans tables use ReplacingMergeTree for idempotent re-delivery. An earlier schema revision had an incorrect sort-key ordering that caused the FINAL keyword (used in read queries to collapse duplicates) to be less effective than intended. The corrected sort key ensures that duplicate rows introduced by fan-out retries (analytics) or at-least-once OTLP delivery (traces) collapse efficiently. Existing data in a table with the old sort key is unaffected at the data level — duplicates are still resolved by FINAL — but you may see a one-time compaction spike after the migration applies as ClickHouse re-sorts the existing parts.
Behavior differences
The ClickHouse backends are designed to be drop-in, but a few behaviors differ by design:
- Approximate unique counts. The analytics-events backend uses
uniq()(HyperLogLog), accurate to within ~1% at scale, instead of the exactCOUNT(DISTINCT)used by PostgreSQL. - Self-referral filter is PostgreSQL-only. The referrer-hostname self-referral filter checks the
project_custom_domainstable, which is not replicated to ClickHouse, so a referrer breakdown on the ClickHouse backend may include your own domain. - Visitor/session analytics stay on PostgreSQL. Only the analytics events read path is backed by ClickHouse; the separate visitor/session path always uses PostgreSQL.
- Geolocation is denormalized. Country/region/city are written onto each ClickHouse event row at fan-out time, so breakdowns group on plain columns with no cross-database join.
- Deduplication semantics. ClickHouse tables use
ReplacingMergeTree; reads that must be exact useFINALto collapse duplicates before aggregating. - Trace summary rows remain in TimescaleDB. The Observe page correlation columns (trace ID, root service, duration, status) are sourced from TimescaleDB summary rows even while full spans live in ClickHouse. This is expected — see TimescaleDB trace summaries. The Settings page marks the traces domain as "mixed" during this window so the split is visible to operators.
- Settings page warning states. If the Settings page shows a "misconfiguration" warning for one domain but not others, the most common cause is that the relevant
TEMPS_CLICKHOUSE_*variable is present but empty. A warning state does not indicate data loss — it means that domain is still running on TimescaleDB (fail-closed) while Temps waits for a complete configuration.
Rollback
Rolling back is symmetric with enabling and loses no system-of-record data:
- Unset
TEMPS_CLICKHOUSE_URL,TEMPS_CLICKHOUSE_USER, andTEMPS_CLICKHOUSE_PASSWORD(andTEMPS_CLICKHOUSE_DATABASEif you set it). - Restart
temps serve.
Temps reverts every domain to the PostgreSQL/TimescaleDB path with no other changes. For analytics events, PostgreSQL was the system of record the entire time, so nothing is lost. For traces / metrics / proxy logs, the console reads from TimescaleDB again — the data ingested while ClickHouse was enabled lives in ClickHouse and will not appear in the TimescaleDB-backed views (mirror of the forward-cutover gap).
Your ClickHouse tables are left intact on rollback. You can drop the temps database in ClickHouse manually if you want to reclaim the space, or keep it in case you re-enable.
Troubleshooting
- Name
ClickHouse stays disabled after setting variables- Type
- config
- Description
All of
TEMPS_CLICKHOUSE_URL,_USER, and_PASSWORDmust be present and non-empty. A variable that is set but empty counts as unset. Check for trailing whitespace or an empty value, and confirm the variables are exported into the same process environment astemps serve.
- Name
“Database temps does not exist” on boot- Type
- error
- Description
Temps issues
CREATE DATABASE IF NOT EXISTSagainst the always-presentdefaultdatabase before any other statement. If you see this, the configured user likely lacksCREATE DATABASEpermission — grant it, or pre-create the database and grant table privileges.
- Name
Migration SQL error at boot- Type
- error
- Description
Each domain applies idempotent migrations tracked in its own
_temps_ch_*_migrationstable. Re-running is safe (CREATE ... IF NOT EXISTS). If a migration genuinely fails, the error surfaces on the first query for that domain; check the server logs for the offending statement.
- Name
Analytics events not appearing in ClickHouse- Type
- data
- Description
Events replicate via the
events_ch_outbox. Verify the fan-out worker is running and ClickHouse is reachable. A growing dead-letter count (rows withattempts >= max_attempts) in the warn logs indicates delivery failures — almost always ClickHouse connectivity or auth.
- Name
Traces/metrics/proxy logs missing history- Type
- expected
- Description
Expected. These three domains do not backfill — see Historical data & backfill. Only data ingested after enabling ClickHouse appears in those views.
- Name
Settings page shows 'misconfiguration' warning for one or more domains- Type
- config
- Description
The Settings page surfaces this when one or more of
TEMPS_CLICKHOUSE_URL,_USER, or_PASSWORDis set but empty (the fail-closed rule). All three must be non-empty for any domain to activate. The warning does not mean data was lost — the affected domain is running on TimescaleDB (the safe default). Export the variables, restarttemps serve, and reload the Settings page to confirm they are picked up.
- Name
Settings page shows traces domain as 'mixed'- Type
- expected
- Description
Expected during the period after enabling ClickHouse when TimescaleDB is still supplying trace summary rows for Observe page correlation. This is not an error. The "mixed" indicator clears once the ClickHouse trace backend has sufficient data to satisfy the Observe page join columns without falling back to TimescaleDB summaries. No operator action is required.
- Name
One-time compaction spike in ClickHouse after migration- Type
- expected
- Description
Expected if the ReplacingMergeTree sort-key correction migration ran against a table that already had data. ClickHouse re-sorts existing parts in the background — this is a one-time cost. Query correctness is unaffected (the
FINALkeyword resolves duplicates regardless), but CPU and I/O may spike briefly on the ClickHouse host. The spike subsides once background merges complete.
Related pages
- Observability storage and retention — measure telemetry growth and review retention, compression, sampling, and quota controls.
- Environment Variables Reference — the full
TEMPS_CLICKHOUSE_*reference and fan-out internals. - OpenTelemetry (OTLP) Ingest & Query — how traces, metrics, and logs are ingested and queried.
- Analytics — the analytics feature reference.
- Observe — the unified observability view that reads from whichever backend is configured.
- Monitoring — resource metrics, backed by
service_metrics.