diff --git a/CHANGELOG.md b/CHANGELOG.md index 5632b6926b9..a774b06cb0d 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)) + ### Features - Add support to configure reporting historical ANRs via `AndroidManifest.xml` using the `io.sentry.anr.report-historical` attribute ([#5387](https://github.com/getsentry/sentry-java/pull/5387)) 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) { 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()) + } }