Skip to content

Commit 9cb04f5

Browse files
runningcodeclaude
authored andcommitted
perf: Replace java.net.URI with custom string parsing in Dsn (#5448)
* perf: Replace java.net.URI with custom string parsing in Dsn The Dsn constructor used `new URI(dsnString).normalize()` to parse the DSN string, which is known to be slow on Android. Since `retrieveParsedDsn()` is called on the main thread during `Sentry.init()` via `preInitConfigurations()`, this directly impacts app startup time. Replace the URI-based parsing with manual indexOf/substring operations. The only remaining URI construction is from pre-parsed components (`new URI(scheme, null, host, port, path, null, null)`), which is significantly cheaper since the JDK doesn't need to re-parse a string. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: Add tests for custom DSN string parsing Cover edge cases specific to the manual indexOf/substring parser: null input, missing scheme separator, no slash after host, multiple path segments, port with path, multiple double slashes, query string with port, empty secret key, and a realistic Sentry DSN with org id. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * changelog: Add entry for custom DSN parser Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(dsn): Strip URI fragments and support IPv6 hosts Harden the custom DSN parser and convert its tests to Google Truth. - Strip URI fragments (#...) alongside query strings so they no longer leak into the project id and corrupt the constructed Sentry URI. - Detect bracketed IPv6 literal hosts when locating the port separator, restoring behavior that java.net.URI handled. - Narrow the parse error handling from catch (Throwable) to the expected exceptions, which stops swallowing Error and removes the doubled exception message. - Extract the parsing steps into focused private helpers. - Convert DsnTest to Google Truth assertions. - Move the changelog entry to the Unreleased section, since 8.43.0 and 8.43.1 have already been released. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(dsn): Assert exception messages via Truth hasMessageThat Follow Truth's recommended pattern for exception testing: catch with assertFailsWith, then assert on the caught throwable with assertThat(ex).hasMessageThat(). Also assert the message in the previously bare throw-only cases so they can no longer pass on an unrelated exception. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(dsn): Give a clear error message for a malformed port Parse the port in a dedicated helper that reports the offending value ("Invalid DSN: Invalid port 'abc'.") instead of leaking the raw NumberFormatException text. Narrow the catch to URISyntaxException now that the port is the only parseInt, and add a test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8ed5abb commit 9cb04f5

5 files changed

Lines changed: 255 additions & 94 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
### Improvements
6+
7+
- Improve SDK init performance by replacing `java.net.URI` with custom string parsing for DSN ([#5448](https://github.com/getsentry/sentry-java/pull/5448))
8+
59
### Fixes
610

711
- Session Replay: Fix `VerifyError` in Compose masking under DexGuard/R8 obfuscation ([#5507](https://github.com/getsentry/sentry-java/pull/5507))

‎gradle/libs.versions.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ camerax-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "ca
238238
camerax-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax" }
239239
camerax-view = { module = "androidx.camera:camera-view", version.ref = "camerax" }
240240

241+
google-truth = { module = "com.google.truth:truth", version = "1.4.5" }
241242
hsqldb = { module = "org.hsqldb:hsqldb", version = "2.6.1" }
242243
javafaker = { module = "com.github.javafaker:javafaker", version = "1.0.2" }
243244
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }

‎sentry/build.gradle.kts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ dependencies {
2525
// tests
2626
testImplementation(kotlin(Config.kotlinStdLib))
2727
testImplementation(libs.awaitility.kotlin)
28+
testImplementation(libs.google.truth)
2829
testImplementation(libs.javafaker)
2930
testImplementation(libs.kotlin.test.junit)
3031
testImplementation(libs.mockito.kotlin)

‎sentry/src/main/java/io/sentry/Dsn.java‎

Lines changed: 106 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import io.sentry.util.Objects;
44
import java.net.URI;
5+
import java.net.URISyntaxException;
56
import java.util.regex.Matcher;
67
import java.util.regex.Pattern;
78
import org.jetbrains.annotations.NotNull;
@@ -17,98 +18,148 @@ final class Dsn {
1718
private final @NotNull URI sentryUri;
1819
private final @Nullable String orgId;
1920

20-
/*
21-
/ The project ID which the authenticated user is bound to.
22-
*/
21+
/** The project ID which the authenticated user is bound to. */
2322
public @NotNull String getProjectId() {
2423
return projectId;
2524
}
2625

27-
/*
28-
/ An optional path of which Sentry is hosted
29-
*/
26+
/** An optional path of which Sentry is hosted. */
3027
public @Nullable String getPath() {
3128
return path;
3229
}
3330

34-
/*
35-
/ The optional secret key to authenticate the SDK.
36-
*/
31+
/** The optional secret key to authenticate the SDK. */
3732
public @Nullable String getSecretKey() {
3833
return secretKey;
3934
}
4035

41-
/*
42-
/ The required public key to authenticate the SDK.
43-
*/
36+
/** The required public key to authenticate the SDK. */
4437
public @NotNull String getPublicKey() {
4538
return publicKey;
4639
}
4740

48-
/*
49-
/ The URI used to communicate with Sentry
50-
*/
41+
/** The org ID extracted from the host, or {@code null} when the host has no org prefix. */
42+
public @Nullable String getOrgId() {
43+
return orgId;
44+
}
45+
46+
/** The URI used to communicate with Sentry. */
5147
@NotNull
5248
URI getSentryUri() {
5349
return sentryUri;
5450
}
5551

52+
// Avoids java.net.URI for DSN parsing, which is slow on Android.
5653
Dsn(@Nullable String dsn) throws IllegalArgumentException {
54+
final String dsnString = Objects.requireNonNull(dsn, "The DSN is required.").trim();
55+
if (dsnString.isEmpty()) {
56+
throw new IllegalArgumentException("The DSN is empty.");
57+
}
58+
5759
try {
58-
final String dsnString = Objects.requireNonNull(dsn, "The DSN is required.").trim();
59-
if (dsnString.isEmpty()) {
60-
throw new IllegalArgumentException("The DSN is empty.");
60+
final int schemeEnd = dsnString.indexOf("://");
61+
if (schemeEnd < 0) {
62+
throw new IllegalArgumentException("Invalid DSN: Missing scheme.");
6163
}
62-
final URI uri = new URI(dsnString).normalize();
63-
final String scheme = uri.getScheme();
64-
if (!("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))) {
65-
throw new IllegalArgumentException("Invalid DSN scheme: " + scheme);
64+
final String scheme = dsnString.substring(0, schemeEnd);
65+
if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) {
66+
throw new IllegalArgumentException("Invalid DSN: Invalid scheme '" + scheme + "'.");
6667
}
6768

68-
String userInfo = uri.getUserInfo();
69-
if (userInfo == null || userInfo.isEmpty()) {
69+
final int authStart = schemeEnd + 3;
70+
final int atIndex = dsnString.indexOf('@', authStart);
71+
if (atIndex < 0) {
7072
throw new IllegalArgumentException("Invalid DSN: No public key provided.");
7173
}
72-
String[] keys = userInfo.split(":", -1);
73-
publicKey = keys[0];
74-
if (publicKey == null || publicKey.isEmpty()) {
74+
final String userInfo = dsnString.substring(authStart, atIndex);
75+
final int colonIndex = userInfo.indexOf(':');
76+
publicKey = colonIndex < 0 ? userInfo : userInfo.substring(0, colonIndex);
77+
secretKey = colonIndex < 0 ? null : userInfo.substring(colonIndex + 1);
78+
if (publicKey.isEmpty()) {
7579
throw new IllegalArgumentException("Invalid DSN: No public key provided.");
7680
}
77-
secretKey = keys.length > 1 ? keys[1] : null;
78-
String uriPath = uri.getPath();
79-
if (uriPath.endsWith("/")) {
80-
uriPath = uriPath.substring(0, uriPath.length() - 1);
81-
}
82-
int projectIdStart = uriPath.lastIndexOf("/") + 1;
83-
String path = uriPath.substring(0, projectIdStart);
84-
if (!path.endsWith("/")) {
85-
path += "/";
81+
82+
final String hostAndPath = stripQueryAndFragment(dsnString, atIndex + 1);
83+
final int firstSlash = hostAndPath.indexOf('/');
84+
if (firstSlash < 0) {
85+
throw new IllegalArgumentException("Invalid DSN: A Project Id is required.");
8686
}
87-
this.path = path;
88-
projectId = uriPath.substring(projectIdStart);
87+
88+
final String hostPort = hostAndPath.substring(0, firstSlash);
89+
final int portColon = portSeparatorIndex(hostPort);
90+
final String host = portColon < 0 ? hostPort : hostPort.substring(0, portColon);
91+
final int port = portColon < 0 ? -1 : parsePort(hostPort.substring(portColon + 1));
92+
93+
final String rawPath = stripTrailingSlash(collapseSlashes(hostAndPath.substring(firstSlash)));
94+
final int projectIdStart = rawPath.lastIndexOf('/') + 1;
95+
path = ensureTrailingSlash(rawPath.substring(0, projectIdStart));
96+
projectId = rawPath.substring(projectIdStart);
8997
if (projectId.isEmpty()) {
9098
throw new IllegalArgumentException("Invalid DSN: A Project Id is required.");
9199
}
92-
sentryUri =
93-
new URI(
94-
scheme, null, uri.getHost(), uri.getPort(), path + "api/" + projectId, null, null);
95-
96-
// Extract org ID from host (e.g., "o123.ingest.sentry.io" -> "123")
97-
String extractedOrgId = null;
98-
final String host = uri.getHost();
99-
if (host != null) {
100-
final Matcher matcher = ORG_ID_PATTERN.matcher(host);
101-
if (matcher.find()) {
102-
extractedOrgId = matcher.group(1);
103-
}
100+
101+
sentryUri = new URI(scheme, null, host, port, path + "api/" + projectId, null, null);
102+
orgId = extractOrgId(host);
103+
} catch (URISyntaxException e) {
104+
throw new IllegalArgumentException("Invalid DSN: " + e.getMessage(), e);
105+
}
106+
}
107+
108+
private static int parsePort(final @NotNull String portString) {
109+
try {
110+
return Integer.parseInt(portString);
111+
} catch (NumberFormatException e) {
112+
throw new IllegalArgumentException("Invalid DSN: Invalid port '" + portString + "'.", e);
113+
}
114+
}
115+
116+
// Drops the query string and/or fragment, whichever appears first, from the host onwards.
117+
private static @NotNull String stripQueryAndFragment(
118+
final @NotNull String dsn, final int fromIndex) {
119+
int cut = dsn.indexOf('?', fromIndex);
120+
final int fragment = dsn.indexOf('#', fromIndex);
121+
if (fragment >= 0 && (cut < 0 || fragment < cut)) {
122+
cut = fragment;
123+
}
124+
return cut < 0 ? dsn.substring(fromIndex) : dsn.substring(fromIndex, cut);
125+
}
126+
127+
// IPv6 literals are bracketed and contain colons, so the port separator follows the ']'.
128+
private static int portSeparatorIndex(final @NotNull String hostPort) {
129+
return hostPort.startsWith("[")
130+
? hostPort.indexOf(':', hostPort.indexOf(']'))
131+
: hostPort.indexOf(':');
132+
}
133+
134+
// Collapses runs of slashes into a single slash, like URI.normalize().
135+
private static @NotNull String collapseSlashes(final @NotNull String path) {
136+
if (!path.contains("//")) {
137+
return path;
138+
}
139+
final StringBuilder sb = new StringBuilder(path.length());
140+
char previous = 0;
141+
for (int i = 0; i < path.length(); i++) {
142+
final char c = path.charAt(i);
143+
if (c == '/' && previous == '/') {
144+
continue;
104145
}
105-
orgId = extractedOrgId;
106-
} catch (Throwable e) {
107-
throw new IllegalArgumentException(e);
146+
sb.append(c);
147+
previous = c;
108148
}
149+
return sb.toString();
109150
}
110151

111-
public @Nullable String getOrgId() {
112-
return orgId;
152+
private static @NotNull String stripTrailingSlash(final @NotNull String path) {
153+
return path.endsWith("/") ? path.substring(0, path.length() - 1) : path;
154+
}
155+
156+
private static @NotNull String ensureTrailingSlash(final @NotNull String path) {
157+
return path.endsWith("/") ? path : path + "/";
158+
}
159+
160+
// Extracts the org ID from a host such as "o123.ingest.sentry.io" -> "123".
161+
private static @Nullable String extractOrgId(final @NotNull String host) {
162+
final Matcher matcher = ORG_ID_PATTERN.matcher(host);
163+
return matcher.find() ? matcher.group(1) : null;
113164
}
114165
}

0 commit comments

Comments
 (0)