From 43114a619f1120daba648abb8a6ae726acf94ecc Mon Sep 17 00:00:00 2001 From: Bartlomiej Flis Date: Thu, 26 Mar 2026 15:11:01 +0100 Subject: [PATCH 1/2] fix: add depth limit to EventSerializer to prevent hangs on complex objects EventSerializer.default() recursively traverses __dict__ and __slots__ of arbitrary objects without a depth limit. When @observe() captures function arguments containing objects like google.genai.Client (which hold aiohttp sessions, connection pools, and threading locks), json.dumps blocks indefinitely on the second invocation. Add a _MAX_DEPTH=20 counter that returns a placeholder when exceeded, preventing infinite recursion into complex object graphs while preserving all existing serialization behavior. --- langfuse/_utils/serializer.py | 14 ++++++ tests/test_serializer.py | 93 +++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/langfuse/_utils/serializer.py b/langfuse/_utils/serializer.py index c2dad3312..776e3012b 100644 --- a/langfuse/_utils/serializer.py +++ b/langfuse/_utils/serializer.py @@ -36,11 +36,24 @@ class Serializable: # type: ignore class EventSerializer(JSONEncoder): + _MAX_DEPTH = 20 + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.seen: set[int] = set() # Track seen objects to detect circular references + self._depth = 0 def default(self, obj: Any) -> Any: + if self._depth >= self._MAX_DEPTH: + return f"<{type(obj).__name__}>" + + self._depth += 1 + try: + return self._default_inner(obj) + finally: + self._depth -= 1 + + def _default_inner(self, obj: Any) -> Any: try: if isinstance(obj, (datetime)): # Timezone-awareness check @@ -167,6 +180,7 @@ def default(self, obj: Any) -> Any: def encode(self, obj: Any) -> str: self.seen.clear() # Clear seen objects before each encode call + self._depth = 0 try: return super().encode(self.default(obj)) diff --git a/tests/test_serializer.py b/tests/test_serializer.py index 4faf7019b..f5d2f1a81 100644 --- a/tests/test_serializer.py +++ b/tests/test_serializer.py @@ -174,3 +174,96 @@ def __init__(self): obj = SlotClass() serializer = EventSerializer() assert json.loads(serializer.encode(obj)) == {"field": "value"} + + +def test_deeply_nested_object_does_not_hang(): + """Objects with deep nesting (e.g. HTTP clients with connection pools) must + not cause infinite recursion or hangs. The serializer should bail out + gracefully after reaching its depth limit.""" + + class Inner: + def __init__(self): + self.lock = threading.Lock() + self.value = "deep" + + class Connection: + def __init__(self): + self._inner = Inner() + self._pool = [Inner() for _ in range(3)] + + class Client: + def __init__(self): + self._connection = Connection() + self._config = {"key": "value"} + + class Platform: + def __init__(self): + self._client = Client() + + obj = {"args": (Platform(),), "kwargs": {}} + serializer = EventSerializer() + result = serializer.encode(obj) + + # Must complete without hanging and produce valid JSON + parsed = json.loads(result) + assert "args" in parsed + + +def test_max_depth_returns_type_name(): + """When nesting exceeds _MAX_DEPTH, the serializer should return the type + name as a placeholder instead of recursing further.""" + + class Level: + def __init__(self, child=None): + self.child = child + + # Build a chain deeper than _MAX_DEPTH + obj = None + for _ in range(EventSerializer._MAX_DEPTH + 10): + obj = Level(child=obj) + + serializer = EventSerializer() + result = json.loads(serializer.encode(obj)) + + # Walk down the chain — at some point it should be truncated to "Level" + node = result + found_truncation = False + while isinstance(node, dict) and "child" in node: + if node["child"] == "Level" or node["child"] == "": + found_truncation = True + break + node = node["child"] + + assert found_truncation, "Expected depth limit to truncate deep nesting" + + +def test_deeply_nested_slots_object_is_truncated(): + """Objects using __slots__ that are deeply nested should also be truncated + at the depth limit rather than recursing indefinitely.""" + + class SlotLevel: + __slots__ = ["child"] + + def __init__(self, child=None): + self.child = child + + obj = None + for _ in range(EventSerializer._MAX_DEPTH + 10): + obj = SlotLevel(child=obj) + + serializer = EventSerializer() + result = json.loads(serializer.encode(obj)) + + # Walk the nested structure and verify it terminates + node = result + depth = 0 + while isinstance(node, dict): + depth += 1 + if "child" in node: + node = node["child"] + else: + break + + assert depth <= EventSerializer._MAX_DEPTH + 5, ( + f"Nesting depth {depth} exceeded limit — serializer should have truncated" + ) From ab2fa6fae8e910c29eb9501535c4d4512d0d664b Mon Sep 17 00:00:00 2001 From: Bartlomiej Flis Date: Thu, 26 Mar 2026 15:39:44 +0100 Subject: [PATCH 2/2] test: tighten slots depth assertion to reflect double depth-counting --- tests/test_serializer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_serializer.py b/tests/test_serializer.py index f5d2f1a81..18c0ba3d1 100644 --- a/tests/test_serializer.py +++ b/tests/test_serializer.py @@ -264,6 +264,6 @@ def __init__(self, child=None): else: break - assert depth <= EventSerializer._MAX_DEPTH + 5, ( + assert depth <= EventSerializer._MAX_DEPTH // 2 + 3, ( f"Nesting depth {depth} exceeded limit — serializer should have truncated" )