Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions langfuse/_utils/serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down
93 changes: 93 additions & 0 deletions tests/test_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"] == "<Level>":
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 // 2 + 3, (
f"Nesting depth {depth} exceeded limit — serializer should have truncated"
)