Skip to content

Commit dc2ee00

Browse files
lmolkovaCopilot
andauthored
Fix execute_tool span kind (#274)
* Emit execute_tool spans with INTERNAL span kind execute_tool runs in-process, so its span must be INTERNAL, not CLIENT. ToolInvocation was inheriting the CLIENT default from GenAIInvocation. Also add a rego live-check rule (genai_span_kind_unexpected) that pins the expected span kind per gen_ai.operation.name, which surfaces this class of bug. * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Rename changelog fragment to PR number and fix import order Renames the changelog fragment to 274.fixed to satisfy the changelog CI check, and sorts imports in test_toolcall.py to fix the ruff lint gate. Assisted-by: Claude Opus 4.8 --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 1e27b90 commit dc2ee00

4 files changed

Lines changed: 49 additions & 1 deletion

File tree

policies/genai_span_validation.rego

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,43 @@ deny contains _span_finding(
215215
not _has_attr(input.sample.span, attr_name)
216216
}
217217

218+
# ─── Per-operation span kind (violation) ────────────────────────────────────
219+
#
220+
# Semconv pins the span kind for each operation. `_expected_kinds_for_op`
221+
# returns the set of kinds semconv allows; a span whose kind is outside that
222+
# set is flagged. Single-element sets are the unambiguous cases (inference and
223+
# embeddings are remote calls → CLIENT; tool execution runs in-process →
224+
# INTERNAL). `invoke_agent` / `create_agent` may be same-process or remote, so
225+
# both kinds are allowed. Undefined (→ no violation) for unmapped ops.
226+
_expected_kinds_for_op["chat"] := {"client"}
227+
_expected_kinds_for_op["generate_content"] := {"client"}
228+
_expected_kinds_for_op["text_completion"] := {"client"}
229+
_expected_kinds_for_op["embeddings"] := {"client"}
230+
_expected_kinds_for_op["execute_tool"] := {"internal"}
231+
_expected_kinds_for_op["invoke_workflow"] := {"internal"}
232+
_expected_kinds_for_op["retrieval"] := {"client"}
233+
_expected_kinds_for_op["invoke_agent"] := {"internal", "client"}
234+
_expected_kinds_for_op["create_agent"] := {"internal", "client"}
235+
236+
deny contains _span_finding(
237+
"genai_span_kind_unexpected",
238+
"violation",
239+
input.sample.span,
240+
{
241+
"operation": op,
242+
"kind": input.sample.span.kind,
243+
},
244+
sprintf(
245+
"Span '%v' (operation '%v') has kind '%v'; semconv expects one of %v",
246+
[input.sample.span.name, op, input.sample.span.kind, sort([k | expected_kinds[k]])],
247+
),
248+
) if {
249+
input.sample.span
250+
op := _attr_value(input.sample.span, "gen_ai.operation.name")
251+
expected_kinds := _expected_kinds_for_op[op]
252+
not expected_kinds[input.sample.span.kind]
253+
}
254+
218255
# ─── Unknown gen_ai.operation.name (violation) ──────────────────────────────
219256
#
220257
# Weaver's built-in `undefined_enum_variant` advice is `information`-level;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Emit ``execute_tool`` spans with ``INTERNAL`` span kind instead of ``CLIENT``.

util/opentelemetry-util-genai/src/opentelemetry/util/genai/_tool_invocation.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from opentelemetry.semconv._incubating.attributes import (
88
gen_ai_attributes as GenAI,
99
)
10-
from opentelemetry.trace import Tracer
10+
from opentelemetry.trace import SpanKind, Tracer
1111
from opentelemetry.util.genai._invocation import Error, GenAIInvocation
1212
from opentelemetry.util.genai.completion_hook import CompletionHook
1313
from opentelemetry.util.genai.metrics import InvocationMetricsRecorder
@@ -71,6 +71,7 @@ def __init__(
7171
completion_hook,
7272
operation_name=_operation_name,
7373
span_name=f"{_operation_name} {name}" if name else _operation_name,
74+
span_kind=SpanKind.INTERNAL,
7475
)
7576
self.should_capture_content_on_span = should_capture_content_on_spans()
7677
self.name = name

util/opentelemetry-util-genai/tests/test_toolcall.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from opentelemetry.semconv._incubating.attributes import (
1818
gen_ai_attributes as GenAI,
1919
)
20+
from opentelemetry.trace import SpanKind
2021
from opentelemetry.util.genai.environment_variables import (
2122
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT,
2223
)
@@ -143,6 +144,14 @@ def test_server_tool_call_in_message():
143144
pytest.main([__file__, "-v"])
144145

145146

147+
def test_tool_span_is_internal_kind():
148+
"""execute_tool runs in-process, so its span must be INTERNAL, not CLIENT."""
149+
span_exporter, handler = _make_span_exporter_and_handler()
150+
handler.tool("get_weather").stop()
151+
152+
assert span_exporter.get_finished_spans()[0].kind == SpanKind.INTERNAL
153+
154+
146155
def test_start_tool_passes_sampling_attributes_at_span_creation():
147156
"""Verify that sampling-relevant attributes are available at start_span() time for tools."""
148157
captured_attributes = {}

0 commit comments

Comments
 (0)