feat(observability): OpenTelemetry 全链路追踪(Jaeger + NATS 跨总线传播)

新增 sundynix-shared/otelx:otelx.Init(ctx,服务名) 注册 W3C 传播器 + OTLP/HTTP
批量导出(默认 localhost:4318,docker 里的 Jaeger);OTEL_SDK_DISABLED=true 关导出。
Jaeger 不在线 / 导出器构建失败都不阻断启动(可观测性是增益而非依赖)。

- NATS 跨进程传播(无现成中间件):bus/trace.go 的 natsHeaderCarrier + inject/extract,
  PublishTask/CallTool 注入 traceparent,ConsumeTasks/ServeTool 抽出续上 → 链路跨总线连成一棵树。
- 埋点:gateway 挂 otelgin(HTTP server span,链路根);dispatcher task.execute →
  node.<kind>(每节点,nctx 下传使工具/LLM 挂到节点下)→ llm.stream/llm.generate;
  bus 自动出 tool.call(client)↔tool.serve(server) 成对跨服务 span。
- docker-compose 加 jaeger all-in-one(UI :16686,OTLP :4318)。
- 依赖修复:otlptracehttp 触发 genproto 单体(旧)vs 拆分模块 ambiguous import(milvus 拉旧版),
  pin genproto 至后拆分版(go.work 工作区全局生效)。
- production_readiness.md 1.1 更新为「已实现」。

验证:真实 input→retriever→agent 任务在 Jaeger 出 14 span / 3 服务的完整树,
跨 NATS(publish→consume)、跨服务(tool.call→tool.serve)均连通,
瓶颈 kb_search 692ms、llm 1597ms 一眼可见;四模块 build+vet+test 全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-24 11:35:08 +08:00
parent 46ef3df221
commit a2d184b7ec
22 changed files with 675 additions and 67 deletions
+76
View File
@@ -0,0 +1,76 @@
// Package otelx 是 OpenTelemetry 链路追踪的统一启动器与门面。
//
// 各服务 main 启动时调一次 Init(ctx, "服务名"),即:
// - 注册全局 W3C TraceContext 传播器(即便禁用导出,trace_id 仍能跨服务串起来);
// - 把 span 经 OTLP/HTTP 批量导出到 OTEL_EXPORTER_OTLP_ENDPOINT(默认 localhost:4318,即 docker 里的 Jaeger)。
//
// 业务代码用 otelx.Tracer().Start(ctx, "span 名") 开 span;跨 NATS 的传播由 bus 层自动完成。
// 设 OTEL_SDK_DISABLED=true 可整体关闭导出(仍保留传播器,零依赖运行)。
package otelx
import (
"context"
"log"
"os"
"strings"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
"go.opentelemetry.io/otel/trace"
)
// scopeName 是本项目所有 span 的 instrumentation scopeJaeger 里按此归组)。
const scopeName = "github.com/sundynix/sundynix-agentix"
const defaultEndpoint = "localhost:4318" // Jaeger all-in-one 的 OTLP/HTTP 入口
// Init 初始化全局链路追踪。返回 shutdown,进程退出前调用以 flush 残留 span。
// 导出器构建失败 / Jaeger 不在线都不阻断启动(批处理异步丢弃),保证可观测性是「增益」而非「依赖」。
func Init(ctx context.Context, serviceName string) (func(context.Context) error, error) {
// 传播器始终注册:哪怕本服务不导出,也要把上游 traceparent 透传给下游。
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{}, propagation.Baggage{},
))
noop := func(context.Context) error { return nil }
if strings.EqualFold(os.Getenv("OTEL_SDK_DISABLED"), "true") {
log.Printf("[otel] 导出已禁用 (OTEL_SDK_DISABLED=true)service=%s", serviceName)
return noop, nil
}
endpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
if endpoint == "" {
endpoint = defaultEndpoint
}
// 容忍带 scheme 的写法(http://host:port),otlptracehttp.WithEndpoint 只要 host:port。
endpoint = strings.TrimPrefix(strings.TrimPrefix(endpoint, "http://"), "https://")
endpoint = strings.TrimSuffix(endpoint, "/")
exp, err := otlptracehttp.New(ctx,
otlptracehttp.WithEndpoint(endpoint),
otlptracehttp.WithInsecure(), // 本地 Jaeger 无 TLS
)
if err != nil {
log.Printf("[otel] 导出器构建失败(降级为不导出): %v", err)
return noop, nil
}
res, _ := resource.Merge(resource.Default(), resource.NewWithAttributes(
semconv.SchemaURL, semconv.ServiceName(serviceName),
))
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exp),
sdktrace.WithResource(res),
)
otel.SetTracerProvider(tp)
log.Printf("[otel] 链路追踪已启用 service=%s → otlp http://%s", serviceName, endpoint)
return tp.Shutdown, nil
}
// Tracer 返回统一 scope 的 tracer,供业务开 span。
func Tracer() trace.Tracer { return otel.Tracer(scopeName) }