|
| 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 | +} |
0 commit comments