diff --git a/CHANGELOG.md b/CHANGELOG.md index 76adaee1462..827928cd495 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +- Add a Micrometer metrics integration with opt-in Spring Boot support ([#6116](https://github.com/getsentry/sentry-java/pull/6116)) + ## 8.56.0 ### Fixes diff --git a/sentry-micrometer/README.md b/sentry-micrometer/README.md index 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")