Add provider.WithTracing decorator mirroring WithMetrics

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-24 10:50:13 +02:00
parent 6b34f68469
commit d15b06ec45
3 changed files with 213 additions and 1 deletions

View File

@@ -0,0 +1,80 @@
package provider
import (
"context"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/tracing"
)
// WithTracing wraps a Provider so every call runs in a client span carrying
// the classified result, mirroring WithMetrics. It goes through
// tracing.StartSpan, so provider-internal logs (e.g. the gcp provider's
// context logger) inherit traceID/spanID. Errors pass through unmodified.
func WithTracing(name string, p Provider, opts ...tracing.Option) Provider {
return &traced{name: name, inner: p, tracer: tracing.Tracer(opts...)}
}
type traced struct {
name string
inner Provider
tracer trace.Tracer
}
func (t *traced) Create(ctx context.Context, req CreateRequest) (string, error) {
ctx, span := t.start(ctx, "provider.create")
id, err := t.inner.Create(ctx, req)
if id != "" {
span.SetAttributes(attribute.String("provider.id", id))
}
t.end(span, err)
return id, err
}
func (t *traced) Get(ctx context.Context, providerID string) (*Instance, error) {
ctx, span := t.start(ctx, "provider.get")
span.SetAttributes(attribute.String("provider.id", providerID))
inst, err := t.inner.Get(ctx, providerID)
t.end(span, err)
return inst, err
}
func (t *traced) Delete(ctx context.Context, providerID string) error {
ctx, span := t.start(ctx, "provider.delete")
span.SetAttributes(attribute.String("provider.id", providerID))
err := t.inner.Delete(ctx, providerID)
t.end(span, err)
return err
}
func (t *traced) ListByTag(ctx context.Context) ([]Instance, error) {
ctx, span := t.start(ctx, "provider.list")
instances, err := t.inner.ListByTag(ctx)
span.SetAttributes(attribute.Int("provider.instances", len(instances)))
t.end(span, err)
return instances, err
}
func (t *traced) start(ctx context.Context, op string) (context.Context, trace.Span) {
return tracing.StartSpan(ctx, t.tracer, op,
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(attribute.String("provider.name", t.name)))
}
// end records the taxonomy class and closes the span. ErrNotFound stays Ok:
// the reconciler polls Get to NotFound during replacement/deletion, so it is
// an expected answer, not a failure — same reasoning as resultLabel's
// distinct "not_found" bucket.
func (t *traced) end(span trace.Span, err error) {
span.SetAttributes(attribute.String("provider.result", resultLabel(err)))
switch Class(err) {
case nil, ErrNotFound:
span.SetStatus(codes.Ok, "")
default:
span.SetStatus(codes.Error, err.Error())
}
span.End()
}

View File

@@ -0,0 +1,118 @@
package provider
import (
"context"
"errors"
"slices"
"testing"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
"go.opentelemetry.io/otel/trace"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/tracing"
)
func TestWithTracing_spanPerCall(t *testing.T) {
t.Parallel()
tests := []struct {
name string
inner *staticProvider
call func(p Provider) error
wantSpan string
wantStatus codes.Code
wantResult string
wantAttr attribute.KeyValue
}{
{
name: "successful create",
inner: &staticProvider{},
call: func(p Provider) error { _, err := p.Create(context.Background(), CreateRequest{}); return err },
wantSpan: "provider.create",
wantStatus: codes.Ok,
wantResult: "ok",
wantAttr: attribute.String("provider.id", "id-1"),
},
{
name: "get NotFound is not a span error",
inner: &staticProvider{getErr: Wrap(ErrNotFound, "get", "x", "id-1", nil)},
call: func(p Provider) error { _, err := p.Get(context.Background(), "id-1"); return err },
wantSpan: "provider.get",
wantStatus: codes.Ok,
wantResult: "not_found",
wantAttr: attribute.String("provider.id", "id-1"),
},
{
name: "delete transient error",
inner: &staticProvider{deleteErr: Wrap(ErrTransient, "delete", "x", "id-1", errors.New("503"))},
call: func(p Provider) error { return p.Delete(context.Background(), "id-1") },
wantSpan: "provider.delete",
wantStatus: codes.Error,
wantResult: "transient",
wantAttr: attribute.String("provider.id", "id-1"),
},
{
name: "quota exceeded create",
inner: &staticProvider{createErr: Wrap(ErrQuotaExceeded, "create", "x", "", nil)},
call: func(p Provider) error { _, err := p.Create(context.Background(), CreateRequest{}); return err },
wantSpan: "provider.create",
wantStatus: codes.Error,
wantResult: "quota_exceeded",
wantAttr: attribute.String("provider.name", "x"),
},
{
name: "list records instance count",
inner: &staticProvider{},
call: func(p Provider) error { _, err := p.ListByTag(context.Background()); return err },
wantSpan: "provider.list",
wantStatus: codes.Ok,
wantResult: "ok",
wantAttr: attribute.Int("provider.instances", 0),
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
sr := tracetest.NewSpanRecorder()
tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(sr))
t.Cleanup(func() { _ = tp.Shutdown(context.Background()) })
p := WithTracing("x", tc.inner, tracing.WithTracerProvider(tp))
err := tc.call(p)
wantErr := errors.Join(tc.inner.createErr, tc.inner.getErr, tc.inner.deleteErr, tc.inner.listErr)
if (wantErr == nil) != (err == nil) {
t.Fatalf("decorator changed the error: got %v", err)
}
ended := sr.Ended()
if len(ended) != 1 {
t.Fatalf("got %d spans, want 1", len(ended))
}
span := ended[0]
if span.Name() != tc.wantSpan {
t.Errorf("span name = %q, want %q", span.Name(), tc.wantSpan)
}
if span.SpanKind() != trace.SpanKindClient {
t.Errorf("span kind = %v, want client", span.SpanKind())
}
if span.Status().Code != tc.wantStatus {
t.Errorf("status = %v, want %v", span.Status().Code, tc.wantStatus)
}
attrs := span.Attributes()
hasAttr := func(want attribute.KeyValue) bool {
return slices.Contains(attrs, want)
}
if !hasAttr(attribute.String("provider.result", tc.wantResult)) {
t.Errorf("provider.result %q missing in %v", tc.wantResult, attrs)
}
if !hasAttr(tc.wantAttr) {
t.Errorf("attribute %v missing in %v", tc.wantAttr, attrs)
}
})
}
}