Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions policies/genai_span_validation.rego
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,43 @@ deny contains _span_finding(
not _has_attr(input.sample.span, attr_name)
}

# ─── Per-operation span kind (violation) ────────────────────────────────────
#
# Semconv pins the span kind for each operation. `_expected_kinds_for_op`
# returns the set of kinds semconv allows; a span whose kind is outside that
# set is flagged. Single-element sets are the unambiguous cases (inference and
# embeddings are remote calls → CLIENT; tool execution runs in-process →
# INTERNAL). `invoke_agent` / `create_agent` may be same-process or remote, so
# both kinds are allowed. Undefined (→ no violation) for unmapped ops.
_expected_kinds_for_op["chat"] := {"client"}
_expected_kinds_for_op["generate_content"] := {"client"}
_expected_kinds_for_op["text_completion"] := {"client"}
_expected_kinds_for_op["embeddings"] := {"client"}
_expected_kinds_for_op["execute_tool"] := {"internal"}
_expected_kinds_for_op["invoke_workflow"] := {"internal"}
_expected_kinds_for_op["retrieval"] := {"client"}
_expected_kinds_for_op["invoke_agent"] := {"internal", "client"}
_expected_kinds_for_op["create_agent"] := {"internal", "client"}

deny contains _span_finding(
"genai_span_kind_unexpected",
"violation",
input.sample.span,
{
"operation": op,
"kind": input.sample.span.kind,
},
sprintf(
"Span '%v' (operation '%v') has kind '%v'; semconv expects one of %v",
[input.sample.span.name, op, input.sample.span.kind, sort([k | expected_kinds[k]])],
),
) if {
input.sample.span
op := _attr_value(input.sample.span, "gen_ai.operation.name")
expected_kinds := _expected_kinds_for_op[op]
not expected_kinds[input.sample.span.kind]
}

# ─── Unknown gen_ai.operation.name (violation) ──────────────────────────────
#
# Weaver's built-in `undefined_enum_variant` advice is `information`-level;
Expand Down
1 change: 1 addition & 0 deletions util/opentelemetry-util-genai/.changelog/274.fixed
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Emit ``execute_tool`` spans with ``INTERNAL`` span kind instead of ``CLIENT``.
Comment thread
lmolkova marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from opentelemetry.semconv._incubating.attributes import (
gen_ai_attributes as GenAI,
)
from opentelemetry.trace import Tracer
from opentelemetry.trace import SpanKind, Tracer
from opentelemetry.util.genai._invocation import Error, GenAIInvocation
from opentelemetry.util.genai.completion_hook import CompletionHook
from opentelemetry.util.genai.metrics import InvocationMetricsRecorder
Expand Down Expand Up @@ -71,6 +71,7 @@ def __init__(
completion_hook,
operation_name=_operation_name,
span_name=f"{_operation_name} {name}" if name else _operation_name,
span_kind=SpanKind.INTERNAL,
)
self.should_capture_content_on_span = should_capture_content_on_spans()
self.name = name
Expand Down
9 changes: 9 additions & 0 deletions util/opentelemetry-util-genai/tests/test_toolcall.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from opentelemetry.semconv._incubating.attributes import (
gen_ai_attributes as GenAI,
)
from opentelemetry.trace import SpanKind
from opentelemetry.util.genai.environment_variables import (
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT,
)
Expand Down Expand Up @@ -143,6 +144,14 @@ def test_server_tool_call_in_message():
pytest.main([__file__, "-v"])


def test_tool_span_is_internal_kind():
"""execute_tool runs in-process, so its span must be INTERNAL, not CLIENT."""
span_exporter, handler = _make_span_exporter_and_handler()
handler.tool("get_weather").stop()

assert span_exporter.get_finished_spans()[0].kind == SpanKind.INTERNAL


def test_start_tool_passes_sampling_attributes_at_span_creation():
"""Verify that sampling-relevant attributes are available at start_span() time for tools."""
captured_attributes = {}
Expand Down