Langfuse 源码剖析:基于 Kysely 0.28 构建的编译期专用 ClickHouse Dialect(ARRAY JOIN / LIMIT BY / 租户注入实现指南)
【免费下载链接】langfuse🪢 Open source AI engineering platform: LLM evals, observability, metrics, prompt management, playground, datasets. Integrates with OpenTelemetry, LangChain, OpenAI SDK, LiteLLM, and more. 🍊YC W23项目地址: https://gitcode.com/GitHub_Trending/la/langfuse
在 Langfuse 的查询引擎中,packages/shared/src/server/query-ast/kysely/目录承载着一个独特的工程实践:不 fork Kysely、不连接数据库,仅通过编译路径输出 ClickHouse SQL的完整方言层。本文以该目录下的开发指南 kysely/README.md 为核心,结合其父级模块说明 query-ast/README.md 与全部源码实现,逐层拆解 ClickHouse 专属子句如何以真实 OperationNode 而非 Raw SQL 字符串落地、强制租户隔离的“咽喉点”如何运作、$call(helper())相比流畅式 builder 方法的优势,以及 golden 测试如何锁定 SQL 输出。读完本文,你将掌握一套可直接复用的“零 fork 扩展 Kysely”方法论,并能理解 Langfuse 中project_id隔离为何“手动写是多余的、忘写是不可能的”。
一、模块定位:只编译、不执行的 ClickHouse 方言
在进入细节前,先明确这个目录在 Langfuse 中的位置。query-ast是服务端查询 AST 模块,编译器、校验 pass、物理表注册表、执行上下文、执行集成都位于packages/shared/src/server/query-ast/下,受@langfuse/shared/src/server导出边界保护;而kysely/子目录是其中唯一承载库相关代码(Kysely 适配)的部分,其余部分与具体查询库解耦。
该目录采用的核心约定是:Kysely 在这里从不运行,它只负责编译。getClickhouseKysely()返回一个接入了DummyDriver的Kysely实例(dialect.ts),SQLite 的 adapter/introspector 只是占位;任何.execute()调用都会直接抛错。唯一的受支持输出路径是compileClickhouseQuery(query, ctx)→{ sql, params },由仓储层(repository layer)交给既有的queryClickhouse执行通道(详见 dialect.ts 与 compile.ts)。
这条边界意味着:不要直接调用.execute()/.compile()——编译器会拒绝任何没有经过租户注入 pass 的语法树(详见下文第三节)。
二、不 fork 的落地方式:四种 ClickHouse 子句如何进入 AST
Kysely 的OperationNodeKind联合类型是封闭的,想要一等公民的节点种类就必须 fork 整个项目。Langfuse 的选择是:让每个 ClickHouse 专属结构以“额外字段”或“特殊处理的节点”的形式挂在现有节点上,再由被覆写的 transformer 与 compiler 负责产出并保留它们。四种结构的落地方案如下表(源自 kysely/README.md):
| 子句 | 落地方式 | 是否 fork |
|---|---|---|
| ARRAY JOIN | 插件把ArrayJoinNode作为SelectQueryNode的额外字段挂载;ClickHouseOperationNodeTransformer负责保留;ClickHouseQueryCompiler.visitSelectQuery在 JOIN 之后、WHERE 之前输出 | 否 |
| LIMIT BY | 插件以同样方式挂载LimitByNode;编译器在 ORDER BY 之后、LIMIT 之前输出 | 否 |
metadataindexOf | 辅助函数构造ArrayIndexNode,其索引子节点是包在绑定ValueNode上的FunctionNode(indexOf);transformer 与 compiler 对该节点做特殊处理,无插件 | 否 |
| 虚拟视图 | 插件把selectFrom(viewName)重写为 WITH CTE;外层类型只暴露视图选中的列 | 否 |
这里有一个关键细节:这些都是真实的节点对象,其子节点是受追踪的 KyselyFunctionNode/ColumnNode/ValueNode/IdentifierNode,而不是RawNode字符串拼接。也就是说,ARRAY JOIN 表达式、LIMIT BY 列、metadata 下标里的键值都会像普通 Kysely 表达式一样被参数化绑定、被类型检查、被 transformer 递归遍历。
2.1 节点定义:为什么是“额外字段”而不是“新 kind”
打开 nodes.ts 可以看到,ArrayJoinNode、LimitByNode、ArrayIndexNode都带有kind字段(如"ArrayJoinNode"),但它们不是Kysely 的OperationNode种类——因为封闭的 kind 联合会让自定义 kind 坍缩为never。因此:
ArrayJoinNode与LimitByNode作为可选字段arrayJoins?/limitBy?挂在扩展类型ClickHouseSelectQueryNode = SelectQueryNode & { arrayJoins?; limitBy? }上;- 每个节点都通过
Object.freeze冻结,子节点只读; ArrayJoinNode.create接受items与variant,其中variant支持"default" | "left" | "inner"三种变体,分别编译为array join、left array join、inner array join(映射表见 compiler.ts)。
2.2 Transformer:默认实现会丢弃额外字段
Kysely 默认的OperationNodeTransformer.transformSelectQuery只按已知槽位重建SelectQueryNode,任何额外字段都会被丢弃——这意味着任何使用默认 transformer 的插件一旦遍历语法树,ARRAY JOIN / LIMIT BY 乃至租户 stamp 都会消失。因此本目录覆写了transformSelectQuery,把arrayJoins/limitBy原样保留并递归转换其子节点(见 transformer.ts)。
此外,transformNodeImpl中还特殊处理了ArrayIndexNode:因为它是 Kysely 封闭节点联合之外的定制种类,基类方法的泛型返回类型无法证明其可赋值性,源码中通过transformArrayIndex(node) as unknown as T完成一次有注释说明的、基于运行时.is守卫的“反模式”类型转换。
2.3 编译器:整段覆写visitSelectQuery,在固定顺序中插入两个块
ClickHouseQueryCompiler继承自DefaultQueryCompiler,但它整体覆写了visitSelectQuery(见 compiler.ts),而不是调用super再追加——原因是父类按固定顺序输出子句,且没有提供“在 JOIN 与 WHERE 之间插入子句”的钩子。对比源码可以看到,覆写版与 Kysely 0.28.17 的父实现逐句保持一致,仅在两处插入 ClickHouse 专属逻辑:
- ARRAY JOIN 块:在 JOIN 列表之后、WHERE 之前,遍历
chNode.arrayJoins逐个输出array join <expr> as <alias>; - LIMIT BY 块:在 ORDER BY 之后、普通 LIMIT 之前,输出
limit <count> by <col1, col2, ...>。
同时,编译器还做了两处 ClickHouse 语义适配(这对理解生成 SQL 的形状很重要):
- 命名参数绑定与去重:参数以
{pN:Type}形式绑定,并按(类型, 值)做 intern 去重——例如 UNION 两个分支都过滤project_id = 'p'时,只生成一个{p1:String}绑定并在两分支复用,而不是两个等价绑定; IN列表折叠为数组参数:col IN (1, 2, 3)编译为col IN ({p:Array(Int64)})这种单数组参数形态,与 ClickHouse 的IN语义及既有生产 SQL 保持一致(visitPrimitiveValueList/visitValueList覆写,见 compiler.ts)。
另外,标识符不加引号输出(getLeftIdentifierWrapper/getRightIdentifierWrapper返回空串),是为了让原始编译输出与clickhouse format规范化后的 golden 快照逐字节可比——本仓库的表名列名都是普通标识符,去掉引号是安全的(compiler.ts)。
三、租户注入:compile 咽喉点如何强制project_id隔离
compileClickhouseQuery(query, ctx)是唯一的受支持编译路径,也是租户隔离被强制执行的咽喉点(compile.ts)。其执行流程如下:
requireExecutionContext(ctx)校验:缺失/空的ExecutionContext抛出QueryCompileError;ctx是必填参数,省略它在编译期就是类型错误(双重保险,见 tenancy.ts)。TenancyInjectionPlugin遍历每个 FROM/JOIN,为每张租户化物理表注入project_id = {projectId},除非语法树中已经存在能证明作用域被覆盖的谓词;随后用WeakSet对整棵树做身份 stamp(复制一个langfuseTenancy属性字段并不算数)。ClickHouseQueryCompiler在compileQuery入口调用assertTenancyStamped,没有 stamp 就拒绝输出 SQL——所以绕过插件直接qb.compile()同样会失败(tenancy.ts)。- 原始 SQL 表源(
selectFrom(sql\...`))以及任何在 SELECT/WHERE 中嵌入SELECT/FROM/JOIN的 raw 片段都会抛UnscopedRelationError;Kysely 自带的关键字片段(asc/desc`)不算关系,不受影响。
3.1 “已覆盖”判定的精确语义
predicateCovers的判定远比“出现过 project_id 就行”严格(tenancy.ts),值得展开:
- 左右两侧都要匹配:左操作数必须是该表的
project_id列,右操作数必须是来自ExecutionContext的字面projectId。project_id = <其他项目>或o.project_id = t.project_id这类谓词不能证明作用域,不会被算作“已覆盖”,pass 会继续注入正确谓词; - 多租户关系时要求表限定:当作用域内存在多于一张租户化关系时,未限定的
project_id = …具有歧义——无法证明它约束的是哪张具体表,因此必须使用带表限定的引用才承认“已覆盖”;只有单个租户化关系时,未限定引用是无歧义的,可以接受; - 限定符匹配规则:带限定的谓词只有在限定符与该关系的别名(有别名时)或表名(无别名时)一致时才覆盖该关系。所以
scores AS traces与traces AS t连接时,两张表仍会被分别正确地限定——一个关系的物理表名在它被别名化后就不再是合法限定符,用物理名匹配会放过真正未限定表的场景(源码注释给出了这个具体反例); - 布尔结构递归:AND 中任一分支覆盖即可,OR 中则要求所有分支都覆盖,括号节点递归展开。
3.2 注入位置与参数稳定性
注入的project_id = {projectId}谓词被前插(prepend)到 WHERE 的最前面(tenancy.ts),这样绑定值始终占据稳定的(第一个)参数位置,不随调用方书写其他谓词而漂移。JOIN 侧则把谓词合并进ON条件(无ON时用JoinNode.createWithOn创建,有则JoinNode.cloneWithOn克隆追加)。
由此得出的结论是:查询体里永远不需要手写project_id过滤——手写是冗余的,忘记写是不可能的。调用方如repositories/environments.ts只传{ projectId }。
四、ClickHouse 专属子句用$call(helper()),而非 builder 方法
ARRAY JOIN 与 LIMIT BY 通过柯里化的辅助函数配合 Kysely 公开的$call应用(完整用法见 query-ast/README.md 的 recipes):
// ARRAY JOIN:mapKeys/mapValues 展开 cost_details 映射 db.selectFrom("observations") .select("environment") .$call(arrayJoin({ cost_key: mapKeys("cost_details"), cost: mapValues("cost_details") })) // … array join mapKeys(cost_details) as cost_key, mapValues(cost_details) as cost // LIMIT BY:按 (span_id, project_id) 每组取 event_ts 最新的 1 行 db.selectFrom("events_core") .select(["span_id", "project_id"]) .orderBy("event_ts", "desc") .$call(limitBy({ count: 1, columns: ["span_id", "project_id"] })) // … order by event_ts desc limit 1 by span_id, project_id为什么是$call(...)而不是流畅的.arrayJoin(...)方法?(kysely/README.md)一个真正的方法必须存在于每一个builder 实例上——包括原生 Kysely builder 通过.with((qb) => …)、子查询、defineView回调交给你的那些实例。要做到这一点,要么 fork Kysely 整个 builder 图,要么通过内部kysely/dist/...导入全局修改其原型(在 NodeNext 下被 Kysely 的exports映射挡住)。而柯里化辅助函数只是“builder 的普通函数”,在上述任何位置都能工作——这正是 ARRAY JOIN / LIMIT BY 能在 CTE、子查询、视图内部组合的原因,composition.test.ts把这个性质锁死为测试。
4.1arrayJoin会拓宽行类型
arrayJoin的每个{ alias: arrayExpr }条目都会加入 builder 的输出行类型(extensions.ts),因此外层查询在 CTE 体之上可以引用被产生的列,别名写错就是编译错误。元素的值类型是unknown——因为 Kysely 的Expression<T>会对类型参数做隐藏,精确的值类型需要给mapKeys/mapValues等加 branded 数组表达式包装,目前尚未实现;types.assert.ts用编译期断言钉住了这一拓宽行为。
还要区分两个同名概念:arrayJoin(子句)≠arrayJoin()(函数)。ClickHouse 两者都有:这里的 helper 构建的是 ARRAY JOIN子句;而行展开的 SELECT函数就是普通的eb.fn("arrayJoin", [...])。
4.2 metadata 取值:metadataValue降级为绑定的indexOf下标
从 metadata Map 取单键值的辅助函数metadataValue(tableAlias, key)会构造ArrayIndexNode:数组部分是metadata_values列引用,索引部分是indexOf(metadata_names, {key})函数节点,其中 key 是绑定的ValueNode,不是 SQL 字面量(extensions.ts):
db.selectFrom("events_core as e") .select((eb) => [metadataValue("e", "my_key").as("my_val")]) .where((eb) => eb(metadataValue("e", "my_key"), ">", 2)); // select metadata_values[indexof(e.metadata_names, {p:String})] as my_val …这样metadata[key]既能出现在 SELECT 也能出现在 WHERE,且全程参数化、可转义、可像普通表达式一样组合。
4.3 底层实现:插件挂节点 + 类型擦除的 ExpressionWrapper
mapKeys/mapValues通过ExpressionWrapper包装FunctionNode.create("mapKeys"/"mapValues", [columnRef(column)]),返回Expression<T[]>(元素类型默认string,可用泛型覆盖)。arrayJoin/limitBy则各自构建ArrayJoinPlugin/LimitByPlugin,插件在transformQuery中先用ClickHouseOperationNodeTransformer转换节点、再把自定义节点挂到 select 节点上(extensions.ts)。limitBy的列名支持"span_id"或"table.column"点分形式——由于这些插件在无ExpressionBuilder的作用域里直接构造 OperationNode,而 Kysely 只在其表达式层解析字符串引用,点分名字在此手动拆分(columnRef,见 extensions.ts)。
五、逃逸舱口与代价
再严谨的类型化构建器也有覆盖不到的地方,文档明确列出了三个逃逸口(kysely/README.md):
sql.ref("alias")—— 引用“非 schema 列”的 SELECT 别名的唯一途径(ClickHouse 允许GROUP BY/ 表达式复用别名,Kysely 的类型不建模这一点)。它完全无类型:拼错会未经检查直达 ClickHouse。务必克制使用。eb.fn("ch_function", [...])—— 任意 ClickHouse 函数。函数名是未检查的字符串,返回类型默认unknown,不做参数个数与返回值检查。- 新增列—— 按需把新列加进
schema.ts的表注册表(每个关系一条defineTable声明)。这条唯一声明同时驱动三份下游视图:Kysely 行类型(ClickHouseDatabase)、type-check pass 查询的运行时列类型映射(COLUMN_DATA_TYPES)、租户 pass 限定作用的租户化表集合(TENANTED_TABLES)——三份手工维护的映射会漂移,一个声明派生则不会。
schema.ts中已注册的关系包括traces、observations、events_core、scores,列类型体系是String/Float/DateTime/Array(String)/Map(String, Float)五类(schema.ts)。值得注意的是defineTable的tenant选项默认开启——这是 fail-closed 设计:新加的表即使忘了写tenant: true也依然会被租户限定,只有真正的全局关系才显式置false。
六、唯一一处 Kysely 内部耦合(升级风险点)
compiler.ts包装了 Kysely私有的visitNode/nodeStack,用来分发ArrayIndexNode(metadata[key]的编译),因为它不属于 Kysely 封闭的OperationNodekind(compiler.ts)。这也是Kysely 被锁定在 0.28.17的原因:升级时必须在同一 PR 内重新验证这个 hack。除此之外的一切(插件、方言、transformer 覆写)都只使用公开或文档化的 protected API——这是整个模块中唯一越过 Kysely 文档化表面伸手到内部的地方。
七、类型在编译期被断言
types.assert.ts存放仅tsc生效的断言:schema 类型、视图不透明性(view opacity)、arrayJoin行类型拓宽、limitBy类型保持。它永不运行,由schema.test.ts锚定在构建图里(否则会被 tree-shaking 丢弃);文件中的@ts-expect-error行必须保持“活着”——一旦断言意图落空(如类型意外变宽),这些行会变成多余的错误注释并导致编译失败。
八、验证变更:golden 测试与 clickhouse format
模块自带一套分层验证体系,全部命令可在packages/shared包内执行:
CLICKHOUSE_BIN=clickhouse pnpm --filter @langfuse/shared run test src/server/query-ast*.golden.test.ts套件(如catalog.golden.test.ts)断言compile(AST) ≡ referenceSQL——先编译、再用clickhouse format规范化(含位置参数名归一化)后与快照比较,因此需要本地clickhouse二进制,否则describe.skip。goldenHarness.ts在测试模式下于repositories/clickhouse.ts的执行接缝处捕获 SQL,无需真实 ClickHouse 服务器。- CI 的 SQL 等价性步骤:安装固定版本的
clickhouse二进制,按.golden.test.ts后缀名挑选并运行所有此类套件——所以新套件必须带该后缀才会在 CI 生效。该步骤刻意做成非阻塞:漂移只产生 warning 注解,不会让流水线失败(|| echo "::warning::"),稳定后才会晋升为 required check。 composition.test.ts断言原始编译器输出,不依赖clickhouse二进制,在所有环境运行。- 版本敏感性:
clickhouse format的输出随版本变化(例如UNION ALL分支的括号化在 25.x 与 26.x 之间改变过),因此提交的快照与 CI 固定的26.4.5.143(Langfuse v4 推荐的 ClickHouse 版本,同样固定在scripts/codex/cloud_services.sh)强耦合;升级 CI 版本时必须用新二进制在同一 PR 内重新生成快照,否则 golden 测试会漂移。
有意的 SQL 变更之后,用-u重新生成基线(见 query-ast/README.md):
pnpm --filter @langfuse/shared run test src/server/query-ast -- -u九、写在最后:这套设计的可迁移经验
回顾整个kysely/模块,可以提炼出四条可迁移到其他项目的工程经验:
- 封闭类型系统面前,用“额外字段 + 覆写 transformer/compiler”替代 fork:只要默认 transformer 会丢弃你的扩展,就覆写它来保留;只要默认编译器顺序固定,就整段覆写并在合适位置插入块。代价是升级时必须复验,所以要显式锁定依赖版本并留下升级说明。
- 安全约束放在唯一编译入口,而不是每个调用点:租户注入通过必填
ctx(编译期)+ 运行时校验 + 身份 stamp + 编译器拒绝未 stamp 树(运行期),形成“忘记写是不可能的”的多层防线。 - 用柯里化 helper +
$call获得“无处不在”的组合性:比起修改 builder 原型或 fork builder 图,普通函数在任何 builder 上下文(CTE 回调、子查询、视图定义)中都可用。 - 测试分层:需要外部二进制的 golden 等价性测试(CI 专用、可跳过)与无依赖的原始输出测试(处处可跑)分离,配合“永不运行但锚定在构建图里”的
tsc类型断言,让类型级契约与 SQL 级契约各自闭环。
对于 Langfuse 而言,这套设计意味着:查询构建代码可以享受 Kysely 的类型安全与表达式组合能力,同时获得 ClickHouse 的 ARRAY JOIN / LIMIT BY / metadata 下标等原生能力,而project_id租户隔离则从“纪律问题”变成了“架构事实”。后续若你需要在项目中为其他方言扩展 Kysely,本节列出的模式与陷阱可以直接作为设计蓝本。
【免费下载链接】langfuse🪢 Open source AI engineering platform: LLM evals, observability, metrics, prompt management, playground, datasets. Integrates with OpenTelemetry, LangChain, OpenAI SDK, LiteLLM, and more. 🍊YC W23项目地址: https://gitcode.com/GitHub_Trending/la/langfuse
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考