From 01b74ec352d66720810032357d10cfa42f0d1ff1 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 13 Aug 2026 06:07:07 +0200 Subject: [PATCH 1/6] feat(android): Warn for legacy Metrics metadata Detect explicit io.sentry.metrics.enabled manifest values and emit tailored migration warnings without applying the obsolete option. Co-Authored-By: Claude --- .../android/core/ManifestMetadataReader.java | 15 ++++++ .../core/ManifestMetadataReaderTest.kt | 50 ++++++++++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 38ee0c1d347..0a4ed051bf7 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -723,6 +723,21 @@ static void applyMetadata( } } + if (metadata.containsKey(ENABLE_METRICS)) { + final boolean enableMetrics = readBool(metadata, logger, ENABLE_METRICS, false); + if (enableMetrics) { + logger.log( + SentryLevel.WARNING, + "The Android manifest option 'io.sentry.metrics.enabled' is no longer supported. " + + "Manual Sentry.metrics() calls no longer require it."); + } else { + logger.log( + SentryLevel.WARNING, + "The Android manifest option 'io.sentry.metrics.enabled' no longer disables " + + "manual Sentry.metrics() calls."); + } + } + options.setEnableTimberLogs( readBool(metadata, logger, ENABLE_TIMBER_LOGS, options.isEnableTimberLogs())); diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index c2dfa7d544a..e5b2f1fcd0b 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -2054,8 +2054,22 @@ class ManifestMetadataReaderTest { } @Test - fun `legacy metrics metadata does not disable capture`() { - val bundle = bundleOf(ManifestMetadataReader.ENABLE_METRICS to false) + fun `applyMetadata does not warn when legacy metrics enabled metadata is absent`() { + fixture.options.isDebug = true + val context = fixture.getContext() + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + verify(fixture.logger, never()).log(eq(SentryLevel.WARNING), any()) + } + + @Test + fun `applyMetadata warns when legacy metrics enabled metadata is true`() { + val bundle = + bundleOf( + ManifestMetadataReader.DEBUG to true, + ManifestMetadataReader.ENABLE_METRICS to true, + ) val context = fixture.getContext(metaData = bundle) val client = createSentryClientMock() @@ -2064,6 +2078,38 @@ class ManifestMetadataReaderTest { val scopes = createTestScopes(fixture.options).also { it.bindClient(client) } scopes.metrics().count("metric name") + verify(fixture.logger) + .log( + SentryLevel.WARNING, + "The Android manifest option 'io.sentry.metrics.enabled' is no longer supported. " + + "Manual Sentry.metrics() calls no longer require it.", + *emptyArray(), + ) + verify(client).captureMetric(any(), anyOrNull(), anyOrNull()) + } + + @Test + fun `applyMetadata warns when legacy metrics enabled metadata is false`() { + val bundle = + bundleOf( + ManifestMetadataReader.DEBUG to true, + ManifestMetadataReader.ENABLE_METRICS to false, + ) + val context = fixture.getContext(metaData = bundle) + val client = createSentryClientMock() + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + fixture.options.dsn = "https://key@sentry.io/proj" + val scopes = createTestScopes(fixture.options).also { it.bindClient(client) } + scopes.metrics().count("metric name") + + verify(fixture.logger) + .log( + SentryLevel.WARNING, + "The Android manifest option 'io.sentry.metrics.enabled' no longer disables manual " + + "Sentry.metrics() calls.", + *emptyArray(), + ) verify(client).captureMetric(any(), anyOrNull(), anyOrNull()) } From 5f068d302b09f5b4d96c80346842205b67bffae2 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 13 Aug 2026 06:11:23 +0200 Subject: [PATCH 2/6] feat(core): Warn for legacy Metrics configuration Emit tailored migration warnings for explicit metrics.enabled values while preserving manual Metrics capture regardless of the obsolete setting. Co-Authored-By: Claude --- .../main/java/io/sentry/SentryOptions.java | 13 ++++ .../test/java/io/sentry/SentryOptionsTest.kt | 66 +++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 5394c22e2d2..33948ead53e 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -3748,6 +3748,19 @@ public void merge(final @NotNull ExternalOptions options) { } } + if (options.isEnableMetrics() != null) { + if (options.isEnableMetrics()) { + logger.log( + SentryLevel.WARNING, + "The 'metrics.enabled' option is no longer supported. Manual Sentry.metrics() calls no " + + "longer require it."); + } else { + logger.log( + SentryLevel.WARNING, + "The 'metrics.enabled' option no longer disables manual Sentry.metrics() calls."); + } + } + if (options.getProfileSessionSampleRate() != null) { setProfileSessionSampleRate(options.getProfileSessionSampleRate()); } diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index d56cbeb41ac..398a1de11b0 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -3,6 +3,7 @@ package io.sentry import io.sentry.SentryOptions.RequestSize import io.sentry.logger.ILoggerBatchProcessorFactory import io.sentry.logger.LoggerApi +import io.sentry.metrics.MetricsApi import io.sentry.test.createSentryClientMock import io.sentry.test.createTestScopes import io.sentry.util.StringUtils @@ -564,6 +565,71 @@ class SentryOptionsTest { verify(client).captureLog(any(), anyOrNull()) } + @Test + fun `merging options does not warn when legacy metrics configuration is absent`() { + val logger = mock() + val options = + SentryOptions().also { + it.isDebug = true + it.setLogger(logger) + } + + options.merge(ExternalOptions()) + + verify(logger, never()).log(eq(SentryLevel.WARNING), any()) + } + + @Test + fun `merging options warns when legacy metrics configuration is true`() { + val logger = mock() + val options = + SentryOptions().also { + it.isDebug = true + it.setLogger(logger) + } + + options.merge(ExternalOptions().apply { isEnableMetrics = true }) + + verify(logger) + .log( + SentryLevel.WARNING, + "The 'metrics.enabled' option is no longer supported. Manual Sentry.metrics() calls no " + + "longer require it.", + *emptyArray(), + ) + assertLegacyMetricsConfigurationDoesNotDisableCapture(options) + } + + @Test + fun `merging options warns when legacy metrics configuration is false`() { + val logger = mock() + val options = + SentryOptions().also { + it.isDebug = true + it.setLogger(logger) + } + + options.merge(ExternalOptions().apply { isEnableMetrics = false }) + + verify(logger) + .log( + SentryLevel.WARNING, + "The 'metrics.enabled' option no longer disables manual Sentry.metrics() calls.", + *emptyArray(), + ) + assertLegacyMetricsConfigurationDoesNotDisableCapture(options) + } + + private fun assertLegacyMetricsConfigurationDoesNotDisableCapture(options: SentryOptions) { + options.dsn = "https://key@sentry.io/proj" + val client = createSentryClientMock() + val scopes = createTestScopes(options).apply { bindClient(client) } + + MetricsApi(scopes).count("test metric") + + verify(client).captureMetric(any(), anyOrNull(), anyOrNull()) + } + @Test fun `merging options merges and overwrites existing tag values`() { val externalOptions = ExternalOptions() From 61f485a949ca7cceaf65301f981430f9b7a63040 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 13 Aug 2026 06:16:48 +0200 Subject: [PATCH 3/6] feat(spring): Warn for legacy Metrics property Inspect the Spring Environment before SDK initialization and emit tailored migration warnings for explicit sentry.metrics.enabled values without binding or applying the obsolete property. Co-Authored-By: Claude --- .../spring/boot4/SentryAutoConfiguration.java | 24 ++++++++++ .../boot4/SentryAutoConfigurationTest.kt | 46 +++++++++++++++++++ .../boot/jakarta/SentryAutoConfiguration.java | 24 ++++++++++ .../jakarta/SentryAutoConfigurationTest.kt | 46 +++++++++++++++++++ .../spring/boot/SentryAutoConfiguration.java | 24 ++++++++++ .../boot/SentryAutoConfigurationTest.kt | 46 +++++++++++++++++++ 6 files changed, 210 insertions(+) diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryAutoConfiguration.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryAutoConfiguration.java index 17be2777505..d118250f341 100644 --- a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryAutoConfiguration.java +++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryAutoConfiguration.java @@ -187,6 +187,7 @@ static class OpenTelemetryNoAgentConfiguration {} // here we make sure that only classes that extend throwable are set on this field options.getIgnoredExceptionsForType().removeIf(it -> !Throwable.class.isAssignableFrom(it)); warnForLegacyLogsConfiguration(environment, options); + warnForLegacyMetricsConfiguration(environment, options); Sentry.init(options); return ScopesAdapter.getInstance(); } @@ -216,6 +217,29 @@ private void warnForLegacyLogsConfiguration( } } + private void warnForLegacyMetricsConfiguration( + final @NotNull Environment environment, final @NotNull SentryOptions options) { + if (environment.containsProperty("sentry.metrics.enabled")) { + final boolean enableMetrics = + Boolean.TRUE.equals(environment.getProperty("sentry.metrics.enabled", Boolean.class)); + if (enableMetrics) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "The 'sentry.metrics.enabled' property is no longer supported. Manual " + + "Sentry.metrics() calls no longer require it."); + } else { + options + .getLogger() + .log( + SentryLevel.WARNING, + "The 'sentry.metrics.enabled' property no longer disables manual " + + "Sentry.metrics() calls."); + } + } + } + @Configuration(proxyBeanMethods = false) @ConditionalOnClass(MDC.class) @Open diff --git a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt index c3a7556e3ec..5aabbdef929 100644 --- a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt @@ -247,6 +247,52 @@ class SentryAutoConfigurationTest { } } + @Test + fun `legacy metrics property emits no warning when absent`() { + val logger = mock() + dsnEnabledRunner + .withPropertyValues("sentry.debug=true") + .withBean(ILogger::class.java, { logger }) + .withUserConfiguration(LoggerConfiguration::class.java) + .run { verify(logger, never()).log(eq(SentryLevel.WARNING), any()) } + } + + @Test + fun `legacy metrics property true emits migration warning`() { + val logger = mock() + dsnEnabledRunner + .withPropertyValues("sentry.debug=true", "sentry.metrics.enabled=true") + .withBean(ILogger::class.java, { logger }) + .withUserConfiguration(LoggerConfiguration::class.java) + .run { + verify(logger) + .log( + SentryLevel.WARNING, + "The 'sentry.metrics.enabled' property is no longer supported. Manual " + + "Sentry.metrics() calls no longer require it.", + *emptyArray(), + ) + } + } + + @Test + fun `legacy metrics property false emits migration warning`() { + val logger = mock() + dsnEnabledRunner + .withPropertyValues("sentry.debug=true", "sentry.metrics.enabled=false") + .withBean(ILogger::class.java, { logger }) + .withUserConfiguration(LoggerConfiguration::class.java) + .run { + verify(logger) + .log( + SentryLevel.WARNING, + "The 'sentry.metrics.enabled' property no longer disables manual " + + "Sentry.metrics() calls.", + *emptyArray(), + ) + } + } + @Test fun `properties are applied to SentryOptions`() { contextRunner diff --git a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java index ec79b61e121..86ef72a48fb 100644 --- a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java +++ b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java @@ -189,6 +189,7 @@ static class OpenTelemetryNoAgentConfiguration {} // here we make sure that only classes that extend throwable are set on this field options.getIgnoredExceptionsForType().removeIf(it -> !Throwable.class.isAssignableFrom(it)); warnForLegacyLogsConfiguration(environment, options); + warnForLegacyMetricsConfiguration(environment, options); Sentry.init(options); return ScopesAdapter.getInstance(); } @@ -218,6 +219,29 @@ private void warnForLegacyLogsConfiguration( } } + private void warnForLegacyMetricsConfiguration( + final @NotNull Environment environment, final @NotNull SentryOptions options) { + if (environment.containsProperty("sentry.metrics.enabled")) { + final boolean enableMetrics = + Boolean.TRUE.equals(environment.getProperty("sentry.metrics.enabled", Boolean.class)); + if (enableMetrics) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "The 'sentry.metrics.enabled' property is no longer supported. Manual " + + "Sentry.metrics() calls no longer require it."); + } else { + options + .getLogger() + .log( + SentryLevel.WARNING, + "The 'sentry.metrics.enabled' property no longer disables manual " + + "Sentry.metrics() calls."); + } + } + } + @Configuration(proxyBeanMethods = false) @ConditionalOnClass(MDC.class) @Open diff --git a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt index 1ce6c8071ea..df994459403 100644 --- a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt @@ -250,6 +250,52 @@ class SentryAutoConfigurationTest { } } + @Test + fun `legacy metrics property emits no warning when absent`() { + val logger = mock() + dsnEnabledRunner + .withPropertyValues("sentry.debug=true") + .withBean(ILogger::class.java, { logger }) + .withUserConfiguration(LoggerConfiguration::class.java) + .run { verify(logger, never()).log(eq(SentryLevel.WARNING), any()) } + } + + @Test + fun `legacy metrics property true emits migration warning`() { + val logger = mock() + dsnEnabledRunner + .withPropertyValues("sentry.debug=true", "sentry.metrics.enabled=true") + .withBean(ILogger::class.java, { logger }) + .withUserConfiguration(LoggerConfiguration::class.java) + .run { + verify(logger) + .log( + SentryLevel.WARNING, + "The 'sentry.metrics.enabled' property is no longer supported. Manual " + + "Sentry.metrics() calls no longer require it.", + *emptyArray(), + ) + } + } + + @Test + fun `legacy metrics property false emits migration warning`() { + val logger = mock() + dsnEnabledRunner + .withPropertyValues("sentry.debug=true", "sentry.metrics.enabled=false") + .withBean(ILogger::class.java, { logger }) + .withUserConfiguration(LoggerConfiguration::class.java) + .run { + verify(logger) + .log( + SentryLevel.WARNING, + "The 'sentry.metrics.enabled' property no longer disables manual " + + "Sentry.metrics() calls.", + *emptyArray(), + ) + } + } + @Test fun `properties are applied to SentryOptions`() { contextRunner diff --git a/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java b/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java index bedb72a19e2..83d342de521 100644 --- a/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java +++ b/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java @@ -184,6 +184,7 @@ static class OpenTelemetryNoAgentConfiguration {} // here we make sure that only classes that extend throwable are set on this field options.getIgnoredExceptionsForType().removeIf(it -> !Throwable.class.isAssignableFrom(it)); warnForLegacyLogsConfiguration(environment, options); + warnForLegacyMetricsConfiguration(environment, options); Sentry.init(options); return ScopesAdapter.getInstance(); } @@ -213,6 +214,29 @@ private void warnForLegacyLogsConfiguration( } } + private void warnForLegacyMetricsConfiguration( + final @NotNull Environment environment, final @NotNull SentryOptions options) { + if (environment.containsProperty("sentry.metrics.enabled")) { + final boolean enableMetrics = + Boolean.TRUE.equals(environment.getProperty("sentry.metrics.enabled", Boolean.class)); + if (enableMetrics) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "The 'sentry.metrics.enabled' property is no longer supported. Manual " + + "Sentry.metrics() calls no longer require it."); + } else { + options + .getLogger() + .log( + SentryLevel.WARNING, + "The 'sentry.metrics.enabled' property no longer disables manual " + + "Sentry.metrics() calls."); + } + } + } + @Configuration(proxyBeanMethods = false) @ConditionalOnClass(MDC.class) @Open diff --git a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt index b0ee03d6577..2315a616d28 100644 --- a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt @@ -248,6 +248,52 @@ class SentryAutoConfigurationTest { } } + @Test + fun `legacy metrics property emits no warning when absent`() { + val logger = mock() + dsnEnabledRunner + .withPropertyValues("sentry.debug=true") + .withBean(ILogger::class.java, { logger }) + .withUserConfiguration(LoggerConfiguration::class.java) + .run { verify(logger, never()).log(eq(SentryLevel.WARNING), any()) } + } + + @Test + fun `legacy metrics property true emits migration warning`() { + val logger = mock() + dsnEnabledRunner + .withPropertyValues("sentry.debug=true", "sentry.metrics.enabled=true") + .withBean(ILogger::class.java, { logger }) + .withUserConfiguration(LoggerConfiguration::class.java) + .run { + verify(logger) + .log( + SentryLevel.WARNING, + "The 'sentry.metrics.enabled' property is no longer supported. Manual " + + "Sentry.metrics() calls no longer require it.", + *emptyArray(), + ) + } + } + + @Test + fun `legacy metrics property false emits migration warning`() { + val logger = mock() + dsnEnabledRunner + .withPropertyValues("sentry.debug=true", "sentry.metrics.enabled=false") + .withBean(ILogger::class.java, { logger }) + .withUserConfiguration(LoggerConfiguration::class.java) + .run { + verify(logger) + .log( + SentryLevel.WARNING, + "The 'sentry.metrics.enabled' property no longer disables manual " + + "Sentry.metrics() calls.", + *emptyArray(), + ) + } + } + @Test fun `properties are applied to SentryOptions`() { contextRunner From 170abc921699aa49a1e4c6380edcdeae393c0d5c Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 13 Aug 2026 06:29:55 +0200 Subject: [PATCH 4/6] perf(core): Start Metrics batch worker on first use Avoid scheduling Metrics processor work for empty flush and close operations until the processor accepts its first item. Preserve existing batching, restart, and Android background behavior after first use. Co-Authored-By: Claude --- .../core/AndroidMetricsBatchProcessorTest.kt | 14 +++ sentry/api/sentry.api | 1 + .../sentry/metrics/MetricsBatchProcessor.java | 30 ++++-- .../metrics/MetricsBatchProcessorTest.kt | 91 +++++++++++++++++++ 4 files changed, 129 insertions(+), 7 deletions(-) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidMetricsBatchProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidMetricsBatchProcessorTest.kt index 7d85502d149..9c35808d0c7 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidMetricsBatchProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidMetricsBatchProcessorTest.kt @@ -1,11 +1,14 @@ package io.sentry.android.core import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat import io.sentry.ISentryClient import io.sentry.SentryMetricsEvent import io.sentry.SentryOptions import io.sentry.protocol.SentryId import io.sentry.test.ImmediateExecutorService +import io.sentry.test.getProperty +import java.util.concurrent.atomic.AtomicBoolean import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -14,6 +17,7 @@ import kotlin.test.assertTrue import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -54,6 +58,16 @@ class AndroidMetricsBatchProcessorTest { assertNotNull(AppState.getInstance().lifecycleObserver) } + @Test + fun `onBackground does not flush before first accepted item`() { + val sut = fixture.getSut(useImmediateExecutor = true) + + sut.onBackground() + + assertThat(sut.getProperty("hasScheduled").get()).isFalse() + verify(fixture.client, never()).captureBatchedMetricsEvents(any()) + } + @Test fun `onBackground schedules flush`() { val sut = fixture.getSut(useImmediateExecutor = true) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 5041ecf13f8..d595cd603a0 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -5490,6 +5490,7 @@ public class io/sentry/metrics/MetricsBatchProcessor : io/sentry/metrics/IMetric public static final field MAX_QUEUE_SIZE I protected final field options Lio/sentry/SentryOptions; public fun (Lio/sentry/SentryOptions;Lio/sentry/ISentryClient;)V + public fun (Lio/sentry/SentryOptions;Lio/sentry/ISentryClient;Lio/sentry/ISentryExecutorService;)V public fun add (Lio/sentry/SentryMetricsEvent;)V public fun close (Z)V public fun flush (J)V diff --git a/sentry/src/main/java/io/sentry/metrics/MetricsBatchProcessor.java b/sentry/src/main/java/io/sentry/metrics/MetricsBatchProcessor.java index 3c744dbe3c5..e2e71d1daa3 100644 --- a/sentry/src/main/java/io/sentry/metrics/MetricsBatchProcessor.java +++ b/sentry/src/main/java/io/sentry/metrics/MetricsBatchProcessor.java @@ -19,8 +19,10 @@ import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; @Open public class MetricsBatchProcessor implements IMetricsBatchProcessor { @@ -34,16 +36,26 @@ public class MetricsBatchProcessor implements IMetricsBatchProcessor { private final @NotNull Queue queue; private final @NotNull ISentryExecutorService executorService; private final @NotNull AtomicBoolean hasScheduled = new AtomicBoolean(false); + private volatile boolean hasAcceptedItem = false; private volatile boolean isShuttingDown = false; private final @NotNull ReusableCountLatch pendingCount = new ReusableCountLatch(); public MetricsBatchProcessor( final @NotNull SentryOptions options, final @NotNull ISentryClient client) { + this(options, client, new SentryExecutorService(options)); + } + + @ApiStatus.Internal + @TestOnly + public MetricsBatchProcessor( + final @NotNull SentryOptions options, + final @NotNull ISentryClient client, + final @NotNull ISentryExecutorService executorService) { this.options = options; this.client = client; this.queue = new ConcurrentLinkedQueue<>(); - this.executorService = new SentryExecutorService(options); + this.executorService = executorService; } @Override @@ -65,6 +77,7 @@ public void add(final @NotNull SentryMetricsEvent metricsEvent) { } pendingCount.increment(); queue.offer(metricsEvent); + hasAcceptedItem = true; maybeSchedule(false); } @@ -72,14 +85,14 @@ public void add(final @NotNull SentryMetricsEvent metricsEvent) { @Override public void close(final boolean isRestarting) { isShuttingDown = true; - if (isRestarting) { + if (isRestarting && hasAcceptedItem) { maybeSchedule(true); executorService.submit(() -> executorService.close(options.getShutdownTimeoutMillis())); - } else { - executorService.close(options.getShutdownTimeoutMillis()); - while (!queue.isEmpty()) { - flushBatch(); - } + return; + } + executorService.close(options.getShutdownTimeoutMillis()); + while (!queue.isEmpty()) { + flushBatch(); } } @@ -106,6 +119,9 @@ private void maybeSchedule(boolean immediately) { @Override public void flush(long timeoutMillis) { + if (!hasAcceptedItem) { + return; + } maybeSchedule(true); try { pendingCount.waitTillZero(timeoutMillis, TimeUnit.MILLISECONDS); diff --git a/sentry/src/test/java/io/sentry/metrics/MetricsBatchProcessorTest.kt b/sentry/src/test/java/io/sentry/metrics/MetricsBatchProcessorTest.kt index d8320d9b1a6..99b8deba2a0 100644 --- a/sentry/src/test/java/io/sentry/metrics/MetricsBatchProcessorTest.kt +++ b/sentry/src/test/java/io/sentry/metrics/MetricsBatchProcessorTest.kt @@ -3,6 +3,7 @@ package io.sentry.metrics import com.google.common.truth.Truth.assertThat import io.sentry.DataCategory import io.sentry.ISentryClient +import io.sentry.ISentryExecutorService import io.sentry.SentryMetricsEvent import io.sentry.SentryMetricsEvents import io.sentry.SentryNanotimeDate @@ -12,19 +13,106 @@ import io.sentry.clientreport.DiscardReason import io.sentry.clientreport.DiscardedEvent import io.sentry.protocol.SentryId import io.sentry.test.DeferredExecutorService +import io.sentry.test.getProperty import io.sentry.test.injectForField +import io.sentry.transport.ReusableCountLatch import io.sentry.util.JsonSerializationUtils import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue +import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.atLeast import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions class MetricsBatchProcessorTest { + @Test + fun `constructor does not submit processor work`() { + val mockExecutor = mock() + + MetricsBatchProcessor(SentryOptions(), mock(), mockExecutor) + + verifyNoInteractions(mockExecutor) + } + + @Test + fun `empty flush does not submit processor work`() { + val mockExecutor = mock() + val processor = MetricsBatchProcessor(SentryOptions(), mock(), mockExecutor) + + processor.flush(0) + + verifyNoInteractions(mockExecutor) + } + + @Test + fun `close before first accepted item does not submit processor work`() { + val mockExecutor = mock() + val processor = MetricsBatchProcessor(SentryOptions(), mock(), mockExecutor) + + processor.close(false) + + verify(mockExecutor).close(any()) + verify(mockExecutor, never()).schedule(any(), any()) + verify(mockExecutor, never()).submit(any()) + } + + @Test + fun `restart close before first accepted item does not submit processor work`() { + val mockExecutor = mock() + val processor = MetricsBatchProcessor(SentryOptions(), mock(), mockExecutor) + + processor.close(true) + + verify(mockExecutor).close(any()) + verify(mockExecutor, never()).schedule(any(), any()) + verify(mockExecutor, never()).submit(any()) + } + + @Test + fun `item rejected during shutdown does not mark processor as used`() { + val mockExecutor = mock() + val processor = MetricsBatchProcessor(SentryOptions(), mock(), mockExecutor) + processor.close(false) + + processor.add(metricsEvent("rejected")) + processor.flush(0) + + verify(mockExecutor, never()).schedule(any(), any()) + verify(mockExecutor, never()).submit(any()) + } + + @Test + fun `item rejected due to queue capacity does not mark processor as used`() { + val mockExecutor = mock() + val processor = MetricsBatchProcessor(SentryOptions(), mock(), mockExecutor) + val pendingCount = processor.getProperty("pendingCount") + repeat(MetricsBatchProcessor.MAX_QUEUE_SIZE) { pendingCount.increment() } + + processor.add(metricsEvent("rejected")) + processor.flush(0) + + verifyNoInteractions(mockExecutor) + } + + @Test + fun `flush and restart close submit processor work after first accepted item`() { + val mockExecutor = mock() + val processor = MetricsBatchProcessor(SentryOptions(), mock(), mockExecutor) + processor.add(metricsEvent("accepted")) + + processor.flush(0) + processor.close(true) + + verify(mockExecutor, times(3)).schedule(any(), any()) + verify(mockExecutor).submit(any()) + } + @Test fun `schedules another flush after previous flush has run`() { val mockClient = mock() @@ -46,6 +134,9 @@ class MetricsBatchProcessorTest { .inOrder() } + private fun metricsEvent(name: String) = + SentryMetricsEvent(SentryId(), SentryNanotimeDate(), name, "gauge", 1.0) + @Test fun `drops metrics events after reaching MAX_QUEUE_SIZE limit`() { // given From b87e3315f048e14126345fcbcf9610661f31e935 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 13 Aug 2026 12:20:36 +0200 Subject: [PATCH 5/6] fix(core): Keep Metrics test constructor internal Use package visibility for executor injection so the test-only constructor does not expand the published API. Co-Authored-By: Claude --- sentry/api/sentry.api | 1 - .../main/java/io/sentry/metrics/MetricsBatchProcessor.java | 6 +----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index d595cd603a0..5041ecf13f8 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -5490,7 +5490,6 @@ public class io/sentry/metrics/MetricsBatchProcessor : io/sentry/metrics/IMetric public static final field MAX_QUEUE_SIZE I protected final field options Lio/sentry/SentryOptions; public fun (Lio/sentry/SentryOptions;Lio/sentry/ISentryClient;)V - public fun (Lio/sentry/SentryOptions;Lio/sentry/ISentryClient;Lio/sentry/ISentryExecutorService;)V public fun add (Lio/sentry/SentryMetricsEvent;)V public fun close (Z)V public fun flush (J)V diff --git a/sentry/src/main/java/io/sentry/metrics/MetricsBatchProcessor.java b/sentry/src/main/java/io/sentry/metrics/MetricsBatchProcessor.java index e2e71d1daa3..8df9dc653f4 100644 --- a/sentry/src/main/java/io/sentry/metrics/MetricsBatchProcessor.java +++ b/sentry/src/main/java/io/sentry/metrics/MetricsBatchProcessor.java @@ -19,10 +19,8 @@ import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; -import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.TestOnly; @Open public class MetricsBatchProcessor implements IMetricsBatchProcessor { @@ -46,9 +44,7 @@ public MetricsBatchProcessor( this(options, client, new SentryExecutorService(options)); } - @ApiStatus.Internal - @TestOnly - public MetricsBatchProcessor( + MetricsBatchProcessor( final @NotNull SentryOptions options, final @NotNull ISentryClient client, final @NotNull ISentryExecutorService executorService) { From 8cf1b6ab245c75f69a4532b437f2bddadbbfb517 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 29 Sep 2026 16:09:59 +0200 Subject: [PATCH 6/6] fix(android): Use metadata helper for legacy Metrics option Keep legacy Metrics metadata detection compatible with both Android bundles and injected metadata maps after merging the v9 branch. --- .../java/io/sentry/android/core/ManifestMetadataReader.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 10f4cd80a7b..0247301cf0a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -771,7 +771,7 @@ static void applyMetadata( } } - if (metadata.containsKey(ENABLE_METRICS)) { + if (containsKey(metadata, ENABLE_METRICS)) { final boolean enableMetrics = readBool(metadata, logger, ENABLE_METRICS, false); if (enableMetrics) { logger.log(