Skip to content

Commit 8fe756b

Browse files
author
邝谧
committed
feat(a2aext/trace): add cross-agent audit trail package
Add a2aext/trace — a W3C Trace Context-inspired audit trail for A2A task delegation chains. Each agent records spans (who called whom, for what task, at what time) that propagate across goroutine boundaries via ServiceParams headers. Key components: - TraceSpan: trace_id + span_id + parent_span_id + agent metadata - ServerInterceptor: creates spans on inbound A2A calls - ClientInterceptor: propagates spans on outbound A2A calls - ParseTraceHeader/Encode: semicolon-delimited header encoding (trace_id;span_id;parent_span_id;agent_id) Three-hop test validates: agent-a → agent-b → agent-c chain with consistent TraceID and correct ParentSpanID linkage across hops. Addresses the audit trail gap discussed in A2A issue #1672. 10 tests pass, zero regressions on existing a2aext/a2asrv suites.
1 parent d52d5a1 commit 8fe756b

5 files changed

Lines changed: 756 additions & 0 deletions

File tree

a2aext/trace/client.go

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
// Copyright 2026 The A2A Authors
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 trace
16+
17+
import (
18+
"context"
19+
"log/slog"
20+
"strings"
21+
22+
"github.com/a2aproject/a2a-go/v2/a2a"
23+
"github.com/a2aproject/a2a-go/v2/a2aclient"
24+
"github.com/a2aproject/a2a-go/v2/a2asrv"
25+
)
26+
27+
const (
28+
// SvcParamTrace is the ServiceParam key for trace propagation headers.
29+
// Values are formatted as: trace_id;span_id;parent_span_id;agent_id
30+
SvcParamTrace = "a2a-trace"
31+
)
32+
33+
// ClientConfig configures the client-side trace interceptor.
34+
type ClientConfig struct {
35+
// AgentID is the identifier injected into child spans when no parent span
36+
// exists. This handles the case where the client is the root caller.
37+
AgentID string
38+
39+
// Logger receives span propagation events. If nil, slog.Default() is used.
40+
Logger *slog.Logger
41+
}
42+
43+
// NewClientInterceptor returns a client interceptor that propagates trace
44+
// context to outgoing A2A calls via ServiceParams headers. If a parent
45+
// [TraceSpan] is present in the context (set by a [ServerInterceptor] on a
46+
// prior inbound call), a child span is created and propagated. Otherwise,
47+
// a new root span is created.
48+
//
49+
// Usage:
50+
//
51+
// client, err := a2aclient.NewFromCard(ctx, card,
52+
// a2aclient.WithCallInterceptors(trace.NewClientInterceptor(trace.ClientConfig{
53+
// AgentID: "sg-architect",
54+
// })),
55+
// )
56+
func NewClientInterceptor(cfg ClientConfig) a2aclient.CallInterceptor {
57+
if cfg.Logger == nil {
58+
cfg.Logger = slog.Default()
59+
}
60+
return &clientInterceptor{cfg: cfg}
61+
}
62+
63+
type clientInterceptor struct {
64+
a2aclient.PassthroughInterceptor
65+
cfg ClientConfig
66+
}
67+
68+
func (c *clientInterceptor) Before(ctx context.Context, req *a2aclient.Request) (context.Context, any, error) {
69+
parent := SpanFrom(ctx)
70+
71+
var span *TraceSpan
72+
if parent != nil {
73+
span = NewChildSpan(parent, c.cfg.AgentID, "", "")
74+
c.cfg.Logger.DebugContext(ctx, "trace: child span propagated", "span", span.String())
75+
} else {
76+
span = NewRootSpan(c.cfg.AgentID, "", "")
77+
c.cfg.Logger.DebugContext(ctx, "trace: root span propagated", "span", span.String())
78+
}
79+
80+
req.ServiceParams.Append(SvcParamTrace, span.Encode())
81+
return WithSpan(ctx, span), nil, nil
82+
}
83+
84+
// ServerConfig configures the server-side trace interceptor.
85+
type ServerConfig struct {
86+
// AgentID is the identifier injected into every span created by this server.
87+
// If empty, the interceptor is a no-op.
88+
AgentID string
89+
90+
// Logger receives span creation events. If nil, slog.Default() is used.
91+
Logger *slog.Logger
92+
}
93+
94+
// NewServerInterceptor returns a server interceptor that creates a new
95+
// [TraceSpan] for every incoming A2A call. The span is attached to the context
96+
// and can be retrieved by downstream interceptors or executors via [SpanFrom].
97+
//
98+
// If the incoming request carries a trace header (SvcParamTrace), the new span
99+
// is created as a child, linking the caller's span as the parent. TaskID and
100+
// ContextID are extracted from the request payload when available.
101+
//
102+
// Usage:
103+
//
104+
// handler := a2asrv.NewHandler(executor,
105+
// a2asrv.WithCallInterceptors(trace.NewServerInterceptor(trace.ServerConfig{
106+
// AgentID: "do-developer",
107+
// })),
108+
// )
109+
func NewServerInterceptor(cfg ServerConfig) a2asrv.CallInterceptor {
110+
if cfg.AgentID == "" {
111+
return a2asrv.PassthroughCallInterceptor{}
112+
}
113+
if cfg.Logger == nil {
114+
cfg.Logger = slog.Default()
115+
}
116+
return &serverInterceptor{cfg: cfg}
117+
}
118+
119+
type serverInterceptor struct {
120+
a2asrv.PassthroughCallInterceptor
121+
cfg ServerConfig
122+
}
123+
124+
func (s *serverInterceptor) Before(ctx context.Context, callCtx *a2asrv.CallContext, req *a2asrv.Request) (context.Context, any, error) {
125+
taskID, contextID := extractTaskInfo(req.Payload)
126+
headerValue := s.readTraceHeader(callCtx)
127+
128+
var span *TraceSpan
129+
if parent := ParseTraceHeader(headerValue); parent != nil {
130+
span = NewChildSpan(parent, s.cfg.AgentID, taskID, contextID)
131+
} else {
132+
span = NewRootSpan(s.cfg.AgentID, taskID, contextID)
133+
}
134+
s.cfg.Logger.DebugContext(ctx, "trace: span created", "span", span.String())
135+
return WithSpan(ctx, span), nil, nil
136+
}
137+
138+
// readTraceHeader reads the SvcParamTrace value from the incoming call.
139+
func (s *serverInterceptor) readTraceHeader(callCtx *a2asrv.CallContext) string {
140+
values, ok := callCtx.ServiceParams().Get(SvcParamTrace)
141+
if !ok || len(values) == 0 {
142+
return ""
143+
}
144+
return values[0]
145+
}
146+
147+
// Encode serializes the span into a ServiceParams header value.
148+
// Format: trace_id;span_id;parent_span_id;agent_id
149+
func (s *TraceSpan) Encode() string {
150+
return strings.Join([]string{s.TraceID, s.SpanID, s.ParentSpanID, s.AgentID}, ";")
151+
}
152+
153+
// ParseTraceHeader decodes a trace header value into a partial TraceSpan
154+
// suitable as a parent reference. Returns nil if the header value is
155+
// empty or has fewer than 3 semicolon-separated parts.
156+
func ParseTraceHeader(val string) *TraceSpan {
157+
if val == "" {
158+
return nil
159+
}
160+
parts := strings.Split(val, ";")
161+
if len(parts) < 3 {
162+
return nil
163+
}
164+
return &TraceSpan{
165+
TraceID: parts[0],
166+
SpanID: parts[1],
167+
ParentSpanID: parts[2],
168+
AgentID: optionalPart(parts, 3),
169+
}
170+
}
171+
172+
func extractTaskInfo(payload any) (taskID, contextID string) {
173+
if provider, ok := payload.(a2a.TaskInfoProvider); ok {
174+
info := provider.TaskInfo()
175+
return string(info.TaskID), info.ContextID
176+
}
177+
return "", ""
178+
}
179+
180+
func optionalPart(parts []string, idx int) string {
181+
if idx < len(parts) {
182+
return parts[idx]
183+
}
184+
return ""
185+
}

