Self-Hosted

ClickHouse Adapter

Insert wide events into ClickHouse over the HTTP interface — typed columns for what you aggregate, full JSON for everything else.

The ClickHouse adapter inserts wide events over the HTTP interface in JSONEachRow format. It works with a local instance, a self-managed cluster, and ClickHouse Cloud.

The default schema gives you typed columns for the fields you filter and aggregate on, plus the complete wide event as JSON in data — so no field is ever lost, and adding a field to your events never requires a migration.

Add the ClickHouse drain adapter

Installation

The ClickHouse adapter comes bundled with evlog:

src/index.ts
import { createClickHouseDrain } from 'evlog/clickhouse'

Create the table

Run this once before wiring the drain:

schema.sql
CREATE TABLE IF NOT EXISTS evlog_events
(
  timestamp     DateTime64(3, 'UTC'),
  level         LowCardinality(String),
  service       LowCardinality(String),
  environment   LowCardinality(String),
  request_id    String,
  trace_id      String,
  span_id       String,
  method        LowCardinality(String),
  path          String,
  status        Nullable(UInt16),
  duration      String,
  error_name    String,
  error_message String,
  data          String
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(timestamp)
ORDER BY (service, environment, timestamp)
TTL toDateTime(timestamp) + INTERVAL 30 DAY;
ORDER BY (service, environment, timestamp) matches how you'll filter most often. Adjust the TTL to your retention policy — drop the clause entirely to keep events forever.

Quick Start

// server/plugins/evlog.ts
import { createClickHouseDrain } from 'evlog/clickhouse'

export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', createClickHouseDrain())
})

Configuration

Environment variables

VariableRequiredDescription
CLICKHOUSE_ENDPOINTYesHTTP interface URL (CLICKHOUSE_URL also accepted)
CLICKHOUSE_USERNoUsername. Default default
CLICKHOUSE_PASSWORDNoPassword. Omit for an unauthenticated local instance
CLICKHOUSE_DATABASENoDatabase. Default default
CLICKHOUSE_TABLENoTable. Default evlog_events

Options

OptionTypeDefaultDescription
endpointstringHTTP interface URL
databasestringdefaultTarget database
tablestringevlog_eventsTarget table
usernamestringdefaultUsername
passwordstringPassword
asyncInsertbooleantrueBatch inserts server-side
waitForAsyncInsertbooleanfalseWait for the flush before responding
transform(event) => Record<string, unknown>toClickHouseRowMap an event to a row
timeoutnumber5000Request timeout in ms
retriesnumber2Retry attempts

Async inserts

Log ingestion means many small inserts, and one MergeTree part per request would quickly degrade a table. The adapter therefore enables asynchronous inserts by default, and does not wait for the flush:

async_insert=1&wait_for_async_insert=0

ClickHouse buffers rows server-side and writes them in batches. Draining never blocks a request on disk writes.

Set waitForAsyncInsert: true if you need the insert acknowledged as durable before the drain resolves — at the cost of latency. Set asyncInsert: false to insert synchronously, which only makes sense when you already batch client-side with evlog/pipeline.

Querying

Typed columns are indexed by the sort key; everything else lives in data and is reachable with ClickHouse's JSON functions:

-- Error rate per service over the last hour
SELECT service, countIf(level = 'error') / count() AS error_rate
FROM evlog_events
WHERE timestamp > now() - INTERVAL 1 HOUR
GROUP BY service;
-- Slowest routes
SELECT path, count() AS hits, avg(JSONExtractFloat(data, 'durationMs')) AS avg_ms
FROM evlog_events
WHERE timestamp > now() - INTERVAL 1 DAY AND method = 'GET'
GROUP BY path
ORDER BY avg_ms DESC
LIMIT 20;
-- One request end to end
SELECT timestamp, level, path, status, data
FROM evlog_events
WHERE request_id = '4a8ff3a8-...'
ORDER BY timestamp;
-- Reach into a custom field kept only in data
SELECT JSONExtractString(data, 'user', 'plan') AS plan, count()
FROM evlog_events
WHERE timestamp > now() - INTERVAL 7 DAY
GROUP BY plan;

Custom schema

Pass transform to target a table of your own shape:

server/plugins/evlog.ts
import { createClickHouseDrain } from 'evlog/clickhouse'

createClickHouseDrain({
  table: 'app_logs',
  transform: event => ({
    ts: event.timestamp,
    lvl: event.level,
    svc: event.service,
    payload: JSON.stringify(event),
  }),
})

Keys must match your column names — ClickHouse rejects a JSONEachRow insert containing unknown columns.

Batching

Pair with a pipeline to insert in larger batches:

server/plugins/evlog.ts
import { createDrainPipeline } from 'evlog/pipeline'
import { createClickHouseDrain } from 'evlog/clickhouse'
import type { DrainContext } from 'evlog'

const pipeline = createDrainPipeline<DrainContext>({
  batch: { size: 500, intervalMs: 5000 },
})

export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', pipeline(createClickHouseDrain()))
})

Troubleshooting

Missing endpointCLICKHOUSE_ENDPOINT is unset and no endpoint was passed. The drain skips silently rather than failing requests.

ClickHouse API error: 401 — Check CLICKHOUSE_USER / CLICKHOUSE_PASSWORD. Credentials are sent as X-ClickHouse-User / X-ClickHouse-Key headers, never in the query string, so they never reach system.query_log.

ClickHouse API error: 400 mentioning unknown column — Your table does not match the row shape. Either create the table from the DDL above, or supply a transform matching your columns.

Cannot parse input — The table exists but a column type disagrees. status is Nullable(UInt16) because it is absent on non-HTTP events.

Direct API usage

scripts/backfill.ts
import { sendBatchToClickHouse, sendToClickHouse } from 'evlog/clickhouse'

await sendToClickHouse(event, { endpoint: 'http://localhost:8123' })
await sendBatchToClickHouse(events, { endpoint: 'http://localhost:8123' })

toClickHouseRow(), toJSONEachRow(), and resolveClickHouseUrl() are also exported.

Next steps