-
Notifications
You must be signed in to change notification settings - Fork 24
HYPERFLEET-1490 - feat: add hyperfleet.resource_id span attribute to service layer #328
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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" | ||
|
|
@@ -82,9 +85,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) | ||
|
|
@@ -118,7 +123,14 @@ func (s *sqlResourceService) Create( | |
| } | ||
| 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 | ||
| } | ||
|
|
||
|
|
@@ -151,6 +163,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 { | ||
|
|
@@ -184,12 +197,14 @@ 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 := rejectSystemIdentityWrite(ctx); svcErr != nil { | ||
| return nil, svcErr | ||
| } | ||
| 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) | ||
|
|
@@ -262,12 +277,14 @@ 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 := rejectSystemIdentityWrite(ctx); svcErr != nil { | ||
| return nil, svcErr | ||
| } | ||
| 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) | ||
|
|
@@ -428,9 +445,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) | ||
|
|
@@ -506,10 +525,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), | ||
| ) | ||
|
Comment on lines
+528
to
+535
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "--- registry.MustGet / Get definitions ---"
fd -t f -e go . pkg/registry --exec rg -n -C 6 'func MustGet|func Get\(|func All\(' {}
echo "--- other unguarded MustGet on a resource-derived kind ---"
rg -nP 'MustGet\(\s*resource\.Kind\s*\)|MustGet\(\s*r\.Kind\s*\)' --type=goRepository: openshift-hyperfleet/hyperfleet-api Length of output: 1107 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "--- GetByID and directly related kind validation/callers ---"
sed -n '500,590p' pkg/services/resource.go
rg -n -C 8 'func \(s \*ResourceService\) (GetByID|ForceDelete|ListStatuses|CreateStatus)|validateKind\(|GetByID\(ctx' pkg/services/resource.go
echo "--- registry validation implementation ---"
sed -n '30,60p' pkg/registry/*.goRepository: openshift-hyperfleet/hyperfleet-api Length of output: 14776 Guard
🤖 Prompt for AI AgentsSource: Path instructions |
||
| return resource, nil | ||
| } | ||
|
|
||
|
|
@@ -543,9 +566,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. | ||
|
|
@@ -872,10 +897,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) | ||
| } | ||
|
|
@@ -936,12 +958,14 @@ 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 := rejectSystemIdentityWrite(ctx); svcErr != nil { | ||
| return svcErr | ||
| } | ||
| 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 { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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/datatypes" | ||
| "gorm.io/gorm" | ||
|
|
||
|
|
@@ -3482,3 +3484,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) | ||
| } | ||
| }) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| return tp, exporter | ||
|
Ruclo marked this conversation as resolved.
|
||
| } | ||
|
|
||
| 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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tip nit — non-blocking suggestion Category: Pattern Nice that |
||
| 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") | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.