Align spans with the OTel GenAI convention (N3 WP4)
ci / vet-and-test (pull_request) Successful in 51s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s

Why: acceptance clause 3 requires span names and attributes to follow
the convention; today names were bare words with two ad-hoc attribute
keys, and every span got a fresh random trace with no parentage.
Evidence: GOWORK=off go vet + go test -count=1 ./... green;
TestSpanNamesFollowGenAIConvention (name suffixes, gen_ai.* key enum,
intValue usage, service.name resource, prompt/reserved-key exclusion),
TestLoopSpansFollowGenAIConvention (invoke_agent root with shared
trace, chat/execute_tool children with parent links, usage attrs on
chat spans); the pre-existing prompt-body denylist test passes
unchanged.
Impact: span names become invoke_agent <agent> / chat <model> /
execute_tool <tool>; attributes move to gen_ai.* with usage as
intValue; one Run shares one trace with invoke_agent as root. New
Config.AgentName/ProviderName (documented defaults keep old hosts
meaningful); telemetry StartFunc gains WithServiceName/WithEndpoint
options (defaults preserve current behavior).
Refs: Agent-System-Documents#43 (N3 plan WP4)
This commit is contained in:
2026-10-07 15:36:35 +00:00
parent 79e1a8fa85
commit fc0558fc93
4 changed files with 436 additions and 32 deletions
+75 -4
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"sync"
"time"
@@ -12,6 +13,7 @@ import (
"agent-native/runtime/llm"
"agent-native/runtime/reminders"
"agent-native/runtime/session"
"agent-native/runtime/telemetry"
"agent-native/runtime/tools"
"logging-system/logsys"
@@ -116,6 +118,15 @@ type Config struct {
// 本次恢复写入的配对摘要以系统消息注入(不持久化,已在 journal 里)。
// 为假(默认)保持既有语义:Session 为权威上下文。
JournalOnly bool
// AgentName 是 invoke_agent span 的名字后缀(OTel GenAI 约定
// "invoke_agent <agent>")。空 = "agent-native"。
AgentName string
// ProviderName 是 gen_ai.system 属性值(如 openai / anthropic)。
// 空 = "openai"。
ProviderName string
// traceCtx 是本次 Run 的 span 关联(Run 启动时建立;指针随 Config
// 副本传播到各轮与各工具)。
traceCtx *traceIDs
// Hooks 是进程内钩子。空总线不改变停止、提醒和拒绝次数。
Hooks *hooks.Bus
// PermissionAsk 在钩子对一次工具调用表态 ask 时向宿主请求裁决。
@@ -176,7 +187,12 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
cfg.ToolExecution = ExecSequential
}
serializeHostCallbacks(&cfg)
endRun := startSpan(cfg, "invoke_agent", map[string]string{"model": cfg.Model})
cfg.traceCtx = newTraceContext()
endRun := startSpan(cfg, "invoke_agent "+agentName(cfg), map[string]string{
"gen_ai.system": providerName(cfg),
"gen_ai.agent.name": agentName(cfg),
"gen_ai.request.model": cfg.Model,
}, true)
defer endRun()
var messages []llm.Message
if cfg.Session != nil && !cfg.JournalOnly {
@@ -438,7 +454,11 @@ func systemText(cfg Config) string {
}
func consume(ctx context.Context, cfg Config, messages []llm.Message) (llm.Event, error) {
endSpan := startSpan(cfg, "chat", map[string]string{"model": cfg.Model})
spanAttrs := map[string]string{
"gen_ai.system": providerName(cfg),
"gen_ai.request.model": cfg.Model,
}
endSpan := startSpan(cfg, "chat "+cfg.Model, spanAttrs, false)
defer endSpan()
var decls []llm.ToolDecl
if cfg.Tools != nil {
@@ -475,6 +495,11 @@ func consume(ctx context.Context, cfg Config, messages []llm.Message) (llm.Event
sawEnd = true
}
}
// usage 在 span 结束前补进属性(结束时过滤导出,见 telemetry 工厂)。
if sawEnd {
spanAttrs["gen_ai.usage.input_tokens"] = itoa(end.Usage.PromptTokens)
spanAttrs["gen_ai.usage.output_tokens"] = itoa(end.Usage.CompletionTokens)
}
if !sawEnd {
return llm.Event{Message: llm.Message{StopReason: llm.StopError}}, errString("stream ended without a message")
}
@@ -686,7 +711,10 @@ func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message {
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: msg.Content})
return msg
}
endSpan := startSpan(cfg, "execute_tool", map[string]string{"tool": call.Name})
endSpan := startSpan(cfg, "execute_tool "+call.Name, map[string]string{
"gen_ai.system": providerName(cfg),
"gen_ai.tool.name": call.Name,
}, false)
defer endSpan()
res := tools.Result{Content: "no tools configured", IsError: true}
known := cfg.Tools != nil && cfg.Tools.Has(call.Name)
@@ -864,10 +892,38 @@ func commitToolIntent(cfg Config, call llm.ToolCall) error {
})
}
func startSpan(cfg Config, name string, attrs map[string]string) func() {
func itoa(n int) string { return strconv.Itoa(n) }
// traceIDs 是一次 Run 的 span 关联:根 trace + invoke_agent 根 span。
type traceIDs struct {
traceID string
rootID string
}
// newTraceContext 建立一次 Run 的关联(Trace 为 nil 时也给空壳,调用方免判空)。
func newTraceContext() *traceIDs {
return &traceIDs{traceID: telemetry.NewTraceID(), rootID: telemetry.NewSpanID()}
}
// startSpan 开一个 OTel GenAI span:root=true 时作 invoke_agent 根(无 parent),
// 否则作子 span(parent = 根)。attrs 由调用方持有引用——存续期间可补
// (usage 在结束时随过滤导出)。
func startSpan(cfg Config, name string, attrs map[string]string, root bool) func() {
if cfg.Trace == nil {
return func() {}
}
if attrs == nil {
attrs = map[string]string{}
}
if cfg.traceCtx != nil {
attrs[telemetry.AttrTraceID] = cfg.traceCtx.traceID
if root {
attrs[telemetry.AttrSpanID] = cfg.traceCtx.rootID
} else {
attrs[telemetry.AttrSpanID] = telemetry.NewSpanID()
attrs[telemetry.AttrParentSpanID] = cfg.traceCtx.rootID
}
}
end := cfg.Trace(name, attrs)
if end == nil {
return func() {}
@@ -875,6 +931,21 @@ func startSpan(cfg Config, name string, attrs map[string]string) func() {
return end
}
// agentName / providerName 是 OTel 属性的默认值解析。
func agentName(cfg Config) string {
if strings.TrimSpace(cfg.AgentName) != "" {
return cfg.AgentName
}
return "agent-native"
}
func providerName(cfg Config) string {
if strings.TrimSpace(cfg.ProviderName) != "" {
return cfg.ProviderName
}
return "openai"
}
func clearOldResults(cfg Config, messages []llm.Message) []llm.Message {
window := cfg.ContextWindow
if window <= 0 {