在 Effect 里替换默认 Logger/Tracer:为什么需要一个树状观测库

Effect 本来就有不错的日志、Cause 和 tracing 语义,但很多开发者在真实项目里都会碰到同一个问题:语义很强,输出却不一定好读。尤其当程序进入多层 Effect.withSpan、并发分支、重试和嵌套调用之后,终端里常见的体验是信息很多,但上下文关系并不直观。你能看到某个任务开始了、失败了、带了哪些属性,却很难一眼看出“它是谁的子任务”“失败发生在整棵执行树的哪个位置”“哪些字段是新增上下文,哪些只是从父层一路继承下来的噪音”。

这正是树状输出值得单独做成一个库的原因。它不是为了把日志“美化”一下,而是为了把 Effect 已经拥有的执行结构,重新表达成更适合人类阅读的形态。对终端调试来说,层级关系、局部失败和活跃路径,往往比一串平铺的行日志更重要。

更进一步,如果目标是替换默认 Logger/Tracer,而不是在业务代码里零散地插 console.log,那它就不能只是一个 formatter。它必须是一个真正贴合 Effect 风格的观测层:可注入、可组合、可局部覆盖,并且不破坏原有 tracing 语义。

它不是另一套 tracing,而是对 Effect Tracer 的增强

这个树状观测库的核心抽象不是“自己发明 span 系统”,而是站在 Effect Tracer 之上做增强。也就是说,业务代码仍然使用原来的 Effect.withSpan、原来的 Cause、原来的 Exit,传播、取消、中断和错误语义都不变;变化只发生在观测层:span 生命周期被旁路记录,然后被渲染成一棵树。

这件事很关键。很多自定义 tracing 方案的问题不在功能,而在语义漂移。一旦你绕开框架原生的 tracer,自建一套开始/结束协议,后面就会不断遇到“上下文不一致”“span 嵌套关系丢失”“业务层和观测层 API 分叉”的问题。基于 Effect 原生 Tracer 做包装,等于把这些风险降到最低。

因此,这个库更准确的定位应该是:

  • 一个可替换默认观测输出的树状 Logger/Tracer 层
  • 一个把 Effect span 生命周期投影为树结构的 renderer
  • 一个遵守 Effect Layer/Service 组合方式的可插拔实现

为什么默认输出不够

默认日志并没有错,它只是偏“事件流”,不偏“结构视图”。

当系统比较简单时,平铺日志已经够用。但一旦进入下面这些场景,阅读成本会迅速上升:

  • 一个顶层任务内部再调用多个子任务,且子任务还会继续嵌套
  • 并发分支很多,开始和结束事件交错出现
  • 失败不是顶层直接抛出,而是在深层 span 中发生,再一路汇总成 Cause
  • 多个 span 共用一批属性,导致每一层都在重复打印相同上下文

这时开发者真正想要的,不只是“看到更多字段”,而是“看见结构”。树状输出的价值就在这里:它把执行路径、父子关系、当前活跃节点、失败归属位置和局部上下文差异统一到一个视图里。

从 Effect 开发者的视角看,这其实很自然。Effect 本身就鼓励你把执行拆成有边界的 effect、service 和 layer,那么观测层也应该尊重这种结构,而不是把最终输出压平成一摊行文本。

还有一个经常被忽略的现实因素:很多本地开发工具、自动化脚本和小型 CLI,并不值得为了调试链路再去接一套完整的可观测性平台。你未必想把数据导到 Grafana、Jaeger 或 OTLP collector,也未必想为一个本地命令配置 exporter、resource、采样和后端存储。对这些场景来说,终端本身就是最自然的观测界面。

如果一个库能直接把 Effect.withSpanEffect.fn("span") 形成的嵌套关系渲染成树,并且把失败、中断、重试原因和下一次重试等待时间一起呈现出来,它在本地开发里的价值就会非常高。因为你看到的不是平台聚合后的遥远遥测,而是这一次命令执行的现场。

核心原理:挂接 span 生命周期,旁路记录,保持语义不变

这个库最重要的设计,不在于终端里如何画树,而在于它如何接进 Effect。

接入方式非常克制。它先从环境中拿到原始 Tracer,然后返回一个包装后的 tracer。新的 tracer 在创建 span 时仍然调用原始实现,拿到的也仍然是真实 span;不同之处在于,它会在两个确定的生命周期节点旁路记录一份观测数据:

  1. 创建 span 时,记录一次 start
  2. 结束 span 时,记录一次 end

典型做法是:

  • 先委托原 tracer 创建 span
  • 立刻把 span 的 id、父子关系、开始时间和名称交给树状 logger
  • 暂存原始 span.end
  • 重写 span.end,在调用原始结束逻辑之前或之后补一次记录

这样一来,树状 logger 就能在完全不干涉业务执行的情况下,维护一棵随生命周期变化的执行树。

这里最值得强调的是“语义不变”。