a2aext/trace/context.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Copyright 2026 The A2A Authors
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 trace
16+
17+
import (
18+
"context"
19+
"crypto/rand"
20+
"encoding/hex"
21+
"fmt"
22+
"time"
23+
)
24+
25+
// spanCtxKey is the context key for [TraceSpan].
26+
type spanCtxKey struct{}
27+
28+
// SpanFrom extracts the current trace span from the context.
29+
// Returns nil if no span is present.
30+
func SpanFrom(ctx context.Context) *TraceSpan {
31+
span, ok := ctx.Value(spanCtxKey{}).(*TraceSpan)
32+
if !ok {
33+
return nil
34+
}
35+
return span
36+
}
37+
38+
// WithSpan attaches a [TraceSpan] to the context.
39+
func WithSpan(ctx context.Context, span *TraceSpan) context.Context {
40+
return context.WithValue(ctx, spanCtxKey{}, span)
41+
}
42+
43+
// TraceSpan records a single step in a cross-agent task chain.
44+
//
45+
// A span is created by a [ServerInterceptor] when an A2A call arrives.
46+
// The [ClientInterceptor] reads the span and links it as the parent of any
47+
// further outgoing calls.
48+
type TraceSpan struct {
49+
// TraceID identifies the end-to-end delegation chain.
50+
// All spans within the same chain share the same TraceID.
51+
// Generated when no parent span exists (the root of the chain).
52+
TraceID string
53+
54+
// SpanID is a unique identifier for this individual call.
55+
SpanID string
56+
57+
// ParentSpanID is the SpanID of the calling agent's span, or empty
58+
// if this is the root of the chain.
59+
ParentSpanID string
60+
61+
// AgentID identifies the agent that handled this call.
62+
AgentID string
63+
64+
// TaskID is the A2A task identifier associated with this call.
65+
TaskID string
66+
67+
// ContextID is the A2A context identifier grouping related interactions.
68+
ContextID string
69+
70+
// Timestamp records when this span was created.
71+
Timestamp time.Time
72+
}
73+
74+
// NewRootSpan creates a root span with a new TraceID and no parent.
75+
func NewRootSpan(agentID, taskID, contextID string) *TraceSpan {
76+
return &TraceSpan{
77+
TraceID: newID(16),
78+
SpanID: newID(8),
79+
ParentSpanID: "",
80+
AgentID: agentID,
81+
TaskID: taskID,
82+
ContextID: contextID,
83+
Timestamp: time.Now(),
84+
}
85+
}
86+
87+
// NewChildSpan creates a child span inheriting the parent's TraceID.
88+
func NewChildSpan(parent *TraceSpan, agentID, taskID, contextID string) *TraceSpan {
89+
return &TraceSpan{
90+
TraceID: parent.TraceID,
91+
SpanID: newID(8),
92+
ParentSpanID: parent.SpanID,
93+
AgentID: agentID,
94+
TaskID: taskID,
95+
ContextID: contextID,
96+
Timestamp: time.Now(),
97+
}
98+
}
99+
100+
// Chain returns the causal chain as a human-readable string:
101+
//
102+
// agent-a:span₀ → agent-b:span₁ → agent-c:span₂
103+
func (s *TraceSpan) Chain() string {
104+
return fmt.Sprintf("%s:%s", s.AgentID, s.SpanID)
105+
}
106+
107+
// String returns a compact representation suitable for log lines.
108+
func (s *TraceSpan) String() string {
109+
if s.ParentSpanID == "" {
110+
return fmt.Sprintf("[trace=%s span=%s agent=%s task=%s]", s.TraceID, s.SpanID, s.AgentID, s.TaskID)
111+
}
112+
return fmt.Sprintf("[trace=%s span=%s parent=%s agent=%s task=%s]", s.TraceID, s.SpanID, s.ParentSpanID, s.AgentID, s.TaskID)
113+
}
114+
115+
// newID generates a random hex string with n bytes of entropy.
116+
func newID(n int) string {
117+
b := make([]byte, n)
118+
if _, err := rand.Read(b); err != nil {
119+
panic(fmt.Sprintf("trace: failed to read random bytes: %v", err))
120+
}
121+
return hex.EncodeToString(b)
122+
}

