Skip to content

observe ​

github.com/Luo-root/pulse/observe 把 Pulse 引擎的 Observer 回调转换为结构化 Record,再交给宿主选择的 Sink。本页是配置、事件和出口的包参考;逐步接入方法见图观测指南。

依赖方向为 pulse ← observe。本包只依赖根包 pulse 和标准库;只使用根包时,无须引入观测包。

版本与安装 ​

v0.5.1 需要 Go 1.26.0 或更高版本,与 Durable 统一。v0.5.0 的 Go 1.25.0+ 要求仅适用于历史版本。本页以 v0.5.1 为 API 基线,安装时固定该版本;开发版或尚未合并的修改按安装说明选择主线或对应本地源码:

bash
go get github.com/Luo-root/pulse@v0.5.1

完整示例 ​

以下程序创建一个不需要输入的节点,将这一轮的记录写到标准输出。所有错误都向上传递;包括运行失败的路径,也会在退出前刷新缓冲并检查刷新错误。

go
package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"os"

	"github.com/Luo-root/pulse"
	"github.com/Luo-root/pulse/observe"
)

func main() {
	if err := run(); err != nil {
		log.Fatal(err)
	}
}

func run() (err error) {
	sink := observe.NewLineSink(os.Stdout)
	defer func() {
		if flushErr := sink.Flush(); flushErr != nil {
			err = errors.Join(err, fmt.Errorf("flush records: %w", flushErr))
		}
	}()

	g, err := pulse.New("demo", "A single observed node")
	if err != nil {
		return err
	}
	if err := g.Add(pulse.NewNode("work", "Complete one step", nil, nil,
		func(*pulse.RunCtx) error { return nil },
	)); err != nil {
		return err
	}

	obs, err := observe.NewRecordObserver(observe.ObserveConfig{
		Sink:    sink,
		HostID:  "demo-host",
		TraceID: observe.NewTraceID(),
	})
	if err != nil {
		return err
	}
	if err := g.Run(context.Background(), pulse.Inputs{}, pulse.WithExecObserver(obs)); err != nil {
		return err
	}
	return nil
}

正常运行会输出 pulse.graph_started、pulse.node_wait_finished、pulse.node_run_finished 和 pulse.graph_finished。时间、耗时与执行身份来自实际运行,每次都可能不同。

g.Run 包含创建 Execution、Start 和 Wait,因此返回前已发出运行结束回调。LineSink 默认缓冲 32 KiB,最后一批未必达到阈值;应在生产者停止写入后、关闭底层 writer 或退出进程前调用 Flush() 并检查结果。需要每条即时可见时,可在构造时加 observe.WithImmediate(),仍应检查最后的 Flush()。

配置与身份 ​

NewRecordObserver(cfg) 返回 (pulse.Observer, error);cfg.Sink 为 nil 时返回 ErrNilSink。

ObserveConfig 字段用途使用规则
Sink记录出口必填,实现必须遵守下文的并发与只读契约
HostID宿主的稳定身份由宿主提供,例如服务或实例标识;空值不输出
TraceID宿主的一次请求或逻辑运行的关联身份由宿主单一生成源提供;同一次逻辑运行的适配器共享,独立运行应换新值
Path回调没有路径时的兜底路径通常留空;优先使用 Site.Path,非空兜底也会作用于根图记录

上述身份与引擎的执行身份各有职责:

身份来源区分什么
pulse.graphSite.Graph图定义
pulse.executionSite.Execution / Execution.ID()每一份独立 Execution;同一图定义跑两次会得到不同值
pulse.pathSite.Path,否则 ObserveConfig.Path子图的挂载位置,例如 step、outer/inner
HostID宿主配置哪个宿主产生记录
TraceID宿主配置跨图、跨组件的业务关联

节点记录还带 pulse.node;运行级记录不带节点属性。根图的 Site.Path 为空,普通配置下不写 pulse.path。所有引擎运行记录都带 pulse.execution,不能仅按 graph + path 汇总并发运行。

观察者内部按执行身份记账,可安全服务多个图和并发执行。但复用一个带固定 TraceID 的配置,会把独立请求关联到一起。对可重复运行的图,建议像示例一样每轮创建配置与观察者,用 pulse.WithExecObserver(obs) 注入;Sink 可以共享。定义级默认观察者使用 pulse.WithObserver(obs)。这两个选项都是覆盖单个观察者;需要组合宿主观察者时,用 pulse.MultiObserver。