它没有改写业务 API,没有要求开发者换用另一组 span 接口,也没有抢走原 Tracer 的职责。真正的 tracing 依旧由 Effect 原生机制负责,树状库只是附加了一层可读性更高的观测视图。换句话说,它记录的是同一批 span,而不是影子 span;它增强的是呈现,不是协议。

这也是为什么这种方案比“额外打一层业务日志”更稳。后者通常只能看到局部代码路径,前者看到的是框架已经帮你维护好的生命周期边界。

一个最小接入例子

树状观测库的价值,在于业务代码几乎不用为了“配合日志”而改写。你仍然按正常的 Effect 风格写程序,只是在运行时提供一个替换后的 layer。

下面是一个足够接近真实用法的例子:

import { Effect } from "effect"
import { makeSpanTreeLayer } from "@effect-x/log-tree"

const scanChanges = Effect.fn("Scan changes")(function* () {
  yield* Effect.sleep("60 millis")
  return yield* Effect.fail("no changes")
})

const runCommit = Effect.fn("Commit Run")(function* () {
  return yield* scanChanges()
}).pipe(
  Effect.withSpan("Commit Run")
)

const program = Effect.gen(function* () {
  yield* Effect.succeed("resolved").pipe(
    Effect.withSpan("Resolve provider")
  )

  yield* Effect.succeed("parsed").pipe(
    Effect.withSpan("Commit ParseTrailers")
  )

  yield* runCommit
}).pipe(
  Effect.withSpan("Command Commit", {
    attributes: {
      vcs: "jj",
      dry_run: true,
      no_stage: false,
      amend: false,
    },
  })
)

Effect.runPromise(
  program.pipe(
    Effect.provide(
      makeSpanTreeLayer({
        formatSpanName: (name) => name,
      })
    )
  )
)

这里的重点不是 API 写法,而是接入方式本身:

  • 业务仍然靠 Effect.fnEffect.withSpan 描述结构
  • 树状输出不要求你手工维护父子节点
  • 是否启用树状观测,由运行时 Layer 决定
  • 当你去掉这个 layer,程序语义并不会改变

这就是一个好的 Effect 扩展应该有的样子。它尊重原始抽象,而不是反客为主。

输出例子:树状视图如何把现场直接带到终端

下面这类输出,比平铺日志更能说明问题:

✕ Git agent  running=0  done=2  failed=3  interrupted=0  elapsed=91ms
✕ Command Commit vcs=jj dry_run=true no_stage=false amend=false 91ms
├─ ✓ Resolve provider 3ms
├─ ✓ Commit ParseTrailers <1ms
└─ ✕ Commit Run 61ms
   └─ ✕ Scan changes 61ms
      ↳ no changes
Active: command failed
no changes

这段输出之所以有用,不只是因为它“长得像树”,而是因为它把几个关键问题同时回答了:

  • 顶层命令 Command Commit 失败了
  • 前两个子步骤已经成功完成
  • 失败发生在 Commit Run -> Scan changes 这条路径上
  • 失败原因被压缩成了贴近节点的一行摘要 no changes

对一个本地 CLI 而言,这已经足够让人直接定位问题,而不需要再去搜索上下几十行日志。

如果命令是长时间运行的,交互式终端还可以给出更像“当前状态面板”的视图,例如:

◐ Git agent  running=2  done=5  failed=0  interrupted=0  elapsed=3.2s
◐ Command Commit vcs=git dry_run=false 3.2s
├─ ✓ Resolve provider 2ms
├─ ✓ Load config 1ms
├─ ◐ Generate message 2.4s
│  ├─ ✓ Build prompt 9ms
│  └─ ◐ Call model model=gpt-5.4 retry=1 next_retry_in=800ms
└─ ◐ Commit Run 120ms
Active: Generate message > Call model

这种输出的价值在于,你不只知道“程序还在跑”,还知道它卡在第几层、当前活跃的是哪条路径、重试发生在什么位置、下一次重试还要等多久。

同样地,中断也不再只是一个含糊的退出事件,而可以被明确表示为结构中的某个终止结果:

! Git agent  running=0  done=4  failed=0  interrupted=1  elapsed=1.8s
! Command Commit vcs=git dry_run=false 1.8s
├─ ✓ Resolve provider 2ms
├─ ✓ Load config 1ms
└─ ! Generate message 1.7s
   └─ ! Call model 1.7s
      ↳ interrupted by user
Active: interrupted

这里最重要的不是符号,而是语义上的清晰度。用户中断、业务失败、系统 defect、可恢复重试,这些本来就不是同一种事情;一个基于 ExitCause 的树状 tracer,可以把它们在输出上明确区分开来。

为什么这种设计是先进的

如果只把它理解为“一个树状终端输出工具”,会低估它的价值。它真正先进的地方,在于它把可观测性提升成了 Effect 世界中的一等公民。

第一,它是可替换默认实现的。不是在某个函数里偷偷打印,而是通过 Layer/Service 注入,明确告诉运行时:这里有一个新的 Logger/Tracer 组合,可以覆盖默认观测行为。

