Skip to content

第 5 篇 · 5.1 packages/shared 是契约中心

学习目标

完成本节后,你将能够:

  1. 判断什么代码应该进入 packages/shared,什么不应该。
  2. 说清 Langfuse 的主要契约:domain、queue、filter、query、client。
  3. 理解为什么 shared 变更通常比单个页面或 processor 变更风险更高。

5.1.1 先给结论:shared 不是 utils 包

packages/shared 的价值不是“复用代码”,而是把多个运行时必须共同理解的规则放在一个地方。

Langfuse 有两个主要运行时:

  • web:同步入口,处理 UI、tRPC、Public API、ingestion HTTP。
  • worker:异步执行,消费 BullMQ job,写 ClickHouse,跑 eval/export/retention/webhook。

它们不能互相 import,但必须对一些东西达成一致:

所以判断一个抽象是否该放进 shared,只问一句:

如果这里改了,是否至少两个运行时必须同时理解?

如果答案是是,它通常属于 shared。如果只影响一个页面、一个弹窗、一个 processor 内部步骤,就不应该提升为 shared 契约。

5.1.2 五类核心契约

契约类型作用生产者消费者代表源码
Domain contract描述 trace、observation、score、prompt、event 等领域对象形状API、worker、repositoryUI、worker、tests、SDK-facing adapterspackages/shared/src/domain/**
Queue contract描述 job name、queue name、payload schemaweb/shared/worker producerworker consumer、DLQ、replay scriptspackages/shared/src/server/queues.ts
Filter contract描述 UI/API 能表达哪些过滤条件UI、Search Bar、Public API filter paramClickHouse SQL builder、Prisma fallback、InMemoryFilterServicepackages/shared/src/interfaces/filters.ts
Query contract描述事件表字段、field set、tenant filter、events_core/full 选择services/repositoriesClickHouseevent-query-builder.tsfactory.ts
Client/storage contract描述如何连接 DB、Redis、ClickHouse、S3,如何带 telemetry contextweb、worker、scripts状态层db.tsserver/redis/**StorageService

这五类契约合起来,才是当前 repo 的架构骨架。

5.1.3 契约和实现的区别

以 ingestion 为例:

是契约吗原因
IngestionEvent queue schemaproducer 和 worker consumer 都要理解。
bucketPrefix optional影响 rolling deploy 和旧 job 兼容。
ingestionQueueProcessorBuilder 的局部变量不是只属于 worker consumer 内部实现。
IngestionService.mergeAndWrite 的实体分派半契约worker 内部实现,但它输出 ClickHouse record,受 domain/CH schema 约束。
ClickhouseWriter retry 策略实现策略多个 processor 共享,但不跨进程传输。

这能帮助你判断改动风险:改 queues.ts 往往比改某个 processor 内部变量风险更高。

5.1.4 为什么 queue payload 字段经常 optional

队列契约和普通函数参数不同。函数参数只在当前进程里传递;queue payload 会被序列化到 Redis,可能在旧版本 producer 和新版本 worker 之间流动。

IngestionEvent.data.bucketPrefix 是一个典型例子:

  • 新 producer 会把自己实际写入 S3 的绝对 prefix 放进 job。
  • 新 worker 优先使用这个 prefix,避免重新按本地 env 拼路径导致漂移。
  • 字段仍然 optional,因为 Redis 里可能还有旧 producer 入队的 job。
  • worker 需要 fallback 到旧的 raw prefix 逻辑。

这就是为什么教程反复强调“queue payload 是跨进程协议,不是普通 DTO”。

5.1.5 FilterState 是查询语言,不只是 UI 状态

FilterState 看起来像前端 filter 数组,但它实际承担三件事:

  1. UI 侧用于 sidebar filter、Search Bar、URL query。
  2. API/service 侧作为过滤参数。
  3. 后端 lowering 成 ClickHouse SQL 或 Prisma where。

它的 schema 在 packages/shared/src/interfaces/filters.ts

  • timeFilter 限定 datetime operator;
  • stringFilternumberFilterbooleanFilter 限定基础类型;
  • stringObjectFilternumberObjectFilter 表达 metadata/scores 这类 key-value 字段;
  • eventsTableSingleFilter 扩展了 matches 全文检索 operator。

所以新增一个 filter operator 不是“前端加一个选项”那么简单。你还要确认:

  • UI 能生成它;
  • Zod schema 接受它;
  • createFilterFromFilterState 能 lower 它;
  • ClickHouse column mapping 支持它;
  • Search Bar grammar 和 validate/lower 逻辑保持一致。

5.1.6 QueryBuilder 是状态层保护边界

EventsQueryBuilder 的作用不是拼字符串,而是把事件查询的几个硬约束集中起来:

约束为什么重要
constructor 传 projectIdtenant filter 默认进入 WHERE,避免跨项目读取。
FIELD_SETS列表页只选必要字段,减少宽表读取成本。
selectIO(truncated)默认不读完整 input/output,需要时显式选择。
events_core / events_full轻量查询走 core,完整 I/O 和 metadata 走 full。
split query先在 core 上 filter/order/limit,再对命中的行回 full 拿大字段。

这就是“查询契约”:调用者表达自己需要什么字段,builder 决定用哪个表、带哪些参数、如何安全组合 SQL。

5.1.7 改 shared 前的检查清单

改 shared 之前先问:

  1. 这个类型是不是被 webworker 同时消费?
  2. 这个字段是否会出现在 Redis job、S3 path、ClickHouse row 或 Public API response 中?
  3. 旧版本 producer 产生的数据/队列是否还能被新版本 consumer 读取?
  4. 是否需要 Zod schema 保持运行时校验?
  5. 是否要更新 Fern API 或 generated client?
  6. 是否要加 ClickHouse migration 或 Prisma migration?
  7. 是否要补 web 和 worker 两侧测试?

如果你要做类似 infra,这个检查清单比“怎么写 TypeScript 类型”更重要。真正的风险在边界和兼容性。

本篇后续