Skip to content

Commit ded835f

Browse files
committed
WIP squash me or drop, get pydantic check working
1 parent 93ec283 commit ded835f

File tree

5 files changed

+105
-28
lines changed

5 files changed

+105
-28
lines changed

util/opentelemetry-util-genai/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ dependencies = [
3434
upload = "opentelemetry.util.genai._upload:upload_completion_hook"
3535

3636
[project.optional-dependencies]
37-
test = ["pytest>=7.0.0"]
37+
test = ["pytest>=7.0.0", "pydantic>=2.0"]
3838
upload = ["fsspec>=2025.9.0"]
3939

4040
[project.urls]

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@
1717
import logging
1818
import os
1919
from base64 import b64encode
20+
from collections.abc import Iterator
2021
from functools import partial
21-
from typing import Any
22+
from typing import Any, Protocol, runtime_checkable
2223

2324
from opentelemetry.instrumentation._semconv import (
2425
_OpenTelemetrySemanticConventionStability,
@@ -65,11 +66,24 @@ def get_content_capturing_mode() -> ContentCapturingMode:
6566
return ContentCapturingMode.NO_CONTENT
6667

6768

69+
@runtime_checkable
70+
class _PydanticModelDumpable(Protocol):
71+
"""Checkable protocol for pydantic model dump-able object.
72+
73+
See https://docs.pydantic.dev/latest/api/base_model/#pydantic.BaseModel.model_dump
74+
"""
75+
76+
def model_dump_json(self, **kwargs: Any) -> str: ...
77+
78+
6879
class _GenAiJsonEncoder(json.JSONEncoder):
80+
def encode(self, o: Any) -> str:
81+
return super().encode(o)
82+
6983
def default(self, o: Any) -> Any:
7084
if isinstance(o, bytes):
7185
return b64encode(o).decode()
72-
elif isinstance(o, (datetime.datetime, datetime.date)):
86+
if isinstance(o, (datetime.datetime, datetime.date)):
7387
return o.isoformat()
7488

7589
try:
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
pytest==7.4.4
22
fsspec==2025.9.0
3+
pydantic==2.11.4
34
-e opentelemetry-instrumentation

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

Lines changed: 85 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,15 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import datetime
16+
import io
1517
import json
1618
import os
1719
import unittest
1820
from typing import Any, Mapping, Optional
19-
from unittest.mock import patch
21+
from unittest.mock import Mock, patch
22+
23+
from pydantic import BaseModel
2024

2125
from opentelemetry import trace
2226
from opentelemetry.instrumentation._semconv import (
@@ -47,7 +51,11 @@
4751
OutputMessage,
4852
Text,
4953
)
50-
from opentelemetry.util.genai.utils import get_content_capturing_mode
54+
from opentelemetry.util.genai.utils import (
55+
gen_ai_json_dump,
56+
gen_ai_json_dumps,
57+
get_content_capturing_mode,
58+
)
5159

5260

5361
def patch_env_vars(stability_mode, content_capturing):
@@ -145,12 +153,12 @@ class TestVersion(unittest.TestCase):
145153
stability_mode="gen_ai_latest_experimental",
146154
content_capturing="SPAN_ONLY",
147155
)
148-
def test_get_content_capturing_mode_parses_valid_envvar(self): # pylint: disable=no-self-use
156+
def test_get_content_capturing_mode_parses_valid_envvar(
157+
self,
158+
): # pylint: disable=no-self-use
149159
assert get_content_capturing_mode() == ContentCapturingMode.SPAN_ONLY
150160

151-
@patch_env_vars(
152-
stability_mode="gen_ai_latest_experimental", content_capturing=""
153-
)
161+
@patch_env_vars(stability_mode="gen_ai_latest_experimental", content_capturing="")
154162
def test_empty_content_capturing_envvar(self): # pylint: disable=no-self-use
155163
assert get_content_capturing_mode() == ContentCapturingMode.NO_CONTENT
156164

@@ -169,9 +177,7 @@ def test_get_content_capturing_mode_raises_exception_on_invalid_envvar(
169177
self,
170178
): # pylint: disable=no-self-use
171179
with self.assertLogs(level="WARNING") as cm:
172-
assert (
173-
get_content_capturing_mode() == ContentCapturingMode.NO_CONTENT
174-
)
180+
assert get_content_capturing_mode() == ContentCapturingMode.NO_CONTENT
175181
self.assertEqual(len(cm.output), 1)
176182
self.assertIn("INVALID_VALUE is not a valid option for ", cm.output[0])
177183

@@ -180,12 +186,8 @@ class TestTelemetryHandler(unittest.TestCase):
180186
def setUp(self):
181187
self.span_exporter = InMemorySpanExporter()
182188
tracer_provider = TracerProvider()
183-
tracer_provider.add_span_processor(
184-
SimpleSpanProcessor(self.span_exporter)
185-
)
186-
self.telemetry_handler = get_telemetry_handler(
187-
tracer_provider=tracer_provider
188-
)
189+
tracer_provider.add_span_processor(SimpleSpanProcessor(self.span_exporter))
190+
self.telemetry_handler = get_telemetry_handler(tracer_provider=tracer_provider)
189191

190192
def tearDown(self):
191193
# Clear spans and reset the singleton telemetry handler so each test starts clean
@@ -245,12 +247,8 @@ def test_llm_start_and_stop_creates_span(self): # pylint: disable=no-self-use
245247
},
246248
)
247249

