Files
2026-07-14 10:24:10 +08:00

654 lines
21 KiB
Go
Raw Permalink 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 trace
import (
"context"
"errors"
"math"
"net/http"
"net/http/httptest"
"os"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/propagation"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
oteltrace "go.opentelemetry.io/otel/trace"
)
// initTracerSnapshot 捕获包 init() 后、任何测试改动前的全局 tracer 快照。
// 用于锁定 init() 的 Noop 兜底不变式(C13a),避免 resetGlobal/Init/Close
// 在其他测试中重置全局后掩盖 init() 路径的回归。
var initTracerSnapshot oteltrace.Tracer
func TestMain(m *testing.M) {
// init() 已运行;此处立即快照 getTracer()(永不 nil 的 Noop 兜底)。
initTracerSnapshot = getTracer()
code := m.Run()
os.Exit(code)
}
// resetGlobal 恢复 trace 包级全局到 Noop 兜底状态,避免测试间污染。
func resetGlobal(t *testing.T) {
t.Helper()
noopProvider := oteltrace.NewNoopTracerProvider()
noopTracer := noopProvider.Tracer("xlgo")
tp := sdktrace.NewTracerProvider(sdktrace.WithSampler(sdktrace.NeverSample()))
tracerProviderPtr.Store(tp)
tracerPtr.Store(&noopTracer)
otel.SetTracerProvider(noopProvider)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{}, propagation.Baggage{}))
}
func setOperationTimeoutForTest(t *testing.T, timeout time.Duration) {
t.Helper()
old := operationTimeoutNanos.Load()
operationTimeoutNanos.Store(int64(timeout))
t.Cleanup(func() {
operationTimeoutNanos.Store(old)
})
}
type blockingExporter struct{}
func (blockingExporter) ExportSpans(context.Context, []sdktrace.ReadOnlySpan) error {
return nil
}
func (blockingExporter) Shutdown(ctx context.Context) error {
<-ctx.Done()
return ctx.Err()
}
type recordingExporter struct {
mu sync.Mutex
spans []sdktrace.ReadOnlySpan
}
func (e *recordingExporter) ExportSpans(_ context.Context, spans []sdktrace.ReadOnlySpan) error {
e.mu.Lock()
defer e.mu.Unlock()
e.spans = append(e.spans, spans...)
return nil
}
func (e *recordingExporter) Shutdown(context.Context) error {
return nil
}
func (e *recordingExporter) Spans() []sdktrace.ReadOnlySpan {
e.mu.Lock()
defer e.mu.Unlock()
spans := make([]sdktrace.ReadOnlySpan, len(e.spans))
copy(spans, e.spans)
return spans
}
func requireNoPanic(t *testing.T, fn func()) {
t.Helper()
defer func() {
if r := recover(); r != nil {
t.Fatalf("unexpected panic: %v", r)
}
}()
fn()
}
// TestC13aInitNoopInvariant 锁定 init() 的 Noop 兜底不变式:
// 包加载后(任何 Init/Close/resetGlobal 之前)getTracer() 必须非 nil。
//
// 变异 init() 去掉 Noop Store 后,initTracerSnapshot(在 TestMain 即 init 后捕获)
// 将为 nil → 此测试红。resetGlobal 等后续改动不影响此快照。
func TestC13aInitNoopInvariant(t *testing.T) {
if initTracerSnapshot == nil {
t.Fatal("init() did not store a Noop tracer: getTracer() was nil at package load (C13a)")
}
}
// ============================================================
// C13a:未 Init 即用 → nil tracer panic
// ============================================================
// TestC13aNoInitNoPanic 验证未 Init 时包级函数不 panic(Noop 兜底)。
//
// 修复前:包级 tracer 为 nilMiddleware/StartSpanFromContext/GetTracer 裸用 → panic。
// 修复后:init() Store NoopgetTracer() 永不 nil。
func TestC13aNoInitNoPanic(t *testing.T) {
// 强制重置到"未 Init"的 Noop 兜底状态。
resetGlobal(t)
// GetTracer 非 nil。
if tr := GetTracer(); tr == nil {
t.Fatal("GetTracer() nil before Init (C13a)")
}
// StartSpanFromContext 不 panic。
ctx, span := StartSpanFromContext(context.Background(), "test-span")
defer span.End()
if span == nil {
t.Fatal("StartSpanFromContext returned nil span")
}
_ = ctx
// Middleware 不 panic:走一次请求。
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(Middleware("test-svc"))
r.GET("/p", func(c *gin.Context) {
// 下游用 c.Request.Context() 取 spanC13d 闭环)。
s := oteltrace.SpanFromContext(c.Request.Context())
c.String(http.StatusOK, s.SpanContext().TraceID().String())
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/p", nil)
r.ServeHTTP(w, req)
// Noop tracer 的 TraceID 为空(Noop 不记录),但绝不应 panic。
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (C13a should not panic)", w.Code)
}
}
// TestC13aInitDisabledNoop 验证 Init(Enabled:false) 后 Noop 安全。
func TestC13aInitDisabledNoop(t *testing.T) {
t.Cleanup(func() { _ = Close(context.Background()); resetGlobal(t) })
if err := Init(Config{Enabled: false, ServiceName: "svc"}); err != nil {
t.Fatalf("Init: %v", err)
}
if GetTracer() == nil {
t.Fatal("GetTracer nil after Init(Enabled:false)")
}
// Noop tracer Start 不 panic。
_, span := StartSpanFromContext(context.Background(), "x")
span.End()
}
// TestC13aCloseThenUseNoPanic 验证 Close 后再用不 panicStore 回 Noop 兜底)。
func TestC13aCloseThenUseNoPanic(t *testing.T) {
resetGlobal(t)
t.Cleanup(func() { resetGlobal(t) })
// 即便未真正 Init 出带 exporter 的 providerClose 也应安全并把全局重置为兜底。
if err := Close(context.Background()); err != nil {
t.Fatalf("Close: %v", err)
}
// Close 后包级函数仍安全。
if GetTracer() == nil {
t.Fatal("GetTracer nil after Close")
}
_, span := StartSpanFromContext(context.Background(), "after-close")
span.End()
}
// ============================================================
// C13b:未知导出器返 nil + stdout 未实现
// ============================================================
// TestC13bStdoutExporterWorks 验证 stdout 导出器可创建(C13b 实现)。
func TestC13bStdoutExporterWorks(t *testing.T) {
// 用 stdouttrace 但 Init 会向 os.Stdout 输出,故直接测 createExporter。
cfg := Config{ExporterType: "stdout"}
exp, err := createExporter(cfg)
if err != nil {
t.Fatalf("createExporter(stdout): %v (C13b stdout unimplemented)", err)
}
if exp == nil {
t.Fatal("createExporter(stdout) returned nil exporter (C13b)")
}
_ = exp.Shutdown(context.Background())
}
// TestC13bUnknownExporterReturnsError 验证未知导出器返错(修复前返 nil,nil)。
func TestC13bUnknownExporterReturnsError(t *testing.T) {
cfg := Config{ExporterType: "xyz-unknown"}
exp, err := createExporter(cfg)
if err == nil {
t.Error("createExporter(unknown) should return error (C13b), got nil")
}
if exp != nil {
t.Errorf("createExporter(unknown) should return nil exporter, got %T", exp)
}
}
// TestC13bInitUnknownExporterFails 验证 Init 未知导出器返错(不喂 nil 给 WithBatcher)。
func TestC13bInitUnknownExporterFails(t *testing.T) {
t.Cleanup(func() { resetGlobal(t) })
err := Init(Config{Enabled: true, ExporterType: "xyz-unknown", Propagator: "w3c"})
if err == nil {
t.Error("Init with unknown exporter should fail (C13b)")
}
}
// ============================================================
// C13cOTLP 默认 HTTPS 无 WithInsecure
// ============================================================
// TestC13cInsecureExporterCreates 验证 Insecure:true 时 otlp-http 导出器可创建(无 TLS 握手)。
// createExporter 仅构造 client,不连接;Insecure 注入不报错即验证 option 路径生效。
func TestC13cInsecureExporterCreates(t *testing.T) {
cfg := Config{ExporterType: "otlp-http", Endpoint: "localhost:4318", Insecure: true}
exp, err := createExporter(cfg)
if err != nil {
t.Fatalf("createExporter(otlp-http, Insecure): %v (C13c)", err)
}
_ = exp.Shutdown(context.Background())
}
// TestC13cOtlpGrpcInsecureCreates 验证 otlp-grpc Insecure 路径。
func TestC13cOtlpGrpcInsecureCreates(t *testing.T) {
cfg := Config{ExporterType: "otlp-grpc", Endpoint: "localhost:4317", Insecure: true}
exp, err := createExporter(cfg)
if err != nil {
t.Fatalf("createExporter(otlp-grpc, Insecure): %v (C13c)", err)
}
_ = exp.Shutdown(context.Background())
}
// ============================================================
// C13dMiddleware 不更新 c.Request
// ============================================================
// TestC13dRequestContextContainsSpan 验证 Middleware 更新 c.Request
// 下游 c.Request.Context() 含 spanTraceID 非空且与 X-Trace-ID 一致)。
//
// 修复前:仅 c.Set("otel_ctx", ctx),下游 c.Request.Context() 无 span。
func TestC13dRequestContextContainsSpan(t *testing.T) {
resetGlobal(t)
t.Cleanup(func() { _ = Close(context.Background()); resetGlobal(t) })
// 用 stdout 导出器 + 全采样,使 span 真实生成(非 Noop)。
if err := Init(Config{
Enabled: true,
ServiceName: "test-svc",
ExporterType: "stdout",
SampleRatio: 1.0,
Propagator: "w3c",
}); err != nil {
t.Fatalf("Init: %v", err)
}
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(Middleware("test-svc"))
var seenTraceID string
r.GET("/p", func(c *gin.Context) {
// 关键:用 c.Request.Context()(而非 trace.GetContext(c))取 span。
s := oteltrace.SpanFromContext(c.Request.Context())
seenTraceID = s.SpanContext().TraceID().String()
c.String(http.StatusOK, "ok")
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/p", nil)
r.ServeHTTP(w, req)
if seenTraceID == "" {
t.Fatal("c.Request.Context() has no span/TraceID (C13d: Middleware didn't update c.Request)")
}
headerTraceID := w.Header().Get("X-Trace-ID")
if headerTraceID == "" {
t.Fatal("X-Trace-ID header missing")
}
if seenTraceID != headerTraceID {
t.Errorf("TraceID mismatch: downstream %q vs header %q (C13d)", seenTraceID, headerTraceID)
}
}
// TestC13dPropagatedTraceContextExtracted 验证 Middleware 从入站 W3C 头提取父 context
// 并写入 c.Request,下游 span 继承父 TraceID。
func TestC13dPropagatedTraceContextExtracted(t *testing.T) {
resetGlobal(t)
t.Cleanup(func() { _ = Close(context.Background()); resetGlobal(t) })
if err := Init(Config{
Enabled: true, ServiceName: "test-svc", ExporterType: "stdout",
SampleRatio: 1.0, Propagator: "w3c",
}); err != nil {
t.Fatalf("Init: %v", err)
}
// 构造一个父 span 并注入 W3C traceparent 头(用真实采样 provider,非 noop)。
parentProvider := sdktrace.NewTracerProvider(sdktrace.WithSampler(sdktrace.AlwaysSample()))
defer parentProvider.Shutdown(context.Background())
parentTracer := parentProvider.Tracer("test-parent")
parentCtx, parentSpan := parentTracer.Start(context.Background(), "parent")
defer parentSpan.End()
parentTraceID := parentSpan.SpanContext().TraceID().String()
carrier := propagation.HeaderCarrier{}
otel.GetTextMapPropagator().Inject(parentCtx, carrier)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(Middleware("test-svc"))
var seen string
r.GET("/p", func(c *gin.Context) {
s := oteltrace.SpanFromContext(c.Request.Context())
seen = s.SpanContext().TraceID().String()
c.Status(http.StatusOK)
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/p", nil)
// 把 carrier 中的头复制到请求。
for _, key := range carrier.Keys() {
req.Header.Set(key, carrier.Get(key))
}
r.ServeHTTP(w, req)
if seen != parentTraceID {
t.Errorf("downstream TraceID = %q, want parent %q (C13d propagation/extract)", seen, parentTraceID)
}
}
// ============================================================
// C13eb3/jaeger 未实现
// ============================================================
// TestC13eB3PropagatorImplemented 验证 b3 传播器返回非 nil 的 b3 propagatorC13e 实现)。
func TestC13eB3PropagatorImplemented(t *testing.T) {
prop, err := createPropagator("b3")
if err != nil {
t.Fatalf("createPropagator(b3): %v (C13e unimplemented)", err)
}
if prop == nil {
t.Fatal("createPropagator(b3) returned nil (C13e)")
}
// 用真实采样的 provider tracer,使 span SpanContext 有效。
tp := sdktrace.NewTracerProvider(sdktrace.WithSampler(sdktrace.AlwaysSample()))
defer tp.Shutdown(context.Background())
tracer := tp.Tracer("t")
ctx, span := tracer.Start(context.Background(), "s")
defer span.End()
// b3 propagator 应识别 b3 头。注入后 b3 单头或 x-b3-traceid 至少其一非空。
carrier := propagation.HeaderCarrier{}
prop.Inject(ctx, carrier)
if carrier.Get("b3") == "" && carrier.Get("x-b3-traceid") == "" {
t.Error("b3 propagator did not inject b3 headers (C13e)")
}
}
// TestC13eJaegerMapsToW3C 验证 jaeger 映射 W3C TraceContext(非静默 nil)。
func TestC13eJaegerMapsToW3C(t *testing.T) {
prop, err := createPropagator("jaeger")
if err != nil {
t.Fatalf("createPropagator(jaeger): %v (C13e)", err)
}
if prop == nil {
t.Fatal("createPropagator(jaeger) returned nil (C13e)")
}
// 用真实采样的 provider tracer,使 span SpanContext 有效(noop tracer 不生成 TraceID)。
tp := sdktrace.NewTracerProvider(sdktrace.WithSampler(sdktrace.AlwaysSample()))
defer tp.Shutdown(context.Background())
tracer := tp.Tracer("t")
ctx, span := tracer.Start(context.Background(), "s")
defer span.End()
// 注入 W3C traceparent 头。
carrier := propagation.HeaderCarrier{}
prop.Inject(ctx, carrier)
if carrier.Get("traceparent") == "" {
t.Error("jaeger(→W3C) propagator did not inject traceparent (C13e)")
}
}
// TestC13eUnknownPropagatorReturnsError 验证未知传播器返错(修复前静默回落 W3C)。
func TestC13eUnknownPropagatorReturnsError(t *testing.T) {
prop, err := createPropagator("xyz")
if err == nil {
t.Error("createPropagator(unknown) should return error (C13e), got nil")
}
if prop != nil {
t.Errorf("createPropagator(unknown) should return nil, got %T", prop)
}
}
// TestC13eInitUnknownPropagatorFails 验证 Init 未知传播器返错且回滚 provider。
func TestC13eInitUnknownPropagatorFails(t *testing.T) {
t.Cleanup(func() { resetGlobal(t) })
err := Init(Config{Enabled: true, ExporterType: "stdout", Propagator: "xyz"})
if err == nil {
t.Error("Init with unknown propagator should fail (C13e)")
}
}
// TestC13eW3CDefault 验证空 propagator 默认 W3C(兼容)。
func TestC13eW3CDefault(t *testing.T) {
prop, err := createPropagator("")
if err != nil {
t.Fatalf("createPropagator(''): %v", err)
}
if prop == nil {
t.Fatal("createPropagator('') returned nil")
}
}
// ============================================================
// C13 顺带:GetContext 裸断言防护
// ============================================================
// TestC13GetContextCommaOk 验证 otel_ctx 被置为非 context 值时不 panic。
func TestC13GetContextCommaOk(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
c.Set("otel_ctx", "not-a-context") // 污染
// 修复前:ctx.(context.Context) 裸断言 panic;修复后 comma-ok 回退 c.Request.Context()。
ctx := GetContext(c)
if ctx == nil {
t.Fatal("GetContext returned nil")
}
// 应回退到 c.Request.Context()。
if ctx != c.Request.Context() {
t.Error("GetContext should fall back to c.Request.Context() on bad otel_ctx")
}
}
// ============================================================
// H-14Close→Init→Close 多轮生命周期(原 sync.Once 致第二次 Close 不 Shutdown
// ============================================================
// TestH14CloseInitCloseMultipleRounds 验证 Close 可反复执行,
// 且 Close→Init→Close 第二次 Close 仍真正 Shutdown 新 provider(不泄漏 exporter)。
//
// 修复前:Close 用 sync.Once,第二次 Close no-op,第二轮 Init 创建的 provider 的
// exporter 后台 goroutine/连接泄漏。
func TestH14CloseInitCloseMultipleRounds(t *testing.T) {
t.Cleanup(func() { resetGlobal(t) })
for round := 0; round < 3; round++ {
if err := Init(Config{Enabled: true, ServiceName: "svc", ExporterType: "stdout", Propagator: "w3c"}); err != nil {
t.Fatalf("round %d Init failed: %v", round, err)
}
// Init 后 provider 非 nil
if TracerProvider() == nil {
t.Fatalf("round %d: provider nil after Init", round)
}
if err := Close(context.Background()); err != nil {
t.Fatalf("round %d Close failed: %v", round, err)
}
// Close 后仍非 nil(兜底 NeverSample providerC13a
if TracerProvider() == nil {
t.Fatalf("round %d: provider nil after Close", round)
}
}
}
// TestH14CloseIdempotent 验证连续 Close 不 panic、不报错。
func TestH14CloseIdempotent(t *testing.T) {
t.Cleanup(func() { resetGlobal(t) })
if err := Close(context.Background()); err != nil {
t.Fatalf("first Close: %v", err)
}
if err := Close(context.Background()); err != nil {
t.Fatalf("second Close: %v", err)
}
}
// ============================================================
// M14 trace residue: validation, timeout, nil guards, headers, service name
// ============================================================
func TestM14SampleRatioRange(t *testing.T) {
t.Cleanup(func() { resetGlobal(t) })
for _, ratio := range []float64{-0.01, 1.01, math.NaN()} {
err := Init(Config{
Enabled: true,
ExporterType: "stdout",
Propagator: "w3c",
SampleRatio: ratio,
})
if err == nil {
t.Fatalf("Init with SampleRatio=%v should fail", ratio)
}
}
for _, ratio := range []float64{0, 1} {
if err := Init(Config{
Enabled: true,
ServiceName: "svc",
ExporterType: "stdout",
Propagator: "w3c",
SampleRatio: ratio,
}); err != nil {
t.Fatalf("Init with boundary SampleRatio=%v failed: %v", ratio, err)
}
if err := Close(context.Background()); err != nil {
t.Fatalf("Close after SampleRatio=%v: %v", ratio, err)
}
}
}
func TestM14CloseUsesOperationTimeout(t *testing.T) {
resetGlobal(t)
t.Cleanup(func() { resetGlobal(t) })
setOperationTimeoutForTest(t, 20*time.Millisecond)
blockingProvider := sdktrace.NewTracerProvider(sdktrace.WithSyncer(blockingExporter{}))
tracerProviderPtr.Store(blockingProvider)
start := time.Now()
err := Close(context.Background())
elapsed := time.Since(start)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("Close error = %v, want context deadline exceeded", err)
}
if elapsed > 500*time.Millisecond {
t.Fatalf("Close took %s, expected operation timeout to bound shutdown", elapsed)
}
}
func TestM14InitDisabledShutdownUsesOperationTimeout(t *testing.T) {
resetGlobal(t)
t.Cleanup(func() { resetGlobal(t) })
setOperationTimeoutForTest(t, 20*time.Millisecond)
blockingProvider := sdktrace.NewTracerProvider(sdktrace.WithSyncer(blockingExporter{}))
tracerProviderPtr.Store(blockingProvider)
start := time.Now()
if err := Init(Config{Enabled: false, ServiceName: "svc"}); err != nil {
t.Fatalf("Init disabled: %v", err)
}
if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
t.Fatalf("Init disabled took %s, expected operation timeout to bound old provider shutdown", elapsed)
}
}
func TestM14RecordErrorNilInputsNoPanic(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
requireNoPanic(t, func() { RecordError(nil, errors.New("boom")) })
requireNoPanic(t, func() { RecordError(c, nil) })
var nilSpan oteltrace.Span
requireNoPanic(t, func() { RecordErrorToSpan(nilSpan, errors.New("boom")) })
requireNoPanic(t, func() {
RecordErrorToSpan(oteltrace.SpanFromContext(context.Background()), nil)
})
}
func TestM14NoZeroTraceIDHeaderFromNoop(t *testing.T) {
resetGlobal(t)
t.Cleanup(func() { resetGlobal(t) })
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(Middleware("svc"))
r.GET("/p", func(c *gin.Context) {
c.Status(http.StatusOK)
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/p", nil)
r.ServeHTTP(w, req)
const zeroTraceID = "00000000000000000000000000000000"
if got := w.Header().Get("X-Trace-ID"); got == zeroTraceID {
t.Fatalf("X-Trace-ID should not expose all-zero TraceID, got %q", got)
}
}
func TestM14MiddlewareServiceNameAttribute(t *testing.T) {
resetGlobal(t)
t.Cleanup(func() { resetGlobal(t) })
exporter := &recordingExporter{}
provider := sdktrace.NewTracerProvider(
sdktrace.WithSyncer(exporter),
sdktrace.WithSampler(sdktrace.AlwaysSample()),
)
tracer := provider.Tracer("test")
tracerProviderPtr.Store(provider)
tracerPtr.Store(&tracer)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(Middleware("middleware-svc"))
r.GET("/p", func(c *gin.Context) {
c.Status(http.StatusOK)
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/p", nil)
r.ServeHTTP(w, req)
for _, span := range exporter.Spans() {
for _, attr := range span.Attributes() {
if string(attr.Key) == "service.name" && attr.Value.AsString() == "middleware-svc" {
return
}
}
}
t.Fatal("server span missing service.name attribute from Middleware(serviceName)")
}
// TestM65PropagatorFailureLeavesOtelUsable 验证传播器失败时 otel 全局未被切到
// 已 Shutdown 的 providerM-65)。失败后仍可正常 start span 不 panic。
func TestM65PropagatorFailureLeavesOtelUsable(t *testing.T) {
t.Cleanup(func() { resetGlobal(t) })
// 先成功 Init 一个
if err := Init(Config{Enabled: true, ServiceName: "svc", ExporterType: "stdout", Propagator: "w3c"}); err != nil {
t.Fatalf("setup Init: %v", err)
}
// 再用非法传播器 Init,应失败
err := Init(Config{Enabled: true, ExporterType: "stdout", Propagator: "xyz"})
if err == nil {
t.Fatal("Init with bad propagator should fail")
}
// otel 全局 provider 仍可用:StartSpan 不 panic
tr := otel.Tracer("test")
_, span := tr.Start(context.Background(), "after-failure")
span.End()
}