From 6b6b69fd10105ad1e76361ea7f293d32c01b021d Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 19 May 2026 11:20:31 +0200 Subject: [PATCH 1/6] 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 --- sentry/src/main/java/io/sentry/Dsn.java | 98 ++++++++++++++++++------- 1 file changed, 72 insertions(+), 26 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Dsn.java b/sentry/src/main/java/io/sentry/Dsn.java index 0d21499b5fc..3c90ff92c85 100644 --- a/sentry/src/main/java/io/sentry/Dsn.java +++ b/sentry/src/main/java/io/sentry/Dsn.java @@ -53,54 +53,100 @@ URI getSentryUri() { return sentryUri; } + // Avoids java.net.URI for DSN parsing, which is slow on Android. Dsn(@Nullable String dsn) throws IllegalArgumentException { try { final String dsnString = Objects.requireNonNull(dsn, "The DSN is required.").trim(); if (dsnString.isEmpty()) { throw new IllegalArgumentException("The DSN is empty."); } - final URI uri = new URI(dsnString).normalize(); - final String scheme = uri.getScheme(); + + // Extract scheme + final int schemeEnd = dsnString.indexOf("://"); + if (schemeEnd < 0) { + throw new IllegalArgumentException("Invalid DSN: missing scheme."); + } + final String scheme = dsnString.substring(0, schemeEnd); if (!("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))) { throw new IllegalArgumentException("Invalid DSN scheme: " + scheme); } - String userInfo = uri.getUserInfo(); - if (userInfo == null || userInfo.isEmpty()) { + // Extract userinfo (public key and optional secret key) + final int authStart = schemeEnd + 3; + final int atIndex = dsnString.indexOf('@', authStart); + if (atIndex < 0) { + throw new IllegalArgumentException("Invalid DSN: No public key provided."); + } + final String userInfo = dsnString.substring(authStart, atIndex); + if (userInfo.isEmpty()) { throw new IllegalArgumentException("Invalid DSN: No public key provided."); } - String[] keys = userInfo.split(":", -1); - publicKey = keys[0]; - if (publicKey == null || publicKey.isEmpty()) { + final int colonIndex = userInfo.indexOf(':'); + if (colonIndex < 0) { + publicKey = userInfo; + secretKey = null; + } else { + publicKey = userInfo.substring(0, colonIndex); + secretKey = userInfo.substring(colonIndex + 1); + } + if (publicKey.isEmpty()) { throw new IllegalArgumentException("Invalid DSN: No public key provided."); } - secretKey = keys.length > 1 ? keys[1] : null; - String uriPath = uri.getPath(); - if (uriPath.endsWith("/")) { - uriPath = uriPath.substring(0, uriPath.length() - 1); + + // Extract host, optional port, and path+projectId + final int hostStart = atIndex + 1; + + // Strip query string if present + final int queryIndex = dsnString.indexOf('?', hostStart); + final String hostAndPath = + queryIndex < 0 + ? dsnString.substring(hostStart) + : dsnString.substring(hostStart, queryIndex); + + final int firstSlash = hostAndPath.indexOf('/'); + if (firstSlash < 0) { + throw new IllegalArgumentException("Invalid DSN: A Project Id is required."); + } + + final String hostPort = hostAndPath.substring(0, firstSlash); + final int portColon = hostPort.indexOf(':'); + final String host; + final int port; + if (portColon < 0) { + host = hostPort; + port = -1; + } else { + host = hostPort.substring(0, portColon); + port = Integer.parseInt(hostPort.substring(portColon + 1)); + } + + // Normalize the path (collapse double slashes, like URI.normalize()) + String rawPath = hostAndPath.substring(firstSlash); + while (rawPath.contains("//")) { + rawPath = rawPath.replace("//", "/"); + } + + if (rawPath.endsWith("/")) { + rawPath = rawPath.substring(0, rawPath.length() - 1); } - int projectIdStart = uriPath.lastIndexOf("/") + 1; - String path = uriPath.substring(0, projectIdStart); - if (!path.endsWith("/")) { - path += "/"; + final int projectIdStart = rawPath.lastIndexOf('/') + 1; + String pathSegment = rawPath.substring(0, projectIdStart); + if (!pathSegment.endsWith("/")) { + pathSegment += "/"; } - this.path = path; - projectId = uriPath.substring(projectIdStart); + this.path = pathSegment; + projectId = rawPath.substring(projectIdStart); if (projectId.isEmpty()) { throw new IllegalArgumentException("Invalid DSN: A Project Id is required."); } - sentryUri = - new URI( - scheme, null, uri.getHost(), uri.getPort(), path + "api/" + projectId, null, null); + + sentryUri = new URI(scheme, null, host, port, pathSegment + "api/" + projectId, null, null); // Extract org ID from host (e.g., "o123.ingest.sentry.io" -> "123") String extractedOrgId = null; - final String host = uri.getHost(); - if (host != null) { - final Matcher matcher = ORG_ID_PATTERN.matcher(host); - if (matcher.find()) { - extractedOrgId = matcher.group(1); - } + final Matcher matcher = ORG_ID_PATTERN.matcher(host); + if (matcher.find()) { + extractedOrgId = matcher.group(1); } orgId = extractedOrgId; } catch (Throwable e) { From 51a9bb0239cdf4a78ccb88b586d6c2ef0cd373b6 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 19 May 2026 11:26:01 +0200 Subject: [PATCH 2/6] 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 --- sentry/src/test/java/io/sentry/DsnTest.kt | 67 +++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/sentry/src/test/java/io/sentry/DsnTest.kt b/sentry/src/test/java/io/sentry/DsnTest.kt index 7e2982073f1..db0207234ae 100644 --- a/sentry/src/test/java/io/sentry/DsnTest.kt +++ b/sentry/src/test/java/io/sentry/DsnTest.kt @@ -145,4 +145,71 @@ class DsnTest { val dsn = Dsn("http://key@localhost:9000/456") assertNull(dsn.orgId) } + + @Test + fun `when dsn is null, throws exception`() { + assertFailsWith { Dsn(null) } + } + + @Test + fun `when dsn has no scheme separator, throws exception`() { + assertFailsWith { Dsn("httpspublicKey@host/id") } + } + + @Test + fun `when dsn has no slash after host, throws exception`() { + assertFailsWith { Dsn("https://key@host") } + } + + @Test + fun `dsn parsed with multiple path segments`() { + val dsn = Dsn("https://key@host/path/to/sentry/id") + + assertEquals("https://host/path/to/sentry/api/id", dsn.sentryUri.toURL().toString()) + assertEquals("key", dsn.publicKey) + assertEquals("/path/to/sentry/", dsn.path) + assertEquals("id", dsn.projectId) + } + + @Test + fun `dsn parsed with port and path`() { + val dsn = Dsn("http://key:secret@host:8080/path/id") + + assertEquals("http://host:8080/path/api/id", dsn.sentryUri.toURL().toString()) + assertEquals("key", dsn.publicKey) + assertEquals("secret", dsn.secretKey) + assertEquals("/path/", dsn.path) + assertEquals("id", dsn.projectId) + } + + @Test + fun `dsn with multiple double slashes in path is normalized`() { + val dsn = Dsn("http://key@host//path//id") + assertEquals("http://host/path/api/id", dsn.sentryUri.toURL().toString()) + } + + @Test + fun `dsn with query string and port`() { + val dsn = Dsn("https://key@host:443/id?foo=bar&baz=1") + + assertEquals("https://host:443/api/id", dsn.sentryUri.toURL().toString()) + assertEquals("id", dsn.projectId) + } + + @Test + fun `dsn with empty secret key after colon`() { + val dsn = Dsn("https://publicKey:@host/id") + + assertEquals("publicKey", dsn.publicKey) + assertEquals("", dsn.secretKey) + } + + @Test + fun `dsn with numeric project id`() { + val dsn = Dsn("https://key@o123.ingest.sentry.io/1234567") + + assertEquals("1234567", dsn.projectId) + assertEquals("123", dsn.orgId) + assertEquals("https://o123.ingest.sentry.io/api/1234567", dsn.sentryUri.toURL().toString()) + } } From a3e82efe30de417cafa34bc2f66759d940af1d7d Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 19 May 2026 11:27:48 +0200 Subject: [PATCH 3/6] changelog: Add entry for custom DSN parser Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d80037414db..bd3ed91a9d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ ## 8.43.0 +### Improvements + +- Improve SDK init performance by replacing `java.net.URI` with custom string parsing for DSN ([#5448](https://github.com/getsentry/sentry-java/pull/5448)) + ### Features - Session Replay: Add `ReplayFrameObserver` for observing captured replay frames ([#5386](https://github.com/getsentry/sentry-java/pull/5386)) From c07e102b186c6449d60f50863c0d40d574e06c06 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 4 Jun 2026 16:07:05 +0200 Subject: [PATCH 4/6] 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 --- CHANGELOG.md | 8 +- gradle/libs.versions.toml | 1 + sentry/build.gradle.kts | 1 + sentry/src/main/java/io/sentry/Dsn.java | 157 +++++++++++----------- sentry/src/test/java/io/sentry/DsnTest.kt | 131 ++++++++++-------- 5 files changed, 160 insertions(+), 138 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd3ed91a9d8..be4a5f7628d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Improvements + +- Improve SDK init performance by replacing `java.net.URI` with custom string parsing for DSN ([#5448](https://github.com/getsentry/sentry-java/pull/5448)) + ### Fixes - Session Replay: Fix `VerifyError` in Compose masking under DexGuard/R8 obfuscation ([#5507](https://github.com/getsentry/sentry-java/pull/5507)) @@ -16,10 +20,6 @@ ## 8.43.0 -### Improvements - -- Improve SDK init performance by replacing `java.net.URI` with custom string parsing for DSN ([#5448](https://github.com/getsentry/sentry-java/pull/5448)) - ### Features - Session Replay: Add `ReplayFrameObserver` for observing captured replay frames ([#5386](https://github.com/getsentry/sentry-java/pull/5386)) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7ee39d75ede..e653069e2b3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -238,6 +238,7 @@ camerax-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "ca camerax-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax" } camerax-view = { module = "androidx.camera:camera-view", version.ref = "camerax" } +google-truth = { module = "com.google.truth:truth", version = "1.4.5" } hsqldb = { module = "org.hsqldb:hsqldb", version = "2.6.1" } javafaker = { module = "com.github.javafaker:javafaker", version = "1.0.2" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } diff --git a/sentry/build.gradle.kts b/sentry/build.gradle.kts index 25e700995b4..4c237803a51 100644 --- a/sentry/build.gradle.kts +++ b/sentry/build.gradle.kts @@ -25,6 +25,7 @@ dependencies { // tests testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(libs.awaitility.kotlin) + testImplementation(libs.google.truth) testImplementation(libs.javafaker) testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) diff --git a/sentry/src/main/java/io/sentry/Dsn.java b/sentry/src/main/java/io/sentry/Dsn.java index 3c90ff92c85..a97bbc74d6d 100644 --- a/sentry/src/main/java/io/sentry/Dsn.java +++ b/sentry/src/main/java/io/sentry/Dsn.java @@ -2,6 +2,7 @@ import io.sentry.util.Objects; import java.net.URI; +import java.net.URISyntaxException; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.jetbrains.annotations.NotNull; @@ -17,37 +18,32 @@ final class Dsn { private final @NotNull URI sentryUri; private final @Nullable String orgId; - /* - / The project ID which the authenticated user is bound to. - */ + /** The project ID which the authenticated user is bound to. */ public @NotNull String getProjectId() { return projectId; } - /* - / An optional path of which Sentry is hosted - */ + /** An optional path of which Sentry is hosted. */ public @Nullable String getPath() { return path; } - /* - / The optional secret key to authenticate the SDK. - */ + /** The optional secret key to authenticate the SDK. */ public @Nullable String getSecretKey() { return secretKey; } - /* - / The required public key to authenticate the SDK. - */ + /** The required public key to authenticate the SDK. */ public @NotNull String getPublicKey() { return publicKey; } - /* - / The URI used to communicate with Sentry - */ + /** The org ID extracted from the host, or {@code null} when the host has no org prefix. */ + public @Nullable String getOrgId() { + return orgId; + } + + /** The URI used to communicate with Sentry. */ @NotNull URI getSentryUri() { return sentryUri; @@ -55,106 +51,107 @@ URI getSentryUri() { // Avoids java.net.URI for DSN parsing, which is slow on Android. Dsn(@Nullable String dsn) throws IllegalArgumentException { - try { - final String dsnString = Objects.requireNonNull(dsn, "The DSN is required.").trim(); - if (dsnString.isEmpty()) { - throw new IllegalArgumentException("The DSN is empty."); - } + final String dsnString = Objects.requireNonNull(dsn, "The DSN is required.").trim(); + if (dsnString.isEmpty()) { + throw new IllegalArgumentException("The DSN is empty."); + } - // Extract scheme + try { final int schemeEnd = dsnString.indexOf("://"); if (schemeEnd < 0) { - throw new IllegalArgumentException("Invalid DSN: missing scheme."); + throw new IllegalArgumentException("Invalid DSN: Missing scheme."); } final String scheme = dsnString.substring(0, schemeEnd); - if (!("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))) { - throw new IllegalArgumentException("Invalid DSN scheme: " + scheme); + if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) { + throw new IllegalArgumentException("Invalid DSN: Invalid scheme '" + scheme + "'."); } - // Extract userinfo (public key and optional secret key) final int authStart = schemeEnd + 3; final int atIndex = dsnString.indexOf('@', authStart); if (atIndex < 0) { throw new IllegalArgumentException("Invalid DSN: No public key provided."); } final String userInfo = dsnString.substring(authStart, atIndex); - if (userInfo.isEmpty()) { - throw new IllegalArgumentException("Invalid DSN: No public key provided."); - } final int colonIndex = userInfo.indexOf(':'); - if (colonIndex < 0) { - publicKey = userInfo; - secretKey = null; - } else { - publicKey = userInfo.substring(0, colonIndex); - secretKey = userInfo.substring(colonIndex + 1); - } + publicKey = colonIndex < 0 ? userInfo : userInfo.substring(0, colonIndex); + secretKey = colonIndex < 0 ? null : userInfo.substring(colonIndex + 1); if (publicKey.isEmpty()) { throw new IllegalArgumentException("Invalid DSN: No public key provided."); } - // Extract host, optional port, and path+projectId - final int hostStart = atIndex + 1; - - // Strip query string if present - final int queryIndex = dsnString.indexOf('?', hostStart); - final String hostAndPath = - queryIndex < 0 - ? dsnString.substring(hostStart) - : dsnString.substring(hostStart, queryIndex); - + final String hostAndPath = stripQueryAndFragment(dsnString, atIndex + 1); final int firstSlash = hostAndPath.indexOf('/'); if (firstSlash < 0) { throw new IllegalArgumentException("Invalid DSN: A Project Id is required."); } final String hostPort = hostAndPath.substring(0, firstSlash); - final int portColon = hostPort.indexOf(':'); - final String host; - final int port; - if (portColon < 0) { - host = hostPort; - port = -1; - } else { - host = hostPort.substring(0, portColon); - port = Integer.parseInt(hostPort.substring(portColon + 1)); - } - - // Normalize the path (collapse double slashes, like URI.normalize()) - String rawPath = hostAndPath.substring(firstSlash); - while (rawPath.contains("//")) { - rawPath = rawPath.replace("//", "/"); - } + final int portColon = portSeparatorIndex(hostPort); + final String host = portColon < 0 ? hostPort : hostPort.substring(0, portColon); + final int port = portColon < 0 ? -1 : Integer.parseInt(hostPort.substring(portColon + 1)); - if (rawPath.endsWith("/")) { - rawPath = rawPath.substring(0, rawPath.length() - 1); - } + final String rawPath = stripTrailingSlash(collapseSlashes(hostAndPath.substring(firstSlash))); final int projectIdStart = rawPath.lastIndexOf('/') + 1; - String pathSegment = rawPath.substring(0, projectIdStart); - if (!pathSegment.endsWith("/")) { - pathSegment += "/"; - } - this.path = pathSegment; + path = ensureTrailingSlash(rawPath.substring(0, projectIdStart)); projectId = rawPath.substring(projectIdStart); if (projectId.isEmpty()) { throw new IllegalArgumentException("Invalid DSN: A Project Id is required."); } - sentryUri = new URI(scheme, null, host, port, pathSegment + "api/" + projectId, null, null); + sentryUri = new URI(scheme, null, host, port, path + "api/" + projectId, null, null); + orgId = extractOrgId(host); + } catch (URISyntaxException | NumberFormatException e) { + throw new IllegalArgumentException("Invalid DSN: " + e.getMessage(), e); + } + } + + // Drops the query string and/or fragment, whichever appears first, from the host onwards. + private static @NotNull String stripQueryAndFragment( + final @NotNull String dsn, final int fromIndex) { + int cut = dsn.indexOf('?', fromIndex); + final int fragment = dsn.indexOf('#', fromIndex); + if (fragment >= 0 && (cut < 0 || fragment < cut)) { + cut = fragment; + } + return cut < 0 ? dsn.substring(fromIndex) : dsn.substring(fromIndex, cut); + } + + // IPv6 literals are bracketed and contain colons, so the port separator follows the ']'. + private static int portSeparatorIndex(final @NotNull String hostPort) { + return hostPort.startsWith("[") + ? hostPort.indexOf(':', hostPort.indexOf(']')) + : hostPort.indexOf(':'); + } - // Extract org ID from host (e.g., "o123.ingest.sentry.io" -> "123") - String extractedOrgId = null; - final Matcher matcher = ORG_ID_PATTERN.matcher(host); - if (matcher.find()) { - extractedOrgId = matcher.group(1); + // Collapses runs of slashes into a single slash, like URI.normalize(). + private static @NotNull String collapseSlashes(final @NotNull String path) { + if (!path.contains("//")) { + return path; + } + final StringBuilder sb = new StringBuilder(path.length()); + char previous = 0; + for (int i = 0; i < path.length(); i++) { + final char c = path.charAt(i); + if (c == '/' && previous == '/') { + continue; } - orgId = extractedOrgId; - } catch (Throwable e) { - throw new IllegalArgumentException(e); + sb.append(c); + previous = c; } + return sb.toString(); } - public @Nullable String getOrgId() { - return orgId; + private static @NotNull String stripTrailingSlash(final @NotNull String path) { + return path.endsWith("/") ? path.substring(0, path.length() - 1) : path; + } + + private static @NotNull String ensureTrailingSlash(final @NotNull String path) { + return path.endsWith("/") ? path : path + "/"; + } + + // Extracts the org ID from a host such as "o123.ingest.sentry.io" -> "123". + private static @Nullable String extractOrgId(final @NotNull String host) { + final Matcher matcher = ORG_ID_PATTERN.matcher(host); + return matcher.find() ? matcher.group(1) : null; } } diff --git a/sentry/src/test/java/io/sentry/DsnTest.kt b/sentry/src/test/java/io/sentry/DsnTest.kt index db0207234ae..fe79429bcf7 100644 --- a/sentry/src/test/java/io/sentry/DsnTest.kt +++ b/sentry/src/test/java/io/sentry/DsnTest.kt @@ -1,21 +1,20 @@ package io.sentry +import com.google.common.truth.Truth.assertThat import java.lang.IllegalArgumentException import kotlin.test.Test -import kotlin.test.assertEquals import kotlin.test.assertFailsWith -import kotlin.test.assertNull class DsnTest { @Test fun `dsn parsed with path, sets all properties`() { val dsn = Dsn("https://publicKey:secretKey@host/path/id") - assertEquals("https://host/path/api/id", dsn.sentryUri.toURL().toString()) - assertEquals("publicKey", dsn.publicKey) - assertEquals("secretKey", dsn.secretKey) - assertEquals("/path/", dsn.path) - assertEquals("id", dsn.projectId) + assertThat(dsn.sentryUri.toURL().toString()).isEqualTo("https://host/path/api/id") + assertThat(dsn.publicKey).isEqualTo("publicKey") + assertThat(dsn.secretKey).isEqualTo("secretKey") + assertThat(dsn.path).isEqualTo("/path/") + assertThat(dsn.projectId).isEqualTo("id") } @Test @@ -23,88 +22,79 @@ class DsnTest { // query strings were once a feature, but no more val dsn = Dsn("https://publicKey:secretKey@host/path/id?sample.rate=0.1") - assertEquals("https://host/path/api/id", dsn.sentryUri.toURL().toString()) - assertEquals("publicKey", dsn.publicKey) - assertEquals("secretKey", dsn.secretKey) - assertEquals("/path/", dsn.path) - assertEquals("id", dsn.projectId) + assertThat(dsn.sentryUri.toURL().toString()).isEqualTo("https://host/path/api/id") + assertThat(dsn.publicKey).isEqualTo("publicKey") + assertThat(dsn.secretKey).isEqualTo("secretKey") + assertThat(dsn.path).isEqualTo("/path/") + assertThat(dsn.projectId).isEqualTo("id") } @Test fun `dsn parsed without path`() { val dsn = Dsn("https://key@host/id") - assertEquals("https://host/api/id", dsn.sentryUri.toURL().toString()) + assertThat(dsn.sentryUri.toURL().toString()).isEqualTo("https://host/api/id") } @Test fun `dsn parsed with port number`() { val dsn = Dsn("http://key@host:69/id") - assertEquals("http://host:69/api/id", dsn.sentryUri.toURL().toString()) + assertThat(dsn.sentryUri.toURL().toString()).isEqualTo("http://host:69/api/id") } @Test fun `dsn parsed with trailing slash`() { val dsn = Dsn("http://key@host/id/") - assertEquals("http://host/api/id", dsn.sentryUri.toURL().toString()) + assertThat(dsn.sentryUri.toURL().toString()).isEqualTo("http://host/api/id") } @Test fun `dsn parsed with no delimiter for key`() { val dsn = Dsn("https://publicKey@host/id") - assertEquals("publicKey", dsn.publicKey) - assertNull(dsn.secretKey) + assertThat(dsn.publicKey).isEqualTo("publicKey") + assertThat(dsn.secretKey).isNull() } @Test fun `when no project id exists, throws exception`() { val ex = assertFailsWith { Dsn("http://key@host/") } - assertEquals( - "java.lang.IllegalArgumentException: Invalid DSN: A Project Id is required.", - ex.message, - ) + assertThat(ex.message).isEqualTo("Invalid DSN: A Project Id is required.") } @Test fun `when no key exists, throws exception`() { val ex = assertFailsWith { Dsn("http://host/id") } - assertEquals( - "java.lang.IllegalArgumentException: Invalid DSN: No public key provided.", - ex.message, - ) + assertThat(ex.message).isEqualTo("Invalid DSN: No public key provided.") } @Test fun `when only passing secret key, throws exception`() { val ex = assertFailsWith { Dsn("https://:secret@host/path/id") } - assertEquals( - "java.lang.IllegalArgumentException: Invalid DSN: No public key provided.", - ex.message, - ) + assertThat(ex.message).isEqualTo("Invalid DSN: No public key provided.") } @Test fun `dsn is normalized`() { val dsn = Dsn("http://key@host//id") - assertEquals("http://host/api/id", dsn.sentryUri.toURL().toString()) + assertThat(dsn.sentryUri.toURL().toString()).isEqualTo("http://host/api/id") } @Test fun `dsn parsed with leading and trailing whitespace`() { val dsn = Dsn(" https://key@host/id ") - assertEquals("https://host/api/id", dsn.sentryUri.toURL().toString()) + assertThat(dsn.sentryUri.toURL().toString()).isEqualTo("https://host/api/id") } @Test fun `when dsn is empty, throws exception`() { val ex = assertFailsWith { Dsn("") } - assertEquals("java.lang.IllegalArgumentException: The DSN is empty.", ex.message) + assertThat(ex.message).isEqualTo("The DSN is empty.") } @Test fun `when dsn is only whitespace, throws exception`() { val ex = assertFailsWith { Dsn(" ") } - assertEquals("java.lang.IllegalArgumentException: The DSN is empty.", ex.message) + assertThat(ex.message).isEqualTo("The DSN is empty.") } @Test @@ -125,25 +115,25 @@ class DsnTest { @Test fun `extracts org id from host`() { val dsn = Dsn("https://key@o123.ingest.sentry.io/456") - assertEquals("123", dsn.orgId) + assertThat(dsn.orgId).isEqualTo("123") } @Test fun `extracts single digit org id from host`() { val dsn = Dsn("https://key@o1.ingest.us.sentry.io/456") - assertEquals("1", dsn.orgId) + assertThat(dsn.orgId).isEqualTo("1") } @Test fun `returns null org id when host has no org prefix`() { val dsn = Dsn("https://key@sentry.io/456") - assertNull(dsn.orgId) + assertThat(dsn.orgId).isNull() } @Test fun `returns null org id for non-standard host`() { val dsn = Dsn("http://key@localhost:9000/456") - assertNull(dsn.orgId) + assertThat(dsn.orgId).isNull() } @Test @@ -165,51 +155,84 @@ class DsnTest { fun `dsn parsed with multiple path segments`() { val dsn = Dsn("https://key@host/path/to/sentry/id") - assertEquals("https://host/path/to/sentry/api/id", dsn.sentryUri.toURL().toString()) - assertEquals("key", dsn.publicKey) - assertEquals("/path/to/sentry/", dsn.path) - assertEquals("id", dsn.projectId) + assertThat(dsn.sentryUri.toURL().toString()).isEqualTo("https://host/path/to/sentry/api/id") + assertThat(dsn.publicKey).isEqualTo("key") + assertThat(dsn.path).isEqualTo("/path/to/sentry/") + assertThat(dsn.projectId).isEqualTo("id") } @Test fun `dsn parsed with port and path`() { val dsn = Dsn("http://key:secret@host:8080/path/id") - assertEquals("http://host:8080/path/api/id", dsn.sentryUri.toURL().toString()) - assertEquals("key", dsn.publicKey) - assertEquals("secret", dsn.secretKey) - assertEquals("/path/", dsn.path) - assertEquals("id", dsn.projectId) + assertThat(dsn.sentryUri.toURL().toString()).isEqualTo("http://host:8080/path/api/id") + assertThat(dsn.publicKey).isEqualTo("key") + assertThat(dsn.secretKey).isEqualTo("secret") + assertThat(dsn.path).isEqualTo("/path/") + assertThat(dsn.projectId).isEqualTo("id") } @Test fun `dsn with multiple double slashes in path is normalized`() { val dsn = Dsn("http://key@host//path//id") - assertEquals("http://host/path/api/id", dsn.sentryUri.toURL().toString()) + assertThat(dsn.sentryUri.toURL().toString()).isEqualTo("http://host/path/api/id") } @Test fun `dsn with query string and port`() { val dsn = Dsn("https://key@host:443/id?foo=bar&baz=1") - assertEquals("https://host:443/api/id", dsn.sentryUri.toURL().toString()) - assertEquals("id", dsn.projectId) + assertThat(dsn.sentryUri.toURL().toString()).isEqualTo("https://host:443/api/id") + assertThat(dsn.projectId).isEqualTo("id") + } + + @Test + fun `dsn with fragment is stripped from project id`() { + val dsn = Dsn("https://key@host/123#frag") + + assertThat(dsn.projectId).isEqualTo("123") + assertThat(dsn.sentryUri.toURL().toString()).isEqualTo("https://host/api/123") + } + + @Test + fun `dsn with both query string and fragment is stripped from project id`() { + val dsn = Dsn("https://key@host/123?foo=bar#frag") + + assertThat(dsn.projectId).isEqualTo("123") + assertThat(dsn.sentryUri.toURL().toString()).isEqualTo("https://host/api/123") + } + + @Test + fun `dsn with ipv6 host and port`() { + val dsn = Dsn("https://key@[2001:db8::1]:9000/1") + + assertThat(dsn.sentryUri.toURL().toString()).isEqualTo("https://[2001:db8::1]:9000/api/1") + assertThat(dsn.projectId).isEqualTo("1") + } + + @Test + fun `dsn with ipv6 host and no port`() { + val dsn = Dsn("https://key@[::1]/1") + + assertThat(dsn.sentryUri.toURL().toString()).isEqualTo("https://[::1]/api/1") + assertThat(dsn.projectId).isEqualTo("1") } @Test fun `dsn with empty secret key after colon`() { val dsn = Dsn("https://publicKey:@host/id") - assertEquals("publicKey", dsn.publicKey) - assertEquals("", dsn.secretKey) + assertThat(dsn.publicKey).isEqualTo("publicKey") + assertThat(dsn.secretKey).isEqualTo("") } @Test fun `dsn with numeric project id`() { val dsn = Dsn("https://key@o123.ingest.sentry.io/1234567") - assertEquals("1234567", dsn.projectId) - assertEquals("123", dsn.orgId) - assertEquals("https://o123.ingest.sentry.io/api/1234567", dsn.sentryUri.toURL().toString()) + assertThat(dsn.projectId).isEqualTo("1234567") + assertThat(dsn.orgId).isEqualTo("123") + assertThat(dsn.sentryUri.toURL().toString()) + .isEqualTo("https://o123.ingest.sentry.io/api/1234567") } } From 4c435eada9029ee4d4956c5488e511e3dabcd251 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 4 Jun 2026 16:39:47 +0200 Subject: [PATCH 5/6] 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 --- sentry/src/test/java/io/sentry/DsnTest.kt | 28 +++++++++++++++-------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/sentry/src/test/java/io/sentry/DsnTest.kt b/sentry/src/test/java/io/sentry/DsnTest.kt index fe79429bcf7..684a4045fea 100644 --- a/sentry/src/test/java/io/sentry/DsnTest.kt +++ b/sentry/src/test/java/io/sentry/DsnTest.kt @@ -58,19 +58,19 @@ class DsnTest { @Test fun `when no project id exists, throws exception`() { val ex = assertFailsWith { Dsn("http://key@host/") } - assertThat(ex.message).isEqualTo("Invalid DSN: A Project Id is required.") + assertThat(ex).hasMessageThat().isEqualTo("Invalid DSN: A Project Id is required.") } @Test fun `when no key exists, throws exception`() { val ex = assertFailsWith { Dsn("http://host/id") } - assertThat(ex.message).isEqualTo("Invalid DSN: No public key provided.") + assertThat(ex).hasMessageThat().isEqualTo("Invalid DSN: No public key provided.") } @Test fun `when only passing secret key, throws exception`() { val ex = assertFailsWith { Dsn("https://:secret@host/path/id") } - assertThat(ex.message).isEqualTo("Invalid DSN: No public key provided.") + assertThat(ex).hasMessageThat().isEqualTo("Invalid DSN: No public key provided.") } @Test @@ -88,19 +88,24 @@ class DsnTest { @Test fun `when dsn is empty, throws exception`() { val ex = assertFailsWith { Dsn("") } - assertThat(ex.message).isEqualTo("The DSN is empty.") + assertThat(ex).hasMessageThat().isEqualTo("The DSN is empty.") } @Test fun `when dsn is only whitespace, throws exception`() { val ex = assertFailsWith { Dsn(" ") } - assertThat(ex.message).isEqualTo("The DSN is empty.") + assertThat(ex).hasMessageThat().isEqualTo("The DSN is empty.") } @Test fun `non http protocols are not accepted`() { - assertFailsWith { Dsn("ftp://publicKey:secretKey@host/path/id") } - assertFailsWith { Dsn("jar://publicKey:secretKey@host/path/id") } + val ftp = + assertFailsWith { Dsn("ftp://publicKey:secretKey@host/path/id") } + assertThat(ftp).hasMessageThat().isEqualTo("Invalid DSN: Invalid scheme 'ftp'.") + + val jar = + assertFailsWith { Dsn("jar://publicKey:secretKey@host/path/id") } + assertThat(jar).hasMessageThat().isEqualTo("Invalid DSN: Invalid scheme 'jar'.") } @Test @@ -138,17 +143,20 @@ class DsnTest { @Test fun `when dsn is null, throws exception`() { - assertFailsWith { Dsn(null) } + val ex = assertFailsWith { Dsn(null) } + assertThat(ex).hasMessageThat().isEqualTo("The DSN is required.") } @Test fun `when dsn has no scheme separator, throws exception`() { - assertFailsWith { Dsn("httpspublicKey@host/id") } + val ex = assertFailsWith { Dsn("httpspublicKey@host/id") } + assertThat(ex).hasMessageThat().isEqualTo("Invalid DSN: Missing scheme.") } @Test fun `when dsn has no slash after host, throws exception`() { - assertFailsWith { Dsn("https://key@host") } + val ex = assertFailsWith { Dsn("https://key@host") } + assertThat(ex).hasMessageThat().isEqualTo("Invalid DSN: A Project Id is required.") } @Test From 7d400c06468854801822494363f326c5fbc21807 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 4 Jun 2026 16:52:15 +0200 Subject: [PATCH 6/6] 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 --- sentry/src/main/java/io/sentry/Dsn.java | 12 ++++++++++-- sentry/src/test/java/io/sentry/DsnTest.kt | 6 ++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Dsn.java b/sentry/src/main/java/io/sentry/Dsn.java index a97bbc74d6d..15aea2a064c 100644 --- a/sentry/src/main/java/io/sentry/Dsn.java +++ b/sentry/src/main/java/io/sentry/Dsn.java @@ -88,7 +88,7 @@ URI getSentryUri() { final String hostPort = hostAndPath.substring(0, firstSlash); final int portColon = portSeparatorIndex(hostPort); final String host = portColon < 0 ? hostPort : hostPort.substring(0, portColon); - final int port = portColon < 0 ? -1 : Integer.parseInt(hostPort.substring(portColon + 1)); + final int port = portColon < 0 ? -1 : parsePort(hostPort.substring(portColon + 1)); final String rawPath = stripTrailingSlash(collapseSlashes(hostAndPath.substring(firstSlash))); final int projectIdStart = rawPath.lastIndexOf('/') + 1; @@ -100,11 +100,19 @@ URI getSentryUri() { sentryUri = new URI(scheme, null, host, port, path + "api/" + projectId, null, null); orgId = extractOrgId(host); - } catch (URISyntaxException | NumberFormatException e) { + } catch (URISyntaxException e) { throw new IllegalArgumentException("Invalid DSN: " + e.getMessage(), e); } } + private static int parsePort(final @NotNull String portString) { + try { + return Integer.parseInt(portString); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid DSN: Invalid port '" + portString + "'.", e); + } + } + // Drops the query string and/or fragment, whichever appears first, from the host onwards. private static @NotNull String stripQueryAndFragment( final @NotNull String dsn, final int fromIndex) { diff --git a/sentry/src/test/java/io/sentry/DsnTest.kt b/sentry/src/test/java/io/sentry/DsnTest.kt index 684a4045fea..f8195d16af6 100644 --- a/sentry/src/test/java/io/sentry/DsnTest.kt +++ b/sentry/src/test/java/io/sentry/DsnTest.kt @@ -159,6 +159,12 @@ class DsnTest { assertThat(ex).hasMessageThat().isEqualTo("Invalid DSN: A Project Id is required.") } + @Test + fun `when port is not a number, throws exception`() { + val ex = assertFailsWith { Dsn("http://key@host:abc/1") } + assertThat(ex).hasMessageThat().isEqualTo("Invalid DSN: Invalid port 'abc'.") + } + @Test fun `dsn parsed with multiple path segments`() { val dsn = Dsn("https://key@host/path/to/sentry/id")