diff --git a/langfuse/_utils/serializer.py b/langfuse/_utils/serializer.py index 46188fce5..a842a5c20 100644 --- a/langfuse/_utils/serializer.py +++ b/langfuse/_utils/serializer.py @@ -70,13 +70,17 @@ def _default_inner(self, obj: Any) -> Any: # Check if numpy is available and if the object is a numpy scalar # If so, convert it to a Python scalar using the item() method + # and recurse through default() so non-finite floats and unsafe + # integers are normalized if np is not None and isinstance(obj, np.generic): - return obj.item() + return self.default(obj.item()) # Check if numpy is available and if the object is a numpy array # If so, convert it to a Python list using the tolist() method + # and recurse through default() so nested non-finite floats and + # unsafe integers are normalized if np is not None and isinstance(obj, np.ndarray): - return obj.tolist() + return self.default(obj.tolist()) if isinstance(obj, float) and math.isnan(obj): return "NaN" @@ -93,7 +97,7 @@ def _default_inner(self, obj: Any) -> Any: return str(obj) if isinstance(obj, enum.Enum): - return obj.value + return self.default(obj.value) if isinstance(obj, Queue): return type(obj).__name__ @@ -127,7 +131,9 @@ def _default_inner(self, obj: Any) -> Any: return f"<{type(obj).__name__}>" if is_dataclass(obj): - return asdict(obj) # type: ignore + # Recurse the dict produced by asdict() back through default() + # so nested non-finite floats and unsafe integers are normalized + return self.default(asdict(obj)) # type: ignore if isinstance(obj, BaseModel): obj.model_rebuild() @@ -144,10 +150,10 @@ def _default_inner(self, obj: Any) -> Any: # if langchain is not available, the Serializable type is NoneType if Serializable is not type(None) and isinstance(obj, Serializable): # type: ignore - return obj.to_json() + return self.default(obj.to_json()) if isinstance(obj, (tuple, set, frozenset)): - return list(obj) + return [self.default(item) for item in obj] if isinstance(obj, dict): return {self.default(k): self.default(v) for k, v in obj.items()} diff --git a/tests/unit/test_serializer.py b/tests/unit/test_serializer.py index ce5798f67..59abdafe4 100644 --- a/tests/unit/test_serializer.py +++ b/tests/unit/test_serializer.py @@ -362,3 +362,96 @@ def test_dict_with_non_string_keys_is_serialized(input_obj, expected): result = json.loads(EventSerializer().encode(input_obj)) assert result == expected + + +def _reject_json_constant(token: str) -> None: + # json.loads accepts bare NaN/Infinity by default, so reject them the way + # the ingestion server's strict JSON parser does. + raise ValueError(f"invalid JSON constant emitted: {token}") + + +def test_non_finite_floats_in_tuple_set_frozenset(): + # Values inside tuple/set/frozenset must be recursed through default() like + # list items, so non-finite floats are converted to safe string tokens + # instead of escaping as bare NaN/Infinity (invalid JSON). + serializer = EventSerializer() + + assert json.loads( + serializer.encode((float("nan"),)), + parse_constant=_reject_json_constant, + ) == ["NaN"] + assert json.loads( + serializer.encode({float("inf")}), parse_constant=_reject_json_constant + ) == ["Infinity"] + assert json.loads( + serializer.encode(frozenset([float("-inf")])), + parse_constant=_reject_json_constant, + ) == ["-Infinity"] + assert json.loads( + serializer.encode({"t": (1, float("nan"))}), + parse_constant=_reject_json_constant, + ) == {"t": [1, "NaN"]} + + +def test_js_unsafe_integers_in_tuple_set_frozenset(): + # Integers outside the JavaScript safe range must be stringified even when + # nested in tuple/set/frozenset containers. + serializer = EventSerializer() + unsafe = 2**60 + + assert json.loads(serializer.encode((unsafe,))) == [str(unsafe)] + assert json.loads(serializer.encode({unsafe})) == [str(unsafe)] + assert json.loads(serializer.encode(frozenset([unsafe]))) == [str(unsafe)] + + +def test_dataclass_with_non_finite_float(): + # asdict() output must be recursed through default() so nested non-finite + # floats are converted to safe string tokens. + @dataclass + class WithFloats: + finite: float + nan: float + + serializer = EventSerializer() + parsed = json.loads( + serializer.encode(WithFloats(finite=1.5, nan=float("nan"))), + parse_constant=_reject_json_constant, + ) + assert parsed == {"finite": 1.5, "nan": "NaN"} + + +def test_enum_with_non_finite_float_value(): + class FloatEnum(Enum): + NAN = float("nan") + + serializer = EventSerializer() + parsed = json.loads( + serializer.encode(FloatEnum.NAN), parse_constant=_reject_json_constant + ) + assert parsed == "NaN" + + +def test_numpy_types_with_non_finite_and_unsafe_values(): + np = pytest.importorskip("numpy") + + serializer = EventSerializer() + + # np.generic scalar: .item() result must be recursed through default() + assert ( + json.loads( + serializer.encode(np.float64("nan")), + parse_constant=_reject_json_constant, + ) + == "NaN" + ) + assert json.loads(serializer.encode(np.int64(2**60))) == str(2**60) + + # np.ndarray: tolist() result must be recursed through default() + parsed = json.loads( + serializer.encode(np.array([float("nan"), float("inf"), 1.0])), + parse_constant=_reject_json_constant, + ) + assert parsed == ["NaN", "Infinity", 1.0] + assert json.loads(serializer.encode(np.array([2**60], dtype=np.int64))) == [ + str(2**60) + ]