From d3f5ecf14599841eb97e8873db8c2e96908e800d Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 23 Jun 2026 11:02:25 +0200 Subject: [PATCH 1/2] perf(core): Replace ISO8601 timestamp handling Replace the Calendar-backed vendored ISO8601 formatting and parsing path with a small Sentry-specific utility that works directly from epoch milliseconds. This avoids formatter and parser allocations on timestamp-heavy serialization paths while keeping the existing DateUtils API as the facade. Co-Authored-By: Claude --- THIRD_PARTY_NOTICES.md | 16 + .../src/main/java/io/sentry/Breadcrumb.java | 7 +- sentry/src/main/java/io/sentry/DateUtils.java | 21 +- .../java/io/sentry/SentryIso8601Utils.java | 281 ++++++++++++++++++ .../src/test/java/io/sentry/DateUtilsTest.kt | 65 ++++ 5 files changed, 382 insertions(+), 8 deletions(-) create mode 100644 sentry/src/main/java/io/sentry/SentryIso8601Utils.java diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 5a48d567fac..f34138048c0 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -62,6 +62,22 @@ limitations under the License. --- +## Howard Hinnant — Date Algorithms (Public Domain) + +**Source:** https://howardhinnant.github.io/date_algorithms.html
+**License:** Public Domain
+**Copyright:** Copyright (c) 2011-2021 Howard Hinnant + +### Scope + +The Sentry Java SDK includes adapted civil date conversion algorithms from Howard Hinnant's date algorithms for UTC ISO 8601 timestamp parsing and formatting. The code resides in `io.sentry.SentryIso8601Utils`. + +``` +This paper and the algorithms contained herein are placed in the public domain. +``` + +--- + ## Android Open Source Project — Base64 (Apache 2.0) **Source:** https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/util/Base64.java
diff --git a/sentry/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java index d122d1459bf..c45b15b4711 100644 --- a/sentry/src/main/java/io/sentry/Breadcrumb.java +++ b/sentry/src/main/java/io/sentry/Breadcrumb.java @@ -823,7 +823,12 @@ public static final class JsonKeys { public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) throws IOException { writer.beginObject(); - writer.name(JsonKeys.TIMESTAMP).value(logger, getTimestamp()); + writer + .name(JsonKeys.TIMESTAMP) + .value( + timestampMs != null + ? DateUtils.getTimestampFromMillis(timestampMs) + : DateUtils.getTimestamp(getTimestamp())); if (message != null) { writer.name(JsonKeys.MESSAGE).value(message); } diff --git a/sentry/src/main/java/io/sentry/DateUtils.java b/sentry/src/main/java/io/sentry/DateUtils.java index 31a8dcd76ea..2d2ab3a10f4 100644 --- a/sentry/src/main/java/io/sentry/DateUtils.java +++ b/sentry/src/main/java/io/sentry/DateUtils.java @@ -2,11 +2,8 @@ import static io.sentry.vendor.gson.internal.bind.util.ISO8601Utils.TIMEZONE_UTC; -import io.sentry.vendor.gson.internal.bind.util.ISO8601Utils; import java.math.BigDecimal; import java.math.RoundingMode; -import java.text.ParseException; -import java.text.ParsePosition; import java.util.Calendar; import java.util.Date; import org.jetbrains.annotations.ApiStatus; @@ -15,6 +12,7 @@ /** Utilities to deal with dates */ @ApiStatus.Internal +@SuppressWarnings("JavaUtilDate") public final class DateUtils { private DateUtils() {} @@ -39,8 +37,8 @@ private DateUtils() {} public static @NotNull Date getDateTime(final @NotNull String timestamp) throws IllegalArgumentException { try { - return ISO8601Utils.parse(timestamp, new ParsePosition(0)); - } catch (ParseException e) { + return getDateTime(SentryIso8601Utils.parseTimestamp(timestamp)); + } catch (IllegalArgumentException e) { throw new IllegalArgumentException("timestamp is not ISO format " + timestamp); } } @@ -51,7 +49,6 @@ private DateUtils() {} * @param timestamp millis eg 1581410911.988 (1581410911 seconds and 988 millis) * @return the UTC Date */ - @SuppressWarnings("JdkObsolete") public static @NotNull Date getDateTimeWithMillisPrecision(final @NotNull String timestamp) throws IllegalArgumentException { try { @@ -69,7 +66,17 @@ private DateUtils() {} * @return the UTC/ISO 8601 timestamp */ public static @NotNull String getTimestamp(final @NotNull Date date) { - return ISO8601Utils.format(date, true); + return getTimestampFromMillis(date.getTime()); + } + + /** + * Get the UTC/ISO 8601 timestamp from millis. + * + * @param millis the UTC millis from the epoch + * @return the UTC/ISO 8601 timestamp + */ + static @NotNull String getTimestampFromMillis(final long millis) { + return SentryIso8601Utils.formatTimestamp(millis); } /** diff --git a/sentry/src/main/java/io/sentry/SentryIso8601Utils.java b/sentry/src/main/java/io/sentry/SentryIso8601Utils.java new file mode 100644 index 00000000000..919ecea436b --- /dev/null +++ b/sentry/src/main/java/io/sentry/SentryIso8601Utils.java @@ -0,0 +1,281 @@ +// Civil date conversion algorithms adapted from Howard Hinnant's date algorithms. +// Copyright (c) 2011-2021 Howard Hinnant. +// Licensed under the Public Domain. +// https://howardhinnant.github.io/date_algorithms.html + +package io.sentry; + +import org.jetbrains.annotations.NotNull; + +final class SentryIso8601Utils { + + private static final long MILLIS_PER_SECOND = 1000L; + private static final long MILLIS_PER_MINUTE = 60L * MILLIS_PER_SECOND; + private static final long MILLIS_PER_HOUR = 60L * MILLIS_PER_MINUTE; + private static final long MILLIS_PER_DAY = 24L * MILLIS_PER_HOUR; + private static final int DAYS_0000_TO_1970 = 719468; + + private SentryIso8601Utils() {} + + static long parseTimestamp(final @NotNull String timestamp) { + final int length = timestamp.length(); + int offset = 0; + + final int year = parseInt(timestamp, offset, offset += 4); + if (checkOffset(timestamp, offset, '-')) { + offset++; + } + + final int month = parseInt(timestamp, offset, offset += 2); + if (checkOffset(timestamp, offset, '-')) { + offset++; + } + + final int day = parseInt(timestamp, offset, offset += 2); + validateDate(year, month, day); + + if (!checkOffset(timestamp, offset, 'T')) { + if (offset != length) { + throw new IllegalArgumentException("Invalid date separator"); + } + return epochMillis(year, month, day, 0, 0, 0, 0, 0); + } + offset++; + + final int hour = parseInt(timestamp, offset, offset += 2); + if (checkOffset(timestamp, offset, ':')) { + offset++; + } + + final int minute = parseInt(timestamp, offset, offset += 2); + if (checkOffset(timestamp, offset, ':')) { + offset++; + } + + int second = 0; + int millisecond = 0; + if (length > offset) { + final char c = timestamp.charAt(offset); + if (c != 'Z' && c != '+' && c != '-') { + second = parseInt(timestamp, offset, offset += 2); + if (second > 59 && second < 63) { + second = 59; + } + if (checkOffset(timestamp, offset, '.')) { + offset++; + final int endOffset = indexOfNonDigit(timestamp, offset); + if (endOffset == offset) { + throw new IllegalArgumentException("Missing millisecond digits"); + } + final int parseEndOffset = Math.min(endOffset, offset + 3); + final int fraction = parseInt(timestamp, offset, parseEndOffset); + switch (parseEndOffset - offset) { + case 1: + millisecond = fraction * 100; + break; + case 2: + millisecond = fraction * 10; + break; + default: + millisecond = fraction; + break; + } + offset = endOffset; + } + } + } + validateTime(hour, minute, second, millisecond); + + if (length <= offset) { + throw new IllegalArgumentException("No time zone indicator"); + } + + final int timezoneOffsetMillis; + final char timezoneIndicator = timestamp.charAt(offset); + if (timezoneIndicator == 'Z') { + timezoneOffsetMillis = 0; + offset++; + } else if (timezoneIndicator == '+' || timezoneIndicator == '-') { + final int sign = timezoneIndicator == '+' ? 1 : -1; + offset++; + final int timezoneHour = parseInt(timestamp, offset, offset += 2); + int timezoneMinute = 0; + if (checkOffset(timestamp, offset, ':')) { + offset++; + } + if (length >= offset + 2) { + timezoneMinute = parseInt(timestamp, offset, offset += 2); + } + validateTimezone(timezoneHour, timezoneMinute); + timezoneOffsetMillis = + sign * (int) (timezoneHour * MILLIS_PER_HOUR + timezoneMinute * MILLIS_PER_MINUTE); + } else { + throw new IllegalArgumentException("Invalid time zone indicator"); + } + + if (offset != length) { + throw new IllegalArgumentException("Invalid trailing characters"); + } + + return epochMillis(year, month, day, hour, minute, second, millisecond, timezoneOffsetMillis); + } + + static @NotNull String formatTimestamp(final long millis) { + final long epochDay = Math.floorDiv(millis, MILLIS_PER_DAY); + int millisOfDay = (int) Math.floorMod(millis, MILLIS_PER_DAY); + + final int[] yearMonthDay = epochDayToYearMonthDay(epochDay); + final int hour = millisOfDay / (int) MILLIS_PER_HOUR; + millisOfDay -= hour * (int) MILLIS_PER_HOUR; + final int minute = millisOfDay / (int) MILLIS_PER_MINUTE; + millisOfDay -= minute * (int) MILLIS_PER_MINUTE; + final int second = millisOfDay / (int) MILLIS_PER_SECOND; + final int millisecond = millisOfDay - second * (int) MILLIS_PER_SECOND; + + final StringBuilder timestamp = new StringBuilder("yyyy-MM-ddThh:mm:ss.sssZ".length()); + padInt(timestamp, yearMonthDay[0], "yyyy".length()); + timestamp.append('-'); + padInt(timestamp, yearMonthDay[1], "MM".length()); + timestamp.append('-'); + padInt(timestamp, yearMonthDay[2], "dd".length()); + timestamp.append('T'); + padInt(timestamp, hour, "hh".length()); + timestamp.append(':'); + padInt(timestamp, minute, "mm".length()); + timestamp.append(':'); + padInt(timestamp, second, "ss".length()); + timestamp.append('.'); + padInt(timestamp, millisecond, "sss".length()); + timestamp.append('Z'); + return timestamp.toString(); + } + + private static long epochMillis( + final int year, + final int month, + final int day, + final int hour, + final int minute, + final int second, + final int millisecond, + final int timezoneOffsetMillis) { + return daysFromYearMonthDay(year, month, day) * MILLIS_PER_DAY + + hour * MILLIS_PER_HOUR + + minute * MILLIS_PER_MINUTE + + second * MILLIS_PER_SECOND + + millisecond + - timezoneOffsetMillis; + } + + private static long daysFromYearMonthDay(int year, final int month, final int day) { + year -= month <= 2 ? 1 : 0; + final long era = Math.floorDiv(year, 400); + final int yearOfEra = (int) (year - era * 400); + final int dayOfYear = (153 * (month + (month > 2 ? -3 : 9)) + 2) / 5 + day - 1; + final int dayOfEra = yearOfEra * 365 + yearOfEra / 4 - yearOfEra / 100 + dayOfYear; + return era * 146097 + dayOfEra - DAYS_0000_TO_1970; + } + + private static int[] epochDayToYearMonthDay(long epochDay) { + epochDay += DAYS_0000_TO_1970; + final long era = Math.floorDiv(epochDay, 146097); + final int dayOfEra = (int) (epochDay - era * 146097); + final int yearOfEra = (dayOfEra - dayOfEra / 1460 + dayOfEra / 36524 - dayOfEra / 146096) / 365; + final int year = (int) (yearOfEra + era * 400); + final int dayOfYear = dayOfEra - (365 * yearOfEra + yearOfEra / 4 - yearOfEra / 100); + final int monthPrime = (5 * dayOfYear + 2) / 153; + final int day = dayOfYear - (153 * monthPrime + 2) / 5 + 1; + final int month = monthPrime < 10 ? monthPrime + 3 : monthPrime - 9; + return new int[] {year + (month <= 2 ? 1 : 0), month, day}; + } + + private static void validateDate(final int year, final int month, final int day) { + if (year < 1 || month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) { + throw new IllegalArgumentException("Invalid date"); + } + } + + private static void validateTime( + final int hour, final int minute, final int second, final int millisecond) { + if (hour < 0 + || hour > 23 + || minute < 0 + || minute > 59 + || second < 0 + || second > 59 + || millisecond < 0 + || millisecond > 999) { + throw new IllegalArgumentException("Invalid time"); + } + } + + private static void validateTimezone(final int hour, final int minute) { + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) { + throw new IllegalArgumentException("Invalid time zone"); + } + } + + private static int daysInMonth(final int year, final int month) { + switch (month) { + case 2: + return isLeapYear(year) ? 29 : 28; + case 4: + case 6: + case 9: + case 11: + return 30; + default: + return 31; + } + } + + private static boolean isLeapYear(final int year) { + return (year % 4 == 0) && (year % 100 != 0 || year % 400 == 0); + } + + private static boolean checkOffset( + final @NotNull String value, final int offset, final char expected) { + return offset < value.length() && value.charAt(offset) == expected; + } + + private static int parseInt( + final @NotNull String value, final int beginIndex, final int endIndex) { + if (beginIndex < 0 || endIndex > value.length() || beginIndex >= endIndex) { + throw new NumberFormatException(value); + } + + int result = 0; + for (int i = beginIndex; i < endIndex; i++) { + final char c = value.charAt(i); + if (c < '0' || c > '9') { + throw new NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex)); + } + result = result * 10 + c - '0'; + } + return result; + } + + private static void padInt( + final @NotNull StringBuilder buffer, final int value, final int length) { + if (value < 0) { + buffer.append('-'); + padInt(buffer, -value, length); + return; + } + final String strValue = Integer.toString(value); + for (int i = length - strValue.length(); i > 0; i--) { + buffer.append('0'); + } + buffer.append(strValue); + } + + private static int indexOfNonDigit(final @NotNull String string, final int offset) { + for (int i = offset; i < string.length(); i++) { + final char c = string.charAt(i); + if (c < '0' || c > '9') { + return i; + } + } + return string.length(); + } +} diff --git a/sentry/src/test/java/io/sentry/DateUtilsTest.kt b/sentry/src/test/java/io/sentry/DateUtilsTest.kt index 9e234b50c1b..0746b1a1b5f 100644 --- a/sentry/src/test/java/io/sentry/DateUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/DateUtilsTest.kt @@ -7,6 +7,7 @@ import java.time.format.DateTimeFormatter import java.util.Date import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -34,6 +35,54 @@ class DateUtilsTest { assertEquals("2020-03-27T08:52:58.000Z", timestamp) } + @Test + fun `When ISO date has offset`() { + val input = + mapOf( + "2020-03-27T10:52:58.015+02:00" to "2020-03-27T08:52:58.015Z", + "2020-03-27T10:52:58.015+0200" to "2020-03-27T08:52:58.015Z", + "2020-03-27T10:52:58.015+02" to "2020-03-27T08:52:58.015Z", + "2020-03-27T05:52:58.015-03:00" to "2020-03-27T08:52:58.015Z", + ) + + input.forEach { + val timestamp = convertDate(DateUtils.getDateTime(it.key)).format(isoFormat) + + assertEquals(it.value, timestamp) + } + } + + @Test + fun `When ISO date uses compact separators`() { + val date = DateUtils.getDateTime("20200327T085258.015Z") + + val utcDate = convertDate(date) + val timestamp = utcDate.format(isoFormat) + + assertEquals("2020-03-27T08:52:58.015Z", timestamp) + } + + @Test + fun `When ISO date has short fraction`() { + val input = + mapOf( + "2020-03-27T08:52:58.1Z" to "2020-03-27T08:52:58.100Z", + "2020-03-27T08:52:58.12Z" to "2020-03-27T08:52:58.120Z", + "2020-03-27T08:52:58.123456Z" to "2020-03-27T08:52:58.123Z", + ) + + input.forEach { + val timestamp = convertDate(DateUtils.getDateTime(it.key)).format(isoFormat) + + assertEquals(it.value, timestamp) + } + } + + @Test + fun `When ISO date is invalid`() { + assertFailsWith { DateUtils.getDateTime("2020-02-30T08:52:58Z") } + } + @Test fun `Converts from Date to ISO 8601 and back to Date`() { val currentDate = DateUtils.getCurrentDateTime() @@ -78,6 +127,21 @@ class DateUtilsTest { assertTrue { utcCurrentDate.minusSeconds(1).isBefore(utcDate) } } + @Test + fun `Formats millis to ISO 8601 timestamp`() { + val input = + mapOf( + Instant.parse("1970-01-01T00:00:00.000Z").toEpochMilli() to "1970-01-01T00:00:00.000Z", + Instant.parse("1969-12-31T23:59:59.999Z").toEpochMilli() to "1969-12-31T23:59:59.999Z", + Instant.parse("2000-02-29T12:34:56.789Z").toEpochMilli() to "2000-02-29T12:34:56.789Z", + Instant.parse("1900-03-01T00:00:00.000Z").toEpochMilli() to "1900-03-01T00:00:00.000Z", + Instant.parse("2100-03-01T00:00:00.000Z").toEpochMilli() to "2100-03-01T00:00:00.000Z", + Instant.parse("2400-02-29T23:59:59.999Z").toEpochMilli() to "2400-02-29T23:59:59.999Z", + ) + + input.forEach { assertEquals(it.value, DateUtils.getTimestampFromMillis(it.key)) } + } + @Test fun `Millis formats to Date`() { val millis = 1591533492L * 1000L + 631 @@ -86,6 +150,7 @@ class DateUtilsTest { val utcActual = convertDate(actual) val timestamp = utcActual.format(isoFormat) + assertEquals(millis, actual.time) assertEquals("2020-06-07T12:38:12.631Z", timestamp) } From fb38dbe9ab030844d9c666890ed56339e5063dde Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 23 Jun 2026 11:20:11 +0200 Subject: [PATCH 2/2] ref(core): Move ISO8601 utility to vendor package Move the Sentry ISO8601 helper under the vendor package and mark it as internal API so the adapted public-domain date conversion code is isolated from core SDK classes. Update attribution metadata to reflect the public-domain dedication source. Co-Authored-By: Claude --- THIRD_PARTY_NOTICES.md | 6 +++--- sentry/api/sentry.api | 5 +++++ sentry/src/main/java/io/sentry/DateUtils.java | 1 + .../io/sentry/{ => vendor}/SentryIso8601Utils.java | 13 +++++++------ 4 files changed, 16 insertions(+), 9 deletions(-) rename sentry/src/main/java/io/sentry/{ => vendor}/SentryIso8601Utils.java (96%) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index f34138048c0..8931402f98e 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -66,14 +66,14 @@ limitations under the License. **Source:** https://howardhinnant.github.io/date_algorithms.html
**License:** Public Domain
-**Copyright:** Copyright (c) 2011-2021 Howard Hinnant +**Copyright:** None; public domain dedication by Howard Hinnant ### Scope -The Sentry Java SDK includes adapted civil date conversion algorithms from Howard Hinnant's date algorithms for UTC ISO 8601 timestamp parsing and formatting. The code resides in `io.sentry.SentryIso8601Utils`. +The Sentry Java SDK includes adapted civil date conversion algorithms from Howard Hinnant's date algorithms for UTC ISO 8601 timestamp parsing and formatting. The code resides in `io.sentry.vendor.SentryIso8601Utils`. ``` -This paper and the algorithms contained herein are placed in the public domain. +Consider these donated to the public domain. ``` --- diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 45268a9a894..df043e92e1c 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -8076,6 +8076,11 @@ public class io/sentry/vendor/Base64 { public static fun encodeToString ([BIII)Ljava/lang/String; } +public final class io/sentry/vendor/SentryIso8601Utils { + public static fun formatTimestamp (J)Ljava/lang/String; + public static fun parseTimestamp (Ljava/lang/String;)J +} + public class io/sentry/vendor/gson/internal/bind/util/ISO8601Utils { public static final field TIMEZONE_UTC Ljava/util/TimeZone; public fun ()V diff --git a/sentry/src/main/java/io/sentry/DateUtils.java b/sentry/src/main/java/io/sentry/DateUtils.java index 2d2ab3a10f4..3fae5cf8264 100644 --- a/sentry/src/main/java/io/sentry/DateUtils.java +++ b/sentry/src/main/java/io/sentry/DateUtils.java @@ -2,6 +2,7 @@ import static io.sentry.vendor.gson.internal.bind.util.ISO8601Utils.TIMEZONE_UTC; +import io.sentry.vendor.SentryIso8601Utils; import java.math.BigDecimal; import java.math.RoundingMode; import java.util.Calendar; diff --git a/sentry/src/main/java/io/sentry/SentryIso8601Utils.java b/sentry/src/main/java/io/sentry/vendor/SentryIso8601Utils.java similarity index 96% rename from sentry/src/main/java/io/sentry/SentryIso8601Utils.java rename to sentry/src/main/java/io/sentry/vendor/SentryIso8601Utils.java index 919ecea436b..66fdc34e80b 100644 --- a/sentry/src/main/java/io/sentry/SentryIso8601Utils.java +++ b/sentry/src/main/java/io/sentry/vendor/SentryIso8601Utils.java @@ -1,13 +1,14 @@ // Civil date conversion algorithms adapted from Howard Hinnant's date algorithms. -// Copyright (c) 2011-2021 Howard Hinnant. -// Licensed under the Public Domain. +// Placed in the public domain by Howard Hinnant. // https://howardhinnant.github.io/date_algorithms.html -package io.sentry; +package io.sentry.vendor; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; -final class SentryIso8601Utils { +@ApiStatus.Internal +public final class SentryIso8601Utils { private static final long MILLIS_PER_SECOND = 1000L; private static final long MILLIS_PER_MINUTE = 60L * MILLIS_PER_SECOND; @@ -17,7 +18,7 @@ final class SentryIso8601Utils { private SentryIso8601Utils() {} - static long parseTimestamp(final @NotNull String timestamp) { + public static long parseTimestamp(final @NotNull String timestamp) { final int length = timestamp.length(); int offset = 0; @@ -120,7 +121,7 @@ static long parseTimestamp(final @NotNull String timestamp) { return epochMillis(year, month, day, hour, minute, second, millisecond, timezoneOffsetMillis); } - static @NotNull String formatTimestamp(final long millis) { + public static @NotNull String formatTimestamp(final long millis) { final long epochDay = Math.floorDiv(millis, MILLIS_PER_DAY); int millisOfDay = (int) Math.floorMod(millis, MILLIS_PER_DAY);