From ad99074df1c494060ebd8b4af8e6d38774342cae Mon Sep 17 00:00:00 2001 From: i-anubhav-anand Date: Sun, 14 Jun 2026 23:23:03 +0530 Subject: [PATCH 1/2] fix(serializer): preserve Decimal values and non-primitive dict keys EventSerializer silently dropped data in two cases: - decimal.Decimal had no branch, so it fell through to the "" type fallback and the value was lost. Route it through float() so it is serialized as a number (and Decimal("NaN")/Decimal("Infinity") reuse the existing special-value handlers). - A dict whose key serializes to a container/object (e.g. tuple, set, custom object) raised inside the dict comprehension / json encoding, which the except clause turned into "" for the WHOLE dict. Stringify such keys via str(key) so one non-primitive key no longer discards every value in the dict. Adds unit tests covering both (fail before, pass after). Existing datetime/uuid/enum/int key behavior is unchanged. --- langfuse/_utils/serializer.py | 21 ++++++++++++++++++++- tests/unit/test_serializer.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/langfuse/_utils/serializer.py b/langfuse/_utils/serializer.py index 27294bf80..27307041b 100644 --- a/langfuse/_utils/serializer.py +++ b/langfuse/_utils/serializer.py @@ -1,6 +1,7 @@ """@private""" import datetime as dt +import decimal import enum import math from asyncio import Queue @@ -72,6 +73,12 @@ def _default_inner(self, obj: Any) -> Any: if np is not None and isinstance(obj, np.ndarray): return obj.tolist() + # Route Decimal through float so it is preserved as a number + # (and Decimal("NaN")/Decimal("Infinity") reuse the handlers below) + # instead of falling through to the "" type fallback. + if isinstance(obj, decimal.Decimal): + return self.default(float(obj)) + if isinstance(obj, float) and math.isnan(obj): return "NaN" @@ -140,7 +147,19 @@ def _default_inner(self, obj: Any) -> Any: return list(obj) if isinstance(obj, dict): - return {self.default(k): self.default(v) for k, v in obj.items()} + result = {} + for k, v in obj.items(): + serialized_key = self.default(k) + # JSON object keys must be scalars. If a key serializes to a + # container/object (e.g. tuple, set, or custom object), fall back + # to its string form so a single non-primitive key does not + # discard the whole dict. + if not isinstance( + serialized_key, (str, int, float, bool, type(None)) + ): + serialized_key = str(k) + result[serialized_key] = self.default(v) + return result if isinstance(obj, list): return [self.default(item) for item in obj] diff --git a/tests/unit/test_serializer.py b/tests/unit/test_serializer.py index f4c8dde86..b76247664 100644 --- a/tests/unit/test_serializer.py +++ b/tests/unit/test_serializer.py @@ -2,6 +2,7 @@ import threading from dataclasses import dataclass from datetime import date, datetime, timezone +from decimal import Decimal from enum import Enum from pathlib import Path from uuid import UUID @@ -304,3 +305,32 @@ def test_dict_with_non_string_keys_is_serialized(input_obj, expected): result = json.loads(EventSerializer().encode(input_obj)) assert result == expected + + +def test_decimal_is_preserved_as_number(): + assert json.loads(EventSerializer().encode(Decimal("1.5"))) == 1.5 + assert json.loads(EventSerializer().encode({"price": Decimal("19.99")})) == { + "price": 19.99 + } + + +def test_decimal_special_values(): + assert EventSerializer().encode(Decimal("NaN")) == '"NaN"' + assert EventSerializer().encode(Decimal("Infinity")) == '"Infinity"' + assert EventSerializer().encode(Decimal("-Infinity")) == '"-Infinity"' + + +def test_dict_with_non_primitive_keys_preserves_values(): + # A tuple key must not discard the entire dict (previously the whole dict + # serialized to a "" string). + result = json.loads(EventSerializer().encode({(1, 2): "v", "other": "data"})) + assert result == {"(1, 2)": "v", "other": "data"} + + +def test_dict_with_custom_object_key_uses_str(): + class _Key: + def __str__(self) -> str: + return "custom-key" + + result = json.loads(EventSerializer().encode({_Key(): "val", "k2": "x"})) + assert result == {"custom-key": "val", "k2": "x"} From c3c939719139ac10eb91b991b53fa143b1a5fe33 Mon Sep 17 00:00:00 2001 From: i-anubhav-anand Date: Mon, 15 Jun 2026 02:41:21 +0530 Subject: [PATCH 2/2] fix(serializer): serialize Decimal exactly via str(), not float() Address review feedback: float(Decimal) silently rounds high-precision values and can OverflowError on very large ones. Use str() to preserve the exact value (NaN/Infinity still render as "NaN"/"Infinity"/"-Infinity", matching the float handling). Update tests to assert exact string output incl. high-precision and >JS-safe-int cases. --- langfuse/_utils/serializer.py | 10 ++++++---- tests/unit/test_serializer.py | 17 ++++++++++++++--- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/langfuse/_utils/serializer.py b/langfuse/_utils/serializer.py index 27307041b..6dcf2a6ce 100644 --- a/langfuse/_utils/serializer.py +++ b/langfuse/_utils/serializer.py @@ -73,11 +73,13 @@ def _default_inner(self, obj: Any) -> Any: if np is not None and isinstance(obj, np.ndarray): return obj.tolist() - # Route Decimal through float so it is preserved as a number - # (and Decimal("NaN")/Decimal("Infinity") reuse the handlers below) - # instead of falling through to the "" type fallback. + # Serialize Decimal as its exact string form rather than via float(): + # float() would silently round high-precision values (and overflow on + # very large ones), and JSON numbers are parsed as doubles downstream + # anyway. str() preserves the exact value; NaN/Infinity render as + # "NaN"/"Infinity"/"-Infinity", matching the float handling below. if isinstance(obj, decimal.Decimal): - return self.default(float(obj)) + return str(obj) if isinstance(obj, float) and math.isnan(obj): return "NaN" diff --git a/tests/unit/test_serializer.py b/tests/unit/test_serializer.py index b76247664..baf3a529a 100644 --- a/tests/unit/test_serializer.py +++ b/tests/unit/test_serializer.py @@ -307,11 +307,22 @@ def test_dict_with_non_string_keys_is_serialized(input_obj, expected): assert result == expected -def test_decimal_is_preserved_as_number(): - assert json.loads(EventSerializer().encode(Decimal("1.5"))) == 1.5 +def test_decimal_is_preserved_exactly(): + # Serialized to its exact string form (never the "" fallback) + assert json.loads(EventSerializer().encode(Decimal("19.99"))) == "19.99" assert json.loads(EventSerializer().encode({"price": Decimal("19.99")})) == { - "price": 19.99 + "price": "19.99" } + # High-precision values are preserved exactly (a float() conversion would + # silently round these). + assert ( + json.loads(EventSerializer().encode(Decimal("1.0000000000000001"))) + == "1.0000000000000001" + ) + assert ( + json.loads(EventSerializer().encode(Decimal("123456789012345678"))) + == "123456789012345678" + ) def test_decimal_special_values():