Skip to content

Commit 0f91669

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

5 files changed

Lines changed: 181 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: 90 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
@@ -208,6 +217,85 @@ def test_tool_definitions_type(self):
208217
assert invocation.tool_definitions[0].name == "get_weather"
209218
assert invocation.tool_definitions[0].type == "function"
210219

220+
@patch(
221+
"opentelemetry.util.genai._invocation.get_content_capturing_mode",
222+
return_value=ContentCapturingMode.SPAN_AND_EVENT,
223+
)
224+
@patch(
225+
"opentelemetry.util.genai._invocation.is_experimental_mode",
226+
return_value=True,
227+
)
228+
def test_system_instruction_on_span(self, _mock_exp, _mock_cap):
229+
invocation = self.handler.start_invoke_local_agent("openai")
230+
invocation.system_instruction = [
231+
Text(content="You are a helpful assistant."),
232+
]
233+
invocation.stop()
234+
235+
attrs = self.span_exporter.get_finished_spans()[0].attributes
236+
assert GenAI.GEN_AI_SYSTEM_INSTRUCTIONS in attrs
237+
238+
@patch(
239+
"opentelemetry.util.genai._invocation.get_content_capturing_mode",
240+
return_value=ContentCapturingMode.SPAN_AND_EVENT,
241+
)
242+
@patch(
243+
"opentelemetry.util.genai._invocation.is_experimental_mode",
244+
return_value=True,
245+
)
246+
def test_tool_definitions_on_span(self, _mock_exp, _mock_cap):
247+
tool = FunctionToolDefinition(
248+
name="get_weather",
249+
description="Get the weather",
250+
parameters={"type": "object", "properties": {}},
251+
)
252+
invocation = self.handler.start_invoke_local_agent("openai")
253+
invocation.tool_definitions = [tool]
254+
invocation.stop()
255+
256+
attrs = self.span_exporter.get_finished_spans()[0].attributes
257+
assert GenAI.GEN_AI_TOOL_DEFINITIONS in attrs
258+
259+
@patch(
260+
"opentelemetry.util.genai._invocation.get_content_capturing_mode",
261+
return_value=ContentCapturingMode.SPAN_AND_EVENT,
262+
)
263+
@patch(
264+
"opentelemetry.util.genai._invocation.is_experimental_mode",
265+
return_value=True,
266+
)
267+
def test_messages_on_span(self, _mock_exp, _mock_cap):
268+
invocation = self.handler.start_invoke_local_agent("openai")
269+
invocation.input_messages = [
270+
InputMessage(role="user", parts=[Text(content="Hello")])
271+
]
272+
invocation.output_messages = [
273+
OutputMessage(
274+
role="assistant",
275+
parts=[Text(content="Hi!")],
276+
finish_reason="stop",
277+
)
278+
]
279+
invocation.stop()
280+
281+
attrs = self.span_exporter.get_finished_spans()[0].attributes
282+
assert GenAI.GEN_AI_INPUT_MESSAGES in attrs
283+
assert GenAI.GEN_AI_OUTPUT_MESSAGES in attrs
284+
285+
def test_content_not_on_span_by_default(self):
286+
invocation = self.handler.start_invoke_local_agent("openai")
287+
invocation.system_instruction = [
288+
Text(content="You are a helpful assistant."),
289+
]
290+
invocation.input_messages = [
291+
InputMessage(role="user", parts=[Text(content="Hello")])
292+
]
293+
invocation.stop()
294+
295+
attrs = self.span_exporter.get_finished_spans()[0].attributes
296+
assert GenAI.GEN_AI_SYSTEM_INSTRUCTIONS not in attrs
297+
assert GenAI.GEN_AI_INPUT_MESSAGES not in attrs
298+
211299
def test_default_lists_are_independent(self):
212300
inv1 = self.handler.start_invoke_local_agent("openai")
213301
inv2 = self.handler.start_invoke_local_agent("openai")

0 commit comments

Comments
 (0)