From bdf347ef230c226d35b2a1d24c92894f9d9c2a1e Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 15 Sep 2026 15:04:31 +0200 Subject: [PATCH 1/6] feat(micrometer): [Micrometer 4] Add Spring Boot integration Add opt-in Micrometer registry configuration for Spring Boot 2, 3, and 4 when the sentry-micrometer artifact is present.\n\nExpose polling configuration, register the Sentry registry with Spring's composite registry, and verify active and passive metric delivery through sample system tests.\n\nCo-Authored-By: Claude --- sentry-micrometer/README.md | 28 ++++ .../build.gradle.kts | 1 + .../src/main/resources/application.properties | 2 + .../io/sentry/systemtest/MetricsSystemTest.kt | 38 +++++ .../build.gradle.kts | 1 + .../src/main/resources/application.properties | 2 + .../io/sentry/systemtest/MetricsSystemTest.kt | 38 +++++ .../build.gradle.kts | 1 + .../samples/spring/boot/MetricController.java | 18 +++ .../spring/boot/SentryDemoApplication.java | 6 + .../src/main/resources/application.properties | 2 + .../io/sentry/systemtest/MetricsSystemTest.kt | 62 ++++++++ .../api/sentry-spring-boot-4.api | 10 ++ sentry-spring-boot-4/build.gradle.kts | 4 + .../spring/boot4/SentryAutoConfiguration.java | 11 ++ .../boot4/SentryMicrometerConfiguration.java | 20 +++ .../sentry/spring/boot4/SentryProperties.java | 33 ++++ .../SentryMicrometerConfigurationTest.kt | 147 ++++++++++++++++++ .../api/sentry-spring-boot-jakarta.api | 10 ++ sentry-spring-boot-jakarta/build.gradle.kts | 4 + .../boot/jakarta/SentryAutoConfiguration.java | 11 ++ .../SentryMicrometerConfiguration.java | 20 +++ .../spring/boot/jakarta/SentryProperties.java | 33 ++++ .../SentryMicrometerConfigurationTest.kt | 147 ++++++++++++++++++ sentry-spring-boot/api/sentry-spring-boot.api | 10 ++ sentry-spring-boot/build.gradle.kts | 4 + .../spring/boot/SentryAutoConfiguration.java | 11 ++ .../boot/SentryMicrometerConfiguration.java | 20 +++ .../sentry/spring/boot/SentryProperties.java | 33 ++++ .../boot/SentryMicrometerConfigurationTest.kt | 147 ++++++++++++++++++ .../sentry/systemtest/util/RestTestClient.kt | 12 ++ 31 files changed, 886 insertions(+) create mode 100644 sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryMicrometerConfiguration.java create mode 100644 sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryMicrometerConfigurationTest.kt create mode 100644 sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryMicrometerConfiguration.java create mode 100644 sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryMicrometerConfigurationTest.kt create mode 100644 sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryMicrometerConfiguration.java create mode 100644 sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryMicrometerConfigurationTest.kt diff --git a/sentry-micrometer/README.md b/sentry-micrometer/README.md index 1adc22df901..cc17bee9ebc 100644 --- a/sentry-micrometer/README.md +++ b/sentry-micrometer/README.md @@ -21,6 +21,34 @@ 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-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-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/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..da449c5ec71 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,7 @@ package io.sentry.systemtest import io.sentry.systemtest.util.TestHelper import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue import org.junit.Before class MetricsSystemTest { @@ -27,6 +28,43 @@ class MetricsSystemTest { } } + @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/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..da449c5ec71 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,7 @@ package io.sentry.systemtest import io.sentry.systemtest.util.TestHelper import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue import org.junit.Before class MetricsSystemTest { @@ -27,6 +28,43 @@ class MetricsSystemTest { } } + @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..d8974f1520b 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..bfe00cd2a3e --- /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(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..e0f2632cba2 --- /dev/null +++ b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryMicrometerConfigurationTest.kt @@ -0,0 +1,147 @@ +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.micrometer.SentryMeterRegistry +import kotlin.test.AfterTest +import kotlin.test.Test +import org.assertj.core.api.Assertions.assertThat +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 `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..4f8b87de832 --- /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(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..6ca4f0eeadf --- /dev/null +++ b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryMicrometerConfigurationTest.kt @@ -0,0 +1,147 @@ +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.micrometer.SentryMeterRegistry +import kotlin.test.AfterTest +import kotlin.test.Test +import org.assertj.core.api.Assertions.assertThat +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 `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..8c03e206139 --- /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(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..503e0cf8043 --- /dev/null +++ b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryMicrometerConfigurationTest.kt @@ -0,0 +1,147 @@ +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.micrometer.SentryMeterRegistry +import kotlin.test.AfterTest +import kotlin.test.Test +import org.assertj.core.api.Assertions.assertThat +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 `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") From 64381f2210e4c49430f53d5ed54f2630ec3bfeac Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 15 Sep 2026 15:31:08 +0200 Subject: [PATCH 2/6] changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) 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 From 32aef21176d7416caa7631cc9581372be4ccc86e Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 17 Sep 2026 16:24:04 +0200 Subject: [PATCH 3/6] test(micrometer): Expect dot-separated metric names Update Spring Boot system test expectations to match the Sentry registry's dot naming convention. Co-Authored-By: Claude --- .../test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt | 4 ++-- .../test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt | 4 ++-- .../test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) 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 da449c5ec71..93b4d034f45 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 @@ -36,7 +36,7 @@ class MetricsSystemTest { testHelper.ensureMetricsReceived { event, header -> event.items.any { metric -> - metric.name == "http_server_requests" && + metric.name == "http.server.requests" && metric.type == "distribution" && metric.unit == "millisecond" && metric.attributes?.get("sentry.origin")?.value == "auto.metrics.micrometer" @@ -52,7 +52,7 @@ class MetricsSystemTest { fun `Spring Boot generated process metric is forwarded through Micrometer polling`() { testHelper.ensureMetricsReceived { event, header -> event.items.any { metric -> - metric.name == "process_uptime" && + metric.name == "process.uptime" && metric.type == "gauge" && metric.unit == "millisecond" && metric.value > 0.0 && 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 da449c5ec71..93b4d034f45 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 @@ -36,7 +36,7 @@ class MetricsSystemTest { testHelper.ensureMetricsReceived { event, header -> event.items.any { metric -> - metric.name == "http_server_requests" && + metric.name == "http.server.requests" && metric.type == "distribution" && metric.unit == "millisecond" && metric.attributes?.get("sentry.origin")?.value == "auto.metrics.micrometer" @@ -52,7 +52,7 @@ class MetricsSystemTest { fun `Spring Boot generated process metric is forwarded through Micrometer polling`() { testHelper.ensureMetricsReceived { event, header -> event.items.any { metric -> - metric.name == "process_uptime" && + metric.name == "process.uptime" && metric.type == "gauge" && metric.unit == "millisecond" && metric.value > 0.0 && 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 d8974f1520b..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 @@ -39,10 +39,10 @@ class MetricsSystemTest { assertEquals(200, restClient.lastKnownStatusCode) testHelper.ensureMetricsReceived { event, header -> - testHelper.doesContainMetric(event, "micrometer_counter", "counter", 1.0) && + testHelper.doesContainMetric(event, "micrometer.counter", "counter", 1.0) && testHelper.doesMetricHaveAttribute( event, - "micrometer_counter", + "micrometer.counter", "source", "spring", ) && @@ -61,7 +61,7 @@ class MetricsSystemTest { testHelper.ensureMetricsReceived { event, _ -> event.items.any { metric -> - metric.name == "http_server_requests" && + metric.name == "http.server.requests" && metric.type == "distribution" && metric.unit == "millisecond" && metric.attributes?.get("method")?.value == "GET" && @@ -76,7 +76,7 @@ class MetricsSystemTest { fun `Spring Boot generated process metric is forwarded through Micrometer polling`() { testHelper.ensureMetricsReceived { event, header -> event.items.any { metric -> - metric.name == "process_uptime" && + metric.name == "process.uptime" && metric.type == "gauge" && metric.unit == "millisecond" && metric.value > 0.0 && From c21ba4a636f2e6b761e05b9f6f2919fd09143b08 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 18 Sep 2026 09:23:45 +0200 Subject: [PATCH 4/6] docs(micrometer): Clarify Spring Boot starter dependencies Document generation-specific starter coordinates so Spring Boot 2 and 3 users can adapt the Boot 4 setup example. Co-Authored-By: Claude --- sentry-micrometer/README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sentry-micrometer/README.md b/sentry-micrometer/README.md index cc17bee9ebc..731544cf9b3 100644 --- a/sentry-micrometer/README.md +++ b/sentry-micrometer/README.md @@ -29,7 +29,10 @@ transitively. ```kotlin dependencies { - implementation("io.sentry:sentry-spring-boot-starter:") + 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:") } ``` From 4b5d105ffa52d6cc489d60baf490a2ad01dcb404 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 23 Sep 2026 15:21:05 +0200 Subject: [PATCH 5/6] fix(micrometer): Use injected scopes in the meter registry Use the supplied IScopes for metric recording and diagnostic logging instead of bypassing it through the static Sentry API. Default standalone constructors to ScopesAdapter so current-context resolution is preserved. Pass the Spring scopes bean through all three Boot integrations while retaining initialization ordering. Cover injected metrics, logging, and Spring wiring with regression tests. Refs #6116 Co-Authored-By: Claude --- sentry-micrometer/api/sentry-micrometer.api | 1 + .../micrometer/SentryMeterRegistry.java | 28 +++++++--- .../micrometer/SentryFunctionTimerTest.kt | 4 +- .../SentryMeterRegistryPollingTest.kt | 8 ++- .../micrometer/SentryMeterRegistryTest.kt | 55 +++++++++++++++++++ .../boot4/SentryMicrometerConfiguration.java | 2 +- .../SentryMicrometerConfigurationTest.kt | 25 +++++++++ .../SentryMicrometerConfiguration.java | 2 +- .../SentryMicrometerConfigurationTest.kt | 25 +++++++++ .../boot/SentryMicrometerConfiguration.java | 2 +- .../boot/SentryMicrometerConfigurationTest.kt | 25 +++++++++ 11 files changed, 163 insertions(+), 14 deletions(-) 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-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 index bfe00cd2a3e..1026bf646f5 100644 --- 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 @@ -15,6 +15,6 @@ final class SentryMicrometerConfiguration { @ConditionalOnMissingBean(SentryMeterRegistry.class) public @NotNull SentryMeterRegistry sentryMeterRegistry( final @NotNull IScopes scopes, final @NotNull SentryProperties properties) { - return new SentryMeterRegistry(properties.getMicrometer().getPollIntervalMillis()); + return new SentryMeterRegistry(scopes, properties.getMicrometer().getPollIntervalMillis()); } } 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 index e0f2632cba2..5b4cd994e9f 100644 --- 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 @@ -5,10 +5,17 @@ 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 @@ -72,6 +79,24 @@ class SentryMicrometerConfigurationTest { 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() 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 index 4f8b87de832..f6b480d0768 100644 --- 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 @@ -15,6 +15,6 @@ final class SentryMicrometerConfiguration { @ConditionalOnMissingBean(SentryMeterRegistry.class) public @NotNull SentryMeterRegistry sentryMeterRegistry( final @NotNull IScopes scopes, final @NotNull SentryProperties properties) { - return new SentryMeterRegistry(properties.getMicrometer().getPollIntervalMillis()); + return new SentryMeterRegistry(scopes, properties.getMicrometer().getPollIntervalMillis()); } } 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 index 6ca4f0eeadf..236595e50f9 100644 --- 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 @@ -5,10 +5,17 @@ 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 @@ -72,6 +79,24 @@ class SentryMicrometerConfigurationTest { 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() 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 index 8c03e206139..5ee0eedd034 100644 --- 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 @@ -15,6 +15,6 @@ final class SentryMicrometerConfiguration { @ConditionalOnMissingBean(SentryMeterRegistry.class) public @NotNull SentryMeterRegistry sentryMeterRegistry( final @NotNull IScopes scopes, final @NotNull SentryProperties properties) { - return new SentryMeterRegistry(properties.getMicrometer().getPollIntervalMillis()); + return new SentryMeterRegistry(scopes, properties.getMicrometer().getPollIntervalMillis()); } } 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 index 503e0cf8043..b8ec971a5cd 100644 --- 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 @@ -5,10 +5,17 @@ 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 @@ -72,6 +79,24 @@ class SentryMicrometerConfigurationTest { 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() From 2d0c4ca0a194c80d18f64554715f24fc2f52472e Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 23 Sep 2026 15:42:31 +0200 Subject: [PATCH 6/6] test(micrometer): Add Spring Boot 3 and 4 sample parity Copy the Boot 2 Micrometer endpoint and secondary SimpleMeterRegistry into the Jakarta and Boot 4 samples. Verify that a single recording reaches both Sentry and the secondary registry, including exported tags and SDK integration metadata. Run the six metrics system tests for each of Boot 2, 3, and 4 against the sample applications and mock Sentry server: all 18 pass. Refs #6116 Co-Authored-By: Claude --- .../spring/boot4/MetricController.java | 18 +++++++++++++ .../spring/boot4/SentryDemoApplication.java | 6 +++++ .../io/sentry/systemtest/MetricsSystemTest.kt | 25 +++++++++++++++++++ .../spring/boot/jakarta/MetricController.java | 18 +++++++++++++ .../boot/jakarta/SentryDemoApplication.java | 6 +++++ .../io/sentry/systemtest/MetricsSystemTest.kt | 25 +++++++++++++++++++ 6 files changed, 98 insertions(+) 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/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt index 93b4d034f45..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,7 @@ 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 @@ -28,6 +29,30 @@ 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 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/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt index 93b4d034f45..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,7 @@ 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 @@ -28,6 +29,30 @@ 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