diff --git a/langfuse/_utils/serializer.py b/langfuse/_utils/serializer.py index 2f25ddd0e..ff98c2a15 100644 --- a/langfuse/_utils/serializer.py +++ b/langfuse/_utils/serializer.py @@ -149,6 +149,11 @@ def _default_inner(self, obj: Any) -> Any: if isinstance(obj, (str, float, type(None))): return obj + # Class objects (e.g. a pydantic model passed as a schema) would + # otherwise match the instance branches below and dump their namespace + if isinstance(obj, type): + return f"" + if self._depth >= self._MAX_DEPTH: return f"<{type(obj).__name__}>" @@ -214,7 +219,7 @@ def _default_inner(self, obj: Any) -> Any: type(obj).__name__, exc_info=e, ) - return f'""' + return f"" def encode(self, obj: Any) -> str: self.seen.clear() # Clear seen objects before each encode call diff --git a/tests/unit/test_observe.py b/tests/unit/test_observe.py index 21ff1418d..60f2f93da 100644 --- a/tests/unit/test_observe.py +++ b/tests/unit/test_observe.py @@ -523,3 +523,26 @@ def marked() -> Any: span = _finished_spans_by_name(memory_exporter, "marked")[0] assert span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT] == "done" + + +def test_observe_records_class_argument_as_class_reference( + langfuse_memory_client: Any, memory_exporter: Any +) -> None: + class Invoice: + total: float = 0.0 + + @observe(name="extract") + def extract(text: str, schema: type) -> str: + return text + + assert extract("hi", schema=Invoice) == "hi" + + langfuse_memory_client.flush() + + span = _finished_spans_by_name(memory_exporter, "extract")[0] + observed_input = json.loads( + span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_INPUT] + ) + assert observed_input["kwargs"]["schema"] == ( + f"" + ) diff --git a/tests/unit/test_serializer.py b/tests/unit/test_serializer.py index 3e5112f6b..1851de4d0 100644 --- a/tests/unit/test_serializer.py +++ b/tests/unit/test_serializer.py @@ -570,3 +570,35 @@ def test_dict_with_non_string_keys_is_serialized(input_obj, expected): result = json.loads(EventSerializer().encode(input_obj)) assert result == expected + + +class _PlainClass: + x = 1 + + +@pytest.mark.parametrize( + "input_obj, expected", + [ + (TestBaseModel, f""), + (TestDataclass, f""), + (_PlainClass, f""), + (TestEnum, f""), + (int, ""), + ], + ids=["pydantic_model", "dataclass", "plain", "enum", "builtin"], +) +def test_class_objects_serialize_as_class_reference(input_obj, expected): + serializer = EventSerializer() + + assert json.loads(serializer.encode(input_obj)) == expected + assert json.loads(serializer.encode({"schema": input_obj})) == {"schema": expected} + + +def test_failed_serialization_falls_back_to_plain_string(): + class BrokenModel(BaseModel): + def model_dump(self, *args, **kwargs): + raise RuntimeError("boom") + + result = json.loads(EventSerializer().encode({"value": BrokenModel()})) + + assert result == {"value": ""}