子图未声明自己的观察者时继承父执行的有效观察者,每条回调已携带挂载位置,因此一个 Sink 可以接收整棵执行树的记录。树形结构可从 Topology 和 Execution 快照读取,Record 本身保持平面结构。

NewTraceID() 返回不透明的关联字符串,进程内并发唯一,跨进程通过随机段区分。它不是 W3C trace-id 格式;需要接入已有 trace context 时,应注入宿主自己的 trace ID。span、parent-id 和 trace-flags 由宿主管理。

Record 字段 ​

字段类型含义
Timetime.Time记录时间;零值由 LineSink / MemorySink 补当前时间,SlogSink 使用 logger handler 的时间
HostID / TraceIDstring宿主身份与关联身份
SourceSource本包产生的记录为 SourceObserver("observe");宿主直写时可使用自己的来源
Eventstring事件名,见下表
Durationtime.Duration实际观测到的分段或整轮墙钟耗时;未计时为零
Statusstringrunning 或终止原因;宿主直写时可用自己的状态词表
Errerror失败或取消的关联错误;正常跳过为 nil
AttrsAttrs开放的标量属性,业务维度通过此字段加入

Attrs ​

Attrs 零值可用;通过 observe.Set(&attrs, key, value) 写入,通过 observe.Get[T](attrs, key) 读取,并检查返回的 ok。值域为 string、int64、float64、bool,以及底层类型属于这四类的命名类型。

属性名使用 <组件>.<字段>,例如 app.model。引擎属性的常量由 pulse 定义。首次写入决定顺序,同名覆盖保持原位置;Range、LineSink 和 SlogSink 按插入序输出,MarshalJSON 按键排序。

Record 不提供任意对象或业务值容器。标量类型约束不能识别字符串中的敏感内容;需要过滤、截断或限制属性数量时,由宿主 Sink 处理。

事件参考 ​

以下事件常量由 observe 定义。一次成功启动并通过 Wait 收尾的 Execution 有两条运行级记录;节点记录数量取决于实际经过哪些阶段。

运行级事件 ​

常量 / 事件名产生时机DurationStatus
EventGraphStarted / pulse.graph_started成功通过启动校验,在提交任何节点 goroutine 前0running
EventGraphFinished / pulse.graph_finished全部节点终止后、Wait 返回前两个图回调之间的整轮耗时completed / failed / canceled

两条记录将本 Execution 的节点记录夹在中间。启动校验失败不会发出 started;只调用 Start、不调用 Wait 的宿主收不到 finished。重复或并发 Wait 不会重复发出 finished。

节点事件 ​

常量 / 事件名产生时机DurationStatus
EventNodeWaitFinished / pulse.node_wait_finished等待段结束:进入 Run,或等待中终止等待段耗时进入 Run 时为 running,否则为节点终态
EventNodeRunFinished / pulse.node_run_finished已进入 Run 的节点终止执行段耗时completed / skipped / failed / canceled
EventNodeFinished / pulse.node_finished只收到 Finished 的失败或取消,例如切面在调用 next 前拒绝执行0,未观测到分段failed / canceled

Observer 的五种回调与上述五种 Record 事件并非一一对应:Waiting 和 Running 启动或结束计时,Finished 负责收尾。Retry 不重复发 Waiting / Running,因此重试不会变成多套节点分段记录,执行段可包含多次尝试及间隔。

自动切面记录 ​

NewRecordObserver 同时实现 Core 的可选 pulse.AspectObserver。安装方式不变:业务原有的 pulse.Retry(3, delay) 和 pulse.Timeout(d) 自动生成 EventAspect(pulse.aspect),无需在业务体加日志,也不重复节点 Waiting/Running。原 Observer 实现仍只需五个方法;MultiObserver 对可选接口逐成员隔离扇出。

pulse.aspect.phase实际事实Status / Duration
started / finished进入/离开一次切面调用running / 实际终态;finished 测本次调用耗时
attempt_started / attempt_finished实际分配或认回的 Retry 尝试进入/结束running / 实际终态;finished 测本次尝试耗时
retry确实分配了后续尝试,随后发 attempt_startedrunning;0,不把首次成功说成重试
backoff_started / backoff_finished失败后实际等待,含等待被取消running / completed 或 canceled;finished 测等待耗时
deadline本层 Timeout 的计时器确实到期expired;截至报告时的耗时,不代表内层已经退出

