Ctrl K
ComunidadGuías…

internal/observability/otel_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package observability

import (
	"context"
	"io"
	"net"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	coltracepb "go.opentelemetry.io/proto/otlp/collector/trace/v1"
	"google.golang.org/grpc"
	"google.golang.org/protobuf/proto"
)

// traceCollector is a minimal OTLP/gRPC trace receiver.
type traceCollector struct {
	coltracepb.UnimplementedTraceServiceServer
	spans chan string
}

func (c *traceCollector) Export(_ context.Context, req *coltracepb.ExportTraceServiceRequest) (*coltracepb.ExportTraceServiceResponse, error) {
	for _, rs := range req.GetResourceSpans() {
		for _, ss := range rs.GetScopeSpans() {
			for _, s := range ss.GetSpans() {
				c.spans <- s.GetName()
			}
		}
	}
	return &coltracepb.ExportTraceServiceResponse{}, nil
}

func startGRPCCollector(t *testing.T) (addr string, spans chan string) {
	t.Helper()
	lis, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatal(err)
	}
	spans = make(chan string, 16)
	srv := grpc.NewServer()
	coltracepb.RegisterTraceServiceServer(srv, &traceCollector{spans: spans})
	go func() { _ = srv.Serve(lis) }()
	t.Cleanup(srv.Stop)
	return lis.Addr().String(), spans
}

func startHTTPCollector(t *testing.T) (addr string, spans chan string) {
	t.Helper()
	spans = make(chan string, 16)
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/v1/traces" {
			w.WriteHeader(http.StatusOK) // metrics/logs: accept and drop
			return
		}
		body, _ := io.ReadAll(r.Body)
		var req coltracepb.ExportTraceServiceRequest
		if err := proto.Unmarshal(body, &req); err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		for _, rs := range req.GetResourceSpans() {
			for _, ss := range rs.GetScopeSpans() {
				for _, s := range ss.GetSpans() {
					spans <- s.GetName()
				}
			}
		}
		w.Header().Set("Content-Type", "application/x-protobuf")
		_, _ = w.Write(nil)
	}))
	t.Cleanup(srv.Close)
	return strings.TrimPrefix(srv.URL, "http://"), spans
}

// exportOneSpan runs Setup with the given endpoint and protocol, records a
// span, flushes via shutdown, and returns the span name the collector saw.
func exportOneSpan(t *testing.T, endpoint, protocol string, spans chan string) string {
	t.Helper()
	// Setup gates on config.OTLPEndpoint; the exporters read the env var.
	t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", endpoint)

	cfg := DefaultConfig()
	cfg.OTLPEndpoint = endpoint
	cfg.OTLPProtocol = protocol
	shutdown, err := Setup(context.Background(), cfg)
	if err != nil {
		t.Fatalf("Setup: %v", err)
	}

	_, span := GetTracer("otel-test").Start(context.Background(), "test-span")
	span.End()

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	// Metrics/logs may be rejected by these trace-only collectors; only the
	// trace delivery matters here.
	_ = shutdown(ctx)

	select {
	case name := <-spans:
		return name
	case <-time.After(5 * time.Second):
		t.Fatal("collector received no span")
		return ""
	}
}

func TestSetupExportsSpans(t *testing.T) {
	tests := []struct {
		name     string
		protocol string
		start    func(*testing.T) (string, chan string)
		endpoint func(addr string) string
	}{
		{"grpc url", ProtocolGRPC, startGRPCCollector, func(a string) string { return "http://" + a }},
		{"grpc bare host:port", ProtocolGRPC, startGRPCCollector, func(a string) string { return a }},
		{"http url", ProtocolHTTPProtobuf, startHTTPCollector, func(a string) string { return "http://" + a }},
		{"http bare host:port", ProtocolHTTPProtobuf, startHTTPCollector, func(a string) string { return a }},
		{"default protocol is http", "", startHTTPCollector, func(a string) string { return "http://" + a }},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			addr, spans := tt.start(t)
			if got := exportOneSpan(t, tt.endpoint(addr), tt.protocol, spans); got != "test-span" {
				t.Fatalf("span name = %q, want test-span", got)
			}
		})
	}
}

