From 237f0096462da024cb436e4f430713b10e594a84 Mon Sep 17 00:00:00 2001 From: Aditya Gupta <84338325+MaiAditya@users.noreply.github.com> Date: Thu, 12 Nov 2026 23:55:42 +0530 Subject: [PATCH] fix(serializer): record class objects as class references A class passed as a value, such as a pydantic model given as an output schema, fell through to the instance branches. Pydantic classes dumped their slot descriptors, plain classes dumped their namespace, and dataclass classes failed in asdict(). Return "" for any class object instead. The failure fallback in default() also returned a string with embedded quotes, which were then JSON-escaped a second time. Drop them; encode() keeps its quotes because its return value is the final JSON. --- langfuse/_utils/serializer.py | 7 ++++++- tests/unit/test_observe.py | 23 +++++++++++++++++++++++ tests/unit/test_serializer.py | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) 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": ""}