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 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

Check failure on line 54 in langfuse/_utils/serializer.py

View check run for this annotation

Claude / Claude Code Review

Depth-check clobbers dict keys to '<str>' at the boundary

The new depth guard in `default()` (lines 44-48) returns `f"<{type(obj).__name__}>"` for **any** object — including primitive strings — before any type analysis. Because the dict branch in `_default_inner` routes both keys and values through `self.default()` (`{self.default(k): self.default(v) for k, v in obj.items()}`), at the truncation boundary a normal string key like `'a'` is replaced with the literal `'<str>'`, silently corrupting user trace data. Fix: either move the depth check inside `_

Check failure on line 54 in langfuse/_utils/serializer.py

View check run for this annotation

Claude / Claude Code Review

__slots__ path consumes double depth budget per object level

The `__slots__` branch at lines 153-156 calls `self.default({...})` on the synthesized dict, which re-enters the new depth-tracking wrapper and increments `_depth` a second time. As a result, every `__slots__` object consumes **2** depth units per nesting level instead of 1, so the effective `_MAX_DEPTH` for `__slots__`-based objects (e.g. pydantic v1 BaseModels, many attrs/slotted dataclasses) is silently halved from 20 to ~10. One-line fix: call `self._default_inner({...})` in the slots branch
Comment on lines +44 to +54
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The new depth guard in default() (lines 44-48) returns f"<{type(obj).__name__}>" for any object — including primitive strings — before any type analysis. Because the dict branch in _default_inner routes both keys and values through self.default() ({self.default(k): self.default(v) for k, v in obj.items()}), at the truncation boundary a normal string key like 'a' is replaced with the literal '<str>', silently corrupting user trace data. Fix: either move the depth check inside _default_inner after the primitive type checks, or change the dict branch to {k: self.default(v) for k, v in obj.items()} (matching the __dict__ branch convention on line 165).

Extended reasoning...

What the bug is

The depth guard added at the top of default() (lines 46-48) is unconditional — it runs before any type dispatch:

def default(self, obj: Any) -> Any:
    if self._depth >= self._MAX_DEPTH:
        return f"<{type(obj).__name__}>"
    ...

This means at the boundary, any value passed to default() — including plain str and int primitives — gets replaced by its type-name placeholder. Combined with the pre-existing dict branch in _default_inner (line 142-143):

if isinstance(obj, dict):
    return {self.default(k): self.default(v) for k, v in obj.items()}

which routes both keys and values through self.default(), the result is that dict keys at the truncation boundary are silently rewritten to "<str>" (or "<int>", etc.) instead of being preserved.

Step-by-step proof

Consider a chain of 22 nested {"a": {...}} dicts encoded by EventSerializer:

  1. encode(D_0) resets _depth=0, calls default(D_0).
  2. default(D_k) enters with _depth=k, checks k >= 20 (false for k<20), increments to k+1, calls _default_inner(D_k).
  3. The dict branch returns {self.default("a"): self.default(D_{k+1})} — both at _depth=k+1.
  4. At k=19: _default_inner calls self.default("a") while _depth=20. The guard _depth >= _MAX_DEPTH (20 >= 20) fires first, returning "<str>" instead of dispatching to the isinstance(obj, str) branch which would have returned "a".

Verified live output:

{"a": {"a": {"a": ... (19 levels) ... {"<str>": "<dict>"}}}}

The last surviving key — the most informative one in the trace — is the one that gets clobbered.

Why existing tests miss it

test_max_depth_returns_type_name uses a Level class with __dict__. That path goes through the __dict__ branch at line 165:

result = {k: self.default(v) for k, v in vars(obj).items()}

Note: keys are not routed through self.default() here. So the test never exercises the corrupted-key path that the raw-dict branch creates.

Impact

This is a regression introduced by this PR. Before the change, default("a") always hit the isinstance(obj, str) branch in _default_inner and returned "a" unchanged. Now, for any traced input/output containing a deeply nested dict (a common shape: nested JSON payloads, message threads, agent state), the key name at the truncation boundary is silently replaced with "<str>". Worse, "<str>" is indistinguishable from a legitimate string a user might have placed there. The __slots__ branch is also affected, because line 153-156 wraps slot names in a synthetic dict that gets routed through default() — so slot names get the same treatment.

How to fix

Two simple options:

  1. Move the depth check into _default_inner after the primitive type checks. Primitives (str, int, float, None, bytes, etc.) are leaves and cannot recurse, so they should never be subject to the depth guard.
  2. Match the __dict__ branch convention: change line 142-143 to {k: self.default(v) for k, v in obj.items()}. Keys are already required to be JSON-encodable (strings/ints), so routing them through default() was never necessary.

Comment on lines +44 to +54
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The __slots__ branch at lines 153-156 calls self.default({...}) on the synthesized dict, which re-enters the new depth-tracking wrapper and increments _depth a second time. As a result, every __slots__ object consumes 2 depth units per nesting level instead of 1, so the effective _MAX_DEPTH for __slots__-based objects (e.g. pydantic v1 BaseModels, many attrs/slotted dataclasses) is silently halved from 20 to ~10. One-line fix: call self._default_inner({...}) in the slots branch so it consumes depth equivalently to the __dict__ branch (which already builds its result dict directly without re-routing through default).

Extended reasoning...

What the bug is

The PR adds depth tracking by wrapping the existing serialization logic in a new default() method that increments _depth, calls _default_inner, and decrements in a finally. The pre-existing __slots__ branch at langfuse/_utils/serializer.py:153-156 was not updated to match the new wrapper-vs-inner split, so it still does:

if hasattr(obj, "__slots__"):
    return self.default(
        {slot: getattr(obj, slot, None) for slot in obj.__slots__}
    )

That self.default(...) call re-enters the wrapper and bumps _depth a second time for the same logical nesting level. The __dict__ branch a few lines below avoids this by building its result directly: {k: self.default(v) for k, v in vars(obj).items()} — only the child traversal increments depth, not the level itself.

Step-by-step proof

Consider a chain SL_0 -> SL_1 -> ... -> SL_N where each SlotLevel has __slots__ = ['child']:

Step Call _depth on entry After increment
1 default(SL_0) 0 1
2 inner sees __slots__default({'child': SL_1}) 1 2
3 inner sees dict → default(SL_1) (via value) 2 3
4 inner sees __slots__default({'child': SL_2}) 3 4
... ... ... ...

So default(SL_i) is entered with _depth = 2i. The truncation check _depth >= _MAX_DEPTH (20) fires at i = 10 — i.e. truncation happens at ~10 levels for __slots__ objects, vs. 20 for __dict__ objects. This is exactly what the PR's own new test bakes in (depth <= _MAX_DEPTH // 2 + 3 = 13), and Greptile flagged the same issue independently.

Why existing code doesn't prevent it

Before this PR, the redundant self.default(...) call in the __slots__ branch was harmless — default() had no per-call side effect. The PR introduces the per-call _depth increment but only modifies the __dict__ branch's data flow (which already bypassed default() for its own dict construction). The __slots__ branch was overlooked.

Impact

  • The documented _MAX_DEPTH = 20 is effectively 10 for any object using __slots__ (pydantic v1 BaseModels, many attrs classes, slotted dataclasses, etc.).
  • When truncation does trigger in a __slots__ chain, the placeholder becomes "<dict>" (the synthesized wrapper dict's type) instead of "<SlotLevel>" (the actual object type) — less informative for debugging.
  • Severity is moderate: the primary fix (preventing hangs) still works, but the depth budget is silently halved for a common class of objects, and the truncation marker is misleading.

How to fix

One-line change in the slots branch — call the inner helper directly so it matches the __dict__ branch's accounting:

if hasattr(obj, "__slots__"):
    return self._default_inner(
        {slot: getattr(obj, slot, None) for slot in obj.__slots__}
    )

The PR author has already offered to make this change ("address Greptile __slots__ double-depth nit — one-line fix: route the slots dict through _default_inner instead of default"). Once applied, the companion test bound (tests/test_serializer.py:267, currently _MAX_DEPTH // 2 + 3 = 13) should be tightened to _MAX_DEPTH + 2 (or restructured like test_max_depth_returns_type_name) so it doesn't encode the buggy behavior.


def _default_inner(self, obj: Any) -> Any:
try:
if isinstance(obj, (datetime)):
# Timezone-awareness check
Expand Down Expand Up @@ -167,6 +180,7 @@

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"
)