trajectory

package
v0.31.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 11, 2026 License: MIT Imports: 5 Imported by: 0

Documentation

Overview

otelmap.go —— 轨迹行 → OTel 属性的**契约表**(属性名 / 来源字段 / 取值域 / 基数上界)。

为什么单独一张表

otel.go 的映射函数回答「这一行导出去长什么样」,这张表回答「**宿主可以依赖什么**」。 分开是因为失效形态不同:映射写错会出可见的错值;契约缺失**不会报错**——宿主建看板时 只能自己猜字段名,于是同一个语义在 A 宿主叫 @turn_id@、在 B 宿主叫 @turn@,跨宿主的 看板与告警全部落空,而遥测数据照常上行。

判据:每一项都必须「有主」

判据在 @otelmap_test.go@,且是**双向**的:映射函数写出了表里没有的属性 ⇒ 红;表里列了 映射函数产不出的属性 ⇒ 也红(后者防的是「表变成了许愿池」)。另逐项判四件事:来源字段 非空、取值域写出、基数上界写出依据、属性名唯一。缺一即红。

Go 类型为什么进契约

取值域的形态写的是 **Go 类型名**(string / int64 / float64 / bool):宿主导出时按类型 选 OTel 属性类型,类型不符的属性在多数后端里**静默丢弃**(不报错、不告警),表现为 「数据看起来上行成功、看板上就是没有」。写成 Go 类型名让用例拿反射直接比对映射函数的 真实产物——这是「表」与「代码」之间唯一不会漂的对拍方式。

哪些字段不许进 metric label

只有**本仓枚举得出、也给得出上界**的属性才允许作 metric label(@LabelSafe@ 蕴含 取值域是枚举、且 @Card.Max@ 不小于取值数)。理由不是洁癖:label 的代价是时序数按取值数 **乘**上去,一个无界 label 就能让指标后端按会话数膨胀。@session_id@ / @agent_slug@ 是 典型,列在 @ForbiddenLabels@ 里——而它们**根本不在 Row 上**:结构上装不进去,不靠纪律拦。

Package trajectory 事件流 → 轨迹行的投影器(机制面)。

「这一轮到底发生了什么、每段花了多久」这件事,宿主此前只能自己从事件流里拼: 起止时刻靠猜,工具调用与结果按 call_id 手工配对,配对缺了没人知道。各宿主 各拼一套,于是同一个问题的答案会漂——尤其是**时长这一个数**:有人拿载荷 体积估、有人拿 token 数除、有人按事件时刻相减,三种口径混进同一张表, 谁也说不清哪一列是实测、哪一列是猜的。

本包把这件事收成一条机制面:**吃本仓 eventbus 的事件,产出轨迹行**。

三条纪律:

  1. **实测与估算不得混同**(本包存在的核心理由)。行的时长**只有一个源**: 起止时刻相减。Row 里**不存**时长字段——时长是 Duration() 现算的。 于是「按载荷体积估一个时长」这件事**无处安放**:不是靠纪律约束,是 结构上装不进去。没有实测值就是没有实测值(Duration 返回 ok=false), 不拿 0 冒充「瞬时完成」,也不拿估算值填进实测字段。

  2. **配对不完整要显形**。工具调用与结果按 call_id 配对;只有调用没有结果、 结果重复、结果没有对应调用、同号重复声明——四种缺口都进 Issues(), 不静默丢弃。

  3. **未完成不是完成**。只有开始没有结束的行,状态是 open(未收口), 不是「时长 0 的已完成行」。Finalize() 把「不会再有新事件」落成显式事实, 未收口的行原样报出。

事件流是追加式的,故投影是**增量**的:先喂一半再喂后半,既有行被就地更新 (补上结束时刻、时长随之变成实测),而不是追加出第二条行。

边界:本包是机制面,不含任何宿主领域知识;**不做前端渲染**——各宿主技术栈 不同,轨迹行是数据不是视图(要文案就用 Options.Summarize 注入)。

Index

Constants