每条记录带 Site 与节点身份,外加 pulse.aspect(retry / timeout / custom)、pulse.aspect.boundary、阶段。边界只在 Execution + NodeID 内唯一;pulse.aspect.parent_boundary 与 pulse.aspect.parent_attempt 关联直接外层。Retry 的 pulse.attempt 从 1 开始,pulse.attempt.limit 包括首次;退避配置是 pulse.retry.delay_ns,Timeout 配置是 pulse.timeout.duration_ns。结束记录的 Err 保留真实失败;正常 skip 是 skipped 且 Err=nil,不重试。

检查点尝试另外带 pulse.attempt.scope / pulse.attempt.parent_scope,直接取自持久游标;pulse.attempt.origin 为 new、resumed 或 unknown。桥的 CheckpointAttempt.Resumed 非空时,false 表示新分配,true 表示认回活跃游标;旧桥 nil 明确为 unknown。认回编号 2 也不补造一条“刚重试”的记录。完整恢复节点不运行切面、不补历史。耗时只覆盖本次进程中的调用,稳定 RunID 由 Durable 的 Sink 适配补充,不能用 Execution ID 替代。切面成功也不代表外层切面或完整节点 Commit 已成功。

自定义切面没有天然稳定名称,必须显式包装:

go
observedApproval := pulse.NamedAspect("approval", approvalAspect)

包装只报告该调用的 started/finished;审批原因等业务事实仍由该切面自行写入 Record/Attrs/Sink,不猜函数名,也不推测内部尝试。name 为空或切面为 nil 时,NamedAspect 在构造时 panic。切面中可用 rc.Site() 读取准确位置副本,Fork/Timeout 保留身份;它不提供业务值或可变执行对象。

跳过、短路与恢复 ​

节点经历产生的节点记录
全部输入跳过,未进入业务体一条 skipped 的等待结束记录
业务体进入 Run 后返回 pulse.NoValue()等待结束记录 + skipped 的执行结束记录
切面在 next 前失败或取消一条无计时的 node_finished,保留完整图、执行、路径和节点归因
切面在 next 前成功或正常跳过不伪造等待或执行记录
检查点恢复的 completed / skipped 节点不伪造业务计时记录;恢复事实由 Durable 事件报告

正常跳过的 Record.Err 为 nil;即使全部节点跳过,运行仍是 completed。可靠检查点故障保留失败身份与错误链,即使错误链包含 ErrSkipped,也不能按这个错误标记过滤为正常跳过。

耗时口径 ​

耗时是墙钟时间,不是 CPU 时间。回调在调用方 goroutine 上同步执行:节点回调在节点 goroutine 上,图回调在 Start / Wait 调用路径上。落在计时窗口内的 Sink 开销会计入相应分段或整轮耗时;慢出口也会直接延迟调用方。AsyncSink 延后内层写入,但排队和入队本身仍在回调路径上。

零耗时既可能表示没有计时,也可能是计时精度不足;判断是否进入某阶段应看事件类型。LineSink 内置版式用 - 显示零耗时。SlogSink 的 duration_ms 是数值毫秒,保留亚毫秒精度,例如 585 微秒输出为 0.585;零耗时省略该键。

状态视图与事件历史 ​

入口回答的问题边界
g.Topology()图声明了哪些节点、键、端口和子树静态结构,不创建执行
e.Snapshot()这一轮目前进行到哪里不依赖 Observer、不产生事件,不带业务值
MemorySink.Snapshot()出口已经收到了哪些记录返回记录切片的浅拷贝,不是执行状态快照

执行状态在回调前迁移,所以回调内可以读取 e.Snapshot()。其中 Graph.Execution / Graph.Path 与回调的 Site 和 Record 的归因属性一致。waiting 节点若还有 pending 输入,就在等输入;输入全部到达或没有输入时,在等进入 Run 的机会,不能仅据此认定它在等并发名额。

Snapshot 分别采样定义、节点状态与槽位,不保证某一瞬间的原子视图。Child 是最近一次已创建尝试的活子 Execution,失败或取消后仍保留;继续调用 Child.Snapshot() 会得到另一个时刻的读数。图在 Wait 收尾时转为终态,只 Start 不 Wait 时仍为 running。

可用 g.Topology().Mermaid(pulse.WithMermaidSnapshot(e.Snapshot())) 把当前状态叠加到静态结构上。无快照时只画结构;固定值和错误正文不进入 Mermaid 图。

Sink 选择与契约 ​

