diff --git a/sentry-micrometer/README.md b/sentry-micrometer/README.md index a4e13d29b8..018f2056a4 100644 --- a/sentry-micrometer/README.md +++ b/sentry-micrometer/README.md @@ -1,6 +1,7 @@ # sentry-micrometer -This module forwards Micrometer metrics to Sentry. +This module forwards Micrometer metrics to Sentry while preserving normal Micrometer registry behavior. +It can be used alongside Prometheus, Datadog, OTLP, and other registries. ## Install @@ -19,3 +20,79 @@ Create a `SentryMeterRegistry` and add it to Micrometer: SentryMeterRegistry sentryRegistry = new SentryMeterRegistry(); Metrics.addRegistry(sentryRegistry); ``` + +## Metric mappings + +Active meters are forwarded when they are recorded: + +| Micrometer meter | Sentry metric | +| --- | --- | +| `Counter` | Counter increment | +| `Timer` | Distribution in milliseconds | +| `DistributionSummary` | Distribution | + +Passive meters are polled every 60 seconds by default: + +| Micrometer meter | Sentry metric | +| --- | --- | +| `Gauge` | Gauge | +| `TimeGauge` | Gauge in milliseconds | +| `LongTaskTimer` active tasks | `${name}.active` gauge | +| `LongTaskTimer` active duration | `${name}.duration` gauge in milliseconds | +| `FunctionCounter` | Positive counter delta | + +The first successful finite `FunctionCounter` poll establishes its baseline and emits nothing. +Later positive deltas are sent. A decreasing value is treated as a reset and establishes a new +baseline. + +Unsupported custom meters remain readable through Micrometer but are not exported to Sentry. + +## Polling + +Pass the interval in milliseconds to configure passive polling: + +```java +SentryMeterRegistry sentryRegistry = new SentryMeterRegistry(30_000); +``` + +Use zero to disable passive polling while keeping active meter forwarding enabled: + +```java +SentryMeterRegistry sentryRegistry = new SentryMeterRegistry(0); +``` + +Each registry with polling enabled owns one daemon scheduler thread. A slow passive-meter callback +delays other passive meters in that registry. Callback failures and non-finite values are skipped +without stopping later meters from being polled. + +Active metrics use the Sentry scope and trace context present when they are recorded. Passive +metrics use the context available on the polling thread because Micrometer does not retain the +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: + +```java +sentryRegistry.config().meterFilter(MeterFilter.denyNameStartsWith("jvm.buffer")); +``` + +Micrometer tags are application-provided metric data and are forwarded as supplied. Use a +registry-local filter or Sentry's metrics `beforeSend` callback to remove sensitive or +high-cardinality attributes. + +## Shutdown + +Remove the registry from its owning global or composite registry, close it, and then close Sentry: + +```java +Metrics.removeRegistry(sentryRegistry); +sentryRegistry.close(); +Sentry.close(); +``` + +Closing the registry stops future polling without invoking passive callbacks or waiting for a +blocked callback to return. Metrics already accepted by Sentry remain available to the normal +Sentry flush and shutdown lifecycle. diff --git a/sentry-micrometer/api/sentry-micrometer.api b/sentry-micrometer/api/sentry-micrometer.api index f8c4d1328e..6fd95e1b4a 100644 --- a/sentry-micrometer/api/sentry-micrometer.api +++ b/sentry-micrometer/api/sentry-micrometer.api @@ -5,5 +5,7 @@ public final class io/sentry/micrometer/BuildConfig { public final class io/sentry/micrometer/SentryMeterRegistry : io/micrometer/core/instrument/MeterRegistry { public fun ()V + public fun (J)V + public fun close ()V } diff --git a/sentry-micrometer/src/main/java/io/sentry/micrometer/SentryFunctionCounter.java b/sentry-micrometer/src/main/java/io/sentry/micrometer/SentryFunctionCounter.java new file mode 100644 index 0000000000..4a0b5400e5 --- /dev/null +++ b/sentry-micrometer/src/main/java/io/sentry/micrometer/SentryFunctionCounter.java @@ -0,0 +1,48 @@ +package io.sentry.micrometer; + +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.cumulative.CumulativeFunctionCounter; +import java.util.function.ToDoubleFunction; +import org.jetbrains.annotations.NotNull; + +final class SentryFunctionCounter extends CumulativeFunctionCounter { + private final @NotNull SentryMeterRegistry registry; + private final @NotNull SentryMetricInfo metricInfo; + private volatile boolean removed; + private boolean initialized; + private double previousValue; + + SentryFunctionCounter( + final @NotNull Meter.Id id, + final @NotNull T obj, + final @NotNull ToDoubleFunction countFunction, + final @NotNull SentryMeterRegistry registry, + final @NotNull SentryMetricInfo metricInfo) { + super(id, obj, countFunction); + this.registry = registry; + this.metricInfo = metricInfo; + } + + void poll() { + final double currentValue = count(); + if (!Double.isFinite(currentValue) || removed || registry.isClosed()) { + return; + } + + if (!initialized || currentValue < previousValue) { + initialized = true; + previousValue = currentValue; + return; + } + + final double delta = currentValue - previousValue; + previousValue = currentValue; + if (delta > 0.0 && !removed) { + registry.captureCounter(metricInfo, delta); + } + } + + void markRemoved() { + removed = true; + } +} 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 a8f3d0898b..068fed61f7 100644 --- a/sentry-micrometer/src/main/java/io/sentry/micrometer/SentryMeterRegistry.java +++ b/sentry-micrometer/src/main/java/io/sentry/micrometer/SentryMeterRegistry.java @@ -13,21 +13,26 @@ import io.micrometer.core.instrument.Meter; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.TimeGauge; import io.micrometer.core.instrument.Timer; -import io.micrometer.core.instrument.cumulative.CumulativeFunctionCounter; import io.micrometer.core.instrument.cumulative.CumulativeFunctionTimer; import io.micrometer.core.instrument.distribution.DistributionStatisticConfig; import io.micrometer.core.instrument.distribution.pause.PauseDetector; import io.micrometer.core.instrument.internal.DefaultGauge; import io.micrometer.core.instrument.internal.DefaultLongTaskTimer; import io.micrometer.core.instrument.internal.DefaultMeter; +import io.micrometer.core.instrument.util.NamedThreadFactory; import io.sentry.Sentry; import io.sentry.SentryAttributes; import io.sentry.SentryIntegrationPackageStorage; import io.sentry.SentryLevel; import io.sentry.metrics.MetricsUnit; +import io.sentry.util.ExceptionUtils; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.function.ToDoubleFunction; import java.util.function.ToLongFunction; @@ -37,16 +42,50 @@ /** A Micrometer registry that forwards metrics to Sentry. */ public final class SentryMeterRegistry extends MeterRegistry { private static final @NotNull String INTEGRATION_NAME = "Micrometer"; + private static final long DEFAULT_POLL_INTERVAL_MILLIS = 60_000; + + private final @Nullable ScheduledExecutorService scheduler; + private final @Nullable ScheduledFuture pollingTask; static { SentryIntegrationPackageStorage.getInstance() .addPackage("maven:io.sentry:sentry-micrometer", BuildConfig.VERSION_NAME); } - /** Creates a registry that forwards active meter observations to Sentry. */ + /** Creates a registry that polls passive meters every 60 seconds. */ public SentryMeterRegistry() { - super(Clock.SYSTEM); + this(DEFAULT_POLL_INTERVAL_MILLIS); + } + + /** + * Creates a registry with the given passive meter polling interval in milliseconds. + * + *

A zero interval disables passive meter polling. Negative intervals are not supported. + */ + public SentryMeterRegistry(final long pollIntervalMillis) { + this(pollIntervalMillis, Clock.SYSTEM, createScheduler(pollIntervalMillis)); + } + + SentryMeterRegistry( + final long pollIntervalMillis, + final @NotNull Clock clock, + final @Nullable ScheduledExecutorService scheduler) { + super(clock); + validatePollInterval(pollIntervalMillis); + if (pollIntervalMillis > 0 && scheduler == null) { + throw new IllegalArgumentException( + "A scheduler is required when passive polling is enabled."); + } + this.scheduler = pollIntervalMillis == 0 ? null : scheduler; + config().onMeterRemoved(this::onMeterRemoved); addIntegrationToSdkVersion(INTEGRATION_NAME); + if (this.scheduler == null) { + pollingTask = null; + } else { + pollingTask = + this.scheduler.scheduleAtFixedRate( + this::pollMeters, pollIntervalMillis, pollIntervalMillis, TimeUnit.MILLISECONDS); + } } @Override @@ -110,7 +149,7 @@ public SentryMeterRegistry() { final @NotNull Meter.Id id, final @NotNull T obj, final @NotNull ToDoubleFunction countFunction) { - return new CumulativeFunctionCounter<>(id, obj, countFunction); + return new SentryFunctionCounter<>(id, obj, countFunction, this, createMetricInfo(id)); } @Override @@ -157,16 +196,127 @@ void captureDistribution(final @NotNull SentryMetricInfo metricInfo, final doubl metricInfo.getName(), value, metricInfo.getUnit(), metricInfo.createParameters()); } + void captureGauge(final @NotNull SentryMetricInfo metricInfo, final double value) { + if (isClosed()) { + return; + } + Sentry.getCurrentScopes() + .metrics() + .gauge(metricInfo.getName(), value, metricInfo.getUnit(), metricInfo.createParameters()); + } + + void pollMeters() { + if (isClosed()) { + return; + } + for (final @NotNull Meter meter : getMeters()) { + if (isClosed()) { + return; + } + try { + publishPassiveMeter(meter); + } catch (Throwable throwable) { + ExceptionUtils.rethrowIfFatal(throwable); + Sentry.getCurrentScopes() + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + throwable, + "Failed to publish Micrometer meter %s to Sentry.", + meter.getId().getName()); + } + } + } + + private void publishPassiveMeter(final @NotNull Meter meter) { + if (meter instanceof TimeGauge) { + publishTimeGauge((TimeGauge) meter); + } else if (meter instanceof Gauge) { + publishGauge((Gauge) meter); + } else if (meter instanceof LongTaskTimer) { + publishLongTaskTimer((LongTaskTimer) meter); + } else if (meter instanceof SentryFunctionCounter) { + ((SentryFunctionCounter) meter).poll(); + } + } + + private void publishGauge(final @NotNull Gauge gauge) { + final double value = gauge.value(); + if (Double.isFinite(value)) { + captureGauge(createMetricInfo(gauge.getId()), value); + } + } + + private void publishTimeGauge(final @NotNull TimeGauge gauge) { + final double value = gauge.value(TimeUnit.MILLISECONDS); + if (Double.isFinite(value)) { + captureGauge(createMetricInfo(gauge.getId(), MetricsUnit.Duration.MILLISECOND), value); + } + } + + private void publishLongTaskTimer(final @NotNull LongTaskTimer timer) { + final @NotNull SentryMetricInfo activeMetric = createMetricInfo(timer.getId(), ".active", null); + captureGauge(activeMetric, timer.activeTasks()); + + final double duration = timer.duration(TimeUnit.MILLISECONDS); + if (Double.isFinite(duration)) { + captureGauge( + createMetricInfo(timer.getId(), ".duration", MetricsUnit.Duration.MILLISECOND), duration); + } + } + + private void onMeterRemoved(final @NotNull Meter meter) { + if (meter instanceof SentryFunctionCounter) { + ((SentryFunctionCounter) meter).markRemoved(); + } + } + private @NotNull SentryMetricInfo createMetricInfo(final @NotNull Meter.Id id) { return createMetricInfo(id, SentryMetricUnit.normalize(id.getBaseUnit())); } private @NotNull SentryMetricInfo createMetricInfo( final @NotNull Meter.Id id, final @Nullable String unit) { + return createMetricInfo(id, "", unit); + } + + private @NotNull SentryMetricInfo createMetricInfo( + final @NotNull Meter.Id id, final @NotNull String suffix, final @Nullable String unit) { final @NotNull Map attributes = new HashMap<>(); for (final @NotNull Tag tag : getConventionTags(id)) { attributes.put(tag.getKey(), tag.getValue()); } - return new SentryMetricInfo(getConventionName(id), unit, SentryAttributes.fromMap(attributes)); + return new SentryMetricInfo( + getConventionName(id) + suffix, unit, SentryAttributes.fromMap(attributes)); + } + + @Override + public void close() { + if (isClosed()) { + return; + } + super.close(); + if (pollingTask != null) { + pollingTask.cancel(true); + } + if (scheduler != null) { + scheduler.shutdownNow(); + } + } + + private static @Nullable ScheduledExecutorService createScheduler(final long pollIntervalMillis) { + validatePollInterval(pollIntervalMillis); + if (pollIntervalMillis == 0) { + return null; + } + return Executors.newSingleThreadScheduledExecutor( + new NamedThreadFactory("sentry-micrometer-poller")); + } + + private static void validatePollInterval(final long pollIntervalMillis) { + if (pollIntervalMillis < 0) { + throw new IllegalArgumentException("The passive meter polling interval cannot be negative."); + } } } diff --git a/sentry-micrometer/src/test/kotlin/io/sentry/micrometer/SentryMeterRegistryPollingTest.kt b/sentry-micrometer/src/test/kotlin/io/sentry/micrometer/SentryMeterRegistryPollingTest.kt new file mode 100644 index 0000000000..b6cdd36ea0 --- /dev/null +++ b/sentry-micrometer/src/test/kotlin/io/sentry/micrometer/SentryMeterRegistryPollingTest.kt @@ -0,0 +1,424 @@ +package io.sentry.micrometer + +import com.google.common.truth.Truth.assertThat +import io.micrometer.core.instrument.Clock +import io.micrometer.core.instrument.FunctionCounter +import io.micrometer.core.instrument.Gauge +import io.micrometer.core.instrument.LongTaskTimer +import io.micrometer.core.instrument.MockClock +import io.micrometer.core.instrument.TimeGauge +import io.sentry.IScopes +import io.sentry.Sentry +import io.sentry.SentryOptions +import io.sentry.metrics.IMetricsApi +import io.sentry.metrics.MetricsUnit +import io.sentry.metrics.SentryMetricsParameters +import io.sentry.test.initForTest +import java.time.Duration +import java.util.concurrent.CountDownLatch +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertFailsWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +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 SentryMeterRegistryPollingTest { + private val registries = mutableListOf() + + @BeforeTest + fun setUp() { + initForTest { it.dsn = "https://key@sentry.io/proj" } + } + + @AfterTest + fun tearDown() { + registries.forEach(SentryMeterRegistry::close) + Sentry.close() + } + + @Test + fun `schedules fixed-rate polling with the configured interval`() { + val metrics = installMetricsApi() + val scheduler = mock() + val task = mock>() + whenever( + scheduler.scheduleAtFixedRate( + any(), + eq(2500L), + eq(2500L), + eq(TimeUnit.MILLISECONDS), + ) + ) + .thenReturn(task) + + val registry = track(SentryMeterRegistry(2500, Clock.SYSTEM, scheduler)) + val value = AtomicReference(4.5) + Gauge.builder("scheduled", value) { it.get() }.strongReference(true).register(registry) + val scheduledPoll = argumentCaptor() + verify(scheduler) + .scheduleAtFixedRate( + scheduledPoll.capture(), + eq(2500L), + eq(2500L), + eq(TimeUnit.MILLISECONDS), + ) + + scheduledPoll.firstValue.run() + registry.close() + registry.close() + + verify(metrics).gauge(eq("scheduled"), eq(4.5), anyOrNull(), any()) + verify(task).cancel(true) + verify(scheduler).shutdownNow() + } + + @Test + fun `zero interval disables polling and active meters still forward`() { + val metrics = installMetricsApi() + val registry = track(SentryMeterRegistry(0)) + val callbackInvocations = AtomicInteger() + Gauge.builder("queue.depth", callbackInvocations) { + callbackInvocations.incrementAndGet().toDouble() + } + .register(registry) + + registry.counter("requests").increment() + + assertThat(callbackInvocations.get()).isEqualTo(0) + verify(metrics).count(eq("requests"), eq(1.0), anyOrNull(), any()) + verify(metrics, never()).gauge(any(), anyOrNull(), anyOrNull(), any()) + } + + @Test + fun `zero interval ignores an available scheduler`() { + val scheduler = mock() + track(SentryMeterRegistry(0, Clock.SYSTEM, scheduler)) + + verifyNoInteractions(scheduler) + } + + @Test + fun `negative intervals are rejected`() { + assertFailsWith { SentryMeterRegistry(-1) } + } + + @Test + fun `polls gauges with converted metadata`() { + val metrics = installMetricsApi() + val registry = pollingRegistry() + val value = AtomicReference(4.5) + Gauge.builder("queue.depth", value) { it.get() } + .baseUnit("bytes") + .tag("queue.name", "primary") + .strongReference(true) + .register(registry) + + registry.pollMeters() + + val parameters = argumentCaptor() + verify(metrics) + .gauge( + eq("queue_depth"), + eq(4.5), + eq(MetricsUnit.Information.BYTE), + parameters.capture(), + ) + assertThat(parameters.firstValue.origin).isEqualTo("auto.metrics.micrometer") + assertThat(parameters.firstValue.attributes!!.attributes["queue_name"]!!.value) + .isEqualTo("primary") + } + + @Test + fun `each passive poll resolves the current scopes metrics API`() { + val first = mock() + val second = mock() + val registry = pollingRegistry() + val value = AtomicReference(4.5) + Gauge.builder("queue.depth", value) { it.get() }.strongReference(true).register(registry) + + installMetricsApi(first) + registry.pollMeters() + installMetricsApi(second) + value.set(5.5) + registry.pollMeters() + + verify(first).gauge(eq("queue_depth"), eq(4.5), anyOrNull(), any()) + verify(second).gauge(eq("queue_depth"), eq(5.5), anyOrNull(), any()) + } + + @Test + fun `polls time gauges as milliseconds`() { + val metrics = installMetricsApi() + val registry = pollingRegistry() + val seconds = AtomicReference(1.5) + TimeGauge.builder("job.duration", seconds, TimeUnit.SECONDS) { it.get() } + .strongReference(true) + .register(registry) + + registry.pollMeters() + + verify(metrics) + .gauge( + eq("job_duration"), + eq(1500.0), + eq(MetricsUnit.Duration.MILLISECOND), + any(), + ) + } + + @Test + fun `polls long task timer active tasks and duration`() { + val metrics = installMetricsApi() + val clock = MockClock() + val registry = pollingRegistry(clock) + val timer = LongTaskTimer.builder("background.jobs").register(registry) + val sample = timer.start() + clock.add(Duration.ofMillis(1250)) + + registry.pollMeters() + + verify(metrics).gauge(eq("background_jobs.active"), eq(1.0), anyOrNull(), any()) + verify(metrics) + .gauge( + eq("background_jobs.duration"), + eq(1250.0), + eq(MetricsUnit.Duration.MILLISECOND), + any(), + ) + sample.stop() + } + + @Test + fun `skips non-finite passive values`() { + val metrics = installMetricsApi() + val registry = pollingRegistry() + val nan = AtomicReference(Double.NaN) + val infinity = AtomicReference(Double.POSITIVE_INFINITY) + Gauge.builder("nan", nan) { it.get() }.strongReference(true).register(registry) + TimeGauge.builder("infinity", infinity, TimeUnit.SECONDS) { it.get() } + .strongReference(true) + .register(registry) + + registry.pollMeters() + + verifyNoInteractions(metrics) + } + + @Test + fun `one failing function callback does not suppress other passive meters`() { + val metrics = installMetricsApi() + val registry = pollingRegistry() + val badValue = AtomicReference(1.0) + val goodValue = AtomicReference(2.0) + FunctionCounter.builder("bad", badValue) { throw IllegalStateException("failed") } + .register(registry) + Gauge.builder("good", goodValue) { it.get() }.strongReference(true).register(registry) + + registry.pollMeters() + + verify(metrics).gauge(eq("good"), eq(2.0), anyOrNull(), any()) + verify(metrics, never()).count(eq("bad"), anyOrNull(), anyOrNull(), any()) + } + + @Test + fun `fatal function counter failures are rethrown`() { + val registry = pollingRegistry() + val value = AtomicReference(1.0) + FunctionCounter.builder("fatal", value) { throw OutOfMemoryError("fatal") }.register(registry) + + assertFailsWith { registry.pollMeters() } + } + + @Test + fun `function counter establishes baseline then emits positive deltas`() { + val metrics = installMetricsApi() + val registry = pollingRegistry() + val value = AtomicReference(10.0) + FunctionCounter.builder("completed.jobs", value) { it.get() } + .baseUnit("jobs") + .register(registry) + + registry.pollMeters() + registry.pollMeters() + verifyNoInteractions(metrics) + + value.set(13.5) + registry.pollMeters() + + verify(metrics).count(eq("completed_jobs"), eq(3.5), eq("jobs"), any()) + } + + @Test + fun `function counter callbacks are not invoked during registration`() { + val registry = pollingRegistry() + val value = AtomicReference(10.0) + val callbackInvocations = AtomicInteger() + + FunctionCounter.builder("completed", value) { + callbackInvocations.incrementAndGet() + it.get() + } + .register(registry) + + assertThat(callbackInvocations.get()).isEqualTo(0) + registry.pollMeters() + assertThat(callbackInvocations.get()).isEqualTo(1) + } + + @Test + fun `function counter resets establish a new baseline`() { + val metrics = installMetricsApi() + val registry = pollingRegistry() + val value = AtomicReference(10.0) + FunctionCounter.builder("completed", value) { it.get() }.register(registry) + + registry.pollMeters() + value.set(7.0) + registry.pollMeters() + value.set(9.0) + registry.pollMeters() + + verify(metrics).count(eq("completed"), eq(2.0), anyOrNull(), any()) + verify(metrics, times(1)).count(any(), anyOrNull(), anyOrNull(), any()) + } + + @Test + fun `function counter waits for first successful finite baseline`() { + val metrics = installMetricsApi() + val registry = pollingRegistry() + val fail = AtomicBoolean(true) + val value = AtomicReference(Double.NaN) + FunctionCounter.builder("completed", value) { + if (fail.get()) { + throw IllegalStateException("failed") + } + it.get() + } + .register(registry) + + registry.pollMeters() + fail.set(false) + registry.pollMeters() + value.set(10.0) + registry.pollMeters() + verifyNoInteractions(metrics) + + value.set(12.0) + registry.pollMeters() + + verify(metrics).count(eq("completed"), eq(2.0), anyOrNull(), any()) + } + + @Test + fun `removing and re-registering a function counter clears its baseline`() { + val metrics = installMetricsApi() + val registry = pollingRegistry() + val firstValue = AtomicReference(10.0) + val first = FunctionCounter.builder("completed", firstValue) { it.get() }.register(registry) + registry.pollMeters() + firstValue.set(12.0) + registry.pollMeters() + registry.remove(first) + + val secondValue = AtomicReference(100.0) + FunctionCounter.builder("completed", secondValue) { it.get() }.register(registry) + registry.pollMeters() + secondValue.set(105.0) + registry.pollMeters() + + verify(metrics).count(eq("completed"), eq(2.0), anyOrNull(), any()) + verify(metrics).count(eq("completed"), eq(5.0), anyOrNull(), any()) + verify(metrics, times(2)).count(any(), anyOrNull(), anyOrNull(), any()) + } + + @Test + fun `close does not wait for a blocked callback and in-flight poll does not emit`() { + val metrics = installMetricsApi() + val registry = pollingRegistry() + val callbackStarted = CountDownLatch(1) + val releaseCallback = CountDownLatch(1) + val pollFinished = CountDownLatch(1) + val closeFinished = CountDownLatch(1) + val value = AtomicReference(1.0) + Gauge.builder("blocked", value) { + callbackStarted.countDown() + releaseCallback.await() + it.get() + } + .strongReference(true) + .register(registry) + + val pollThread = Thread { + registry.pollMeters() + pollFinished.countDown() + } + pollThread.start() + try { + assertThat(callbackStarted.await(1, TimeUnit.SECONDS)).isTrue() + + Thread { + registry.close() + closeFinished.countDown() + } + .start() + assertThat(closeFinished.await(1, TimeUnit.SECONDS)).isTrue() + } finally { + releaseCallback.countDown() + pollThread.join(1000) + } + + assertThat(pollFinished.count).isEqualTo(0) + verifyNoInteractions(metrics) + } + + @Test + fun `closed registry does not invoke passive callbacks`() { + val registry = pollingRegistry() + val callbackInvocations = AtomicInteger() + Gauge.builder("gauge", callbackInvocations) { + callbackInvocations.incrementAndGet().toDouble() + } + .strongReference(true) + .register(registry) + + registry.close() + registry.pollMeters() + + assertThat(callbackInvocations.get()).isEqualTo(0) + } + + private fun pollingRegistry(clock: Clock = Clock.SYSTEM): SentryMeterRegistry { + val scheduler = mock() + val task = mock>() + whenever(scheduler.scheduleAtFixedRate(any(), any(), any(), any())).thenReturn(task) + return track(SentryMeterRegistry(60_000, clock, scheduler)) + } + + private fun track(registry: SentryMeterRegistry): SentryMeterRegistry { + registries.add(registry) + return registry + } + + private fun installMetricsApi(metrics: IMetricsApi = mock()): IMetricsApi { + val scopes = mock() + whenever(scopes.metrics()).thenReturn(metrics) + whenever(scopes.options).thenReturn(SentryOptions()) + Sentry.setCurrentScopes(scopes) + return metrics + } +} 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 44df982552..01910a863b 100644 --- a/sentry-micrometer/src/test/kotlin/io/sentry/micrometer/SentryMeterRegistryTest.kt +++ b/sentry-micrometer/src/test/kotlin/io/sentry/micrometer/SentryMeterRegistryTest.kt @@ -41,6 +41,8 @@ import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever class SentryMeterRegistryTest { + private val registries = mutableListOf() + @BeforeTest fun setUp() { initForTest { it.dsn = "https://key@sentry.io/proj" } @@ -48,13 +50,14 @@ class SentryMeterRegistryTest { @AfterTest fun tearDown() { + registries.forEach(SentryMeterRegistry::close) Sentry.close() } @Test fun `counter forwards positive finite increments with converted metadata`() { val metrics = installMetricsApi() - val registry = SentryMeterRegistry() + val registry = registry() val counter = Counter.builder("request.count") .tags("http.method", "GET") @@ -74,7 +77,7 @@ class SentryMeterRegistryTest { @Test fun `uses the configured naming convention for names tags and values`() { val metrics = installMetricsApi() - val registry = SentryMeterRegistry() + val registry = registry() registry .config() .namingConvention( @@ -98,7 +101,7 @@ class SentryMeterRegistryTest { @Test fun `counter does not forward zero negative or non-finite increments`() { val metrics = installMetricsApi() - val registry = SentryMeterRegistry() + val registry = registry() val counter = registry.counter("counter") counter.increment(0.0) @@ -112,7 +115,7 @@ class SentryMeterRegistryTest { @Test fun `timer forwards accepted durations as millisecond distributions`() { val metrics = installMetricsApi() - val registry = SentryMeterRegistry() + val registry = registry() val timer = Timer.builder("request.duration").register(registry) timer.record(1500, TimeUnit.MICROSECONDS) @@ -133,7 +136,7 @@ class SentryMeterRegistryTest { @Test fun `distribution summary forwards scaled finite observations and remains readable`() { val metrics = installMetricsApi() - val registry = SentryMeterRegistry() + val registry = registry() val summary = DistributionSummary.builder("payload.size").baseUnit("bytes").scale(2.0).register(registry) @@ -151,7 +154,7 @@ class SentryMeterRegistryTest { fun `each recording resolves the current scopes metrics API`() { val first = mock() val second = mock() - val registry = SentryMeterRegistry() + val registry = registry() val counter = registry.counter("counter") installMetricsApi(first) @@ -166,7 +169,7 @@ class SentryMeterRegistryTest { @Test fun `registry created before Sentry init forwards after initialization`() { Sentry.close() - val registry = SentryMeterRegistry() + val registry = registry() val counter = registry.counter("counter") counter.increment() @@ -181,7 +184,7 @@ class SentryMeterRegistryTest { @Test fun `active meters stop forwarding after registry close but retain local behavior`() { val metrics = installMetricsApi() - val registry = SentryMeterRegistry() + val registry = registry() val counter = registry.counter("counter") val timer = registry.timer("timer") val summary = registry.summary("summary") @@ -209,7 +212,7 @@ class SentryMeterRegistryTest { val scopes = createTestScopes(options) scopes.bindClient(client) Sentry.setCurrentScopes(scopes) - val registry = SentryMeterRegistry() + val registry = registry() registry.counter("counter").increment() @@ -226,7 +229,7 @@ class SentryMeterRegistryTest { val scopes = createTestScopes(SentryOptions().apply { dsn = "https://key@sentry.io/proj" }) scopes.bindClient(client) Sentry.setCurrentScopes(scopes) - val registry = SentryMeterRegistry() + val registry = registry() val counter = registry.counter("counter") counter.increment(3.0) @@ -237,7 +240,7 @@ class SentryMeterRegistryTest { @Test fun `registry-local filters do not affect another composite registry`() { val metrics = installMetricsApi() - val sentryRegistry = SentryMeterRegistry() + val sentryRegistry = registry() sentryRegistry.config().meterFilter(MeterFilter.denyNameStartsWith("denied")) val otherRegistry = SimpleMeterRegistry() val composite = CompositeMeterRegistry() @@ -256,7 +259,7 @@ class SentryMeterRegistryTest { @Test fun `custom meters remain readable and are not forwarded`() { val metrics = installMetricsApi() - val registry = SentryMeterRegistry() + val registry = registry() val meter = Meter.builder( "custom", @@ -272,13 +275,15 @@ class SentryMeterRegistryTest { @Test fun `registry construction records integration and package metadata`() { - SentryMeterRegistry() + registries.add(SentryMeterRegistry()) val storage = SentryIntegrationPackageStorage.getInstance() assertThat(storage.integrations).contains("Micrometer") assertThat(storage.packages.map { it.name }).contains("maven:io.sentry:sentry-micrometer") } + private fun registry(): SentryMeterRegistry = SentryMeterRegistry(0).also(registries::add) + private fun installMetricsApi(metrics: IMetricsApi = mock()): IMetricsApi { val scopes = mock() whenever(scopes.metrics()).thenReturn(metrics)