Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Comment thread
runningcode marked this conversation as resolved.
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" }
Expand Down
1 change: 1 addition & 0 deletions sentry/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
161 changes: 106 additions & 55 deletions sentry/src/main/java/io/sentry/Dsn.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -17,98 +18,148 @@ 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;
}

// Avoids java.net.URI for DSN parsing, which is slow on Android.
Dsn(@Nullable String dsn) throws IllegalArgumentException {
final String dsnString = Objects.requireNonNull(dsn, "The DSN is required.").trim();
if (dsnString.isEmpty()) {
throw new IllegalArgumentException("The DSN is empty.");
}

try {
final String dsnString = Objects.requireNonNull(dsn, "The DSN is required.").trim();
if (dsnString.isEmpty()) {
throw new IllegalArgumentException("The DSN is empty.");
final int schemeEnd = dsnString.indexOf("://");
if (schemeEnd < 0) {
throw new IllegalArgumentException("Invalid DSN: Missing scheme.");
}
final URI uri = new URI(dsnString).normalize();
final String scheme = uri.getScheme();
if (!("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))) {
throw new IllegalArgumentException("Invalid DSN scheme: " + scheme);
final String scheme = dsnString.substring(0, schemeEnd);
if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) {
throw new IllegalArgumentException("Invalid DSN: Invalid scheme '" + scheme + "'.");
}

String userInfo = uri.getUserInfo();
if (userInfo == null || userInfo.isEmpty()) {
final int authStart = schemeEnd + 3;
final int atIndex = dsnString.indexOf('@', authStart);
if (atIndex < 0) {
throw new IllegalArgumentException("Invalid DSN: No public key provided.");
}
String[] keys = userInfo.split(":", -1);
publicKey = keys[0];
if (publicKey == null || publicKey.isEmpty()) {
final String userInfo = dsnString.substring(authStart, atIndex);
final int colonIndex = userInfo.indexOf(':');
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.");
}
secretKey = keys.length > 1 ? keys[1] : null;
String uriPath = uri.getPath();
if (uriPath.endsWith("/")) {
uriPath = uriPath.substring(0, uriPath.length() - 1);
}
int projectIdStart = uriPath.lastIndexOf("/") + 1;
String path = uriPath.substring(0, projectIdStart);
if (!path.endsWith("/")) {
path += "/";

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.");
}
this.path = path;
projectId = uriPath.substring(projectIdStart);

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 : parsePort(hostPort.substring(portColon + 1));

final String rawPath = stripTrailingSlash(collapseSlashes(hostAndPath.substring(firstSlash)));
Comment thread
runningcode marked this conversation as resolved.
final int projectIdStart = rawPath.lastIndexOf('/') + 1;
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, uri.getHost(), uri.getPort(), path + "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);
}

sentryUri = new URI(scheme, null, host, port, path + "api/" + projectId, null, null);
Comment thread
sentry[bot] marked this conversation as resolved.
orgId = extractOrgId(host);
} 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) {
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(':');
}

// 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;
}
}
Loading
Loading