出口适合场景生命周期与错误检查
NewLineSink(w, opts...)给人阅读的列式文本自带缓冲;结束时检查 Flush(),运行中可检查 Err()
SlogSink{Logger: logger}接宿主 logger、输出 JSON、接日志采集器Logger 为 nil 时用 slog.Default();handler 生命周期由宿主管理
&MemorySink{}测试断言、演示、收集事件用 Len() / Snapshot() 读取;记录保留在内存中
MultiSink{sink1, sink2}同一记录写到多个出口按序调用、跳过 nil 成员;宿主负责分别刷新或关闭叶子出口
NewAsyncSink(inner, opts...)将较慢出口的写入交给单个后台 worker用 Flush(ctx) / Close(ctx) 检查结果,并查看 Dropped()

Sink 只要求一个方法:

go
type Sink interface {
	Write(Record)
}

实现必须并发安全,且不应长时间阻塞;接口没有 context 或返回错误。需要截止时间、缓冲或错误上报的实现,应自行提供相应方法或内部队列。普通 Observer 的 panic 不会升级为节点失败,因此宿主必须单独检查出口错误。

引用语义的责任如下:

  • 产生方为每条记录构造独立 Attrs,交给 Write 后不再修改。
  • Sink 只读消费 Record 及其 Attrs。MultiSink 会把相同的 Attrs 交给多个成员。
  • 异步实现持有记录前须复制需要的引用字段;内置 AsyncSink 会复制 Attrs 条目。
  • MemorySink.Snapshot 只复制外层记录切片,应继续按只读契约使用其中的 Attrs。

LineSink 选项 ​

选项默认值 / 行为
WithBufSize(n)32 KiB;n 必须大于 0
WithImmediate()等价于阈值 1 字节,每条记录立即写到底层 writer
WithPrefix(text)默认 PULSE;空字符串关闭前缀
WithColor(on)未指定时,仅对 *os.File 字符设备启用 ANSI 颜色
WithRenderer(fn)替换行体渲染器;fn 不得为 nil

默认版式依次输出时间、状态、带单位耗时、事件、来源、属性、宿主、错误和 trace。状态列宽为下限,长值不截断;属性按插入序保留,文本值按需加引号。颜色依据错误与耗时,不根据自由状态文本猜测业务语义。默认渲染复用缓冲、不经过 slog;自定义渲染器的分配由实现自身决定。

底层 writer 为 nil、缓冲大小非法或渲染器为 nil,属于构造时的编程错误,会 panic。LineSink 会串行调用 writer,所以 writer 无须因这个 Sink 而额外支持并发。写错误以首错为准,Err() 和 Flush() 都可返回它;失败批次会清空以避免无限积累。Flush 只负责写出缓冲,不关闭底层 writer。

AsyncSink 参考 ​

队列与背压 ​

选项 / 方法语义
WithCapacity(n)预分配有界队列,默认 1024 条;n 必须大于 0
默认满队列策略阻塞生产者,保留背压
DropOnFull()满时丢弃新记录,计入 Dropped
Dropped()合计未送达的记录,包括满队列丢弃、关闭后写入、关闭唤醒的阻塞写入、关闭超时残留和 inner.Write panic

inner 为 nil 或容量非法时,NewAsyncSink 会 panic。单个 worker 按 FIFO 调用 inner.Write,并串行执行内层刷新与错误查询;AsyncSink 不会让同一个 inner 的 Write / Flush / Err 相互并发。

Flush 与 Close ​

调用等待范围取消后的结果
Flush(ctx)调用时已入队的快照,包含在途记录,然后刷新内层出口返回 ctx.Err();记录保留,worker 继续处理
Close(ctx)先停止接收,再排空、刷新并停止 worker首次调用超时确定最终关闭结果;队列剩余计入 Dropped,worker 在能返回内层调用后退出

后续写入不会扩大较早 Flush 的等待范围。同目标的等待者共享刷新任务,不同快照按目标顺序登记;未开始且已无等待者的边界在取消后撤销,边界数量至多为容量加一。Flush 不会为每个等待者再开 goroutine。

刷新由 worker 在边界处暂停消费后执行:内层实现 Flush() error 时调用它,否则检查可选的 Err() error。两者不重复调用。包装 LineSink 时,这一步会真正写出它自己的缓冲;只将记录交给 inner.Write 并不代表内层缓冲已经写出。包装 MultiSink 时,它没有刷新方法,叶子出口仍需由宿主分别刷新。

