From 3945b3688f62ba5e9f6cec62da05d37a01a1b3a1 Mon Sep 17 00:00:00 2001 From: mesutoezdil Date: Thu, 23 Apr 2026 16:48:40 +0200 Subject: [PATCH 1/2] feat(go-adk): propagate A2A message metadata as OTEL span attributes Scalar values (string, bool, number) from the A2A message Metadata field are now set as attributes on the invocation span under the prefix a2a.message.metadata.. Non-scalar values such as nested objects and lists are skipped to keep attributes flat and filterable. This lets callers attach contextual data to traces without a separate observability pipeline. A common case is human-in-the-loop approval flows where the approver identity is passed back via message/send and needs to appear in audit traces. Closes #1734 Signed-off-by: mesutoezdil --- go/adk/pkg/a2a/executor.go | 4 ++ go/adk/pkg/telemetry/attributes.go | 31 +++++++++++ go/adk/pkg/telemetry/attributes_test.go | 71 +++++++++++++++++++++++++ 3 files changed, 106 insertions(+) diff --git a/go/adk/pkg/a2a/executor.go b/go/adk/pkg/a2a/executor.go index aa5957c42..2c31375bc 100644 --- a/go/adk/pkg/a2a/executor.go +++ b/go/adk/pkg/a2a/executor.go @@ -138,6 +138,10 @@ func (e *KAgentExecutor) Execute(ctx context.Context, reqCtx *a2asrv.RequestCont ctx, invocationSpan := telemetry.StartInvocationSpan(ctx) defer invocationSpan.End() + // Propagate scalar values from the A2A message metadata as span attributes + // so callers can attach contextual data (e.g. approver identity) to traces. + telemetry.SetMessageMetadataAttributes(ctx, reqCtx.Message.Metadata) + // 3. Initialize skills session path. if e.skillsDirectory != "" && sessionID != "" { if _, err := skills.InitializeSessionPath(sessionID, e.skillsDirectory); err != nil { diff --git a/go/adk/pkg/telemetry/attributes.go b/go/adk/pkg/telemetry/attributes.go index 88512e057..9e03becd2 100644 --- a/go/adk/pkg/telemetry/attributes.go +++ b/go/adk/pkg/telemetry/attributes.go @@ -3,6 +3,7 @@ package telemetry import ( "context" "encoding/json" + "fmt" "os" "strings" @@ -87,6 +88,36 @@ func stringAttributes(attrs map[string]string) []attribute.KeyValue { return out } +// SetMessageMetadataAttributes reads scalar values from an A2A message's +// Metadata map and sets them as span attributes using the prefix +// "a2a.message.metadata.". Non-scalar values (maps, slices) are skipped +// so that only clean, filterable attributes appear in the trace. +func SetMessageMetadataAttributes(ctx context.Context, metadata map[string]any) { + if len(metadata) == 0 { + return + } + var attrs []attribute.KeyValue + for k, v := range metadata { + key := "a2a.message.metadata." + k + switch val := v.(type) { + case string: + if val != "" { + attrs = append(attrs, attribute.String(key, val)) + } + case bool: + attrs = append(attrs, attribute.Bool(key, val)) + case float64: + attrs = append(attrs, attribute.String(key, fmt.Sprintf("%g", val))) + case int: + attrs = append(attrs, attribute.Int(key, val)) + case int64: + attrs = append(attrs, attribute.Int64(key, val)) + // skip maps, slices, and other complex types + } + } + setSpanAttributes(ctx, attrs...) +} + func setSpanAttributes(ctx context.Context, attrs ...attribute.KeyValue) { span := trace.SpanFromContext(ctx) if !span.IsRecording() || len(attrs) == 0 { diff --git a/go/adk/pkg/telemetry/attributes_test.go b/go/adk/pkg/telemetry/attributes_test.go index 9e3ec2d82..a58cac700 100644 --- a/go/adk/pkg/telemetry/attributes_test.go +++ b/go/adk/pkg/telemetry/attributes_test.go @@ -2,6 +2,7 @@ package telemetry import ( "context" + "strings" "testing" "go.opentelemetry.io/otel" @@ -170,6 +171,76 @@ func TestSetLLMAttributes_EmitsEmptyPayloadWhenContentCaptureDisabled(t *testing } } +func TestSetMessageMetadataAttributes(t *testing.T) { + exporter := tracetest.NewInMemoryExporter() + tp := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exporter)) + t.Cleanup(func() { _ = tp.Shutdown(context.Background()) }) + + tracer := tp.Tracer("test") + ctx, span := tracer.Start(context.Background(), "test-span") + + SetMessageMetadataAttributes(ctx, map[string]any{ + "approver_email": "admin@example.com", + "attempt_count": float64(3), + "dry_run": true, + "nested": map[string]any{"should": "be skipped"}, + "list_val": []string{"also", "skipped"}, + "empty_str": "", + }) + span.End() + + spans := exporter.GetSpans() + if len(spans) == 0 { + t.Fatal("no spans recorded") + } + attrs := make(map[string]attribute.Value) + for _, a := range spans[0].Attributes { + attrs[string(a.Key)] = a.Value + } + + if got := attrs["a2a.message.metadata.approver_email"].AsString(); got != "admin@example.com" { + t.Errorf("approver_email: got %q, want %q", got, "admin@example.com") + } + if got := attrs["a2a.message.metadata.attempt_count"].AsString(); got != "3" { + t.Errorf("attempt_count: got %q, want %q", got, "3") + } + if got := attrs["a2a.message.metadata.dry_run"].AsBool(); !got { + t.Errorf("dry_run: got %v, want true", got) + } + if _, exists := attrs["a2a.message.metadata.nested"]; exists { + t.Error("nested map should not be set as span attribute") + } + if _, exists := attrs["a2a.message.metadata.list_val"]; exists { + t.Error("list value should not be set as span attribute") + } + if _, exists := attrs["a2a.message.metadata.empty_str"]; exists { + t.Error("empty string should not be set as span attribute") + } +} + +func TestSetMessageMetadataAttributes_NilAndEmpty(t *testing.T) { + exporter := tracetest.NewInMemoryExporter() + tp := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exporter)) + t.Cleanup(func() { _ = tp.Shutdown(context.Background()) }) + + tracer := tp.Tracer("test") + ctx, span := tracer.Start(context.Background(), "test-span") + + SetMessageMetadataAttributes(ctx, nil) + SetMessageMetadataAttributes(ctx, map[string]any{}) + span.End() + + spans := exporter.GetSpans() + if len(spans) == 0 { + t.Fatal("no spans recorded") + } + for _, a := range spans[0].Attributes { + if strings.HasPrefix(string(a.Key), "a2a.message.metadata.") { + t.Errorf("no metadata attributes expected, got %q", a.Key) + } + } +} + func spanAttributesByName(t *testing.T, spans tracetest.SpanStubs, name string) map[string]attribute.Value { t.Helper() From 534ebd3f4b2448368cb277dcfdb6f755699cec8c Mon Sep 17 00:00:00 2001 From: mesutoezdil Date: Thu, 30 Apr 2026 16:18:22 +0200 Subject: [PATCH 2/2] style(go-adk): fix gofmt violation and trim comments per conventions Remove trailing switch comment that caused gofmt to report a formatting error, trim 4-line doc comment to one line (conventions: max one short line), and drop the call-site comment whose content is already expressed by the function name. Signed-off-by: mesutoezdil --- go/adk/pkg/a2a/executor.go | 2 -- go/adk/pkg/telemetry/attributes.go | 6 +----- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/go/adk/pkg/a2a/executor.go b/go/adk/pkg/a2a/executor.go index 2c31375bc..cf2da8c34 100644 --- a/go/adk/pkg/a2a/executor.go +++ b/go/adk/pkg/a2a/executor.go @@ -138,8 +138,6 @@ func (e *KAgentExecutor) Execute(ctx context.Context, reqCtx *a2asrv.RequestCont ctx, invocationSpan := telemetry.StartInvocationSpan(ctx) defer invocationSpan.End() - // Propagate scalar values from the A2A message metadata as span attributes - // so callers can attach contextual data (e.g. approver identity) to traces. telemetry.SetMessageMetadataAttributes(ctx, reqCtx.Message.Metadata) // 3. Initialize skills session path. diff --git a/go/adk/pkg/telemetry/attributes.go b/go/adk/pkg/telemetry/attributes.go index 9e03becd2..59227d4e7 100644 --- a/go/adk/pkg/telemetry/attributes.go +++ b/go/adk/pkg/telemetry/attributes.go @@ -88,10 +88,7 @@ func stringAttributes(attrs map[string]string) []attribute.KeyValue { return out } -// SetMessageMetadataAttributes reads scalar values from an A2A message's -// Metadata map and sets them as span attributes using the prefix -// "a2a.message.metadata.". Non-scalar values (maps, slices) are skipped -// so that only clean, filterable attributes appear in the trace. +// SetMessageMetadataAttributes sets scalar values from an A2A message's metadata as span attributes. func SetMessageMetadataAttributes(ctx context.Context, metadata map[string]any) { if len(metadata) == 0 { return @@ -112,7 +109,6 @@ func SetMessageMetadataAttributes(ctx context.Context, metadata map[string]any) attrs = append(attrs, attribute.Int(key, val)) case int64: attrs = append(attrs, attribute.Int64(key, val)) - // skip maps, slices, and other complex types } } setSpanAttributes(ctx, attrs...)