Skip to content

perf(json): Avoid exceptions when typing JSON numbers (JAVA-536) - #5783

Merged
runningcode merged 2 commits into
mainfrom
no/java-536-json-number-parsing
Jul 27, 2026
Merged

perf(json): Avoid exceptions when typing JSON numbers (JAVA-536)#5783
runningcode merged 2 commits into
mainfrom
no/java-536-json-number-parsing

Conversation

@runningcode

@runningcode runningcode commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

📜 Description

JsonObjectDeserializer.nextNumber() (used when deserializing arbitrary/unknown JSON into a generic Map/List tree) decided whether a JSON number was an int or a double by calling reader.nextInt() and catching the NumberFormatException it throws for every non-integer value, then falling back to reader.nextDouble(). Because NumberFormatException fills in a stack trace, every floating-point value (timestamps, measurements, …) paid the cost of a thrown exception.

This replaces the exception-driven typing by reading the literal as a double once and narrowing it back to an int only when the value is integral and fits:

final double value = reader.nextDouble();
final int intValue = (int) value;
if (intValue == value) {
  return intValue;
}
return value;

Return types are intentionally unchangedInteger for values that are integral and fit an int, Double otherwise (fractional or out of int range). This matters: callers read integers back out of the generic tree and cast them (e.g. ReplayRecording casts rrweb source/type values to Integer/int), and defined fields such as MeasurementValue.value and context values like thread.id would otherwise change on the wire (44.0). Only the internal path to those same results changes; no exceptions are thrown.

Note that reader.nextDouble() is used rather than Double.parseDouble(reader.nextString()): it reuses the reader's already-buffered numeric token instead of materializing a String and re-parsing it, and it keeps the reader's non-finite guard, so a literal that overflows to infinity (1e400) is still rejected as malformed JSON instead of being stored as Infinity. (NaN/Infinity literals never reach this method — the reader tokenizes them as STRING, not NUMBER, even in lenient mode.)

For context: Gson and Moshi both return Double for all numbers when deserializing arbitrary JSON. We deliberately do not match that here, because the generic tree feeds code that depends on the int/double distinction.

One deliberate narrowing versus the old code: the previous reader.nextLong() fallback is gone, so nextNumber() no longer returns Long. Integers beyond int range were already returned as Double by the old path (nextInt() failed, nextDouble() then succeeded), so this only removes a branch that was unreachable for well-formed numbers; values above 2^53 still lose precision exactly as before.

💡 Motivation and Context

Part of JAVA-536 (optimize vendored libraries for startup performance). While benchmarking whether the vendored gson JSON streaming code should be replaced with moshi, this exception-based number typing showed up as a self-inflicted cost. The moshi comparison itself concluded a swap is not worthwhile (the vendored gson engine is as fast or faster, and moshi would add Okio + churn ~80 files); full data is in JAVA-536.

📈 Benchmark

Parsing one sentry_event.json into a generic object tree, median ms per 1000 iterations (desktop JVM, 5 warmup rounds, median of 10):

Scenario Before After
Isolated number typing (recursive read, number handling only differs) ~33 ms ~16 ms
End-to-end via JsonObjectDeserializer.nextObjectOrNull() ~37 ms ~34 ms

Removing the exceptions roughly halves the number-typing work in isolation. End-to-end the win is more modest (~5%), because JsonObjectDeserializer's token-stack allocations dominate that path; payloads with many floating-point values (e.g. profiling measurements) benefit more.

Numbers were measured with a local micro-benchmark (not committed, to keep this PR to the fix + tests); the before/after was obtained by stashing the change and re-running. Methodology: preload the payload into memory, parse it nextObjectOrNull ×1000 per timed run, 2–5 warmup rounds discarded, report the median. The benchmark measured the Double.parseDouble(nextString()) variant; the committed nextDouble() version avoids an additional String allocation and re-parse per number, so it should be at least as fast. Full moshi-vs-gson comparison data is in JAVA-536.

