Skip to content

Commit 7399716

Browse files
authored
Merge pull request #5609 from getsentry/perf/sdk-overhead-reduction-iso8601-compat
fix(core): [SDK Overhead Reduction 13] Preserve ISO8601 utility compatibility
2 parents e839983 + 385411f commit 7399716

2 files changed

Lines changed: 259 additions & 5 deletions

File tree

sentry/src/main/java/io/sentry/vendor/SentryIso8601Utils.java

Lines changed: 120 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
package io.sentry.vendor;
66

7+
import java.util.Calendar;
8+
import java.util.GregorianCalendar;
9+
import java.util.SimpleTimeZone;
710
import org.jetbrains.annotations.ApiStatus;
811
import org.jetbrains.annotations.NotNull;
912

@@ -14,6 +17,7 @@ public final class SentryIso8601Utils {
1417
private static final long MILLIS_PER_MINUTE = 60L * MILLIS_PER_SECOND;
1518
private static final long MILLIS_PER_HOUR = 60L * MILLIS_PER_MINUTE;
1619
private static final long MILLIS_PER_DAY = 24L * MILLIS_PER_HOUR;
20+
private static final long GREGORIAN_CUTOVER_MILLIS = -12219292800000L;
1721
private static final int DAYS_0000_TO_1970 = 719468;
1822

1923
private SentryIso8601Utils() {}
@@ -33,14 +37,18 @@ public static long parseTimestamp(final @NotNull String timestamp) {
3337
}
3438

3539
final int day = parseInt(timestamp, offset, offset += 2);
36-
validateDate(year, month, day);
3740

3841
if (!checkOffset(timestamp, offset, 'T')) {
39-
if (offset != length) {
40-
throw new IllegalArgumentException("Invalid date separator");
42+
if (offset == length) {
43+
return dateOnlyEpochMillis(year, month, day);
44+
}
45+
final char timezoneIndicator = timestamp.charAt(offset);
46+
if (timezoneIndicator == 'Z' || timezoneIndicator == '+' || timezoneIndicator == '-') {
47+
return dateOnlyEpochMillisWithTimezone(timestamp, length, offset, year, month, day);
4148
}
42-
return epochMillis(year, month, day, 0, 0, 0, 0, 0);
49+
throw new IllegalArgumentException("Invalid date separator");
4350
}
51+
validateDate(year, month, day);
4452
offset++;
4553

4654
final int hour = parseInt(timestamp, offset, offset += 2);
@@ -92,10 +100,12 @@ public static long parseTimestamp(final @NotNull String timestamp) {
92100
}
93101

94102
final int timezoneOffsetMillis;
103+
final boolean allowTrailingCharacters;
95104
final char timezoneIndicator = timestamp.charAt(offset);
96105
if (timezoneIndicator == 'Z') {
97106
timezoneOffsetMillis = 0;
98107
offset++;
108+
allowTrailingCharacters = true;
99109
} else if (timezoneIndicator == '+' || timezoneIndicator == '-') {
100110
final int sign = timezoneIndicator == '+' ? 1 : -1;
101111
offset++;
@@ -110,18 +120,28 @@ public static long parseTimestamp(final @NotNull String timestamp) {
110120
validateTimezone(timezoneHour, timezoneMinute);
111121
timezoneOffsetMillis =
112122
sign * (int) (timezoneHour * MILLIS_PER_HOUR + timezoneMinute * MILLIS_PER_MINUTE);
123+
allowTrailingCharacters = false;
113124
} else {
114125
throw new IllegalArgumentException("Invalid time zone indicator");
115126
}
116127

117-
if (offset != length) {
128+
if (!allowTrailingCharacters && offset != length) {
118129
throw new IllegalArgumentException("Invalid trailing characters");
119130
}
120131

132+
if (isBeforeGregorianCutover(year, month, day)) {
133+
return epochMillisWithCalendar(
134+
year, month, day, hour, minute, second, millisecond, timezoneOffsetMillis);
135+
}
136+
121137
return epochMillis(year, month, day, hour, minute, second, millisecond, timezoneOffsetMillis);
122138
}
123139

124140
public static @NotNull String formatTimestamp(final long millis) {
141+
if (millis < GREGORIAN_CUTOVER_MILLIS) {
142+
return formatTimestampWithCalendar(millis);
143+
}
144+
125145
final long epochDay = Math.floorDiv(millis, MILLIS_PER_DAY);
126146
int millisOfDay = (int) Math.floorMod(millis, MILLIS_PER_DAY);
127147

@@ -151,6 +171,97 @@ public static long parseTimestamp(final @NotNull String timestamp) {
151171
return timestamp.toString();
152172
}
153173

174+
private static long dateOnlyEpochMillis(final int year, final int month, final int day) {
175+
return new GregorianCalendar(year, month - 1, day).getTimeInMillis();
176+
}
177+
178+
private static long dateOnlyEpochMillisWithTimezone(
179+
final @NotNull String timestamp,
180+
final int length,
181+
int offset,
182+
final int year,
183+
final int month,
184+
final int day) {
185+
final int timezoneOffsetMillis;
186+
final boolean allowTrailingCharacters;
187+
final char timezoneIndicator = timestamp.charAt(offset);
188+
if (timezoneIndicator == 'Z') {
189+
timezoneOffsetMillis = 0;
190+
offset++;
191+
allowTrailingCharacters = true;
192+
} else if (timezoneIndicator == '+' || timezoneIndicator == '-') {
193+
final int sign = timezoneIndicator == '+' ? 1 : -1;
194+
offset++;
195+
final int timezoneHour = parseInt(timestamp, offset, offset += 2);
196+
int timezoneMinute = 0;
197+
if (checkOffset(timestamp, offset, ':')) {
198+
offset++;
199+
}
200+
if (length >= offset + 2) {
201+
timezoneMinute = parseInt(timestamp, offset, offset += 2);
202+
}
203+
validateTimezone(timezoneHour, timezoneMinute);
204+
timezoneOffsetMillis =
205+
sign * (int) (timezoneHour * MILLIS_PER_HOUR + timezoneMinute * MILLIS_PER_MINUTE);
206+
allowTrailingCharacters = false;
207+
} else {
208+
throw new IllegalArgumentException("Invalid time zone indicator");
209+
}
210+
211+
if (!allowTrailingCharacters && offset != length) {
212+
throw new IllegalArgumentException("Invalid trailing characters");
213+
}
214+
215+
if (isBeforeGregorianCutover(year, month, day)) {
216+
return epochMillisWithCalendar(year, month, day, 0, 0, 0, 0, timezoneOffsetMillis);
217+
}
218+
validateDate(year, month, day);
219+
return epochMillis(year, month, day, 0, 0, 0, 0, timezoneOffsetMillis);
220+
}
221+
222+
private static long epochMillisWithCalendar(
223+
final int year,
224+
final int month,
225+
final int day,
226+
final int hour,
227+
final int minute,
228+
final int second,
229+
final int millisecond,
230+
final int timezoneOffsetMillis) {
231+
final GregorianCalendar calendar = new GregorianCalendar(new SimpleTimeZone(timezoneOffsetMillis, "GMT"));
232+
calendar.setLenient(false);
233+
calendar.set(Calendar.YEAR, year);
234+
calendar.set(Calendar.MONTH, month - 1);
235+
calendar.set(Calendar.DAY_OF_MONTH, day);
236+
calendar.set(Calendar.HOUR_OF_DAY, hour);
237+
calendar.set(Calendar.MINUTE, minute);
238+
calendar.set(Calendar.SECOND, second);
239+
calendar.set(Calendar.MILLISECOND, millisecond);
240+
return calendar.getTimeInMillis();
241+
}
242+
243+
private static @NotNull String formatTimestampWithCalendar(final long millis) {
244+
final GregorianCalendar calendar = new GregorianCalendar(new SimpleTimeZone(0, "UTC"));
245+
calendar.setTimeInMillis(millis);
246+
247+
final StringBuilder timestamp = new StringBuilder("yyyy-MM-ddThh:mm:ss.sssZ".length());
248+
padInt(timestamp, calendar.get(Calendar.YEAR), "yyyy".length());
249+
timestamp.append('-');
250+
padInt(timestamp, calendar.get(Calendar.MONTH) + 1, "MM".length());
251+
timestamp.append('-');
252+
padInt(timestamp, calendar.get(Calendar.DAY_OF_MONTH), "dd".length());
253+
timestamp.append('T');
254+
padInt(timestamp, calendar.get(Calendar.HOUR_OF_DAY), "hh".length());
255+
timestamp.append(':');
256+
padInt(timestamp, calendar.get(Calendar.MINUTE), "mm".length());
257+
timestamp.append(':');
258+
padInt(timestamp, calendar.get(Calendar.SECOND), "ss".length());
259+
timestamp.append('.');
260+
padInt(timestamp, calendar.get(Calendar.MILLISECOND), "sss".length());
261+
timestamp.append('Z');
262+
return timestamp.toString();
263+
}
264+
154265
private static long epochMillis(
155266
final int year,
156267
final int month,
@@ -190,6 +301,10 @@ private static int[] epochDayToYearMonthDay(long epochDay) {
190301
return new int[] {year + (month <= 2 ? 1 : 0), month, day};
191302
}
192303

304+
private static boolean isBeforeGregorianCutover(final int year, final int month, final int day) {
305+
return year < 1582 || (year == 1582 && (month < 10 || (month == 10 && day < 15)));
306+
}
307+
193308
private static void validateDate(final int year, final int month, final int day) {
194309
if (year < 1 || month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) {
195310
throw new IllegalArgumentException("Invalid date");

sentry/src/test/java/io/sentry/DateUtilsTest.kt

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package io.sentry
22

3+
import io.sentry.vendor.gson.internal.bind.util.ISO8601Utils
4+
import java.text.ParsePosition
35
import java.time.Instant
46
import java.time.LocalDateTime
57
import java.time.ZoneId
68
import java.time.format.DateTimeFormatter
79
import java.util.Date
10+
import java.util.TimeZone
811
import kotlin.test.Test
912
import kotlin.test.assertEquals
1013
import kotlin.test.assertFailsWith
@@ -142,6 +145,132 @@ class DateUtilsTest {
142145
input.forEach { assertEquals(it.value, DateUtils.getTimestampFromMillis(it.key)) }
143146
}
144147

148+
@Test
149+
fun `Fast timestamp formatter matches previous ISO8601 formatter`() {
150+
val input =
151+
listOf(
152+
"1582-10-04T00:00:00.000Z",
153+
"1582-10-15T00:00:00.000Z",
154+
"1900-03-01T00:00:00.000Z",
155+
"1969-12-31T23:59:59.999Z",
156+
"1970-01-01T00:00:00.000Z",
157+
"1999-12-31T23:59:59.999Z",
158+
"2000-02-29T12:34:56.789Z",
159+
"2020-03-27T08:52:58.015Z",
160+
"2024-02-29T23:59:59.001Z",
161+
"2100-03-01T00:00:00.000Z",
162+
"2400-02-29T23:59:59.999Z",
163+
)
164+
165+
input
166+
.map { ISO8601Utils.parse(it, ParsePosition(0)).time }
167+
.forEach {
168+
assertEquals(
169+
ISO8601Utils.format(Date(it), true),
170+
DateUtils.getTimestampFromMillis(it),
171+
"millis=$it",
172+
)
173+
}
174+
}
175+
176+
@Test
177+
fun `Fast timestamp parser matches previous ISO8601 parser`() {
178+
val input =
179+
listOf(
180+
"2020-03-27T08:52Z",
181+
"2020-03-27T08:52:58Z",
182+
"2020-03-27T08:52:58.015Z",
183+
"20200327T085258.015Z",
184+
"2020-03-27T10:52:58.015+02:00",
185+
"2020-03-27T10:52:58.015+0200",
186+
"2020-03-27T10:52:58.015+02",
187+
"2020-03-27T05:52:58.015-03:00",
188+
"2020-03-27T05:22:58.015-0330",
189+
"2020-03-27T08:52:58.1Z",
190+
"2020-03-27T08:52:58.12Z",
191+
"2020-03-27T08:52:58.123456Z",
192+
"2020-03-27T08:52:58Ztrailing",
193+
"2016-12-31T23:59:60Z",
194+
"1582-10-04T00:00:00.000Z",
195+
"1582-10-15T00:00:00.000Z",
196+
"1900-03-01T00:00:00.000Z",
197+
"2000-02-29T12:34:56.789Z",
198+
"2100-03-01T00:00:00.000Z",
199+
)
200+
201+
input.forEach {
202+
assertEquals(
203+
ISO8601Utils.parse(it, ParsePosition(0)).time,
204+
DateUtils.getDateTime(it).time,
205+
"timestamp=$it",
206+
)
207+
}
208+
}
209+
210+
@Test
211+
fun `Fast timestamp parser matches previous ISO8601 parser for date-only values`() {
212+
withDefaultTimeZone("America/Los_Angeles") {
213+
val input = listOf("2020-03-27", "20200327", "2020-02-30")
214+
215+
input.forEach {
216+
assertEquals(
217+
ISO8601Utils.parse(it, ParsePosition(0)).time,
218+
DateUtils.getDateTime(it).time,
219+
"timestamp=$it",
220+
)
221+
}
222+
}
223+
}
224+
225+
@Test
226+
fun `Fast timestamp parser matches previous ISO8601 parser for date-only values with timezone`() {
227+
val input =
228+
listOf(
229+
"2020-03-27Z",
230+
"2020-03-27+02:00",
231+
"2020-03-27+0200",
232+
"2020-03-27+02",
233+
"2020-03-27-03:30",
234+
"20200327Z",
235+
"20200327+02:00",
236+
"20200327-0330",
237+
)
238+
239+
input.forEach {
240+
assertEquals(
241+
ISO8601Utils.parse(it, ParsePosition(0)).time,
242+
DateUtils.getDateTime(it).time,
243+
"timestamp=$it",
244+
)
245+
}
246+
}
247+
248+
@Test
249+
fun `Fast timestamp parser rejects invalid date-only values with timezone like previous ISO8601 parser`() {
250+
val timestamp = "2020-02-30Z"
251+
252+
assertFailsWith<Exception> { ISO8601Utils.parse(timestamp, ParsePosition(0)) }
253+
assertFailsWith<IllegalArgumentException> { DateUtils.getDateTime(timestamp) }
254+
}
255+
256+
@Test
257+
fun `Fast timestamp parser rejects date-time without timezone like previous ISO8601 parser`() {
258+
val input = listOf("2020-03-27T08:52", "2020-03-27T08:52:58", "2020-03-27T08:52:58.015")
259+
260+
input.forEach {
261+
assertFailsWith<Exception>("timestamp=$it") { ISO8601Utils.parse(it, ParsePosition(0)) }
262+
assertFailsWith<IllegalArgumentException>("timestamp=$it") { DateUtils.getDateTime(it) }
263+
}
264+
}
265+
266+
@Test
267+
fun `Fast timestamp parser rejects Gregorian cutover gap like previous ISO8601 parser`() {
268+
val timestamp = "1582-10-10T00:00:00.000Z"
269+
270+
assertFailsWith<Exception> { ISO8601Utils.parse(timestamp, ParsePosition(0)) }
271+
assertFailsWith<IllegalArgumentException> { DateUtils.getDateTime(timestamp) }
272+
}
273+
145274
@Test
146275
fun `Millis formats to Date`() {
147276
val millis = 1591533492L * 1000L + 631
@@ -185,6 +314,16 @@ class DateUtilsTest {
185314
private fun convertDate(date: Date): LocalDateTime =
186315
Instant.ofEpochMilli(date.time).atZone(utcTimeZone).toLocalDateTime()
187316

317+
private fun withDefaultTimeZone(timeZoneId: String, block: () -> Unit) {
318+
val previousTimeZone = TimeZone.getDefault()
319+
try {
320+
TimeZone.setDefault(TimeZone.getTimeZone(timeZoneId))
321+
block()
322+
} finally {
323+
TimeZone.setDefault(previousTimeZone)
324+
}
325+
}
326+
188327
private fun assertClose(expected: Double, actual: Double?) {
189328
assertNotNull(actual)
190329
val diff = Math.abs(expected - actual)

0 commit comments

Comments
 (0)