Skip to content

Commit 5ba9da5

Browse files
feat(otel): set ate.* actor identity on router and ateapi spans
1 parent de5d1d9 commit 5ba9da5

14 files changed

Lines changed: 672 additions & 0 deletions

File tree

cmd/ateapi/internal/controlapi/create_actor.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ func (s *Service) CreateActor(ctx context.Context, req *ateapipb.CreateActorRequ
3333
if err := validateCreateActorRequest(req); err != nil {
3434
return nil, err
3535
}
36+
setSpanActorRefIdentity(ctx, req.GetActorRef().GetAtespace(), req.GetActorRef().GetName())
3637
_, err := s.actorTemplateLister.ActorTemplates(req.GetActorTemplateNamespace()).Get(req.GetActorTemplateName())
3738
if err != nil {
3839
if k8serrors.IsNotFound(err) {
@@ -69,6 +70,7 @@ func (s *Service) CreateActor(ctx context.Context, req *ateapipb.CreateActorRequ
6970
return nil, fmt.Errorf("while recording actor: %w", err)
7071
}
7172

73+
setSpanActorIdentity(ctx, stored)
7274
return &ateapipb.CreateActorResponse{
7375
Actor: stored,
7476
}, nil

cmd/ateapi/internal/controlapi/delete_actor.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ func (s *Service) DeleteActor(ctx context.Context, req *ateapipb.DeleteActorRequ
3131
if err := validateDeleteActorRequest(req); err != nil {
3232
return nil, err
3333
}
34+
setSpanActorRefIdentity(ctx, req.GetActorRef().GetAtespace(), req.GetActorRef().GetName())
3435

3536
if err := s.persistence.DeleteActor(ctx, req.GetActorRef().GetAtespace(), req.GetActorRef().GetName()); err != nil {
3637
if errors.Is(err, store.ErrNotFound) {

cmd/ateapi/internal/controlapi/pause_actor.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ func (s *Service) PauseActor(ctx context.Context, req *ateapipb.PauseActorReques
3030
if err := validatePauseActorRequest(req); err != nil {
3131
return nil, err
3232
}
33+
setSpanActorRefIdentity(ctx, req.GetActorRef().GetAtespace(), req.GetActorRef().GetName())
3334

3435
actor, err := s.actorWorkflow.PauseActor(ctx, req.GetActorRef().GetAtespace(), req.GetActorRef().GetName())
3536
if err != nil {
@@ -42,6 +43,7 @@ func (s *Service) PauseActor(ctx context.Context, req *ateapipb.PauseActorReques
4243
return nil, err
4344
}
4445

46+
setSpanActorIdentity(ctx, actor)
4547
return &ateapipb.PauseActorResponse{Actor: actor}, nil
4648
}
4749

cmd/ateapi/internal/controlapi/resume_actor.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ func (s *Service) ResumeActor(ctx context.Context, req *ateapipb.ResumeActorRequ
3030
if err := validateResumeActorRequest(req); err != nil {
3131
return nil, err
3232
}
33+
setSpanActorRefIdentity(ctx, req.GetActorRef().GetAtespace(), req.GetActorRef().GetName())
3334

3435
actor, err := s.actorWorkflow.ResumeActor(ctx, req.GetActorRef().GetAtespace(), req.GetActorRef().GetName(), req.GetBoot())
3536
if err != nil {
@@ -42,6 +43,7 @@ func (s *Service) ResumeActor(ctx context.Context, req *ateapipb.ResumeActorRequ
4243
return nil, err
4344
}
4445

46+
setSpanActorIdentity(ctx, actor)
4547
return &ateapipb.ResumeActorResponse{Actor: actor}, nil
4648
}
4749

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package controlapi
16+
17+
import (
18+
"context"
19+
20+
"go.opentelemetry.io/otel/trace"
21+
22+
"github.com/agent-substrate/substrate/internal/ateattr"
23+
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
24+
)
25+
26+
// setSpanActorIdentity annotates the RPC's server span (from ctx) with the
27+
// actor's full identity. A no-op when ctx carries no recording span.
28+
func setSpanActorIdentity(ctx context.Context, a *ateapipb.Actor) {
29+
trace.SpanFromContext(ctx).SetAttributes(ateattr.ActorIdentity(a)...)
30+
}
31+
32+
// setSpanActorRefIdentity is setSpanActorIdentity for the identity subset known
33+
// before the Actor record resolves, so a failed lookup still carries who/where.
34+
func setSpanActorRefIdentity(ctx context.Context, atespace, actorID string) {
35+
trace.SpanFromContext(ctx).SetAttributes(ateattr.ActorRefIdentity(atespace, actorID)...)
36+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package controlapi
16+
17+
import (
18+
"context"
19+
"testing"
20+
21+
"go.opentelemetry.io/otel"
22+
"go.opentelemetry.io/otel/attribute"
23+
sdktrace "go.opentelemetry.io/otel/sdk/trace"
24+
"go.opentelemetry.io/otel/sdk/trace/tracetest"
25+
26+
"github.com/agent-substrate/substrate/internal/ateattr"
27+
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
28+
)
29+
30+
// The functional harness registers no otelgrpc StatsHandler, so there is no
31+
// server span on the client path. These tests instead call the Service methods
32+
// in-process under a self-provided recording root span, which stands in for the
33+
// span the otelgrpc handler injects in production and exercises the same
34+
// trace.SpanFromContext(ctx).SetAttributes path.
35+
func installSpanRecorder(t *testing.T) *tracetest.SpanRecorder {
36+
t.Helper()
37+
sr := tracetest.NewSpanRecorder()
38+
tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(sr))
39+
prev := otel.GetTracerProvider()
40+
otel.SetTracerProvider(tp)
41+
t.Cleanup(func() { otel.SetTracerProvider(prev) })
42+
return sr
43+
}
44+
45+
func rootSpanAttrs(t *testing.T, sr *tracetest.SpanRecorder, fn func(ctx context.Context)) map[attribute.Key]attribute.Value {
46+
t.Helper()
47+
ctx, root := otel.Tracer("test").Start(context.Background(), "root")
48+
fn(ctx)
49+
root.End()
50+
for _, s := range sr.Ended() {
51+
if s.Name() == "root" {
52+
m := make(map[attribute.Key]attribute.Value, len(s.Attributes()))
53+
for _, kv := range s.Attributes() {
54+
m[kv.Key] = kv.Value
55+
}
56+
return m
57+
}
58+
}
59+
t.Fatal("root span not recorded")
60+
return nil
61+
}
62+
63+
func assertSpanStr(t *testing.T, attrs map[attribute.Key]attribute.Value, key attribute.Key, want string) {
64+
t.Helper()
65+
v, ok := attrs[key]
66+
if !ok {
67+
t.Errorf("missing %s", key)
68+
return
69+
}
70+
if v.AsString() != want {
71+
t.Errorf("%s = %q, want %q", key, v.AsString(), want)
72+
}
73+
}
74+
75+
func TestCreateActor_StampsFullSpanIdentity(t *testing.T) {
76+
ns := namespaceForTest("ns-span-create")
77+
tc := setupTest(t, ns)
78+
defer tc.cleanup()
79+
createTemplate(t, tc, ns)
80+
81+
sr := installSpanRecorder(t)
82+
attrs := rootSpanAttrs(t, sr, func(ctx context.Context) {
83+
if _, err := tc.service.CreateActor(ctx, &ateapipb.CreateActorRequest{
84+
ActorRef: &ateapipb.ActorRef{Atespace: testAtespace, Name: "id1"},
85+
ActorTemplateNamespace: ns,
86+
ActorTemplateName: "tmpl1",
87+
}); err != nil {
88+
t.Fatalf("CreateActor: %v", err)
89+
}
90+
})
91+
92+
assertSpanStr(t, attrs, ateattr.AtespaceKey, testAtespace)
93+
assertSpanStr(t, attrs, ateattr.ActorIDKey, "id1")
94+
assertSpanStr(t, attrs, ateattr.ActorTemplateNameKey, "tmpl1")
95+
assertSpanStr(t, attrs, ateattr.ActorTemplateNamespaceKey, ns)
96+
if v, ok := attrs[ateattr.ActorVersionKey]; !ok || v.Type() != attribute.INT64 || v.AsInt64() != 1 {
97+
t.Errorf("%s = %v, want int64 1", ateattr.ActorVersionKey, v.Emit())
98+
}
99+
}
100+
101+
func TestDeleteActor_StampsRefSpanIdentity(t *testing.T) {
102+
ns := namespaceForTest("ns-span-delete")
103+
tc := setupTest(t, ns)
104+
defer tc.cleanup()
105+
createTemplate(t, tc, ns)
106+
if _, err := tc.service.CreateActor(context.Background(), &ateapipb.CreateActorRequest{
107+
ActorRef: &ateapipb.ActorRef{Atespace: testAtespace, Name: "id1"},
108+
ActorTemplateNamespace: ns,
109+
ActorTemplateName: "tmpl1",
110+
}); err != nil {
111+
t.Fatalf("seed CreateActor: %v", err)
112+
}
113+
114+
sr := installSpanRecorder(t)
115+
attrs := rootSpanAttrs(t, sr, func(ctx context.Context) {
116+
if _, err := tc.service.DeleteActor(ctx, &ateapipb.DeleteActorRequest{
117+
ActorRef: &ateapipb.ActorRef{Atespace: testAtespace, Name: "id1"},
118+
}); err != nil {
119+
t.Fatalf("DeleteActor: %v", err)
120+
}
121+
})
122+
123+
assertSpanStr(t, attrs, ateattr.AtespaceKey, testAtespace)
124+
assertSpanStr(t, attrs, ateattr.ActorIDKey, "id1")
125+
}
126+
127+
// The early ref stamp must land on the span even when the operation fails, so a
128+
// failed resume is still attributable to who/where.
129+
func TestResumeActor_ErrorStillStampsRefSpanIdentity(t *testing.T) {
130+
ns := namespaceForTest("ns-span-resume-err")
131+
tc := setupTest(t, ns)
132+
defer tc.cleanup()
133+
134+
sr := installSpanRecorder(t)
135+
attrs := rootSpanAttrs(t, sr, func(ctx context.Context) {
136+
if _, err := tc.service.ResumeActor(ctx, &ateapipb.ResumeActorRequest{
137+
ActorRef: &ateapipb.ActorRef{Atespace: testAtespace, Name: "missing"},
138+
}); err == nil {
139+
t.Fatal("expected error resuming missing actor")
140+
}
141+
})
142+
143+
assertSpanStr(t, attrs, ateattr.AtespaceKey, testAtespace)
144+
assertSpanStr(t, attrs, ateattr.ActorIDKey, "missing")
145+
}

cmd/ateapi/internal/controlapi/suspend_actor.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ func (s *Service) SuspendActor(ctx context.Context, req *ateapipb.SuspendActorRe
3030
if err := validateSuspendActorRequest(req); err != nil {
3131
return nil, err
3232
}
33+
setSpanActorRefIdentity(ctx, req.GetActorRef().GetAtespace(), req.GetActorRef().GetName())
3334

3435
actor, err := s.actorWorkflow.SuspendActor(ctx, req.GetActorRef().GetAtespace(), req.GetActorRef().GetName())
3536
if err != nil {
@@ -42,6 +43,7 @@ func (s *Service) SuspendActor(ctx context.Context, req *ateapipb.SuspendActorRe
4243
return nil, err
4344
}
4445

46+
setSpanActorIdentity(ctx, actor)
4547
return &ateapipb.SuspendActorResponse{Actor: actor}, nil
4648
}
4749

internal/ateattr/ateattr.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Package ateattr projects an Actor onto substrate's ate.* identity attributes.
16+
// Identity is a span-level subject attribute (the producer is the substrate
17+
// component, the actor is the subject), so it belongs on spans rather than the
18+
// resource, and uses substrate's own ate.* namespace rather than service.*.
19+
package ateattr
20+
21+
import (
22+
"go.opentelemetry.io/otel/attribute"
23+
24+
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
25+
)
26+
27+
// Dotted ate.* matches the metric-instrument naming (atenet.*, atelet.*), not the
28+
// ate.dev/ slash form used for k8s labels and stdout log fields.
29+
const (
30+
AtespaceKey = attribute.Key("ate.atespace")
31+
ActorIDKey = attribute.Key("ate.actor.id")
32+
ActorTemplateNameKey = attribute.Key("ate.actor.template.name")
33+
ActorTemplateNamespaceKey = attribute.Key("ate.actor.template.namespace")
34+
ActorVersionKey = attribute.Key("ate.actor.version")
35+
)
36+
37+
// ActorRefIdentity returns the subset knowable before the Actor record resolves.
38+
func ActorRefIdentity(atespace, actorID string) []attribute.KeyValue {
39+
return []attribute.KeyValue{
40+
AtespaceKey.String(atespace),
41+
ActorIDKey.String(actorID),
42+
}
43+
}
44+
45+
// ActorIdentity is nil-safe; a nil Actor yields zero-valued attributes.
46+
func ActorIdentity(a *ateapipb.Actor) []attribute.KeyValue {
47+
return []attribute.KeyValue{
48+
AtespaceKey.String(a.GetMetadata().GetAtespace()),
49+
ActorIDKey.String(a.GetMetadata().GetName()),
50+
ActorTemplateNameKey.String(a.GetActorTemplateName()),
51+
ActorTemplateNamespaceKey.String(a.GetActorTemplateNamespace()),
52+
ActorVersionKey.Int64(a.GetMetadata().GetVersion()),
53+
}
54+
}

0 commit comments

Comments
 (0)