From e4db821d0c0f5b309f05f4668353f32f43dd10cb Mon Sep 17 00:00:00 2001 From: Lorenzo Cian <17258265+lcian@users.noreply.github.com> Date: Fri, 26 Sep 2025 09:27:33 +0200 Subject: [PATCH 01/15] Mark `SentryEnvelope` as not internal (#4748) Co-authored-by: Sentry Github Bot Co-authored-by: Alexander Dinauer --- CHANGELOG.md | 6 ++++++ sentry/src/main/java/io/sentry/SentryEnvelope.java | 9 ++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa094db937d..c6f830a3476 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Improvements + +- Mark `SentryEnvelope` as not internal ([#4748](https://github.com/getsentry/sentry-java/pull/4748)) + ## 8.22.0 ### Features diff --git a/sentry/src/main/java/io/sentry/SentryEnvelope.java b/sentry/src/main/java/io/sentry/SentryEnvelope.java index 5c3f141baf4..70e3e944936 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelope.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelope.java @@ -11,7 +11,6 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -@ApiStatus.Internal public final class SentryEnvelope { // types: session_batch, session, event, attachment @@ -19,14 +18,17 @@ public final class SentryEnvelope { private final @NotNull SentryEnvelopeHeader header; private final @NotNull Iterable items; + @ApiStatus.Internal public @NotNull Iterable getItems() { return items; } + @ApiStatus.Internal public @NotNull SentryEnvelopeHeader getHeader() { return header; } + @ApiStatus.Internal public SentryEnvelope( final @NotNull SentryEnvelopeHeader header, final @NotNull Iterable items) { @@ -34,6 +36,7 @@ public SentryEnvelope( this.items = Objects.requireNonNull(items, "SentryEnvelope items are required."); } + @ApiStatus.Internal public SentryEnvelope( final @Nullable SentryId eventId, final @Nullable SdkVersion sdkVersion, @@ -42,6 +45,7 @@ public SentryEnvelope( this.items = Objects.requireNonNull(items, "SentryEnvelope items are required."); } + @ApiStatus.Internal public SentryEnvelope( final @Nullable SentryId eventId, final @Nullable SdkVersion sdkVersion, @@ -54,6 +58,7 @@ public SentryEnvelope( this.items = items; } + @ApiStatus.Internal public static @NotNull SentryEnvelope from( final @NotNull ISerializer serializer, final @NotNull Session session, @@ -66,6 +71,7 @@ public SentryEnvelope( null, sdkVersion, SentryEnvelopeItem.fromSession(serializer, session)); } + @ApiStatus.Internal public static @NotNull SentryEnvelope from( final @NotNull ISerializer serializer, final @NotNull SentryBaseEvent event, @@ -78,6 +84,7 @@ public SentryEnvelope( event.getEventId(), sdkVersion, SentryEnvelopeItem.fromEvent(serializer, event)); } + @ApiStatus.Internal public static @NotNull SentryEnvelope from( final @NotNull ISerializer serializer, final @NotNull ProfilingTraceData profilingTraceData, From 818a27d409b3e05ab5e4d45862fd71cc74127f63 Mon Sep 17 00:00:00 2001 From: Lorenzo Cian <17258265+lcian@users.noreply.github.com> Date: Fri, 26 Sep 2025 10:22:27 +0200 Subject: [PATCH 02/15] Handle `RejectedExecutionException` everywhere (#4747) Co-authored-by: Sentry Github Bot --- CHANGELOG.md | 1 + .../core/DefaultAndroidEventProcessor.java | 92 +++++++++++-------- sentry/src/main/java/io/sentry/Scopes.java | 20 ++-- .../java/io/sentry/SentryExecutorService.java | 48 ++++++---- .../backpressure/BackpressureMonitor.java | 9 +- .../sentry/logger/LoggerBatchProcessor.java | 10 +- .../transport/QueuedThreadPoolExecutor.java | 10 +- 7 files changed, 123 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6f830a3476..7f2b745f91a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Improvements +- Handle `RejectedExecutionException` everywhere ([#4747](https://github.com/getsentry/sentry-java/pull/4747)) - Mark `SentryEnvelope` as not internal ([#4748](https://github.com/getsentry/sentry-java/pull/4748)) ## 8.22.0 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java index 9409c29b0d8..1e37916aaee 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java @@ -4,18 +4,7 @@ import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.os.Build; -import io.sentry.DateUtils; -import io.sentry.EventProcessor; -import io.sentry.Hint; -import io.sentry.IpAddressUtils; -import io.sentry.NoOpLogger; -import io.sentry.SentryAttributeType; -import io.sentry.SentryBaseEvent; -import io.sentry.SentryEvent; -import io.sentry.SentryLevel; -import io.sentry.SentryLogEvent; -import io.sentry.SentryLogEventAttributeValue; -import io.sentry.SentryReplayEvent; +import io.sentry.*; import io.sentry.android.core.internal.util.AndroidThreadChecker; import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; @@ -37,6 +26,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; @@ -47,7 +37,7 @@ final class DefaultAndroidEventProcessor implements EventProcessor { private final @NotNull BuildInfoProvider buildInfoProvider; private final @NotNull SentryAndroidOptions options; - private final @NotNull Future deviceInfoUtil; + private final @Nullable Future deviceInfoUtil; private final @NotNull LazyEvaluator deviceFamily = new LazyEvaluator<>(() -> ContextUtils.getFamily(NoOpLogger.getInstance())); @@ -65,9 +55,16 @@ public DefaultAndroidEventProcessor( // don't ref. to method reference, theres a bug on it // noinspection Convert2MethodRef // some device info performs disk I/O, but it's result is cached, let's pre-cache it + @Nullable Future deviceInfoUtil; final @NotNull ExecutorService executorService = Executors.newSingleThreadExecutor(); - this.deviceInfoUtil = - executorService.submit(() -> DeviceInfoUtil.getInstance(this.context, options)); + try { + deviceInfoUtil = + executorService.submit(() -> DeviceInfoUtil.getInstance(this.context, options)); + } catch (RejectedExecutionException e) { + deviceInfoUtil = null; + options.getLogger().log(SentryLevel.WARNING, "Device info caching task rejected.", e); + } + this.deviceInfoUtil = deviceInfoUtil; executorService.shutdown(); } @@ -181,12 +178,16 @@ private void setDevice( final boolean errorEvent, final boolean applyScopeData) { if (event.getContexts().getDevice() == null) { - try { - event - .getContexts() - .setDevice(deviceInfoUtil.get().collectDeviceInformation(errorEvent, applyScopeData)); - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Failed to retrieve device info", e); + if (deviceInfoUtil != null) { + try { + event + .getContexts() + .setDevice(deviceInfoUtil.get().collectDeviceInformation(errorEvent, applyScopeData)); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Failed to retrieve device info", e); + } + } else { + options.getLogger().log(SentryLevel.ERROR, "Failed to retrieve device info"); } mergeOS(event); } @@ -194,12 +195,17 @@ private void setDevice( private void mergeOS(final @NotNull SentryBaseEvent event) { final OperatingSystem currentOS = event.getContexts().getOperatingSystem(); - try { - final OperatingSystem androidOS = deviceInfoUtil.get().getOperatingSystem(); - // make Android OS the main OS using the 'os' key - event.getContexts().setOperatingSystem(androidOS); - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Failed to retrieve os system", e); + + if (deviceInfoUtil != null) { + try { + final OperatingSystem androidOS = deviceInfoUtil.get().getOperatingSystem(); + // make Android OS the main OS using the 'os' key + event.getContexts().setOperatingSystem(androidOS); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Failed to retrieve os system", e); + } + } else { + options.getLogger().log(SentryLevel.ERROR, "Failed to retrieve device info"); } if (currentOS != null) { @@ -284,10 +290,14 @@ private void setPackageInfo(final @NotNull SentryBaseEvent event, final @NotNull setDist(event, versionCode); @Nullable DeviceInfoUtil deviceInfoUtil = null; - try { - deviceInfoUtil = this.deviceInfoUtil.get(); - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Failed to retrieve device info", e); + if (this.deviceInfoUtil != null) { + try { + deviceInfoUtil = this.deviceInfoUtil.get(); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Failed to retrieve device info", e); + } + } else { + options.getLogger().log(SentryLevel.ERROR, "Failed to retrieve device info"); } ContextUtils.setAppPackageInfo(packageInfo, buildInfoProvider, deviceInfoUtil, app); @@ -331,16 +341,20 @@ private void setAppExtras(final @NotNull App app, final @NotNull Hint hint) { } private void setSideLoadedInfo(final @NotNull SentryBaseEvent event) { - try { - final ContextUtils.SideLoadedInfo sideLoadedInfo = deviceInfoUtil.get().getSideLoadedInfo(); - if (sideLoadedInfo != null) { - final @NotNull Map tags = sideLoadedInfo.asTags(); - for (Map.Entry entry : tags.entrySet()) { - event.setTag(entry.getKey(), entry.getValue()); + if (deviceInfoUtil != null) { + try { + final ContextUtils.SideLoadedInfo sideLoadedInfo = deviceInfoUtil.get().getSideLoadedInfo(); + if (sideLoadedInfo != null) { + final @NotNull Map tags = sideLoadedInfo.asTags(); + for (Map.Entry entry : tags.entrySet()) { + event.setTag(entry.getKey(), entry.getValue()); + } } + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Error getting side loaded info.", e); } - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Error getting side loaded info.", e); + } else { + options.getLogger().log(SentryLevel.ERROR, "Failed to retrieve device info"); } } diff --git a/sentry/src/main/java/io/sentry/Scopes.java b/sentry/src/main/java/io/sentry/Scopes.java index 77b9b94731c..ce11b19f738 100644 --- a/sentry/src/main/java/io/sentry/Scopes.java +++ b/sentry/src/main/java/io/sentry/Scopes.java @@ -5,10 +5,7 @@ import io.sentry.hints.SessionStartHint; import io.sentry.logger.ILoggerApi; import io.sentry.logger.LoggerApi; -import io.sentry.protocol.Feedback; -import io.sentry.protocol.SentryId; -import io.sentry.protocol.SentryTransaction; -import io.sentry.protocol.User; +import io.sentry.protocol.*; import io.sentry.transport.RateLimiter; import io.sentry.util.HintUtils; import io.sentry.util.Objects; @@ -16,6 +13,7 @@ import io.sentry.util.TracingUtils; import java.io.Closeable; import java.util.List; +import java.util.concurrent.RejectedExecutionException; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -449,8 +447,18 @@ public void close(final boolean isRestarting) { getOptions().getConnectionStatusProvider().close(); final @NotNull ISentryExecutorService executorService = getOptions().getExecutorService(); if (isRestarting) { - executorService.submit( - () -> executorService.close(getOptions().getShutdownTimeoutMillis())); + try { + executorService.submit( + () -> executorService.close(getOptions().getShutdownTimeoutMillis())); + } catch (RejectedExecutionException e) { + getOptions() + .getLogger() + .log( + SentryLevel.WARNING, + "Failed to submit executor service shutdown task during restart. Shutting down synchronously.", + e); + executorService.close(getOptions().getShutdownTimeoutMillis()); + } } else { executorService.close(getOptions().getShutdownTimeoutMillis()); } diff --git a/sentry/src/main/java/io/sentry/SentryExecutorService.java b/sentry/src/main/java/io/sentry/SentryExecutorService.java index 3fe262b5538..873e4744e3c 100644 --- a/sentry/src/main/java/io/sentry/SentryExecutorService.java +++ b/sentry/src/main/java/io/sentry/SentryExecutorService.java @@ -54,11 +54,11 @@ public SentryExecutorService() { } @Override - public @NotNull Future submit(final @NotNull Runnable runnable) { + public @NotNull Future submit(final @NotNull Runnable runnable) + throws RejectedExecutionException { if (executorService.getQueue().size() < MAX_QUEUE_SIZE) { return executorService.submit(runnable); } - // TODO: maybe RejectedExecutionException? if (options != null) { options .getLogger() @@ -68,11 +68,11 @@ public SentryExecutorService() { } @Override - public @NotNull Future submit(final @NotNull Callable callable) { + public @NotNull Future submit(final @NotNull Callable callable) + throws RejectedExecutionException { if (executorService.getQueue().size() < MAX_QUEUE_SIZE) { return executorService.submit(callable); } - // TODO: maybe RejectedExecutionException? if (options != null) { options .getLogger() @@ -82,11 +82,11 @@ public SentryExecutorService() { } @Override - public @NotNull Future schedule(final @NotNull Runnable runnable, final long delayMillis) { + public @NotNull Future schedule(final @NotNull Runnable runnable, final long delayMillis) + throws RejectedExecutionException { if (executorService.getQueue().size() < MAX_QUEUE_SIZE) { return executorService.schedule(runnable, delayMillis, TimeUnit.MILLISECONDS); } - // TODO: maybe RejectedExecutionException? if (options != null) { options .getLogger() @@ -122,20 +122,30 @@ public boolean isClosed() { @SuppressWarnings({"FutureReturnValueIgnored"}) @Override public void prewarm() { - executorService.submit( - () -> { - try { - // schedule a bunch of dummy runnables in the future that will never execute to trigger - // queue growth and then purge the queue - for (int i = 0; i < INITIAL_QUEUE_SIZE; i++) { - final Future future = executorService.schedule(dummyRunnable, 365L, TimeUnit.DAYS); - future.cancel(true); + try { + executorService.submit( + () -> { + try { + // schedule a bunch of dummy runnables in the future that will never execute to + // trigger + // queue growth and then purge the queue + for (int i = 0; i < INITIAL_QUEUE_SIZE; i++) { + final Future future = + executorService.schedule(dummyRunnable, 365L, TimeUnit.DAYS); + future.cancel(true); + } + executorService.purge(); + } catch (RejectedExecutionException ignored) { + // ignore } - executorService.purge(); - } catch (RejectedExecutionException ignored) { - // ignore - } - }); + }); + } catch (RejectedExecutionException e) { + if (options != null) { + options + .getLogger() + .log(SentryLevel.WARNING, "Prewarm task rejected from " + executorService, e); + } + } } private static final class SentryExecutorServiceThreadFactory implements ThreadFactory { diff --git a/sentry/src/main/java/io/sentry/backpressure/BackpressureMonitor.java b/sentry/src/main/java/io/sentry/backpressure/BackpressureMonitor.java index 92fa3ce0a5c..fdbfea2a3a2 100644 --- a/sentry/src/main/java/io/sentry/backpressure/BackpressureMonitor.java +++ b/sentry/src/main/java/io/sentry/backpressure/BackpressureMonitor.java @@ -7,6 +7,7 @@ import io.sentry.SentryOptions; import io.sentry.util.AutoClosableReentrantLock; import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -79,7 +80,13 @@ private void reschedule(final int delay) { final @NotNull ISentryExecutorService executorService = sentryOptions.getExecutorService(); if (!executorService.isClosed()) { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { - latestScheduledRun = executorService.schedule(this, delay); + try { + latestScheduledRun = executorService.schedule(this, delay); + } catch (RejectedExecutionException e) { + sentryOptions + .getLogger() + .log(SentryLevel.WARNING, "Backpressure monitor reschedule task rejected", e); + } } } } diff --git a/sentry/src/main/java/io/sentry/logger/LoggerBatchProcessor.java b/sentry/src/main/java/io/sentry/logger/LoggerBatchProcessor.java index 3d760263f38..369f24f75de 100644 --- a/sentry/src/main/java/io/sentry/logger/LoggerBatchProcessor.java +++ b/sentry/src/main/java/io/sentry/logger/LoggerBatchProcessor.java @@ -15,6 +15,7 @@ import java.util.Queue; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -76,7 +77,14 @@ private void maybeSchedule(boolean forceSchedule, boolean immediately) { || latestScheduledFlush.isCancelled()) { hasScheduled = true; final int flushAfterMs = immediately ? 0 : FLUSH_AFTER_MS; - scheduledFlush = executorService.schedule(new BatchRunnable(), flushAfterMs); + try { + scheduledFlush = executorService.schedule(new BatchRunnable(), flushAfterMs); + } catch (RejectedExecutionException e) { + hasScheduled = false; + options + .getLogger() + .log(SentryLevel.WARNING, "Logs batch processor flush task rejected", e); + } } } } diff --git a/sentry/src/main/java/io/sentry/transport/QueuedThreadPoolExecutor.java b/sentry/src/main/java/io/sentry/transport/QueuedThreadPoolExecutor.java index f8d35a6888a..e5565a279fc 100644 --- a/sentry/src/main/java/io/sentry/transport/QueuedThreadPoolExecutor.java +++ b/sentry/src/main/java/io/sentry/transport/QueuedThreadPoolExecutor.java @@ -8,6 +8,7 @@ import java.util.concurrent.CancellationException; import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.RejectedExecutionHandler; import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; @@ -65,7 +66,14 @@ public QueuedThreadPoolExecutor( public Future submit(final @NotNull Runnable task) { if (isSchedulingAllowed()) { unfinishedTasksCount.increment(); - return super.submit(task); + try { + return super.submit(task); + } catch (RejectedExecutionException e) { + unfinishedTasksCount.decrement(); + lastRejectTimestamp = dateProvider.now(); + logger.log(SentryLevel.WARNING, "Submit rejected by thread pool executor", e); + return new CancelledFuture<>(); + } } else { lastRejectTimestamp = dateProvider.now(); // if the thread pool is full, we don't cache it From f0a95bc83e251eb1b3a465787a0624695da983e8 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 26 Sep 2025 11:52:11 +0200 Subject: [PATCH 03/15] Add Spring 7 and Spring Boot 4 module to .craft.yml (#4755) --- .craft.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.craft.yml b/.craft.yml index d2177bc98e8..7dbf0382589 100644 --- a/.craft.yml +++ b/.craft.yml @@ -19,13 +19,13 @@ targets: maven:io.sentry:sentry: maven:io.sentry:sentry-spring: maven:io.sentry:sentry-spring-jakarta: -# maven:io.sentry:sentry-spring-7: + maven:io.sentry:sentry-spring-7: maven:io.sentry:sentry-spring-boot: maven:io.sentry:sentry-spring-boot-jakarta: maven:io.sentry:sentry-spring-boot-starter: maven:io.sentry:sentry-spring-boot-starter-jakarta: -# maven:io.sentry:sentry-spring-boot-4: -# maven:io.sentry:sentry-spring-boot-4-starter: + maven:io.sentry:sentry-spring-boot-4: + maven:io.sentry:sentry-spring-boot-4-starter: maven:io.sentry:sentry-servlet: maven:io.sentry:sentry-servlet-jakarta: maven:io.sentry:sentry-logback: From 8294a87351ab808834dcda7f4295592786d47f9b Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 26 Sep 2025 11:59:50 +0200 Subject: [PATCH 04/15] Add new spring modules to README.md (#4756) --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5f06d14f9ee..096ecf7d19a 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,13 @@ Sentry SDK for Java and Android | sentry-servlet-jakarta | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-servlet-jakarta/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-servlet-jakarta) | | | sentry-spring-boot | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot) | | sentry-spring-boot-jakarta | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot-jakarta/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot-jakarta) | +| sentry-spring-boot-4 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot-4/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot-4) | +| sentry-spring-boot-4-starter | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot-4-starter/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot-4-starter) | | sentry-spring-boot-starter | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot-starter/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot-starter) | | sentry-spring-boot-starter-jakarta | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot-starter-jakarta/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot-starter-jakarta) | | sentry-spring | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring) | | sentry-spring-jakarta | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-jakarta/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-jakarta) | +| sentry-spring-7 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-7/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-7) | | sentry-logback | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-logback/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-logback) | | sentry-log4j2 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-log4j2/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-log4j2) | | sentry-bom | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-bom/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-bom) | @@ -58,7 +61,7 @@ Sentry SDK for Java and Android | sentry-opentelemetry-agentcustomization | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-opentelemetry-agentcustomization/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-opentelemetry-agentcustomization) | | sentry-opentelemetry-core | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-opentelemetry-core/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-opentelemetry-core) | | sentry-okhttp | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-okhttp/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-okhttp) | -| sentry-reactor | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-reactor/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-reactor) | +| sentry-reactor | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-reactor/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-reactor) | # Releases From d478d664f7b2f3081e85c635faa16efe096870d5 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 26 Sep 2025 12:08:28 +0200 Subject: [PATCH 05/15] Add e2e tests for Spring 7 sample (#4753) --- .github/workflows/system-tests-backend.yml | 3 ++ .../sentry-samples-spring-7/build.gradle.kts | 37 ++++++++++++++- .../java/io/sentry/samples/spring7/Main.java | 32 +++++++++++++ .../io/sentry/samples/spring7/web/Person.java | 7 ++- .../samples/spring7/web/PersonController.java | 2 +- .../src/test/kotlin/io/sentry/DummyTest.kt | 12 +++++ .../io/sentry/systemtest/PersonSystemTest.kt | 46 +++++++++++++++++++ test/system-test-runner.py | 1 + 8 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/Main.java create mode 100644 sentry-samples/sentry-samples-spring-7/src/test/kotlin/io/sentry/DummyTest.kt create mode 100644 sentry-samples/sentry-samples-spring-7/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index ffa539c0440..43f69a69889 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -78,6 +78,9 @@ jobs: - sample: "sentry-samples-spring-boot-4-opentelemetry" agent: "true" agent-auto-init: "false" + - sample: "sentry-samples-spring-7" + agent: "false" + agent-auto-init: "true" - sample: "sentry-samples-spring-jakarta" agent: "false" agent-auto-init: "true" diff --git a/sentry-samples/sentry-samples-spring-7/build.gradle.kts b/sentry-samples/sentry-samples-spring-7/build.gradle.kts index f1565124743..1d31ff91d01 100644 --- a/sentry-samples/sentry-samples-spring-7/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-7/build.gradle.kts @@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.springframework.boot.gradle.plugin.SpringBootPlugin plugins { + application alias(libs.plugins.springboot4) apply false alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) @@ -11,6 +12,11 @@ plugins { alias(libs.plugins.gretty) } +application { mainClass.set("io.sentry.samples.spring7.Main") } + +// Ensure WAR is up to date before run task +tasks.named("run") { dependsOn(tasks.named("war")) } + group = "io.sentry.sample.spring-7" version = "0.0.1-SNAPSHOT" @@ -37,13 +43,17 @@ dependencies { implementation(libs.logback.classic) implementation(libs.servlet.jakarta.api) implementation(libs.slf4j2.api) + + implementation(libs.tomcat.catalina.jakarta) + implementation(libs.tomcat.embed.jasper.jakarta) + + testImplementation(projects.sentrySystemTestSupport) + testImplementation(libs.kotlin.test.junit) testImplementation(libs.springboot.starter.test) { exclude(group = "org.junit.vintage", module = "junit-vintage-engine") } } -tasks.withType().configureEach { useJUnitPlatform() } - tasks.withType().configureEach { kotlin { explicitApi() @@ -55,3 +65,26 @@ tasks.withType().configureEach { compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } } + +configure { test { java.srcDir("src/test/java") } } + +tasks.register("systemTest").configure { + group = "verification" + description = "Runs the System tests" + + outputs.upToDateWhen { false } + + maxParallelForks = 1 + + // Cap JVM args per test + minHeapSize = "128m" + maxHeapSize = "1g" + + filter { includeTestsMatching("io.sentry.systemtest*") } +} + +tasks.named("test").configure { + require(this is Test) + + filter { excludeTestsMatching("io.sentry.systemtest.*") } +} diff --git a/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/Main.java b/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/Main.java new file mode 100644 index 00000000000..805e08c641f --- /dev/null +++ b/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/Main.java @@ -0,0 +1,32 @@ +package io.sentry.samples.spring7; + +import java.io.File; +import java.io.IOException; +import org.apache.catalina.LifecycleException; +import org.apache.catalina.startup.Tomcat; + +public class Main { + + public static void main(String[] args) throws LifecycleException, IOException { + File webappsDirectory = new File("./tomcat.8080/webapps"); + if (!webappsDirectory.exists()) { + boolean didCreateDirectories = webappsDirectory.mkdirs(); + if (!didCreateDirectories) { + throw new RuntimeException( + "Failed to create directory required by Tomcat: " + webappsDirectory.getAbsolutePath()); + } + } + + String pathToWar = "./build/libs"; + String warName = "sentry-samples-spring-7-0.0.1-SNAPSHOT"; + File war = new File(pathToWar + "/" + warName + ".war"); + + Tomcat tomcat = new Tomcat(); + tomcat.setPort(8080); + tomcat.getConnector(); + + tomcat.addWebapp("/" + warName, war.getCanonicalPath()); + tomcat.start(); + tomcat.getServer().await(); + } +} diff --git a/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/web/Person.java b/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/web/Person.java index 784291f1c0d..b2ff7fff4a9 100644 --- a/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/web/Person.java +++ b/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/web/Person.java @@ -1,10 +1,15 @@ package io.sentry.samples.spring7.web; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + public class Person { private final String firstName; private final String lastName; - public Person(String firstName, String lastName) { + @JsonCreator + public Person( + @JsonProperty("firstName") String firstName, @JsonProperty("lastName") String lastName) { this.firstName = firstName; this.lastName = lastName; } diff --git a/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/web/PersonController.java b/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/web/PersonController.java index 5baf24acc16..a9a413fd5f7 100644 --- a/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/web/PersonController.java +++ b/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/web/PersonController.java @@ -22,7 +22,7 @@ public PersonController(PersonService personService) { } @GetMapping("{id}") - Person person(@PathVariable Long id) { + Person person(@PathVariable("id") Long id) { Sentry.logger().warn("warn Sentry logging"); Sentry.logger().error("error Sentry logging"); Sentry.logger().info("hello %s %s", "there", "world!"); diff --git a/sentry-samples/sentry-samples-spring-7/src/test/kotlin/io/sentry/DummyTest.kt b/sentry-samples/sentry-samples-spring-7/src/test/kotlin/io/sentry/DummyTest.kt new file mode 100644 index 00000000000..6f762b7e453 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-7/src/test/kotlin/io/sentry/DummyTest.kt @@ -0,0 +1,12 @@ +package io.sentry + +import kotlin.test.Test +import kotlin.test.assertTrue + +class DummyTest { + @Test + fun `the only test`() { + // only needed to have more than 0 tests and not fail the build + assertTrue(true) + } +} diff --git a/sentry-samples/sentry-samples-spring-7/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-7/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt new file mode 100644 index 00000000000..628c27f4c6f --- /dev/null +++ b/sentry-samples/sentry-samples-spring-7/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -0,0 +1,46 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class PersonSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080/sentry-samples-spring-7-0.0.1-SNAPSHOT") + testHelper.reset() + } + + @Test + fun `get person fails`() { + val restClient = testHelper.restClient + restClient.getPerson(11L) + assertEquals(500, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionHaveOp(transaction, "http.server") + } + + Thread.sleep(10000) + + testHelper.ensureLogsReceived { logs, envelopeHeader -> + testHelper.doesContainLogWithBody(logs, "warn Sentry logging") && + testHelper.doesContainLogWithBody(logs, "error Sentry logging") && + testHelper.doesContainLogWithBody(logs, "hello there world!") + } + } + + @Test + fun `create person works`() { + val restClient = testHelper.restClient + val person = Person("firstA", "lastB") + val returnedPerson = restClient.createPerson(person) + assertEquals(200, restClient.lastKnownStatusCode) + + assertEquals(person.firstName, returnedPerson!!.firstName) + assertEquals(person.lastName, returnedPerson!!.lastName) + } +} diff --git a/test/system-test-runner.py b/test/system-test-runner.py index c25c79dcb9d..182bf164f42 100644 --- a/test/system-test-runner.py +++ b/test/system-test-runner.py @@ -713,6 +713,7 @@ def get_available_modules(self) -> List[ModuleConfig]: """Get list of all available test modules.""" return [ ModuleConfig("sentry-samples-spring", "false", "true", "false"), + ModuleConfig("sentry-samples-spring-7", "false", "true", "false"), ModuleConfig("sentry-samples-spring-jakarta", "false", "true", "false"), ModuleConfig("sentry-samples-spring-boot", "false", "true", "false"), ModuleConfig("sentry-samples-spring-boot-opentelemetry-noagent", "false", "true", "false"), From f634d01652b3f4ddf21398daf541b63620228184 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 26 Sep 2025 13:28:59 +0200 Subject: [PATCH 06/15] Create Test Matrix for Spring Boot (#4741) * Create Test Matrix for Spring Boot * caching * ignore android changes * Add missing otel samples / auto init false * add spring modules * fix used module * comment out spring boot 4 noagent for now --- .github/workflows/spring-boot-2-matrix.yml | 176 ++++++++++++++++++++ .github/workflows/spring-boot-3-matrix.yml | 176 ++++++++++++++++++++ .github/workflows/spring-boot-4-matrix.yml | 177 +++++++++++++++++++++ 3 files changed, 529 insertions(+) create mode 100644 .github/workflows/spring-boot-2-matrix.yml create mode 100644 .github/workflows/spring-boot-3-matrix.yml create mode 100644 .github/workflows/spring-boot-4-matrix.yml diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml new file mode 100644 index 00000000000..2e249c3fd29 --- /dev/null +++ b/.github/workflows/spring-boot-2-matrix.yml @@ -0,0 +1,176 @@ +name: Spring Boot 2.x Matrix + +on: + push: + branches: + - main + paths-ignore: + - '**/sentry-android/**' + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + spring-boot-2-matrix: + timeout-minutes: 45 + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + springboot-version: [ '2.1.0', '2.2.5', '2.4.13', '2.5.15', '2.6.15', '2.7.0', '2.7.18' ] + + name: Spring Boot ${{ matrix.springboot-version }} + env: + SENTRY_URL: http://127.0.0.1:8000 + GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + + steps: + - name: Checkout Repo + uses: actions/checkout@v5 + with: + submodules: 'recursive' + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.10.5' + + - name: Install Python dependencies + run: | + python3 -m pip install --upgrade pip + python3 -m pip install -r requirements.txt + + - name: Set up Java + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + + # Workaround for https://github.com/gradle/actions/issues/21 to use config cache + - name: Cache buildSrc + uses: actions/cache@v4 + with: + path: buildSrc/build + key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a + with: + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + + - name: Update Spring Boot 2.x version + run: | + sed -i 's/^springboot2=.*/springboot2=${{ matrix.springboot-version }}/' gradle/libs.versions.toml + echo "Updated Spring Boot 2.x version to ${{ matrix.springboot-version }}" + + - name: Exclude android modules from build + run: | + sed -i \ + -e '/.*"sentry-android-ndk",/d' \ + -e '/.*"sentry-android",/d' \ + -e '/.*"sentry-compose",/d' \ + -e '/.*"sentry-android-core",/d' \ + -e '/.*"sentry-android-fragment",/d' \ + -e '/.*"sentry-android-navigation",/d' \ + -e '/.*"sentry-android-sqlite",/d' \ + -e '/.*"sentry-android-timber",/d' \ + -e '/.*"sentry-android-integration-tests:sentry-uitest-android-benchmark",/d' \ + -e '/.*"sentry-android-integration-tests:sentry-uitest-android",/d' \ + -e '/.*"sentry-android-integration-tests:sentry-uitest-android-critical",/d' \ + -e '/.*"sentry-android-integration-tests:test-app-sentry",/d' \ + -e '/.*"sentry-samples:sentry-samples-android",/d' \ + -e '/.*"sentry-android-replay",/d' \ + settings.gradle.kts + + - name: Exclude android modules from ignore list + run: | + sed -i \ + -e '/.*"sentry-uitest-android",/d' \ + -e '/.*"sentry-uitest-android-benchmark",/d' \ + -e '/.*"sentry-uitest-android-critical",/d' \ + -e '/.*"test-app-sentry",/d' \ + -e '/.*"sentry-samples-android",/d' \ + build.gradle.kts + + - name: Build SDK + run: | + ./gradlew assemble --parallel + + - name: Test sentry-samples-spring-boot + run: | + python3 test/system-test-runner.py test \ + --module "sentry-samples-spring-boot" \ + --agent false \ + --auto-init "true" \ + --build "true" + + - name: Test sentry-samples-spring-boot-webflux + run: | + python3 test/system-test-runner.py test \ + --module "sentry-samples-spring-boot-webflux" \ + --agent false \ + --auto-init "true" \ + --build "true" + + - name: Test sentry-samples-spring-boot-opentelemetry agent init true + run: | + python3 test/system-test-runner.py test \ + --module "sentry-samples-spring-boot-opentelemetry" \ + --agent true \ + --auto-init "true" \ + --build "true" + + - name: Test sentry-samples-spring-boot-opentelemetry agent init false + run: | + python3 test/system-test-runner.py test \ + --module "sentry-samples-spring-boot-opentelemetry" \ + --agent true \ + --auto-init "false" \ + --build "true" + + - name: Test sentry-samples-spring-boot-opentelemetry-noagent + run: | + python3 test/system-test-runner.py test \ + --module "sentry-samples-spring-boot-opentelemetry-noagent" \ + --agent false \ + --auto-init "true" \ + --build "true" + + - name: Test sentry-samples-spring + run: | + python3 test/system-test-runner.py test \ + --module "sentry-samples-spring" \ + --agent false \ + --auto-init "true" \ + --build "true" + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-springboot-2-${{ matrix.springboot-version }} + path: | + **/build/reports/* + **/build/test-results/**/*.xml + sentry-mock-server.txt + spring-server.txt + + - name: Test Report + uses: phoenix-actions/test-reporting@f957cd93fc2d848d556fa0d03c57bc79127b6b5e # pin@v15 + if: always() + with: + name: JUnit Spring Boot 2.x ${{ matrix.springboot-version }} + path: | + **/build/test-results/**/*.xml + reporter: java-junit + output-to: step-summary + fail-on-error: false + + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f + with: + token: ${{ secrets.CODECOV_TOKEN }} + file: '**/build/test-results/**/*.xml' diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml new file mode 100644 index 00000000000..3195fc9c4e8 --- /dev/null +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -0,0 +1,176 @@ +name: Spring Boot 3.x Matrix + +on: + push: + branches: + - main + paths-ignore: + - '**/sentry-android/**' + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + spring-boot-3-matrix: + timeout-minutes: 45 + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + springboot-version: [ '3.0.0', '3.2.10', '3.3.5', '3.4.5', '3.5.6' ] + + name: Spring Boot ${{ matrix.springboot-version }} + env: + SENTRY_URL: http://127.0.0.1:8000 + GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + + steps: + - name: Checkout Repo + uses: actions/checkout@v5 + with: + submodules: 'recursive' + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.10.5' + + - name: Install Python dependencies + run: | + python3 -m pip install --upgrade pip + python3 -m pip install -r requirements.txt + + - name: Set up Java + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + + # Workaround for https://github.com/gradle/actions/issues/21 to use config cache + - name: Cache buildSrc + uses: actions/cache@v4 + with: + path: buildSrc/build + key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a + with: + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + + - name: Update Spring Boot 3.x version + run: | + sed -i 's/^springboot3=.*/springboot3=${{ matrix.springboot-version }}/' gradle/libs.versions.toml + echo "Updated Spring Boot 3.x version to ${{ matrix.springboot-version }}" + + - name: Exclude android modules from build + run: | + sed -i \ + -e '/.*"sentry-android-ndk",/d' \ + -e '/.*"sentry-android",/d' \ + -e '/.*"sentry-compose",/d' \ + -e '/.*"sentry-android-core",/d' \ + -e '/.*"sentry-android-fragment",/d' \ + -e '/.*"sentry-android-navigation",/d' \ + -e '/.*"sentry-android-sqlite",/d' \ + -e '/.*"sentry-android-timber",/d' \ + -e '/.*"sentry-android-integration-tests:sentry-uitest-android-benchmark",/d' \ + -e '/.*"sentry-android-integration-tests:sentry-uitest-android",/d' \ + -e '/.*"sentry-android-integration-tests:sentry-uitest-android-critical",/d' \ + -e '/.*"sentry-android-integration-tests:test-app-sentry",/d' \ + -e '/.*"sentry-samples:sentry-samples-android",/d' \ + -e '/.*"sentry-android-replay",/d' \ + settings.gradle.kts + + - name: Exclude android modules from ignore list + run: | + sed -i \ + -e '/.*"sentry-uitest-android",/d' \ + -e '/.*"sentry-uitest-android-benchmark",/d' \ + -e '/.*"sentry-uitest-android-critical",/d' \ + -e '/.*"test-app-sentry",/d' \ + -e '/.*"sentry-samples-android",/d' \ + build.gradle.kts + + - name: Build SDK + run: | + ./gradlew assemble --parallel + + - name: Test sentry-samples-spring-boot-jakarta + run: | + python3 test/system-test-runner.py test \ + --module "sentry-samples-spring-boot-jakarta" \ + --agent false \ + --auto-init "true" \ + --build "true" + + - name: Test sentry-samples-spring-boot-webflux-jakarta + run: | + python3 test/system-test-runner.py test \ + --module "sentry-samples-spring-boot-webflux-jakarta" \ + --agent false \ + --auto-init "true" \ + --build "true" + + - name: Test sentry-samples-spring-boot-jakarta-opentelemetry agent init true + run: | + python3 test/system-test-runner.py test \ + --module "sentry-samples-spring-boot-jakarta-opentelemetry" \ + --agent true \ + --auto-init "true" \ + --build "true" + + - name: Test sentry-samples-spring-boot-jakarta-opentelemetry agent init false + run: | + python3 test/system-test-runner.py test \ + --module "sentry-samples-spring-boot-jakarta-opentelemetry" \ + --agent true \ + --auto-init "false" \ + --build "true" + + - name: Test sentry-samples-spring-boot-jakarta-opentelemetry-noagent + run: | + python3 test/system-test-runner.py test \ + --module "sentry-samples-spring-boot-jakarta-opentelemetry-noagent" \ + --agent false \ + --auto-init "true" \ + --build "true" + + - name: Test sentry-samples-spring-jakarta + run: | + python3 test/system-test-runner.py test \ + --module "sentry-samples-spring-jakarta" \ + --agent false \ + --auto-init "true" \ + --build "true" + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-springboot-3-${{ matrix.springboot-version }} + path: | + **/build/reports/* + **/build/test-results/**/*.xml + sentry-mock-server.txt + spring-server.txt + + - name: Test Report + uses: phoenix-actions/test-reporting@f957cd93fc2d848d556fa0d03c57bc79127b6b5e # pin@v15 + if: always() + with: + name: JUnit Spring Boot 3.x ${{ matrix.springboot-version }} + path: | + **/build/test-results/**/*.xml + reporter: java-junit + output-to: step-summary + fail-on-error: false + + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f + with: + token: ${{ secrets.CODECOV_TOKEN }} + file: '**/build/test-results/**/*.xml' diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml new file mode 100644 index 00000000000..6c980e10646 --- /dev/null +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -0,0 +1,177 @@ +name: Spring Boot 4.x Matrix + +on: + push: + branches: + - main + paths-ignore: + - '**/sentry-android/**' + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + spring-boot-4-matrix: + timeout-minutes: 45 + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + springboot-version: [ '4.0.0-M1', '4.0.0-M2', '4.0.0-M3' ] + + name: Spring Boot ${{ matrix.springboot-version }} + env: + SENTRY_URL: http://127.0.0.1:8000 + GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + + steps: + - name: Checkout Repo + uses: actions/checkout@v5 + with: + submodules: 'recursive' + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.10.5' + + - name: Install Python dependencies + run: | + python3 -m pip install --upgrade pip + python3 -m pip install -r requirements.txt + + - name: Set up Java + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + + # Workaround for https://github.com/gradle/actions/issues/21 to use config cache + - name: Cache buildSrc + uses: actions/cache@v4 + with: + path: buildSrc/build + key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a + with: + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + + - name: Update Spring Boot 4.x version + run: | + sed -i 's/^springboot4=.*/springboot4=${{ matrix.springboot-version }}/' gradle/libs.versions.toml + echo "Updated Spring Boot 4.x version to ${{ matrix.springboot-version }}" + + - name: Exclude android modules from build + run: | + sed -i \ + -e '/.*"sentry-android-ndk",/d' \ + -e '/.*"sentry-android",/d' \ + -e '/.*"sentry-compose",/d' \ + -e '/.*"sentry-android-core",/d' \ + -e '/.*"sentry-android-fragment",/d' \ + -e '/.*"sentry-android-navigation",/d' \ + -e '/.*"sentry-android-sqlite",/d' \ + -e '/.*"sentry-android-timber",/d' \ + -e '/.*"sentry-android-integration-tests:sentry-uitest-android-benchmark",/d' \ + -e '/.*"sentry-android-integration-tests:sentry-uitest-android",/d' \ + -e '/.*"sentry-android-integration-tests:sentry-uitest-android-critical",/d' \ + -e '/.*"sentry-android-integration-tests:test-app-sentry",/d' \ + -e '/.*"sentry-samples:sentry-samples-android",/d' \ + -e '/.*"sentry-android-replay",/d' \ + settings.gradle.kts + + - name: Exclude android modules from ignore list + run: | + sed -i \ + -e '/.*"sentry-uitest-android",/d' \ + -e '/.*"sentry-uitest-android-benchmark",/d' \ + -e '/.*"sentry-uitest-android-critical",/d' \ + -e '/.*"test-app-sentry",/d' \ + -e '/.*"sentry-samples-android",/d' \ + build.gradle.kts + + - name: Build SDK + run: | + ./gradlew assemble --parallel + + - name: Run sentry-samples-spring-boot-4 + run: | + python3 test/system-test-runner.py test \ + --module "sentry-samples-spring-boot-4" \ + --agent false \ + --auto-init "true" \ + --build "true" + + - name: Run sentry-samples-spring-boot-4-webflux + run: | + python3 test/system-test-runner.py test \ + --module "sentry-samples-spring-boot-4-webflux" \ + --agent false \ + --auto-init "true" \ + --build "true" + + - name: Run sentry-samples-spring-boot-4-opentelemetry agent init true + run: | + python3 test/system-test-runner.py test \ + --module "sentry-samples-spring-boot-4-opentelemetry" \ + --agent true \ + --auto-init "true" \ + --build "true" + + - name: Run sentry-samples-spring-boot-4-opentelemetry agent init false + run: | + python3 test/system-test-runner.py test \ + --module "sentry-samples-spring-boot-4-opentelemetry" \ + --agent true \ + --auto-init "false" \ + --build "true" + +# needs a fix in opentelemetry-spring-boot-starter +# - name: Run sentry-samples-spring-boot-4-opentelemetry-noagent +# run: | +# python3 test/system-test-runner.py test \ +# --module "sentry-samples-spring-boot-4-opentelemetry-noagent" \ +# --agent false \ +# --auto-init "true" \ +# --build "true" + + - name: Run sentry-samples-spring-7 + run: | + python3 test/system-test-runner.py test \ + --module "sentry-samples-spring-7" \ + --agent false \ + --auto-init "true" \ + --build "true" + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-springboot-4-${{ matrix.springboot-version }} + path: | + **/build/reports/* + **/build/test-results/**/*.xml + sentry-mock-server.txt + spring-server.txt + + - name: Test Report + uses: phoenix-actions/test-reporting@f957cd93fc2d848d556fa0d03c57bc79127b6b5e # pin@v15 + if: always() + with: + name: JUnit Spring Boot 4.x ${{ matrix.springboot-version }} + path: | + **/build/test-results/**/*.xml + reporter: java-junit + output-to: step-summary + fail-on-error: false + + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f + with: + token: ${{ secrets.CODECOV_TOKEN }} + file: '**/build/test-results/**/*.xml' From 8e6d732eb6bca255e37eb7aeb75ba738cce99beb Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 29 Sep 2025 08:27:40 +0200 Subject: [PATCH 07/15] feat(android-distribution): add httpclient for checking for build distribution updates (#4734) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(android-distribution): implement checkForUpdateBlocking functionality Implements the checkForUpdateBlocking method in DistributionIntegration to check for app updates via Sentry's distribution API. ## Why not reuse existing HttpConnection? The existing `HttpConnection` class is designed specifically for Sentry event transport and is not suitable for distribution API calls: - Hardcoded for POST requests (we need GET) - Expects Sentry envelopes with gzip encoding (we need simple JSON) - Only considers status 200 successful (REST APIs use 200-299 range) - Includes Sentry-specific rate limiting logic ## Changes - **DistributionHttpClient**: New HTTP client for distribution API requests - Supports GET requests with query parameters (main_binary_identifier, app_id, platform, version) - Uses SentryOptions.DistributionOptions for configuration (orgSlug, projectSlug, orgAuthToken) - Handles SSL configuration, timeouts, and proper error handling - **UpdateResponseParser**: JSON response parser for API responses - Parses API responses into UpdateStatus objects (UpToDate, NewRelease, UpdateError) - Handles various HTTP status codes with appropriate error messages - Validates required fields in update information - **DistributionIntegration**: Updated to use new classes - Automatically extracts app information (package name, version) from Android context - Clean separation of concerns with HTTP client and response parser - Comprehensive error handling and logging - **Tests**: Added unit test for DistributionHttpClient with real API integration 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Address PR review feedback - Remove unnecessary Claude-style comments from DistributionHttpClient - Replace manual URL building with Android Uri.Builder for safer parameter encoding - Add comprehensive tests for UpdateResponseParser with 11 test cases - Improve error handling to distinguish between network connection vs server issues - Add clarifying comments about which exceptions indicate network connectivity problems - Fix null value handling in JSON parsing to properly validate "null" strings - Remove unclear comment about package name usage 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Fix User-Agent header to follow codebase conventions Replace custom fallback "sentry-android-distribution" with error throw when sentryClientName is null, following the pattern used throughout the codebase where sentryClientName is expected to always be set. Addresses PR review feedback about reusing consistent user agent. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Update SocketTimeoutException message to mention network connection Change "check connection speed" to "check network connection" to be more general and align with the goal of distinguishing network connectivity issues. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Add NoNetwork status and improve error messages - Add UpdateStatus.NoNetwork subclass for network-specific errors - Update DistributionIntegration to use NoNetwork for UnknownHostException and SocketTimeoutException - Improve UpdateResponseParser error messages to specify which required fields are missing - Add comprehensive tests for specific missing field error messages 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Fix CI compilation errors in DistributionHttpClient - Update UpdateCheckParams constructor to use separate versionCode and versionName parameters - Replace Android Uri with string building for better compatibility - Remove unused Android Uri import - Update URL construction to use build_number and build_version query parameters This fixes the CI compilation errors where the old constructor expected a single 'version' parameter. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --------- Co-authored-by: Claude --- sentry-android-distribution/build.gradle.kts | 11 + .../distribution/DistributionHttpClient.kt | 116 +++++++ .../distribution/DistributionIntegration.kt | 71 +++- .../distribution/UpdateResponseParser.kt | 82 +++++ .../DistributionHttpClientTest.kt | 54 +++ .../distribution/UpdateResponseParserTest.kt | 313 ++++++++++++++++++ sentry/api/sentry.api | 5 + .../src/main/java/io/sentry/UpdateInfo.java | 7 +- .../src/main/java/io/sentry/UpdateStatus.java | 13 + 9 files changed, 668 insertions(+), 4 deletions(-) create mode 100644 sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionHttpClient.kt create mode 100644 sentry-android-distribution/src/main/java/io/sentry/android/distribution/UpdateResponseParser.kt create mode 100644 sentry-android-distribution/src/test/java/io/sentry/android/distribution/DistributionHttpClientTest.kt create mode 100644 sentry-android-distribution/src/test/java/io/sentry/android/distribution/UpdateResponseParserTest.kt diff --git a/sentry-android-distribution/build.gradle.kts b/sentry-android-distribution/build.gradle.kts index dd9a278aa3c..2d23bf3ab74 100644 --- a/sentry-android-distribution/build.gradle.kts +++ b/sentry-android-distribution/build.gradle.kts @@ -11,6 +11,13 @@ android { defaultConfig { minSdk = libs.versions.minSdk.get().toInt() } buildFeatures { buildConfig = false } + + testOptions { + unitTests.apply { + isReturnDefaultValues = true + isIncludeAndroidResources = true + } + } } kotlin { @@ -29,4 +36,8 @@ dependencies { libs.jetbrains.annotations ) // Use implementation instead of compileOnly to override kotlin stdlib's version implementation(kotlin(Config.kotlinStdLib, Config.kotlinStdLibVersionAndroid)) + testImplementation(libs.androidx.test.ext.junit) + testImplementation(libs.roboelectric) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.androidx.test.core) } diff --git a/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionHttpClient.kt b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionHttpClient.kt new file mode 100644 index 00000000000..5cb7724908a --- /dev/null +++ b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionHttpClient.kt @@ -0,0 +1,116 @@ +package io.sentry.android.distribution + +import io.sentry.SentryLevel +import io.sentry.SentryOptions +import java.io.BufferedReader +import java.io.IOException +import java.io.InputStreamReader +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder +import javax.net.ssl.HttpsURLConnection + +/** HTTP client for making requests to Sentry's distribution API. */ +internal class DistributionHttpClient(private val options: SentryOptions) { + + /** Represents the result of an HTTP request. */ + data class HttpResponse( + val statusCode: Int, + val body: String, + val isSuccessful: Boolean = statusCode in 200..299, + ) + + /** Parameters for checking updates. */ + data class UpdateCheckParams( + val mainBinaryIdentifier: String, + val appId: String, + val platform: String = "android", + val versionCode: Long, + val versionName: String, + ) + + /** + * Makes a GET request to the distribution API to check for updates. + * + * @param params Update check parameters + * @return HttpResponse containing the response details + */ + fun checkForUpdates(params: UpdateCheckParams): HttpResponse { + val distributionOptions = options.distribution + val orgSlug = distributionOptions.orgSlug + val projectSlug = distributionOptions.projectSlug + val authToken = distributionOptions.orgAuthToken + val baseUrl = distributionOptions.sentryBaseUrl + + if (orgSlug.isNullOrEmpty() || projectSlug.isNullOrEmpty() || authToken.isNullOrEmpty()) { + throw IllegalStateException( + "Missing required distribution configuration: orgSlug, projectSlug, or orgAuthToken" + ) + } + + val urlString = buildString { + append(baseUrl.trimEnd('/')) + append( + "/api/0/projects/${URLEncoder.encode(orgSlug, "UTF-8")}/${URLEncoder.encode(projectSlug, "UTF-8")}/preprodartifacts/check-for-updates/" + ) + append("?main_binary_identifier=${URLEncoder.encode(params.mainBinaryIdentifier, "UTF-8")}") + append("&app_id=${URLEncoder.encode(params.appId, "UTF-8")}") + append("&platform=${URLEncoder.encode(params.platform, "UTF-8")}") + append("&build_number=${URLEncoder.encode(params.versionCode.toString(), "UTF-8")}") + append("&build_version=${URLEncoder.encode(params.versionName, "UTF-8")}") + } + val url = URL(urlString) + + return try { + makeRequest(url, authToken) + } catch (e: IOException) { + options.logger.log(SentryLevel.ERROR, e, "Network error while checking for updates") + throw e + } + } + + private fun makeRequest(url: URL, authToken: String): HttpResponse { + val connection = url.openConnection() as HttpURLConnection + + try { + connection.requestMethod = "GET" + connection.setRequestProperty("Authorization", "Bearer $authToken") + connection.setRequestProperty("Accept", "application/json") + connection.setRequestProperty( + "User-Agent", + options.sentryClientName ?: throw IllegalStateException("sentryClientName must be set"), + ) + connection.connectTimeout = options.connectionTimeoutMillis + connection.readTimeout = options.readTimeoutMillis + + if (connection is HttpsURLConnection && options.sslSocketFactory != null) { + connection.sslSocketFactory = options.sslSocketFactory + } + + val responseCode = connection.responseCode + val responseBody = readResponse(connection) + + options.logger.log( + SentryLevel.DEBUG, + "Distribution API request completed with status: $responseCode", + ) + + return HttpResponse(responseCode, responseBody) + } finally { + connection.disconnect() + } + } + + private fun readResponse(connection: HttpURLConnection): String { + val inputStream = + if (connection.responseCode in 200..299) { + connection.inputStream + } else { + connection.errorStream ?: connection.inputStream + } + + return inputStream?.use { stream -> + BufferedReader(InputStreamReader(stream, "UTF-8")).use { reader -> reader.readText() } + } ?: "" + } +} diff --git a/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionIntegration.kt b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionIntegration.kt index 7490e8d627f..f08522bb643 100644 --- a/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionIntegration.kt +++ b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionIntegration.kt @@ -2,13 +2,18 @@ package io.sentry.android.distribution import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri +import android.os.Build import io.sentry.IDistributionApi import io.sentry.IScopes import io.sentry.Integration +import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.UpdateInfo import io.sentry.UpdateStatus +import java.net.SocketTimeoutException +import java.net.UnknownHostException import org.jetbrains.annotations.ApiStatus /** @@ -24,6 +29,9 @@ public class DistributionIntegration(context: Context) : Integration, IDistribut private lateinit var sentryOptions: SentryOptions private val context: Context = context.applicationContext + private lateinit var httpClient: DistributionHttpClient + private lateinit var responseParser: UpdateResponseParser + /** * Registers the Distribution integration with Sentry. * @@ -34,6 +42,10 @@ public class DistributionIntegration(context: Context) : Integration, IDistribut // Store scopes and options for use by distribution functionality this.scopes = scopes this.sentryOptions = options + + // Initialize HTTP client and response parser + this.httpClient = DistributionHttpClient(options) + this.responseParser = UpdateResponseParser(options) } /** @@ -44,7 +56,31 @@ public class DistributionIntegration(context: Context) : Integration, IDistribut * @return UpdateStatus indicating if an update is available, up to date, or error */ public override fun checkForUpdateBlocking(): UpdateStatus { - throw NotImplementedError() + return try { + sentryOptions.logger.log(SentryLevel.DEBUG, "Checking for distribution updates") + + val params = createUpdateCheckParams() + val response = httpClient.checkForUpdates(params) + responseParser.parseResponse(response.statusCode, response.body) + } catch (e: IllegalStateException) { + sentryOptions.logger.log(SentryLevel.WARNING, e.message ?: "Configuration error") + UpdateStatus.UpdateError(e.message ?: "Configuration error") + } catch (e: UnknownHostException) { + // UnknownHostException typically indicates no internet connection available + sentryOptions.logger.log( + SentryLevel.ERROR, + e, + "DNS lookup failed - check internet connection", + ) + UpdateStatus.NoNetwork("No internet connection or invalid server URL") + } catch (e: SocketTimeoutException) { + // SocketTimeoutException could indicate either slow network or server issues + sentryOptions.logger.log(SentryLevel.ERROR, e, "Network request timed out") + UpdateStatus.NoNetwork("Request timed out - check network connection") + } catch (e: Exception) { + sentryOptions.logger.log(SentryLevel.ERROR, e, "Unexpected error checking for updates") + UpdateStatus.UpdateError("Unexpected error: ${e.message}") + } } /** @@ -75,4 +111,37 @@ public class DistributionIntegration(context: Context) : Integration, IDistribut // Silently fail as this is expected behavior in some environments } } + + private fun createUpdateCheckParams(): DistributionHttpClient.UpdateCheckParams { + return try { + val packageManager = context.packageManager + val packageName = context.packageName + val packageInfo = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0)) + } else { + @Suppress("DEPRECATION") packageManager.getPackageInfo(packageName, 0) + } + + val versionName = packageInfo.versionName ?: "unknown" + val versionCode = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageInfo.longVersionCode + } else { + @Suppress("DEPRECATION") packageInfo.versionCode.toLong() + } + val appId = context.applicationInfo.packageName + + DistributionHttpClient.UpdateCheckParams( + mainBinaryIdentifier = appId, + appId = appId, + platform = "android", + versionCode = versionCode, + versionName = versionName, + ) + } catch (e: PackageManager.NameNotFoundException) { + sentryOptions.logger.log(SentryLevel.ERROR, e, "Failed to get package info") + throw IllegalStateException("Unable to get app package information", e) + } + } } diff --git a/sentry-android-distribution/src/main/java/io/sentry/android/distribution/UpdateResponseParser.kt b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/UpdateResponseParser.kt new file mode 100644 index 00000000000..a1396cae462 --- /dev/null +++ b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/UpdateResponseParser.kt @@ -0,0 +1,82 @@ +package io.sentry.android.distribution + +import io.sentry.SentryLevel +import io.sentry.SentryOptions +import io.sentry.UpdateInfo +import io.sentry.UpdateStatus +import org.json.JSONException +import org.json.JSONObject + +/** Parser for distribution API responses. */ +internal class UpdateResponseParser(private val options: SentryOptions) { + + /** + * Parses the API response and returns the appropriate UpdateStatus. + * + * @param statusCode HTTP status code + * @param responseBody Response body as string + * @return UpdateStatus indicating the result + */ + fun parseResponse(statusCode: Int, responseBody: String): UpdateStatus { + return when (statusCode) { + 200 -> parseSuccessResponse(responseBody) + in 400..499 -> UpdateStatus.UpdateError("Client error: $statusCode") + in 500..599 -> UpdateStatus.UpdateError("Server error: $statusCode") + else -> UpdateStatus.UpdateError("Unexpected response code: $statusCode") + } + } + + private fun parseSuccessResponse(responseBody: String): UpdateStatus { + return try { + val json = JSONObject(responseBody) + + options.logger.log(SentryLevel.DEBUG, "Parsing distribution API response") + + // Check if there's a new release available + val updateAvailable = json.optBoolean("updateAvailable", false) + + if (updateAvailable) { + val updateInfo = parseUpdateInfo(json) + UpdateStatus.NewRelease(updateInfo) + } else { + UpdateStatus.UpToDate.getInstance() + } + } catch (e: JSONException) { + options.logger.log(SentryLevel.ERROR, e, "Failed to parse API response") + UpdateStatus.UpdateError("Invalid response format: ${e.message}") + } catch (e: Exception) { + options.logger.log(SentryLevel.ERROR, e, "Unexpected error parsing response") + UpdateStatus.UpdateError("Failed to parse response: ${e.message}") + } + } + + private fun parseUpdateInfo(json: JSONObject): UpdateInfo { + val id = json.optString("id", "") + val buildVersion = json.optString("buildVersion", "") + val buildNumber = json.optInt("buildNumber", 0) + val downloadUrl = json.optString("downloadUrl", "") + val appName = json.optString("appName", "") + val createdDate = json.optString("createdDate", "") + + // Validate required fields (optString returns "null" for null values) + val missingFields = mutableListOf() + + if (id.isEmpty() || id == "null") { + missingFields.add("id") + } + if (buildVersion.isEmpty() || buildVersion == "null") { + missingFields.add("buildVersion") + } + if (downloadUrl.isEmpty() || downloadUrl == "null") { + missingFields.add("downloadUrl") + } + + if (missingFields.isNotEmpty()) { + throw IllegalArgumentException( + "Missing required fields in API response: ${missingFields.joinToString(", ")}" + ) + } + + return UpdateInfo(id, buildVersion, buildNumber, downloadUrl, appName, createdDate) + } +} diff --git a/sentry-android-distribution/src/test/java/io/sentry/android/distribution/DistributionHttpClientTest.kt b/sentry-android-distribution/src/test/java/io/sentry/android/distribution/DistributionHttpClientTest.kt new file mode 100644 index 00000000000..9d456ae6f7c --- /dev/null +++ b/sentry-android-distribution/src/test/java/io/sentry/android/distribution/DistributionHttpClientTest.kt @@ -0,0 +1,54 @@ +package io.sentry.android.distribution + +import io.sentry.SentryOptions +import org.junit.Assert.* +import org.junit.Before +import org.junit.Ignore +import org.junit.Test + +class DistributionHttpClientTest { + + private lateinit var options: SentryOptions + private lateinit var httpClient: DistributionHttpClient + + @Before + fun setUp() { + options = + SentryOptions().apply { + connectionTimeoutMillis = 10000 + readTimeoutMillis = 10000 + } + + options.distribution.apply { + orgSlug = "sentry" + projectSlug = "launchpad-test" + orgAuthToken = "DONT_CHECK_THIS_IN" + sentryBaseUrl = "https://sentry.io" + } + + httpClient = DistributionHttpClient(options) + } + + @Test + @Ignore("This is just used for testing against the real API.") + fun `test checkForUpdates with real API`() { + val params = + DistributionHttpClient.UpdateCheckParams( + mainBinaryIdentifier = "com.emergetools.hackernews", + appId = "com.emergetools.hackernews", + versionName = "1.0.0", + versionCode = 5L, + ) + + val response = httpClient.checkForUpdates(params) + + // Print response for debugging + println("HTTP Status: ${response.statusCode}") + println("Response Body: ${response.body}") + println("Is Successful: ${response.isSuccessful}") + + // Basic assertions + assertTrue("Response should have a status code", response.statusCode > 0) + assertNotNull("Response body should not be null", response.body) + } +} diff --git a/sentry-android-distribution/src/test/java/io/sentry/android/distribution/UpdateResponseParserTest.kt b/sentry-android-distribution/src/test/java/io/sentry/android/distribution/UpdateResponseParserTest.kt new file mode 100644 index 00000000000..1cefdfa7ac2 --- /dev/null +++ b/sentry-android-distribution/src/test/java/io/sentry/android/distribution/UpdateResponseParserTest.kt @@ -0,0 +1,313 @@ +package io.sentry.android.distribution + +import io.sentry.SentryOptions +import io.sentry.UpdateStatus +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class UpdateResponseParserTest { + + private lateinit var options: SentryOptions + private lateinit var parser: UpdateResponseParser + + @Before + fun setUp() { + options = SentryOptions() + parser = UpdateResponseParser(options) + } + + @Test + fun `parseResponse returns NewRelease when update is available`() { + val responseBody = + """ + { + "updateAvailable": true, + "id": "update-123", + "buildVersion": "2.0.0", + "buildNumber": 42, + "downloadUrl": "https://example.com/download", + "appName": "Test App", + "createdDate": "2023-10-01T00:00:00Z" + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return NewRelease", result is UpdateStatus.NewRelease) + val updateInfo = (result as UpdateStatus.NewRelease).info + assertEquals("update-123", updateInfo.id) + assertEquals("2.0.0", updateInfo.buildVersion) + assertEquals(42, updateInfo.buildNumber) + assertEquals("https://example.com/download", updateInfo.downloadUrl) + assertEquals("Test App", updateInfo.appName) + assertEquals("2023-10-01T00:00:00Z", updateInfo.createdDate) + } + + @Test + fun `parseResponse returns UpToDate when no update is available`() { + val responseBody = + """ + { + "updateAvailable": false + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return UpToDate", result is UpdateStatus.UpToDate) + } + + @Test + fun `parseResponse returns UpToDate when updateAvailable is missing`() { + val responseBody = + """ + { + "someOtherField": "value" + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return UpToDate", result is UpdateStatus.UpToDate) + } + + @Test + fun `parseResponse returns UpdateError for 4xx status codes`() { + val result = parser.parseResponse(404, "Not found") + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertEquals("Client error: 404", error.message) + } + + @Test + fun `parseResponse returns UpdateError for 5xx status codes`() { + val result = parser.parseResponse(500, "Internal server error") + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertEquals("Server error: 500", error.message) + } + + @Test + fun `parseResponse returns UpdateError for unexpected status codes`() { + val result = parser.parseResponse(999, "Unknown status") + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertEquals("Unexpected response code: 999", error.message) + } + + @Test + fun `parseResponse returns UpdateError for invalid JSON`() { + val result = parser.parseResponse(200, "invalid json {") + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertTrue( + "Error message should mention invalid format", + error.message.startsWith("Invalid response format:"), + ) + } + + @Test + fun `parseResponse returns UpdateError when required fields are missing`() { + val responseBody = + """ + { + "updateAvailable": true, + "buildVersion": "2.0.0" + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertTrue( + "Error message should mention failed to parse", + error.message.startsWith("Failed to parse response:"), + ) + } + + @Test + fun `parseResponse handles minimal valid update response`() { + val responseBody = + """ + { + "updateAvailable": true, + "id": "update-123", + "buildVersion": "2.0.0", + "downloadUrl": "https://example.com/download" + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return NewRelease", result is UpdateStatus.NewRelease) + val updateInfo = (result as UpdateStatus.NewRelease).info + assertEquals("update-123", updateInfo.id) + assertEquals("2.0.0", updateInfo.buildVersion) + assertEquals(0, updateInfo.buildNumber) // Default value + assertEquals("https://example.com/download", updateInfo.downloadUrl) + assertEquals("", updateInfo.appName) // Default value + assertEquals("", updateInfo.createdDate) // Default value + } + + @Test + fun `parseResponse handles empty response body`() { + val result = parser.parseResponse(200, "") + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertTrue( + "Error message should mention invalid format", + error.message.startsWith("Invalid response format:"), + ) + } + + @Test + fun `parseResponse handles null values in JSON`() { + val responseBody = + """ + { + "updateAvailable": true, + "id": null, + "buildVersion": "2.0.0", + "downloadUrl": "https://example.com/download" + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertTrue( + "Error message should mention failed to parse", + error.message.startsWith("Failed to parse response:"), + ) + } + + @Test + fun `parseResponse returns specific error message when id is missing`() { + val responseBody = + """ + { + "updateAvailable": true, + "buildVersion": "2.0.0", + "downloadUrl": "https://example.com/download" + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertTrue( + "Error message should mention missing id field", + error.message.contains("Missing required fields in API response: id"), + ) + } + + @Test + fun `parseResponse returns specific error message when buildVersion is missing`() { + val responseBody = + """ + { + "updateAvailable": true, + "id": "update-123", + "downloadUrl": "https://example.com/download" + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertTrue( + "Error message should mention missing buildVersion field", + error.message.contains("Missing required fields in API response: buildVersion"), + ) + } + + @Test + fun `parseResponse returns specific error message when downloadUrl is missing`() { + val responseBody = + """ + { + "updateAvailable": true, + "id": "update-123", + "buildVersion": "2.0.0" + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertTrue( + "Error message should mention missing downloadUrl field", + error.message.contains("Missing required fields in API response: downloadUrl"), + ) + } + + @Test + fun `parseResponse returns specific error message when multiple fields are missing`() { + val responseBody = + """ + { + "updateAvailable": true, + "buildNumber": 42 + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertTrue( + "Error message should mention all missing required fields", + error.message.contains( + "Missing required fields in API response: id, buildVersion, downloadUrl" + ), + ) + } + + @Test + fun `parseResponse returns specific error message when field is null string`() { + val responseBody = + """ + { + "updateAvailable": true, + "id": "null", + "buildVersion": "2.0.0", + "downloadUrl": "https://example.com/download" + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertTrue( + "Error message should mention missing id field when value is 'null' string", + error.message.contains("Missing required fields in API response: id"), + ) + } +} diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 527461870aa..0f478c95d57 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4344,6 +4344,11 @@ public final class io/sentry/UpdateStatus$NewRelease : io/sentry/UpdateStatus { public fun getInfo ()Lio/sentry/UpdateInfo; } +public final class io/sentry/UpdateStatus$NoNetwork : io/sentry/UpdateStatus { + public fun (Ljava/lang/String;)V + public fun getMessage ()Ljava/lang/String; +} + public final class io/sentry/UpdateStatus$UpToDate : io/sentry/UpdateStatus { public static fun getInstance ()Lio/sentry/UpdateStatus$UpToDate; } diff --git a/sentry/src/main/java/io/sentry/UpdateInfo.java b/sentry/src/main/java/io/sentry/UpdateInfo.java index 84edd7c4c59..66a5452191d 100644 --- a/sentry/src/main/java/io/sentry/UpdateInfo.java +++ b/sentry/src/main/java/io/sentry/UpdateInfo.java @@ -2,6 +2,7 @@ import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; /** Information about an available app update. */ @ApiStatus.Experimental @@ -11,7 +12,7 @@ public final class UpdateInfo { private final int buildNumber; private final @NotNull String downloadUrl; private final @NotNull String appName; - private final @NotNull String createdDate; + private final @Nullable String createdDate; public UpdateInfo( final @NotNull String id, @@ -19,7 +20,7 @@ public UpdateInfo( final int buildNumber, final @NotNull String downloadUrl, final @NotNull String appName, - final @NotNull String createdDate) { + final @Nullable String createdDate) { this.id = id; this.buildVersion = buildVersion; this.buildNumber = buildNumber; @@ -48,7 +49,7 @@ public int getBuildNumber() { return appName; } - public @NotNull String getCreatedDate() { + public @Nullable String getCreatedDate() { return createdDate; } } diff --git a/sentry/src/main/java/io/sentry/UpdateStatus.java b/sentry/src/main/java/io/sentry/UpdateStatus.java index 3de15680a0c..c7c14ca68cd 100644 --- a/sentry/src/main/java/io/sentry/UpdateStatus.java +++ b/sentry/src/main/java/io/sentry/UpdateStatus.java @@ -43,4 +43,17 @@ public UpdateError(final @NotNull String message) { return message; } } + + /** No network connection is available to check for updates. */ + public static final class NoNetwork extends UpdateStatus { + private final @NotNull String message; + + public NoNetwork(final @NotNull String message) { + this.message = message; + } + + public @NotNull String getMessage() { + return message; + } + } } From 4c657b6b0245044bee3b079b14cbde0235f5bdb0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Sep 2025 07:38:41 +0000 Subject: [PATCH 08/15] build(deps): bump github/codeql-action from 3.30.3 to 3.30.5 (#4761) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.30.3 to 3.30.5. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/192325c86100d080feab897ff886c34abd4c83a3...3599b3baa15b485a2e49ef411a7a4bb2452e7f93) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 3.30.5 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 5fa5aea6989..8111b344955 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -36,7 +36,7 @@ jobs: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Initialize CodeQL - uses: github/codeql-action/init@192325c86100d080feab897ff886c34abd4c83a3 # pin@v2 + uses: github/codeql-action/init@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # pin@v2 with: languages: 'java' @@ -45,4 +45,4 @@ jobs: ./gradlew buildForCodeQL --no-build-cache - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@192325c86100d080feab897ff886c34abd4c83a3 # pin@v2 + uses: github/codeql-action/analyze@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # pin@v2 From b66ccf37e6b3000d423c8162a56173ccaeba1ab9 Mon Sep 17 00:00:00 2001 From: Michiel Vleeming <75809013+VleemingM@users.noreply.github.com> Date: Mon, 29 Sep 2025 11:07:27 +0200 Subject: [PATCH 09/15] Fix(compose): Preserve modifiers in SentryNavigable (#4757) * Fix(compose): Preserve modifiers in SentryNavigable * Update Changelog --------- Co-authored-by: Michiel Vleeming Co-authored-by: Markus Hintersteiner --- CHANGELOG.md | 4 ++++ .../kotlin/io/sentry/compose/SentryComposeTracing.kt | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f2b745f91a..d24d2840e69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixes + +- Preserve modifiers in `SentryTraced` ([#4757](https://github.com/getsentry/sentry-java/pull/4757)) + ### Improvements - Handle `RejectedExecutionException` everywhere ([#4747](https://github.com/getsentry/sentry-java/pull/4747)) diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt index aa1b2279b83..ca923e5d2c9 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt @@ -78,7 +78,7 @@ public fun SentryTraced( } val firstRendered = remember { ImmutableHolder(false) } - val baseModifier = if (enableUserInteractionTracing) Modifier.sentryTag(tag) else modifier + val baseModifier = if (enableUserInteractionTracing) modifier.sentryTag(tag) else modifier Box( modifier = From 806307f1660314ee20989b724d670bafe8dd124a Mon Sep 17 00:00:00 2001 From: Stefano Date: Mon, 29 Sep 2025 17:46:54 +0200 Subject: [PATCH 10/15] Start performance collection on AppStart continuous profiling (#4752) * app start continuous profiler will now start performance collection when the SDK inits --- CHANGELOG.md | 1 + .../api/sentry-android-core.api | 1 + .../core/AndroidContinuousProfiler.java | 9 +++- .../core/AndroidOptionsInitializer.java | 49 ++++++++++++------- .../core/AndroidContinuousProfilerTest.kt | 10 ++++ .../core/AndroidOptionsInitializerTest.kt | 29 +++++++++++ sentry/api/sentry.api | 2 + .../java/io/sentry/IContinuousProfiler.java | 3 ++ .../io/sentry/NoOpContinuousProfiler.java | 5 ++ .../io/sentry/NoOpContinuousProfilerTest.kt | 5 ++ 10 files changed, 93 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d24d2840e69..d8fb0ca1af8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixes +- Start performance collection on AppStart continuous profiling ([#4752](https://github.com/getsentry/sentry-java/pull/4752)) - Preserve modifiers in `SentryTraced` ([#4757](https://github.com/getsentry/sentry-java/pull/4757)) ### Improvements diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 5b416486576..0712c78ce91 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -43,6 +43,7 @@ public final class io/sentry/android/core/ActivityLifecycleIntegration : android public class io/sentry/android/core/AndroidContinuousProfiler : io/sentry/IContinuousProfiler, io/sentry/transport/RateLimiter$IRateLimitObserver { public fun (Lio/sentry/android/core/BuildInfoProvider;Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;Lio/sentry/ILogger;Ljava/lang/String;ILio/sentry/ISentryExecutorService;)V public fun close (Z)V + public fun getChunkId ()Lio/sentry/protocol/SentryId; public fun getProfilerId ()Lio/sentry/protocol/SentryId; public fun getRootSpanCounter ()I public fun isRunning ()Z diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java index a3fb6f6c8db..699e49ba973 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java @@ -208,11 +208,11 @@ private void start() { isRunning = true; - if (profilerId == SentryId.EMPTY_ID) { + if (profilerId.equals(SentryId.EMPTY_ID)) { profilerId = new SentryId(); } - if (chunkId == SentryId.EMPTY_ID) { + if (chunkId.equals(SentryId.EMPTY_ID)) { chunkId = new SentryId(); } @@ -344,6 +344,11 @@ public void close(final boolean isTerminating) { return profilerId; } + @Override + public @NotNull SentryId getChunkId() { + return chunkId; + } + private void sendChunks(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { try { options diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 296916bb9ef..70c76c72824 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -5,6 +5,7 @@ import android.app.Application; import android.content.Context; import android.content.pm.PackageInfo; +import io.sentry.CompositePerformanceCollector; import io.sentry.DeduplicateMultithreadedEventProcessor; import io.sentry.DefaultCompositePerformanceCollector; import io.sentry.DefaultVersionDetector; @@ -45,6 +46,7 @@ import io.sentry.internal.gestures.GestureTargetLocator; import io.sentry.internal.modules.NoOpModulesLoader; import io.sentry.internal.viewhierarchy.ViewHierarchyExporter; +import io.sentry.protocol.SentryId; import io.sentry.transport.CurrentDateProvider; import io.sentry.transport.NoOpEnvelopeCache; import io.sentry.transport.NoOpTransportGate; @@ -180,25 +182,7 @@ static void initializeIntegrationsAndProcessors( options.setTransportGate(new AndroidTransportGate(options)); } - // Check if the profiler was already instantiated in the app start. - // We use the Android profiler, that uses a global start/stop api, so we need to preserve the - // state of the profiler, and it's only possible retaining the instance. final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); - final @Nullable ITransactionProfiler appStartTransactionProfiler; - final @Nullable IContinuousProfiler appStartContinuousProfiler; - try (final @NotNull ISentryLifecycleToken ignored = AppStartMetrics.staticLock.acquire()) { - appStartTransactionProfiler = appStartMetrics.getAppStartProfiler(); - appStartContinuousProfiler = appStartMetrics.getAppStartContinuousProfiler(); - appStartMetrics.setAppStartProfiler(null); - appStartMetrics.setAppStartContinuousProfiler(null); - } - - setupProfiler( - options, - context, - buildInfoProvider, - appStartTransactionProfiler, - appStartContinuousProfiler); if (options.getModulesLoader() instanceof NoOpModulesLoader) { options.setModulesLoader(new AssetsModulesLoader(context, options.getLogger())); @@ -262,6 +246,26 @@ static void initializeIntegrationsAndProcessors( if (options.getCompositePerformanceCollector() instanceof NoOpCompositePerformanceCollector) { options.setCompositePerformanceCollector(new DefaultCompositePerformanceCollector(options)); } + + // Check if the profiler was already instantiated in the app start. + // We use the Android profiler, that uses a global start/stop api, so we need to preserve the + // state of the profiler, and it's only possible retaining the instance. + final @Nullable ITransactionProfiler appStartTransactionProfiler; + final @Nullable IContinuousProfiler appStartContinuousProfiler; + try (final @NotNull ISentryLifecycleToken ignored = AppStartMetrics.staticLock.acquire()) { + appStartTransactionProfiler = appStartMetrics.getAppStartProfiler(); + appStartContinuousProfiler = appStartMetrics.getAppStartContinuousProfiler(); + appStartMetrics.setAppStartProfiler(null); + appStartMetrics.setAppStartContinuousProfiler(null); + } + + setupProfiler( + options, + context, + buildInfoProvider, + appStartTransactionProfiler, + appStartContinuousProfiler, + options.getCompositePerformanceCollector()); } /** Setup the correct profiler (transaction or continuous) based on the options. */ @@ -270,7 +274,8 @@ private static void setupProfiler( final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider, final @Nullable ITransactionProfiler appStartTransactionProfiler, - final @Nullable IContinuousProfiler appStartContinuousProfiler) { + final @Nullable IContinuousProfiler appStartContinuousProfiler, + final @NotNull CompositePerformanceCollector performanceCollector) { if (options.isProfilingEnabled() || options.getProfilesSampleRate() != null) { options.setContinuousProfiler(NoOpContinuousProfiler.getInstance()); // This is a safeguard, but it should never happen, as the app start profiler should be the @@ -299,6 +304,12 @@ private static void setupProfiler( } if (appStartContinuousProfiler != null) { options.setContinuousProfiler(appStartContinuousProfiler); + // If the profiler is running, we start the performance collector too, otherwise we'd miss + // measurements in app launch profiles + final @NotNull SentryId chunkId = appStartContinuousProfiler.getChunkId(); + if (appStartContinuousProfiler.isRunning() && !chunkId.equals(SentryId.EMPTY_ID)) { + performanceCollector.start(chunkId.toString()); + } } else { options.setContinuousProfiler( new AndroidContinuousProfiler( diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt index 34fc60d3634..60a5ab530fc 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt @@ -30,6 +30,7 @@ import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -167,9 +168,13 @@ class AndroidContinuousProfilerTest { // We are scheduling the profiler to stop at the end of the chunk, so it should still be running profiler.stopProfiler(ProfileLifecycle.MANUAL) assertTrue(profiler.isRunning) + assertNotEquals(SentryId.EMPTY_ID, profiler.profilerId) + assertNotEquals(SentryId.EMPTY_ID, profiler.chunkId) // We run the executor service to trigger the chunk finish, and the profiler shouldn't restart fixture.executor.runAll() assertFalse(profiler.isRunning) + assertEquals(SentryId.EMPTY_ID, profiler.profilerId) + assertEquals(SentryId.EMPTY_ID, profiler.chunkId) } @Test @@ -397,6 +402,7 @@ class AndroidContinuousProfilerTest { val profiler = fixture.getSut() profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) assertTrue(profiler.isRunning) + val oldChunkId = profiler.chunkId fixture.executor.runAll() verify(fixture.mockLogger) @@ -407,6 +413,7 @@ class AndroidContinuousProfilerTest { verify(fixture.mockLogger, times(2)) .log(eq(SentryLevel.DEBUG), eq("Profile chunk finished. Starting a new one.")) assertTrue(profiler.isRunning) + assertNotEquals(oldChunkId, profiler.chunkId) } @Test @@ -508,6 +515,7 @@ class AndroidContinuousProfilerTest { profiler.onRateLimitChanged(rateLimiter) assertFalse(profiler.isRunning) assertEquals(SentryId.EMPTY_ID, profiler.profilerId) + assertEquals(SentryId.EMPTY_ID, profiler.chunkId) verify(fixture.mockLogger) .log(eq(SentryLevel.WARNING), eq("SDK is rate limited. Stopping profiler.")) } @@ -523,6 +531,7 @@ class AndroidContinuousProfilerTest { profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) assertFalse(profiler.isRunning) assertEquals(SentryId.EMPTY_ID, profiler.profilerId) + assertEquals(SentryId.EMPTY_ID, profiler.chunkId) verify(fixture.mockLogger) .log(eq(SentryLevel.WARNING), eq("SDK is rate limited. Stopping profiler.")) } @@ -541,6 +550,7 @@ class AndroidContinuousProfilerTest { profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) assertFalse(profiler.isRunning) assertEquals(SentryId.EMPTY_ID, profiler.profilerId) + assertEquals(SentryId.EMPTY_ID, profiler.chunkId) verify(fixture.mockLogger) .log(eq(SentryLevel.WARNING), eq("Device is offline. Stopping profiler.")) } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index 79b5ce39be1..d49a905772d 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -12,6 +12,7 @@ import io.sentry.IConnectionStatusProvider import io.sentry.IContinuousProfiler import io.sentry.ILogger import io.sentry.ISocketTagger +import io.sentry.ITransaction import io.sentry.ITransactionProfiler import io.sentry.MainEventProcessor import io.sentry.NoOpContinuousProfiler @@ -34,6 +35,7 @@ import io.sentry.cache.PersistingScopeObserver import io.sentry.compose.gestures.ComposeGestureTargetLocator import io.sentry.internal.debugmeta.IDebugMetaLoader import io.sentry.internal.modules.IModulesLoader +import io.sentry.protocol.SentryId import io.sentry.test.ImmediateExecutorService import io.sentry.transport.ITransportGate import io.sentry.util.thread.IThreadChecker @@ -426,6 +428,33 @@ class AndroidOptionsInitializerTest { assertNull(AppStartMetrics.getInstance().appStartContinuousProfiler) } + @Test + fun `init starts performance collector if continuous profiler of appStartMetrics is running`() { + val appStartContinuousProfiler = mock() + val mockPerformanceCollector = mock() + val chunkId = SentryId() + whenever(appStartContinuousProfiler.isRunning()).thenReturn(true) + whenever(appStartContinuousProfiler.chunkId).thenReturn(chunkId) + + AppStartMetrics.getInstance().appStartContinuousProfiler = appStartContinuousProfiler + fixture.initSut(configureOptions = { compositePerformanceCollector = mockPerformanceCollector }) + + verify(mockPerformanceCollector).start(eq(chunkId.toString())) + } + + @Test + fun `init does not start performance collector if transaction profiler of appStartMetrics is running`() { + val appStartTransactionProfiler = mock() + val mockPerformanceCollector = mock() + whenever(appStartTransactionProfiler.isRunning()).thenReturn(true) + + AppStartMetrics.getInstance().appStartProfiler = appStartTransactionProfiler + fixture.initSut(configureOptions = { compositePerformanceCollector = mockPerformanceCollector }) + + verify(mockPerformanceCollector, never()).start(any()) + verify(mockPerformanceCollector, never()).start(any()) + } + @Test fun `init with transaction profiling closes continuous profiler of appStartMetrics`() { val appStartContinuousProfiler = mock() diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 0f478c95d57..e32a335e881 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -760,6 +760,7 @@ public abstract interface class io/sentry/IConnectionStatusProvider$IConnectionS public abstract interface class io/sentry/IContinuousProfiler { public abstract fun close (Z)V + public abstract fun getChunkId ()Lio/sentry/protocol/SentryId; public abstract fun getProfilerId ()Lio/sentry/protocol/SentryId; public abstract fun isRunning ()Z public abstract fun reevaluateSampling ()V @@ -1471,6 +1472,7 @@ public final class io/sentry/NoOpConnectionStatusProvider : io/sentry/IConnectio public final class io/sentry/NoOpContinuousProfiler : io/sentry/IContinuousProfiler { public fun close (Z)V + public fun getChunkId ()Lio/sentry/protocol/SentryId; public static fun getInstance ()Lio/sentry/NoOpContinuousProfiler; public fun getProfilerId ()Lio/sentry/protocol/SentryId; public fun isRunning ()Z diff --git a/sentry/src/main/java/io/sentry/IContinuousProfiler.java b/sentry/src/main/java/io/sentry/IContinuousProfiler.java index 3abca9822aa..f7e59362273 100644 --- a/sentry/src/main/java/io/sentry/IContinuousProfiler.java +++ b/sentry/src/main/java/io/sentry/IContinuousProfiler.java @@ -25,4 +25,7 @@ void startProfiler( @NotNull SentryId getProfilerId(); + + @NotNull + SentryId getChunkId(); } diff --git a/sentry/src/main/java/io/sentry/NoOpContinuousProfiler.java b/sentry/src/main/java/io/sentry/NoOpContinuousProfiler.java index 893eb914ad9..4cda59e7c33 100644 --- a/sentry/src/main/java/io/sentry/NoOpContinuousProfiler.java +++ b/sentry/src/main/java/io/sentry/NoOpContinuousProfiler.java @@ -36,4 +36,9 @@ public void reevaluateSampling() {} public @NotNull SentryId getProfilerId() { return SentryId.EMPTY_ID; } + + @Override + public @NotNull SentryId getChunkId() { + return SentryId.EMPTY_ID; + } } diff --git a/sentry/src/test/java/io/sentry/NoOpContinuousProfilerTest.kt b/sentry/src/test/java/io/sentry/NoOpContinuousProfilerTest.kt index 5c32f4f6a71..eeae9bdcca1 100644 --- a/sentry/src/test/java/io/sentry/NoOpContinuousProfilerTest.kt +++ b/sentry/src/test/java/io/sentry/NoOpContinuousProfilerTest.kt @@ -25,6 +25,11 @@ class NoOpContinuousProfilerTest { assertEquals(profiler.profilerId, SentryId.EMPTY_ID) } + @Test + fun `getChunkId returns Empty SentryId`() { + assertEquals(profiler.chunkId, SentryId.EMPTY_ID) + } + @Test fun `reevaluateSampling does not throw`() { profiler.reevaluateSampling() From 604a2616bebd80ea051cdcec916aa052b509c5a8 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 30 Sep 2025 15:02:40 +0200 Subject: [PATCH 11/15] feat(android-distribution): Add update check button to Android sample (#4764) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add sentry-android-distribution as debug-only dependency - Add "Check for Update" button to main activity UI - Implement handler that calls Sentry.distribution().checkForUpdate() - Handle all UpdateStatus types: NewRelease, UpToDate, NoNetwork, UpdateError - Display results in toast messages Note: This feature requires proper distribution tokens and configuration to work. This change makes it easier to test the distribution integration. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude --- .../sentry-samples-android/build.gradle.kts | 1 + .../sentry/samples/android/MainActivity.java | 36 +++++++++++++++++++ .../src/main/res/layout/activity_main.xml | 6 ++++ .../src/main/res/values/strings.xml | 1 + 4 files changed, 44 insertions(+) diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index 56f270d235b..acec6ac809c 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -150,6 +150,7 @@ dependencies { implementation(libs.sentry.native.ndk) implementation(libs.timber) + debugImplementation(projects.sentryAndroidDistribution) debugImplementation(libs.leakcanary) } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java index 1b9acc3c267..824bef1ebab 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java @@ -5,12 +5,14 @@ import android.content.res.Configuration; import android.os.Bundle; import android.os.Handler; +import android.widget.Toast; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import io.sentry.Attachment; import io.sentry.ISpan; import io.sentry.MeasurementUnit; import io.sentry.Sentry; +import io.sentry.UpdateStatus; import io.sentry.instrumentation.file.SentryFileOutputStream; import io.sentry.protocol.Feedback; import io.sentry.protocol.User; @@ -304,6 +306,40 @@ public void run() { Sentry.replay().enableDebugMaskingOverlay(); }); + binding.checkForUpdate.setOnClickListener( + view -> { + Toast.makeText(this, "Checking for updates...", Toast.LENGTH_SHORT).show(); + Sentry.distribution() + .checkForUpdate( + result -> { + runOnUiThread( + () -> { + String message; + if (result instanceof UpdateStatus.NewRelease) { + UpdateStatus.NewRelease newRelease = (UpdateStatus.NewRelease) result; + message = + "Update available: " + + newRelease.getInfo().getBuildVersion() + + " (Build " + + newRelease.getInfo().getBuildNumber() + + ")\nDownload URL: " + + newRelease.getInfo().getDownloadUrl(); + } else if (result instanceof UpdateStatus.UpToDate) { + message = "App is up to date!"; + } else if (result instanceof UpdateStatus.NoNetwork) { + UpdateStatus.NoNetwork noNetwork = (UpdateStatus.NoNetwork) result; + message = "No network connection: " + noNetwork.getMessage(); + } else if (result instanceof UpdateStatus.UpdateError) { + UpdateStatus.UpdateError error = (UpdateStatus.UpdateError) result; + message = "Error checking for updates: " + error.getMessage(); + } else { + message = "Unknown status"; + } + Toast.makeText(this, message, Toast.LENGTH_LONG).show(); + }); + }); + }); + setContentView(binding.getRoot()); } diff --git a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml index d2eda41a387..0083fae8f93 100644 --- a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml +++ b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml @@ -170,6 +170,12 @@ android:layout_height="wrap_content" android:text="@string/enable_replay_debug_mode"/> +