Close 会唤醒正在等队列空位的写入者,这些记录和关闭后的新 Write 都只计入 Dropped,不 panic。第一次 Close 的 ctx 控制排空和最终结果;结果确定后,重复 Close 返回该结果。结果尚未确定时,并发调用者可以用自己的 ctx 停止等待,但不会改变首次关闭的结果。关闭后的 Flush 等待同一个关闭结果。

ctx 也限制等待内层 Flush / Err 的时间,但不能强制中断没有 context 参数的 inner.Write / Flush / Err。取消等待可以立即返回;已开始的内层调用由原 worker 继续处理。若它始终不返回,该 worker 也无法退出。

内层 panic ​

来源处理
inner.Write panic捕获、该条计入 Dropped,worker 继续处理后续记录
inner.Flush / Err panic返回固定错误 observe: AsyncSink inner Flush/Err panicked,不格式化 panic 正文

Flush / Err 的 panic 不会把已经交给 inner.Write 的记录计为 Dropped。若 panic payload 是 error,返回错误的 Unwrap 会保留它,可通过 errors.Is / errors.As 检查;固定 Error 文本不调用 payload.Error,即使该方法本身会 panic 也能安全报告。worker 仍可处理后续记录。

进程关闭路径应显式检查 Flush 或 Close;异步队列不会在进程退出后继续存在。需要停止 worker 时使用 Close。

自定义行体渲染 ​

LineRenderer 的形态为 func(dst []byte, r Record, color bool) []byte。通过 WithRenderer 安装后,只负责追加行体;前缀、换行、颜色判定、缓冲、写出时机和错误收集仍由 LineSink 负责。

六个导出原语与内置版式使用相同的编码口径:

原语用途与边界
AppendDuration(dst, d)追加带单位耗时;ns、µs、ms、s 按量级选,µs 一位小数,ms / s 两位;零值原语输出 0ns,内置缺耗时占位由版式另行处理
AppendTextValue(dst, text)对空字符串、空白、等号、引号、不可打印字符和非法 UTF-8 按需加 Go 字符串引号;非 ASCII 判据与 slog.TextHandler 一致,也保守地为 U+FFFD 加引号;空字符串为 "",不是缺值
AppendAttrs(dst, attrs)按插入序追加 k=v,组内单空格分隔,无前导分隔符;空组输出零字节
AppendAttrsExcept(dst, attrs, keys...)同一编码规则,跳过已放进固定列的键;不存在的键无影响,全被跳过时输出零字节
AppendPadding(dst, n)追加 n 个空格;n <= 0 不追加
DisplayWidth(text)计算近似终端显示列数,配合 Padding;常见东亚字符按两列、组合与零宽字符按零列

这些原语保证相应片段与内置版式逐字节一致。DisplayWidth 使用常见 Unicode 区段的近似表,罕用宽字符或 emoji 与实际终端可能存在宽度差异。

非 ASCII 文本的引号修正会改变冻结编码面的输出字节,必须随 minor 发布;解析日志或比较固定输出的调用方应按迁移说明更新预期。普通中文与可打印 emoji 保持原样。

渲染器在 Sink 的内部锁内调用,必须避免回调该 Sink 的 Write / Flush / Err,也不应阻塞。Get[T] 的 ok 位用于区分缺属性与真实零值。Attrs 分隔符由宿主版式添加;使用 AppendAttrsExcept 时,按实际追加长度判定是否为空,不能仅看 Attrs.Len()。单列标量可复用字符串原语或 strconv.AppendInt / AppendFloat(..., 'g', -1, 64) / AppendBool,保持与属性段同形。

完整可运行实现见 Example_hostRenderer:

bash
go test -run '^Example_hostRenderer$' ./observe

常见问题 ​

问题排查与处理
Run 返回了,终端仍没有日志?默认 LineSink 可能尚未达到缓冲阈值;检查 Flush,或使用 WithImmediate
为什么一个普通节点只有两条 Record?三种节点回调用于计时和收尾,Record 通常只有等待结束、执行结束两条
为什么节点没有业务计时记录?检查输入跳过、切面短路或检查点恢复;参考事件表判断实际阶段
Sink 出错为什么 Run 仍成功?Observer 是只读观测边界;检查 LineSink.Err / Flush、AsyncSink.Flush / Close 和 Dropped
怎么查询正在等哪些输入?持有 Execution,读 Snapshot 中节点的 Inputs 和到达状态
多次运行怎么关联与区分?Execution 区分每一轮;TraceID 承担宿主业务关联,不可替代执行身份

相关资料 ​

开发版 API 文档 · Go 1.26+ · MIT