mobile wallpaper 1
mobile wallpaper 2
mobile wallpaper 3
mobile wallpaper 4
1577 字
4 分钟
讲解 @earendil-works/pi-telemetry
2026-08-21

packages/telemetry 发布为 @earendil-works/pi-telemetry。当前仓库里它是 0.84.2,ESM,要求 Node >= 22.19.0。入口两个:../testing

它不连 OpenTelemetry,也不连 Sentry。没有全局 current-span,没有 AsyncLocalStorage,没有 exporter。Pi 其它包显式传入一个 TelemetryContext;你要接后端,就自己实现这份合同。

earendil-works
/
pi
Waiting for api.github.com...
00K
0K
0K
Waiting...

这个包实际有哪些文件#

src/ 很小:

文件职责
index.ts核心类型、defineTelemetrySchemacreateTypedSpanStarter
noop.ts共享的 NOOP_TELEMETRY_CONTEXT
memory.tsInMemoryTelemetryContext 参考实现
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。嵌套不是靠线程局部存储,而是把回调里拿到的 spanstartSpan。父节点就是参数。

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 忽略
  • setStatus last-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 的 parentIdnull,child 指向 parent 的 id。

graph TD parent["example.parent"] child["example.child"] parent --> child

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 / node 解耦:你提供新鲜的 fixture(context + 把后端 span 归一成 RecordedTelemetrySpangetSpans),再按 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 在创建时传入,可标 requiredendAttributes 之后用 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,编译过不了。

属性类型只有六种标量/数组。定义里还可以写 sensitivecardinalitylow | high)、examples。这些同样是给工具和人看的元数据,adapter 不强制执行。

和其它 pi 包怎么接#

这个包停在合同层。相邻包的所有权是:

  • pi-telemetry:合同、no-op、内存参考、schema 工具、conformance
  • pi-ai:请求选项里传递 telemetryContext,自己没有 schema
  • pi-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」。

也不要把 TelemetryContextTelemetrySpan 或后端 trace 对象写进 session、消息、快照、延迟 handle。属性避开 prompt、补全、工具参数、文件内容、headers、凭据,除非领域 schema 和数据策略明确允许。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

讲解 @earendil-works/pi-telemetry
https://l1ngg.info/posts/tech/pi-telemetry/
作者
L1ngg
发布于
2026-08-21
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录