💚 How did you test it?

  • Full sentry unit test suite passes (3434 tests, 0 failures).
  • Added edge-case tests to JsonObjectDeserializerTest covering negative ints/doubles, integral vs. fractional exponent notation (1e2Integer, 2.5e-3Double), whole-valued decimals (1.0Integer), integers beyond int range (→ Double, never Long), the Int.MAX_VALUE boundary, and a literal that overflows to infinity (1e400 → rejected) — locking in the return types that must not change.

📝 Checklist

  • I added GH Issue ID & Linear ID
  • I added tests to verify the changes.
  • No new PII added or SDK only sends newly added PII if sendDefaultPII is enabled.
  • I updated the docs if needed.
  • I updated the wizard if needed.
  • Review from the native team if needed.
  • No breaking change or entry added to the changelog.
  • No breaking change for hybrid SDKs or communicated to hybrid SDKs.

@linear-code

linear-code Bot commented Jul 17, 2026

Copy link
Copy Markdown

JAVA-536

@sentry

sentry Bot commented Jul 17, 2026

Copy link
Copy Markdown

📲 Install Builds

Android

🔗 App Name App ID Version Configuration
SDK Size io.sentry.tests.size 8.50.0 (1) release

⚙️ sentry-android Build Distribution Settings

@runningcode
runningcode force-pushed the no/java-536-json-number-parsing branch from 5f8ae11 to 0998a8a Compare July 17, 2026 13:33
@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Performance metrics 🚀

  Plain With Sentry Diff
Startup time 286.72 ms 355.73 ms 69.02 ms
Size 0 B 0 B 0 B

Baseline results on branch: main

Startup times

Revision Plain With Sentry Diff
05aa61d 326.06 ms 385.46 ms 59.40 ms
bb0ff41 315.84 ms 350.76 ms 34.92 ms
806307f 357.85 ms 424.64 ms 66.79 ms
d501a7e 307.33 ms 341.94 ms 34.61 ms
0ee65e9 321.06 ms 361.24 ms 40.18 ms
ed33deb 334.19 ms 362.30 ms 28.11 ms
9fbb112 401.87 ms 515.87 ms 114.00 ms
b8bd880 314.56 ms 336.50 ms 21.94 ms
5b1a06b 315.40 ms 353.33 ms 37.94 ms
6edfca2 316.43 ms 398.90 ms 82.46 ms

App size

Revision Plain With Sentry Diff
05aa61d 0 B 0 B 0 B
bb0ff41 0 B 0 B 0 B
806307f 1.58 MiB 2.10 MiB 533.42 KiB
d501a7e 0 B 0 B 0 B
0ee65e9 0 B 0 B 0 B
ed33deb 1.58 MiB 2.13 MiB 559.52 KiB
9fbb112 1.58 MiB 2.11 MiB 539.18 KiB
b8bd880 1.58 MiB 2.29 MiB 722.92 KiB
5b1a06b 0 B 0 B 0 B
6edfca2 1.58 MiB 2.13 MiB 559.07 KiB

Previous results on branch: no/java-536-json-number-parsing

Startup times

Revision Plain With Sentry Diff
aa6d42e 314.08 ms 353.82 ms 39.73 ms
df001d4 316.39 ms 356.04 ms 39.65 ms

App size

Revision Plain With Sentry Diff
aa6d42e 0 B 0 B 0 B
df001d4 0 B 0 B 0 B

@runningcode
runningcode marked this pull request as ready for review July 17, 2026 14:08

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 0998a8a. Configure here.

Comment thread sentry/src/main/java/io/sentry/JsonObjectDeserializer.java

@romtsn romtsn left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pre-approving, but I'd double-check the bot comment

@runningcode
runningcode force-pushed the no/java-536-json-number-parsing branch from 0998a8a to 64d79e7 Compare July 27, 2026 10:42
runningcode and others added 2 commits July 27, 2026 12:54
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) <noreply@anthropic.com>
@runningcode
runningcode force-pushed the no/java-536-json-number-parsing branch from 64d79e7 to bf765cf Compare July 27, 2026 10:54
@runningcode
runningcode enabled auto-merge (squash) July 27, 2026 11:06
@runningcode
runningcode merged commit 3dd4c87 into main Jul 27, 2026
72 checks passed
@runningcode
runningcode deleted the no/java-536-json-number-parsing branch July 27, 2026 11:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants