Files
Agent-Native/runtime/telemetry/telemetry.go
T
weichen fc0558fc93
ci / vet-and-test (pull_request) Successful in 51s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Align spans with the OTel GenAI convention (N3 WP4)
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)
2026-10-07 15:36:35 +00:00

202 lines
5.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package telemetry 记录 GenAI span。默认不采集提示词正文。
package telemetry
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"os"
"strconv"
"strings"
"time"
"logging-system/logsys"
)
// Span 是一次 invoke_agent、chat 或 execute_tool。
type Span struct {
Name string
TraceID string
SpanID string
ParentSpanID string
Start time.Time
End time.Time
Attrs map[string]string
}
// 保留属性键:循环用它们把 run 级的 trace 关联传进工厂(不导出)。
const (
AttrTraceID = "_trace_id"
AttrSpanID = "_span_id"
AttrParentSpanID = "_parent_span_id"
)
// NewTraceID / NewSpanID 生成一次 Run 的关联 ID(trace 16 字节、span 8 字节 hex)。
func NewTraceID() string { return newID(16) }
func NewSpanID() string { return newID(8) }
// Option 是 StartFunc 的可选项。
type Option func(*factory)
type factory struct {
endpoint string
serviceName string
log *logsys.Logger
}
// WithServiceName 设置 OTLP resource 的 service.name(默认 agent-native)。
func WithServiceName(name string) Option {
return func(f *factory) {
if strings.TrimSpace(name) != "" {
f.serviceName = name
}
}
}
// WithEndpoint 设置 OTLP 端点(优先级高于环境变量;空 = 回退环境变量)。
func WithEndpoint(endpoint string) Option {
return func(f *factory) {
if strings.TrimSpace(endpoint) != "" {
f.endpoint = strings.TrimRight(endpoint, "/")
}
}
}
// StartFunc 返回循环使用的追踪函数。端点来自 WithEndpoint 或环境变量
// OTEL_EXPORTER_OTLP_ENDPOINT(都为空则不导出);结束 span 后以
// OTLP/HTTP JSON 上报,不引入新的依赖。默认不采集提示词正文。
func StartFunc(log *logsys.Logger, opts ...Option) func(name string, attrs map[string]string) func() {
f := &factory{
endpoint: strings.TrimRight(os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"), "/"),
serviceName: "agent-native",
log: log,
}
for _, o := range opts {
o(f)
}
return func(name string, attrs map[string]string) func() {
sp := Span{
Name: name, TraceID: newID(16), SpanID: newID(8),
Start: time.Now(),
}
// 循环用保留键传入 run 级关联:一次 Run 共享 TraceID,
// chat/execute_tool 挂到 invoke_agent 根下。
if attrs != nil {
if v := attrs[AttrTraceID]; v != "" {
sp.TraceID = v
}
if v := attrs[AttrSpanID]; v != "" {
sp.SpanID = v
}
if v := attrs[AttrParentSpanID]; v != "" {
sp.ParentSpanID = v
}
}
return func() {
sp.End = time.Now()
// 属性在结束时过滤:允许循环在 span 存续期间补充(如 usage)。
sp.Attrs = publicAttrs(attrs)
if f.log != nil {
kvs := []any{"span", sp.Name, "trace_id", sp.TraceID, "span_id", sp.SpanID}
if sp.ParentSpanID != "" {
kvs = append(kvs, "parent_span_id", sp.ParentSpanID)
}
for k, v := range sp.Attrs {
kvs = append(kvs, k, v)
}
f.log.Info("genai span", kvs...)
}
if f.endpoint != "" {
if err := ExportOTLP(f.endpoint, sp, f.serviceName); err != nil && f.log != nil {
f.log.Info("genai span export failed", "error", err)
}
}
}
}
}
// publicAttrs 丢掉提示词正文与保留键(_ 前缀的内部约定)。调用方即使
// 传入正文也不会进入 span。
func publicAttrs(in map[string]string) map[string]string {
out := map[string]string{}
for k, v := range in {
if k == "prompt" || k == "messages" || k == "content" || strings.HasPrefix(k, "_") {
continue
}
out[k] = v
}
return out
}
// ExportOTLP 把一个 span POST 到端点的 /v1/traces。
func ExportOTLP(endpoint string, sp Span, serviceName ...string) error {
endpoint = strings.TrimRight(endpoint, "/")
if !strings.HasSuffix(endpoint, "/v1/traces") {
endpoint += "/v1/traces"
}
body, err := json.Marshal(otlpBody(sp, serviceName...))
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
func otlpBody(sp Span, serviceName ...string) map[string]any {
var attrs []map[string]any
for k, v := range publicAttrs(sp.Attrs) {
// gen_ai.usage.* 是数值型语义(intValue),其余保持 stringValue。
if strings.HasPrefix(k, "gen_ai.usage.") {
if n, err := strconv.Atoi(v); err == nil {
attrs = append(attrs, map[string]any{"key": k, "value": map[string]any{"intValue": n}})
continue
}
}
attrs = append(attrs, map[string]any{"key": k, "value": map[string]any{"stringValue": v}})
}
name := "agent-native"
if len(serviceName) > 0 && strings.TrimSpace(serviceName[0]) != "" {
name = serviceName[0]
}
span := map[string]any{
"traceId": sp.TraceID,
"spanId": sp.SpanID,
"name": sp.Name,
"startTimeUnixNano": sp.Start.UnixNano(),
"endTimeUnixNano": sp.End.UnixNano(),
"attributes": attrs,
}
if sp.ParentSpanID != "" {
span["parentSpanId"] = sp.ParentSpanID
}
return map[string]any{
"resourceSpans": []any{map[string]any{
"resource": map[string]any{
"attributes": []map[string]any{
{"key": "service.name", "value": map[string]any{"stringValue": name}},
},
},
"scopeSpans": []any{map[string]any{
"spans": []any{span},
}},
}},
}
}
func newID(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}