diff --git a/CHANGELOG.md b/CHANGELOG.md index 76adaee1462..827928cd495 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +- Add a Micrometer metrics integration with opt-in Spring Boot support ([#6116](https://github.com/getsentry/sentry-java/pull/6116)) + ## 8.56.0 ### Fixes diff --git a/sentry-micrometer/README.md b/sentry-micrometer/README.md index 09d5392fa4c..7712c0bf194 100644 --- a/sentry-micrometer/README.md +++ b/sentry-micrometer/README.md @@ -21,6 +21,37 @@ SentryMeterRegistry sentryRegistry = new SentryMeterRegistry(); Metrics.addRegistry(sentryRegistry); ``` +### Spring Boot + +Spring Boot 2, 3, and 4 applications can use auto-configuration by adding `sentry-micrometer` +alongside the matching Sentry Spring Boot starter. The starter does not install this module +transitively. + +```kotlin +dependencies { + implementation("io.sentry:sentry-spring-boot-4-starter:") + // Spring Boot 3: implementation("io.sentry:sentry-spring-boot-starter-jakarta:") + // Spring Boot 2: implementation("io.sentry:sentry-spring-boot-starter:") + + implementation("io.sentry:sentry-micrometer:") +} +``` + +Enable the integration explicitly and optionally configure passive polling: + +```properties +sentry.micrometer.enabled=true +sentry.micrometer.poll-interval-millis=60000 +``` + +Auto-configuration requires Sentry to be initialized and backs off when the application provides +its own `SentryMeterRegistry` bean. Spring Boot adds the registry to its primary composite registry, +applies compatible `MeterRegistryCustomizer` beans, and closes it with the application context. +Supported metrics registered automatically by Spring Boot Actuator—including HTTP server, JVM, +process, and logging metrics—are forwarded through the same registry. Set the polling interval to +zero to keep immediate forwarding enabled without a polling worker; passive auto-generated meters +then remain registered but are not sent. + ## Metric mappings Active meters are forwarded when they are recorded: diff --git a/sentry-micrometer/api/sentry-micrometer.api b/sentry-micrometer/api/sentry-micrometer.api index 6fd95e1b4a3..2cb26a0515c 100644 --- a/sentry-micrometer/api/sentry-micrometer.api +++ b/sentry-micrometer/api/sentry-micrometer.api @@ -6,6 +6,7 @@ public final class io/sentry/micrometer/BuildConfig { public final class io/sentry/micrometer/SentryMeterRegistry : io/micrometer/core/instrument/MeterRegistry { public fun ()V public fun (J)V + public fun (Lio/sentry/IScopes;J)V public fun close ()V } diff --git a/sentry-micrometer/src/main/java/io/sentry/micrometer/SentryMeterRegistry.java b/sentry-micrometer/src/main/java/io/sentry/micrometer/SentryMeterRegistry.java index e816713ada6..2088e34817c 100644 --- a/sentry-micrometer/src/main/java/io/sentry/micrometer/SentryMeterRegistry.java +++ b/sentry-micrometer/src/main/java/io/sentry/micrometer/SentryMeterRegistry.java @@ -22,12 +22,14 @@ import io.micrometer.core.instrument.internal.DefaultLongTaskTimer; import io.micrometer.core.instrument.internal.DefaultMeter; import io.micrometer.core.instrument.util.NamedThreadFactory; -import io.sentry.Sentry; +import io.sentry.IScopes; +import io.sentry.ScopesAdapter; import io.sentry.SentryAttributes; import io.sentry.SentryIntegrationPackageStorage; import io.sentry.SentryLevel; import io.sentry.metrics.MetricsUnit; import io.sentry.util.ExceptionUtils; +import io.sentry.util.Objects; import java.util.HashMap; import java.util.Map; import java.util.concurrent.Executors; @@ -44,6 +46,7 @@ public final class SentryMeterRegistry extends MeterRegistry { private static final @NotNull String INTEGRATION_NAME = "Micrometer"; private static final long DEFAULT_POLL_INTERVAL_MILLIS = 60_000; + private final @NotNull IScopes scopes; private final @Nullable ScheduledExecutorService scheduler; private final @Nullable ScheduledFuture pollingTask; @@ -63,14 +66,25 @@ public SentryMeterRegistry() { *

A zero interval disables passive meter polling. Negative intervals are not supported. */ public SentryMeterRegistry(final long pollIntervalMillis) { - this(pollIntervalMillis, Clock.SYSTEM, createScheduler(pollIntervalMillis)); + this(ScopesAdapter.getInstance(), pollIntervalMillis); + } + + /** + * Creates a registry with the given scopes and passive meter polling interval in milliseconds. + * + *

A zero interval disables passive meter polling. Negative intervals are not supported. + */ + public SentryMeterRegistry(final @NotNull IScopes scopes, final long pollIntervalMillis) { + this(scopes, pollIntervalMillis, Clock.SYSTEM, createScheduler(pollIntervalMillis)); } SentryMeterRegistry( + final @NotNull IScopes scopes, final long pollIntervalMillis, final @NotNull Clock clock, final @Nullable ScheduledExecutorService scheduler) { super(clock); + this.scopes = Objects.requireNonNull(scopes, "Scopes are required"); validatePollInterval(pollIntervalMillis); if (pollIntervalMillis > 0 && scheduler == null) { throw new IllegalArgumentException( @@ -165,7 +179,7 @@ public SentryMeterRegistry(final long pollIntervalMillis) { final @NotNull Meter.Id id, final @NotNull Meter.Type type, final @NotNull Iterable measurements) { - Sentry.getCurrentScopes() + scopes .getOptions() .getLogger() .log( @@ -189,7 +203,7 @@ void captureCounter(final @NotNull SentryMetricInfo metricInfo, final double val if (isClosed()) { return; } - Sentry.getCurrentScopes() + scopes .metrics() .count(metricInfo.getName(), value, metricInfo.getUnit(), metricInfo.createParameters()); } @@ -198,7 +212,7 @@ void captureDistribution(final @NotNull SentryMetricInfo metricInfo, final doubl if (isClosed()) { return; } - Sentry.getCurrentScopes() + scopes .metrics() .distribution( metricInfo.getName(), value, metricInfo.getUnit(), metricInfo.createParameters()); @@ -208,7 +222,7 @@ void captureGauge(final @NotNull SentryMetricInfo metricInfo, final double value if (isClosed()) { return; } - Sentry.getCurrentScopes() + scopes .metrics() .gauge(metricInfo.getName(), value, metricInfo.getUnit(), metricInfo.createParameters()); } @@ -231,7 +245,7 @@ void pollMeters() { } void logPollingFailure(final @NotNull Throwable throwable, final @NotNull String meterName) { - Sentry.getCurrentScopes() + scopes .getOptions() .getLogger() .log( diff --git a/sentry-micrometer/src/test/kotlin/io/sentry/micrometer/SentryFunctionTimerTest.kt b/sentry-micrometer/src/test/kotlin/io/sentry/micrometer/SentryFunctionTimerTest.kt index 36537849398..5f7af8792a3 100644 --- a/sentry-micrometer/src/test/kotlin/io/sentry/micrometer/SentryFunctionTimerTest.kt +++ b/sentry-micrometer/src/test/kotlin/io/sentry/micrometer/SentryFunctionTimerTest.kt @@ -4,6 +4,7 @@ import com.google.common.truth.Truth.assertThat import io.micrometer.core.instrument.Clock import io.micrometer.core.instrument.FunctionTimer import io.sentry.IScopes +import io.sentry.ScopesAdapter import io.sentry.Sentry import io.sentry.SentryOptions import io.sentry.metrics.IMetricsApi @@ -357,7 +358,8 @@ class SentryFunctionTimerTest { val scheduler = mock() val task = mock>() whenever(scheduler.scheduleAtFixedRate(any(), any(), any(), any())).thenReturn(task) - return SentryMeterRegistry(60_000, Clock.SYSTEM, scheduler).also(registries::add) + return SentryMeterRegistry(ScopesAdapter.getInstance(), 60_000, Clock.SYSTEM, scheduler) + .also(registries::add) } private fun installMetricsApi(): IMetricsApi { diff --git a/sentry-micrometer/src/test/kotlin/io/sentry/micrometer/SentryMeterRegistryPollingTest.kt b/sentry-micrometer/src/test/kotlin/io/sentry/micrometer/SentryMeterRegistryPollingTest.kt index ac761dba82b..7d900543ab5 100644 --- a/sentry-micrometer/src/test/kotlin/io/sentry/micrometer/SentryMeterRegistryPollingTest.kt +++ b/sentry-micrometer/src/test/kotlin/io/sentry/micrometer/SentryMeterRegistryPollingTest.kt @@ -8,6 +8,7 @@ import io.micrometer.core.instrument.LongTaskTimer import io.micrometer.core.instrument.MockClock import io.micrometer.core.instrument.TimeGauge import io.sentry.IScopes +import io.sentry.ScopesAdapter import io.sentry.Sentry import io.sentry.SentryOptions import io.sentry.metrics.IMetricsApi @@ -66,7 +67,8 @@ class SentryMeterRegistryPollingTest { ) .thenReturn(task) - val registry = track(SentryMeterRegistry(2500, Clock.SYSTEM, scheduler)) + val registry = + track(SentryMeterRegistry(ScopesAdapter.getInstance(), 2500, Clock.SYSTEM, scheduler)) val value = AtomicReference(4.5) Gauge.builder("scheduled", value) { it.get() }.strongReference(true).register(registry) val scheduledPoll = argumentCaptor() @@ -107,7 +109,7 @@ class SentryMeterRegistryPollingTest { @Test fun `zero interval ignores an available scheduler`() { val scheduler = mock() - track(SentryMeterRegistry(0, Clock.SYSTEM, scheduler)) + track(SentryMeterRegistry(ScopesAdapter.getInstance(), 0, Clock.SYSTEM, scheduler)) verifyNoInteractions(scheduler) } @@ -406,7 +408,7 @@ class SentryMeterRegistryPollingTest { val scheduler = mock() val task = mock>() whenever(scheduler.scheduleAtFixedRate(any(), any(), any(), any())).thenReturn(task) - return track(SentryMeterRegistry(60_000, clock, scheduler)) + return track(SentryMeterRegistry(ScopesAdapter.getInstance(), 60_000, clock, scheduler)) } private fun track(registry: SentryMeterRegistry): SentryMeterRegistry { diff --git a/sentry-micrometer/src/test/kotlin/io/sentry/micrometer/SentryMeterRegistryTest.kt b/sentry-micrometer/src/test/kotlin/io/sentry/micrometer/SentryMeterRegistryTest.kt index ff899c99167..c286ab05b13 100644 --- a/sentry-micrometer/src/test/kotlin/io/sentry/micrometer/SentryMeterRegistryTest.kt +++ b/sentry-micrometer/src/test/kotlin/io/sentry/micrometer/SentryMeterRegistryTest.kt @@ -3,6 +3,7 @@ package io.sentry.micrometer import com.google.common.truth.Truth.assertThat import io.micrometer.core.instrument.Counter import io.micrometer.core.instrument.DistributionSummary +import io.micrometer.core.instrument.Gauge import io.micrometer.core.instrument.Measurement import io.micrometer.core.instrument.Meter import io.micrometer.core.instrument.Statistic @@ -12,10 +13,12 @@ import io.micrometer.core.instrument.config.MeterFilter import io.micrometer.core.instrument.config.NamingConvention import io.micrometer.core.instrument.simple.SimpleMeterRegistry import io.sentry.Hint +import io.sentry.ILogger import io.sentry.IScopes import io.sentry.ISentryClient import io.sentry.Sentry import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryLevel import io.sentry.SentryMetricsEvent import io.sentry.SentryOptions import io.sentry.metrics.IMetricsApi @@ -166,6 +169,58 @@ class SentryMeterRegistryTest { verify(second).count(eq("counter"), eq(2.0), anyOrNull(), any()) } + @Test + fun `injected scopes receive active and passive metrics instead of global scopes`() { + val globalMetrics = installMetricsApi() + val metrics = mock() + val scopes = mock() + whenever(scopes.metrics()).thenReturn(metrics) + val registry = SentryMeterRegistry(scopes, 0).also(registries::add) + + registry.counter("counter").increment(2.0) + registry.timer("timer").record(3, TimeUnit.MILLISECONDS) + registry.summary("summary").record(4.0) + Gauge.builder("gauge", Supplier { 5.0 }).register(registry) + registry.pollMeters() + + verify(metrics).count(eq("counter"), eq(2.0), anyOrNull(), any()) + verify(metrics).distribution(eq("timer"), eq(3.0), eq(MetricsUnit.Duration.MILLISECOND), any()) + verify(metrics).distribution(eq("summary"), eq(4.0), anyOrNull(), any()) + verify(metrics).gauge(eq("gauge"), eq(5.0), anyOrNull(), any()) + verifyNoInteractions(globalMetrics) + } + + @Test + fun `diagnostic logging uses injected scopes`() { + val logger = mock() + val options = + SentryOptions().apply { + isDebug = true + setLogger(logger) + } + val scopes = mock() + whenever(scopes.options).thenReturn(options) + val registry = SentryMeterRegistry(scopes, 0).also(registries::add) + val failure = IllegalStateException("poll failed") + + Meter.builder( + "custom", + Meter.Type.OTHER, + listOf(Measurement(Supplier { 7.0 }, Statistic.VALUE)), + ) + .register(registry) + registry.logPollingFailure(failure, "gauge") + + verify(logger) + .log( + SentryLevel.DEBUG, + "Micrometer meter type %s is not supported for Sentry export.", + Meter.Type.OTHER, + ) + verify(logger) + .log(SentryLevel.DEBUG, failure, "Failed to publish Micrometer meter %s to Sentry.", "gauge") + } + @Test fun `registry created before Sentry init forwards after initialization`() { Sentry.close() diff --git a/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts index 17ec5b2a45f..eab9553121c 100644 --- a/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts @@ -51,6 +51,7 @@ dependencies { implementation(Config.Libs.kotlinReflect) implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) implementation(projects.sentrySpringBoot4Starter) + implementation(projects.sentryMicrometer) implementation(projects.sentryLogback) implementation(projects.sentryGraphql22) implementation(projects.sentryQuartz) diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/MetricController.java b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/MetricController.java index be75f5e3002..71836dc15bc 100644 --- a/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/MetricController.java +++ b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/MetricController.java @@ -1,5 +1,7 @@ package io.sentry.samples.spring.boot4; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import io.sentry.Sentry; import io.sentry.metrics.MetricsUnit; import org.slf4j.Logger; @@ -14,6 +16,14 @@ public class MetricController { private static final Logger LOGGER = LoggerFactory.getLogger(MetricController.class); + private final MeterRegistry meterRegistry; + private final SimpleMeterRegistry simpleMeterRegistry; + + public MetricController(MeterRegistry meterRegistry, SimpleMeterRegistry simpleMeterRegistry) { + this.meterRegistry = meterRegistry; + this.simpleMeterRegistry = simpleMeterRegistry; + } + @GetMapping("count") String count() { Sentry.setAttribute("user.type", "admin"); @@ -22,6 +32,14 @@ String count() { return "count metric increased"; } + @GetMapping("micrometer") + String micrometer() { + meterRegistry.counter("micrometer.counter", "source", "spring").increment(); + double simpleRegistryCount = + simpleMeterRegistry.get("micrometer.counter").tag("source", "spring").counter().count(); + return "micrometer metric increased: " + simpleRegistryCount; + } + @GetMapping("gauge/{value}") String gauge(@PathVariable("value") Long value) { Sentry.metrics().gauge("memory.free", value.doubleValue(), MetricsUnit.Information.BYTE); diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/SentryDemoApplication.java b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/SentryDemoApplication.java index 13d97fa8442..975f9c42c69 100644 --- a/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/SentryDemoApplication.java +++ b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/SentryDemoApplication.java @@ -2,6 +2,7 @@ import static io.sentry.quartz.SentryJobListener.SENTRY_SLUG_KEY; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import io.sentry.samples.spring.boot4.quartz.SampleJob; import java.util.Collections; import org.quartz.JobDetail; @@ -42,6 +43,11 @@ RestClient restClient(RestClient.Builder builder) { return builder.build(); } + @Bean + SimpleMeterRegistry simpleMeterRegistry() { + return new SimpleMeterRegistry(); + } + @Bean public JobDetailFactoryBean jobDetail() { JobDetailFactoryBean jobDetailFactory = new JobDetailFactoryBean(); diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/main/resources/application.properties b/sentry-samples/sentry-samples-spring-boot-4/src/main/resources/application.properties index 8198059343a..3a678e77d9b 100644 --- a/sentry-samples/sentry-samples-spring-boot-4/src/main/resources/application.properties +++ b/sentry-samples/sentry-samples-spring-boot-4/src/main/resources/application.properties @@ -17,6 +17,8 @@ sentry.enable-spotlight=true sentry.enablePrettySerializationOutput=false sentry.in-app-includes="io.sentry.samples" sentry.logs.enabled=true +sentry.micrometer.enabled=true +sentry.micrometer.poll-interval-millis=1000 sentry.profile-session-sample-rate=1.0 sentry.profiling-traces-dir-path=tmp/sentry/profiling-traces sentry.profile-lifecycle=TRACE diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt index 039d9d640c7..65e39b0d730 100644 --- a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt @@ -3,6 +3,8 @@ package io.sentry.systemtest import io.sentry.systemtest.util.TestHelper import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue import org.junit.Before class MetricsSystemTest { @@ -27,6 +29,67 @@ class MetricsSystemTest { } } + @Test + fun `Micrometer metric is forwarded to Sentry and another registry`() { + val restClient = testHelper.restClient + val response = assertNotNull(restClient.getMicrometerMetric()) + val responsePrefix = "micrometer metric increased: " + assertTrue(response.startsWith(responsePrefix)) + assertTrue(response.removePrefix(responsePrefix).toDouble() > 0.0) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "micrometer.counter", "counter", 1.0) && + testHelper.doesMetricHaveAttribute( + event, + "micrometer.counter", + "source", + "spring", + ) && + header.sdkVersion?.integrationSet?.contains("Micrometer") == true && + header.sdkVersion?.packageSet?.any { + it.name == "maven:io.sentry:sentry-micrometer" + } == true + } + } + + @Test + fun `Spring Boot generated HTTP metric is forwarded through Micrometer`() { + val restClient = testHelper.restClient + assertTrue(restClient.getActuatorHealth()?.contains("\"status\":\"UP\"") == true) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + event.items.any { metric -> + metric.name == "http.server.requests" && + metric.type == "distribution" && + metric.unit == "millisecond" && + metric.attributes?.get("sentry.origin")?.value == "auto.metrics.micrometer" + } && + header.sdkVersion?.integrationSet?.contains("Micrometer") == true && + header.sdkVersion?.packageSet?.any { + it.name == "maven:io.sentry:sentry-micrometer" + } == true + } + } + + @Test + fun `Spring Boot generated process metric is forwarded through Micrometer polling`() { + testHelper.ensureMetricsReceived { event, header -> + event.items.any { metric -> + metric.name == "process.uptime" && + metric.type == "gauge" && + metric.unit == "millisecond" && + metric.value > 0.0 && + metric.attributes?.get("sentry.origin")?.value == "auto.metrics.micrometer" + } && + header.sdkVersion?.integrationSet?.contains("Micrometer") == true && + header.sdkVersion?.packageSet?.any { + it.name == "maven:io.sentry:sentry-micrometer" + } == true + } + } + @Test fun `gauge metric`() { val restClient = testHelper.restClient diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts index 320a9cc2512..8fa974453ce 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts @@ -56,6 +56,7 @@ dependencies { implementation(Config.Libs.kotlinReflect) implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) implementation(projects.sentrySpringBootStarterJakarta) + implementation(projects.sentryMicrometer) implementation(projects.sentryLogback) implementation(projects.sentryGraphql22) implementation(projects.sentryQuartz) diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/MetricController.java b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/MetricController.java index 6b28e59d6a3..7f09c47633b 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/MetricController.java +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/MetricController.java @@ -1,5 +1,7 @@ package io.sentry.samples.spring.boot.jakarta; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import io.sentry.Sentry; import io.sentry.metrics.MetricsUnit; import org.slf4j.Logger; @@ -14,6 +16,14 @@ public class MetricController { private static final Logger LOGGER = LoggerFactory.getLogger(MetricController.class); + private final MeterRegistry meterRegistry; + private final SimpleMeterRegistry simpleMeterRegistry; + + public MetricController(MeterRegistry meterRegistry, SimpleMeterRegistry simpleMeterRegistry) { + this.meterRegistry = meterRegistry; + this.simpleMeterRegistry = simpleMeterRegistry; + } + @GetMapping("count") String count() { Sentry.setAttribute("user.type", "admin"); @@ -22,6 +32,14 @@ String count() { return "count metric increased"; } + @GetMapping("micrometer") + String micrometer() { + meterRegistry.counter("micrometer.counter", "source", "spring").increment(); + double simpleRegistryCount = + simpleMeterRegistry.get("micrometer.counter").tag("source", "spring").counter().count(); + return "micrometer metric increased: " + simpleRegistryCount; + } + @GetMapping("gauge/{value}") String gauge(@PathVariable("value") Long value) { Sentry.metrics().gauge("memory.free", value.doubleValue(), MetricsUnit.Information.BYTE); diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java index e818cbe42ff..05bf7232f00 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java @@ -2,6 +2,7 @@ import static io.sentry.quartz.SentryJobListener.SENTRY_SLUG_KEY; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import io.sentry.samples.spring.boot.jakarta.quartz.SampleJob; import java.util.Collections; import org.quartz.JobDetail; @@ -42,6 +43,11 @@ RestClient restClient(RestClient.Builder builder) { return builder.build(); } + @Bean + SimpleMeterRegistry simpleMeterRegistry() { + return new SimpleMeterRegistry(); + } + @Bean public JobDetailFactoryBean jobDetail() { JobDetailFactoryBean jobDetailFactory = new JobDetailFactoryBean(); diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties index 20f9463aabc..c2c62af6591 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties @@ -17,6 +17,8 @@ sentry.enable-spotlight=false sentry.enablePrettySerializationOutput=false sentry.in-app-includes="io.sentry.samples" sentry.logs.enabled=true +sentry.micrometer.enabled=true +sentry.micrometer.poll-interval-millis=1000 sentry.profile-session-sample-rate=1.0 sentry.profiling-traces-dir-path=tmp/sentry/profiling-traces sentry.profile-lifecycle=TRACE diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt index 039d9d640c7..65e39b0d730 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt @@ -3,6 +3,8 @@ package io.sentry.systemtest import io.sentry.systemtest.util.TestHelper import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue import org.junit.Before class MetricsSystemTest { @@ -27,6 +29,67 @@ class MetricsSystemTest { } } + @Test + fun `Micrometer metric is forwarded to Sentry and another registry`() { + val restClient = testHelper.restClient + val response = assertNotNull(restClient.getMicrometerMetric()) + val responsePrefix = "micrometer metric increased: " + assertTrue(response.startsWith(responsePrefix)) + assertTrue(response.removePrefix(responsePrefix).toDouble() > 0.0) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "micrometer.counter", "counter", 1.0) && + testHelper.doesMetricHaveAttribute( + event, + "micrometer.counter", + "source", + "spring", + ) && + header.sdkVersion?.integrationSet?.contains("Micrometer") == true && + header.sdkVersion?.packageSet?.any { + it.name == "maven:io.sentry:sentry-micrometer" + } == true + } + } + + @Test + fun `Spring Boot generated HTTP metric is forwarded through Micrometer`() { + val restClient = testHelper.restClient + assertTrue(restClient.getActuatorHealth()?.contains("\"status\":\"UP\"") == true) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + event.items.any { metric -> + metric.name == "http.server.requests" && + metric.type == "distribution" && + metric.unit == "millisecond" && + metric.attributes?.get("sentry.origin")?.value == "auto.metrics.micrometer" + } && + header.sdkVersion?.integrationSet?.contains("Micrometer") == true && + header.sdkVersion?.packageSet?.any { + it.name == "maven:io.sentry:sentry-micrometer" + } == true + } + } + + @Test + fun `Spring Boot generated process metric is forwarded through Micrometer polling`() { + testHelper.ensureMetricsReceived { event, header -> + event.items.any { metric -> + metric.name == "process.uptime" && + metric.type == "gauge" && + metric.unit == "millisecond" && + metric.value > 0.0 && + metric.attributes?.get("sentry.origin")?.value == "auto.metrics.micrometer" + } && + header.sdkVersion?.integrationSet?.contains("Micrometer") == true && + header.sdkVersion?.packageSet?.any { + it.name == "maven:io.sentry:sentry-micrometer" + } == true + } + } + @Test fun `gauge metric`() { val restClient = testHelper.restClient diff --git a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts index 0c8d2dc28e7..6c2ab5b18cf 100644 --- a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts @@ -70,6 +70,7 @@ dependencies { implementation(Config.Libs.kotlinReflect) implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) implementation(projects.sentrySpringBootStarter) + implementation(projects.sentryMicrometer) implementation(projects.sentryLogback) if (includeGraphql) { implementation(projects.sentryGraphql) diff --git a/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/MetricController.java b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/MetricController.java index 352571ee434..421d0730daa 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/MetricController.java +++ b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/MetricController.java @@ -1,5 +1,7 @@ package io.sentry.samples.spring.boot; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import io.sentry.Sentry; import io.sentry.metrics.MetricsUnit; import org.slf4j.Logger; @@ -14,6 +16,14 @@ public class MetricController { private static final Logger LOGGER = LoggerFactory.getLogger(MetricController.class); + private final MeterRegistry meterRegistry; + private final SimpleMeterRegistry simpleMeterRegistry; + + public MetricController(MeterRegistry meterRegistry, SimpleMeterRegistry simpleMeterRegistry) { + this.meterRegistry = meterRegistry; + this.simpleMeterRegistry = simpleMeterRegistry; + } + @GetMapping("count") String count() { Sentry.setAttribute("user.type", "admin"); @@ -22,6 +32,14 @@ String count() { return "count metric increased"; } + @GetMapping("micrometer") + String micrometer() { + meterRegistry.counter("micrometer.counter", "source", "spring").increment(); + double simpleRegistryCount = + simpleMeterRegistry.get("micrometer.counter").tag("source", "spring").counter().count(); + return "micrometer metric increased: " + simpleRegistryCount; + } + @GetMapping("gauge/{value}") String gauge(@PathVariable("value") Long value) { Sentry.metrics().gauge("memory.free", value.doubleValue(), MetricsUnit.Information.BYTE); diff --git a/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/SentryDemoApplication.java b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/SentryDemoApplication.java index a08770b1029..8e65c05d6fc 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/SentryDemoApplication.java +++ b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/SentryDemoApplication.java @@ -2,6 +2,7 @@ import static io.sentry.quartz.SentryJobListener.SENTRY_SLUG_KEY; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import io.sentry.samples.spring.boot.quartz.SampleJob; import java.util.Collections; import org.quartz.JobDetail; @@ -36,6 +37,11 @@ WebClient webClient(WebClient.Builder builder) { return builder.build(); } + @Bean + SimpleMeterRegistry simpleMeterRegistry() { + return new SimpleMeterRegistry(); + } + @Bean public JobDetailFactoryBean jobDetail() { JobDetailFactoryBean jobDetailFactory = new JobDetailFactoryBean(); diff --git a/sentry-samples/sentry-samples-spring-boot/src/main/resources/application.properties b/sentry-samples/sentry-samples-spring-boot/src/main/resources/application.properties index 4e97e7a1eb8..4c0eecc05e4 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/main/resources/application.properties +++ b/sentry-samples/sentry-samples-spring-boot/src/main/resources/application.properties @@ -15,6 +15,8 @@ sentry.graphql.ignored-error-types=SOME_ERROR,ANOTHER_ERROR sentry.enable-backpressure-handling=true sentry.enable-spotlight=true sentry.logs.enabled=true +sentry.micrometer.enabled=true +sentry.micrometer.poll-interval-millis=1000 sentry.in-app-includes="io.sentry.samples" sentry.profile-session-sample-rate=1.0 sentry.profiling-traces-dir-path=tmp/sentry/profiling-traces diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt index 039d9d640c7..5286207471b 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt @@ -3,6 +3,8 @@ package io.sentry.systemtest import io.sentry.systemtest.util.TestHelper import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue import org.junit.Before class MetricsSystemTest { @@ -27,6 +29,66 @@ class MetricsSystemTest { } } + @Test + fun `Micrometer metric is forwarded to Sentry and another registry`() { + val restClient = testHelper.restClient + val response = assertNotNull(restClient.getMicrometerMetric()) + val responsePrefix = "micrometer metric increased: " + assertTrue(response.startsWith(responsePrefix)) + assertTrue(response.removePrefix(responsePrefix).toDouble() > 0.0) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "micrometer.counter", "counter", 1.0) && + testHelper.doesMetricHaveAttribute( + event, + "micrometer.counter", + "source", + "spring", + ) && + header.sdkVersion?.integrationSet?.contains("Micrometer") == true && + header.sdkVersion?.packageSet?.any { + it.name == "maven:io.sentry:sentry-micrometer" + } == true + } + } + + @Test + fun `Spring Boot generated HTTP metric is forwarded through Micrometer`() { + val restClient = testHelper.restClient + assertTrue(restClient.getActuatorHealth()?.contains("\"status\":\"UP\"") == true) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, _ -> + event.items.any { metric -> + metric.name == "http.server.requests" && + metric.type == "distribution" && + metric.unit == "millisecond" && + metric.attributes?.get("method")?.value == "GET" && + metric.attributes?.get("status")?.value == "200" && + metric.attributes?.get("uri")?.value == "/actuator/health" && + metric.attributes?.get("sentry.origin")?.value == "auto.metrics.micrometer" + } + } + } + + @Test + fun `Spring Boot generated process metric is forwarded through Micrometer polling`() { + testHelper.ensureMetricsReceived { event, header -> + event.items.any { metric -> + metric.name == "process.uptime" && + metric.type == "gauge" && + metric.unit == "millisecond" && + metric.value > 0.0 && + metric.attributes?.get("sentry.origin")?.value == "auto.metrics.micrometer" + } && + header.sdkVersion?.integrationSet?.contains("Micrometer") == true && + header.sdkVersion?.packageSet?.any { + it.name == "maven:io.sentry:sentry-micrometer" + } == true + } + } + @Test fun `gauge metric`() { val restClient = testHelper.restClient diff --git a/sentry-spring-boot-4/api/sentry-spring-boot-4.api b/sentry-spring-boot-4/api/sentry-spring-boot-4.api index 4c8be990b85..57ed178daa7 100644 --- a/sentry-spring-boot-4/api/sentry-spring-boot-4.api +++ b/sentry-spring-boot-4/api/sentry-spring-boot-4.api @@ -33,6 +33,7 @@ public class io/sentry/spring/boot4/SentryProperties : io/sentry/SentryOptions { public fun getExceptionResolverOrder ()I public fun getGraphql ()Lio/sentry/spring/boot4/SentryProperties$Graphql; public fun getLogging ()Lio/sentry/spring/boot4/SentryProperties$Logging; + public fun getMicrometer ()Lio/sentry/spring/boot4/SentryProperties$Micrometer; public fun getReactive ()Lio/sentry/spring/boot4/SentryProperties$Reactive; public fun getUserFilterOrder ()Ljava/lang/Integer; public fun isEnableAotCompatibility ()Z @@ -43,6 +44,7 @@ public class io/sentry/spring/boot4/SentryProperties : io/sentry/SentryOptions { public fun setGraphql (Lio/sentry/spring/boot4/SentryProperties$Graphql;)V public fun setKeepTransactionsOpenForAsyncResponses (Z)V public fun setLogging (Lio/sentry/spring/boot4/SentryProperties$Logging;)V + public fun setMicrometer (Lio/sentry/spring/boot4/SentryProperties$Micrometer;)V public fun setReactive (Lio/sentry/spring/boot4/SentryProperties$Reactive;)V public fun setUseGitCommitIdAsRelease (Z)V public fun setUserFilterOrder (Ljava/lang/Integer;)V @@ -68,6 +70,14 @@ public class io/sentry/spring/boot4/SentryProperties$Logging { public fun setMinimumLevel (Lorg/slf4j/event/Level;)V } +public class io/sentry/spring/boot4/SentryProperties$Micrometer { + public fun ()V + public fun getPollIntervalMillis ()J + public fun isEnabled ()Z + public fun setEnabled (Z)V + public fun setPollIntervalMillis (J)V +} + public class io/sentry/spring/boot4/SentryProperties$Reactive { public fun ()V public fun isThreadLocalAccessorEnabled ()Z diff --git a/sentry-spring-boot-4/build.gradle.kts b/sentry-spring-boot-4/build.gradle.kts index 2a6634b257f..0710a4b9ac0 100644 --- a/sentry-spring-boot-4/build.gradle.kts +++ b/sentry-spring-boot-4/build.gradle.kts @@ -29,6 +29,8 @@ tasks.withType().configureEach { dependencies { api(projects.sentry) api(projects.sentrySpring7) + compileOnly(projects.sentryMicrometer) + compileOnly("io.micrometer:micrometer-core") compileOnly(projects.sentryLogback) compileOnly(projects.sentryApacheHttpClient5) compileOnly(platform(SpringBootPlugin.BOM_COORDINATES)) @@ -64,6 +66,7 @@ dependencies { errorprone(libs.nullaway) // tests + testImplementation(projects.sentryMicrometer) testImplementation(projects.sentryLogback) testImplementation(projects.sentryApacheHttpClient5) testImplementation(projects.sentryGraphql) @@ -95,6 +98,7 @@ dependencies { */ // testImplementation(libs.springboot4.otel) testImplementation(libs.springboot4.starter) + testImplementation(libs.springboot4.starter.actuator) testImplementation(libs.springboot4.starter.aspectj) testImplementation(libs.springboot4.starter.graphql) testImplementation(libs.spring.kafka4) 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 2429c1e7446..813477a8e45 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 @@ -158,6 +158,17 @@ static class OpenTelemetryAgentWithoutAutoInitConfiguration {} @ConditionalOnMissingClass("io.sentry.opentelemetry.agent.AgentMarker") static class OpenTelemetryNoAgentConfiguration {} + @Configuration(proxyBeanMethods = false) + @Import(SentryMicrometerConfiguration.class) + @Open + @ConditionalOnClass( + name = { + "io.micrometer.core.instrument.MeterRegistry", + "io.sentry.micrometer.SentryMeterRegistry" + }) + @ConditionalOnProperty(prefix = "sentry.micrometer", name = "enabled", havingValue = "true") + static class MicrometerConfiguration {} + @Bean public @NotNull IScopes sentryHub( final @NotNull List> optionsConfigurations, diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryMicrometerConfiguration.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryMicrometerConfiguration.java new file mode 100644 index 00000000000..1026bf646f5 --- /dev/null +++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryMicrometerConfiguration.java @@ -0,0 +1,20 @@ +package io.sentry.spring.boot4; + +import io.sentry.IScopes; +import io.sentry.micrometer.SentryMeterRegistry; +import org.jetbrains.annotations.NotNull; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** Configures the Sentry Micrometer registry. */ +@Configuration(proxyBeanMethods = false) +final class SentryMicrometerConfiguration { + + @Bean(destroyMethod = "close") + @ConditionalOnMissingBean(SentryMeterRegistry.class) + public @NotNull SentryMeterRegistry sentryMeterRegistry( + final @NotNull IScopes scopes, final @NotNull SentryProperties properties) { + return new SentryMeterRegistry(scopes, properties.getMicrometer().getPollIntervalMillis()); + } +} diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryProperties.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryProperties.java index edb8d44cdd3..e86c6efce6b 100644 --- a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryProperties.java +++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryProperties.java @@ -47,6 +47,9 @@ public class SentryProperties extends SentryOptions { /** Graphql integration properties. */ private @NotNull Graphql graphql = new Graphql(); + /** Micrometer integration properties. */ + private @NotNull Micrometer micrometer = new Micrometer(); + public boolean isUseGitCommitIdAsRelease() { return useGitCommitIdAsRelease; } @@ -124,6 +127,36 @@ public void setGraphql(@NotNull Graphql graphql) { this.graphql = graphql; } + public @NotNull Micrometer getMicrometer() { + return micrometer; + } + + public void setMicrometer(final @NotNull Micrometer micrometer) { + this.micrometer = micrometer; + } + + @Open + public static class Micrometer { + private boolean enabled; + private long pollIntervalMillis = 60_000; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(final boolean enabled) { + this.enabled = enabled; + } + + public long getPollIntervalMillis() { + return pollIntervalMillis; + } + + public void setPollIntervalMillis(final long pollIntervalMillis) { + this.pollIntervalMillis = pollIntervalMillis; + } + } + @Open public static class Logging { /** Enable/Disable logging auto-configuration. */ diff --git a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryMicrometerConfigurationTest.kt b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryMicrometerConfigurationTest.kt new file mode 100644 index 00000000000..5b4cd994e9f --- /dev/null +++ b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryMicrometerConfigurationTest.kt @@ -0,0 +1,172 @@ +package io.sentry.spring.boot4 + +import io.micrometer.core.instrument.MeterRegistry +import io.micrometer.core.instrument.composite.CompositeMeterRegistry +import io.micrometer.core.instrument.simple.SimpleMeterRegistry +import io.sentry.IScopes +import io.sentry.Sentry +import io.sentry.metrics.IMetricsApi +import io.sentry.micrometer.SentryMeterRegistry +import kotlin.test.AfterTest +import kotlin.test.Test +import org.assertj.core.api.Assertions.assertThat +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.boot.autoconfigure.AutoConfigurations +import org.springframework.boot.micrometer.metrics.autoconfigure.CompositeMeterRegistryAutoConfiguration +import org.springframework.boot.micrometer.metrics.autoconfigure.MetricsAutoConfiguration +import org.springframework.boot.test.context.FilteredClassLoader +import org.springframework.boot.test.context.runner.ApplicationContextRunner +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +class SentryMicrometerConfigurationTest { + private val contextRunner = + ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(SentryAutoConfiguration::class.java)) + .withUserConfiguration(SentryAutoConfigurationTest.NoOpTransportConfiguration::class.java) + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.shutdown-timeout-millis=0", + "sentry.metrics.enabled=false", + ) + + @AfterTest + fun tearDown() { + Sentry.close() + } + + @Test + fun `integration is disabled by default and supports explicit opt out`() { + contextRunner.run { + assertThat(it).doesNotHaveBean(SentryMeterRegistry::class.java) + assertThat(it.getBean(SentryProperties::class.java).micrometer.isEnabled).isFalse() + assertThat(it.getBean(SentryProperties::class.java).micrometer.pollIntervalMillis) + .isEqualTo(60_000) + } + contextRunner.withPropertyValues("sentry.micrometer.enabled=false").run { + assertThat(it).doesNotHaveBean(SentryMeterRegistry::class.java) + } + } + + @Test + fun `integration binds properties and depends on initialized Sentry scopes`() { + var registry: SentryMeterRegistry? = null + + contextRunner + .withPropertyValues( + "sentry.micrometer.enabled=true", + "sentry.micrometer.poll-interval-millis=0", + ) + .run { + assertThat(it).hasSingleBean(SentryMeterRegistry::class.java) + assertThat(it).hasSingleBean(IScopes::class.java) + assertThat(it.getBean(SentryProperties::class.java).micrometer.isEnabled).isTrue() + assertThat(it.getBean(SentryProperties::class.java).micrometer.pollIntervalMillis) + .isEqualTo(0) + assertThat( + it.sourceApplicationContext.beanFactory.getDependenciesForBean("sentryMeterRegistry") + ) + .contains("sentryHub") + registry = it.getBean(SentryMeterRegistry::class.java) + assertThat(registry!!.isClosed).isFalse() + } + + assertThat(registry!!.isClosed).isTrue() + } + + @Test + fun `registry uses the injected scopes bean`() { + val scopes = mock() + val metrics = mock() + whenever(scopes.metrics()).thenReturn(metrics) + val properties = SentryProperties().apply { micrometer.pollIntervalMillis = 0 } + + ApplicationContextRunner() + .withUserConfiguration(SentryMicrometerConfiguration::class.java) + .withBean(IScopes::class.java, { scopes }) + .withBean(SentryProperties::class.java, { properties }) + .run { + it.getBean(SentryMeterRegistry::class.java).counter("requests").increment(2.0) + + verify(metrics).count(eq("requests"), eq(2.0), anyOrNull(), any()) + } + } + + @Test + fun `integration is absent when Sentry auto-configuration is disabled`() { + ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(SentryAutoConfiguration::class.java)) + .withUserConfiguration(SentryAutoConfigurationTest.NoOpTransportConfiguration::class.java) + .withPropertyValues("sentry.micrometer.enabled=true") + .run { assertThat(it).doesNotHaveBean(SentryMeterRegistry::class.java) } + } + + @Test + fun `integration backs off for a user provided registry`() { + contextRunner + .withPropertyValues("sentry.micrometer.enabled=true") + .withUserConfiguration(CustomRegistryConfiguration::class.java) + .run { + assertThat(it).hasSingleBean(SentryMeterRegistry::class.java) + assertThat(it).hasBean("customSentryMeterRegistry") + assertThat(it).doesNotHaveBean("sentryMeterRegistry") + } + } + + @Test + fun `integration is absent when sentry micrometer is not on the classpath`() { + contextRunner + .withPropertyValues("sentry.micrometer.enabled=true") + .withClassLoader(FilteredClassLoader(SentryMeterRegistry::class.java)) + .run { assertThat(it).doesNotHaveBean(SentryMeterRegistry::class.java) } + } + + @Test + fun `Spring primary registry forwards to Sentry and another registry`() { + ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of( + SentryAutoConfiguration::class.java, + MetricsAutoConfiguration::class.java, + CompositeMeterRegistryAutoConfiguration::class.java, + ) + ) + .withUserConfiguration( + SentryAutoConfigurationTest.NoOpTransportConfiguration::class.java, + SimpleRegistryConfiguration::class.java, + ) + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.shutdown-timeout-millis=0", + "sentry.metrics.enabled=false", + "sentry.micrometer.enabled=true", + "sentry.micrometer.poll-interval-millis=0", + ) + .run { + val primary = it.getBean(MeterRegistry::class.java) + val sentry = it.getBean(SentryMeterRegistry::class.java) + val simple = it.getBean(SimpleMeterRegistry::class.java) + + assertThat(primary).isInstanceOf(CompositeMeterRegistry::class.java) + primary.counter("requests").increment() + + assertThat(sentry.get("requests").counter().count()).isEqualTo(1.0) + assertThat(simple.get("requests").counter().count()).isEqualTo(1.0) + } + } + + @Configuration(proxyBeanMethods = false) + open class CustomRegistryConfiguration { + @Bean open fun customSentryMeterRegistry() = SentryMeterRegistry(0) + } + + @Configuration(proxyBeanMethods = false) + open class SimpleRegistryConfiguration { + @Bean open fun simpleMeterRegistry() = SimpleMeterRegistry() + } +} diff --git a/sentry-spring-boot-jakarta/api/sentry-spring-boot-jakarta.api b/sentry-spring-boot-jakarta/api/sentry-spring-boot-jakarta.api index 197bdbeef72..f3eed099a59 100644 --- a/sentry-spring-boot-jakarta/api/sentry-spring-boot-jakarta.api +++ b/sentry-spring-boot-jakarta/api/sentry-spring-boot-jakarta.api @@ -33,6 +33,7 @@ public class io/sentry/spring/boot/jakarta/SentryProperties : io/sentry/SentryOp public fun getExceptionResolverOrder ()I public fun getGraphql ()Lio/sentry/spring/boot/jakarta/SentryProperties$Graphql; public fun getLogging ()Lio/sentry/spring/boot/jakarta/SentryProperties$Logging; + public fun getMicrometer ()Lio/sentry/spring/boot/jakarta/SentryProperties$Micrometer; public fun getReactive ()Lio/sentry/spring/boot/jakarta/SentryProperties$Reactive; public fun getUserFilterOrder ()Ljava/lang/Integer; public fun isEnableAotCompatibility ()Z @@ -43,6 +44,7 @@ public class io/sentry/spring/boot/jakarta/SentryProperties : io/sentry/SentryOp public fun setGraphql (Lio/sentry/spring/boot/jakarta/SentryProperties$Graphql;)V public fun setKeepTransactionsOpenForAsyncResponses (Z)V public fun setLogging (Lio/sentry/spring/boot/jakarta/SentryProperties$Logging;)V + public fun setMicrometer (Lio/sentry/spring/boot/jakarta/SentryProperties$Micrometer;)V public fun setReactive (Lio/sentry/spring/boot/jakarta/SentryProperties$Reactive;)V public fun setUseGitCommitIdAsRelease (Z)V public fun setUserFilterOrder (Ljava/lang/Integer;)V @@ -68,6 +70,14 @@ public class io/sentry/spring/boot/jakarta/SentryProperties$Logging { public fun setMinimumLevel (Lorg/slf4j/event/Level;)V } +public class io/sentry/spring/boot/jakarta/SentryProperties$Micrometer { + public fun ()V + public fun getPollIntervalMillis ()J + public fun isEnabled ()Z + public fun setEnabled (Z)V + public fun setPollIntervalMillis (J)V +} + public class io/sentry/spring/boot/jakarta/SentryProperties$Reactive { public fun ()V public fun isThreadLocalAccessorEnabled ()Z diff --git a/sentry-spring-boot-jakarta/build.gradle.kts b/sentry-spring-boot-jakarta/build.gradle.kts index 1ed9373f4bf..16b993a6dd6 100644 --- a/sentry-spring-boot-jakarta/build.gradle.kts +++ b/sentry-spring-boot-jakarta/build.gradle.kts @@ -33,6 +33,8 @@ dependencies { api(projects.sentry) api(projects.sentrySpringJakarta) + compileOnly(projects.sentryMicrometer) + compileOnly("io.micrometer:micrometer-core") compileOnly(projects.sentryLogback) compileOnly(projects.sentryApacheHttpClient5) compileOnly(platform(SpringBootPlugin.BOM_COORDINATES)) @@ -66,6 +68,7 @@ dependencies { errorprone(libs.nullaway) // tests + testImplementation(projects.sentryMicrometer) testImplementation(projects.sentryLogback) testImplementation(projects.sentryApacheHttpClient5) testImplementation(projects.sentryGraphql) @@ -89,6 +92,7 @@ dependencies { testImplementation(libs.otel.extension.autoconfigure.spi) testImplementation(libs.springboot3.otel) testImplementation(libs.springboot3.starter) + testImplementation(libs.springboot3.starter.actuator) testImplementation(libs.springboot3.starter.aop) testImplementation(libs.springboot3.starter.graphql) testImplementation(libs.spring.kafka3) 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 e1f8b026274..de037bbfbe4 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 @@ -160,6 +160,17 @@ static class OpenTelemetryAgentWithoutAutoInitConfiguration {} @ConditionalOnMissingClass("io.sentry.opentelemetry.agent.AgentMarker") static class OpenTelemetryNoAgentConfiguration {} + @Configuration(proxyBeanMethods = false) + @Import(SentryMicrometerConfiguration.class) + @Open + @ConditionalOnClass( + name = { + "io.micrometer.core.instrument.MeterRegistry", + "io.sentry.micrometer.SentryMeterRegistry" + }) + @ConditionalOnProperty(prefix = "sentry.micrometer", name = "enabled", havingValue = "true") + static class MicrometerConfiguration {} + @Bean public @NotNull IScopes sentryHub( final @NotNull List> optionsConfigurations, diff --git a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryMicrometerConfiguration.java b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryMicrometerConfiguration.java new file mode 100644 index 00000000000..f6b480d0768 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryMicrometerConfiguration.java @@ -0,0 +1,20 @@ +package io.sentry.spring.boot.jakarta; + +import io.sentry.IScopes; +import io.sentry.micrometer.SentryMeterRegistry; +import org.jetbrains.annotations.NotNull; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** Configures the Sentry Micrometer registry. */ +@Configuration(proxyBeanMethods = false) +final class SentryMicrometerConfiguration { + + @Bean(destroyMethod = "close") + @ConditionalOnMissingBean(SentryMeterRegistry.class) + public @NotNull SentryMeterRegistry sentryMeterRegistry( + final @NotNull IScopes scopes, final @NotNull SentryProperties properties) { + return new SentryMeterRegistry(scopes, properties.getMicrometer().getPollIntervalMillis()); + } +} diff --git a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryProperties.java b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryProperties.java index 7813c2e5512..02fc004f678 100644 --- a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryProperties.java +++ b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryProperties.java @@ -47,6 +47,9 @@ public class SentryProperties extends SentryOptions { /** Graphql integration properties. */ private @NotNull Graphql graphql = new Graphql(); + /** Micrometer integration properties. */ + private @NotNull Micrometer micrometer = new Micrometer(); + public boolean isUseGitCommitIdAsRelease() { return useGitCommitIdAsRelease; } @@ -124,6 +127,36 @@ public void setGraphql(@NotNull Graphql graphql) { this.graphql = graphql; } + public @NotNull Micrometer getMicrometer() { + return micrometer; + } + + public void setMicrometer(final @NotNull Micrometer micrometer) { + this.micrometer = micrometer; + } + + @Open + public static class Micrometer { + private boolean enabled; + private long pollIntervalMillis = 60_000; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(final boolean enabled) { + this.enabled = enabled; + } + + public long getPollIntervalMillis() { + return pollIntervalMillis; + } + + public void setPollIntervalMillis(final long pollIntervalMillis) { + this.pollIntervalMillis = pollIntervalMillis; + } + } + @Open public static class Logging { /** Enable/Disable logging auto-configuration. */ diff --git a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryMicrometerConfigurationTest.kt b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryMicrometerConfigurationTest.kt new file mode 100644 index 00000000000..236595e50f9 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryMicrometerConfigurationTest.kt @@ -0,0 +1,172 @@ +package io.sentry.spring.boot.jakarta + +import io.micrometer.core.instrument.MeterRegistry +import io.micrometer.core.instrument.composite.CompositeMeterRegistry +import io.micrometer.core.instrument.simple.SimpleMeterRegistry +import io.sentry.IScopes +import io.sentry.Sentry +import io.sentry.metrics.IMetricsApi +import io.sentry.micrometer.SentryMeterRegistry +import kotlin.test.AfterTest +import kotlin.test.Test +import org.assertj.core.api.Assertions.assertThat +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.boot.actuate.autoconfigure.metrics.CompositeMeterRegistryAutoConfiguration +import org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration +import org.springframework.boot.autoconfigure.AutoConfigurations +import org.springframework.boot.test.context.FilteredClassLoader +import org.springframework.boot.test.context.runner.ApplicationContextRunner +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +class SentryMicrometerConfigurationTest { + private val contextRunner = + ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(SentryAutoConfiguration::class.java)) + .withUserConfiguration(SentryAutoConfigurationTest.NoOpTransportConfiguration::class.java) + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.shutdown-timeout-millis=0", + "sentry.metrics.enabled=false", + ) + + @AfterTest + fun tearDown() { + Sentry.close() + } + + @Test + fun `integration is disabled by default and supports explicit opt out`() { + contextRunner.run { + assertThat(it).doesNotHaveBean(SentryMeterRegistry::class.java) + assertThat(it.getBean(SentryProperties::class.java).micrometer.isEnabled).isFalse() + assertThat(it.getBean(SentryProperties::class.java).micrometer.pollIntervalMillis) + .isEqualTo(60_000) + } + contextRunner.withPropertyValues("sentry.micrometer.enabled=false").run { + assertThat(it).doesNotHaveBean(SentryMeterRegistry::class.java) + } + } + + @Test + fun `integration binds properties and depends on initialized Sentry scopes`() { + var registry: SentryMeterRegistry? = null + + contextRunner + .withPropertyValues( + "sentry.micrometer.enabled=true", + "sentry.micrometer.poll-interval-millis=0", + ) + .run { + assertThat(it).hasSingleBean(SentryMeterRegistry::class.java) + assertThat(it).hasSingleBean(IScopes::class.java) + assertThat(it.getBean(SentryProperties::class.java).micrometer.isEnabled).isTrue() + assertThat(it.getBean(SentryProperties::class.java).micrometer.pollIntervalMillis) + .isEqualTo(0) + assertThat( + it.sourceApplicationContext.beanFactory.getDependenciesForBean("sentryMeterRegistry") + ) + .contains("sentryHub") + registry = it.getBean(SentryMeterRegistry::class.java) + assertThat(registry!!.isClosed).isFalse() + } + + assertThat(registry!!.isClosed).isTrue() + } + + @Test + fun `registry uses the injected scopes bean`() { + val scopes = mock() + val metrics = mock() + whenever(scopes.metrics()).thenReturn(metrics) + val properties = SentryProperties().apply { micrometer.pollIntervalMillis = 0 } + + ApplicationContextRunner() + .withUserConfiguration(SentryMicrometerConfiguration::class.java) + .withBean(IScopes::class.java, { scopes }) + .withBean(SentryProperties::class.java, { properties }) + .run { + it.getBean(SentryMeterRegistry::class.java).counter("requests").increment(2.0) + + verify(metrics).count(eq("requests"), eq(2.0), anyOrNull(), any()) + } + } + + @Test + fun `integration is absent when Sentry auto-configuration is disabled`() { + ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(SentryAutoConfiguration::class.java)) + .withUserConfiguration(SentryAutoConfigurationTest.NoOpTransportConfiguration::class.java) + .withPropertyValues("sentry.micrometer.enabled=true") + .run { assertThat(it).doesNotHaveBean(SentryMeterRegistry::class.java) } + } + + @Test + fun `integration backs off for a user provided registry`() { + contextRunner + .withPropertyValues("sentry.micrometer.enabled=true") + .withUserConfiguration(CustomRegistryConfiguration::class.java) + .run { + assertThat(it).hasSingleBean(SentryMeterRegistry::class.java) + assertThat(it).hasBean("customSentryMeterRegistry") + assertThat(it).doesNotHaveBean("sentryMeterRegistry") + } + } + + @Test + fun `integration is absent when sentry micrometer is not on the classpath`() { + contextRunner + .withPropertyValues("sentry.micrometer.enabled=true") + .withClassLoader(FilteredClassLoader(SentryMeterRegistry::class.java)) + .run { assertThat(it).doesNotHaveBean(SentryMeterRegistry::class.java) } + } + + @Test + fun `Spring primary registry forwards to Sentry and another registry`() { + ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of( + SentryAutoConfiguration::class.java, + MetricsAutoConfiguration::class.java, + CompositeMeterRegistryAutoConfiguration::class.java, + ) + ) + .withUserConfiguration( + SentryAutoConfigurationTest.NoOpTransportConfiguration::class.java, + SimpleRegistryConfiguration::class.java, + ) + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.shutdown-timeout-millis=0", + "sentry.metrics.enabled=false", + "sentry.micrometer.enabled=true", + "sentry.micrometer.poll-interval-millis=0", + ) + .run { + val primary = it.getBean(MeterRegistry::class.java) + val sentry = it.getBean(SentryMeterRegistry::class.java) + val simple = it.getBean(SimpleMeterRegistry::class.java) + + assertThat(primary).isInstanceOf(CompositeMeterRegistry::class.java) + primary.counter("requests").increment() + + assertThat(sentry.get("requests").counter().count()).isEqualTo(1.0) + assertThat(simple.get("requests").counter().count()).isEqualTo(1.0) + } + } + + @Configuration(proxyBeanMethods = false) + open class CustomRegistryConfiguration { + @Bean open fun customSentryMeterRegistry() = SentryMeterRegistry(0) + } + + @Configuration(proxyBeanMethods = false) + open class SimpleRegistryConfiguration { + @Bean open fun simpleMeterRegistry() = SimpleMeterRegistry() + } +} diff --git a/sentry-spring-boot/api/sentry-spring-boot.api b/sentry-spring-boot/api/sentry-spring-boot.api index ef726c4fc25..bc9d7a67f7d 100644 --- a/sentry-spring-boot/api/sentry-spring-boot.api +++ b/sentry-spring-boot/api/sentry-spring-boot.api @@ -33,6 +33,7 @@ public class io/sentry/spring/boot/SentryProperties : io/sentry/SentryOptions { public fun getExceptionResolverOrder ()I public fun getGraphql ()Lio/sentry/spring/boot/SentryProperties$Graphql; public fun getLogging ()Lio/sentry/spring/boot/SentryProperties$Logging; + public fun getMicrometer ()Lio/sentry/spring/boot/SentryProperties$Micrometer; public fun getUserFilterOrder ()Ljava/lang/Integer; public fun isKeepTransactionsOpenForAsyncResponses ()Z public fun isUseGitCommitIdAsRelease ()Z @@ -40,6 +41,7 @@ public class io/sentry/spring/boot/SentryProperties : io/sentry/SentryOptions { public fun setGraphql (Lio/sentry/spring/boot/SentryProperties$Graphql;)V public fun setKeepTransactionsOpenForAsyncResponses (Z)V public fun setLogging (Lio/sentry/spring/boot/SentryProperties$Logging;)V + public fun setMicrometer (Lio/sentry/spring/boot/SentryProperties$Micrometer;)V public fun setUseGitCommitIdAsRelease (Z)V public fun setUserFilterOrder (Ljava/lang/Integer;)V } @@ -64,6 +66,14 @@ public class io/sentry/spring/boot/SentryProperties$Logging { public fun setMinimumLevel (Lorg/slf4j/event/Level;)V } +public class io/sentry/spring/boot/SentryProperties$Micrometer { + public fun ()V + public fun getPollIntervalMillis ()J + public fun isEnabled ()Z + public fun setEnabled (Z)V + public fun setPollIntervalMillis (J)V +} + public class io/sentry/spring/boot/SentryWebfluxAutoConfiguration { public fun ()V public fun sentryScheduleHookApplicationRunner ()Lorg/springframework/boot/ApplicationRunner; diff --git a/sentry-spring-boot/build.gradle.kts b/sentry-spring-boot/build.gradle.kts index 947eaf9b03a..bea528f75c8 100644 --- a/sentry-spring-boot/build.gradle.kts +++ b/sentry-spring-boot/build.gradle.kts @@ -28,6 +28,8 @@ dependencies { api(projects.sentry) api(projects.sentrySpring) + compileOnly(projects.sentryMicrometer) + compileOnly("io.micrometer:micrometer-core") compileOnly(projects.sentryLogback) compileOnly(projects.sentryApacheHttpClient5) compileOnly(libs.jetbrains.annotations) @@ -57,6 +59,7 @@ dependencies { errorprone(libs.nullaway) // tests + testImplementation(projects.sentryMicrometer) testImplementation(projects.sentryLogback) testImplementation(projects.sentryQuartz) testImplementation(projects.sentryApacheHttpClient5) @@ -70,6 +73,7 @@ dependencies { testImplementation(libs.otel) testImplementation(libs.otel.extension.autoconfigure.spi) testImplementation(libs.springboot.starter) + testImplementation(libs.springboot.starter.actuator) testImplementation(libs.springboot.starter.aop) testImplementation(libs.springboot.starter.quartz) testImplementation(libs.springboot.starter.security) 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 f89f5c5bb31..42a254b351c 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 @@ -155,6 +155,17 @@ static class OpenTelemetryAgentWithoutAutoInitConfiguration {} @ConditionalOnMissingClass("io.sentry.opentelemetry.agent.AgentMarker") static class OpenTelemetryNoAgentConfiguration {} + @Configuration(proxyBeanMethods = false) + @Import(SentryMicrometerConfiguration.class) + @Open + @ConditionalOnClass( + name = { + "io.micrometer.core.instrument.MeterRegistry", + "io.sentry.micrometer.SentryMeterRegistry" + }) + @ConditionalOnProperty(prefix = "sentry.micrometer", name = "enabled", havingValue = "true") + static class MicrometerConfiguration {} + @Bean public @NotNull IScopes sentryHub( final @NotNull List> optionsConfigurations, diff --git a/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryMicrometerConfiguration.java b/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryMicrometerConfiguration.java new file mode 100644 index 00000000000..5ee0eedd034 --- /dev/null +++ b/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryMicrometerConfiguration.java @@ -0,0 +1,20 @@ +package io.sentry.spring.boot; + +import io.sentry.IScopes; +import io.sentry.micrometer.SentryMeterRegistry; +import org.jetbrains.annotations.NotNull; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** Configures the Sentry Micrometer registry. */ +@Configuration(proxyBeanMethods = false) +final class SentryMicrometerConfiguration { + + @Bean(destroyMethod = "close") + @ConditionalOnMissingBean(SentryMeterRegistry.class) + public @NotNull SentryMeterRegistry sentryMeterRegistry( + final @NotNull IScopes scopes, final @NotNull SentryProperties properties) { + return new SentryMeterRegistry(scopes, properties.getMicrometer().getPollIntervalMillis()); + } +} diff --git a/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryProperties.java b/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryProperties.java index f959fc930ba..95be7cd817f 100644 --- a/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryProperties.java +++ b/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryProperties.java @@ -37,6 +37,9 @@ public class SentryProperties extends SentryOptions { /** Graphql integration properties. */ private @NotNull Graphql graphql = new Graphql(); + /** Micrometer integration properties. */ + private @NotNull Micrometer micrometer = new Micrometer(); + public boolean isUseGitCommitIdAsRelease() { return useGitCommitIdAsRelease; } @@ -98,6 +101,36 @@ public void setGraphql(@NotNull Graphql graphql) { this.graphql = graphql; } + public @NotNull Micrometer getMicrometer() { + return micrometer; + } + + public void setMicrometer(final @NotNull Micrometer micrometer) { + this.micrometer = micrometer; + } + + @Open + public static class Micrometer { + private boolean enabled; + private long pollIntervalMillis = 60_000; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(final boolean enabled) { + this.enabled = enabled; + } + + public long getPollIntervalMillis() { + return pollIntervalMillis; + } + + public void setPollIntervalMillis(final long pollIntervalMillis) { + this.pollIntervalMillis = pollIntervalMillis; + } + } + @Open public static class Logging { /** Enable/Disable logging auto-configuration. */ diff --git a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryMicrometerConfigurationTest.kt b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryMicrometerConfigurationTest.kt new file mode 100644 index 00000000000..b8ec971a5cd --- /dev/null +++ b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryMicrometerConfigurationTest.kt @@ -0,0 +1,172 @@ +package io.sentry.spring.boot + +import io.micrometer.core.instrument.MeterRegistry +import io.micrometer.core.instrument.composite.CompositeMeterRegistry +import io.micrometer.core.instrument.simple.SimpleMeterRegistry +import io.sentry.IScopes +import io.sentry.Sentry +import io.sentry.metrics.IMetricsApi +import io.sentry.micrometer.SentryMeterRegistry +import kotlin.test.AfterTest +import kotlin.test.Test +import org.assertj.core.api.Assertions.assertThat +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.boot.actuate.autoconfigure.metrics.CompositeMeterRegistryAutoConfiguration +import org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration +import org.springframework.boot.autoconfigure.AutoConfigurations +import org.springframework.boot.test.context.FilteredClassLoader +import org.springframework.boot.test.context.runner.ApplicationContextRunner +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +class SentryMicrometerConfigurationTest { + private val contextRunner = + ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(SentryAutoConfiguration::class.java)) + .withUserConfiguration(SentryAutoConfigurationTest.NoOpTransportConfiguration::class.java) + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.shutdown-timeout-millis=0", + "sentry.metrics.enabled=false", + ) + + @AfterTest + fun tearDown() { + Sentry.close() + } + + @Test + fun `integration is disabled by default and supports explicit opt out`() { + contextRunner.run { + assertThat(it).doesNotHaveBean(SentryMeterRegistry::class.java) + assertThat(it.getBean(SentryProperties::class.java).micrometer.isEnabled).isFalse() + assertThat(it.getBean(SentryProperties::class.java).micrometer.pollIntervalMillis) + .isEqualTo(60_000) + } + contextRunner.withPropertyValues("sentry.micrometer.enabled=false").run { + assertThat(it).doesNotHaveBean(SentryMeterRegistry::class.java) + } + } + + @Test + fun `integration binds properties and depends on initialized Sentry scopes`() { + var registry: SentryMeterRegistry? = null + + contextRunner + .withPropertyValues( + "sentry.micrometer.enabled=true", + "sentry.micrometer.poll-interval-millis=0", + ) + .run { + assertThat(it).hasSingleBean(SentryMeterRegistry::class.java) + assertThat(it).hasSingleBean(IScopes::class.java) + assertThat(it.getBean(SentryProperties::class.java).micrometer.isEnabled).isTrue() + assertThat(it.getBean(SentryProperties::class.java).micrometer.pollIntervalMillis) + .isEqualTo(0) + assertThat( + it.sourceApplicationContext.beanFactory.getDependenciesForBean("sentryMeterRegistry") + ) + .contains("sentryHub") + registry = it.getBean(SentryMeterRegistry::class.java) + assertThat(registry!!.isClosed).isFalse() + } + + assertThat(registry!!.isClosed).isTrue() + } + + @Test + fun `registry uses the injected scopes bean`() { + val scopes = mock() + val metrics = mock() + whenever(scopes.metrics()).thenReturn(metrics) + val properties = SentryProperties().apply { micrometer.pollIntervalMillis = 0 } + + ApplicationContextRunner() + .withUserConfiguration(SentryMicrometerConfiguration::class.java) + .withBean(IScopes::class.java, { scopes }) + .withBean(SentryProperties::class.java, { properties }) + .run { + it.getBean(SentryMeterRegistry::class.java).counter("requests").increment(2.0) + + verify(metrics).count(eq("requests"), eq(2.0), anyOrNull(), any()) + } + } + + @Test + fun `integration is absent when Sentry auto-configuration is disabled`() { + ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(SentryAutoConfiguration::class.java)) + .withUserConfiguration(SentryAutoConfigurationTest.NoOpTransportConfiguration::class.java) + .withPropertyValues("sentry.micrometer.enabled=true") + .run { assertThat(it).doesNotHaveBean(SentryMeterRegistry::class.java) } + } + + @Test + fun `integration backs off for a user provided registry`() { + contextRunner + .withPropertyValues("sentry.micrometer.enabled=true") + .withUserConfiguration(CustomRegistryConfiguration::class.java) + .run { + assertThat(it).hasSingleBean(SentryMeterRegistry::class.java) + assertThat(it).hasBean("customSentryMeterRegistry") + assertThat(it).doesNotHaveBean("sentryMeterRegistry") + } + } + + @Test + fun `integration is absent when sentry micrometer is not on the classpath`() { + contextRunner + .withPropertyValues("sentry.micrometer.enabled=true") + .withClassLoader(FilteredClassLoader(SentryMeterRegistry::class.java)) + .run { assertThat(it).doesNotHaveBean(SentryMeterRegistry::class.java) } + } + + @Test + fun `Spring primary registry forwards to Sentry and another registry`() { + ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of( + SentryAutoConfiguration::class.java, + MetricsAutoConfiguration::class.java, + CompositeMeterRegistryAutoConfiguration::class.java, + ) + ) + .withUserConfiguration( + SentryAutoConfigurationTest.NoOpTransportConfiguration::class.java, + SimpleRegistryConfiguration::class.java, + ) + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.shutdown-timeout-millis=0", + "sentry.metrics.enabled=false", + "sentry.micrometer.enabled=true", + "sentry.micrometer.poll-interval-millis=0", + ) + .run { + val primary = it.getBean(MeterRegistry::class.java) + val sentry = it.getBean(SentryMeterRegistry::class.java) + val simple = it.getBean(SimpleMeterRegistry::class.java) + + assertThat(primary).isInstanceOf(CompositeMeterRegistry::class.java) + primary.counter("requests").increment() + + assertThat(sentry.get("requests").counter().count()).isEqualTo(1.0) + assertThat(simple.get("requests").counter().count()).isEqualTo(1.0) + } + } + + @Configuration(proxyBeanMethods = false) + open class CustomRegistryConfiguration { + @Bean open fun customSentryMeterRegistry() = SentryMeterRegistry(0) + } + + @Configuration(proxyBeanMethods = false) + open class SimpleRegistryConfiguration { + @Bean open fun simpleMeterRegistry() = SimpleMeterRegistry() + } +} diff --git a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/RestTestClient.kt b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/RestTestClient.kt index b9dc0f3ccad..dc80673f0f7 100644 --- a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/RestTestClient.kt +++ b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/RestTestClient.kt @@ -93,6 +93,18 @@ class RestTestClient(private val backendBaseUrl: String) : LoggingInsecureRestCl return callTyped(request, true) } + fun getMicrometerMetric(): String? { + val request = Request.Builder().url("$backendBaseUrl/metric/micrometer") + + return callTyped(request, true) + } + + fun getActuatorHealth(): String? { + val request = Request.Builder().url("$backendBaseUrl/actuator/health") + + return callTyped(request, true) + } + fun getGaugeMetric(value: Long): String? { val request = Request.Builder().url("$backendBaseUrl/metric/gauge/$value")