v1.1.0 Pi v1.1.0 源码分析 Analysis of Pi v1.1.0 · 2026-10-07
telemetry telemetry
与厂商无关的遥测契约与类型化 schema 工具 Vendor-neutral telemetry contracts and typed schema utilities for pi
Infra
← → 翻页 · F 全屏 · O 目录 · T 明暗 · L 语言 ← → navigate · F fullscreen · O overview · T theme · L language
01 · 一个契约、两个参考实现、一套一致性测试 01 · One contract, two reference contexts, one conformance suite Infra
一个契约、两个参考实现、一套一致性测试 One contract, two reference contexts, one conformance suite pi-telemetry: 935 lines — a contract, two reference contexts and a conformance suite packages/telemetry/src testing/ ./testing 依赖与使用者 Dependencies and users 357 index.ts 契约类型 · schema 定义与类型推导 · createTypedSpanStarter · 重新导出两个 context contract types · schema definitions and inference · createTypedSpanStarter · re-exports both contexts 20 noop.ts NOOP_TELEMETRY_CONTEXT 一个冻结的惰性 span one frozen inert span 219 memory.ts InMemoryTelemetryContext 确定性的内存记录 deterministic recording 6 index.ts 子路径入口 subpath entry 18 types.ts Fixture · Case 类型 Fixture · Case types 315 conformance.ts 9 个用例 · node:assert 9 cases · node:assert defineTelemetrySchema createTypedSpanStarter NOOP_TELEMETRY_CONTEXT InMemoryTelemetryContext TelemetryContext TelemetrySpan SpanStatus TelemetrySchemaDefinition TypedSpanStarter SchemaTelemetrySpan Infer*Attributes 1 pi-ai ProviderRequestOptions.telemetryContext 只传递,不拥有任何 schema propagates it; owns no schema pi-agent-core AGENT_TELEMETRY_SCHEMAS · startAiSpan … agent-core 既不依赖也不导出它们 agent-core neither depends on nor exports them coding-agent · core/telemetry.ts 安装遥测:PI_TELEMETRY 或设置项 install telemetry: PI_TELEMETRY or a setting 不导入 pi-telemetry does not import pi-telemetry 再导出 re-export 再导出 re-export 父 span 已结束或记录失败时,memory.ts 退回 NOOP 上下文。 memory.ts falls back to the NOOP context when the parent has settled or recording fails. 只有这里用 Node 的 Only this subpath uses 断言 API;根包保持 Node assertion APIs; the 运行时无关。 root stays runtime-neutral. 根入口导出 4 个值 The root exports 4 values … 和 30 个类型,例如 … and 30 types, such as 运行时依赖 Runtime dependencies 无:package.json 只有 devDependencies(@types/node、vitest) none: package.json has only devDependencies (@types/node, vitest) 被导入(src 中导入它的文件数) Imported by (files in src) README 里写到、但 v1.1.0 源码里没有 In the README, but not in the v1.1.0 source 同名但无关 Same name, unrelated pi-telemetry 只定义契约; pi-telemetry only defines the contract; 导出到 OpenTelemetry、Sentry 等由适配器负责。 exporting to OpenTelemetry, Sentry … is the adapter's job. CHANGELOG:整个包在 0.84.0 一次性加入(回调式上下文契约、no-op、内存参考适配器、一致性套件、类型化 schema);1.1.0 没有新条目。 CHANGELOG: the whole package arrived in 0.84.0 (callback context contract, no-op, in-memory reference adapter, conformance suite, typed schemas); 1.1.0 has no entries. 标签 = 行数 / 文件数 tag = lines / files 契约与参考实现 contract & reference contexts ./testing 使用者 users 导出 / 回退 export / fallback
根入口只有 4 个值导出:defineTelemetrySchema、createTypedSpanStarter、NOOP_TELEMETRY_CONTEXT 与 InMemoryTelemetryContext,其余全是类型。 The root has only 4 value exports — defineTelemetrySchema, createTypedSpanStarter, NOOP_TELEMETRY_CONTEXT and InMemoryTelemetryContext; everything else is a type.
02 · span、属性、事件、状态与上下文 02 · Spans, attributes, events, status and context Infra
span、属性、事件、状态与上下文 Spans, attributes, events, status and context The model: a span tree with attributes, events and status; a TelemetrySpan is itself the context for children 一次运行产生的遥测(README 的例子) Telemetry from one run (the README example) 核心类型(index.ts) Core types (index.ts) telemetryContext 调用方传进来的父上下文 the parent passed in by the caller span example.account.load span:一次操作的计时记录 span: a timed record of one operation attributes account.id=123 · found=true 关于 span 的事实 facts about the span event example.cache.lookup { cache.hit: false } span 期间的一个时刻 a moment during the span status ok 最终结果:ok 或 error the outcome: ok or error child span example.db.query span.startSpan() → 子节点 span.startSpan() → a child «interface» TelemetryContext startSpan<T>(options, callback): Promise<T> «interface» extends TelemetryContext TelemetrySpan addEvent(name, attributes?): void setAttributes(attributes): void setStatus(status): void SpanOptions name: string attributes?: SpanAttributes «union» SpanStatus { status: "ok" } { status: "error"; error?: { name, message } } startSpan() 子 span 只能经由显式传下去的父 span 创建:没有环境上下文(AsyncLocalStorage)。 Children are created only through the explicitly passed parent span: no ambient context (AsyncLocalStorage). AttributeValue = string | number | boolean | 三种只读数组 AttributeValue = string | number | boolean | three readonly arrays SpanAttributes = { [name]: AttributeValue | undefined } 没有公开的 end():startSpan() 拥有结束时机——回调的值或 promise 落定时 span 才结束。span 是诊断数据,不是业务状态:记录与否不能改变业务结果。 There is no public end(): startSpan() owns settlement — the span ends when the callback's value or promise settles. A span is diagnostic data, not business state: recording must not change the outcome. span 与契约 spans & contract 事件 event 状态 status 开始 span start a span 包含 contains
没有 end():startSpan() 拥有结束时机,回调的返回值或 promise 落定时 span 才结束。 There is no end(): startSpan() owns settlement, and the span ends when the callback's value or promise settles.
03 · 一次 startSpan() 的生命周期 03 · The life of one startSpan() Infra
一次 startSpan() 的生命周期 The life of one startSpan() One startSpan(): the callback runs once, synchronously; the span ends when it settles span 的方法 Span methods startSpan(opts, cb) 父 span 已结束? Parent settled? 或 createSpan 抛错 or createSpan throws createSpan() 复制属性 · id = nextSpanId++ copy attributes · id = nextSpanId++ callback(span) 同步调用,恰好一次;span 打开 called synchronously, exactly once; span open NOOP_TELEMETRY_CONTEXT 回调照常执行,不记录 callback still runs, nothing recorded 返回值 / promise 兑现 Returns / promise resolves status 保持 ok(除非显式设置) status stays ok (unless set explicitly) 同步抛错 / promise 拒绝 Throws / promise rejects 自动 error { name, message } automatic error { name, message } settleSpan() settled = true · endSequence = nextEndSequence++ · 只执行一次 settled = true · endSequence = nextEndSequence++ · runs once 返回的 promise The returned promise 以同一个值兑现,或以同一个值拒绝(同步抛错也变成拒绝) resolves with the same value or rejects with the same value (a sync throw becomes a rejection) 否 no 是 yes setAttributes(a) 合并;后写的定义值覆盖,undefined 被忽略 merge; later defined values win, undefined ignored addEvent(name, a?) 按顺序追加事件 append an event, in order setStatus(s) 最后一次生效;标记 explicitStatus last write wins; marks explicitStatus startSpan(opts, cb) 子 span,parentId = 这个 span a child span, parentId = this span 结束之后:前三个方法静默无效;startSpan 走 NOOP(回调仍执行)。 After settlement: the first three are silent no-ops; startSpan goes to NOOP (callback still runs). 任何记录调用都同步、被动、不抛错;坏数据被整体忽略。 Every recording call is synchronous, passive, never throws; bad payloads are ignored whole. 显式状态优先:回调先 setStatus({ status: "ok" }) 再抛错,span 仍是 ok;返回 { ok: false } 这类“预期失败”要自己 setStatus(error)。 Explicit status wins: setStatus({ status: "ok" }) and then throw, and the span is still ok; an expected failure returned as { ok: false } must setStatus(error) itself. NOOP 上下文走同样的路径,只是不记录:一个共享的冻结 span,嵌套 span 也用它。 The NOOP context follows the same path without recording: one shared frozen span, nested spans included. 正常 normal 抛错 / 拒绝 throw / reject memory.ts 结束 settlement
显式状态优先于自动状态:先 setStatus({ status: 'ok' }) 再抛错,span 仍是 ok。 Explicit status beats the automatic one: setStatus({ status: 'ok' }) and then throw, and the span stays ok.
04 · 适配器契约与一致性套件 04 · The adapter contract and its conformance suite Infra
适配器契约与一致性套件 The adapter contract and its conformance suite The adapter contract: 9 rules in the README, 9 conformance cases in ./testing 适配器必须……(README · Adapter Contract) An adapter must … (README · Adapter Contract) createTelemetryAdapterConformance(factory) 1 · 2 · 4 2 · 4 4 · 5 6 9 8 1 · 3 7 · 9 9 同步调用回调,恰好一次 Call the callback synchronously, exactly once 并创建子 span and create a child span 保留返回值与拒绝值 Preserve the return and rejection value 同步抛错 → 以同一个值拒绝的 promise sync throw → a promise rejected with it promise 落定前 span 保持打开 Keep the span open until the promise settles 原生 span 的结束由 startSpan 负责 startSpan ends the native span 正常 → ok,抛错 → error Normal → ok, throw → error 除非已显式设置状态 unless a status was set explicitly setStatus 最后一次生效 setStatus is last-write-wins setAttributes 合并 setAttributes merges 后写的定义值覆盖,undefined 忽略 later defined values win, undefined ignored 记录方法同步、被动、不抛错 Recording is synchronous, passive, non-throwing 结束之后的调用被忽略 Calls after settlement are ignored 失败的记录调用被整体忽略 A failed recording call is ignored whole 后端错误被吞掉,业务回调仍只执行一次 backend errors suppressed; callback still runs once callback lifecycle admits once synchronously and preserves the result preserves synchronous and asynchronous rejection values status uses last explicit status without automatic overwrite recording merges attributes and records ordered events ignores failed attribute calls atomically makes calls after settlement inert parentage records nested and concurrent child relationships passivity suppresses unreadable telemetry payload failures ignores failed status calls atomically 右侧标签 = 用例覆盖的规则编号(本站的对应关系) tag = the rules a case covers (this site's mapping) createTelemetryAdapterConformance(async () => ({ context, getSpans: async () => normalizedSpans(), async [Symbol.asyncDispose]() { … } })) → readonly { group, name, run() }[] · 注册到任何测试框架:for (group) describe(group, …) · it(name, () => run()) 每个用例用 await using 拿一个全新 fixture;getSpans() 可以先 flush 异步导出器。用 Proxy 构造“读取即抛错”的载荷来检验被动性。 Each case takes a fresh fixture with await using; getSpans() may flush an async exporter first. Payloads that throw on read (a Proxy) test passivity. 1 2 3 4 5 6 7 8 9 契约规则 contract rules 一致性用例(按组) conformance cases (by group)
用例是普通对象 { group, name, run() },可以注册进 vitest、node:test 或任何框架;pi 自己的测试把它们挂到 InMemoryTelemetryContext 上。 Cases are plain { group, name, run() } objects you can register with vitest, node:test or any runner; pi's own tests run them against InMemoryTelemetryContext.
05 · 内存参考适配器 05 · The in-memory reference adapter Infra
内存参考适配器 The in-memory reference adapter InMemoryTelemetryContext: deterministic ids and end sequence numbers, no timestamps 每个实例的状态 Per-instance state memory.ts 一致性用例 “records nested and concurrent child relationships” Conformance case “records nested and concurrent child relationships” getSpans() 的结果 What getSpans() returns id parentId name attributes events status settled endSequence? explicitStatus endSequence 3 endSequence 2 endSequence 1 state = { spans: MutableRecordedTelemetrySpan[] nextSpanId: 1 nextEndSequence: 1 } 每条记录(可变,内部) Each record (mutable, internal) getSpans():按开始顺序返回分离的快照—— getSpans(): detached snapshots in start order — 属性、数组、事件、状态全部复制; attributes, arrays, events and status all copied; explicitStatus 不出现在快照里。 explicitStatus is not part of the snapshot. 改快照不影响记录(有专门的测试)。 Mutating a snapshot never leaks back (tested). parent id 1 first-child id 2 · parent 1 second-child id 3 · parent 1 时间 → time → 开始顺序 = id 顺序 start order = id order second-child 同步返回 "done",先结束; second-child returns "done" synchronously and ends first; first-child 等闸门打开后才结束。 first-child ends once its gate opens. id parentId name status settled endSequence attributes / events 1 null parent ok true 3 {} / [] 2 1 first-child ok true 2 {} / [] 3 1 second-child ok true 1 {} / [] 回调还在运行时调用 getSpans():settled = false,没有 endSequence。 getSpans() while a callback is still running: settled = false, no endSequence. 用途:测试、本地诊断、只想在进程内收集的应用。存储无上限且只在本进程内;为隔离测试请每次 new 一个实例。 Use it for tests, local diagnostics and apps that only want in-process capture. Storage is unbounded and process-local; create a fresh instance per test. 根 span root span 子 span child span 结束序号 end sequence
一致性用例里,second-child 同步返回、先结束(endSequence 1),first-child 等闸门打开才结束(2),parent 最后(3)。 In the conformance case, second-child returns synchronously and ends first (endSequence 1), first-child ends once its gate opens (2), and parent last (3).
06 · 可序列化的 schema 06 · Serializable schemas pi-agent-core
可序列化的 schema Serializable schemas A serializable schema: spans, attributes, events, parent and status rules — all plain JSON data TelemetrySchemaDefinition version: number spans: Record<name, SpanDefinition> TelemetrySpanDefinition description: string parents: TelemetryParentDefinition startAttributes: Record<name, StartAttributeDefinition> endAttributes: Record<name, AttributeDefinition> events?: Record<name, EventDefinition> status: { default: "ok"; errorWhen: string } TelemetryAttributeDefinition description: string sensitive?: boolean cardinality?: "low" | "high" type: one of 6 (below) values? · elementValues? examples? TelemetryParentDefinition { kind: "any" } { kind: "root_or_external" } { kind: "spans", spans } TelemetryEventDefinition description: string attributes: Record<…> type:string · number · boolean · string[] · number[] · boolean[] type: string · number · boolean · string[] · number[] · boolean[] values = 标量的封闭集合;elementValues = 数组元素的封闭集合 values = a closed set for scalars; elementValues = for array elements start 与 event 属性另加 required: boolean;end 属性没有,永远可选 start and event attributes add required: boolean; end attributes never do README 的例子(description 字段从略) The README example (descriptions omitted) defineTelemetrySchema({ version: 1, spans: { 'example.read': { parents: { kind: 'any' }, startAttributes: { 'example.resource': { type: 'string', required: true, values: ['account', 'project'] }, }, endAttributes: { 'example.item_count': { type: 'number' }, }, events: { 'example.cache': { attributes: { 'example.cache.hit': { type: 'boolean', required: true } } }, }, status: { default: 'ok', errorWhen: '…' }, }, }, } as const) 何时可知 When a value is known startAttributes:开始时传入 starter 的 attributes 参数 startAttributes: passed to the starter when the span is created endAttributes:之后用 span.setAttributes() 补充,可以不调 endAttributes: added later with span.setAttributes(), or not at all 两者落在同一个后端 span 的同一个属性袋里。 Both land in the same attribute bag of the same backend span. defineTelemetrySchema() 是类型化的恒等函数: defineTelemetrySchema() is a typed identity function: 原样返回参数,运行时不校验、不强制父子规则。 it returns its argument; no runtime validation or parent rules. schema 类型 schema types 包含(◆) contains (◆)
defineTelemetrySchema() 是类型化的恒等函数:原样返回参数,不做运行时校验,也不强制父子规则。defineTelemetrySchema() is a typed identity function: it returns its argument, with no runtime validation and no parent-rule enforcement.
07 · 类型化的 span 启动器 07 · Typed span starters pi-agent-core
类型化的 span 启动器 Typed span starters createTypedSpanStarter: checked against the schemas at compile time, a thin startSpan wrapper at run time 编译期(TypeScript) Compile time (TypeScript) 运行时(bindTypedSpanStarter) Run time (bindTypedSpanStarter) createTypedSpanStarter(ctx, [A, B]) 一个父上下文 + 一组 schema(元组) one parent context + a tuple of schemas 缺少 required 事件属性 missing required event attribute 封闭集合外的值 value outside a closed set 未声明的事件 undeclared event 空 end schema 的任何属性 any attribute on an empty end schema 未收窄的联合 span 名 un-narrowed union span name 多出未知键的变量 a variable with unknown keys 未知 span 名 unknown span name 属性取自别的 span attributes from another span 重复的 span 名 duplicate span names UniqueTelemetrySchemas 跨 schema 的同名 span → 参数类型变成 "duplicate telemetry span names" a span name in two schemas → the argument type becomes "duplicate telemetry span names" TypedSpanStarter 每个 span 名一个重载(UnionToIntersection);联合类型的名字必须先收窄 one overload per span name (UnionToIntersection); union-typed names must be narrowed ExactTelemetryAttributes 多出来的键 → never;缺 required 的 start 属性 → 报错 extra keys → never; a missing required start attribute → error AttributeDefinitionValue values / elementValues → 字面量联合;否则 string / number / boolean values / elementValues → literal unions; otherwise string / number / boolean SchemaTelemetrySpan addEvent 只收已声明的事件;setAttributes 只收 end 属性 addEvent takes declared events only; setAttributes end attributes only startSpan(name, attributes, callback) => telemetryContext.startSpan({ name, attributes }, (span) => callback(span, bindTypedSpanStarter(span))) 子 starter 绑定到回调的 span:显式传播,同一套 schema。 The child starter is bound to the callback span: explicit propagation, same schemas. schema 值只用于类型推导:运行时不检查、不合并、不保留。 Schema values only drive type inference: not inspected, merged or kept at run time. 测试里的例子 The example from the tests startSpan("operation", { kind: "read" }, (_op, startChild) => startChild("request", { provider: "example" }, (req) => { req.setAttributes({ response: "cached" }); return 42; })) // → 42 · request.parentId === operation.id telemetry.test.ts 用 9 处 @ts-expect-error 锁定这些拒绝: telemetry.test.ts pins these rejections with 9 @ts-expect-error lines: 类型层 type level 运行时 run time 编译期拒绝 compile-time rejection
回调签名是 (span, startChildSpan):span 只接受这个 span 声明的事件和结束属性,startChildSpan 已绑定到 span。 The callback signature is (span, startChildSpan): span accepts only this span's declared events and end attributes, and startChildSpan is already bound to span.
08 · 在 pi 里的位置 08 · Where it sits in pi pi-ai
在 pi 里的位置 Where it sits in pi In pi: pi-ai carries telemetryContext through to the provider and opens no span itself pi-ai 的测试证明它在每条分派路径上都还是同一个对象 pi-ai tests prove it is the same object on every dispatch path telemetry-options.test.ts 按 README 的分工 Ownership, per the README 安全与可移植性 Security and portability 应用 / 宿主 Application / host 拥有适配器(OTel、Sentry…) owns the adapter (OTel, Sentry …) { telemetryContext } ProviderRequestOptions · types.ts buildBaseOptions() simple-options.ts:SimpleStreamOptions → StreamOptions,原样带上 simple-options.ts: SimpleStreamOptions → StreamOptions, passed as is provider.stream models.stream provider.streamSimple models.streamSimple provider.fetchDeferred models.fetchDeferred provider.cancelDeferred models.cancelDeferred generateImages models.generateImages v1.1.0 ✓ v1.1.0 ✓ 尚未出现 not yet 流式与延迟分派共 8 处观察到的 options.telemetryContext 全部 === 传入的对象;图像分派单独一个用例。 All 8 observed options.telemetryContext values for streaming and deferred dispatch === the one passed in; image dispatch has its own case. pi-telemetry 契约、no-op 与内存上下文、schema 工具、一致性套件 contract, no-op and in-memory contexts, schema utilities, conformance pi-ai 接受并传递 telemetryContext,不拥有 schema accepts and propagates telemetryContext, owns no schema pi-agent-core pi.ai.* / pi.harness.* / pi.session.* schema 与 span 助手 pi.ai.* / pi.harness.* / pi.session.* schemas and span helpers 不要持久化 context 或 span Never persist a context or span (记录、消息、快照、deferred handle) (records, messages, snapshots, deferred handles) 属性只有标量与数组 Attributes are scalars and arrays only 避开提示词、输出、文件内容、凭据 avoid prompts, outputs, file contents, credentials 没有 AsyncLocalStorage No AsyncLocalStorage Node.js、Bun、浏览器、worker 都能用 works in Node.js, Bun, browsers and workers pi-ai 的 src 里 telemetryContext 只出现在 2 行(类型声明与 buildBaseOptions),没有一处 startSpan:真正开 span 的是拿到它的 provider 实现或上层代码。 telemetryContext appears on just 2 lines of pi-ai's src (the type and buildBaseOptions), with no startSpan anywhere: spans are opened by the provider implementation or code above that receives it. 传递 passed along pi-ai pi-telemetry 缺口 / 规则 gap / rules
pi-ai 的 src 里没有一处 startSpan:开 span 的是拿到上下文的 provider 实现或更上层的代码。 There is no startSpan anywhere in pi-ai's src: spans are opened by the provider implementation or higher-level code that receives the context.
扩展点 Extension points pi-coding-agent
telemetry 的扩展点 Where to plug into telemetry 1
TelemetryContext TelemetryContext
实现它就是一个适配器:把 startSpan 映射到 OpenTelemetry、Sentry、日志或任何后端。 Implementing it makes an adapter: map startSpan onto OpenTelemetry, Sentry, logs or any backend.
2
createTelemetryAdapterConformance(factory) createTelemetryAdapterConformance(factory)
用 9 个一致性用例验证你的适配器,可注册到任何测试框架。 Verify your adapter with the 9 conformance cases, registered with any test runner.
3
defineTelemetrySchema + createTypedSpanStarter defineTelemetrySchema + createTypedSpanStarter
为自己的领域定义封闭的 span 词汇,在编译期检查名字与属性。 Define a closed span vocabulary for your domain, with names and attributes checked at compile time.
4
ProviderRequestOptions.telemetryContext ProviderRequestOptions.telemetryContext
把父 span 交给 pi-ai 的一次请求;provider 实现决定开哪些子 span。 Hand a parent span to one pi-ai request; the provider implementation decides which child spans to open.
5
InMemoryTelemetryContext InMemoryTelemetryContext
在测试里断言你的代码产生了哪些 span、属性、事件与状态。 Assert in tests which spans, attributes, events and statuses your code produced.