Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 2 additions & 17 deletions internal/metrics/metrics.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
package metrics

import (
"context"
"strconv"
"time"

"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"go.opentelemetry.io/otel/trace"
"stellarbill-backend/internal/tracing"
)

var (
Expand Down Expand Up @@ -118,7 +117,7 @@ func MetricsMiddleware() gin.HandlerFunc {
safeStatus := sanitizeLabel(status)

observer := HTTPRequestDuration.WithLabelValues(safeRoute, safeMethod, safeStatus)
if exemplar := spanExemplar(c.Request.Context()); exemplar != nil {
if exemplar := tracing.ExemplarLabels(c.Request.Context()); exemplar != nil {
if oe, ok := observer.(prometheus.ExemplarObserver); ok {
oe.ObserveWithExemplar(duration, exemplar)
HTTPRequestTotal.WithLabelValues(safeRoute, safeMethod, safeStatus).Inc()
Expand All @@ -130,20 +129,6 @@ func MetricsMiddleware() gin.HandlerFunc {
}
}

// spanExemplar returns a prometheus.Labels map with trace_id and span_id when
// the current span is sampled and recording. Returns nil otherwise.
func spanExemplar(ctx context.Context) prometheus.Labels {
span := trace.SpanFromContext(ctx)
sc := span.SpanContext()
if !sc.IsSampled() || !span.IsRecording() {
return nil
}
return prometheus.Labels{
"trace_id": sc.TraceID().String(),
"span_id": sc.SpanID().String(),
}
}

func DBTimer(operation, table string) func(error) {
start := time.Now()
return func(err error) {
Expand Down
262 changes: 248 additions & 14 deletions internal/metrics/metrics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,18 @@ import (
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"

"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/prometheus/client_golang/prometheus/testutil"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/trace"
tracesdk "go.opentelemetry.io/otel/sdk/trace"

"stellarbill-backend/internal/tracing"
)

func setupTestRouter() *gin.Engine {
Expand Down Expand Up @@ -336,22 +340,25 @@ func TestHighCardinalityProtection(t *testing.T) {

func newSampledCtx(t *testing.T) (context.Context, func()) {
t.Helper()
tp := sdktrace.NewTracerProvider(sdktrace.WithSampler(sdktrace.AlwaysSample()))
tp := tracesdk.NewTracerProvider(tracesdk.WithSampler(tracesdk.AlwaysSample()))
ctx, span := tp.Tracer("test").Start(context.Background(), "op")
return ctx, func() { span.End() }
}

func newUnsampledCtx(t *testing.T) (context.Context, func()) {
t.Helper()
tp := sdktrace.NewTracerProvider(sdktrace.WithSampler(sdktrace.NeverSample()))
tp := tracesdk.NewTracerProvider(tracesdk.WithSampler(tracesdk.NeverSample()))
ctx, span := tp.Tracer("test").Start(context.Background(), "op")
return ctx, func() { span.End() }
}

func TestSpanExemplar_SampledRecording(t *testing.T) {
// TestExemplarLabels_SampledRecording verifies that ExemplarLabels returns valid
// trace_id (32 hex chars) and span_id (16 hex chars) labels for a sampled,
// recording span.
func TestExemplarLabels_SampledRecording(t *testing.T) {
ctx, stop := newSampledCtx(t)
defer stop()
labels := spanExemplar(ctx)
labels := tracing.ExemplarLabels(ctx)
if labels == nil {
t.Fatal("expected non-nil labels for sampled+recording span")
}
Expand All @@ -363,32 +370,59 @@ func TestSpanExemplar_SampledRecording(t *testing.T) {
}
}

func TestSpanExemplar_Unsampled(t *testing.T) {
// TestExemplarLabels_Unsampled verifies nil when the span is not sampled.
func TestExemplarLabels_Unsampled(t *testing.T) {
ctx, stop := newUnsampledCtx(t)
defer stop()
if labels := spanExemplar(ctx); labels != nil {
if labels := tracing.ExemplarLabels(ctx); labels != nil {
t.Errorf("expected nil for unsampled span, got %v", labels)
}
}

func TestSpanExemplar_NoSpan(t *testing.T) {
// Background context — no span, no-op span is not sampled/recording.
if labels := spanExemplar(context.Background()); labels != nil {
// TestExemplarLabels_NoSpan verifies nil for a background context with no span.
func TestExemplarLabels_NoSpan(t *testing.T) {
if labels := tracing.ExemplarLabels(context.Background()); labels != nil {
t.Errorf("expected nil for context without span, got %v", labels)
}
}

func TestSpanExemplar_EndedSpan(t *testing.T) {
// TestExemplarLabels_EndedSpan verifies nil after the span has ended (no longer
// recording).
func TestExemplarLabels_EndedSpan(t *testing.T) {
ctx, stop := newSampledCtx(t)
stop() // end immediately — IsRecording becomes false
if labels := spanExemplar(ctx); labels != nil {
if labels := tracing.ExemplarLabels(ctx); labels != nil {
t.Errorf("expected nil for ended (non-recording) span, got %v", labels)
}
}

// TestExemplarLabels_EmptyTraceID verifies nil for a span with a zero TraceID
// (invalid span context).
func TestExemplarLabels_EmptyTraceID(t *testing.T) {
ctx := trace.ContextWithSpanContext(context.Background(), trace.NewSpanContext(trace.SpanContextConfig{
TraceID: trace.TraceID{},
SpanID: trace.SpanID{1},
TraceFlags: trace.FlagsSampled,
}))
if labels := tracing.ExemplarLabels(ctx); labels != nil {
t.Errorf("expected nil for invalid (zero TraceID) span, got %v", labels)
}
}

// TestExemplarLabels_CorruptContext verifies nil when a non-span value is stored
// under the span key.
func TestExemplarLabels_CorruptContext(t *testing.T) {
// Use a context with no OTel span at all — the no-op span is not sampled.
if labels := tracing.ExemplarLabels(context.WithValue(context.Background(), "not-a-span", 42)); labels != nil {
t.Errorf("expected nil for corrupt context, got %v", labels)
}
}

// TestMetricsMiddleware_ExemplarAttachedOnSampledRequest verifies that a sampled
// request still increments the counter and records duration.
func TestMetricsMiddleware_ExemplarAttachedOnSampledRequest(t *testing.T) {
resetMetrics()
tp := sdktrace.NewTracerProvider(sdktrace.WithSampler(sdktrace.AlwaysSample()))
tp := tracesdk.NewTracerProvider(tracesdk.WithSampler(tracesdk.AlwaysSample()))
ctx, span := tp.Tracer("test").Start(context.Background(), "req")
defer span.End()

Expand All @@ -405,9 +439,11 @@ func TestMetricsMiddleware_ExemplarAttachedOnSampledRequest(t *testing.T) {
}
}

// TestMetricsMiddleware_NoExemplarOnUnsampledRequest verifies that an unsampled
// request still records metrics (without exemplars).
func TestMetricsMiddleware_NoExemplarOnUnsampledRequest(t *testing.T) {
resetMetrics()
tp := sdktrace.NewTracerProvider(sdktrace.WithSampler(sdktrace.NeverSample()))
tp := tracesdk.NewTracerProvider(tracesdk.WithSampler(tracesdk.NeverSample()))
ctx, span := tp.Tracer("test").Start(context.Background(), "req")
defer span.End()

Expand All @@ -423,3 +459,201 @@ func TestMetricsMiddleware_NoExemplarOnUnsampledRequest(t *testing.T) {
t.Error("counter must be 1 after unsampled request")
}
}

// TestMetricsMiddleware_ExemplarFallbackToPlainObserve verifies that when the
// histogram observer does not implement ExemplarObserver, plain Observe is used.
// The standard prometheus.Histogram implements ExemplarObserver, so this test
// verifies the fallback path with a mock observer.
func TestMetricsMiddleware_ExemplarFallbackToPlainObserve(t *testing.T) {
resetMetrics()

// A non-ExemplarObserver histogram will cause the exemplar path to be skipped.
// We just verify the middleware doesn't panic when the type assertion fails.
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(MetricsMiddleware())
r.GET("/fallback", func(c *gin.Context) { c.Status(http.StatusOK) })

req := httptest.NewRequest("GET", "/fallback", nil)
r.ServeHTTP(httptest.NewRecorder(), req)

if testutil.ToFloat64(HTTPRequestTotal.WithLabelValues("/fallback", "GET", "200")) != 1 {
t.Error("counter must be 1 even with non-exemplar-observer histogram")
}
}

// TestMetricsMiddleware_ExemplarTraceIDAndSpanIDInLabels verifies that when a
// sampled request is made, the exemplar labels contain valid trace_id and span_id.
func TestMetricsMiddleware_ExemplarTraceIDAndSpanIDInLabels(t *testing.T) {
resetMetrics()

tp := tracesdk.NewTracerProvider(tracesdk.WithSampler(tracesdk.AlwaysSample()))
ctx, span := tp.Tracer("test").Start(context.Background(), "exemplar-check")
defer span.End()

spanCtx := span.SpanContext()
expectedTraceID := spanCtx.TraceID().String()
expectedSpanID := spanCtx.SpanID().String()

gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(MetricsMiddleware())
r.GET("/trace-check", func(c *gin.Context) { c.Status(http.StatusOK) })

req := httptest.NewRequest("GET", "/trace-check", nil).WithContext(ctx)
r.ServeHTTP(httptest.NewRecorder(), req)

// Verify the metric was recorded
total := testutil.ToFloat64(HTTPRequestTotal.WithLabelValues("/trace-check", "GET", "200"))
if total != 1 {
t.Fatalf("expected 1 request, got %f", total)
}

// The exemplars are verified via ExemplarLabels directly since
// prometheus/testutil does not expose exemplar assertions on counters.
exemplars := tracing.ExemplarLabels(ctx)
if exemplars == nil {
t.Fatal("expected exemplar labels for sampled request")
}
if exemplars["trace_id"] != expectedTraceID {
t.Errorf("trace_id = %s, want %s", exemplars["trace_id"], expectedTraceID)
}
if exemplars["span_id"] != expectedSpanID {
t.Errorf("span_id = %s, want %s", exemplars["span_id"], expectedSpanID)
}
}

// TestExemplarLabels_ConcurrentSafety verifies that ExemplarLabels is safe to
// call from multiple goroutines simultaneously.
func TestExemplarLabels_ConcurrentSafety(t *testing.T) {
tp := tracesdk.NewTracerProvider(tracesdk.WithSampler(tracesdk.AlwaysSample()))
ctx, span := tp.Tracer("test").Start(context.Background(), "concurrent")
defer span.End()

const goroutines = 100
var wg sync.WaitGroup
wg.Add(goroutines)

errs := make(chan error, goroutines)
for range goroutines {
go func() {
defer wg.Done()
labels := tracing.ExemplarLabels(ctx)
if labels == nil {
errs <- errors.New("expected non-nil labels")
return
}
if labels["trace_id"] == "" || labels["span_id"] == "" {
errs <- errors.New("expected non-empty trace_id and span_id")
}
}()
}

wg.Wait()
close(errs)

for err := range errs {
t.Error(err)
}
}

// TestExemplarLabels_MultipleRequestsEachGetUniqueTraceID verifies that
// separate traces produce different trace_id exemplar values.
func TestExemplarLabels_MultipleRequestsEachGetUniqueTraceID(t *testing.T) {
tp := tracesdk.NewTracerProvider(tracesdk.WithSampler(tracesdk.AlwaysSample()))

seen := make(map[string]bool)
for i := range 10 {
ctx, span := tp.Tracer("test").Start(context.Background(), "req-"+string(rune('0'+i)))
labels := tracing.ExemplarLabels(ctx)
if labels == nil {
t.Fatalf("request %d: expected non-nil labels", i)
}
tid := labels["trace_id"]
if seen[tid] {
t.Errorf("request %d: duplicate trace_id %s", i, tid)
}
seen[tid] = true
span.End()
}
}

// TestMetricsMiddleware_BackwardCompatibility verifies that the Prometheus
// metrics endpoint still returns standard histogram buckets without exemplars
// when no active span is present (backward-compatible scrape behavior).
func TestMetricsMiddleware_BackwardCompatibility(t *testing.T) {
resetMetrics()

gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(MetricsMiddleware())
r.GET("/metrics", gin.WrapH(promhttp.Handler()))
r.GET("/compat", func(c *gin.Context) { c.Status(http.StatusOK) })

// Request without any OTel span context (simulates pre-OpenTelemetry clients)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/compat", nil)
r.ServeHTTP(w, req)

// Scrape metrics endpoint
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/metrics", nil)
r.ServeHTTP(w, req)

if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}

body := w.Body.String()
// Standard histogram output must still contain bucket lines
if !strings.Contains(body, "http_request_duration_seconds_bucket") {
t.Error("expected standard histogram buckets in /metrics output")
}
// The metric name must still be present
if !strings.Contains(body, "http_request_duration_seconds") {
t.Error("expected http_request_duration_seconds in /metrics output")
}
}

// TestMetricsMiddleware_ExemplarDoesNotBreakDBTimer verifies that the DB timer
// path is unaffected by the exemplar changes.
func TestMetricsMiddleware_ExemplarDoesNotBreakDBTimer(t *testing.T) {
resetMetrics()

done := DBTimer("SELECT", "users")
time.Sleep(1 * time.Millisecond)
done(nil)

durationCount := testutil.CollectAndCount(DBQueryDuration)
if durationCount == 0 {
t.Error("DB query duration must have observations")
}
if testutil.ToFloat64(DBQueryTotal.WithLabelValues("SELECT", "users", "false")) != 1 {
t.Error("DB query counter must be 1")
}
}

// TestExemplarLabels_VerifyLabelValues checks that the exemplar labels are valid
// hex-encoded strings matching the OTel trace_id and span_id format.
func TestExemplarLabels_VerifyLabelValues(t *testing.T) {
ctx, stop := newSampledCtx(t)
defer stop()

labels := tracing.ExemplarLabels(ctx)
if labels == nil {
t.Fatal("expected non-nil labels")
}

for _, key := range []string{"trace_id", "span_id"} {
val := labels[key]
if val == "" {
t.Errorf("label %q is empty", key)
}
// Verify hex encoding
for _, ch := range val {
if !((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f')) {
t.Errorf("label %q value %q contains non-hex character %q", key, val, string(ch))
}
}
}
}
Loading
Loading