View Source
const (
	// AttrRowID 行标识(与 Row.ID 同值)。
	AttrRowID = "harness.trajectory.row_id"
	// AttrKind 行类目(turn / step / tool_call / tool_result)。
	AttrKind = "harness.trajectory.kind"
	// AttrRowStatus 行的收口状态(open / closed / orphan)。
	AttrRowStatus = "harness.trajectory.status"
	// AttrTurnSeq 所属轮次序号(1 起;轮次外的事件不写此项)。
	AttrTurnSeq = "harness.turn.seq"
	// AttrStepID 所属环节号(仅带环节号的行)。
	AttrStepID = "harness.step.id"
	// AttrToolName 工具名(仅工具行;**属性**而不是 span 名,见头注纪律 1)。
	AttrToolName = "harness.tool.name"
	// AttrToolCallID 工具调用标识(配对键,宿主跨 span join 用)。
	AttrToolCallID = "harness.tool.call_id"
	// AttrToolPairing 配对状态(paired / missing_result / duplicate_result / …)。
	AttrToolPairing = "harness.tool.pairing"
	// AttrToolDupResults 重复结果计数(>0 才写)。
	AttrToolDupResults = "harness.tool.duplicate_results"
	// AttrDurationMs 实测时长(毫秒,float64)。**仅实测时出现**(见头注纪律 2)。
	AttrDurationMs = "harness.trajectory.duration_ms"
	// AttrSummary 行摘要(宿主可用 Options.Summarize 覆盖;空则不写)。
	AttrSummary = "harness.trajectory.summary"
)

OTel 属性名(本仓命名空间)。宿主据此建 dashboard 与告警;改名即破坏兼容。

View Source
const TraceBoundary = TracePerTurn

TraceBoundary 本仓选定的 trace 切分单位。

依据见 otel.go 头注「trace 边界」:选「轮」不是习惯,是因为 Row.ID 的唯一性 范围恰好是轮内——把边界挪到环节,标识唯一性与 trace 内唯一性就不再是同一个范围。

Variables

View Source
var AttrSpecs = []AttrSpec{
	{
		Name:   AttrRowID,
		Source: "Row.ID",
		Domain: Domain{Kind: "string", Note: "行标识;形态由投影器决定(turn#N / step#N / tool#<call_id> / <kind>#-<seq>),唯一范围是轮内"},
		Card:   Cardinality{Why: "取值随行数增长(行数由事件流长度决定),本仓定不出上界"},
	},
	{
		Name:   AttrKind,
		Source: "Row.Kind",
		Domain: Domain{Kind: "string", Values: []string{
			string(KindTurn), string(KindStep), string(KindToolCall), string(KindToolResult),
		}},
		Card:      Cardinality{Max: 4, Why: "Kind 词表固定四值(与 Span.Name 同一词表:新增类目必须同步这里与用例)"},
		LabelSafe: true,
	},
	{
		Name:   AttrRowStatus,
		Source: "Row.Status",
		Domain: Domain{Kind: "string", Values: []string{
			string(StatusOpen), string(StatusClosed), string(StatusOrphan),
		}},
		Card:      Cardinality{Max: 3, Why: "Status 词表固定三值"},
		LabelSafe: true,
	},
	{
		Name:   AttrTurnSeq,
		Source: "Row.Turn",
		Domain: Domain{Kind: "int64", Note: "1 起的轮次序号;轮次外的事件(Turn 为 0)不写此项"},
		Card:   Cardinality{Why: "随会话轮数增长,本仓定不出上界"},
	},
	{
		Name:   AttrStepID,
		Source: "Row.StepID(Row.HasStep 为真时才写:环节号 0 是合法值,不能靠零值判有无)",
		Domain: Domain{Kind: "int64", Note: "环节号,由调用方给定(uint64 全域)"},
		Card:   Cardinality{Why: "取值由调用方决定,本仓定不出上界"},
	},
	{
		Name:   AttrToolName,
		Source: "Row.Tool(仅工具行)",
		Domain: Domain{Kind: "string", Note: "工具名,来自工具调用事件载荷"},
		Card:   Cardinality{Why: "取值数由宿主的工具目录规模决定,本仓看不到那个目录;宿主若要按工具聚合,应在 label 侧用自己的白名单收敛"},
	},
	{
		Name:   AttrToolCallID,
		Source: "Row.CallID(仅工具行)",
		Domain: Domain{Kind: "string", Note: "配对键:一次工具调用一个值,宿主跨 span join 用"},
		Card:   Cardinality{Why: "调用次数无上界"},
	},
	{
		Name:   AttrToolPairing,
		Source: "Row.Pairing(仅工具行,且不为 PairingNone)",
		Domain: Domain{Kind: "string", Values: []string{
			string(PairingPaired), string(PairingMissingResult), string(PairingDuplicateResult),
			string(PairingUnpairedResult), string(PairingDuplicateCall),
		}},
		Card:      Cardinality{Max: 5, Why: "Pairing 词表五值(PairingNone 是「不适用」,不写进属性)"},
		LabelSafe: true,
	},
	{
		Name:   AttrToolDupResults,
		Source: "Row.DupResults(仅工具行,且 >0)",
		Domain: Domain{Kind: "int64", Note: "重复结果计数,1 起;没有重复就没有这个属性"},
		Card:   Cardinality{Why: "重复次数无上界(正常情况下恒为 1)"},
	},
	{
		Name:   AttrDurationMs,
		Source: "Row.Duration()(StartAt 与 EndAt 相减,不是单独存的字段)",
		Domain: Domain{Kind: "float64", Note: "实测毫秒;**仅实测时出现**——没有实测就没有这个属性,不写 0、不估一个"},
		Card:   Cardinality{Why: "连续值,本仓定不出上界"},
	},
	{
		Name:   AttrSummary,
		Source: "Row.Summary(可由 Options.Summarize 覆盖)",
		Domain: Domain{Kind: "string", Note: "宿主注入的展示文案;内核默认标签不含任何领域词"},
		Card:   Cardinality{Why: "自由文本,本仓定不出上界"},
	},
}

