Skip to content

Commit e839983

Browse files
authored
Merge pull request #5602 from getsentry/perf/sdk-overhead-reduction-fast-dates
perf(core): [SDK Overhead Reduction 11] Replace ISO8601 timestamp handling
2 parents 69edcfa + 5534d0f commit e839983

8 files changed

Lines changed: 397 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Internal
66

7+
- Reduce timestamp parsing and formatting overhead with Sentry-specific ISO-8601 handling. ([#5602](https://github.com/getsentry/sentry-java/pull/5602))
78
- Reduce JSON serialization overhead by creating the reflection serializer only when unknown-object fallback serialization is needed. ([#5601](https://github.com/getsentry/sentry-java/pull/5601))
89
- Reduce JSON serialization overhead by allocating reflection cycle-tracking state only when reflection serialization is used. ([#5600](https://github.com/getsentry/sentry-java/pull/5600))
910
- Reduce context serialization overhead by sorting key snapshots with arrays instead of temporary lists. ([#5599](https://github.com/getsentry/sentry-java/pull/5599))

THIRD_PARTY_NOTICES.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,22 @@ limitations under the License.
6262

6363
---
6464

65+
## Howard Hinnant — Date Algorithms (Public Domain)
66+
67+
**Source:** https://howardhinnant.github.io/date_algorithms.html<br>
68+
**License:** Public Domain<br>
69+
**Copyright:** None; public domain dedication by Howard Hinnant
70+
71+
### Scope
72+
73+
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`.
74+
75+
```
76+
Consider these donated to the public domain.
77+
```
78+
79+
---
80+
6581
## Android Open Source Project — Base64 (Apache 2.0)
6682

6783
**Source:** https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/util/Base64.java<br>

sentry/api/sentry.api

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8076,6 +8076,11 @@ public class io/sentry/vendor/Base64 {
80768076
public static fun encodeToString ([BIII)Ljava/lang/String;
80778077
}
80788078

8079+
public final class io/sentry/vendor/SentryIso8601Utils {
8080+
public static fun formatTimestamp (J)Ljava/lang/String;
8081+
public static fun parseTimestamp (Ljava/lang/String;)J
8082+
}
8083+
80798084
public class io/sentry/vendor/gson/internal/bind/util/ISO8601Utils {
80808085
public static final field TIMEZONE_UTC Ljava/util/TimeZone;
80818086
public fun <init> ()V

sentry/src/main/java/io/sentry/Breadcrumb.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -849,7 +849,12 @@ public static final class JsonKeys {
849849
public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger)
850850
throws IOException {
851851
writer.beginObject();
852-
writer.name(JsonKeys.TIMESTAMP).value(logger, getTimestamp());
852+
writer
853+
.name(JsonKeys.TIMESTAMP)
854+
.value(
855+
timestampMs != null
856+
? DateUtils.getTimestampFromMillis(timestampMs)
857+
: DateUtils.getTimestamp(getTimestamp()));
853858
if (message != null) {
854859
writer.name(JsonKeys.MESSAGE).value(message);
855860
}

sentry/src/main/java/io/sentry/DateUtils.java

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
11
package io.sentry;
22

3-
import io.sentry.vendor.gson.internal.bind.util.ISO8601Utils;
3+
import io.sentry.vendor.SentryIso8601Utils;
44
import java.math.BigDecimal;
55
import java.math.RoundingMode;
6-
import java.text.ParseException;
7-
import java.text.ParsePosition;
86
import java.util.Date;
97
import org.jetbrains.annotations.ApiStatus;
108
import org.jetbrains.annotations.NotNull;
119
import org.jetbrains.annotations.Nullable;
1210

1311
/** Utilities to deal with dates */
1412
@ApiStatus.Internal
13+
@SuppressWarnings("JavaUtilDate")
1514
public final class DateUtils {
1615

1716
private DateUtils() {}
@@ -35,8 +34,8 @@ private DateUtils() {}
3534
public static @NotNull Date getDateTime(final @NotNull String timestamp)
3635
throws IllegalArgumentException {
3736
try {
38-
return ISO8601Utils.parse(timestamp, new ParsePosition(0));
39-
} catch (ParseException e) {
37+
return getDateTime(SentryIso8601Utils.parseTimestamp(timestamp));
38+
} catch (IllegalArgumentException e) {
4039
throw new IllegalArgumentException("timestamp is not ISO format " + timestamp);
4140
}
4241
}
@@ -47,7 +46,6 @@ private DateUtils() {}
4746
* @param timestamp millis eg 1581410911.988 (1581410911 seconds and 988 millis)
4847
* @return the UTC Date
4948
*/
50-
@SuppressWarnings("JdkObsolete")
5149
public static @NotNull Date getDateTimeWithMillisPrecision(final @NotNull String timestamp)
5250
throws IllegalArgumentException {
5351
try {
@@ -65,7 +63,17 @@ private DateUtils() {}
6563
* @return the UTC/ISO 8601 timestamp
6664
*/
6765
public static @NotNull String getTimestamp(final @NotNull Date date) {
68-
return ISO8601Utils.format(date, true);
66+
return getTimestampFromMillis(date.getTime());
67+
}
68+
69+
/**
70+
* Get the UTC/ISO 8601 timestamp from millis.
71+
*
72+
* @param millis the UTC millis from the epoch
73+
* @return the UTC/ISO 8601 timestamp
74+
*/
75+
static @NotNull String getTimestampFromMillis(final long millis) {
76+
return SentryIso8601Utils.formatTimestamp(millis);
6977
}
7078

7179
/**
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
// Civil date conversion algorithms adapted from Howard Hinnant's date algorithms.
2+
// Placed in the public domain by Howard Hinnant.
3+
// https://howardhinnant.github.io/date_algorithms.html
4+
5+
package io.sentry.vendor;
6+
7+
import org.jetbrains.annotations.ApiStatus;
8+
import org.jetbrains.annotations.NotNull;
9+
10+
@ApiStatus.Internal
11+
public final class SentryIso8601Utils {
12+
13+
private static final long MILLIS_PER_SECOND = 1000L;
14+
private static final long MILLIS_PER_MINUTE = 60L * MILLIS_PER_SECOND;
15+
private static final long MILLIS_PER_HOUR = 60L * MILLIS_PER_MINUTE;
16+
private static final long MILLIS_PER_DAY = 24L * MILLIS_PER_HOUR;
17+
private static final int DAYS_0000_TO_1970 = 719468;
18+
19+
private SentryIso8601Utils() {}
20+
21+
public static long parseTimestamp(final @NotNull String timestamp) {
22+
final int length = timestamp.length();
23+
int offset = 0;
24+
25+
final int year = parseInt(timestamp, offset, offset += 4);
26+
if (checkOffset(timestamp, offset, '-')) {
27+
offset++;
28+
}
29+
30+
final int month = parseInt(timestamp, offset, offset += 2);
31+
if (checkOffset(timestamp, offset, '-')) {
32+
offset++;
33+
}
34+
35+
final int day = parseInt(timestamp, offset, offset += 2);
36+
validateDate(year, month, day);
37+
38+
if (!checkOffset(timestamp, offset, 'T')) {
39+
if (offset != length) {
40+
throw new IllegalArgumentException("Invalid date separator");
41+
}
42+
return epochMillis(year, month, day, 0, 0, 0, 0, 0);
43+
}
44+
offset++;
45+
46+
final int hour = parseInt(timestamp, offset, offset += 2);
47+
if (checkOffset(timestamp, offset, ':')) {
48+
offset++;
49+
}
50+
51+
final int minute = parseInt(timestamp, offset, offset += 2);
52+
if (checkOffset(timestamp, offset, ':')) {
53+
offset++;
54+
}
55+
56+
int second = 0;
57+
int millisecond = 0;
58+
if (length > offset) {
59+
final char c = timestamp.charAt(offset);
60+
if (c != 'Z' && c != '+' && c != '-') {
61+
second = parseInt(timestamp, offset, offset += 2);
62+
if (second > 59 && second < 63) {
63+
second = 59;
64+
}
65+
if (checkOffset(timestamp, offset, '.')) {
66+
offset++;
67+
final int endOffset = indexOfNonDigit(timestamp, offset);
68+
if (endOffset == offset) {
69+
throw new IllegalArgumentException("Missing millisecond digits");
70+
}
71+
final int parseEndOffset = Math.min(endOffset, offset + 3);
72+
final int fraction = parseInt(timestamp, offset, parseEndOffset);
73+
switch (parseEndOffset - offset) {
74+
case 1:
75+
millisecond = fraction * 100;
76+
break;
77+
case 2:
78+
millisecond = fraction * 10;
79+
break;
80+
default:
81+
millisecond = fraction;
82+
break;
83+
}
84+
offset = endOffset;
85+
}
86+
}
87+
}
88+
validateTime(hour, minute, second, millisecond);
89+
90+
if (length <= offset) {
91+
throw new IllegalArgumentException("No time zone indicator");
92+
}
93+
94+
final int timezoneOffsetMillis;
95+
final char timezoneIndicator = timestamp.charAt(offset);
96+
if (timezoneIndicator == 'Z') {
97+
timezoneOffsetMillis = 0;
98+
offset++;
99+
} else if (timezoneIndicator == '+' || timezoneIndicator == '-') {
100+
final int sign = timezoneIndicator == '+' ? 1 : -1;
101+
offset++;
102+
final int timezoneHour = parseInt(timestamp, offset, offset += 2);
103+
int timezoneMinute = 0;
104+
if (checkOffset(timestamp, offset, ':')) {
105+
offset++;
106+
}
107+
if (length >= offset + 2) {
108+
timezoneMinute = parseInt(timestamp, offset, offset += 2);
109+
}
110+
validateTimezone(timezoneHour, timezoneMinute);
111+
timezoneOffsetMillis =
112+
sign * (int) (timezoneHour * MILLIS_PER_HOUR + timezoneMinute * MILLIS_PER_MINUTE);
113+
} else {
114+
throw new IllegalArgumentException("Invalid time zone indicator");
115+
}
116+
117+
if (offset != length) {
118+
throw new IllegalArgumentException("Invalid trailing characters");
119+
}
120+
121+
return epochMillis(year, month, day, hour, minute, second, millisecond, timezoneOffsetMillis);
122+
}
123+
124+
public static @NotNull String formatTimestamp(final long millis) {
125+
final long epochDay = Math.floorDiv(millis, MILLIS_PER_DAY);
126+
int millisOfDay = (int) Math.floorMod(millis, MILLIS_PER_DAY);
127+
128+
final int[] yearMonthDay = epochDayToYearMonthDay(epochDay);
129+
final int hour = millisOfDay / (int) MILLIS_PER_HOUR;
130+
millisOfDay -= hour * (int) MILLIS_PER_HOUR;
131+
final int minute = millisOfDay / (int) MILLIS_PER_MINUTE;
132+
millisOfDay -= minute * (int) MILLIS_PER_MINUTE;
133+
final int second = millisOfDay / (int) MILLIS_PER_SECOND;
134+
final int millisecond = millisOfDay - second * (int) MILLIS_PER_SECOND;
135+
136+
final StringBuilder timestamp = new StringBuilder("yyyy-MM-ddThh:mm:ss.sssZ".length());
137+
padInt(timestamp, yearMonthDay[0], "yyyy".length());
138+
timestamp.append('-');
139+
padInt(timestamp, yearMonthDay[1], "MM".length());
140+
timestamp.append('-');
141+
padInt(timestamp, yearMonthDay[2], "dd".length());
142+
timestamp.append('T');
143+
padInt(timestamp, hour, "hh".length());
144+
timestamp.append(':');
145+
padInt(timestamp, minute, "mm".length());
146+
timestamp.append(':');
147+
padInt(timestamp, second, "ss".length());
148+
timestamp.append('.');
149+
padInt(timestamp, millisecond, "sss".length());
150+
timestamp.append('Z');
151+
return timestamp.toString();
152+
}
153+
154+
private static long epochMillis(
155+
final int year,
156+
final int month,
157+
final int day,
158+
final int hour,
159+
final int minute,
160+
final int second,
161+
final int millisecond,
162+
final int timezoneOffsetMillis) {
163+
return daysFromYearMonthDay(year, month, day) * MILLIS_PER_DAY
164+
+ hour * MILLIS_PER_HOUR
165+
+ minute * MILLIS_PER_MINUTE
166+
+ second * MILLIS_PER_SECOND
167+
+ millisecond
168+
- timezoneOffsetMillis;
169+
}
170+
171+
private static long daysFromYearMonthDay(int year, final int month, final int day) {
172+
year -= month <= 2 ? 1 : 0;
173+
final long era = Math.floorDiv(year, 400);
174+
final int yearOfEra = (int) (year - era * 400);
175+
final int dayOfYear = (153 * (month + (month > 2 ? -3 : 9)) + 2) / 5 + day - 1;
176+
final int dayOfEra = yearOfEra * 365 + yearOfEra / 4 - yearOfEra / 100 + dayOfYear;
177+
return era * 146097 + dayOfEra - DAYS_0000_TO_1970;
178+
}
179+
180+
private static int[] epochDayToYearMonthDay(long epochDay) {
181+
epochDay += DAYS_0000_TO_1970;
182+
final long era = Math.floorDiv(epochDay, 146097);
183+
final int dayOfEra = (int) (epochDay - era * 146097);
184+
final int yearOfEra = (dayOfEra - dayOfEra / 1460 + dayOfEra / 36524 - dayOfEra / 146096) / 365;
185+
final int year = (int) (yearOfEra + era * 400);
186+
final int dayOfYear = dayOfEra - (365 * yearOfEra + yearOfEra / 4 - yearOfEra / 100);
187+
final int monthPrime = (5 * dayOfYear + 2) / 153;
188+
final int day = dayOfYear - (153 * monthPrime + 2) / 5 + 1;
189+
final int month = monthPrime < 10 ? monthPrime + 3 : monthPrime - 9;
190+
return new int[] {year + (month <= 2 ? 1 : 0), month, day};
191+
}
192+
193+
private static void validateDate(final int year, final int month, final int day) {
194+
if (year < 1 || month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) {
195+
throw new IllegalArgumentException("Invalid date");
196+
}
197+
}
198+
199+
private static void validateTime(
200+
final int hour, final int minute, final int second, final int millisecond) {
201+
if (hour < 0
202+
|| hour > 23
203+
|| minute < 0
204+
|| minute > 59
205+
|| second < 0
206+
|| second > 59
207+
|| millisecond < 0
208+
|| millisecond > 999) {
209+
throw new IllegalArgumentException("Invalid time");
210+
}
211+
}
212+
213+
private static void validateTimezone(final int hour, final int minute) {
214+
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
215+
throw new IllegalArgumentException("Invalid time zone");
216+
}
217+
}
218+
219+
private static int daysInMonth(final int year, final int month) {
220+
switch (month) {
221+
case 2:
222+
return isLeapYear(year) ? 29 : 28;
223+
case 4:
224+
case 6:
225+
case 9:
226+
case 11:
227+
return 30;
228+
default:
229+
return 31;
230+
}
231+
}
232+
233+
private static boolean isLeapYear(final int year) {
234+
return (year % 4 == 0) && (year % 100 != 0 || year % 400 == 0);
235+
}
236+
237+
private static boolean checkOffset(
238+
final @NotNull String value, final int offset, final char expected) {
239+
return offset < value.length() && value.charAt(offset) == expected;
240+
}
241+
242+
private static int parseInt(
243+
final @NotNull String value, final int beginIndex, final int endIndex) {
244+
if (beginIndex < 0 || endIndex > value.length() || beginIndex >= endIndex) {
245+
throw new NumberFormatException(value);
246+
}
247+
248+
int result = 0;
249+
for (int i = beginIndex; i < endIndex; i++) {
250+
final char c = value.charAt(i);
251+
if (c < '0' || c > '9') {
252+
throw new NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex));
253+
}
254+
result = result * 10 + c - '0';
255+
}
256+
return result;
257+
}
258+
259+
private static void padInt(
260+
final @NotNull StringBuilder buffer, final int value, final int length) {
261+
if (value < 0) {
262+
buffer.append('-');
263+
padInt(buffer, -value, length);
264+
return;
265+
}
266+
final String strValue = Integer.toString(value);
267+
for (int i = length - strValue.length(); i > 0; i--) {
268+
buffer.append('0');
269+
}
270+
buffer.append(strValue);
271+
}
272+
273+
private static int indexOfNonDigit(final @NotNull String string, final int offset) {
274+
for (int i = offset; i < string.length(); i++) {
275+
final char c = string.charAt(i);
276+
if (c < '0' || c > '9') {
277+
return i;
278+
}
279+
}
280+
return string.length();
281+
}
282+
}

0 commit comments

Comments
 (0)