Add tracing and relevant tests (#21)

* Add tracing and relevant tests.

* fixup! Add tracing and relevant tests.

* gofmt the code 🤷

* fixup! gofmt the code 🤷
This commit is contained in:
2025-01-08 18:29:25 +00:00
committed by GitHub
parent dda7044284
commit 6af5aefe54
10 changed files with 408 additions and 40 deletions
+113
View File
@@ -0,0 +1,113 @@
package tracing
import (
"context"
"encoding/json"
"fmt"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"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.21.0"
"go.opentelemetry.io/otel/trace"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
type TracingSetup struct {
tracerProvider *sdktrace.TracerProvider
tracer trace.Tracer
}
type TraceSpanInfo struct {
TraceParent string `json:"traceparent"`
}
// NewTracing creates a new tracing setup with OTLP exporter
func NewTracing(ctx context.Context, endpoint string) (*TracingSetup, error) {
if ctx == nil {
return nil, fmt.Errorf("context cannot be nil")
}
if endpoint == "" {
return nil, fmt.Errorf("endpoint cannot be empty")
}
conn, err := grpc.DialContext(ctx, endpoint,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithBlock(),
grpc.WithReturnConnectionError(),
)
if err != nil {
return nil, fmt.Errorf("failed to create gRPC connection to collector: %w", err)
}
exporter, err := otlptracegrpc.New(ctx, otlptracegrpc.WithGRPCConn(conn))
if err != nil {
return nil, fmt.Errorf("failed to create trace exporter: %w", err)
}
res, err := resource.New(ctx,
resource.WithAttributes(
semconv.ServiceName("graphql-monitoring-proxy"),
),
)
if err != nil {
return nil, fmt.Errorf("failed to create resource: %w", err)
}
tracerProvider := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter),
sdktrace.WithResource(res),
)
otel.SetTracerProvider(tracerProvider)
otel.SetTextMapPropagator(propagation.TraceContext{})
tracer := tracerProvider.Tracer("graphql-monitoring-proxy")
return &TracingSetup{
tracerProvider: tracerProvider,
tracer: tracer,
}, nil
}
// ExtractSpanContext extracts span context from TraceSpanInfo
func (ts *TracingSetup) ExtractSpanContext(spanInfo *TraceSpanInfo) (trace.SpanContext, error) {
carrier := propagation.MapCarrier{
"traceparent": spanInfo.TraceParent,
}
ctx := context.Background()
ctx = otel.GetTextMapPropagator().Extract(ctx, carrier)
spanCtx := trace.SpanContextFromContext(ctx)
if !spanCtx.IsValid() {
return trace.SpanContext{}, fmt.Errorf("invalid span context")
}
return spanCtx, nil
}
// ParseTraceHeader parses X-Trace-Span header content
func ParseTraceHeader(headerContent string) (*TraceSpanInfo, error) {
var spanInfo TraceSpanInfo
if err := json.Unmarshal([]byte(headerContent), &spanInfo); err != nil {
return nil, fmt.Errorf("failed to parse trace header: %w", err)
}
return &spanInfo, nil
}
// Shutdown cleanly shuts down the tracer provider
func (ts *TracingSetup) Shutdown(ctx context.Context) error {
if ts.tracerProvider == nil {
return nil
}
return ts.tracerProvider.Shutdown(ctx)
}
// StartSpan starts a new span with the given name and parent context
func (ts *TracingSetup) StartSpan(ctx context.Context, name string) (trace.Span, context.Context) {
if ts.tracer == nil {
return trace.SpanFromContext(ctx), ctx
}
ctx, span := ts.tracer.Start(ctx, name)
return span, ctx
}
+102
View File
@@ -0,0 +1,102 @@
package tracing
import (
"context"
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"go.opentelemetry.io/otel/trace"
)
func TestParseTraceHeader(t *testing.T) {
tests := []struct {
name string
header string
want *TraceSpanInfo
wantErr bool
}{
{
name: "valid trace header",
header: `{"traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"}`,
want: &TraceSpanInfo{
TraceParent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
},
wantErr: false,
},
{
name: "invalid json",
header: `{"traceparent": invalid}`,
want: nil,
wantErr: true,
},
{
name: "empty header",
header: "",
want: nil,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseTraceHeader(tt.header)
if (err != nil) != tt.wantErr {
t.Errorf("ParseTraceHeader() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr {
gotJSON, _ := json.Marshal(got)
wantJSON, _ := json.Marshal(tt.want)
if string(gotJSON) != string(wantJSON) {
t.Errorf("ParseTraceHeader() = %v, want %v", string(gotJSON), string(wantJSON))
}
}
})
}
}
func TestNewTracing(t *testing.T) {
// Skip actual connection tests since they require a running collector
t.Run("empty endpoint", func(t *testing.T) {
ctx := context.Background()
_, err := NewTracing(ctx, "")
assert.Error(t, err, "Expected error for empty endpoint")
assert.Contains(t, err.Error(), "endpoint cannot be empty")
})
t.Run("nil context", func(t *testing.T) {
_, err := NewTracing(nil, "localhost:4317")
assert.Error(t, err, "Expected error for nil context")
assert.Contains(t, err.Error(), "context cannot be nil")
})
}
func TestTracingSetup_ExtractSpanContext(t *testing.T) {
ts := &TracingSetup{}
spanInfo := &TraceSpanInfo{
TraceParent: "invalid-traceparent",
}
_, err := ts.ExtractSpanContext(spanInfo)
assert.Error(t, err, "Expected error for invalid traceparent")
assert.Contains(t, err.Error(), "invalid span context")
}
func TestTracingSetup_StartSpan(t *testing.T) {
ts := &TracingSetup{}
ctx := context.Background()
span, newCtx := ts.StartSpan(ctx, "test-span")
assert.NotNil(t, span, "Expected non-nil span even when tracer is nil")
assert.NotNil(t, newCtx, "Expected non-nil context")
assert.Equal(t, trace.SpanFromContext(ctx), span, "Expected span from context when tracer is nil")
}
func TestTracingSetup_Shutdown(t *testing.T) {
ts := &TracingSetup{}
ctx := context.Background()
err := ts.Shutdown(ctx)
assert.NoError(t, err, "Expected no error when shutting down nil tracer provider")
}