// TestSetupTracesOnlySkipsOtherExporters points Sparrow at a gRPC collector
// that serves traces only (as Jaeger and Tempo do). With OTLPSignals limited
// to traces, no metric or log exporter is created, so shutdown flushes
// cleanly instead of failing with "unknown service ...MetricsService".
func TestSetupTracesOnlySkipsOtherExporters(t *testing.T) {
	addr, spans := startGRPCCollector(t)
	t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://"+addr)

	cfg := DefaultConfig()
	cfg.OTLPEndpoint = "http://" + addr
	cfg.OTLPProtocol = ProtocolGRPC
	cfg.OTLPSignals = []string{SignalTraces}
	cfg.Prometheus = true
	shutdown, err := Setup(context.Background(), cfg)
	if err != nil {
		t.Fatalf("Setup: %v", err)
	}
	t.Cleanup(func() { metricsHandler = nil })

	_, span := GetTracer("otel-test").Start(context.Background(), "traces-only-span")
	span.End()
	counter, err := GetMeter("otel-test").Int64Counter("sparrow_traces_only_total")
	if err != nil {
		t.Fatal(err)
	}
	counter.Add(context.Background(), 1)

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	if err := shutdown(ctx); err != nil {
		t.Fatalf("shutdown: %v", err)
	}
	select {
	case name := <-spans:
		if name != "traces-only-span" {
			t.Fatalf("span name = %q", name)
		}
	case <-time.After(5 * time.Second):
		t.Fatal("collector received no span")
	}
	if MetricsHandler() == nil {
		t.Fatal("Prometheus scrape handler missing: /metrics must work regardless of OTLP signals")
	}
}

func TestSetupRejectsUnknownSignal(t *testing.T) {
	cfg := DefaultConfig()
	cfg.OTLPEndpoint = "http://localhost:4318"
	cfg.OTLPSignals = []string{"traces", "spans"}
	if _, err := Setup(context.Background(), cfg); err == nil {
		t.Fatal("expected error for unknown signal")
	}
}

func TestSetupRejectsUnknownProtocol(t *testing.T) {
	cfg := DefaultConfig()
	cfg.OTLPEndpoint = "http://localhost:4318"
	cfg.OTLPProtocol = "http/json"
	if _, err := Setup(context.Background(), cfg); err == nil {
		t.Fatal("expected error for unsupported protocol")
	}
}

func TestSetupServesPrometheusWithoutOTLP(t *testing.T) {
	cfg := DefaultConfig()
	cfg.Prometheus = true
	shutdown, err := Setup(context.Background(), cfg)
	if err != nil {
		t.Fatalf("Setup: %v", err)
	}
	t.Cleanup(func() { _ = shutdown(context.Background()); metricsHandler = nil })

	handler := MetricsHandler()
	if handler == nil {
		t.Fatal("MetricsHandler() = nil with Prometheus enabled")
	}
	counter, err := GetMeter("prom-test").Int64Counter("sparrow_test_things_total")
	if err != nil {
		t.Fatal(err)
	}
	counter.Add(context.Background(), 3)

	rec := httptest.NewRecorder()
	handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
	body := rec.Body.String()
	for _, want := range []string{"sparrow_test_things_total", "go_goroutines"} {
		if !strings.Contains(body, want) {
			t.Errorf("scrape output lacks %s:\n%s", want, body)
		}
	}
	if strings.Contains(body, "_total_total") {
		t.Errorf("counter name got a doubled _total suffix:\n%s", body)
	}
}

func TestSetupWithoutExportersHasNoMetricsHandler(t *testing.T) {
	shutdown, err := Setup(context.Background(), DefaultConfig())
	if err != nil {
		t.Fatalf("Setup: %v", err)
	}
	_ = shutdown(context.Background())
	if MetricsHandler() != nil {
		t.Fatal("MetricsHandler() != nil with Prometheus and OTLP both off")
	}
}