Skip to content

Commit a8f1839

Browse files
committed
fix(genai-utils): move get_content_attributes to _invocation.py, add test coverage
1 parent fd08416 commit a8f1839

5 files changed

Lines changed: 191 additions & 112 deletions

File tree

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,11 @@
2222
)
2323
from opentelemetry.semconv.attributes import server_attributes
2424
from opentelemetry.trace import SpanKind, Tracer
25-
from opentelemetry.util.genai._content import _get_content_attributes
26-
from opentelemetry.util.genai._invocation import Error, GenAIInvocation
25+
from opentelemetry.util.genai._invocation import (
26+
Error,
27+
GenAIInvocation,
28+
get_content_attributes,
29+
)
2730
from opentelemetry.util.genai.metrics import InvocationMetricsRecorder
2831
from opentelemetry.util.genai.types import (
2932
InputMessage,
@@ -154,7 +157,7 @@ def _get_usage_attributes(self) -> dict[str, Any]:
154157
return {k: v for k, v in optional_attrs if v is not None}
155158

156159
def _get_content_attributes_for_span(self) -> dict[str, Any]:
157-
return _get_content_attributes(
160+
return get_content_attributes(
158161
input_messages=self.input_messages,
159162
output_messages=self.output_messages,
160163
system_instruction=self.system_instruction,

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

Lines changed: 0 additions & 102 deletions
This file was deleted.

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,11 @@
2323
)
2424
from opentelemetry.semconv.attributes import server_attributes
2525
from opentelemetry.trace import INVALID_SPAN, Span, SpanKind, Tracer
26-
from opentelemetry.util.genai._content import _get_content_attributes
27-
from opentelemetry.util.genai._invocation import Error, GenAIInvocation
26+
from opentelemetry.util.genai._invocation import (
27+
Error,
28+
GenAIInvocation,
29+
get_content_attributes,
30+
)
2831
from opentelemetry.util.genai.metrics import InvocationMetricsRecorder
2932
from opentelemetry.util.genai.types import (
3033
InputMessage,
@@ -118,7 +121,7 @@ def __init__( # pylint: disable=too-many-locals
118121
self._start()
119122

120123
def _get_message_attributes(self, *, for_span: bool) -> dict[str, Any]:
121-
return _get_content_attributes(
124+
return get_content_attributes(
122125
input_messages=self.input_messages,
123126
output_messages=self.output_messages,
124127
system_instruction=self.system_instruction,

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

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,33 @@
1818
from abc import ABC, abstractmethod
1919
from contextlib import contextmanager
2020
from contextvars import Token
21-
from typing import TYPE_CHECKING, Any, Iterator
21+
from dataclasses import asdict
22+
from typing import TYPE_CHECKING, Any, Iterator, Sequence
2223

2324
from typing_extensions import Self, TypeAlias
2425

2526
from opentelemetry._logs import Logger
2627
from opentelemetry.context import Context, attach, detach
28+
from opentelemetry.semconv._incubating.attributes import (
29+
gen_ai_attributes as GenAI,
30+
)
2731
from opentelemetry.semconv.attributes import error_attributes
2832
from opentelemetry.trace import INVALID_SPAN as _INVALID_SPAN
2933
from opentelemetry.trace import Span, SpanKind, Tracer, set_span_in_context
3034
from opentelemetry.trace.status import Status, StatusCode
31-
from opentelemetry.util.genai.types import Error
35+
from opentelemetry.util.genai.types import (
36+
Error,
37+
InputMessage,
38+
MessagePart,
39+
OutputMessage,
40+
ToolDefinition,
41+
)
42+
from opentelemetry.util.genai.utils import (
43+
ContentCapturingMode,
44+
gen_ai_json_dumps,
45+
get_content_capturing_mode,
46+
is_experimental_mode,
47+
)
3248

3349
if TYPE_CHECKING:
3450
from opentelemetry.util.genai.metrics import InvocationMetricsRecorder
@@ -138,3 +154,64 @@ def _managed(self) -> Iterator[Self]:
138154
self.fail(exc)
139155
raise
140156
self.stop()
157+
158+
159+
def get_content_attributes(
160+
*,
161+
input_messages: Sequence[InputMessage],
162+
output_messages: Sequence[OutputMessage],
163+
system_instruction: Sequence[MessagePart],
164+
tool_definitions: Sequence[ToolDefinition] | None,
165+
for_span: bool,
166+
) -> dict[str, Any]:
167+
"""Serialize messages, system instructions, and tool definitions into attributes.
168+
169+
Args:
170+
input_messages: Input messages to serialize.
171+
output_messages: Output messages to serialize.
172+
system_instruction: System instructions to serialize.
173+
tool_definitions: Tool definitions to serialize (may be None).
174+
for_span: If True, serialize for span attributes (JSON string);
175+
if False, serialize for event attributes (list of dicts).
176+
"""
177+
if not is_experimental_mode():
178+
return {}
179+
180+
mode = get_content_capturing_mode()
181+
allowed_modes = (
182+
(
183+
ContentCapturingMode.SPAN_ONLY,
184+
ContentCapturingMode.SPAN_AND_EVENT,
185+
)
186+
if for_span
187+
else (
188+
ContentCapturingMode.EVENT_ONLY,
189+
ContentCapturingMode.SPAN_AND_EVENT,
190+
)
191+
)
192+
if mode not in allowed_modes:
193+
return {}
194+
195+
def serialize(items: Sequence[Any]) -> Any:
196+
dicts = [asdict(item) for item in items]
197+
return gen_ai_json_dumps(dicts) if for_span else dicts
198+
199+
optional_attrs = (
200+
(
201+
GenAI.GEN_AI_INPUT_MESSAGES,
202+
serialize(input_messages) if input_messages else None,
203+
),
204+
(
205+
GenAI.GEN_AI_OUTPUT_MESSAGES,
206+
serialize(output_messages) if output_messages else None,
207+
),
208+
(
209+
GenAI.GEN_AI_SYSTEM_INSTRUCTIONS,
210+
serialize(system_instruction) if system_instruction else None,
211+
),
212+
(
213+
GenAI.GEN_AI_TOOL_DEFINITIONS,
214+
serialize(tool_definitions) if tool_definitions else None,
215+
),
216+
)
217+
return {key: value for key, value in optional_attrs if value is not None}

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

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from opentelemetry.trace import INVALID_SPAN, SpanKind
1717
from opentelemetry.util.genai.handler import TelemetryHandler
1818
from opentelemetry.util.genai.types import (
19+
ContentCapturingMode,
1920
Error,
2021
FunctionToolDefinition,
2122
InputMessage,
@@ -79,8 +80,12 @@ def test_all_attributes(self):
7980
invocation.output_type = "text"
8081
invocation.temperature = 0.7
8182
invocation.top_p = 0.9
83+
invocation.frequency_penalty = 0.5
84+
invocation.presence_penalty = 0.3
8285
invocation.max_tokens = 1000
86+
invocation.stop_sequences = ["END", "STOP"]
8387
invocation.seed = 42
88+
invocation.choice_count = 3
8489
invocation.input_tokens = 100
8590
invocation.output_tokens = 200
8691
invocation.stop()
@@ -97,8 +102,12 @@ def test_all_attributes(self):
97102
assert attrs[GenAI.GEN_AI_OUTPUT_TYPE] == "text"
98103
assert attrs[GenAI.GEN_AI_REQUEST_TEMPERATURE] == 0.7
99104
assert attrs[GenAI.GEN_AI_REQUEST_TOP_P] == 0.9
105+
assert attrs[GenAI.GEN_AI_REQUEST_FREQUENCY_PENALTY] == 0.5
106+
assert attrs[GenAI.GEN_AI_REQUEST_PRESENCE_PENALTY] == 0.3
100107
assert attrs[GenAI.GEN_AI_REQUEST_MAX_TOKENS] == 1000
108+
assert attrs[GenAI.GEN_AI_REQUEST_STOP_SEQUENCES] == ("END", "STOP")
101109
assert attrs[GenAI.GEN_AI_REQUEST_SEED] == 42
110+
assert attrs[GenAI.GEN_AI_REQUEST_CHOICE_COUNT] == 3
102111

103112
def test_no_response_model_or_finish_reasons(self):
104113
invocation = self.handler.start_invoke_local_agent("openai")
@@ -164,8 +173,8 @@ def test_default_values(self):
164173
assert invocation.agent_name is None
165174
assert invocation.provider == "openai"
166175
assert invocation.request_model is None
167-
assert invocation.input_messages == []
168-
assert invocation.output_messages == []
176+
assert not invocation.input_messages
177+
assert not invocation.output_messages
169178
assert invocation.tool_definitions is None
170179
assert invocation.cache_creation_input_tokens is None
171180
assert invocation.cache_read_input_tokens is None
@@ -239,6 +248,95 @@ def test_provider_always_set(self):
239248
assert attrs[GenAI.GEN_AI_PROVIDER_NAME] == "gcp_vertex_ai"
240249

241250

251+
class TestAgentInvocationContent(unittest.TestCase):
252+
def setUp(self):
253+
self.span_exporter = InMemorySpanExporter()
254+
tracer_provider = TracerProvider()
255+
tracer_provider.add_span_processor(
256+
SimpleSpanProcessor(self.span_exporter)
257+
)
258+
self.handler = TelemetryHandler(tracer_provider=tracer_provider)
259+
260+
@patch(
261+
"opentelemetry.util.genai._invocation.get_content_capturing_mode",
262+
return_value=ContentCapturingMode.SPAN_AND_EVENT,
263+
)
264+
@patch(
265+
"opentelemetry.util.genai._invocation.is_experimental_mode",
266+
return_value=True,
267+
)
268+
def test_system_instruction_on_span(self, _mock_exp, _mock_cap):
269+
invocation = self.handler.start_invoke_local_agent("openai")
270+
invocation.system_instruction = [
271+
Text(content="You are a helpful assistant."),
272+
]
273+
invocation.stop()
274+
275+
attrs = self.span_exporter.get_finished_spans()[0].attributes
276+
assert GenAI.GEN_AI_SYSTEM_INSTRUCTIONS in attrs
277+
278+
@patch(
279+
"opentelemetry.util.genai._invocation.get_content_capturing_mode",
280+
return_value=ContentCapturingMode.SPAN_AND_EVENT,
281+
)
282+
@patch(
283+
"opentelemetry.util.genai._invocation.is_experimental_mode",
284+
return_value=True,
285+
)
286+
def test_tool_definitions_on_span(self, _mock_exp, _mock_cap):
287+
tool = FunctionToolDefinition(
288+
name="get_weather",
289+
description="Get the weather",
290+
parameters={"type": "object", "properties": {}},
291+
)
292+
invocation = self.handler.start_invoke_local_agent("openai")
293+
invocation.tool_definitions = [tool]
294+
invocation.stop()
295+
296+
attrs = self.span_exporter.get_finished_spans()[0].attributes
297+
assert GenAI.GEN_AI_TOOL_DEFINITIONS in attrs
298+
299+
@patch(
300+
"opentelemetry.util.genai._invocation.get_content_capturing_mode",
301+
return_value=ContentCapturingMode.SPAN_AND_EVENT,
302+
)
303+
@patch(
304+
"opentelemetry.util.genai._invocation.is_experimental_mode",
305+
return_value=True,
306+
)
307+
def test_messages_on_span(self, _mock_exp, _mock_cap):
308+
invocation = self.handler.start_invoke_local_agent("openai")
309+
invocation.input_messages = [
310+
InputMessage(role="user", parts=[Text(content="Hello")])
311+
]
312+
invocation.output_messages = [
313+
OutputMessage(
314+
role="assistant",
315+
parts=[Text(content="Hi!")],
316+
finish_reason="stop",
317+
)
318+
]
319+
invocation.stop()
320+
321+
attrs = self.span_exporter.get_finished_spans()[0].attributes
322+
assert GenAI.GEN_AI_INPUT_MESSAGES in attrs
323+
assert GenAI.GEN_AI_OUTPUT_MESSAGES in attrs
324+
325+
def test_content_not_on_span_by_default(self):
326+
invocation = self.handler.start_invoke_local_agent("openai")
327+
invocation.system_instruction = [
328+
Text(content="You are a helpful assistant."),
329+
]
330+
invocation.input_messages = [
331+
InputMessage(role="user", parts=[Text(content="Hello")])
332+
]
333+
invocation.stop()
334+
335+
attrs = self.span_exporter.get_finished_spans()[0].attributes
336+
assert GenAI.GEN_AI_SYSTEM_INSTRUCTIONS not in attrs
337+
assert GenAI.GEN_AI_INPUT_MESSAGES not in attrs
338+
339+
242340
class TestRemoteAgentInvocation(unittest.TestCase):
243341
def setUp(self):
244342
self.span_exporter = InMemorySpanExporter()

0 commit comments

Comments
 (0)