第二,它是与业务配置解耦的。span 名称怎么显示、属性怎样裁剪、失败摘要如何格式化,这些都不应该硬编码在核心 renderer 里。业务语义属于调用方,运行时渲染属于库本身。把两者拆开之后,这个库才能既有合理默认,又能被不同项目复用。

第三,它比传统“日志库”更贴近执行语义。它依赖的不是字符串拼接,而是 SpanExitCause 这些 Effect 原生结构,所以它天然知道什么时候是成功、什么时候是失败、什么时候是中断,也知道父子 span 之间如何关联。这种信息密度,不是普通日志中间件能轻易补出来的。

第四,它允许同一种程序在不同运行环境里切换不同呈现方式。TTY 下可以做动态树视图,非交互环境下可以退化成稳定文本;测试里可以拿 snapshot,CLI 里可以做持续刷新。这些都不需要改业务代码,只需要替换 Layer。

对现代应用来说,这才是更高级的观测方式:不是把更多内容塞进日志,而是让观测能力跟运行时组合模型保持同构。

如果把它放回最常见的使用场景里,这种“先进”会更具体一些。对本地开发工具来说,先进不等于引入更重的基础设施,而等于在不引入额外平台的情况下,仍然获得强语义、强结构、强可读性的观测体验。你不需要先把信息发到远端,才能知道程序为什么失败、重试了几次、是用户中断还是业务错误;终端本身就能成为足够强的观测面。

可组合性才是它真正符合 Effect 风格的地方

这个库如果只有一个“大而全”的入口,其实还不够 Effect。它更合理的做法是拆成至少两层:

  • makeSpanTreeLoggerLayer():构造树状 logger service
  • makeSpanTreeLayer():在 logger 之上进一步提供 tracer 替换层

这样的分层非常关键。

makeSpanTreeLoggerLayer() 代表“先把记录与渲染能力注册进环境”。如果你只想在测试里收集一棵树、只想把输出写进内存缓冲区、或者只想复用 renderer 而不覆盖 tracer,这一层就足够了。

makeSpanTreeLayer() 则是“给我一个开箱即用的完整接入方式”。它把 logger 和 tracer 包在一起,适合直接替换默认实现,让应用里原本的 withSpan 自动获得树状观测输出。

这就是典型的 Effect 风格:能力拆成可组合的 layer,而不是压成单一全局对象。开发者可以像组合任何其他 Layer 一样组合它、覆盖它、局部替换它。

例如,全局接入可以很直接:

const AppLayer = makeSpanTreeLayer({
  formatSpanName: (name) => name,
  formatFailureSummary: (cause) => /* ... */,
})

局部替换也同样自然:

program.pipe(
  Effect.provide(AppLayer),
  Effect.provide(makeSpanTreeLayer(localDebugConfig)),
)

重点不在于这段 API 本身,而在于它表达了一个更重要的原则:观测策略不是写死在应用入口之外的黑盒,而是运行时可组合的一部分。你可以像覆盖数据库实现、缓存实现、鉴权实现一样,覆盖 Logger/Tracer 实现。

树状输出为什么比平铺日志更有价值

树状输出真正解决的,不是“好看”,而是“更接近调试时的大脑模型”。

当你排查一个复杂 Effect 程序时,你通常会在脑中问这些问题:

  • 现在最外层任务卡在哪个子步骤?
  • 这个失败属于哪个分支?
  • 当前 span 和父 span 相比,多了哪些关键属性?
  • 并发执行时,哪些子树已经完成,哪些还在跑?

树状视图天然适合回答这些问题。它把时间线的一部分信息压缩进结构里,让你不需要在几十行开始/结束日志之间来回跳转。只要 renderer 再加上适度的属性去重、失败摘要提炼和 TTY/非 TTY 分流,终端体验就会从“能看”变成“能定位问题”。

这里也能看出一个重要取舍:这个库无需暴露过多内部数据结构细节。Map、children 索引、刷新频率这些都只是实现手段;真正值得保留的是设计原则,即如何让 span 生命周期稳定地投影为一棵可读、可刷新的树。

最终落点:用自定义 Logger/Tracer 替换默认实现

这类库最有说服力的地方,不是“它成功从某个仓库里拆出来了”,而是它证明了一件事:在 Effect 里,你完全可以不用接受默认观测输出的形态。

你可以保留 Effect 原生 tracing 语义,同时把默认 Logger/Tracer 替换成一个树状、可读、可扩展的实现。你可以让 span 生命周期自动汇聚成结构化视图,让失败摘要真正贴着出错节点显示,让业务配置和运行时渲染彼此解耦,还能像组合其他 Layer 一样组合、覆盖、局部启用这一整套能力。

从这个意义上说,树状观测库不是一个“日志美化包”,而是一种更符合 Effect 气质的观测方式:不脱离原生语义,不侵入业务代码,却把复杂执行过程重新呈现为人能快速理解的结构。

如果默认日志给你的是事件列表,那么一个设计正确的树状 Logger/Tracer,给你的就是执行现场。