AttrSpecs 映射契约表(顺序 = otel.go 的常量声明顺序,便于逐行对读)。

View Source
var ForbiddenLabels = []ForbiddenLabel{
	{
		Name: "session_id",
		Why:  "一次会话一个取值:进 label 会让指标时序数按会话数膨胀,且会话标识是可关联标识——进了指标后端就等于把它变成长期可查询的面",
	},
	{
		Name: "agent_slug",
		Why:  "取值数随部署的 agent 目录增长,本仓定不出上界;同一部署内它也更像维度而不是枚举",
	},
	{
		Name: "payload",
		Why:  "事件载荷:自由文本、基数无界,且可能含未脱敏内容——映射面刻意不读它(见 otel.go 头注「映射面不含 payload」)",
	},
}

ForbiddenLabels 禁区清单。

这三项都不在 Row 上——写在这里是给宿主的:它们看起来「很像维度」(也确实是有用的 关联键),但落进 metric label 就变成无界基数。要关联会话,用 trace_id(真相源在 @oekit/tracing@),那是**标识**不是维度。

Functions

This section is empty.

Types

type AttrSpec

type AttrSpec struct {
	// Name 属性名(必是 otel.go 里的某个 Attr* 常量,且全表不重复)。
	Name string
	// Source 来源字段:Row 上的哪一个字段(派生值写清怎么算出来的)。
	Source string
	// Domain 取值域。
	Domain Domain
	// Card 基数上界。
	Card Cardinality
	// LabelSafe 能否作为 metric label;为真必须「枚举取值 + 本仓给得出上界」。
	LabelSafe bool
}

AttrSpec 一个属性的映射契约。

func AttrSpecOf

func AttrSpecOf(name string) (AttrSpec, bool)

AttrSpecOf 按属性名取契约(不存在时 ok=false)。

type Cardinality

type Cardinality struct {
	// Max 本仓能保证的取值上界;0 = 本仓定不出上界。
	Max int
	// Why 上界依据(Max 为 0 时写清为什么定不出)。
	Why string
}

Cardinality 属性基数上界。

type Domain

type Domain struct {
	// Kind 取值形态,取 **Go 类型名**(string / int64 / float64 / bool)。
	// 见头注「Go 类型为什么进契约」。
	Kind string
	// Values 枚举取值(按本仓词表顺序);空 = 非枚举。
	Values []string
	// Note 取值域说明。非枚举必写(枚举的取值域已由 Values 表达完整)。
	Note string
}

Domain 属性取值域。

type ForbiddenLabel

type ForbiddenLabel struct {
	// Name 语义名。
	Name string
	// Why 为什么禁止。
	Why string
}

ForbiddenLabel 明确禁止作为 metric label 的语义(宿主不得自行补进 label)。

type Issue

type Issue struct {
	// Pairing 缺口类目(missing_result / duplicate_result / unpaired_result /
	// duplicate_call)。
	Pairing Pairing
	// CallID 相关工具调用标识(无关联调用时为空)。
	CallID string
	// Seq 相关行的序号。
	Seq int
}

Issue 配对缺口(可观测面):配对不完整时显式报出,不静默丢弃。

type Kind

type Kind string

Kind 轨迹行的类目。

const (
	// KindTurn 轮次行:一轮 turn 的起止(turn_started / turn_ended)。
	KindTurn Kind = "turn"
	// KindStep 步骤行:一个环节的起止(step_started / step_finished)。
	KindStep Kind = "step"
	// KindToolCall 工具调用行:调用时刻为起,配对到的结果时刻为止。
	KindToolCall Kind = "tool_call"
	// KindToolResult 工具结果行:**只在结果没有对应调用时**出现——
	// 配对缺口要显形,不能因为「没有调用行可挂」就把结果丢掉。
	KindToolResult Kind = "tool_result"
)

