packages/telemetry 发布为 @earendil-works/pi-telemetry。当前仓库里它是 0.84.2,ESM,要求 Node >= 22.19.0。入口两个:. 和 ./testing。
它不连 OpenTelemetry,也不连 Sentry。没有全局 current-span,没有 AsyncLocalStorage,没有 exporter。Pi 其它包显式传入一个 TelemetryContext;你要接后端,就自己实现这份合同。
这个包实际有哪些文件
src/ 很小:
| 文件 | 职责 |
|---|---|
index.ts | 核心类型、defineTelemetrySchema、createTypedSpanStarter |
noop.ts | 共享的 NOOP_TELEMETRY_CONTEXT |
memory.ts | InMemoryTelemetryContext 参考实现 |
testing/ | adapter 一致性套件 |
CHANGELOG 里真正新增能力的版本是 0.84.0:回调式 context、no-op、内存 adapter、conformance、typed schema。
读源码时抓住三件事:span 结算由 startSpan 拥有;记录失败不能影响业务;schema 对象在运行时不被检查。
TelemetrySpan 就是下一层的 Context
核心类型在 index.ts:
export interface TelemetryContext { startSpan<T>( options: SpanOptions, callback: (span: TelemetrySpan) => T | Promise<T>, ): Promise<T>;}
export interface TelemetrySpan extends TelemetryContext { addEvent(name: string, attributes?: SpanAttributes): void; setAttributes(attributes: SpanAttributes): void; setStatus(status: SpanStatus): void;}TelemetrySpan 继承 TelemetryContext。嵌套不是靠线程局部存储,而是把回调里拿到的 span 再 startSpan。父节点就是参数。
SpanOptions 只有 name 和可选的 attributes。属性值只能是 string / number / boolean 及其只读数组。没有 object、没有任意 JSON。
SpanStatus 是 "ok",或 "error" 加可选的 { name, message }。
startSpan 的返回类型永远是 Promise<T>。回调即使同步,也会被 Promise.resolve 包一层;同步 throw 变成 Promise.reject。没有公开的 end()。span 开到回调的值或 promise 结算为止。
业务失败如果是正常返回值而不是 throw,要自己标错误:
return telemetryContext.startSpan({ name: "example.save" }, async (span) => { const result = await save(); if (!result.ok) { span.setStatus({ status: "error", error: { name: "SaveError", message: result.reason }, }); } return result;});记录是诊断,不是业务状态。README 写明:recording 不得改变操作是否执行、成功、失败或落盘。
no-op 是一个冻住的空 span
noop.ts 里,context 和 span 是同一个对象:
const noopTelemetrySpan: TelemetrySpan = { startSpan: startNoopSpan, addEvent: () => {}, setAttributes: () => {}, setStatus: () => {},};Object.freeze(noopTelemetrySpan);
export const NOOP_TELEMETRY_CONTEXT: TelemetryContext = noopTelemetrySpan;嵌套时仍然把这一个冻住的对象传下去。它不看 name,不留 attribute,不留 event。回调同步执行;返回值保留;同步 throw 转成 rejected promise。
函数默认参数写成 telemetryContext = NOOP_TELEMETRY_CONTEXT 即可。调用方不传 context 时,代码路径不变,只是什么都不记。
InMemoryTelemetryContext 是参考实现
memory.ts 把 span 记在进程内存里,给测试和本机诊断用。它实现同一份 TelemetryContext。
行为可以从源码直接读出来:
- id 从
1起,父子用parentId串起来 getSpans()按开始顺序返回脱离后的快照,带settled和可选的endSequence- 不记时间戳。这棵树告诉你嵌套和先后结算,不给你 duration
setAttributes合并;后来的已定义值覆盖先前值;undefined忽略setStatuslast-write-wins;一旦显式设过,throw 不会再用自动错误状态覆盖- throw / reject 且没有显式 status 时,若是
Error就抄name/message,否则只标"error" addEvent/setAttributes/setStatus包在try/catch里:记不住就丢掉,业务继续- 父 span 已经
settled,子startSpan退回 no-op - 创建记录本身失败,同样退回 no-op
存储无界、只在进程内。测完换新实例。不要把 prompt、凭据写进 attribute,除非调用方的数据策略允许。
import { InMemoryTelemetryContext } from "@earendil-works/pi-telemetry";
const telemetry = new InMemoryTelemetryContext();
await telemetry.startSpan( { name: "example.parent" }, async (parent) => { parent.setAttributes({ phase: "start" }); return parent.startSpan({ name: "example.child" }, async (child) => { child.addEvent("example.cache.lookup", { "example.cache.hit": true }); return 42; }); },);
console.log(telemetry.getSpans());一次这样的调用会得到两棵记录:parent 的 parentId 为 null,child 指向 parent 的 id。
Adapter 要对齐哪些语义
自己接 OTel / Sentry / 日志时,实现的是 TelemetryContext,不是另造一套 API。README 把必须对齐的点写死了,./testing 再拿 RecordedTelemetrySpan 快照核对。
要点:
- 创建子 span 后,同步、恰好一次调用业务回调
- 返回值和 rejection 与回调一致;同步 throw 变成同样值的 rejected promise
- 原生 span 开到返回的 promise 结算
- 正常完成默认
ok,throw/reject 默认error,除非已经setStatus - 记录方法同步、被动、不抛
- 结算后再调用记录方法必须当没看见
- 某次记录失败要原子丢掉,后端故障被吞掉,业务回调仍执行一次
Adapter 内部可以启用后端自己的 ambient context,方便自动埋点。Pi 的代码路径始终通过参数传父节点。缓冲、flush、采样、后端 ID 属于 adapter,不属于这个包。
一致性套件从 @earendil-works/pi-telemetry/testing 导出 createTelemetryAdapterConformance。它和 vitest / nodecontext + 把后端 span 归一成 RecordedTelemetrySpan 的 getSpans),再按 group 跑 case。测试子路径用 Node 的 assert;根包保持 runtime-neutral。
Schema 只在类型里生效
底层 API 故意收开放的 name 和 attribute bag,adapter 才通用。领域包若要闭合词汇,用 defineTelemetrySchema。
它是 typed identity:传入什么对象就返回什么对象,不做运行时校验,也不执行 parent 规则。
import { createTypedSpanStarter, defineTelemetrySchema,} from "@earendil-works/pi-telemetry";
export const EXAMPLE_TELEMETRY_SCHEMA = defineTelemetrySchema({ version: 1, spans: { "example.read": { description: "Read one resource", parents: { kind: "any" }, startAttributes: { "example.resource": { type: "string", required: true, values: ["account", "project"], description: "Resource kind", }, }, endAttributes: { "example.item_count": { type: "number", description: "Number of returned items", }, }, events: { "example.cache": { description: "Cache lookup result", attributes: { "example.cache.hit": { type: "boolean", required: true, description: "Whether the cache contained the resource", }, }, }, }, status: { default: "ok", errorWhen: "The read throws or returns an error result", }, }, },} as const);createTypedSpanStarter(context, [EXAMPLE_TELEMETRY_SCHEMA]) 在运行时只用 context。源码里 schema 参数叫 _schemas,只参与类型推断。重复的 span 名会在编译期被 UniqueTelemetrySchemas 挡掉;运行时不会合并、检查或保存这些 schema。
startAttributes 在创建时传入,可标 required。endAttributes 之后用 setAttributes 补,永远可选。两类最后都是同一个后端 span 上的普通属性,没有单独的 end payload。回调还没返回就可以 setAttributes;一次都不调用也合法。这是为了早期失败、取消、以及并非每条路径都有的 provider 字段。
parent 元数据只是文档:
{ kind: "any" }:根或任意调用方 span{ kind: "root_or_external" }:根,或 schema 外的调用方 span{ kind: "spans", spans: [...] }:仅列出的 schema span
Adapter 不必读 schema。约束发生在 TypeScript:缺 required、多余 key、不在 values 里的枚举、未声明的 event,编译过不了。
属性类型只有六种标量/数组。定义里还可以写 sensitive、cardinality(low | high)、examples。这些同样是给工具和人看的元数据,adapter 不强制执行。
和其它 pi 包怎么接
这个包停在合同层。相邻包的所有权是:
pi-telemetry:合同、no-op、内存参考、schema 工具、conformancepi-ai:请求选项里传递telemetryContext,自己没有 schemapi-agent-core:拥有pi.ai.*/pi.harness.*/pi.session.*,导出AGENT_TELEMETRY_SCHEMAS和 typed helper
在 agent 代码里会看到:
import { AGENT_TELEMETRY_SCHEMAS } from "@earendil-works/pi-agent-core";
const startAgentSpan = createTypedSpanStarter( telemetryContext, AGENT_TELEMETRY_SCHEMAS,);那份词汇不在 packages/telemetry 里。本包装的是「如何声明和绑定任意 schema」,不是「一次 Pi 对话有哪些 span」。
也不要把 TelemetryContext、TelemetrySpan 或后端 trace 对象写进 session、消息、快照、延迟 handle。属性避开 prompt、补全、工具参数、文件内容、headers、凭据,除非领域 schema 和数据策略明确允许。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时