Cloud or Self-Hosted

ClickHouse 适配器

通过 HTTP 接口将宽事件写入 ClickHouse——对需要聚合的内容使用类型化列,其余内容完整保留为 JSON。

ClickHouse 适配器通过HTTP 接口,以 JSONEachRow 格式写入宽事件。它支持本地实例、自管理集群以及 ClickHouse Cloud。

默认架构会为你进行筛选和聚合的字段提供类型化列,并将完整的宽事件以 JSON 形式存储在 data 中,因此不会丢失任何字段,而且向事件中添加字段也永远不需要迁移。

添加 ClickHouse drain 适配器

安装

ClickHouse 适配器已随 evlog 一起提供:

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

创建表

在接入排空程序之前运行一次:

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,
  duration_ms   Nullable(UInt32),
  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) 与你最常用的筛选方式相匹配。根据你的保留策略调整 TTL,如果要永久保留事件,则完全删除该子句。

快速开始

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

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

配置

环境变量

变量必填描述
CLICKHOUSE_ENDPOINTHTTP 接口 URL(也接受 CLICKHOUSE_URL
CLICKHOUSE_USER用户名。默认为 default
CLICKHOUSE_PASSWORD密码。未经身份验证的本地实例可省略
CLICKHOUSE_DATABASE数据库。默认为 default
CLICKHOUSE_TABLE表。默认为 evlog_events

选项

选项类型默认值描述
endpointstringHTTP 接口 URL
databasestringdefault目标数据库
tablestringevlog_events目标表
usernamestringdefault用户名
passwordstring密码
asyncInsertbooleantrue在服务器端批量插入
waitForAsyncInsertbooleanfalse响应前等待刷新完成
transform(event) => Record<string, unknown>toClickHouseRow将事件映射为一行
timeoutnumber5000请求超时时间(毫秒)
retriesnumber2重试次数

部署

无论采用哪种方式,适配器都相同。只有端点和凭据会发生变化。

自托管

本地或自行管理的实例,可选择启用或不启用身份验证:

server/plugins/evlog.ts
createClickHouseDrain({ endpoint: 'http://localhost:8123' })
.env
CLICKHOUSE_ENDPOINT=http://localhost:8123
# 仅当你的实例需要身份验证时:
CLICKHOUSE_USER=evlog
CLICKHOUSE_PASSWORD=your-password

全新的容器以 default 用户运行且没有密码,这就是为什么 username 默认为 default,而 password 是可选的。

ClickHouse Cloud

云服务始终需要凭据,并通过 HTTPS 的 8443 端口进行通信:

server/plugins/evlog.ts
createClickHouseDrain({
  endpoint: 'https://abc123.eu-west-1.aws.clickhouse.cloud:8443',
  password: process.env.CLICKHOUSE_PASSWORD,
  database: 'logs',
})
.env
CLICKHOUSE_ENDPOINT=https://abc123.eu-west-1.aws.clickhouse.cloud:8443
CLICKHOUSE_PASSWORD=your-service-password
CLICKHOUSE_DATABASE=logs
从 ClickHouse Cloud 控制台的 Connect → HTTPS 中复制端点, 包括 :8443 端口。云服务会在空闲时暂停,因此暂停后的第一次插入 可能需要几秒钟;如果看到中止错误,请增大 timeout
凭据通过 X-ClickHouse-User / X-ClickHouse-Key请求头发送, 绝不会作为查询参数发送,因此不会出现在 system.query_log 或 中间代理的访问日志中。

在本地验证

在 Docker 中启动 ClickHouse,创建表并推送一个真实事件:

Terminal
docker compose -f packages/evlog/test/e2e/docker-compose.yml up -d
CLICKHOUSE_ENDPOINT=http://localhost:8123 pnpm run test:e2e
docker compose -f packages/evlog/test/e2e/docker-compose.yml down -v

该测试套件是一个往返测试:它插入事件,使用 SELECT 将其读回,并断言类型化列和 data JSON 均得以保留。 compose 文件会在首次启动时根据上述架构创建 evlog_events。 如果没有设置 CLICKHOUSE_ENDPOINT,测试会显示醒目标记并跳过自身。

若要查看事件而不是对其进行断言,请填充沙盒并打开 已配置的仪表板。该技术栈附带一个已连接 ClickHouse 数据源的 Grafana:

Terminal
pnpm run sandbox:up
pnpm run sandbox:seed
# → http://localhost:3001/d/evlog-wide-events

ClickHouse 内置的 /play 页面 http://localhost:8123/play 也可以 用于执行原始 SQL,但它有意保持简洁,而仪表板是更好的起点。

异步插入

日志摄取意味着会有许多小型插入操作,而每个请求对应一个 MergeTree 分区会很快导致表性能下降。因此,适配器默认启用异步插入,并且不会等待刷新完成:

async_insert=1&wait_for_async_insert=0

ClickHouse 会在服务器端缓冲行,并批量写入。排空操作不会因磁盘写入而阻塞请求。

如果需要在 drain 解析前确认插入已持久化,请设置 waitForAsyncInsert: true,代价是增加延迟。设置 asyncInsert: false 可进行同步插入,但这只有在你已经通过 evlog/pipeline 在客户端完成批处理时才有意义。

查询

按排序键对类型化列建立索引;其他所有内容都存储在 data 中,并可通过 ClickHouse 的 JSON 函数访问:

-- 过去一小时内每个服务的错误率
SELECT service, countIf(level = 'error') / count() AS error_rate
FROM evlog_events
WHERE timestamp > now() - INTERVAL 1 HOUR
GROUP BY service;
-- 最慢的路由
SELECT path, count() AS hits, avg(duration_ms) AS avg_ms, quantile(0.95)(duration_ms) AS p95_ms
FROM evlog_events
WHERE timestamp > now() - INTERVAL 1 DAY AND method = 'GET' AND duration_ms IS NOT NULL
GROUP BY path
ORDER BY avg_ms DESC
LIMIT 20;
-- 一个请求的端到端记录
SELECT timestamp, level, path, status, data
FROM evlog_events
WHERE request_id = '4a8ff3a8-...'
ORDER BY timestamp;
-- 访问仅保存在 data 中的自定义字段
SELECT JSONExtractString(data, 'user', 'plan') AS plan, count()
FROM evlog_events
WHERE timestamp > now() - INTERVAL 7 DAY
GROUP BY plan;

自定义架构

传入 transform 以针对自定义结构的表:

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),
  }),
})

键必须与列名匹配,因为 ClickHouse 会拒绝包含未知列的 JSONEachRow 插入。

批处理

与管道配合使用,以更大的批次插入:

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()))
})

故障排除

Missing endpoint 未设置 CLICKHOUSE_ENDPOINT,且未传入 endpoint。排空程序会记录一次 [evlog/clickhouse] Missing endpoint,之后不再执行任何操作,因此请求永远不会被阻塞或失败。

ClickHouse API error: 401 检查 CLICKHOUSE_USER / CLICKHOUSE_PASSWORD。凭据通过 X-ClickHouse-User / X-ClickHouse-Key 请求头发送,绝不会出现在查询字符串中,因此不会进入 system.query_log

提及未知列的 ClickHouse API error: 400 你的表结构与行数据形状不匹配。请根据上面的 DDL 创建表,或提供与列匹配的 transform

Cannot parse input 表已存在,但某一列的类型不匹配。statusNullable(UInt16),因为非 HTTP 事件中没有该字段。

直接使用 API

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()resolveClickHouseUrl() 也已导出。

后续步骤