From a2d0240ba64cb2f8796430e685e59b48245c860a Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 17 Jul 2026 15:33:50 +0200 Subject: [PATCH 1/2] perf(json): Avoid exceptions when typing JSON numbers (JAVA-536) JsonObjectDeserializer typed numbers by calling nextInt() and catching the NumberFormatException it throws for every non-integer value, then falling back to nextDouble(). For payloads full of floating-point values (timestamps, measurements) this threw and filled a stack trace on nearly every number, dominating the cost of deserializing arbitrary objects. Parse the value as a double once and narrow it back to an int only when it is integral and fits, which avoids the throws. Return types are unchanged (Integer for values that fit an int, Double otherwise), so callers that read integers out of the generic object tree are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../io/sentry/JsonObjectDeserializer.java | 19 ++++--- .../io/sentry/JsonObjectDeserializerTest.kt | 50 +++++++++++++++++++ 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/sentry/src/main/java/io/sentry/JsonObjectDeserializer.java b/sentry/src/main/java/io/sentry/JsonObjectDeserializer.java index 0916f6e82d5..cd1d99d66fb 100644 --- a/sentry/src/main/java/io/sentry/JsonObjectDeserializer.java +++ b/sentry/src/main/java/io/sentry/JsonObjectDeserializer.java @@ -172,17 +172,16 @@ private boolean handlePrimitive(NextValue callback) throws IOException { } private Object nextNumber(JsonObjectReader reader) throws IOException { - try { - return reader.nextInt(); - } catch (Exception exception) { - // Need to try/fail as there are no int/double/long tokens. + // JSON has no int/double token distinction. Probing with reader.nextInt() and catching the + // NumberFormatException it throws for every non-integer (e.g. timestamps) dominated the cost of + // deserializing arbitrary objects. Read once as a double and narrow to an int only when the + // value is integral and fits, which reproduces the previous return types without any throws. + final double value = reader.nextDouble(); + final int intValue = (int) value; + if (intValue == value) { + return intValue; } - try { - return reader.nextDouble(); - } catch (Exception exception) { - // Need to try/fail as there are no int/double/long tokens. - } - return reader.nextLong(); + return value; } private @Nullable Token getCurrentToken() { diff --git a/sentry/src/test/java/io/sentry/JsonObjectDeserializerTest.kt b/sentry/src/test/java/io/sentry/JsonObjectDeserializerTest.kt index 04e2aaceba0..f2bb1d79044 100644 --- a/sentry/src/test/java/io/sentry/JsonObjectDeserializerTest.kt +++ b/sentry/src/test/java/io/sentry/JsonObjectDeserializerTest.kt @@ -1,8 +1,10 @@ package io.sentry +import java.io.IOException import java.io.StringReader import java.lang.Exception import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNull import kotlin.test.fail import org.junit.Test @@ -42,6 +44,54 @@ class JsonObjectDeserializerTest { assertEquals(1.1, actual) } + // A value that is integral and fits an int is typed as Integer (regardless of how it was + // written); anything else is a Double. This matches the behavior prior to removing the + // exception-based number typing. + + @Test + fun `deserialize negative int`() { + assertEquals(-5, deserialize("-5")) + } + + @Test + fun `deserialize negative double`() { + assertEquals(-3.14, deserialize("-3.14")) + } + + @Test + fun `deserialize integral exponent notation as int`() { + assertEquals(100, deserialize("1e2")) + assertEquals(100, deserialize("1E2")) + } + + @Test + fun `deserialize fractional exponent notation as double`() { + assertEquals(0.0025, deserialize("2.5e-3")) + } + + @Test + fun `deserialize whole-valued decimal as int`() { + assertEquals(1, deserialize("1.0")) + } + + @Test + fun `deserialize integer larger than int range as double`() { + assertEquals(1.0e10, deserialize("10000000000")) + assertEquals(2147483648.0, deserialize("2147483648")) + } + + @Test + fun `deserialize max int as int`() { + assertEquals(Int.MAX_VALUE, deserialize("2147483647")) + } + + @Test + fun `deserialize rejects literal overflowing to infinity`() { + // Strict JSON forbids non-finite numbers, so an out-of-range literal must fail rather than be + // stored as Infinity. + assertFailsWith { deserialize("1e400") } + } + @Test fun `deserialize array`() { val json = "[\"a\",\"b\"]" From bf765cfd9ecb4c63d18ff06b78d79b9b4ca2876c Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 17 Jul 2026 15:33:50 +0200 Subject: [PATCH 2/2] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8fc67b6b53..c2e8119a422 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ ### Performance - Reduce the number of SDK threads: `LifecycleWatcher` now schedules the session-end task on the shared timer executor instead of creating a dedicated `java.util.Timer` thread ([#5819](https://github.com/getsentry/sentry-java/pull/5819)) +- Speed up deserialization of arbitrary JSON objects by typing numbers without throwing exceptions ([#5783](https://github.com/getsentry/sentry-java/pull/5783)) ## 8.50.1