248-
input_message = _get_single_message(
249-
span_attrs, "gen_ai.input.messages"
250-
)
251-
output_message = _get_single_message(
252-
span_attrs, "gen_ai.output.messages"
253-
)
250+
input_message = _get_single_message(span_attrs, "gen_ai.input.messages")
251+
output_message = _get_single_message(span_attrs, "gen_ai.output.messages")
254252
_assert_text_message(input_message, "Human", "hello world")
255253
_assert_text_message(output_message, "AI", "hello back", "stop")
256254
self.assertEqual(invocation.attributes.get("custom_attr"), "value")
@@ -376,10 +374,7 @@ def test_llm_span_uses_expected_schema_url(self):
376374
instrumentation = getattr(span, "instrumentation_info", None)
377375

378376
assert instrumentation is not None
379-
assert (
380-
getattr(instrumentation, "schema_url", None)
381-
== Schemas.V1_37_0.value
382-
)
377+
assert getattr(instrumentation, "schema_url", None) == Schemas.V1_37_0.value
383378

384379
@patch_env_vars(
385380
stability_mode="gen_ai_latest_experimental",
@@ -467,3 +462,68 @@ class BoomError(RuntimeError):
467462
GenAI.GEN_AI_USAGE_OUTPUT_TOKENS: 22,
468463
},
469464
)
465+
466+
467+
class TestGenAiJson(unittest.TestCase):
468+
def test_gen_ai_json_dumps(self):
469+
class Unserializable:
470+
# pylint: disable=no-self-use
471+
def __str__(self):
472+
return "unserializable"
473+
474+
obj = {
475+
"bytes": b"test",
476+
"datetime": datetime.datetime(2023, 1, 1, 12, 0, 0),
477+
"date": datetime.date(2023, 1, 1),
478+
"unserializable": Unserializable(),
479+
}
480+
result = gen_ai_json_dumps(obj)
481+
expected = '{"bytes":"dGVzdA==","datetime":"2023-01-01T12:00:00","date":"2023-01-01","unserializable":"unserializable"}'
482+
self.assertEqual(result, expected)
483+
484+
def test_gen_ai_json_dumps_pydantic(self):
485+
class ExamplePydanticModel(BaseModel):
486+
datetime_field: datetime.datetime
487+
string_field: str
488+
489+
pydantic_model = ExamplePydanticModel(
490+
datetime_field=datetime.datetime(2023, 1, 1, 12, 0, 0),
491+
string_field="test",
492+
)
493+
# spy the model
494+
pydantic_model = Mock(wraps=pydantic_model)
495+
496+
result = gen_ai_json_dumps({"some": {"pydantic": [pydantic_model]}})
497+
self.assertEqual(result, '{"key":"pydantic"}')
498+
499+
pydantic_model.model_dump_json.assert_called_once()
500+
501+
def test_gen_ai_json_dump(self):
502+
class Unserializable:
503+
# pylint: disable=no-self-use
504+
def __str__(self):
505+
return "unserializable"
506+
507+
obj = {
508+
"bytes": b"test",
509+
"datetime": datetime.datetime(2023, 1, 1, 12, 0, 0),
510+
"date": datetime.date(2023, 1, 1),
511+
"unserializable": Unserializable(),
512+
}
513+
string_io = io.StringIO()
514+
gen_ai_json_dump(obj, string_io)
515+
result = string_io.getvalue()
516+
expected = '{"bytes":"dGVzdA==","datetime":"2023-01-01T12:00:00","date":"2023-01-01","unserializable":"unserializable"}'
517+
self.assertEqual(result, expected)
518+
519+
def test_gen_ai_json_dump_pydantic(self):
520+
class PydanticModel:
521+
# pylint: disable=no-self-use
522+
def model_dump_json(self, **kwargs):
523+
return '{"key":"pydantic"}'
524+
525+
obj = PydanticModel()
526+
string_io = io.StringIO()
527+
gen_ai_json_dump(obj, string_io)
528+
result = string_io.getvalue()
529+
self.assertEqual(result, '{"key":"pydantic"}')

uv.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)