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..3e5112f6b 100644 --- a/tests/unit/test_serializer.py +++ b/tests/unit/test_serializer.py @@ -452,16 +452,97 @@ 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 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):