type Options

type Options struct {
	// Summarize 摘要注入点:宿主自定义行标签(展示文案归宿主,内核不认识
	// 任何领域词)。返回空串时沿用本包的通用标签;nil 时全程用通用标签。
	//
	// 只在行创建时调用一次——行的可读字段(类目、标识、工具名、时刻)在创建
	// 后不再变化,故摘要不需要跟着配对状态刷新。
	Summarize func(Row) string
}

Options 投影选项。

type Pairing

type Pairing string

Pairing 工具调用与结果的配对状态(非工具行恒为 PairingNone)。

const (
	// PairingNone 不适用(非工具行)。
	PairingNone Pairing = ""
	// PairingPaired 调用与结果已配对:起止齐备,时长为实测。
	PairingPaired Pairing = "paired"
	// PairingMissingResult 尚未观察到结果。可能仍在途;投影收口后仍如此,
	// 即确认为「等了也没等到」。
	PairingMissingResult Pairing = "missing_result"
	// PairingDuplicateResult 同一 call_id 收到第二个及以后的结果。
	PairingDuplicateResult Pairing = "duplicate_result"
	// PairingUnpairedResult 结果没有对应的调用。
	PairingUnpairedResult Pairing = "unpaired_result"
	// PairingDuplicateCall 同一 call_id 收到第二个调用声明。
	PairingDuplicateCall Pairing = "duplicate_call"
)

type Projector

type Projector struct {
	// contains filtered or unexported fields
}

Projector 增量投影器:事件流 → 轨迹行。

并发安全:事件流可能由多个生产者投递,故 Apply 内部加锁。

func NewProjector

func NewProjector(opts Options) *Projector

NewProjector 构造投影器。

func (*Projector) Apply

func (p *Projector) Apply(ev eventbus.Event)

Apply 投一条事件(增量投影)。

已经被投过的事件不会被重复计入:后续事件只会**更新**既有行(补结束时刻、 把配对状态从「未配对」改成「已配对」),不会追加出第二条行。

func (*Projector) ApplyAll

func (p *Projector) ApplyAll(evs []eventbus.Event)

ApplyAll 依次投多条事件(增量投影;与逐条 Apply 等价)。

func (*Projector) Finalize

func (p *Projector) Finalize() []Row

Finalize 收口投影:把「不会再有新事件」落成显式事实。

返回仍未收口的行(未完成行)。这些行是 open 态:它们有起点、缺终点, 不是「时长 0 的已完成行」。工具调用行若此时仍无结果,会被记成 missing_result 缺口——到这一步才知道「等不到」是真的等不到。

不锁死投影器:宿主若继续投事件,投影照常更新,相应缺口也会被清掉。

func (*Projector) Issues

func (p *Projector) Issues() []Issue

Issues 配对缺口清单(累计)。

口径:只有「当前仍成立」的缺口会被后来的事件清掉(缺结果等到了结果、 无对应的结果等到了调用);「发生过」的缺口(重复结果、重复声明)永远留在 清单里。同一 (类目, call_id) 只记一条——重复次数由行上的 DupResults 承载。

func (*Projector) Len

func (p *Projector) Len() int

Len 当前轨迹行数。

func (*Projector) Rows

func (p *Projector) Rows() []Row

Rows 当前轨迹行快照(按行序号升序)。

返回拷贝:调用方改动拿到的切片不影响投影器内部状态。

type Row

type Row struct {
	// Seq 行序号(1 起,按创建顺序):宿主据此排序,投影内稳定。
	Seq int
	// Kind 行类目。
	Kind Kind
	// ID 行标识:轮次行 "turn#1";带环节号的步骤行 "step#7";
	// 工具行 "tool#<call_id>";无自然键的行用 "step#-<seq>" 形式兜底
	// ("-" 前缀表示「这个标识是投影生成的,不是业务自然键」)。
	// 格式的唯一生成点是 turnRowID / stepRowID / toolRowID / seqID——otel 的
	// 父子关系复用同一组函数,另写一份格式父子就会连不上。
	ID string
	// Turn 所属轮次序号(1 起);轮次之外的事件为 0。
	Turn int
	// HasStep 是否带环节号(环节号 0 是合法值,故用独立标志位表达「有没有」)。
	HasStep bool
	// StepID 所属环节号(HasStep 为真时有效)。
	StepID uint64
	// CallID 工具调用标识(仅工具行;配对键)。
	CallID string
	// Tool 工具名(仅工具行)。
	Tool string
	// StartAt 起始时刻(取自事件时刻;缺失时零值)。
	StartAt time.Time
	// EndAt 结束时刻(取自事件时刻;未收口时零值)。
	EndAt time.Time
	// Status 收口状态。
	Status Status
	// Pairing 配对状态(仅工具行)。
	Pairing Pairing
	// DupResults 重复结果计数(同一 call_id 收到第二个及以后的结果时累加)。
	DupResults int
	// Summary 行摘要(默认通用标签;宿主可用 Options.Summarize 覆盖)。
	Summary string
}

