)
Langfuse 源码剖析基于 Kysely 0.28 构建的编译期专用 ClickHouse DialectARRAY 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.tsSQLite 的 adapter/introspector 只是占位任何.execute()调用都会直接抛错。唯一的受支持输出路径是compileClickhouseQuery(query, ctx)→{ sql, params }由仓储层repository layer交给既有的queryClickhouse执行通道详见 dialect.ts 与 compile.ts。这条边界意味着不要直接调用.execute()/.compile()——编译器会拒绝任何没有经过租户注入 pass 的语法树详见下文第三节。二、不 fork 的落地方式四种 ClickHouse 子句如何进入 ASTKysely 的OperationNodeKind联合类型是封闭的想要一等公民的节点种类就必须 fork 整个项目。Langfuse 的选择是让每个 ClickHouse 专属结构以“额外字段”或“特殊处理的节点”的形式挂在现有节点上再由被覆写的 transformer 与 compiler 负责产出并保留它们。四种结构的落地方案如下表源自 kysely/README.md子句落地方式是否 forkARRAY JOIN插件把ArrayJoinNode作为SelectQueryNode的额外字段挂载ClickHouseOperationNodeTransformer负责保留ClickHouseQueryCompiler.visitSelectQuery在 JOIN 之后、WHERE 之前输出否LIMIT BY插件以同样方式挂载LimitByNode编译器在 ORDER BY 之后、LIMIT 之前输出否metadataindexOf辅助函数构造ArrayIndexNode其索引子节点是包在绑定ValueNode上的FunctionNodeindexOftransformer 与 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 aliasLIMIT 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抛出QueryCompileErrorctx是必填参数省略它在编译期就是类型错误双重保险见 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 片段都会抛UnscopedRelationErrorKysely 自带的关键字片段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 JOINmapKeys/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 的ExpressionT会对类型参数做隐藏精确的值类型需要给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.tsdb.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 底层实现插件挂节点 类型擦除的 ExpressionWrappermapKeys/mapValues通过ExpressionWrapper包装FunctionNode.create(mapKeys/mapValues, [columnRef(column)])返回ExpressionT[]元素类型默认string可用泛型覆盖。arrayJoin/limitBy则各自构建ArrayJoinPlugin/LimitByPlugin插件在transformQuery中先用ClickHouseOperationNodeTransformer转换节点、再把自定义节点挂到 select 节点上extensions.ts。limitBy的列名支持span_id或table.column点分形式——由于这些插件在无ExpressionBuilder的作用域里直接构造 OperationNode而 Kysely 只在其表达式层解析字符串引用点分名字在此手动拆分columnRef见 extensions.ts。五、逃逸舱口与代价再严谨的类型化构建器也有覆盖不到的地方文档明确列出了三个逃逸口kysely/README.mdsql.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用来分发ArrayIndexNodemetadata[key]的编译因为它不属于 Kysely 封闭的OperationNodekindcompiler.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_BINclickhouse 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.143Langfuse v4 推荐的 ClickHouse 版本同样固定在scripts/codex/cloud_services.sh强耦合升级 CI 版本时必须用新二进制在同一 PR 内重新生成快照否则 golden 测试会漂移。有意的 SQL 变更之后用-u重新生成基线见 query-ast/README.mdpnpm --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),仅供参考