From d943cbfe0bf3fd2537ad509fb56e7727d8eeda5c Mon Sep 17 00:00:00 2001 From: Timo Derstappen Date: Sat, 17 Oct 2026 20:32:00 +0200 Subject: [PATCH 1/4] fix(telemetry): count a turn by its segment, not by its invoke_agent spans Claude Code 2.1.302 marks its claude_code.interaction span with gen_ai.operation.name=invoke_agent; 2.1.285 emits the same span without it. The span is a child of the invoke_agent span kagent opens for the harness, which it reaches through TRACEPARENT, so a Claude turn's trace now holds two invoke_agent spans and TestE2ECompletedChatFlushesTraces failed on every run of the Claude Code bump with "invoke_agent spans = 2, want exactly one". kagent keeps its own span for a native coding agent: only it carries the segment, the A2A identity, the capture and the duration metric. The contract states the case: kagent opens invoke_agent for a runtime whose own spans carry no invocation segment, a native agent may still mark a span of its own invoke_agent beneath it, and a consumer counts turns by kagent.invocation.segment, as the request-span notes already say. The test selects the invocation by operation and segment and requires Claude Code's interaction span under it. The trace diagnostic lists the expected trace's spans before the other traces' request spans, and with their operation: the parallel cases' request spans filled its twelve entries and hid the trace that failed. Signed-off-by: Timo Derstappen Signed-off-by: QuentinBisson --- docs/architecture/telemetry-contract.md | 2 +- docs/architecture/telemetry.md | 4 +- go/core/test/e2e/tracing_test.go | 60 ++++++++++++++++--------- telemetry/registry/signals.yaml | 7 ++- telemetry/resolved.yaml | 2 +- 5 files changed, 48 insertions(+), 27 deletions(-) diff --git a/docs/architecture/telemetry-contract.md b/docs/architecture/telemetry-contract.md index 8f267294be..a66d8aba2c 100644 --- a/docs/architecture/telemetry-contract.md +++ b/docs/architecture/telemetry-contract.md @@ -353,7 +353,7 @@ Refines `gen_ai.client.inference`, kind `client`. One model request of a harness ### `kagent.invoke_agent.internal` -Refines `gen_ai.invoke_agent.internal`, kind `internal`. One execution segment of a kagent agent. Named `invoke_agent {gen_ai.agent.name}`. kagent opens it only for a runtime that emits no invoke_agent span of its own. A resumed segment links to the segment that started the native turn, with `kagent.invocation.relationship` set to `resume_origin`. Usage and finish reasons are only on inference spans, so they are opt-in here and never set. +Refines `gen_ai.invoke_agent.internal`, kind `internal`. One execution segment of a kagent agent. Named `invoke_agent {gen_ai.agent.name}`. kagent opens it only for a runtime whose own spans carry no invocation segment: a native coding agent it wraps. Such an agent may still mark a span of its own invoke_agent, as Claude Code does with `claude_code.interaction`; that span is a child of this one, so a consumer counts turns by `kagent.invocation.segment`. A resumed segment links to the segment that started the native turn, with `kagent.invocation.relationship` set to `resume_origin`. Usage and finish reasons are only on inference spans, so they are opt-in here and never set. | Attribute | Requirement | Note | | --- | --- | --- | diff --git a/docs/architecture/telemetry.md b/docs/architecture/telemetry.md index cf5240f11a..197404ea3c 100644 --- a/docs/architecture/telemetry.md +++ b/docs/architecture/telemetry.md @@ -13,7 +13,7 @@ The contract is a Weaver registry in [`telemetry/registry`](../../telemetry/regi | Concept | Attributes | Where | | --- | --- | --- | -| Operation | `gen_ai.operation.name` | A span is a GenAI operation when it carries this. kagent writes `invoke_agent` only where the runtime emits none of its own | +| Operation | `gen_ai.operation.name` | A span is a GenAI operation when it carries this. kagent writes `invoke_agent` only where the runtime's own spans carry no invocation segment. Count turns by `kagent.invocation.segment` | | Runtime | `kagent.runtime` | Resource | | Agent | `gen_ai.agent.name`, `gen_ai.agent.id` | invoke_agent and request spans. The resource carries the same identity as `gen_ai.main_agent.name` and `gen_ai.main_agent.id` | | Provider and model | `gen_ai.provider.name`, `gen_ai.request.model` | Resource and invoke_agent span, for harnesses compiled against one model | @@ -33,7 +33,7 @@ The registry describes the contract the runtimes are moving to. The next section | Runtime | `kagent.runtime` | Request span | `invoke_agent` | Inference spans | Keys outside the contract | | --- | --- | --- | --- | --- | --- | | Go ADK | `adk-go` | `otelgrpc` or `otelhttp` SERVER span and kagent's `a2a.request` | The ADK's own | The ADK's own | `gcp.vertex.agent.*`, written by the ADK itself | -| Claude Code, Codex | `claude`, `codex` | `otelgrpc` or `otelhttp` SERVER span | kagent's wrapper, `invoke_agent ` | kagent's harness runtime, `chat ` and `execute_tool ` | None | +| Claude Code, Codex | `claude`, `codex` | `otelgrpc` or `otelhttp` SERVER span | kagent's wrapper, `invoke_agent `; Claude Code's `claude_code.interaction`, which also marks itself `invoke_agent`, is its child | kagent's harness runtime, `chat ` and `execute_tool ` | None | | Python ADK | `adk-python` | gRPC or FastAPI SERVER span and kagent's `a2a.request` | The ADK's own | The ADK's `generate_content` | `gcp.vertex.agent.*`, and usage repeated on `call_llm`, both written by the ADK itself | | LangGraph | `langgraph` | gRPC or FastAPI SERVER span | kagent's wrapper, `invoke_agent ` | `opentelemetry-instrumentation-genai-langchain` | None | | CrewAI | `crewai` | gRPC or FastAPI SERVER span | kagent's wrapper, `invoke_agent ` | `opentelemetry-instrumentation-genai-openai` and `-genai-anthropic` | None | diff --git a/go/core/test/e2e/tracing_test.go b/go/core/test/e2e/tracing_test.go index c16c19cb60..30aab98b88 100644 --- a/go/core/test/e2e/tracing_test.go +++ b/go/core/test/e2e/tracing_test.go @@ -249,36 +249,46 @@ func (r *otlpTraceReceiver) clear() { } // diagnostic summarizes only the spans useful for explaining a failed -// assertion: A2A request spans and spans correlated by the injected trace ID. -// Codex can emit hundreds of native spans, so cap the detail included in logs. +// assertion: the spans correlated by the injected trace ID, then the A2A +// request spans of other traces. Codex can emit hundreds of native spans, so +// cap the detail included in logs; the expected trace comes first, so the +// parallel cases' request spans never crowd it out. func (r *otlpTraceReceiver) diagnostic(expectedTraceID []byte) string { r.mu.Lock() defer r.mu.Unlock() const detailLimit = 12 totalSpans := 0 - details := make([]string, 0, detailLimit) + var expected, others []string for _, resourceSpans := range r.records { serviceName := stringAttribute(resourceSpans.GetResource().GetAttributes(), "service.name") for _, scopeSpans := range resourceSpans.GetScopeSpans() { for _, span := range scopeSpans.GetSpans() { totalSpans++ - requestSpan := stringAttribute(span.GetAttributes(), tracing.AttributeMethod) != "" - if len(details) == detailLimit || !bytes.Equal(span.GetTraceId(), expectedTraceID) && !requestSpan { + inTrace := bytes.Equal(span.GetTraceId(), expectedTraceID) + if !inTrace && stringAttribute(span.GetAttributes(), tracing.AttributeMethod) == "" { continue } - details = append(details, fmt.Sprintf( - "service=%q scope=%q name=%q trace_id=%x %s=%q %s=%q", + detail := fmt.Sprintf( + "service=%q scope=%q name=%q trace_id=%x %s=%q %s=%q %s=%q", serviceName, scopeSpans.GetScope().GetName(), span.GetName(), span.GetTraceId(), + tracing.AttributeOperationName, stringAttribute(span.GetAttributes(), tracing.AttributeOperationName), tracing.AttributeMethod, stringAttribute(span.GetAttributes(), tracing.AttributeMethod), tracing.AttributeTaskState, stringAttribute(span.GetAttributes(), tracing.AttributeTaskState), - )) + ) + if inTrace { + expected = append(expected, detail) + } else { + others = append(others, detail) + } } } } + details := append(expected, others...) + details = details[:min(len(details), detailLimit)] return fmt.Sprintf("exports=%d resource_spans=%d total_spans=%d relevant_spans=%v", r.exports, len(r.records), totalSpans, details) } @@ -356,7 +366,7 @@ func TestE2ECompletedChatFlushesTraces(t *testing.T) { modelURL func(*testing.T) string createModel func(*testing.T, string) *v1alpha3.ModelConfig prompt string - assertNative func(*testing.T, *otlpTraceReceiver, []byte) + assertNative func(*testing.T, *otlpTraceReceiver, []byte, capturedSpan) }{ { name: "claude", harness: claudeTracingE2EHarness, templateLabel: "claude-tracing", @@ -368,14 +378,17 @@ func TestE2ECompletedChatFlushesTraces(t *testing.T) { return createClaudeMockModel(t, interactionKubeClient(t), baseURL) }, prompt: "What is 2+2?", - assertNative: func(t *testing.T, receiver *otlpTraceReceiver, traceID []byte) { + // Claude Code's interaction span is its own record of the turn and + // marks itself invoke_agent; it runs under kagent's invocation, + // which it reaches through TRACEPARENT. + assertNative: func(t *testing.T, receiver *otlpTraceReceiver, traceID []byte, invocation capturedSpan) { t.Helper() - for _, captured := range receiver.selectSpans(traceID, "", "", "", nil) { - if strings.HasPrefix(captured.span.GetName(), "claude_code.") { + for _, captured := range receiver.selectSpans(traceID, "", "", "claude_code.interaction", nil) { + if bytes.Equal(captured.span.GetParentSpanId(), invocation.span.GetSpanId()) { return } } - t.Fatal("no claude_code.* span was exported before suspension") + t.Fatalf("no claude_code.interaction span under the invocation was exported before suspension: %s", receiver.diagnostic(traceID)) }, }, { @@ -388,7 +401,7 @@ func TestE2ECompletedChatFlushesTraces(t *testing.T) { return createCodexMockModel(t, interactionKubeClient(t), baseURL) }, prompt: "What is 2+2?", - assertNative: func(t *testing.T, receiver *otlpTraceReceiver, traceID []byte) { + assertNative: func(t *testing.T, receiver *otlpTraceReceiver, traceID []byte, _ capturedSpan) { t.Helper() for _, captured := range receiver.selectSpans(traceID, "", "", "", nil) { if captured.serviceName == "codex-app-server" || strings.Contains(strings.ToLower(captured.scopeName), "codex") { @@ -413,15 +426,20 @@ func TestE2ECompletedChatFlushesTraces(t *testing.T) { } // Wait for the Actor to suspend then assert that the completed invocation span was exported. assertActorSuspended(t, fixture) - // Invocations are selected by operation alone, the way a consumer - // counts them, so a second invoke_agent span in the trace or one - // missing an attribute fails the test rather than going unseen. The - // compiler owns this identity, so the assertion holds without any + // Invocations are selected by operation and segment, the way the + // contract tells a consumer to count turns: a native runtime's own + // invoke_agent span, such as Claude Code's interaction span, carries + // no segment. A second segment in the trace or one missing an + // attribute fails the test rather than going unseen. The compiler + // owns this identity, so the assertion holds without any // user-supplied resource marker on the Harness. agentName := template - spans := receiver.selectSpans(traceID, "", "", "", map[string]string{tracing.AttributeOperationName: tracing.OperationInvokeAgent}) + spans := receiver.selectSpans(traceID, "", "", "", map[string]string{ + tracing.AttributeOperationName: tracing.OperationInvokeAgent, + tracing.AttributeSegment: tracing.SegmentInitial, + }) if len(spans) != 1 { - t.Fatalf("invoke_agent spans = %d, want exactly one before suspension: %s", len(spans), receiver.diagnostic(traceID)) + t.Fatalf("invocation segments = %d, want exactly one before suspension: %s", len(spans), receiver.diagnostic(traceID)) } invocation := spans[0] if got, want := invocation.span.GetName(), "invoke_agent "+agentName; got != want { @@ -510,7 +528,7 @@ func TestE2ECompletedChatFlushesTraces(t *testing.T) { t.Errorf("chat %s = %d (present %v), want %d", key, got, ok, want) } } - test.assertNative(t, receiver, traceID) + test.assertNative(t, receiver, traceID, invocation) }) } } diff --git a/telemetry/registry/signals.yaml b/telemetry/registry/signals.yaml index 8b8ea31dda..e5b2f6f55c 100644 --- a/telemetry/registry/signals.yaml +++ b/telemetry/registry/signals.yaml @@ -253,8 +253,11 @@ span_refinements: brief: One execution segment of a kagent agent. note: > Named `invoke_agent {gen_ai.agent.name}`. kagent opens it only for a - runtime that emits no invoke_agent span of its own. A resumed segment links - to the segment that started the native turn, with + runtime whose own spans carry no invocation segment: a native coding agent + it wraps. Such an agent may still mark a span of its own invoke_agent, as + Claude Code does with `claude_code.interaction`; that span is a child of + this one, so a consumer counts turns by `kagent.invocation.segment`. A + resumed segment links to the segment that started the native turn, with `kagent.invocation.relationship` set to `resume_origin`. Usage and finish reasons are only on inference spans, so they are opt-in here and never set. stability: development diff --git a/telemetry/resolved.yaml b/telemetry/resolved.yaml index c3a759f583..2319da93df 100644 --- a/telemetry/resolved.yaml +++ b/telemetry/resolved.yaml @@ -4769,7 +4769,7 @@ refinements: When `gen_ai.agent.name` is not available, it SHOULD be `invoke_agent`. Semantic conventions for individual GenAI systems and frameworks MAY specify different span name format. note: | - Named `invoke_agent {gen_ai.agent.name}`. kagent opens it only for a runtime that emits no invoke_agent span of its own. A resumed segment links to the segment that started the native turn, with `kagent.invocation.relationship` set to `resume_origin`. Usage and finish reasons are only on inference spans, so they are opt-in here and never set. + Named `invoke_agent {gen_ai.agent.name}`. kagent opens it only for a runtime whose own spans carry no invocation segment: a native coding agent it wraps. Such an agent may still mark a span of its own invoke_agent, as Claude Code does with `claude_code.interaction`; that span is a child of this one, so a consumer counts turns by `kagent.invocation.segment`. A resumed segment links to the segment that started the native turn, with `kagent.invocation.relationship` set to `resume_origin`. Usage and finish reasons are only on inference spans, so they are opt-in here and never set. provenance: path: telemetry/registry/signals.yaml requirement_level: recommended From 23db080164cf469d0a2ea1e1da04f4d64a3efa58 Mon Sep 17 00:00:00 2001 From: QuentinBisson Date: Thu, 22 Oct 2026 18:09:22 +0200 Subject: [PATCH 2/4] docs(telemetry): tie Claude Code's invoke_agent span to the collector's native strip kagent also opens invoke_agent for LangGraph, CrewAI and BYO runtimes, not only native harnesses. Name the existing mechanism that keeps claude_code.interaction from counting as a second invocation: the reference collector removes the GenAI operation from kagent.genai.producer=runtime spans. Signed-off-by: QuentinBisson --- docs/architecture/telemetry-contract.md | 2 +- docs/architecture/telemetry.md | 6 +++--- telemetry/registry/signals.yaml | 9 +++++---- telemetry/resolved.yaml | 2 +- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/architecture/telemetry-contract.md b/docs/architecture/telemetry-contract.md index a66d8aba2c..570cdd1f9a 100644 --- a/docs/architecture/telemetry-contract.md +++ b/docs/architecture/telemetry-contract.md @@ -353,7 +353,7 @@ Refines `gen_ai.client.inference`, kind `client`. One model request of a harness ### `kagent.invoke_agent.internal` -Refines `gen_ai.invoke_agent.internal`, kind `internal`. One execution segment of a kagent agent. Named `invoke_agent {gen_ai.agent.name}`. kagent opens it only for a runtime whose own spans carry no invocation segment: a native coding agent it wraps. Such an agent may still mark a span of its own invoke_agent, as Claude Code does with `claude_code.interaction`; that span is a child of this one, so a consumer counts turns by `kagent.invocation.segment`. A resumed segment links to the segment that started the native turn, with `kagent.invocation.relationship` set to `resume_origin`. Usage and finish reasons are only on inference spans, so they are opt-in here and never set. +Refines `gen_ai.invoke_agent.internal`, kind `internal`. One execution segment of a kagent agent. Named `invoke_agent {gen_ai.agent.name}`. kagent opens it only for a runtime that emits no invoke_agent span of its own, or a native harness whose GenAI spans the kagent harness runtime writes. Claude Code still marks its `claude_code.interaction` span invoke_agent, as a child of this one; the reference collector removes that operation from native spans, and a consumer counts turns by `kagent.invocation.segment` either way. A resumed segment links to the segment that started the native turn, with `kagent.invocation.relationship` set to `resume_origin`. Usage and finish reasons are only on inference spans, so they are opt-in here and never set. | Attribute | Requirement | Note | | --- | --- | --- | diff --git a/docs/architecture/telemetry.md b/docs/architecture/telemetry.md index 197404ea3c..ace021b94c 100644 --- a/docs/architecture/telemetry.md +++ b/docs/architecture/telemetry.md @@ -13,7 +13,7 @@ The contract is a Weaver registry in [`telemetry/registry`](../../telemetry/regi | Concept | Attributes | Where | | --- | --- | --- | -| Operation | `gen_ai.operation.name` | A span is a GenAI operation when it carries this. kagent writes `invoke_agent` only where the runtime's own spans carry no invocation segment. Count turns by `kagent.invocation.segment` | +| Operation | `gen_ai.operation.name` | A span is a GenAI operation when it carries this. kagent writes `invoke_agent` only where the runtime emits none of its own or the kagent harness runtime writes its GenAI spans. Count turns by `kagent.invocation.segment` | | Runtime | `kagent.runtime` | Resource | | Agent | `gen_ai.agent.name`, `gen_ai.agent.id` | invoke_agent and request spans. The resource carries the same identity as `gen_ai.main_agent.name` and `gen_ai.main_agent.id` | | Provider and model | `gen_ai.provider.name`, `gen_ai.request.model` | Resource and invoke_agent span, for harnesses compiled against one model | @@ -181,7 +181,7 @@ The registry depends on the core semantic conventions at v1.44.0 and on the GenA Each A2A `SendMessage` or `SendStreamingMessage` request opens one span in the instrumentation scope `github.com/kagent-dev/kagent/go/adk/pkg/a2a/server`. It represents one execution segment, and what it is called depends on whether anything beneath it describes the agent invocation. -For a native harness, Claude Code or Codex, nothing does: the native runtime emits no agent invocation, and kagent's harness runtime writes the model and tool spans. The request span is therefore the GenAI conventions' `invoke_agent` operation, named `invoke_agent `, and it is the anchor consumers should read. +For a native harness, Claude Code or Codex, kagent's harness runtime writes the GenAI spans in the native runtime's place. The request span is therefore the GenAI conventions' `invoke_agent` operation, named `invoke_agent `, and it is the anchor consumers should read. Claude Code still marks its `claude_code.interaction` span `invoke_agent`, as a child of the request span; the reference collector removes that operation with the native runtime's other GenAI attributes. For the ADK runtime the ADK emits `invoke_agent` spans of its own, carrying `gen_ai.agent.name` and `gen_ai.conversation.id`: one for the root agent and one for each sub-agent it transfers to within the turn. The root one is the invocation. The request span stays a transport span named `a2a.request` with the same identity attributes and no `gen_ai.operation.name`, so the wrapper never adds an invocation of its own to what the ADK reports. A consumer that counts turns rather than agent invocations counts `kagent.invocation.segment`, which only the request span carries, whichever runtime produced the trace. @@ -268,7 +268,7 @@ CI runs it in the e2e job and uploads the report with the e2e logs. It reports o - associates Kubernetes metadata by `k8s.pod.uid` only, which only the controller sets. A runtime on Substrate runs inside a worker pod, so a connection IP would name the worker; - deletes `gen_ai.usage.*` from the Python ADK's `call_llm` spans, so usage is counted once per model call; -- deletes `gen_ai.operation.name`, `gen_ai.system`, `gen_ai.request.model`, `gen_ai.response.finish_reasons` and `gen_ai.usage.*` from the native Claude Code and Codex spans, which carry `kagent.genai.producer=runtime`. The harness runtime writes their model and tool spans, so each model call is counted once. A standalone Claude request keeps them, since the runtime writes no span for it; +- deletes `gen_ai.operation.name`, `gen_ai.system`, `gen_ai.request.model`, `gen_ai.response.finish_reasons` and `gen_ai.usage.*` from the native Claude Code and Codex spans, which carry `kagent.genai.producer=runtime`. The harness runtime writes their model and tool spans, so each model call is counted once, and Claude Code's `claude_code.interaction` span does not count as a second invocation. A standalone Claude request keeps them, since the runtime writes no span for it; - deletes captured content and the Go ADK's legacy payload keys, as a backstop for an installation with capture off. Remove `transform/genai_content` from the pipelines when capture is on; - derives `gen_ai.invoke_agent.duration` from invoke_agent spans that kagent did not open, before any sampling; - exports over gzip OTLP with a `file_storage` sending queue, so a collector restart keeps what it has accepted. diff --git a/telemetry/registry/signals.yaml b/telemetry/registry/signals.yaml index e5b2f6f55c..7d720c8027 100644 --- a/telemetry/registry/signals.yaml +++ b/telemetry/registry/signals.yaml @@ -253,10 +253,11 @@ span_refinements: brief: One execution segment of a kagent agent. note: > Named `invoke_agent {gen_ai.agent.name}`. kagent opens it only for a - runtime whose own spans carry no invocation segment: a native coding agent - it wraps. Such an agent may still mark a span of its own invoke_agent, as - Claude Code does with `claude_code.interaction`; that span is a child of - this one, so a consumer counts turns by `kagent.invocation.segment`. A + runtime that emits no invoke_agent span of its own, or a native harness + whose GenAI spans the kagent harness runtime writes. Claude Code still + marks its `claude_code.interaction` span invoke_agent, as a child of this + one; the reference collector removes that operation from native spans, and + a consumer counts turns by `kagent.invocation.segment` either way. A resumed segment links to the segment that started the native turn, with `kagent.invocation.relationship` set to `resume_origin`. Usage and finish reasons are only on inference spans, so they are opt-in here and never set. diff --git a/telemetry/resolved.yaml b/telemetry/resolved.yaml index 2319da93df..2ceee87f12 100644 --- a/telemetry/resolved.yaml +++ b/telemetry/resolved.yaml @@ -4769,7 +4769,7 @@ refinements: When `gen_ai.agent.name` is not available, it SHOULD be `invoke_agent`. Semantic conventions for individual GenAI systems and frameworks MAY specify different span name format. note: | - Named `invoke_agent {gen_ai.agent.name}`. kagent opens it only for a runtime whose own spans carry no invocation segment: a native coding agent it wraps. Such an agent may still mark a span of its own invoke_agent, as Claude Code does with `claude_code.interaction`; that span is a child of this one, so a consumer counts turns by `kagent.invocation.segment`. A resumed segment links to the segment that started the native turn, with `kagent.invocation.relationship` set to `resume_origin`. Usage and finish reasons are only on inference spans, so they are opt-in here and never set. + Named `invoke_agent {gen_ai.agent.name}`. kagent opens it only for a runtime that emits no invoke_agent span of its own, or a native harness whose GenAI spans the kagent harness runtime writes. Claude Code still marks its `claude_code.interaction` span invoke_agent, as a child of this one; the reference collector removes that operation from native spans, and a consumer counts turns by `kagent.invocation.segment` either way. A resumed segment links to the segment that started the native turn, with `kagent.invocation.relationship` set to `resume_origin`. Usage and finish reasons are only on inference spans, so they are opt-in here and never set. provenance: path: telemetry/registry/signals.yaml requirement_level: recommended From 3ecc6cec49da7c932fc96d591f7a26084cbc72b2 Mon Sep 17 00:00:00 2001 From: QuentinBisson Date: Sat, 24 Oct 2026 02:39:38 +0200 Subject: [PATCH 3/4] test(telemetry): fail on any invoke_agent span the reference collector keeps Selecting the invocation by segment let a second invoke_agent span pass unseen. Select every invoke_agent span outside the native harness process, whose GenAI operation the reference collector removes by kagent.genai.producer=runtime, require exactly one, and require its segment. Signed-off-by: QuentinBisson --- go/core/test/e2e/tracing_test.go | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/go/core/test/e2e/tracing_test.go b/go/core/test/e2e/tracing_test.go index 30aab98b88..457b495bdb 100644 --- a/go/core/test/e2e/tracing_test.go +++ b/go/core/test/e2e/tracing_test.go @@ -426,22 +426,28 @@ func TestE2ECompletedChatFlushesTraces(t *testing.T) { } // Wait for the Actor to suspend then assert that the completed invocation span was exported. assertActorSuspended(t, fixture) - // Invocations are selected by operation and segment, the way the - // contract tells a consumer to count turns: a native runtime's own - // invoke_agent span, such as Claude Code's interaction span, carries - // no segment. A second segment in the trace or one missing an - // attribute fails the test rather than going unseen. The compiler - // owns this identity, so the assertion holds without any + // Invocations are counted the way the reference collector leaves + // them: it removes the GenAI operation from the spans of a native + // harness process, such as Claude Code's interaction span, which + // carry kagent.genai.producer=runtime on their resource. Any other + // invoke_agent span in the trace, or one missing its segment or + // another attribute, fails the test rather than going unseen. The + // compiler owns this identity, so the assertion holds without any // user-supplied resource marker on the Harness. agentName := template - spans := receiver.selectSpans(traceID, "", "", "", map[string]string{ - tracing.AttributeOperationName: tracing.OperationInvokeAgent, - tracing.AttributeSegment: tracing.SegmentInitial, - }) + var spans []capturedSpan + for _, captured := range receiver.selectSpans(traceID, "", "", "", map[string]string{tracing.AttributeOperationName: tracing.OperationInvokeAgent}) { + if stringAttribute(captured.resource.GetAttributes(), tracing.AttributeGenAIProducer) != conv.KagentGenaiProducerRuntime { + spans = append(spans, captured) + } + } if len(spans) != 1 { - t.Fatalf("invocation segments = %d, want exactly one before suspension: %s", len(spans), receiver.diagnostic(traceID)) + t.Fatalf("invoke_agent spans outside the native process = %d, want exactly one before suspension: %s", len(spans), receiver.diagnostic(traceID)) } invocation := spans[0] + if got := stringAttribute(invocation.span.GetAttributes(), tracing.AttributeSegment); got != tracing.SegmentInitial { + t.Errorf("invocation %s = %q, want %q", tracing.AttributeSegment, got, tracing.SegmentInitial) + } if got, want := invocation.span.GetName(), "invoke_agent "+agentName; got != want { t.Errorf("invocation span name = %q, want %q", got, want) } From f415f0f955c1b76158edecc9aec2b4150c9db73a Mon Sep 17 00:00:00 2001 From: QuentinBisson Date: Thu, 29 Oct 2026 11:33:40 +0100 Subject: [PATCH 4/4] test(telemetry): drop the duplicate segment assertion Signed-off-by: QuentinBisson --- go/core/test/e2e/tracing_test.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/go/core/test/e2e/tracing_test.go b/go/core/test/e2e/tracing_test.go index 457b495bdb..c235cd112f 100644 --- a/go/core/test/e2e/tracing_test.go +++ b/go/core/test/e2e/tracing_test.go @@ -445,9 +445,6 @@ func TestE2ECompletedChatFlushesTraces(t *testing.T) { t.Fatalf("invoke_agent spans outside the native process = %d, want exactly one before suspension: %s", len(spans), receiver.diagnostic(traceID)) } invocation := spans[0] - if got := stringAttribute(invocation.span.GetAttributes(), tracing.AttributeSegment); got != tracing.SegmentInitial { - t.Errorf("invocation %s = %q, want %q", tracing.AttributeSegment, got, tracing.SegmentInitial) - } if got, want := invocation.span.GetName(), "invoke_agent "+agentName; got != want { t.Errorf("invocation span name = %q, want %q", got, want) }