Row 轨迹行:一轮 turn 里的一段有起止的过程。

行里**没有**时长字段——时长由 Duration() 从起止时刻现算(见包头纪律 1)。 存一个时长字段就会多出第二个真相:事件时刻与存下来的时长各说各话, 而估算值也就能混进同一个字段冒充实测。

func (Row) Complete

func (r Row) Complete() bool

Complete 行是否已收口(起止齐备)。

与 Duration 的区别:orphan 行(只有结束没有开始)同样没有实测时长, 但它的缺口不是「还在等」,而是「起点从未有过」——两件事要分开看。

func (Row) Duration

func (r Row) Duration() (time.Duration, bool)

Duration 返回实测耗时与「是否实测」。

时长只有这一个源:起止时刻相减。行内不存时长——存了就会有两个真相, 而估算值也就能混进同一个字段冒充实测。没有实测值时返回 ok=false: 不拿 0 冒充「瞬时完成」,更不拿载荷体积估一个填进来。

结束早于开始(事件时刻本身不自洽,例如结果先于调用到达)时同样返回 ok=false:负时长不是实测值。

type Span

type Span struct {
	// Name span 名(低基数,取值域见 Kind)。
	Name string
	// ParentID 父 span 的行标识(与 Row.ID 同源,见头注「trace 边界」);
	// 空 = 根,宿主应为它开一个新 trace。
	ParentID string
	// Status span 状态。
	Status SpanStatus
	// StartAt 起点(未收口时为零值,宿主导出时可自行取当前时刻)。
	StartAt time.Time
	// EndAt 终点(未收口时为零值)。
	EndAt time.Time
	// Attributes 属性表(键取本文件的 Attr* 常量,值是 string/int64/float64)。
	Attributes map[string]any
}

Span 一行轨迹的 span 表达(不含 exporter 相关字段:那是投递侧的事)。

func SpanOf

func SpanOf(r Row) Span

SpanOf 把一行轨迹映射成一个 span。

纯函数、不分配时间戳(不读 @time.Now()@):导出时刻由宿主决定, 内核读时钟会让同一份轨迹在两次导出里得出不同的时间——那是第二个真相。

func SpansOf

func SpansOf(rows []Row) []Span

SpansOf 批量映射(保持输入顺序 = 行序)。

与逐条调用 @SpanOf@ 的唯一差别是**解析悬空父**:父行不在本批内时把 @ParentID@ 清成空,这一行随之成为根(见头注)。宿主导出一批行时应当用这个入口—— 逐条导出的断链 span 在多数后端里表现为「trace 缺了一段」,而不会报错。

type SpanStatus

type SpanStatus string

SpanStatus span 状态(OTel 的三值词表)。

const (
	// SpanStatusUnset 未定:这一行还没收口,或「仍在等结果」。
	//
	// 缺省就是这个——把未定硬记成 error 会让真正的事故淹在噪声里。
	SpanStatusUnset SpanStatus = "unset"
	// SpanStatusOK 已完成且没有观察到问题。
	SpanStatusOK SpanStatus = "ok"
	// SpanStatusError 观察到**发生过**的问题(不是「还没等到」)。
	SpanStatusError SpanStatus = "error"
)

type Status

type Status string

Status 轨迹行的收口状态。

const (
	// StatusOpen 已开始、未收口:缺结束事件(可能仍在途,也可能是缺口)。
	StatusOpen Status = "open"
	// StatusClosed 起止齐备:行已收口。
	StatusClosed Status = "closed"
	// StatusOrphan 只有结束事件、没有开始事件:起点缺失,配不出时长。
	StatusOrphan Status = "orphan"
)

type TraceGranularity

type TraceGranularity string

TraceGranularity trace 的切分单位。

const (
	// TracePerTurn 一轮 turn 一个 trace(**本仓选它**)。
	TracePerTurn TraceGranularity = "turn"
	// TracePerStep 一个环节一个 trace(本仓不选:行标识只在轮内唯一,
	// 同号环节会在两个 trace 里撞同一个标识)。
	TracePerStep TraceGranularity = "step"
)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL