diff --git a/CHANGELOG.md b/CHANGELOG.md index 827928cd49..9ae5e2ab58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ ### Features - Add a Micrometer metrics integration with opt-in Spring Boot support ([#6116](https://github.com/getsentry/sentry-java/pull/6116)) +- Add `options.getMetrics().setIgnoredMetrics(...)` to filter metric names before processing, including early Micrometer filtering ([#6155](https://github.com/getsentry/sentry-java/pull/6155)) + - Configure names or full regular-expression patterns through the Java API, Android manifest, `sentry.properties`, or Spring Boot's `application.properties`. Use `[.]` to match a literal dot in a pattern. + - **Spring Boot 2, 3, and 4 default:** ignore `logback.events` and `log4j2.events` metrics to avoid logging-driven metric queue overload. An explicit list replaces these defaults; an empty list disables filtering. Outside Spring Boot, no metric names are ignored by default. Actual log messages are unaffected. + - **AndroidManifest.xml:** add `io.sentry.metrics.ignored-metrics` under ``: + ```xml + + ``` + - **sentry.properties:** use `metrics.ignored-metrics` (requires external configuration to be enabled with `options.setEnableExternalConfiguration(true)`): + ```properties + metrics.ignored-metrics=noisy[.]metric + ``` + - **Spring Boot application.properties:** use `sentry.metrics.ignored-metrics`. Include the logging patterns to retain the defaults while adding another exclusion: + ```properties + sentry.metrics.ignored-metrics=logback[.]events,log4j2[.]events,noisy[.]metric + ``` + To disable all name filters, including the Spring Boot defaults: + ```properties + sentry.metrics.ignored-metrics= + ``` ## 8.56.0 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 1ca91bbada..6a33c503a5 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -168,6 +168,7 @@ final class ManifestMetadataReader { static final String ENABLE_LOGS = "io.sentry.logs.enabled"; static final String ENABLE_METRICS = "io.sentry.metrics.enabled"; + static final String IGNORED_METRICS = "io.sentry.metrics.ignored-metrics"; static final String ENABLE_AUTO_TRACE_ID_GENERATION = "io.sentry.traces.enable-auto-id-generation"; @@ -717,6 +718,10 @@ static void applyMetadata( .getMetrics() .setEnabled( readBool(metadata, logger, ENABLE_METRICS, options.getMetrics().isEnabled())); + final @Nullable List ignoredMetrics = readList(metadata, logger, IGNORED_METRICS); + if (ignoredMetrics != null) { + options.getMetrics().setIgnoredMetrics(ignoredMetrics); + } final @NotNull SentryFeedbackOptions feedbackOptions = options.getFeedbackOptions(); feedbackOptions.setNameRequired( diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index c387cc8794..3fa43d120e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -2016,6 +2016,48 @@ class ManifestMetadataReaderTest { assertTrue(fixture.options.logs.isEnabled) } + @Test + fun `applyMetadata reads ignored metrics`() { + val context = + fixture.getContext( + metaData = bundleOf(ManifestMetadataReader.IGNORED_METRICS to "logback.events,jvm[.].*") + ) + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + assertThat(fixture.options.metrics.ignoredMetrics) + .containsExactly(FilterString("logback.events"), FilterString("jvm[.].*")) + } + + @Test + fun `applyMetadata does not ignore any metrics by default`() { + ManifestMetadataReader.applyMetadata( + fixture.getContext(), + fixture.options, + fixture.buildInfoProvider, + ) + assertThat(fixture.options.metrics.ignoredMetrics).isNull() + } + + @Test + fun `absent manifest ignored metrics preserve configured filters`() { + fixture.options.metrics.addIgnoredMetric("logback.events") + ManifestMetadataReader.applyMetadata( + fixture.getContext(), + fixture.options, + fixture.buildInfoProvider, + ) + assertThat(fixture.options.metrics.ignoredMetrics) + .containsExactly(FilterString("logback.events")) + } + + @Test + fun `empty manifest ignored metrics clear configured filters`() { + fixture.options.metrics.addIgnoredMetric("logback.events") + val context = + fixture.getContext(metaData = bundleOf(ManifestMetadataReader.IGNORED_METRICS to "")) + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + assertThat(fixture.options.metrics.ignoredMetrics).isEmpty() + } + @Test fun `applyMetadata reads metrics enabled and keep default value if not found`() { // Arrange diff --git a/sentry-micrometer/README.md b/sentry-micrometer/README.md index 7712c0bf19..d1ed737588 100644 --- a/sentry-micrometer/README.md +++ b/sentry-micrometer/README.md @@ -48,9 +48,10 @@ Auto-configuration requires Sentry to be initialized and backs off when the appl 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. +and process metrics—are forwarded through the same registry. Sentry's Spring Boot auto-configuration +ignores Logback and Log4j2 logging counters by default; see [Filtering and volume](#filtering-and-volume). +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 @@ -108,9 +109,60 @@ context that changed a backing value. ## Filtering and volume -Each active timer or distribution-summary recording creates one Sentry metric before the existing -Sentry metrics batch processor batches it for transport. Apply Micrometer `MeterFilter`s directly -to the Sentry registry to control volume and cardinality without affecting other registries: +Each counter increment, timer recording, or distribution-summary recording creates one Sentry +metric before the metrics batch processor batches it for transport. This includes each log event +counted by Micrometer's Logback or Log4j2 binders. High logging volume can fill the shared metrics +queue and cause other metrics to be dropped. + +Outside Spring Boot, no metric names are ignored by default. Configure ignored names before +registering meters: + +```java +options.getMetrics().setIgnoredMetrics(Arrays.asList("logback[.]events", "log4j2[.]events")); +``` + +Sentry's Spring Boot 2, 3, and 4 auto-configuration defaults to `logback[.]events` and +`log4j2[.]events` when the ignored-metrics list is unset. These patterns match the logging counters +`logback.events` and `log4j2.events` without treating the dots as regex wildcards. This filters only +metrics, not actual log messages, Sentry Logs, breadcrumbs, or error events. + +An explicit list replaces these defaults. Include them to retain logging exclusions alongside +custom filters: + +```properties +sentry.metrics.ignored-metrics=logback[.]events,log4j2[.]events,my.noisy.metric +``` + +To disable all name filters, including the logging defaults: + +```properties +sentry.metrics.ignored-metrics= +``` + +Defaults are applied before `Sentry.OptionsConfiguration` callbacks. A callback can append filters +with `options.getMetrics().addIgnoredMetric(...)`, replace them with `setIgnoredMetrics(...)`, or +clear them with an empty list or `null`. Enabled external configuration is merged afterward. +These Boot defaults apply only when Micrometer export is enabled. While enabled, manually recorded +Sentry metrics with the same names are also filtered. + +For `sentry.properties`, use `metrics.ignored-metrics`; the environment variable is +`SENTRY_METRICS_IGNORED_METRICS`. Android supports the manifest metadata key +`io.sentry.metrics.ignored-metrics` with a comma-separated string value. + +Patterns match final exported Sentry names after Micrometer naming conventions, using +case-insensitive exact matches or full regular-expression matches. Derived names such as +`task.active`, `task.duration`, `task.count`, and `task.total_time` are matched individually. +The option applies to manual metrics too, but does not affect other Micrometer registries. +All Micrometer metrics share `auto.metrics.micrometer` as their origin, so origin cannot +select just logging metrics. + +Sentry checks ignored names before creating metric events. The registry also denies registration +when all possible exported names of a meter are ignored, avoiding recording and polling overhead. +Changing the list later still filters captured metrics, but does not remove existing meters or +reactivate previously returned no-op meters. Configure filters before registration for the lowest +overhead. Intentional ignores do not generate client reports. + +Apply Micrometer `MeterFilter`s directly to the Sentry registry for Micrometer-only filtering: ```java sentryRegistry.config().meterFilter(MeterFilter.denyNameStartsWith("jvm.buffer")); diff --git a/sentry-micrometer/src/main/java/io/sentry/micrometer/SentryIgnoredMetricsFilter.java b/sentry-micrometer/src/main/java/io/sentry/micrometer/SentryIgnoredMetricsFilter.java new file mode 100644 index 0000000000..3d0fdd53f8 --- /dev/null +++ b/sentry-micrometer/src/main/java/io/sentry/micrometer/SentryIgnoredMetricsFilter.java @@ -0,0 +1,69 @@ +package io.sentry.micrometer; + +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.config.MeterFilter; +import io.micrometer.core.instrument.config.MeterFilterReply; +import io.sentry.FilterString; +import io.sentry.IScopes; +import io.sentry.util.MetricsUtils; +import java.util.List; +import java.util.Locale; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +final class SentryIgnoredMetricsFilter implements MeterFilter { + private final @NotNull SentryMeterRegistry registry; + private final @NotNull IScopes scopes; + + SentryIgnoredMetricsFilter( + final @NotNull SentryMeterRegistry registry, final @NotNull IScopes scopes) { + this.registry = registry; + this.scopes = scopes; + } + + @Override + public @NotNull MeterFilterReply accept(final @NotNull Meter.Id id) { + final @Nullable List ignoredMetrics = + scopes.getOptions().getMetrics().getIgnoredMetrics(); + if (ignoredMetrics == null || ignoredMetrics.isEmpty()) { + return MeterFilterReply.NEUTRAL; + } + final boolean ignored; + switch (id.getType()) { + case LONG_TASK_TIMER: + final String longTaskName = getTimeMeterName(id); + ignored = + MetricsUtils.isIgnored(ignoredMetrics, longTaskName + ".active") + && MetricsUtils.isIgnored(ignoredMetrics, longTaskName + ".duration"); + break; + case TIMER: + // Timer and FunctionTimer share a type. Keep the meter unless all possible outputs + // are ignored; MetricsApi filters each individual output when it is captured. + final String timerName = getTimeMeterName(id); + ignored = + MetricsUtils.isIgnored(ignoredMetrics, timerName) + && MetricsUtils.isIgnored(ignoredMetrics, timerName + ".count") + && MetricsUtils.isIgnored(ignoredMetrics, timerName + ".total_time"); + break; + case GAUGE: + // Gauge and TimeGauge also share a type, but only TimeGauge changes the base unit. + ignored = + MetricsUtils.isIgnored( + ignoredMetrics, id.getConventionName(registry.config().namingConvention())) + && MetricsUtils.isIgnored(ignoredMetrics, getTimeMeterName(id)); + break; + default: + ignored = + MetricsUtils.isIgnored( + ignoredMetrics, id.getConventionName(registry.config().namingConvention())); + break; + } + return ignored ? MeterFilterReply.DENY : MeterFilterReply.NEUTRAL; + } + + private @NotNull String getTimeMeterName(final @NotNull Meter.Id id) { + // MeterRegistry normalizes time-meter IDs after its filters run, using the default locale. + return id.withBaseUnit(registry.getBaseTimeUnit().toString().toLowerCase(Locale.getDefault())) + .getConventionName(registry.config().namingConvention()); + } +} 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 2088e34817..a6b4981add 100644 --- a/sentry-micrometer/src/main/java/io/sentry/micrometer/SentryMeterRegistry.java +++ b/sentry-micrometer/src/main/java/io/sentry/micrometer/SentryMeterRegistry.java @@ -91,7 +91,10 @@ public SentryMeterRegistry(final @NotNull IScopes scopes, final long pollInterva "A scheduler is required when passive polling is enabled."); } this.scheduler = pollIntervalMillis == 0 ? null : scheduler; - config().namingConvention(NamingConvention.identity).onMeterRemoved(this::onMeterRemoved); + config() + .namingConvention(NamingConvention.identity) + .meterFilter(new SentryIgnoredMetricsFilter(this, scopes)) + .onMeterRemoved(this::onMeterRemoved); addIntegrationToSdkVersion(INTEGRATION_NAME); if (this.scheduler == null) { pollingTask = null; diff --git a/sentry-micrometer/src/test/kotlin/io/sentry/micrometer/SentryIgnoredMetricsFilterTest.kt b/sentry-micrometer/src/test/kotlin/io/sentry/micrometer/SentryIgnoredMetricsFilterTest.kt new file mode 100644 index 0000000000..570c213940 --- /dev/null +++ b/sentry-micrometer/src/test/kotlin/io/sentry/micrometer/SentryIgnoredMetricsFilterTest.kt @@ -0,0 +1,320 @@ +package io.sentry.micrometer + +import com.google.common.truth.Truth.assertThat +import io.micrometer.core.instrument.FunctionCounter +import io.micrometer.core.instrument.FunctionTimer +import io.micrometer.core.instrument.Gauge +import io.micrometer.core.instrument.LongTaskTimer +import io.micrometer.core.instrument.Meter +import io.micrometer.core.instrument.TimeGauge +import io.micrometer.core.instrument.composite.CompositeMeterRegistry +import io.micrometer.core.instrument.config.MeterFilter +import io.micrometer.core.instrument.config.NamingConvention +import io.micrometer.core.instrument.simple.SimpleMeterRegistry +import io.sentry.IScopes +import io.sentry.ISentryClient +import io.sentry.Sentry +import io.sentry.SentryMetricsEvent +import io.sentry.SentryOptions +import io.sentry.metrics.IMetricsApi +import io.sentry.test.createTestScopes +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever + +class SentryIgnoredMetricsFilterTest { + private val options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } + private val client = mock() + private lateinit var registry: SentryMeterRegistry + + @BeforeTest + fun setUp() { + whenever(client.isEnabled).thenReturn(true) + val scopes = createTestScopes(options) + scopes.bindClient(client) + Sentry.setCurrentScopes(scopes) + registry = SentryMeterRegistry(0) + } + + @AfterTest + fun tearDown() { + registry.close() + Sentry.close() + } + + @Test + fun `logging metrics are not ignored by default`() { + registry.counter("logback.events").increment() + registry.counter("log4j2.events").increment() + val events = argumentCaptor() + verify(client, times(2)).captureMetric(events.capture(), any(), anyOrNull()) + assertThat(events.allValues.map { it.name }).containsExactly("logback.events", "log4j2.events") + } + + @Test + fun `registration filtering and recording use injected scopes instead of global scopes`() { + options.metrics.addIgnoredMetric("global[.]blocked") + val injectedOptions = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + metrics.addIgnoredMetric("injected[.]blocked") + } + val injectedClient = mock() + whenever(injectedClient.isEnabled).thenReturn(true) + val scopes = createTestScopes(injectedOptions) + scopes.bindClient(injectedClient) + + val injectedRegistry = SentryMeterRegistry(scopes, 0) + try { + injectedRegistry.counter("injected.blocked").increment() + injectedRegistry.counter("global.blocked").increment(2.0) + + assertThat(injectedRegistry.find("injected.blocked").counter()).isNull() + assertThat(injectedRegistry.find("global.blocked").counter()).isNotNull() + val event = argumentCaptor() + verify(injectedClient).captureMetric(event.capture(), any(), anyOrNull()) + assertThat(event.firstValue.name).isEqualTo("global.blocked") + assertThat(event.firstValue.value).isEqualTo(2.0) + verify(client, never()).captureMetric(any(), any(), anyOrNull()) + } finally { + injectedRegistry.close() + } + } + + @Test + fun `denied counter never invokes the metrics API even under a log flood`() { + options.metrics.addIgnoredMetric("LOGBACK.EVENTS") + val api = mock() + val scopes = mock() + whenever(scopes.options).thenReturn(options) + whenever(scopes.metrics()).thenReturn(api) + Sentry.setCurrentScopes(scopes) + + val counter = registry.counter("logback.events") + repeat(100_000) { counter.increment() } + + assertThat(registry.meters).isEmpty() + verifyNoInteractions(api) + } + + @Test + fun `ignored active and passive meters are not registered or evaluated`() { + options.metrics.addIgnoredMetric("ignored[.].*") + val reads = AtomicInteger() + val state = AtomicInteger() + registry.counter("ignored.counter").increment() + registry.timer("ignored.timer").record(1, TimeUnit.SECONDS) + registry.summary("ignored.summary").record(1.0) + Gauge.builder("ignored.gauge", state) { reads.incrementAndGet().toDouble() }.register(registry) + TimeGauge.builder("ignored.time", state, TimeUnit.SECONDS) { + reads.incrementAndGet().toDouble() + } + .register(registry) + FunctionCounter.builder("ignored.function", state) { reads.incrementAndGet().toDouble() } + .register(registry) + FunctionTimer.builder( + "ignored.function.timer", + state, + { reads.incrementAndGet().toLong() }, + { reads.incrementAndGet().toDouble() }, + TimeUnit.SECONDS, + ) + .register(registry) + LongTaskTimer.builder("ignored.long.timer").register(registry).start().stop() + registry.pollMeters() + + assertThat(registry.meters).isEmpty() + assertThat(reads.get()).isEqualTo(0) + verify(client, never()).captureMetric(any(), any(), anyOrNull()) + } + + @Test + fun `filter matches the final mapped and convention-converted name`() { + options.metrics.addIgnoredMetric("export_logback_events") + registry.config().namingConvention(NamingConvention.snakeCase) + registry + .config() + .meterFilter( + object : MeterFilter { + override fun map(id: Meter.Id): Meter.Id = id.withName("export." + id.name) + } + ) + + registry.counter("logback.events").increment() + + assertThat(registry.meters).isEmpty() + verify(client, never()).captureMetric(any(), any(), anyOrNull()) + } + + @Test + fun `filter does not match the raw name when convention changes the export`() { + options.metrics.addIgnoredMetric("logback.events") + registry + .config() + .namingConvention( + object : NamingConvention { + override fun name(name: String, type: Meter.Type, baseUnit: String?) = "export_$name" + } + ) + registry.counter("logback.events").increment() + val event = argumentCaptor() + verify(client).captureMetric(event.capture(), any(), anyOrNull()) + assertThat(event.firstValue.name).isEqualTo("export_logback.events") + } + + @Test + fun `ignoring a timer base name preserves function timer outputs`() { + options.metrics.addIgnoredMetric("task") + val state = AtomicInteger() + FunctionTimer.builder( + "task", + state, + { it.get().toLong() }, + { it.get().toDouble() }, + TimeUnit.SECONDS, + ) + .register(registry) + registry.pollMeters() + state.set(2) + registry.pollMeters() + val events = argumentCaptor() + verify(client, times(2)).captureMetric(events.capture(), any(), anyOrNull()) + assertThat(events.allValues.map { it.name }).containsExactly("task.count", "task.total_time") + } + + @Test + fun `ignoring function timer suffixes preserves ordinary timers`() { + options.metrics.setIgnoredMetrics(listOf("task.count", "task.total_time")) + registry.timer("task").record(1, TimeUnit.SECONDS) + val event = argumentCaptor() + verify(client).captureMetric(event.capture(), any(), anyOrNull()) + assertThat(event.firstValue.name).isEqualTo("task") + } + + @Test + fun `core filter drops only the ignored function timer output`() { + options.metrics.addIgnoredMetric("task.count") + val state = AtomicInteger() + FunctionTimer.builder( + "task", + state, + { it.get().toLong() }, + { it.get().toDouble() }, + TimeUnit.SECONDS, + ) + .register(registry) + registry.pollMeters() + state.set(2) + registry.pollMeters() + val event = argumentCaptor() + verify(client).captureMetric(event.capture(), any(), anyOrNull()) + assertThat(event.firstValue.name).isEqualTo("task.total_time") + assertThat(event.firstValue.value).isEqualTo(2000.0) + } + + @Test + fun `core filter drops only the ignored long task timer output`() { + options.metrics.addIgnoredMetric("task.active") + LongTaskTimer.builder("task").register(registry).start() + registry.pollMeters() + val event = argumentCaptor() + verify(client).captureMetric(event.capture(), any(), anyOrNull()) + assertThat(event.firstValue.name).isEqualTo("task.duration") + } + + @Test + fun `long task timer is denied when both outputs are ignored without matching its base name`() { + options.metrics.setIgnoredMetrics(listOf("task.active", "task.duration")) + LongTaskTimer.builder("task").register(registry).start() + assertThat(registry.meters).isEmpty() + } + + @Test + fun `rules added after registration still apply through the core filter`() { + val counter = registry.counter("logback.events") + options.metrics.addIgnoredMetric("logback.events") + counter.increment() + verify(client, never()).captureMetric(any(), any(), anyOrNull()) + assertThat(counter.count()).isEqualTo(1.0) + options.metrics.setIgnoredMetrics(emptyList()) + counter.increment() + verify(client).captureMetric(any(), any(), anyOrNull()) + } + + @Test + fun `ignored metrics affect only the Sentry registry in a composite`() { + options.metrics.addIgnoredMetric("logback.events") + val other = SimpleMeterRegistry() + val composite = CompositeMeterRegistry() + try { + composite.add(registry) + composite.add(other) + composite.counter("logback.events").increment(2.0) + assertThat(other.counter("logback.events").count()).isEqualTo(2.0) + assertThat(registry.meters).isEmpty() + verify(client, never()).captureMetric(any(), any(), anyOrNull()) + } finally { + composite.close() + other.close() + } + } + + @Test + fun `time meter filters use the base unit applied after registration filtering`() { + registry + .config() + .namingConvention( + object : NamingConvention { + override fun name(name: String, type: Meter.Type, baseUnit: String?) = "${name}_$baseUnit" + } + ) + options.metrics.addIgnoredMetric("blocked_milliseconds.*") + registry.timer("blocked").record(1, TimeUnit.SECONDS) + LongTaskTimer.builder("blocked").register(registry).start() + assertThat(registry.meters).isEmpty() + + options.metrics.setIgnoredMetrics(listOf("allowed_null.*")) + registry.timer("allowed").record(1, TimeUnit.SECONDS) + val event = argumentCaptor() + verify(client).captureMetric(event.capture(), any(), anyOrNull()) + assertThat(event.firstValue.name).isEqualTo("allowed_milliseconds") + } + + @Test + fun `gauge filter preserves both possible unit-dependent names`() { + registry + .config() + .namingConvention( + object : NamingConvention { + override fun name(name: String, type: Meter.Type, baseUnit: String?) = "${name}_$baseUnit" + } + ) + options.metrics.setIgnoredMetrics(listOf("time_null", "plain_milliseconds")) + val state = AtomicInteger(1) + TimeGauge.builder("time", state, TimeUnit.SECONDS) { it.get().toDouble() }.register(registry) + Gauge.builder("plain", state) { it.get().toDouble() }.register(registry) + registry.pollMeters() + val events = argumentCaptor() + verify(client, times(2)).captureMetric(events.capture(), any(), anyOrNull()) + assertThat(events.allValues.map { it.name }).containsExactly("time_milliseconds", "plain_null") + } + + @Test + fun `nonmatching filter remains neutral so user filters can still deny meters`() { + options.metrics.addIgnoredMetric("other") + registry.config().meterFilter(MeterFilter.denyNameStartsWith("logback")) + registry.counter("logback.events").increment() + assertThat(registry.meters).isEmpty() + } +} 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 c286ab05b1..793fbc8b1c 100644 --- a/sentry-micrometer/src/test/kotlin/io/sentry/micrometer/SentryMeterRegistryTest.kt +++ b/sentry-micrometer/src/test/kotlin/io/sentry/micrometer/SentryMeterRegistryTest.kt @@ -175,6 +175,7 @@ class SentryMeterRegistryTest { val metrics = mock() val scopes = mock() whenever(scopes.metrics()).thenReturn(metrics) + whenever(scopes.options).thenReturn(SentryOptions()) val registry = SentryMeterRegistry(scopes, 0).also(registries::add) registry.counter("counter").increment(2.0) 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 813477a8e4..0d0f5eb781 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 @@ -175,6 +175,11 @@ static class MicrometerConfiguration {} final @NotNull SentryProperties options, final @NotNull ObjectProvider spanFactory, final @NotNull ObjectProvider gitProperties) { + if (options.getMicrometer().isEnabled() && options.getMetrics().getIgnoredMetrics() == null) { + options + .getMetrics() + .setIgnoredMetrics(Arrays.asList("logback[.]events", "log4j2[.]events")); + } optionsConfigurations.forEach( optionsConfiguration -> optionsConfiguration.configure(options)); gitProperties.ifAvailable( diff --git a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt index ef1f12aeec..b1a2a1778f 100644 --- a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt @@ -244,6 +244,7 @@ class SentryAutoConfigurationTest { "sentry.cron.default-failure-issue-threshold=40", "sentry.cron.default-recovery-threshold=50", "sentry.logs.enabled=true", + "sentry.metrics.ignored-metrics=logback.events,jvm[.].*", "sentry.strict-trace-continuation=true", "sentry.org-id=12345", ) @@ -301,6 +302,8 @@ class SentryAutoConfigurationTest { assertThat(options.cron!!.defaultFailureIssueThreshold).isEqualTo(40L) assertThat(options.cron!!.defaultRecoveryThreshold).isEqualTo(50L) assertThat(options.logs.isEnabled).isEqualTo(true) + assertThat(options.metrics.ignoredMetrics) + .containsExactly(FilterString("logback.events"), FilterString("jvm[.].*")) assertThat(options.isStrictTraceContinuation).isEqualTo(true) assertThat(options.orgId).isEqualTo("12345") } 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 5b4cd994e9..d4c07ec093 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,6 +5,7 @@ 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.SentryOptions import io.sentry.metrics.IMetricsApi import io.sentry.micrometer.SentryMeterRegistry import kotlin.test.AfterTest @@ -47,9 +48,127 @@ class SentryMicrometerConfigurationTest { assertThat(it.getBean(SentryProperties::class.java).micrometer.isEnabled).isFalse() assertThat(it.getBean(SentryProperties::class.java).micrometer.pollIntervalMillis) .isEqualTo(60_000) + assertThat(it.getBean(SentryProperties::class.java).metrics.ignoredMetrics).isNull() } contextRunner.withPropertyValues("sentry.micrometer.enabled=false").run { assertThat(it).doesNotHaveBean(SentryMeterRegistry::class.java) + assertThat(it.getBean(SentryProperties::class.java).metrics.ignoredMetrics).isNull() + } + } + + @Test + fun `logging metrics are ignored by default before meter registration`() { + contextRunner + .withPropertyValues( + "sentry.micrometer.enabled=true", + "sentry.micrometer.poll-interval-millis=0", + ) + .run { + val registry = it.getBean(SentryMeterRegistry::class.java) + assertThat( + it.getBean(IScopes::class.java).options.metrics.ignoredMetrics?.map { it.filterString } + ) + .containsExactly("logback[.]events", "log4j2[.]events") + for (name in listOf("logback.events", "log4j2.events")) { + val counter = registry.counter(name) + counter.increment() + assertThat(counter.count()).isZero() + assertThat(registry.find(name).counter()).isNull() + } + for (name in listOf("business.operations", "logbackXevents", "log4j2Xevents")) { + val counter = registry.counter(name) + counter.increment() + assertThat(counter.count()).isEqualTo(1.0) + } + } + } + + @Test + fun `empty ignored metrics property disables logging defaults`() { + contextRunner + .withPropertyValues("sentry.micrometer.enabled=true", "sentry.metrics.ignored-metrics=") + .run { + assertThat(it.getBean(IScopes::class.java).options.metrics.ignoredMetrics).isEmpty() + val registry = it.getBean(SentryMeterRegistry::class.java) + for (name in listOf("logback.events", "log4j2.events")) { + val counter = registry.counter(name) + counter.increment() + assertThat(counter.count()).isEqualTo(1.0) + } + } + } + + @Test + fun `custom ignored metrics property replaces logging defaults`() { + contextRunner + .withPropertyValues( + "sentry.micrometer.enabled=true", + "sentry.metrics.ignored-metrics=business[.]operations", + ) + .run { + assertThat( + it.getBean(IScopes::class.java).options.metrics.ignoredMetrics?.map { it.filterString } + ) + .containsExactly("business[.]operations") + val registry = it.getBean(SentryMeterRegistry::class.java) + val business = registry.counter("business.operations") + business.increment() + assertThat(business.count()).isZero() + for (name in listOf("logback.events", "log4j2.events")) { + val counter = registry.counter(name) + counter.increment() + assertThat(counter.count()).isEqualTo(1.0) + } + } + } + + @Test + fun `options callback can append to logging defaults`() { + contextRunner + .withPropertyValues("sentry.micrometer.enabled=true") + .withBean( + Sentry.OptionsConfiguration::class.java, + { + Sentry.OptionsConfiguration { options -> + options.metrics.addIgnoredMetric("business[.]operations") + } + }, + ) + .run { + assertThat( + it.getBean(IScopes::class.java).options.metrics.ignoredMetrics?.map { it.filterString } + ) + .containsExactly("logback[.]events", "log4j2[.]events", "business[.]operations") + } + } + + @Test + fun `options callback can replace or clear logging defaults`() { + for (names in listOf(null, emptyList(), listOf("business[.]operations"))) { + contextRunner + .withPropertyValues("sentry.micrometer.enabled=true") + .withBean( + Sentry.OptionsConfiguration::class.java, + { + Sentry.OptionsConfiguration { options -> + options.metrics.setIgnoredMetrics(names) + } + }, + ) + .run { + assertThat( + it.getBean(IScopes::class.java).options.metrics.ignoredMetrics?.map { + it.filterString + } + ) + .isEqualTo(names) + val registry = it.getBean(SentryMeterRegistry::class.java) + for (name in listOf("logback.events", "log4j2.events")) { + val counter = registry.counter(name) + counter.increment() + assertThat(counter.count()).isEqualTo(1.0) + } + } } } @@ -85,6 +204,7 @@ class SentryMicrometerConfigurationTest { val metrics = mock() whenever(scopes.metrics()).thenReturn(metrics) val properties = SentryProperties().apply { micrometer.pollIntervalMillis = 0 } + whenever(scopes.options).thenReturn(properties) ApplicationContextRunner() .withUserConfiguration(SentryMicrometerConfiguration::class.java) @@ -157,6 +277,11 @@ class SentryMicrometerConfigurationTest { assertThat(sentry.get("requests").counter().count()).isEqualTo(1.0) assertThat(simple.get("requests").counter().count()).isEqualTo(1.0) + for (name in listOf("logback.events", "log4j2.events")) { + primary.counter(name).increment() + assertThat(sentry.find(name).counter()).isNull() + assertThat(simple.get(name).counter().count()).isEqualTo(1.0) + } } } 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 de037bbfbe..70b85a306f 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 @@ -177,6 +177,11 @@ static class MicrometerConfiguration {} final @NotNull SentryProperties options, final @NotNull ObjectProvider spanFactory, final @NotNull ObjectProvider gitProperties) { + if (options.getMicrometer().isEnabled() && options.getMetrics().getIgnoredMetrics() == null) { + options + .getMetrics() + .setIgnoredMetrics(Arrays.asList("logback[.]events", "log4j2[.]events")); + } optionsConfigurations.forEach( optionsConfiguration -> optionsConfiguration.configure(options)); gitProperties.ifAvailable( diff --git a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt index 91677d16b4..f708e88977 100644 --- a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt @@ -246,6 +246,7 @@ class SentryAutoConfigurationTest { "sentry.cron.default-failure-issue-threshold=40", "sentry.cron.default-recovery-threshold=50", "sentry.logs.enabled=true", + "sentry.metrics.ignored-metrics=logback.events,jvm[.].*", "sentry.profile-session-sample-rate=1.0", "sentry.profiling-traces-dir-path=tmp/sentry/profiling-traces", "sentry.profile-lifecycle=TRACE", @@ -305,6 +306,8 @@ class SentryAutoConfigurationTest { assertThat(options.cron!!.defaultFailureIssueThreshold).isEqualTo(40L) assertThat(options.cron!!.defaultRecoveryThreshold).isEqualTo(50L) assertThat(options.logs.isEnabled).isEqualTo(true) + assertThat(options.metrics.ignoredMetrics) + .containsExactly(FilterString("logback.events"), FilterString("jvm[.].*")) assertThat(options.profileSessionSampleRate).isEqualTo(1.0) assertThat(options.profilingTracesDirPath) .startsWith(File("tmp/sentry/profiling-traces").absolutePath) 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 236595e50f..d5ae1629b5 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,6 +5,7 @@ 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.SentryOptions import io.sentry.metrics.IMetricsApi import io.sentry.micrometer.SentryMeterRegistry import kotlin.test.AfterTest @@ -47,9 +48,127 @@ class SentryMicrometerConfigurationTest { assertThat(it.getBean(SentryProperties::class.java).micrometer.isEnabled).isFalse() assertThat(it.getBean(SentryProperties::class.java).micrometer.pollIntervalMillis) .isEqualTo(60_000) + assertThat(it.getBean(SentryProperties::class.java).metrics.ignoredMetrics).isNull() } contextRunner.withPropertyValues("sentry.micrometer.enabled=false").run { assertThat(it).doesNotHaveBean(SentryMeterRegistry::class.java) + assertThat(it.getBean(SentryProperties::class.java).metrics.ignoredMetrics).isNull() + } + } + + @Test + fun `logging metrics are ignored by default before meter registration`() { + contextRunner + .withPropertyValues( + "sentry.micrometer.enabled=true", + "sentry.micrometer.poll-interval-millis=0", + ) + .run { + val registry = it.getBean(SentryMeterRegistry::class.java) + assertThat( + it.getBean(IScopes::class.java).options.metrics.ignoredMetrics?.map { it.filterString } + ) + .containsExactly("logback[.]events", "log4j2[.]events") + for (name in listOf("logback.events", "log4j2.events")) { + val counter = registry.counter(name) + counter.increment() + assertThat(counter.count()).isZero() + assertThat(registry.find(name).counter()).isNull() + } + for (name in listOf("business.operations", "logbackXevents", "log4j2Xevents")) { + val counter = registry.counter(name) + counter.increment() + assertThat(counter.count()).isEqualTo(1.0) + } + } + } + + @Test + fun `empty ignored metrics property disables logging defaults`() { + contextRunner + .withPropertyValues("sentry.micrometer.enabled=true", "sentry.metrics.ignored-metrics=") + .run { + assertThat(it.getBean(IScopes::class.java).options.metrics.ignoredMetrics).isEmpty() + val registry = it.getBean(SentryMeterRegistry::class.java) + for (name in listOf("logback.events", "log4j2.events")) { + val counter = registry.counter(name) + counter.increment() + assertThat(counter.count()).isEqualTo(1.0) + } + } + } + + @Test + fun `custom ignored metrics property replaces logging defaults`() { + contextRunner + .withPropertyValues( + "sentry.micrometer.enabled=true", + "sentry.metrics.ignored-metrics=business[.]operations", + ) + .run { + assertThat( + it.getBean(IScopes::class.java).options.metrics.ignoredMetrics?.map { it.filterString } + ) + .containsExactly("business[.]operations") + val registry = it.getBean(SentryMeterRegistry::class.java) + val business = registry.counter("business.operations") + business.increment() + assertThat(business.count()).isZero() + for (name in listOf("logback.events", "log4j2.events")) { + val counter = registry.counter(name) + counter.increment() + assertThat(counter.count()).isEqualTo(1.0) + } + } + } + + @Test + fun `options callback can append to logging defaults`() { + contextRunner + .withPropertyValues("sentry.micrometer.enabled=true") + .withBean( + Sentry.OptionsConfiguration::class.java, + { + Sentry.OptionsConfiguration { options -> + options.metrics.addIgnoredMetric("business[.]operations") + } + }, + ) + .run { + assertThat( + it.getBean(IScopes::class.java).options.metrics.ignoredMetrics?.map { it.filterString } + ) + .containsExactly("logback[.]events", "log4j2[.]events", "business[.]operations") + } + } + + @Test + fun `options callback can replace or clear logging defaults`() { + for (names in listOf(null, emptyList(), listOf("business[.]operations"))) { + contextRunner + .withPropertyValues("sentry.micrometer.enabled=true") + .withBean( + Sentry.OptionsConfiguration::class.java, + { + Sentry.OptionsConfiguration { options -> + options.metrics.setIgnoredMetrics(names) + } + }, + ) + .run { + assertThat( + it.getBean(IScopes::class.java).options.metrics.ignoredMetrics?.map { + it.filterString + } + ) + .isEqualTo(names) + val registry = it.getBean(SentryMeterRegistry::class.java) + for (name in listOf("logback.events", "log4j2.events")) { + val counter = registry.counter(name) + counter.increment() + assertThat(counter.count()).isEqualTo(1.0) + } + } } } @@ -85,6 +204,7 @@ class SentryMicrometerConfigurationTest { val metrics = mock() whenever(scopes.metrics()).thenReturn(metrics) val properties = SentryProperties().apply { micrometer.pollIntervalMillis = 0 } + whenever(scopes.options).thenReturn(properties) ApplicationContextRunner() .withUserConfiguration(SentryMicrometerConfiguration::class.java) @@ -157,6 +277,11 @@ class SentryMicrometerConfigurationTest { assertThat(sentry.get("requests").counter().count()).isEqualTo(1.0) assertThat(simple.get("requests").counter().count()).isEqualTo(1.0) + for (name in listOf("logback.events", "log4j2.events")) { + primary.counter(name).increment() + assertThat(sentry.find(name).counter()).isNull() + assertThat(simple.get(name).counter().count()).isEqualTo(1.0) + } } } 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 42a254b351..bf90c3b51e 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 @@ -172,6 +172,11 @@ static class MicrometerConfiguration {} final @NotNull SentryProperties options, final @NotNull ObjectProvider spanFactory, final @NotNull ObjectProvider gitProperties) { + if (options.getMicrometer().isEnabled() && options.getMetrics().getIgnoredMetrics() == null) { + options + .getMetrics() + .setIgnoredMetrics(Arrays.asList("logback[.]events", "log4j2[.]events")); + } optionsConfigurations.forEach( optionsConfiguration -> optionsConfiguration.configure(options)); gitProperties.ifAvailable( diff --git a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt index d9e598d047..c7f56c7357 100644 --- a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt @@ -244,6 +244,7 @@ class SentryAutoConfigurationTest { "sentry.cron.default-failure-issue-threshold=40", "sentry.cron.default-recovery-threshold=50", "sentry.logs.enabled=true", + "sentry.metrics.ignored-metrics=logback.events,jvm[.].*", "sentry.profile-session-sample-rate=1.0", "sentry.profiling-traces-dir-path=tmp/sentry/profiling-traces", "sentry.profile-lifecycle=TRACE", @@ -303,6 +304,8 @@ class SentryAutoConfigurationTest { assertThat(options.cron!!.defaultFailureIssueThreshold).isEqualTo(40L) assertThat(options.cron!!.defaultRecoveryThreshold).isEqualTo(50L) assertThat(options.logs.isEnabled).isEqualTo(true) + assertThat(options.metrics.ignoredMetrics) + .containsExactly(FilterString("logback.events"), FilterString("jvm[.].*")) assertThat(options.profileSessionSampleRate).isEqualTo(1.0) assertThat(options.profilingTracesDirPath) .startsWith(File("tmp/sentry/profiling-traces").absolutePath) 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 b8ec971a5c..aa056c03e9 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,6 +5,7 @@ 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.SentryOptions import io.sentry.metrics.IMetricsApi import io.sentry.micrometer.SentryMeterRegistry import kotlin.test.AfterTest @@ -47,9 +48,127 @@ class SentryMicrometerConfigurationTest { assertThat(it.getBean(SentryProperties::class.java).micrometer.isEnabled).isFalse() assertThat(it.getBean(SentryProperties::class.java).micrometer.pollIntervalMillis) .isEqualTo(60_000) + assertThat(it.getBean(SentryProperties::class.java).metrics.ignoredMetrics).isNull() } contextRunner.withPropertyValues("sentry.micrometer.enabled=false").run { assertThat(it).doesNotHaveBean(SentryMeterRegistry::class.java) + assertThat(it.getBean(SentryProperties::class.java).metrics.ignoredMetrics).isNull() + } + } + + @Test + fun `logging metrics are ignored by default before meter registration`() { + contextRunner + .withPropertyValues( + "sentry.micrometer.enabled=true", + "sentry.micrometer.poll-interval-millis=0", + ) + .run { + val registry = it.getBean(SentryMeterRegistry::class.java) + assertThat( + it.getBean(IScopes::class.java).options.metrics.ignoredMetrics?.map { it.filterString } + ) + .containsExactly("logback[.]events", "log4j2[.]events") + for (name in listOf("logback.events", "log4j2.events")) { + val counter = registry.counter(name) + counter.increment() + assertThat(counter.count()).isZero() + assertThat(registry.find(name).counter()).isNull() + } + for (name in listOf("business.operations", "logbackXevents", "log4j2Xevents")) { + val counter = registry.counter(name) + counter.increment() + assertThat(counter.count()).isEqualTo(1.0) + } + } + } + + @Test + fun `empty ignored metrics property disables logging defaults`() { + contextRunner + .withPropertyValues("sentry.micrometer.enabled=true", "sentry.metrics.ignored-metrics=") + .run { + assertThat(it.getBean(IScopes::class.java).options.metrics.ignoredMetrics).isEmpty() + val registry = it.getBean(SentryMeterRegistry::class.java) + for (name in listOf("logback.events", "log4j2.events")) { + val counter = registry.counter(name) + counter.increment() + assertThat(counter.count()).isEqualTo(1.0) + } + } + } + + @Test + fun `custom ignored metrics property replaces logging defaults`() { + contextRunner + .withPropertyValues( + "sentry.micrometer.enabled=true", + "sentry.metrics.ignored-metrics=business[.]operations", + ) + .run { + assertThat( + it.getBean(IScopes::class.java).options.metrics.ignoredMetrics?.map { it.filterString } + ) + .containsExactly("business[.]operations") + val registry = it.getBean(SentryMeterRegistry::class.java) + val business = registry.counter("business.operations") + business.increment() + assertThat(business.count()).isZero() + for (name in listOf("logback.events", "log4j2.events")) { + val counter = registry.counter(name) + counter.increment() + assertThat(counter.count()).isEqualTo(1.0) + } + } + } + + @Test + fun `options callback can append to logging defaults`() { + contextRunner + .withPropertyValues("sentry.micrometer.enabled=true") + .withBean( + Sentry.OptionsConfiguration::class.java, + { + Sentry.OptionsConfiguration { options -> + options.metrics.addIgnoredMetric("business[.]operations") + } + }, + ) + .run { + assertThat( + it.getBean(IScopes::class.java).options.metrics.ignoredMetrics?.map { it.filterString } + ) + .containsExactly("logback[.]events", "log4j2[.]events", "business[.]operations") + } + } + + @Test + fun `options callback can replace or clear logging defaults`() { + for (names in listOf(null, emptyList(), listOf("business[.]operations"))) { + contextRunner + .withPropertyValues("sentry.micrometer.enabled=true") + .withBean( + Sentry.OptionsConfiguration::class.java, + { + Sentry.OptionsConfiguration { options -> + options.metrics.setIgnoredMetrics(names) + } + }, + ) + .run { + assertThat( + it.getBean(IScopes::class.java).options.metrics.ignoredMetrics?.map { + it.filterString + } + ) + .isEqualTo(names) + val registry = it.getBean(SentryMeterRegistry::class.java) + for (name in listOf("logback.events", "log4j2.events")) { + val counter = registry.counter(name) + counter.increment() + assertThat(counter.count()).isEqualTo(1.0) + } + } } } @@ -85,6 +204,7 @@ class SentryMicrometerConfigurationTest { val metrics = mock() whenever(scopes.metrics()).thenReturn(metrics) val properties = SentryProperties().apply { micrometer.pollIntervalMillis = 0 } + whenever(scopes.options).thenReturn(properties) ApplicationContextRunner() .withUserConfiguration(SentryMicrometerConfiguration::class.java) @@ -157,6 +277,11 @@ class SentryMicrometerConfigurationTest { assertThat(sentry.get("requests").counter().count()).isEqualTo(1.0) assertThat(simple.get("requests").counter().count()).isEqualTo(1.0) + for (name in listOf("logback.events", "log4j2.events")) { + primary.counter(name).increment() + assertThat(sentry.find(name).counter()).isNull() + assertThat(simple.get(name).counter().count()).isEqualTo(1.0) + } } } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index e6b83beb21..2912e5b90d 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -504,6 +504,7 @@ public final class io/sentry/ExternalOptions { public fun getIgnoredCheckIns ()Ljava/util/List; public fun getIgnoredErrors ()Ljava/util/List; public fun getIgnoredExceptionsForType ()Ljava/util/Set; + public fun getIgnoredMetrics ()Ljava/util/List; public fun getIgnoredTransactions ()Ljava/util/List; public fun getInAppExcludes ()Ljava/util/List; public fun getInAppIncludes ()Ljava/util/List; @@ -563,6 +564,7 @@ public final class io/sentry/ExternalOptions { public fun setIdleTimeout (Ljava/lang/Long;)V public fun setIgnoredCheckIns (Ljava/util/List;)V public fun setIgnoredErrors (Ljava/util/List;)V + public fun setIgnoredMetrics (Ljava/util/List;)V public fun setIgnoredTransactions (Ljava/util/List;)V public fun setMaxRequestBodySize (Lio/sentry/SentryOptions$RequestSize;)V public fun setOrgId (Ljava/lang/String;)V @@ -4019,11 +4021,14 @@ public abstract interface class io/sentry/SentryOptions$Logs$BeforeSendLogCallba public final class io/sentry/SentryOptions$Metrics { public fun ()V + public fun addIgnoredMetric (Ljava/lang/String;)V public fun getBeforeSend ()Lio/sentry/SentryOptions$Metrics$BeforeSendMetricCallback; + public fun getIgnoredMetrics ()Ljava/util/List; public fun getMetricsBatchProcessorFactory ()Lio/sentry/metrics/IMetricsBatchProcessorFactory; public fun isEnabled ()Z public fun setBeforeSend (Lio/sentry/SentryOptions$Metrics$BeforeSendMetricCallback;)V public fun setEnabled (Z)V + public fun setIgnoredMetrics (Ljava/util/List;)V public fun setMetricsBatchProcessorFactory (Lio/sentry/metrics/IMetricsBatchProcessorFactory;)V } @@ -7999,6 +8004,10 @@ public final class io/sentry/util/MapObjectWriter : io/sentry/ObjectWriter { public fun value (Z)Lio/sentry/util/MapObjectWriter; } +public final class io/sentry/util/MetricsUtils { + public static fun isIgnored (Ljava/util/List;Ljava/lang/String;)Z +} + public final class io/sentry/util/Objects { public static fun equals (Ljava/lang/Object;Ljava/lang/Object;)Z public static fun hash ([Ljava/lang/Object;)I diff --git a/sentry/src/main/java/io/sentry/ExternalOptions.java b/sentry/src/main/java/io/sentry/ExternalOptions.java index 4e44ea422e..9659d55e7f 100644 --- a/sentry/src/main/java/io/sentry/ExternalOptions.java +++ b/sentry/src/main/java/io/sentry/ExternalOptions.java @@ -48,6 +48,7 @@ public final class ExternalOptions { private @Nullable Boolean enableSpotlight; private @Nullable Boolean enableLogs; private @Nullable Boolean enableMetrics; + private @Nullable List ignoredMetrics; private @Nullable String spotlightConnectionUrl; private @Nullable List ignoredCheckIns; @@ -179,6 +180,7 @@ public final class ExternalOptions { options.setEnableLogs(propertiesProvider.getBooleanProperty("logs.enabled")); options.setEnableMetrics(propertiesProvider.getBooleanProperty("metrics.enabled")); + options.setIgnoredMetrics(propertiesProvider.getListOrNull("metrics.ignored-metrics")); for (final String ignoredExceptionType : propertiesProvider.getList("ignored-exceptions-for-type")) { @@ -623,6 +625,14 @@ public void setEnableMetrics(final @Nullable Boolean enableMetrics) { return enableMetrics; } + public @Nullable List getIgnoredMetrics() { + return ignoredMetrics; + } + + public void setIgnoredMetrics(final @Nullable List ignoredMetrics) { + this.ignoredMetrics = ignoredMetrics; + } + public @Nullable Double getProfileSessionSampleRate() { return profileSessionSampleRate; } diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 1a1fbf738c..c72cbf2f6e 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -3786,6 +3786,9 @@ public void merge(final @NotNull ExternalOptions options) { if (options.isEnableMetrics() != null) { getMetrics().setEnabled(options.isEnableMetrics()); } + if (options.getIgnoredMetrics() != null) { + getMetrics().setIgnoredMetrics(options.getIgnoredMetrics()); + } if (options.getProfileSessionSampleRate() != null) { setProfileSessionSampleRate(options.getProfileSessionSampleRate()); @@ -4091,6 +4094,53 @@ public static final class Metrics { private @NotNull IMetricsBatchProcessorFactory metricsBatchProcessorFactory = new DefaultMetricsBatchProcessorFactory(); + private @Nullable List ignoredMetrics; + + /** + * Returns the filters applied to final metric names. No metrics are ignored by default. + * + * @return the configured name filters, or null if unset + */ + public @Nullable List getIgnoredMetrics() { + return ignoredMetrics; + } + + /** + * Sets metric names or regular expressions to ignore before processing metrics. Exact matches + * are case-insensitive; regular expressions must match the entire name. This applies to both + * manual and automatically captured metrics. + * + *

Micrometer also uses these filters when registering meters. Changing the list does not + * remove existing meters or reactivate meters previously denied registration. + * + * @param ignoredMetrics the names or regex patterns, or null to clear the filters + */ + public void setIgnoredMetrics(final @Nullable List ignoredMetrics) { + if (ignoredMetrics == null) { + this.ignoredMetrics = null; + } else { + final List filters = new ArrayList<>(); + for (final String name : ignoredMetrics) { + if (name != null && !name.isEmpty()) { + filters.add(new FilterString(name)); + } + } + this.ignoredMetrics = filters; + } + } + + /** + * Adds a metric name or regular expression to ignore. + * + * @param ignoredMetric the name or regex pattern + */ + public void addIgnoredMetric(final @NotNull String ignoredMetric) { + if (ignoredMetrics == null) { + ignoredMetrics = new ArrayList<>(); + } + ignoredMetrics.add(new FilterString(ignoredMetric)); + } + /** * Whether Sentry Metrics feature is enabled and metrics are sent to Sentry. * diff --git a/sentry/src/main/java/io/sentry/metrics/MetricsApi.java b/sentry/src/main/java/io/sentry/metrics/MetricsApi.java index cebcad9735..fdaad9ab51 100644 --- a/sentry/src/main/java/io/sentry/metrics/MetricsApi.java +++ b/sentry/src/main/java/io/sentry/metrics/MetricsApi.java @@ -17,6 +17,7 @@ import io.sentry.protocol.SdkVersion; import io.sentry.protocol.SentryId; import io.sentry.protocol.User; +import io.sentry.util.MetricsUtils; import io.sentry.util.Platform; import io.sentry.util.TracingUtils; import java.util.HashMap; @@ -127,7 +128,7 @@ private void captureMetrics( return; } - if (name == null) { + if (name == null || MetricsUtils.isIgnored(options.getMetrics().getIgnoredMetrics(), name)) { return; } diff --git a/sentry/src/main/java/io/sentry/util/MetricsUtils.java b/sentry/src/main/java/io/sentry/util/MetricsUtils.java new file mode 100644 index 0000000000..c517188af7 --- /dev/null +++ b/sentry/src/main/java/io/sentry/util/MetricsUtils.java @@ -0,0 +1,30 @@ +package io.sentry.util; + +import io.sentry.FilterString; +import java.util.List; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class MetricsUtils { + private MetricsUtils() {} + + /** Checks a final metric name against case-insensitive exact matches and full regex matches. */ + public static boolean isIgnored( + final @Nullable List ignoredMetrics, final @Nullable String name) { + if (name == null || ignoredMetrics == null || ignoredMetrics.isEmpty()) { + return false; + } + for (final FilterString ignoredMetric : ignoredMetrics) { + if (ignoredMetric.getFilterString().equalsIgnoreCase(name)) { + return true; + } + } + for (final FilterString ignoredMetric : ignoredMetrics) { + if (ignoredMetric.matches(name)) { + return true; + } + } + return false; + } +} diff --git a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt index fee707d31f..5e15a2668f 100644 --- a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt @@ -1,5 +1,6 @@ package io.sentry +import com.google.common.truth.Truth.assertThat import io.sentry.config.PropertiesProviderFactory import java.lang.RuntimeException import kotlin.test.Test @@ -15,6 +16,28 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.verify class ExternalOptionsTest { + @Test + fun `reads ignored metrics from external properties`() { + withPropertiesFile("metrics.ignored-metrics=logback.events,jvm[.].*") { + assertThat(it.ignoredMetrics).containsExactly("logback.events", "jvm[.].*") + } + } + + @Test + fun `ignored metrics remain unset when external property is absent`() { + withPropertiesFile { assertThat(it.ignoredMetrics).isNull() } + } + + @Test + fun `empty external ignored metrics clear configured filters`() { + withPropertiesFile("metrics.ignored-metrics=") { external -> + val options = SentryOptions() + options.metrics.addIgnoredMetric("logback.events") + options.merge(external) + assertThat(options.metrics.ignoredMetrics).isEmpty() + } + } + @Test fun `creates options with proxy using external properties`() { withPropertiesFile( diff --git a/sentry/src/test/java/io/sentry/ScopesTest.kt b/sentry/src/test/java/io/sentry/ScopesTest.kt index d1cb38c649..8fbc4ff095 100644 --- a/sentry/src/test/java/io/sentry/ScopesTest.kt +++ b/sentry/src/test/java/io/sentry/ScopesTest.kt @@ -3306,6 +3306,53 @@ class ScopesTest { // region metrics + @Test + fun `ignored metric names are dropped for all metric types before timestamps and capture`() { + val (sut, client) = getEnabledScopes { it.metrics.setIgnoredMetrics(listOf("ignored[.].*")) } + val dateProvider = mock() + sut.options.setDateProvider(dateProvider) + + sut.metrics().count("ignored.counter") + sut.metrics().distribution("ignored.distribution", 1.0) + sut.metrics().gauge("ignored.gauge", 1.0) + + verify(dateProvider, never()).now() + verify(client, never()).captureMetric(any(), anyOrNull(), anyOrNull()) + } + + @Test + fun `ignored metric names apply to both manual and integration origins`() { + val (sut, client) = getEnabledScopes { it.metrics.addIgnoredMetric("logback.events") } + sut.metrics().count("logback.events") + sut + .metrics() + .count( + "LOGBACK.EVENTS", + 1.0, + null, + SentryMetricsParameters.create(null, null).apply { origin = "auto.metrics.micrometer" }, + ) + verify(client, never()).captureMetric(any(), anyOrNull(), anyOrNull()) + } + + @Test + fun `ignored metric rules do not drop other names`() { + val (sut, client) = getEnabledScopes { it.metrics.addIgnoredMetric("logback.events") } + sut.metrics().count("business.operations") + verify(client).captureMetric(any(), anyOrNull(), anyOrNull()) + } + + @Test + fun `changing ignored metric rules affects subsequent captures`() { + val (sut, client) = getEnabledScopes() + sut.metrics().count("metric") + sut.options.metrics.addIgnoredMetric("metric") + sut.metrics().count("metric") + sut.options.metrics.setIgnoredMetrics(null) + sut.metrics().count("metric") + verify(client, times(2)).captureMetric(any(), anyOrNull(), anyOrNull()) + } + @Test fun `when captureMetric is called on disabled client, do nothing`() { val (sut, mockClient) = getEnabledScopes() diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index 64482b5d5d..1a63dcecc2 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -1,5 +1,6 @@ package io.sentry +import com.google.common.truth.Truth.assertThat import io.sentry.SentryOptions.RequestSize import io.sentry.logger.ILoggerBatchProcessorFactory import io.sentry.util.StringUtils @@ -20,6 +21,48 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.verify class SentryOptionsTest { + @Test + fun `no metrics are ignored by default`() { + assertThat(SentryOptions().metrics.ignoredMetrics).isNull() + } + + @Test + fun `ignored metrics can be added replaced and cleared`() { + val metrics = SentryOptions().metrics + metrics.addIgnoredMetric("logback.events") + assertThat(metrics.ignoredMetrics).containsExactly(FilterString("logback.events")) + val names = mutableListOf("jvm[.].*", "", "metric[") + metrics.setIgnoredMetrics(names) + names.clear() + assertThat(metrics.ignoredMetrics) + .containsExactly(FilterString("jvm[.].*"), FilterString("metric[")) + metrics.setIgnoredMetrics(emptyList()) + assertThat(metrics.ignoredMetrics).isEmpty() + metrics.setIgnoredMetrics(null) + assertThat(metrics.ignoredMetrics).isNull() + } + + @Test + fun `external ignored metrics replace existing filters and are copied`() { + val options = SentryOptions() + options.metrics.addIgnoredMetric("old") + val names = mutableListOf("logback.events", "jvm[.].*") + options.merge(ExternalOptions().apply { ignoredMetrics = names }) + names.clear() + assertThat(options.metrics.ignoredMetrics) + .containsExactly(FilterString("logback.events"), FilterString("jvm[.].*")) + } + + @Test + fun `unset external ignored metrics preserve filters but empty list clears them`() { + val options = SentryOptions() + options.metrics.addIgnoredMetric("logback.events") + options.merge(ExternalOptions()) + assertThat(options.metrics.ignoredMetrics).containsExactly(FilterString("logback.events")) + options.merge(ExternalOptions().apply { ignoredMetrics = emptyList() }) + assertThat(options.metrics.ignoredMetrics).isEmpty() + } + @Test fun `when options is initialized, logger is not null`() { assertNotNull(SentryOptions().logger) diff --git a/sentry/src/test/java/io/sentry/util/MetricsUtilsTest.kt b/sentry/src/test/java/io/sentry/util/MetricsUtilsTest.kt new file mode 100644 index 0000000000..83f0fad08f --- /dev/null +++ b/sentry/src/test/java/io/sentry/util/MetricsUtilsTest.kt @@ -0,0 +1,48 @@ +package io.sentry.util + +import com.google.common.truth.Truth.assertThat +import io.sentry.FilterString +import kotlin.test.Test + +class MetricsUtilsTest { + @Test + fun `no filters or no name does not ignore metrics`() { + assertThat(MetricsUtils.isIgnored(null, "logback.events")).isFalse() + assertThat(MetricsUtils.isIgnored(emptyList(), "logback.events")).isFalse() + assertThat(MetricsUtils.isIgnored(listOf(FilterString(".*")), null)).isFalse() + } + + @Test + fun `exact matches are case insensitive`() { + assertThat(MetricsUtils.isIgnored(listOf(FilterString("LOGBACK.EVENTS")), "logback.events")) + .isTrue() + } + + @Test + fun `regex matches the full name and respects regex case sensitivity`() { + val filters = listOf(FilterString("logback[.].*")) + assertThat(MetricsUtils.isIgnored(filters, "logback.events")).isTrue() + assertThat(MetricsUtils.isIgnored(filters, "prefix.logback.events")).isFalse() + assertThat(MetricsUtils.isIgnored(filters, "LOGBACK.events")).isFalse() + assertThat(MetricsUtils.isIgnored(listOf(FilterString("(?i)logback[.].*")), "LOGBACK.events")) + .isTrue() + } + + @Test + fun `invalid regex still supports exact matches without throwing`() { + val filters = listOf(FilterString("metric[")) + assertThat(MetricsUtils.isIgnored(filters, "METRIC[")).isTrue() + assertThat(MetricsUtils.isIgnored(filters, "other")).isFalse() + } + + @Test + fun `decisions are not shared between option sets or stale after changes`() { + val filters = mutableListOf(FilterString("ignored")) + assertThat(MetricsUtils.isIgnored(filters, "ignored")).isTrue() + assertThat(MetricsUtils.isIgnored(listOf(FilterString("other")), "ignored")).isFalse() + filters.clear() + filters.add(FilterString("allowed")) + assertThat(MetricsUtils.isIgnored(filters, "ignored")).isFalse() + assertThat(MetricsUtils.isIgnored(filters, "allowed")).isTrue() + } +}