Skip to content
Open
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
34 changes: 29 additions & 5 deletions pkg/services/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import (
"strings"
"time"

"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"

"github.com/openshift-hyperfleet/hyperfleet-api/pkg/api"
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/dao"
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/db"
Expand Down Expand Up @@ -81,9 +84,11 @@ type sqlResourceService struct {

// Get returns a single resource by kind and ID. Returns 404 if not found.
func (s *sqlResourceService) Get(ctx context.Context, kind, id string) (*api.Resource, *errors.ServiceError) {
trace.SpanFromContext(ctx).SetAttributes(attribute.String("hyperfleet.resource_id", id))
if svcErr := validateKind(kind); svcErr != nil {
return nil, svcErr
}
trace.SpanFromContext(ctx).SetAttributes(attribute.String("hyperfleet.resource_type", registry.MustGet(kind).Plural))
resource, err := s.resourceDao.Get(ctx, kind, id)
if err != nil {
return nil, handleGetError(kind, "id", id, err)
Comment on lines 88 to 94

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same two attributes end up getting set in four different orderings across the 8 instrumented methods here (Get/Patch/Delete tag both together after validateKind, Create splits type-early/id-late, GetByOwner/ProcessAdapterStatus/ForceDelete tag id before validateKind, GetByID tags id before the DAO call). None of these are unsafe, but on an invalid kind, some methods leave the span fully untagged while others still tag resource_id. Worth picking one ordering convention and applying it everywhere for consistency.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I try to set them asap, so there is as much information available as possible. I think these are helpful attributes.

Expand All @@ -103,7 +108,14 @@ func (s *sqlResourceService) Create(
) (*api.Resource, *errors.ServiceError) {
resource.Kind = kind

if svcErr := validateResourceName(kind, resource.Name); svcErr != nil {
if svcErr := validateKind(kind); svcErr != nil {
return nil, svcErr
}
trace.SpanFromContext(ctx).SetAttributes(
attribute.String("hyperfleet.resource_type", registry.MustGet(kind).Plural),
)

if svcErr := validateName(kind, resource.Name); svcErr != nil {
return nil, svcErr
}

Expand Down Expand Up @@ -135,6 +147,7 @@ func (s *sqlResourceService) Create(
if err != nil {
return nil, handleCreateError(kind, err)
}
trace.SpanFromContext(ctx).SetAttributes(attribute.String("hyperfleet.resource_id", resource.ID))

if len(resource.Labels) > 0 {
if labelErr := s.resourceLabelDao.ReplaceLabels(ctx, resource.ID, resource.Labels); labelErr != nil {
Expand Down Expand Up @@ -168,9 +181,11 @@ func (s *sqlResourceService) Create(
func (s *sqlResourceService) Patch(
ctx context.Context, kind, id string, patch *api.ResourcePatch,
) (*api.Resource, *errors.ServiceError) {
trace.SpanFromContext(ctx).SetAttributes(attribute.String("hyperfleet.resource_id", id))
if svcErr := validateKind(kind); svcErr != nil {
return nil, svcErr
}
trace.SpanFromContext(ctx).SetAttributes(attribute.String("hyperfleet.resource_type", registry.MustGet(kind).Plural))
resource, err := s.resourceDao.GetForUpdate(ctx, kind, id)
if err != nil {
return nil, handleGetError(kind, "id", id, err)
Expand Down Expand Up @@ -243,9 +258,11 @@ func (s *sqlResourceService) Patch(

// Resources with required adapters are soft-deleted; all others are hard-deleted.
func (s *sqlResourceService) Delete(ctx context.Context, kind, id string) (*api.Resource, *errors.ServiceError) {
trace.SpanFromContext(ctx).SetAttributes(attribute.String("hyperfleet.resource_id", id))
if svcErr := validateKind(kind); svcErr != nil {
return nil, svcErr
}
trace.SpanFromContext(ctx).SetAttributes(attribute.String("hyperfleet.resource_type", registry.MustGet(kind).Plural))
resource, err := s.resourceDao.GetForUpdate(ctx, kind, id)
if err != nil {
return nil, handleSoftDeleteError(kind, err)
Expand Down Expand Up @@ -406,9 +423,11 @@ func (s *sqlResourceService) checkCanDelete(
func (s *sqlResourceService) GetByOwner(
ctx context.Context, kind, id, ownerID string,
) (*api.Resource, *errors.ServiceError) {
trace.SpanFromContext(ctx).SetAttributes(attribute.String("hyperfleet.resource_id", id))
if svcErr := validateKind(kind); svcErr != nil {
return nil, svcErr
}
trace.SpanFromContext(ctx).SetAttributes(attribute.String("hyperfleet.resource_type", registry.MustGet(kind).Plural))
resource, err := s.resourceDao.GetByOwner(ctx, kind, id, ownerID)
if err != nil {
return nil, handleGetError(kind, "id", id, err)
Expand Down Expand Up @@ -484,10 +503,14 @@ func (s *sqlResourceService) ListByOwner(
}

func (s *sqlResourceService) GetByID(ctx context.Context, id string) (*api.Resource, *errors.ServiceError) {
trace.SpanFromContext(ctx).SetAttributes(attribute.String("hyperfleet.resource_id", id))
resource, err := s.resourceDao.GetByID(ctx, id)
if err != nil {
return nil, handleGetError("Resource", "id", id, err)
}
trace.SpanFromContext(ctx).SetAttributes(
attribute.String("hyperfleet.resource_type", registry.MustGet(resource.Kind).Plural),
)
return resource, nil
}

Expand Down Expand Up @@ -521,9 +544,11 @@ func (s *sqlResourceService) ListAll(
func (s *sqlResourceService) ProcessAdapterStatus(
ctx context.Context, kind, resourceID string, adapterStatus *api.AdapterStatus,
) (*api.AdapterStatus, *errors.ServiceError) {
trace.SpanFromContext(ctx).SetAttributes(attribute.String("hyperfleet.resource_id", resourceID))
if svcErr := validateKind(kind); svcErr != nil {
return nil, svcErr
}
trace.SpanFromContext(ctx).SetAttributes(attribute.String("hyperfleet.resource_type", registry.MustGet(kind).Plural))

// Step 1: Acquire a row-level exclusive lock on the resource. Concurrent
// adapter status updates for the same resource are serialized here.
Expand Down Expand Up @@ -850,10 +875,7 @@ func validateKind(kind string) *errors.ServiceError {
}

// Name format/length validation is handled by OpenAPI spec validation middleware.
func validateResourceName(kind, name string) *errors.ServiceError {
if svcErr := validateKind(kind); svcErr != nil {
return svcErr
}
func validateName(kind, name string) *errors.ServiceError {
if name == "" {
return errors.Validation("%s name cannot be empty", kind)
}
Expand Down Expand Up @@ -914,9 +936,11 @@ func applyResourcePatch(resource *api.Resource, patch *api.ResourcePatch) error
}

func (s *sqlResourceService) ForceDelete(ctx context.Context, kind, id, reason string) *errors.ServiceError {
trace.SpanFromContext(ctx).SetAttributes(attribute.String("hyperfleet.resource_id", id))
if svcErr := validateKind(kind); svcErr != nil {
return svcErr
}
trace.SpanFromContext(ctx).SetAttributes(attribute.String("hyperfleet.resource_type", registry.MustGet(kind).Plural))

resource, err := s.resourceDao.GetForUpdate(ctx, kind, id)
if err != nil {
Expand Down
234 changes: 234 additions & 0 deletions pkg/services/resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"time"

. "github.com/onsi/gomega"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
"gorm.io/gorm"

"github.com/openshift-hyperfleet/hyperfleet-api/pkg/api"
Expand Down Expand Up @@ -3311,3 +3313,235 @@ func TestResourceService_ConditionMapper_IntegrationPath(t *testing.T) {
"ObservedGeneration should match resource generation",
)
}

// --- Span attribute tests ---

func setupTestTracer(t *testing.T) (*sdktrace.TracerProvider, *tracetest.InMemoryExporter) {
t.Helper()
exporter := tracetest.NewInMemoryExporter()
tp := sdktrace.NewTracerProvider(
sdktrace.WithSampler(sdktrace.AlwaysSample()),
sdktrace.WithSyncer(exporter),
)
t.Cleanup(func() {
if err := tp.Shutdown(context.Background()); err != nil {
t.Errorf("failed to shutdown tracer: %v", err)
}
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return tp, exporter
Comment on lines +3319 to +3331

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setupTestTracer swaps the global otel TracerProvider for the test, then each test starts its span via the global otel.Tracer rather than the tp returned by this helper. Using tp.Tracer(...) directly would avoid touching process-global state, which would remove a potential source of flakiness if t.Parallel() is ever added to these tests.

}

func findSpanAttribute(spans tracetest.SpanStubs, attrKey string) (string, bool) {
for _, span := range spans {
for _, attr := range span.Attributes {
if string(attr.Key) == attrKey {
return attr.Value.AsString(), true
}
}
}
return "", false
}

func TestResourceService_SetsSpanAttributes(t *testing.T) {
setupTestDescriptors()

tests := []struct {
invoke func(ctx context.Context, svc ResourceService) error
name string
seedID string
expectedResourceID string
expectedType string
expectError bool
}{
{
name: "Get",
seedID: "ch-1",
expectedResourceID: "ch-1",
expectedType: "channels",
invoke: func(ctx context.Context, svc ResourceService) error {
_, err := svc.Get(ctx, "Channel", "ch-1")
return svcErrOrNil(err)
},
},
{
name: "Get not found still tags span",
expectedResourceID: "nonexistent",
expectedType: "channels",
expectError: true,
invoke: func(ctx context.Context, svc ResourceService) error {
_, err := svc.Get(ctx, "Channel", "nonexistent")
return svcErrOrNil(err)
},
},
{
name: "Create",
expectedResourceID: "ch-new",
expectedType: "channels",
invoke: func(ctx context.Context, svc ResourceService) error {
_, err := svc.Create(ctx, "Channel", testResource("Channel", "ch-new", "beta"), nil)
return svcErrOrNil(err)
},
},
{
name: "Create failure still tags resource_type",
expectedType: "channels",
expectError: true,
invoke: func(ctx context.Context, svc ResourceService) error {
r := testResource("Channel", "", "")
r.Name = "" // triggers name validation error
_, err := svc.Create(ctx, "Channel", r, nil)
return svcErrOrNil(err)
},
},
{
name: "Patch",
seedID: "ch-1",
expectedResourceID: "ch-1",
expectedType: "channels",
invoke: func(ctx context.Context, svc ResourceService) error {
_, err := svc.Patch(ctx, "Channel", "ch-1", &api.ResourcePatch{
Spec: map[string]interface{}{"key": "updated"},
})
return svcErrOrNil(err)
},
},
{
name: "Delete",
seedID: "ch-1",
expectedResourceID: "ch-1",
expectedType: "channels",
invoke: func(ctx context.Context, svc ResourceService) error {
_, err := svc.Delete(ctx, "Channel", "ch-1")
return svcErrOrNil(err)
},
},
{
name: "GetByID",
seedID: "ch-1",
expectedResourceID: "ch-1",
expectedType: "channels",
invoke: func(ctx context.Context, svc ResourceService) error {
_, err := svc.GetByID(ctx, "ch-1")
return svcErrOrNil(err)
},
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
RegisterTestingT(t)
tp, exporter := setupTestTracer(t)

mockDao := newMockResourceDao()
svc, _, _ := newTestResourceService(mockDao)
if tc.seedID != "" {
mockDao.addResource(testResource("Channel", tc.seedID, "stable"))
}

ctx, span := tp.Tracer("test").Start(context.Background(), "test")
err := tc.invoke(ctx, svc)
span.End()
if tc.expectError {
Expect(err).ToNot(BeNil())
} else {
Expect(err).To(BeNil())
}

if flushErr := tp.ForceFlush(context.Background()); flushErr != nil {
t.Fatalf("failed to flush spans: %v", flushErr)
}
spans := exporter.GetSpans()

if tc.expectedResourceID != "" {
resourceID, found := findSpanAttribute(spans, "hyperfleet.resource_id")
Expect(found).To(BeTrue(), "hyperfleet.resource_id attribute not found")
Expect(resourceID).To(Equal(tc.expectedResourceID))
}

resourceType, found := findSpanAttribute(spans, "hyperfleet.resource_type")
Expect(found).To(BeTrue(), "hyperfleet.resource_type attribute not found")
Expect(resourceType).To(Equal(tc.expectedType))
})
}
}

func svcErrOrNil(err *errors.ServiceError) error {
if err != nil {
return err
}
return nil
}

func assertSpanAttributes(
t *testing.T, tp *sdktrace.TracerProvider, exporter *tracetest.InMemoryExporter,
expectedID, expectedType string,
) {
t.Helper()
if err := tp.ForceFlush(context.Background()); err != nil {
t.Fatalf("failed to flush spans: %v", err)
}
spans := exporter.GetSpans()

resourceID, found := findSpanAttribute(spans, "hyperfleet.resource_id")
Expect(found).To(BeTrue(), "hyperfleet.resource_id attribute not found")
Expect(resourceID).To(Equal(expectedID))

resourceType, found := findSpanAttribute(spans, "hyperfleet.resource_type")
Expect(found).To(BeTrue(), "hyperfleet.resource_type attribute not found")
Expect(resourceType).To(Equal(expectedType))
}

func TestResourceService_GetByOwner_SetsSpanAttributes(t *testing.T) {
RegisterTestingT(t)
setupTestDescriptors()
tp, exporter := setupTestTracer(t)

mockDao := newMockResourceDao()
svc, _, _ := newTestResourceService(mockDao)
r := testResource("Version", "v-1", "1.0")
r.OwnerID = strPtr("ch-1")
mockDao.addResource(r)

ctx, span := tp.Tracer("test").Start(context.Background(), "test")
_, svcErr := svc.GetByOwner(ctx, "Version", "v-1", "ch-1")
span.End()
Expect(svcErr).To(BeNil())
assertSpanAttributes(t, tp, exporter, "v-1", "versions")
}

func TestResourceService_ForceDelete_SetsSpanAttributes(t *testing.T) {
RegisterTestingT(t)
setupTestDescriptors()
tp, exporter := setupTestTracer(t)

mockDao := newMockResourceDao()
svc, _, _ := newTestResourceService(mockDao)
r := testResource("Channel", "ch-1", "stable")
now := time.Now().UTC()
r.DeletedTime = &now
mockDao.addResource(r)

ctx, span := tp.Tracer("test").Start(context.Background(), "test")
svcErr := svc.ForceDelete(ctx, "Channel", "ch-1", "test cleanup")
span.End()
Expect(svcErr).To(BeNil())
assertSpanAttributes(t, tp, exporter, "ch-1", "channels")
}

func TestResourceService_ProcessAdapterStatus_SetsSpanAttributes(t *testing.T) {
RegisterTestingT(t)
setupAdapterStatusDescriptors()
tp, exporter := setupTestTracer(t)

mockDao := newMockResourceDao()
svc, _, _, _ := newTestResourceServiceWithAdapterStatus(mockDao)
r := testResource("TestResource", "r-1", "test")
r.Generation = 1
mockDao.addResource(r)

ctx, span := tp.Tracer("test").Start(context.Background(), "test")
_, svcErr := svc.ProcessAdapterStatus(ctx, "TestResource", "r-1", testAdapterStatusRequest(1))
span.End()
Expect(svcErr).To(BeNil())
assertSpanAttributes(t, tp, exporter, "r-1", "testresources")
}