Skip to content

Commit 43114a6

Browse files
committed
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 <TypeName> placeholder when exceeded, preventing infinite recursion into complex object graphs while preserving all existing serialization behavior.
1 parent c5dc24d commit 43114a6

File tree

2 files changed

+107
-0
lines changed

2 files changed

+107
-0
lines changed

langfuse/_utils/serializer.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,24 @@ class Serializable: # type: ignore
3636

3737

3838
class EventSerializer(JSONEncoder):
39+
_MAX_DEPTH = 20
40+
3941
def __init__(self, *args: Any, **kwargs: Any) -> None:
4042
super().__init__(*args, **kwargs)
4143
self.seen: set[int] = set() # Track seen objects to detect circular references
44+
self._depth = 0
4245

4346
def default(self, obj: Any) -> Any:
47+
if self._depth >= self._MAX_DEPTH:
48+
return f"<{type(obj).__name__}>"
49+
50+
self._depth += 1
51+
try:
52+
return self._default_inner(obj)
53+
finally:
54+
self._depth -= 1
55+
56+
def _default_inner(self, obj: Any) -> Any:
4457
try:
4558
if isinstance(obj, (datetime)):
4659
# Timezone-awareness check
@@ -167,6 +180,7 @@ def default(self, obj: Any) -> Any:
167180

168181
def encode(self, obj: Any) -> str:
169182
self.seen.clear() # Clear seen objects before each encode call
183+
self._depth = 0
170184

171185
try:
172186
return super().encode(self.default(obj))

tests/test_serializer.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,3 +174,96 @@ def __init__(self):
174174
obj = SlotClass()
175175
serializer = EventSerializer()
176176
assert json.loads(serializer.encode(obj)) == {"field": "value"}
177+
178+
179+
def test_deeply_nested_object_does_not_hang():
180+
"""Objects with deep nesting (e.g. HTTP clients with connection pools) must
181+
not cause infinite recursion or hangs. The serializer should bail out
182+
gracefully after reaching its depth limit."""
183+
184+
class Inner:
185+
def __init__(self):
186+
self.lock = threading.Lock()
187+
self.value = "deep"
188+
189+
class Connection:
190+
def __init__(self):
191+
self._inner = Inner()
192+
self._pool = [Inner() for _ in range(3)]
193+
194+
class Client:
195+
def __init__(self):
196+
self._connection = Connection()
197+
self._config = {"key": "value"}
198+
199+
class Platform:
200+
def __init__(self):
201+
self._client = Client()
202+
203+
obj = {"args": (Platform(),), "kwargs": {}}
204+
serializer = EventSerializer()
205+
result = serializer.encode(obj)
206+
207+
# Must complete without hanging and produce valid JSON
208+
parsed = json.loads(result)
209+
assert "args" in parsed
210+
211+
212+
def test_max_depth_returns_type_name():
213+
"""When nesting exceeds _MAX_DEPTH, the serializer should return the type
214+
name as a placeholder instead of recursing further."""
215+
216+
class Level:
217+
def __init__(self, child=None):
218+
self.child = child
219+
220+
# Build a chain deeper than _MAX_DEPTH
221+
obj = None
222+
for _ in range(EventSerializer._MAX_DEPTH + 10):
223+
obj = Level(child=obj)
224+
225+
serializer = EventSerializer()
226+
result = json.loads(serializer.encode(obj))
227+
228+
# Walk down the chain — at some point it should be truncated to "Level"
229+
node = result
230+
found_truncation = False
231+
while isinstance(node, dict) and "child" in node:
232+
if node["child"] == "Level" or node["child"] == "<Level>":
233+
found_truncation = True
234+
break
235+
node = node["child"]
236+
237+
assert found_truncation, "Expected depth limit to truncate deep nesting"
238+
239+
240+
def test_deeply_nested_slots_object_is_truncated():
241+
"""Objects using __slots__ that are deeply nested should also be truncated
242+
at the depth limit rather than recursing indefinitely."""
243+
244+
class SlotLevel:
245+
__slots__ = ["child"]
246+
247+
def __init__(self, child=None):
248+
self.child = child
249+
250+
obj = None
251+
for _ in range(EventSerializer._MAX_DEPTH + 10):
252+
obj = SlotLevel(child=obj)
253+
254+
serializer = EventSerializer()
255+
result = json.loads(serializer.encode(obj))
256+
257+
# Walk the nested structure and verify it terminates
258+
node = result
259+
depth = 0
260+
while isinstance(node, dict):
261+
depth += 1
262+
if "child" in node:
263+
node = node["child"]
264+
else:
265+
break
266+
267+
assert depth <= EventSerializer._MAX_DEPTH + 5, (
268+
f"Nesting depth {depth} exceeded limit — serializer should have truncated"
269+
)

0 commit comments

Comments
 (0)