From f062e6754cb870547640cc7c4c6dcbcf19c4a54f Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Tue, 10 Nov 2026 19:55:33 -0700 Subject: [PATCH 1/3] Leave out a value that is not a number, rather than sending it or losing the event (#37) A string or bool value went to the server as a JSON string or boolean, and a Decimal or numpy integer made json.dumps raise, which dropped the whole event. Real numbers are now sent as floats; anything else is left out like NaN, and the event still goes. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 ++++-- tests/test_trace_client.py | 29 +++++++++++++++++++++++++++++ trace_client/trace_client.py | 29 +++++++++++++++++++++++++---- 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c2a84fc..5667c1f 100644 --- a/README.md +++ b/README.md @@ -152,8 +152,10 @@ There is no PyPI package yet; the file is the distribution. {"application":"roam","name":"command","value":1.0,"tags":{"name":"home","version":"1.4.0"}} ``` -`value` is omitted when not given, and when it is NaN or infinite (JSON has no -such numbers); `tags` always holds at least `version`, and +`value` is omitted when not given, when it is NaN or infinite (JSON has no +such numbers), and when it is not a real number at all — a string such as +`"3"`, or a `bool` — though the event itself is still sent. Any other real +number (a `Decimal`, a numpy integer) is sent as a float; `tags` always holds at least `version`, and `install` when the client has an [installation ID](#every-event-carries-a-random-installation-id). A tag whose key or value is `None` is left out, and every other key and value is sent as a string. An event with a blank `name` is not sent at all. The server assigns the diff --git a/tests/test_trace_client.py b/tests/test_trace_client.py index 3d3e7bd..6a656c4 100644 --- a/tests/test_trace_client.py +++ b/tests/test_trace_client.py @@ -608,6 +608,35 @@ def test_json_drops_infinite_values_but_keeps_zero(self): "JSON has no infinity; sending one would be rejected by the server") self.assertEqual(0, json.loads(_json("App", "n", 0, None))["value"], "zero is a value, not a missing one") + def test_json_leaves_out_a_value_that_is_not_a_real_number_but_keeps_the_event(self): + from trace_client.trace_client import _json + for odd in ("3", "", True, False, b"3", [1], object(), 1j): + body = json.loads(_json("App", "n", odd, None)) + self.assertEqual({"application": "App", "name": "n"}, body, + "%r is not a number the server can take; the event goes without it" % (odd,)) + + def test_json_sends_other_real_numbers_as_floats(self): + import decimal + import fractions + from trace_client.trace_client import _json + self.assertEqual(1.5, json.loads(_json("App", "n", decimal.Decimal("1.5"), None))["value"]) + self.assertEqual(0.25, json.loads(_json("App", "n", fractions.Fraction(1, 4), None))["value"]) + self.assertEqual(7, json.loads(_json("App", "n", 7, None))["value"]) + for unsendable in (decimal.Decimal("NaN"), decimal.Decimal("sNaN"), decimal.Decimal("1e400"), + fractions.Fraction(10 ** 400)): + self.assertNotIn("value", json.loads(_json("App", "n", unsendable, None)), + "%r has no finite float; it is left out rather than raising" % (unsendable,)) + + def test_report_with_a_decimal_value_still_delivers_the_event(self): + import decimal + client = TraceClient(self.base_url, "MyGame", "1.2.3", key="k") + try: + client.report("score", decimal.Decimal("2.5")) + self.assertTrue(self.capture.arrived.wait(5), "a Decimal must not cost the event its place") + finally: + client.close() + self.assertEqual(2.5, json.loads(self.capture.requests[0]["body"])["value"]) + def test_json_omits_tags_that_are_empty_after_cleaning_and_stringifies_the_rest(self): from trace_client.trace_client import _json self.assertNotIn("tags", json.loads(_json("App", "n", None, {"none": None}))) diff --git a/trace_client/trace_client.py b/trace_client/trace_client.py index b57041a..4c30e03 100644 --- a/trace_client/trace_client.py +++ b/trace_client/trace_client.py @@ -8,8 +8,10 @@ """ from __future__ import annotations +import decimal import json import logging +import numbers import os import queue import re @@ -272,8 +274,9 @@ def report(self, name: str, value: Optional[float] = None, tags: Optional[Mapping[str, str]] = None) -> None: """Report that ``name`` happened, with an optional numeric value and optional string tags. Returns immediately; see the class docstring. - A blank ``name`` sends nothing. A NaN or infinite ``value``, and any - tag whose key or value is ``None``, is left out of the event.""" + A blank ``name`` sends nothing. A ``value`` that is NaN, infinite or + not a real number (a string, a bool), and any tag whose key or value + is ``None``, is left out of the event; the event is still sent.""" if self._queue is None: return try: @@ -384,10 +387,28 @@ def _with_install(tags: Dict[str, str], install_id: Optional[str]) -> Dict[str, return merged +def _number(value: object) -> Optional[float]: + """``value`` as a number JSON can carry, or ``None`` for one it cannot: + anything that is not a real number (a string, a bool), NaN and infinity. + A ``Decimal`` or numpy integer becomes a float rather than costing the + event its place: ``json.dumps`` would refuse it, and the event with it.""" + if isinstance(value, bool) or not isinstance(value, (numbers.Real, decimal.Decimal)): + return None + if type(value) not in (int, float): + try: + value = float(value) + except (OverflowError, ValueError): + return None + if value != value or value in (float("inf"), float("-inf")): + return None + return value # type: ignore[return-value] + + def _json(application: str, name: str, value: Optional[float], tags: Optional[Mapping[str, str]]) -> bytes: payload: Dict[str, object] = {"application": application, "name": name} - if value is not None and value == value and value not in (float("inf"), float("-inf")): - payload["value"] = value + number = _number(value) + if number is not None: + payload["value"] = number if tags: clean = {str(k): str(v) for k, v in tags.items() if k is not None and v is not None} if clean: From daf6dbd0e450a1dc53384e0db3668e9b02696d29 Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Tue, 10 Nov 2026 19:56:02 -0700 Subject: [PATCH 2/3] Name only the number types the tests cover, and rewrap the README line Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++-- trace_client/trace_client.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5667c1f..85f65f0 100644 --- a/README.md +++ b/README.md @@ -155,8 +155,8 @@ There is no PyPI package yet; the file is the distribution. `value` is omitted when not given, when it is NaN or infinite (JSON has no such numbers), and when it is not a real number at all — a string such as `"3"`, or a `bool` — though the event itself is still sent. Any other real -number (a `Decimal`, a numpy integer) is sent as a float; `tags` always holds at least `version`, and -`install` when the client has an [installation ID](#every-event-carries-a-random-installation-id). A tag whose +number (a `Decimal`, a `Fraction`) is sent as a float. `tags` always holds at +least `version`, and `install` when the client has an [installation ID](#every-event-carries-a-random-installation-id). A tag whose key or value is `None` is left out, and every other key and value is sent as a string. An event with a blank `name` is not sent at all. The server assigns the timestamp. A `201` is success; anything else is logged at `DEBUG` and dropped. diff --git a/trace_client/trace_client.py b/trace_client/trace_client.py index 4c30e03..f1db2b3 100644 --- a/trace_client/trace_client.py +++ b/trace_client/trace_client.py @@ -390,7 +390,7 @@ def _with_install(tags: Dict[str, str], install_id: Optional[str]) -> Dict[str, def _number(value: object) -> Optional[float]: """``value`` as a number JSON can carry, or ``None`` for one it cannot: anything that is not a real number (a string, a bool), NaN and infinity. - A ``Decimal`` or numpy integer becomes a float rather than costing the + A ``Decimal`` or ``Fraction`` becomes a float rather than costing the event its place: ``json.dumps`` would refuse it, and the event with it.""" if isinstance(value, bool) or not isinstance(value, (numbers.Real, decimal.Decimal)): return None From dd16fdab29fa44f2809e317d965c830d6ba146ae Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Tue, 10 Nov 2026 19:58:10 -0700 Subject: [PATCH 3/3] Import decimal and fractions at the top of the test module, like its other imports Co-Authored-By: Claude Opus 5.5 --- tests/test_trace_client.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_trace_client.py b/tests/test_trace_client.py index 6a656c4..1365144 100644 --- a/tests/test_trace_client.py +++ b/tests/test_trace_client.py @@ -1,6 +1,8 @@ """Drives the client against a real HTTP server on a loopback port -- the standard library's own, so the tests have no more dependencies than the client does.""" +import decimal +import fractions import json import logging import os @@ -616,8 +618,6 @@ def test_json_leaves_out_a_value_that_is_not_a_real_number_but_keeps_the_event(s "%r is not a number the server can take; the event goes without it" % (odd,)) def test_json_sends_other_real_numbers_as_floats(self): - import decimal - import fractions from trace_client.trace_client import _json self.assertEqual(1.5, json.loads(_json("App", "n", decimal.Decimal("1.5"), None))["value"]) self.assertEqual(0.25, json.loads(_json("App", "n", fractions.Fraction(1, 4), None))["value"]) @@ -628,7 +628,6 @@ def test_json_sends_other_real_numbers_as_floats(self): "%r has no finite float; it is left out rather than raising" % (unsendable,)) def test_report_with_a_decimal_value_still_delivers_the_event(self): - import decimal client = TraceClient(self.base_url, "MyGame", "1.2.3", key="k") try: client.report("score", decimal.Decimal("2.5"))