ClickHouse

Production-grade OLAP AnalyticsSink over ClickHouse — self-hosted or ClickHouse Cloud — for billions of events with millisecond aggregates.

@voltro/plugin-clickhouse is the production AnalyticsSink for analytics at scale — native HTTP ingest into a ClickHouse cluster (self-hosted or ClickHouse Cloud, both via the official @clickhouse/client). The sink owns an events table inside the configured database (a MergeTree engine created on first boot) and implements all four contract methods. Reach for it when postgres-lite hits its ~10M events/day ceiling and you need millisecond aggregate queries over billions of rows. For the shared AnalyticsSink API and the useAnalytics() read methods, see Analytics & warehouse sinks.

Install

pnpm add @voltro/plugin-clickhouse

Wiring

// app.config.ts
import { clickhouseAnalytics } from '@voltro/plugin-clickhouse'

export default {
  type:      'api' as const,
  name:      'myApi',
  store:     'postgres' as const,
  analytics: clickhouseAnalytics({
    url:      process.env.CLICKHOUSE_URL!,   // https://my-cluster.clickhouse.cloud
    username: process.env.CLICKHOUSE_USER,
    password: process.env.CLICKHOUSE_PASSWORD,
    database: 'voltro_events',               // optional, default 'default'
  }),
}

At boot the plugin opens the client, pings the cluster (fail-fast on bad config), and creates the events table + any mirror tables. The client is closed on graceful shutdown.

Options

clickhouseAnalytics(options):

Option Type Default Notes
url string — (required) HTTP URL of the ClickHouse server, incl. protocol + port.
username string
password string
database string 'default' The plugin creates the events table inside it on first boot.
table string 'events' Override the events-table name.
mirrorTables ReadonlyArray<string> [] (events-only) Reactive tables to CDC-mirror into voltro_mirror_<table> (a ReplacingMergeTree(version) over { id, data, version, is_deleted }) so analytical queries JOIN events against live user data. The version is the framework's commit-order stamp, so a change that arrives late loses the collapse.
mirrorPrimaryKey string 'id' Primary-key column on the mirrored source rows.
batch { maxSize?, flushIntervalMs? } | false on{ maxSize: 20, flushIntervalMs: 5000 } Client-side batching of track() inserts. false opts out (one immediate, confirmed insert per event).

Batching is on by default

One HTTP insert per event is how a MergeTree gets part-exploded under load — every single-row insert creates a data part the server must merge away. So track() batches by default: rows buffer in the process and flush in ONE multi-row insert once 20 events are pending, 5 seconds after the first buffered event (whichever comes first), and on graceful shutdown (the dispose hook drains the buffer before the client closes).

What changes observably with batching on:

  • A successful track() means "buffered", not "ClickHouse accepted the row" — the insert happens later, off the call path.
  • Events become visible to reads up to flushIntervalMs later than they were tracked.
  • A flush failure is logged and that batch dropped (best-effort analytics — re-queuing a partially-applied insert would risk duplicates). A hard crash loses whatever is still buffered; a graceful shutdown loses nothing.

If you need per-event delivery confirmation, opt out explicitly:

analytics: clickhouseAnalytics({
  url: process.env.CLICKHOUSE_URL!,
  batch: false,   // one immediate insert per track(); success = row accepted
})

…or tune the window: batch: { maxSize: 500, flushIntervalMs: 2000 }.

database, table, and each mirrorTables entry are validated as SQL identifiers at boot (they're interpolated into DDL) — a bad name fails loudly at app.config eval, not on the first query.

Connection details come from options, not env. The plugin reads no CLICKHOUSE_* variables itself — the example above wires process.env into the options. Use @voltro/env's defineEnv to declare them as secrets.

No raw-client escape hatch

ClickHouse-specific features outside the cross-provider contract (HyperLogLog, dictionaries, materialised views) are not reachable through the plugin — it exposes no raw @clickhouse/client handle; the AnalyticsSink contract is the extension seam. Where you need them, query ClickHouse with your own client instance against the same tables.

Using the Tag binds your handler to ClickHouse — stay on useAnalytics() for provider-portable code. The plugin also exports EVENTS_TABLE_DDL and MIRROR_TABLE_DDL for advanced schema work.

See also

  • Analytics & warehouse sinks — the shared AnalyticsSink contract, the useAnalytics() read API, composeAnalytics, and the CDC-mirror details.