From fa74e11be017ff3883c92081d76fcd338a83e7a1 Mon Sep 17 00:00:00 2001 From: David Traina <44659830+DavidTraina@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:50:21 -0500 Subject: [PATCH 1/2] perf(serializer): skip the isinstance chain for plain JSON values EventSerializer.encode walks the whole object through default() before the C encoder runs, and every str, int, float, dict and list went through the full isinstance chain. default() now returns exact str, None, bool, JS-safe int and finite float values directly, and exact dicts, lists and tuples are walked before the other checks, with the same depth limit. Subclasses and all other types take the existing path, so the output is unchanged. --- langfuse/_utils/serializer.py | 28 +++++++++++++++++++---- tests/unit/test_serializer.py | 43 ++++++++++++++++++++++++++++++----- 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/langfuse/_utils/serializer.py b/langfuse/_utils/serializer.py index 3d795d13e..2f25ddd0e 100644 --- a/langfuse/_utils/serializer.py +++ b/langfuse/_utils/serializer.py @@ -34,6 +34,9 @@ class Serializable: # type: ignore logger = getLogger(__name__) +_MAX_SAFE_INT = 2**53 - 1 +_MIN_SAFE_INT = -(2**53) + 1 + class EventSerializer(JSONEncoder): _MAX_DEPTH = 20 @@ -44,6 +47,16 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self._depth = 0 def default(self, obj: Any) -> Any: + # Exact built-in scalars come back unchanged from _default_inner, so skip + # its isinstance chain. Exact types only: a subclass may match another branch. + obj_type = type(obj) + if obj_type is str or obj is None or obj_type is bool: + return obj + if obj_type is int and _MIN_SAFE_INT <= obj <= _MAX_SAFE_INT: + return obj + if obj_type is float and math.isfinite(obj): + return obj + self._depth += 1 try: return self._default_inner(obj) @@ -52,6 +65,16 @@ def default(self, obj: Any) -> Any: def _default_inner(self, obj: Any) -> Any: try: + # Mirrors the depth check and container branches below. No branch + # before them matches an exact dict, list or tuple. + obj_type = type(obj) + if obj_type is dict or obj_type is list or obj_type is tuple: + if self._depth >= self._MAX_DEPTH: + return f"<{obj_type.__name__}>" + if obj_type is dict: + return {self.default(k): self.default(v) for k, v in obj.items()} + return [self.default(item) for item in obj] + if isinstance(obj, (SecretStr, SecretBytes)): return "" @@ -209,10 +232,7 @@ def is_js_safe_integer(value: int) -> bool: Python's 64-bit integers can exceed this range, necessitating this check. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER """ - max_safe_int = 2**53 - 1 - min_safe_int = -(2**53) + 1 - - return min_safe_int <= value <= max_safe_int + return _MIN_SAFE_INT <= value <= _MAX_SAFE_INT def serialize_datetime(v: dt.datetime) -> str: diff --git a/tests/unit/test_serializer.py b/tests/unit/test_serializer.py index b26990b12..8cf1ff88d 100644 --- a/tests/unit/test_serializer.py +++ b/tests/unit/test_serializer.py @@ -452,16 +452,47 @@ def __init__(self, child=None): ) -def test_deeply_nested_dict_preserves_keys_at_depth_boundary(monkeypatch): +def _circular_list(): + items = [] + items.append(items) + return items + + +@pytest.mark.parametrize( + "input_obj, expected", + [ + ({"a": {"b": {"c": "leaf"}}}, {"a": {"b": ""}}), + ([[["leaf"]]], [[""]]), + (((("leaf",),),), [[""]]), + (_circular_list(), [[""]]), + ], + ids=["dict", "list", "tuple", "circular_list"], +) +def test_nested_containers_are_truncated_at_depth_boundary( + monkeypatch, input_obj, expected +): monkeypatch.setattr(EventSerializer, "_MAX_DEPTH", 3) - input_obj = {"a": {"b": {"c": "leaf"}}} - expected = {"a": {"b": ""}} + assert json.loads(EventSerializer().encode(input_obj)) == expected - serializer = EventSerializer() - result = json.loads(serializer.encode(input_obj)) - assert result == expected +@pytest.mark.parametrize( + "input_obj, expected", + [ + ( + {"safe": [2**53 - 1, -(2**53) + 1], "unsafe": [2**53, -(2**53)]}, + '{"safe": [9007199254740991, -9007199254740991], ' + '"unsafe": ["9007199254740992", "-9007199254740992"]}', + ), + ( + {"a": [float("nan")], "b": {"c": float("inf")}, "d": (float("-inf"),)}, + '{"a": ["NaN"], "b": {"c": "Infinity"}, "d": ["-Infinity"]}', + ), + ], + ids=["js_safe_int_bounds", "non_finite_floats"], +) +def test_plain_containers_convert_nested_values(input_obj, expected): + assert EventSerializer().encode(input_obj) == expected class _Color(Enum): From f564bc3852c3670255db4baa833e18b6fec5fc2f Mon Sep 17 00:00:00 2001 From: David Traina <44659830+DavidTraina@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:31:18 +0000 Subject: [PATCH 2/2] test(serializer): cover built-in subclasses that must skip the fast paths --- tests/unit/test_serializer.py | 50 +++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/unit/test_serializer.py b/tests/unit/test_serializer.py index 8cf1ff88d..3e5112f6b 100644 --- a/tests/unit/test_serializer.py +++ b/tests/unit/test_serializer.py @@ -495,6 +495,56 @@ def test_plain_containers_convert_nested_values(input_obj, expected): assert EventSerializer().encode(input_obj) == expected +class StreamingStr(str): + def __str__(self) -> str: + return "streaming-str" + + +class StreamingInt(int): + def __str__(self) -> str: + return "streaming-int" + + +class StreamingFloat(float): + def __str__(self) -> str: + return "streaming-float" + + +class StreamingDict(dict): + def __str__(self) -> str: + return "streaming-dict" + + +class StreamingList(list): + def __str__(self) -> str: + return "streaming-list" + + +class StreamingTuple(tuple): + def __str__(self) -> str: + return "streaming-tuple" + + +@pytest.mark.parametrize( + "input_obj, expected", + [ + (StreamingStr("payload"), '"streaming-str"'), + (StreamingInt(7), '"streaming-int"'), + (StreamingFloat(1.5), '"streaming-float"'), + (StreamingDict(a=1), '"streaming-dict"'), + (StreamingList([1, 2]), '"streaming-list"'), + (StreamingTuple((1, 2)), '"streaming-tuple"'), + ], + ids=["str", "int", "float", "dict", "list", "tuple"], +) +def test_builtin_subclasses_take_the_full_branch_chain( + input_obj: object, expected: str +) -> None: + # Subclasses of the fast-pathed built-ins must still reach the "Streaming" + # branch, which serializes them with str(). + assert EventSerializer().encode(input_obj) == expected + + class _Color(Enum): RED = "red" NUMERIC = 7