Langfuse 源码剖析:基于 Kysely 0.28 构建的编译期专用 ClickHouse Dialect(ARRAY JOIN / LIMIT BY / 租户注入实现指南)
2026/9/10 13:09:13 网站建设 项目流程

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()返回一个接入了DummyDriverKysely实例(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上的FunctionNodeindexOf);transformer 与 compiler 对该节点做特殊处理,无插件
虚拟视图插件把selectFrom(viewName)重写为 WITH CTE;外层类型只暴露视图选中的列

这里有一个关键细节:这些都是真实的节点对象,其子节点是受追踪的 KyselyFunctionNode/ColumnNode/ValueNode/IdentifierNode,而不是RawNode字符串拼接。也就是说,ARRAY JOIN 表达式、LIMIT BY 列、metadata 下标里的键值都会像普通 Kysely 表达式一样被参数化绑定、被类型检查、被 transformer 递归遍历。

2.1 节点定义:为什么是“额外字段”而不是“新 kind”

打开 nodes.ts 可以看到,ArrayJoinNodeLimitByNodeArrayIndexNode都带有kind字段(如"ArrayJoinNode"),但它们不是Kysely 的OperationNode种类——因为封闭的 kind 联合会让自定义 kind 坍缩为never。因此:

  • ArrayJoinNodeLimitByNode作为可选字段arrayJoins?/limitBy?挂在扩展类型ClickHouseSelectQueryNode = SelectQueryNode & { arrayJoins?; limitBy? }上;
  • 每个节点都通过Object.freeze冻结,子节点只读;
  • ArrayJoinNode.create接受itemsvariant,其中variant支持"default" | "left" | "inner"三种变体,分别编译为array joinleft array joininner 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 专属逻辑:

  1. ARRAY JOIN 块:在 JOIN 列表之后、WHERE 之前,遍历chNode.arrayJoins逐个输出array join <expr> as <alias>
  2. 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)。其执行流程如下:

  1. requireExecutionContext(ctx)校验:缺失/空的ExecutionContext抛出QueryCompileErrorctx是必填参数,省略它在编译期就是类型错误(双重保险,见 tenancy.ts)。
  2. TenancyInjectionPlugin遍历每个 FROM/JOIN,为每张租户化物理表注入project_id = {projectId},除非语法树中已经存在能证明作用域被覆盖的谓词;随后用WeakSet对整棵树做身份 stamp(复制一个langfuseTenancy属性字段并不算数)。
  3. ClickHouseQueryCompilercompileQuery入口调用assertTenancyStamped没有 stamp 就拒绝输出 SQL——所以绕过插件直接qb.compile()同样会失败(tenancy.ts)。
  4. 原始 SQL 表源(selectFrom(sql\...`))以及任何在 SELECT/WHERE 中嵌入SELECT/FROM/JOIN的 raw 片段都会抛UnscopedRelationError;Kysely 自带的关键字片段(asc/desc`)不算关系,不受影响。

3.1 “已覆盖”判定的精确语义

predicateCovers的判定远比“出现过 project_id 就行”严格(tenancy.ts),值得展开:

  • 左右两侧都要匹配:左操作数必须是该表的project_id列,右操作数必须是来自ExecutionContext的字面projectIdproject_id = <其他项目>o.project_id = t.project_id这类谓词不能证明作用域,不会被算作“已覆盖”,pass 会继续注入正确谓词;
  • 多租户关系时要求表限定:当作用域内存在多于一张租户化关系时,未限定的project_id = …具有歧义——无法证明它约束的是哪张具体表,因此必须使用带表限定的引用才承认“已覆盖”;只有单个租户化关系时,未限定引用是无歧义的,可以接受;
  • 限定符匹配规则:带限定的谓词只有在限定符与该关系的别名(有别名时)或表名(无别名时)一致时才覆盖该关系。所以scores AS tracestraces 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中已注册的关系包括tracesobservationsevents_corescores,列类型体系是String/Float/DateTime/Array(String)/Map(String, Float)五类(schema.ts)。值得注意的是defineTabletenant选项默认开启——这是 fail-closed 设计:新加的表即使忘了写tenant: true也依然会被租户限定,只有真正的全局关系才显式置false

六、唯一一处 Kysely 内部耦合(升级风险点)

compiler.ts包装了 Kysely私有visitNode/nodeStack,用来分发ArrayIndexNodemetadata[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.skipgoldenHarness.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/模块,可以提炼出四条可迁移到其他项目的工程经验:

  1. 封闭类型系统面前,用“额外字段 + 覆写 transformer/compiler”替代 fork:只要默认 transformer 会丢弃你的扩展,就覆写它来保留;只要默认编译器顺序固定,就整段覆写并在合适位置插入块。代价是升级时必须复验,所以要显式锁定依赖版本并留下升级说明
  2. 安全约束放在唯一编译入口,而不是每个调用点:租户注入通过必填ctx(编译期)+ 运行时校验 + 身份 stamp + 编译器拒绝未 stamp 树(运行期),形成“忘记写是不可能的”的多层防线。
  3. 用柯里化 helper +$call获得“无处不在”的组合性:比起修改 builder 原型或 fork builder 图,普通函数在任何 builder 上下文(CTE 回调、子查询、视图定义)中都可用。
  4. 测试分层:需要外部二进制的 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询