diff --git a/cmd/ateapi/internal/controlapi/create_actor.go b/cmd/ateapi/internal/controlapi/create_actor.go index 50eb05473..95bb4937b 100644 --- a/cmd/ateapi/internal/controlapi/create_actor.go +++ b/cmd/ateapi/internal/controlapi/create_actor.go @@ -33,11 +33,12 @@ func (s *Service) CreateActor(ctx context.Context, req *ateapipb.CreateActorRequ if err := validateCreateActorRequest(req); err != nil { return nil, err } - in := req.GetActor() templateNamespace := in.GetActorTemplateNamespace() templateName := in.GetActorTemplateName() + setSpanActorRefIdentity(ctx, in.GetMetadata().GetAtespace(), in.GetMetadata().GetName()) + _, err := s.actorTemplateLister.ActorTemplates(templateNamespace).Get(templateName) if err != nil { if k8serrors.IsNotFound(err) { @@ -76,6 +77,7 @@ func (s *Service) CreateActor(ctx context.Context, req *ateapipb.CreateActorRequ return nil, fmt.Errorf("while recording actor: %w", err) } + setSpanActorIdentity(ctx, stored) return stored, nil } diff --git a/cmd/ateapi/internal/controlapi/create_actor_test.go b/cmd/ateapi/internal/controlapi/create_actor_test.go new file mode 100644 index 000000000..199f4b8de --- /dev/null +++ b/cmd/ateapi/internal/controlapi/create_actor_test.go @@ -0,0 +1,59 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controlapi + +import ( + "context" + "testing" + + "go.opentelemetry.io/otel/attribute" + + "github.com/agent-substrate/substrate/internal/ateattr" + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" +) + +// CreateActor is the only lifecycle op with the full identity (incl. version) +// available in the request, so the whole ate.* set should land on its span. +func TestCreateActor_StampsFullSpanIdentity(t *testing.T) { + ns := namespaceForTest("ns-span-create") + tc := setupTest(t, ns) + defer tc.cleanup() + createTemplate(t, tc, ns) + + attrs := recordRootSpanAttrs(t, func(ctx context.Context) { + if _, err := tc.service.CreateActor(ctx, &ateapipb.CreateActorRequest{ + Actor: &ateapipb.Actor{ + Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: testActorID}, + ActorTemplateNamespace: ns, + ActorTemplateName: "tmpl1", + }, + }); err != nil { + t.Fatalf("CreateActor: %v", err) + } + }) + + assertSpanStr(t, attrs, ateattr.AtespaceKey, testAtespace) + assertSpanStr(t, attrs, ateattr.ActorNameKey, testActorID) + assertSpanStr(t, attrs, ateattr.ActorTemplateNameKey, "tmpl1") + assertSpanStr(t, attrs, ateattr.ActorTemplateNamespaceKey, ns) + // uid is server-assigned on create, so assert it is present and non-empty + // rather than a fixed value. + if v, ok := attrs[ateattr.ActorUIDKey]; !ok || v.Type() != attribute.STRING || v.AsString() == "" { + t.Errorf("%s = %v, want non-empty server-assigned uid", ateattr.ActorUIDKey, v.Emit()) + } + if v, ok := attrs[ateattr.ActorVersionKey]; !ok || v.Type() != attribute.INT64 || v.AsInt64() != 1 { + t.Errorf("%s = %v, want int64 1", ateattr.ActorVersionKey, v.Emit()) + } +} diff --git a/cmd/ateapi/internal/controlapi/delete_actor.go b/cmd/ateapi/internal/controlapi/delete_actor.go index e58df4cb3..19cbec253 100644 --- a/cmd/ateapi/internal/controlapi/delete_actor.go +++ b/cmd/ateapi/internal/controlapi/delete_actor.go @@ -31,6 +31,7 @@ func (s *Service) DeleteActor(ctx context.Context, req *ateapipb.DeleteActorRequ if err := validateDeleteActorRequest(req); err != nil { return nil, err } + setSpanActorRefIdentity(ctx, req.GetActor().GetAtespace(), req.GetActor().GetName()) deleted, err := s.persistence.DeleteActor(ctx, req.GetActor().GetAtespace(), req.GetActor().GetName()) if err != nil { diff --git a/cmd/ateapi/internal/controlapi/delete_actor_test.go b/cmd/ateapi/internal/controlapi/delete_actor_test.go new file mode 100644 index 000000000..5ba206c92 --- /dev/null +++ b/cmd/ateapi/internal/controlapi/delete_actor_test.go @@ -0,0 +1,52 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controlapi + +import ( + "context" + "testing" + + "github.com/agent-substrate/substrate/internal/ateattr" + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" +) + +// Delete addresses the actor by ref (atespace + id) and does not resolve the +// template/version, so only the ref identity is stamped. +func TestDeleteActor_StampsRefSpanIdentity(t *testing.T) { + ns := namespaceForTest("ns-span-delete") + tc := setupTest(t, ns) + defer tc.cleanup() + createTemplate(t, tc, ns) + if _, err := tc.service.CreateActor(context.Background(), &ateapipb.CreateActorRequest{ + Actor: &ateapipb.Actor{ + Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: testActorID}, + ActorTemplateNamespace: ns, + ActorTemplateName: "tmpl1", + }, + }); err != nil { + t.Fatalf("seed CreateActor: %v", err) + } + + attrs := recordRootSpanAttrs(t, func(ctx context.Context) { + if _, err := tc.service.DeleteActor(ctx, &ateapipb.DeleteActorRequest{ + Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: testActorID}, + }); err != nil { + t.Fatalf("DeleteActor: %v", err) + } + }) + + assertSpanStr(t, attrs, ateattr.AtespaceKey, testAtespace) + assertSpanStr(t, attrs, ateattr.ActorNameKey, testActorID) +} diff --git a/cmd/ateapi/internal/controlapi/functional_test.go b/cmd/ateapi/internal/controlapi/functional_test.go index 57c08a9c5..a147ef8e2 100644 --- a/cmd/ateapi/internal/controlapi/functional_test.go +++ b/cmd/ateapi/internal/controlapi/functional_test.go @@ -63,7 +63,10 @@ var ( fakeAtelet = &FakeAteletServer{} ) -const testAtespace = "test-atespace" +const ( + testAtespace = "test-atespace" + testActorID = "id1" +) var ( ignoreUID = protocmp.IgnoreFields(&ateapipb.ResourceMetadata{}, "uid") diff --git a/cmd/ateapi/internal/controlapi/pause_actor.go b/cmd/ateapi/internal/controlapi/pause_actor.go index 58c5c8d0d..be56adba8 100644 --- a/cmd/ateapi/internal/controlapi/pause_actor.go +++ b/cmd/ateapi/internal/controlapi/pause_actor.go @@ -30,6 +30,7 @@ func (s *Service) PauseActor(ctx context.Context, req *ateapipb.PauseActorReques if err := validatePauseActorRequest(req); err != nil { return nil, err } + setSpanActorRefIdentity(ctx, req.GetActor().GetAtespace(), req.GetActor().GetName()) actor, err := s.actorWorkflow.PauseActor(ctx, req.GetActor().GetAtespace(), req.GetActor().GetName()) if err != nil { @@ -42,6 +43,7 @@ func (s *Service) PauseActor(ctx context.Context, req *ateapipb.PauseActorReques return nil, err } + setSpanActorIdentity(ctx, actor) return &ateapipb.PauseActorResponse{Actor: actor}, nil } diff --git a/cmd/ateapi/internal/controlapi/pause_actor_test.go b/cmd/ateapi/internal/controlapi/pause_actor_test.go new file mode 100644 index 000000000..c658c30b2 --- /dev/null +++ b/cmd/ateapi/internal/controlapi/pause_actor_test.go @@ -0,0 +1,52 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controlapi + +import ( + "context" + "testing" + + "go.opentelemetry.io/otel/attribute" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/agent-substrate/substrate/internal/ateattr" + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" +) + +// Pause stamps the ref identity before resolving the Actor record, so a failed +// lookup still carries who/where; it must not invent template/version, which are +// known only once the record resolves (and stamped on success). +func TestPauseActor_FailedLookupStampsRefIdentityOnly(t *testing.T) { + ns := namespaceForTest("ns-span-pause-err") + tc := setupTest(t, ns) + defer tc.cleanup() + + attrs := recordRootSpanAttrs(t, func(ctx context.Context) { + if _, err := tc.service.PauseActor(ctx, &ateapipb.PauseActorRequest{ + Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: testActorID}, + }); status.Code(err) != codes.NotFound { + t.Fatalf("PauseActor(missing) error = %v, want code NotFound", err) + } + }) + + assertSpanStr(t, attrs, ateattr.AtespaceKey, testAtespace) + assertSpanStr(t, attrs, ateattr.ActorNameKey, testActorID) + for _, k := range []attribute.Key{ateattr.ActorUIDKey, ateattr.ActorTemplateNameKey, ateattr.ActorTemplateNamespaceKey, ateattr.ActorVersionKey} { + if _, ok := attrs[k]; ok { + t.Errorf("unexpected %s on failed-pause span", k) + } + } +} diff --git a/cmd/ateapi/internal/controlapi/resume_actor.go b/cmd/ateapi/internal/controlapi/resume_actor.go index 0b2df3910..f9aaf4c24 100644 --- a/cmd/ateapi/internal/controlapi/resume_actor.go +++ b/cmd/ateapi/internal/controlapi/resume_actor.go @@ -30,6 +30,7 @@ func (s *Service) ResumeActor(ctx context.Context, req *ateapipb.ResumeActorRequ if err := validateResumeActorRequest(req); err != nil { return nil, err } + setSpanActorRefIdentity(ctx, req.GetActor().GetAtespace(), req.GetActor().GetName()) actor, err := s.actorWorkflow.ResumeActor(ctx, req.GetActor().GetAtespace(), req.GetActor().GetName(), req.GetBoot()) if err != nil { @@ -42,6 +43,7 @@ func (s *Service) ResumeActor(ctx context.Context, req *ateapipb.ResumeActorRequ return nil, err } + setSpanActorIdentity(ctx, actor) return &ateapipb.ResumeActorResponse{Actor: actor}, nil } diff --git a/cmd/ateapi/internal/controlapi/resume_actor_test.go b/cmd/ateapi/internal/controlapi/resume_actor_test.go new file mode 100644 index 000000000..56711afbf --- /dev/null +++ b/cmd/ateapi/internal/controlapi/resume_actor_test.go @@ -0,0 +1,42 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controlapi + +import ( + "context" + "testing" + + "github.com/agent-substrate/substrate/internal/ateattr" + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" +) + +// The early ref stamp must land on the span even when the op fails, so a failed +// resume is still attributable to who/where. +func TestResumeActor_ErrorStillStampsRefSpanIdentity(t *testing.T) { + ns := namespaceForTest("ns-span-resume-err") + tc := setupTest(t, ns) + defer tc.cleanup() + + attrs := recordRootSpanAttrs(t, func(ctx context.Context) { + if _, err := tc.service.ResumeActor(ctx, &ateapipb.ResumeActorRequest{ + Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "missing"}, + }); err == nil { + t.Fatal("expected error resuming missing actor") + } + }) + + assertSpanStr(t, attrs, ateattr.AtespaceKey, testAtespace) + assertSpanStr(t, attrs, ateattr.ActorNameKey, "missing") +} diff --git a/cmd/ateapi/internal/controlapi/span_identity.go b/cmd/ateapi/internal/controlapi/span_identity.go new file mode 100644 index 000000000..18b17acf9 --- /dev/null +++ b/cmd/ateapi/internal/controlapi/span_identity.go @@ -0,0 +1,36 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controlapi + +import ( + "context" + + "go.opentelemetry.io/otel/trace" + + "github.com/agent-substrate/substrate/internal/ateattr" + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" +) + +// setSpanActorIdentity annotates the RPC's server span (from ctx) with the +// actor's full identity. A no-op when ctx carries no recording span. +func setSpanActorIdentity(ctx context.Context, a *ateapipb.Actor) { + trace.SpanFromContext(ctx).SetAttributes(ateattr.ActorAttributes(a)...) +} + +// setSpanActorRefIdentity is setSpanActorIdentity for the identity subset known +// before the Actor record resolves, so a failed lookup still carries who/where. +func setSpanActorRefIdentity(ctx context.Context, atespace, name string) { + trace.SpanFromContext(ctx).SetAttributes(ateattr.ActorRefAttributes(atespace, name)...) +} diff --git a/cmd/ateapi/internal/controlapi/span_identity_test.go b/cmd/ateapi/internal/controlapi/span_identity_test.go new file mode 100644 index 000000000..96db69899 --- /dev/null +++ b/cmd/ateapi/internal/controlapi/span_identity_test.go @@ -0,0 +1,111 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controlapi + +import ( + "context" + "testing" + + "go.opentelemetry.io/otel/attribute" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + + "github.com/agent-substrate/substrate/internal/ateattr" + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" +) + +// recordRootSpanAttrs runs fn under a fresh recording root span from a local +// TracerProvider and returns that span's attributes, so a test can observe what +// the code under test stamps on the span carried in ctx. It never swaps the +// global provider (the code under test reads its span via trace.SpanFromContext, +// not the global provider), so span tests stay parallel-safe. Shared by the +// per-method span tests (create/delete/resume/pause_actor_test.go). +func recordRootSpanAttrs(t *testing.T, fn func(ctx context.Context)) map[attribute.Key]attribute.Value { + t.Helper() + sr := tracetest.NewSpanRecorder() + tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(sr)) + ctx, root := tp.Tracer("test").Start(context.Background(), "root") + fn(ctx) + root.End() + for _, s := range sr.Ended() { + if s.Name() == "root" { + m := make(map[attribute.Key]attribute.Value, len(s.Attributes())) + for _, kv := range s.Attributes() { + m[kv.Key] = kv.Value + } + return m + } + } + t.Fatal("root span not recorded") + return nil +} + +func assertSpanStr(t *testing.T, attrs map[attribute.Key]attribute.Value, key attribute.Key, want string) { + t.Helper() + v, ok := attrs[key] + if !ok { + t.Errorf("missing %s", key) + return + } + if v.AsString() != want { + t.Errorf("%s = %q, want %q", key, v.AsString(), want) + } +} + +func TestSetSpanActorIdentity(t *testing.T) { + t.Parallel() + actor := &ateapipb.Actor{ + Metadata: &ateapipb.ResourceMetadata{Atespace: "team-a", Name: "a1", Uid: "uid-a1", Version: 3}, + ActorTemplateNamespace: "ns1", + ActorTemplateName: "tmpl1", + } + + attrs := recordRootSpanAttrs(t, func(ctx context.Context) { + setSpanActorIdentity(ctx, actor) + }) + + assertSpanStr(t, attrs, ateattr.AtespaceKey, "team-a") + assertSpanStr(t, attrs, ateattr.ActorNameKey, "a1") + assertSpanStr(t, attrs, ateattr.ActorUIDKey, "uid-a1") + assertSpanStr(t, attrs, ateattr.ActorTemplateNameKey, "tmpl1") + assertSpanStr(t, attrs, ateattr.ActorTemplateNamespaceKey, "ns1") + if v, ok := attrs[ateattr.ActorVersionKey]; !ok || v.Type() != attribute.INT64 || v.AsInt64() != 3 { + t.Errorf("%s = %v, want int64 3", ateattr.ActorVersionKey, v.Emit()) + } +} + +func TestSetSpanActorRefIdentity(t *testing.T) { + t.Parallel() + + attrs := recordRootSpanAttrs(t, func(ctx context.Context) { + setSpanActorRefIdentity(ctx, "team-a", "a1") + }) + + assertSpanStr(t, attrs, ateattr.AtespaceKey, "team-a") + assertSpanStr(t, attrs, ateattr.ActorNameKey, "a1") + // The ref-only stamp must not invent uid/template/version (not known pre-resolve). + for _, k := range []attribute.Key{ateattr.ActorUIDKey, ateattr.ActorTemplateNameKey, ateattr.ActorTemplateNamespaceKey, ateattr.ActorVersionKey} { + if _, ok := attrs[k]; ok { + t.Errorf("unexpected %s on ref-only stamp", k) + } + } +} + +// A context with no recording span must be a safe no-op, so call sites need no guard. +func TestSetSpanActorIdentity_NoRecordingSpanIsNoop(t *testing.T) { + t.Parallel() + setSpanActorIdentity(context.Background(), &ateapipb.Actor{Metadata: &ateapipb.ResourceMetadata{Name: "a1"}}) + setSpanActorRefIdentity(context.Background(), "team-a", "a1") +} diff --git a/cmd/ateapi/internal/controlapi/suspend_actor.go b/cmd/ateapi/internal/controlapi/suspend_actor.go index bb3bc9833..e60c4592e 100644 --- a/cmd/ateapi/internal/controlapi/suspend_actor.go +++ b/cmd/ateapi/internal/controlapi/suspend_actor.go @@ -30,6 +30,7 @@ func (s *Service) SuspendActor(ctx context.Context, req *ateapipb.SuspendActorRe if err := validateSuspendActorRequest(req); err != nil { return nil, err } + setSpanActorRefIdentity(ctx, req.GetActor().GetAtespace(), req.GetActor().GetName()) actor, err := s.actorWorkflow.SuspendActor(ctx, req.GetActor().GetAtespace(), req.GetActor().GetName()) if err != nil { @@ -42,6 +43,7 @@ func (s *Service) SuspendActor(ctx context.Context, req *ateapipb.SuspendActorRe return nil, err } + setSpanActorIdentity(ctx, actor) return &ateapipb.SuspendActorResponse{Actor: actor}, nil } diff --git a/internal/ateattr/ateattr.go b/internal/ateattr/ateattr.go new file mode 100644 index 000000000..4d02d24f5 --- /dev/null +++ b/internal/ateattr/ateattr.go @@ -0,0 +1,62 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package ateattr projects an Actor onto substrate's ate.* span attributes. +// Identity is a span-level subject attribute (the producer is the substrate +// component, the actor is the subject), so it belongs on spans rather than the +// resource, and uses substrate's own ate.* namespace rather than service.*. +package ateattr + +import ( + "go.opentelemetry.io/otel/attribute" + + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" +) + +// Dotted ate.* matches the metric-instrument naming (atenet.*, atelet.*), not the +// ate.dev/ slash form used for k8s labels and stdout log fields. +// name vs uid mirror the k8s object model that ResourceMetadata follows: +// ate.actor.name is the atespace-scoped addressable name, ate.actor.uid is the +// server-assigned globally-unique key. There is deliberately no ate.actor.id +// (an ambiguous term when both a name and a uid exist). +const ( + AtespaceKey = attribute.Key("ate.atespace") + ActorNameKey = attribute.Key("ate.actor.name") + ActorUIDKey = attribute.Key("ate.actor.uid") + ActorTemplateNameKey = attribute.Key("ate.actor.template.name") + ActorTemplateNamespaceKey = attribute.Key("ate.actor.template.namespace") + ActorVersionKey = attribute.Key("ate.actor.version") +) + +// ActorRefAttributes returns the subset knowable before the Actor record +// resolves: only the (atespace, name) the request addresses. The uid and version +// are server-assigned and unknown until the record loads, so they are omitted. +func ActorRefAttributes(atespace, name string) []attribute.KeyValue { + return []attribute.KeyValue{ + AtespaceKey.String(atespace), + ActorNameKey.String(name), + } +} + +// ActorAttributes is nil-safe; a nil Actor yields zero-valued attributes. +func ActorAttributes(a *ateapipb.Actor) []attribute.KeyValue { + return []attribute.KeyValue{ + AtespaceKey.String(a.GetMetadata().GetAtespace()), + ActorNameKey.String(a.GetMetadata().GetName()), + ActorUIDKey.String(a.GetMetadata().GetUid()), + ActorTemplateNameKey.String(a.GetActorTemplateName()), + ActorTemplateNamespaceKey.String(a.GetActorTemplateNamespace()), + ActorVersionKey.Int64(a.GetMetadata().GetVersion()), + } +} diff --git a/internal/ateattr/ateattr_test.go b/internal/ateattr/ateattr_test.go new file mode 100644 index 000000000..7336dadc4 --- /dev/null +++ b/internal/ateattr/ateattr_test.go @@ -0,0 +1,137 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ateattr + +import ( + "testing" + + "go.opentelemetry.io/otel/attribute" + + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" +) + +func toMap(kvs []attribute.KeyValue) map[attribute.Key]attribute.Value { + m := make(map[attribute.Key]attribute.Value, len(kvs)) + for _, kv := range kvs { + m[kv.Key] = kv.Value + } + return m +} + +// assertAttrs checks each expected key is present with the expected value and +// OTel type. want values are string or int64; int64 doubles as the "version must +// not be stringified" check. +func assertAttrs(t *testing.T, got map[attribute.Key]attribute.Value, want map[attribute.Key]any) { + t.Helper() + if len(got) != len(want) { + t.Errorf("got %d attributes, want %d: %v", len(got), len(want), got) + } + for k, wv := range want { + v, ok := got[k] + if !ok { + t.Errorf("missing attribute %s", k) + continue + } + switch exp := wv.(type) { + case string: + if v.Type() != attribute.STRING || v.AsString() != exp { + t.Errorf("%s = %v (%s), want string %q", k, v.Emit(), v.Type(), exp) + } + case int64: + if v.Type() != attribute.INT64 || v.AsInt64() != exp { + t.Errorf("%s = %v (%s), want int64 %d", k, v.Emit(), v.Type(), exp) + } + default: + t.Fatalf("unsupported want type for %s: %T", k, wv) + } + } +} + +func TestActorAttributes(t *testing.T) { + tests := []struct { + name string + actor *ateapipb.Actor + want map[attribute.Key]any + }{ + { + name: "full actor", + actor: &ateapipb.Actor{ + Metadata: &ateapipb.ResourceMetadata{Atespace: "team-a", Name: "support-agent-42", Uid: "uid-abc", Version: 7}, + ActorTemplateNamespace: "ate-agents", + ActorTemplateName: "support-agent", + }, + want: map[attribute.Key]any{ + AtespaceKey: "team-a", + ActorNameKey: "support-agent-42", + ActorUIDKey: "uid-abc", + ActorTemplateNameKey: "support-agent", + ActorTemplateNamespaceKey: "ate-agents", + ActorVersionKey: int64(7), + }, + }, + { + name: "nil actor yields zero values, not a panic", + actor: nil, + want: map[attribute.Key]any{ + AtespaceKey: "", + ActorNameKey: "", + ActorUIDKey: "", + ActorTemplateNameKey: "", + ActorTemplateNamespaceKey: "", + ActorVersionKey: int64(0), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertAttrs(t, toMap(ActorAttributes(tt.actor)), tt.want) + }) + } +} + +func TestActorRefAttributes(t *testing.T) { + tests := []struct { + name string + atespace string + actorName string + want map[attribute.Key]any + }{ + { + name: "atespace and actor name only", + atespace: "team-a", + actorName: "support-agent-42", + want: map[attribute.Key]any{ + AtespaceKey: "team-a", + ActorNameKey: "support-agent-42", + }, + }, + { + name: "empty values still produce both keys", + atespace: "", + actorName: "", + want: map[attribute.Key]any{ + AtespaceKey: "", + ActorNameKey: "", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertAttrs(t, toMap(ActorRefAttributes(tt.atespace, tt.actorName)), tt.want) + }) + } +} diff --git a/vendor/go.opentelemetry.io/otel/sdk/trace/tracetest/README.md b/vendor/go.opentelemetry.io/otel/sdk/trace/tracetest/README.md new file mode 100644 index 000000000..0678d6564 --- /dev/null +++ b/vendor/go.opentelemetry.io/otel/sdk/trace/tracetest/README.md @@ -0,0 +1,3 @@ +# SDK Trace test + +[![PkgGoDev](https://pkg.go.dev/badge/go.opentelemetry.io/otel/sdk/trace/tracetest)](https://pkg.go.dev/go.opentelemetry.io/otel/sdk/trace/tracetest) diff --git a/vendor/go.opentelemetry.io/otel/sdk/trace/tracetest/exporter.go b/vendor/go.opentelemetry.io/otel/sdk/trace/tracetest/exporter.go new file mode 100644 index 000000000..e12fa67e6 --- /dev/null +++ b/vendor/go.opentelemetry.io/otel/sdk/trace/tracetest/exporter.go @@ -0,0 +1,74 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +// Package tracetest is a testing helper package for the SDK. User can +// configure no-op or in-memory exporters to verify different SDK behaviors or +// custom instrumentation. +package tracetest // import "go.opentelemetry.io/otel/sdk/trace/tracetest" + +import ( + "context" + "sync" + + "go.opentelemetry.io/otel/sdk/trace" +) + +var _ trace.SpanExporter = (*NoopExporter)(nil) + +// NewNoopExporter returns a new no-op exporter. +func NewNoopExporter() *NoopExporter { + return new(NoopExporter) +} + +// NoopExporter is an exporter that drops all received spans and performs no +// action. +type NoopExporter struct{} + +// ExportSpans handles export of spans by dropping them. +func (*NoopExporter) ExportSpans(context.Context, []trace.ReadOnlySpan) error { return nil } + +// Shutdown stops the exporter by doing nothing. +func (*NoopExporter) Shutdown(context.Context) error { return nil } + +var _ trace.SpanExporter = (*InMemoryExporter)(nil) + +// NewInMemoryExporter returns a new InMemoryExporter. +func NewInMemoryExporter() *InMemoryExporter { + return new(InMemoryExporter) +} + +// InMemoryExporter is an exporter that stores all received spans in-memory. +type InMemoryExporter struct { + mu sync.Mutex + ss SpanStubs +} + +// ExportSpans handles export of spans by storing them in memory. +func (imsb *InMemoryExporter) ExportSpans(_ context.Context, spans []trace.ReadOnlySpan) error { + imsb.mu.Lock() + defer imsb.mu.Unlock() + imsb.ss = append(imsb.ss, SpanStubsFromReadOnlySpans(spans)...) + return nil +} + +// Shutdown stops the exporter by clearing spans held in memory. +func (imsb *InMemoryExporter) Shutdown(context.Context) error { + imsb.Reset() + return nil +} + +// Reset the current in-memory storage. +func (imsb *InMemoryExporter) Reset() { + imsb.mu.Lock() + defer imsb.mu.Unlock() + imsb.ss = nil +} + +// GetSpans returns the current in-memory stored spans. +func (imsb *InMemoryExporter) GetSpans() SpanStubs { + imsb.mu.Lock() + defer imsb.mu.Unlock() + ret := make(SpanStubs, len(imsb.ss)) + copy(ret, imsb.ss) + return ret +} diff --git a/vendor/go.opentelemetry.io/otel/sdk/trace/tracetest/recorder.go b/vendor/go.opentelemetry.io/otel/sdk/trace/tracetest/recorder.go new file mode 100644 index 000000000..ca63038f3 --- /dev/null +++ b/vendor/go.opentelemetry.io/otel/sdk/trace/tracetest/recorder.go @@ -0,0 +1,94 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package tracetest // import "go.opentelemetry.io/otel/sdk/trace/tracetest" + +import ( + "context" + "sync" + + sdktrace "go.opentelemetry.io/otel/sdk/trace" +) + +// SpanRecorder records started and ended spans. +type SpanRecorder struct { + startedMu sync.RWMutex + started []sdktrace.ReadWriteSpan + + endedMu sync.RWMutex + ended []sdktrace.ReadOnlySpan +} + +var _ sdktrace.SpanProcessor = (*SpanRecorder)(nil) + +// NewSpanRecorder returns a new initialized SpanRecorder. +func NewSpanRecorder() *SpanRecorder { + return new(SpanRecorder) +} + +// OnStart records started spans. +// +// This method is safe to be called concurrently. +func (sr *SpanRecorder) OnStart(_ context.Context, s sdktrace.ReadWriteSpan) { + sr.startedMu.Lock() + defer sr.startedMu.Unlock() + sr.started = append(sr.started, s) +} + +// OnEnd records completed spans. +// +// This method is safe to be called concurrently. +func (sr *SpanRecorder) OnEnd(s sdktrace.ReadOnlySpan) { + sr.endedMu.Lock() + defer sr.endedMu.Unlock() + sr.ended = append(sr.ended, s) +} + +// Shutdown does nothing. +// +// This method is safe to be called concurrently. +func (*SpanRecorder) Shutdown(context.Context) error { + return nil +} + +// ForceFlush does nothing. +// +// This method is safe to be called concurrently. +func (*SpanRecorder) ForceFlush(context.Context) error { + return nil +} + +// Started returns a copy of all started spans that have been recorded. +// +// This method is safe to be called concurrently. +func (sr *SpanRecorder) Started() []sdktrace.ReadWriteSpan { + sr.startedMu.RLock() + defer sr.startedMu.RUnlock() + dst := make([]sdktrace.ReadWriteSpan, len(sr.started)) + copy(dst, sr.started) + return dst +} + +// Reset clears the recorded spans. +// +// This method is safe to be called concurrently. +func (sr *SpanRecorder) Reset() { + sr.startedMu.Lock() + sr.endedMu.Lock() + defer sr.startedMu.Unlock() + defer sr.endedMu.Unlock() + + sr.started = nil + sr.ended = nil +} + +// Ended returns a copy of all ended spans that have been recorded. +// +// This method is safe to be called concurrently. +func (sr *SpanRecorder) Ended() []sdktrace.ReadOnlySpan { + sr.endedMu.RLock() + defer sr.endedMu.RUnlock() + dst := make([]sdktrace.ReadOnlySpan, len(sr.ended)) + copy(dst, sr.ended) + return dst +} diff --git a/vendor/go.opentelemetry.io/otel/sdk/trace/tracetest/span.go b/vendor/go.opentelemetry.io/otel/sdk/trace/tracetest/span.go new file mode 100644 index 000000000..12b384b08 --- /dev/null +++ b/vendor/go.opentelemetry.io/otel/sdk/trace/tracetest/span.go @@ -0,0 +1,166 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package tracetest // import "go.opentelemetry.io/otel/sdk/trace/tracetest" + +import ( + "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/sdk/instrumentation" + "go.opentelemetry.io/otel/sdk/resource" + tracesdk "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/trace" +) + +// SpanStubs is a slice of SpanStub use for testing an SDK. +type SpanStubs []SpanStub + +// SpanStubsFromReadOnlySpans returns SpanStubs populated from ro. +func SpanStubsFromReadOnlySpans(ro []tracesdk.ReadOnlySpan) SpanStubs { + if len(ro) == 0 { + return nil + } + + s := make(SpanStubs, 0, len(ro)) + for _, r := range ro { + s = append(s, SpanStubFromReadOnlySpan(r)) + } + + return s +} + +// Snapshots returns s as a slice of ReadOnlySpans. +func (s SpanStubs) Snapshots() []tracesdk.ReadOnlySpan { + if len(s) == 0 { + return nil + } + + ro := make([]tracesdk.ReadOnlySpan, len(s)) + for i := range s { + ro[i] = s[i].Snapshot() + } + return ro +} + +// SpanStub is a stand-in for a Span. +type SpanStub struct { + Name string + SpanContext trace.SpanContext + Parent trace.SpanContext + SpanKind trace.SpanKind + StartTime time.Time + EndTime time.Time + Attributes []attribute.KeyValue + Events []tracesdk.Event + Links []tracesdk.Link + Status tracesdk.Status + DroppedAttributes int + DroppedEvents int + DroppedLinks int + ChildSpanCount int + Resource *resource.Resource + InstrumentationScope instrumentation.Scope + + // Deprecated: use InstrumentationScope instead. + InstrumentationLibrary instrumentation.Library //nolint:staticcheck // This method needs to be define for backwards compatibility +} + +// SpanStubFromReadOnlySpan returns a SpanStub populated from ro. +func SpanStubFromReadOnlySpan(ro tracesdk.ReadOnlySpan) SpanStub { + if ro == nil { + return SpanStub{} + } + + return SpanStub{ + Name: ro.Name(), + SpanContext: ro.SpanContext(), + Parent: ro.Parent(), + SpanKind: ro.SpanKind(), + StartTime: ro.StartTime(), + EndTime: ro.EndTime(), + Attributes: ro.Attributes(), + Events: ro.Events(), + Links: ro.Links(), + Status: ro.Status(), + DroppedAttributes: ro.DroppedAttributes(), + DroppedEvents: ro.DroppedEvents(), + DroppedLinks: ro.DroppedLinks(), + ChildSpanCount: ro.ChildSpanCount(), + Resource: ro.Resource(), + InstrumentationScope: ro.InstrumentationScope(), + InstrumentationLibrary: ro.InstrumentationScope(), + } +} + +// Snapshot returns a read-only copy of the SpanStub. +func (s SpanStub) Snapshot() tracesdk.ReadOnlySpan { + scopeOrLibrary := s.InstrumentationScope + if scopeOrLibrary.Name == "" && scopeOrLibrary.Version == "" && scopeOrLibrary.SchemaURL == "" { + scopeOrLibrary = s.InstrumentationLibrary + } + + return spanSnapshot{ + name: s.Name, + spanContext: s.SpanContext, + parent: s.Parent, + spanKind: s.SpanKind, + startTime: s.StartTime, + endTime: s.EndTime, + attributes: s.Attributes, + events: s.Events, + links: s.Links, + status: s.Status, + droppedAttributes: s.DroppedAttributes, + droppedEvents: s.DroppedEvents, + droppedLinks: s.DroppedLinks, + childSpanCount: s.ChildSpanCount, + resource: s.Resource, + instrumentationScope: scopeOrLibrary, + } +} + +type spanSnapshot struct { + // Embed the interface to implement the private method. + tracesdk.ReadOnlySpan + + name string + spanContext trace.SpanContext + parent trace.SpanContext + spanKind trace.SpanKind + startTime time.Time + endTime time.Time + attributes []attribute.KeyValue + events []tracesdk.Event + links []tracesdk.Link + status tracesdk.Status + droppedAttributes int + droppedEvents int + droppedLinks int + childSpanCount int + resource *resource.Resource + instrumentationScope instrumentation.Scope +} + +func (s spanSnapshot) Name() string { return s.name } +func (s spanSnapshot) SpanContext() trace.SpanContext { return s.spanContext } +func (s spanSnapshot) Parent() trace.SpanContext { return s.parent } +func (s spanSnapshot) SpanKind() trace.SpanKind { return s.spanKind } +func (s spanSnapshot) StartTime() time.Time { return s.startTime } +func (s spanSnapshot) EndTime() time.Time { return s.endTime } +func (s spanSnapshot) Attributes() []attribute.KeyValue { return s.attributes } +func (s spanSnapshot) Links() []tracesdk.Link { return s.links } +func (s spanSnapshot) Events() []tracesdk.Event { return s.events } +func (s spanSnapshot) Status() tracesdk.Status { return s.status } +func (s spanSnapshot) DroppedAttributes() int { return s.droppedAttributes } +func (s spanSnapshot) DroppedLinks() int { return s.droppedLinks } +func (s spanSnapshot) DroppedEvents() int { return s.droppedEvents } +func (s spanSnapshot) ChildSpanCount() int { return s.childSpanCount } +func (s spanSnapshot) Resource() *resource.Resource { return s.resource } +func (s spanSnapshot) InstrumentationScope() instrumentation.Scope { + return s.instrumentationScope +} + +func (s spanSnapshot) InstrumentationLibrary() instrumentation.Library { //nolint:staticcheck // This method needs to be define for backwards compatibility + return s.instrumentationScope +} diff --git a/vendor/modules.txt b/vendor/modules.txt index f94b360ba..ddfff76d4 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -830,6 +830,7 @@ go.opentelemetry.io/otel/sdk/resource go.opentelemetry.io/otel/sdk/trace go.opentelemetry.io/otel/sdk/trace/internal/env go.opentelemetry.io/otel/sdk/trace/internal/observ +go.opentelemetry.io/otel/sdk/trace/tracetest # go.opentelemetry.io/otel/sdk/metric v1.43.0 ## explicit; go 1.25.0 go.opentelemetry.io/otel/sdk/metric