a2aext/trace/doc.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright 2026 The A2A Authors
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 trace provides an audit trail for A2A task execution chains.
16+
//
17+
// When Agent A delegates to Agent B, trace attaches a span to the outgoing call
18+
// and records a span on the incoming side. The result is a causal chain of
19+
// [TraceSpan] values — who called whom, for what task, at what time — that
20+
// can be collected and queried for post-hoc audit.
21+
//
22+
// # Design
23+
//
24+
// A [ServerInterceptor] (on the callee) creates a new span for every incoming A2A
25+
// call, stashing it in the context. A [ClientInterceptor] (on the caller) reads
26+
// the span from the context and attaches it as the parent to any outgoing calls.
27+
//
28+
// Multiple hops accumulate a chain:
29+
//
30+
// Agent A ──call──► Agent B ──call──► Agent C
31+
// span₀ span₁(parent=₀) span₂(parent=₁)
32+
//
33+
// # Relationship to a2aext propagator
34+
//
35+
// The a2aext propagator carries extension metadata; trace carries task-level
36+
// provenance. They are complementary and can be combined in the same interceptor
37+
// chain. The propagator's context key is distinct, so no interference occurs.
38+
package trace

a2aext/trace/server.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Copyright 2026 The A2A Authors
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 trace
16+
17+
// ServerConfig and NewServerInterceptor are defined in client.go alongside
18+
// ClientConfig to keep the dual-interceptor design cohesive.

0 commit comments

Comments
 (0)