From 7a06b65d1a41718fe202c7dbd7e24f1bc904becb Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 10 Sep 2026 06:28:27 +0200 Subject: [PATCH] feat(micrometer): [Micrometer 1] Add active meters Add a framework-neutral Micrometer registry that forwards counters, timers, and distribution summaries through the existing Sentry metrics API while preserving local meter behavior. Register the new integration artifact and cover naming, units, filtering, scope resolution, and composite registry coexistence. Co-Authored-By: Claude --- .craft.yml | 2 + .github/ISSUE_TEMPLATE/bug_report_java.yml | 1 + README.md | 1 + buildSrc/src/main/java/Config.kt | 1 + gradle/libs.versions.toml | 2 + sentry-micrometer/README.md | 21 ++ sentry-micrometer/api/sentry-micrometer.api | 9 + sentry-micrometer/build.gradle.kts | 66 ++++ .../io/sentry/micrometer/SentryCounter.java | 27 ++ .../micrometer/SentryDistributionSummary.java | 32 ++ .../micrometer/SentryMeterRegistry.java | 172 +++++++++++ .../sentry/micrometer/SentryMetricInfo.java | 40 +++ .../sentry/micrometer/SentryMetricUnit.java | 115 +++++++ .../io/sentry/micrometer/SentryTimer.java | 54 ++++ .../micrometer/SentryMeterRegistryTest.kt | 289 ++++++++++++++++++ .../sentry/micrometer/SentryMetricUnitTest.kt | 22 ++ settings.gradle.kts | 1 + 17 files changed, 855 insertions(+) create mode 100644 sentry-micrometer/README.md create mode 100644 sentry-micrometer/api/sentry-micrometer.api create mode 100644 sentry-micrometer/build.gradle.kts create mode 100644 sentry-micrometer/src/main/java/io/sentry/micrometer/SentryCounter.java create mode 100644 sentry-micrometer/src/main/java/io/sentry/micrometer/SentryDistributionSummary.java create mode 100644 sentry-micrometer/src/main/java/io/sentry/micrometer/SentryMeterRegistry.java create mode 100644 sentry-micrometer/src/main/java/io/sentry/micrometer/SentryMetricInfo.java create mode 100644 sentry-micrometer/src/main/java/io/sentry/micrometer/SentryMetricUnit.java create mode 100644 sentry-micrometer/src/main/java/io/sentry/micrometer/SentryTimer.java create mode 100644 sentry-micrometer/src/test/kotlin/io/sentry/micrometer/SentryMeterRegistryTest.kt create mode 100644 sentry-micrometer/src/test/kotlin/io/sentry/micrometer/SentryMetricUnitTest.kt diff --git a/.craft.yml b/.craft.yml index bee668917c1..1708450e8a3 100644 --- a/.craft.yml +++ b/.craft.yml @@ -55,6 +55,8 @@ targets: maven:io.sentry:sentry-opentelemetry-otlp: maven:io.sentry:sentry-opentelemetry-otlp-spring: maven:io.sentry:sentry-kafka: + # TODO: Add after first release of the artifact. + # maven:io.sentry:sentry-micrometer: maven:io.sentry:sentry-apollo: maven:io.sentry:sentry-jdbc: maven:io.sentry:sentry-jcache: diff --git a/.github/ISSUE_TEMPLATE/bug_report_java.yml b/.github/ISSUE_TEMPLATE/bug_report_java.yml index 8355d75a43b..ebde4d5112b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report_java.yml +++ b/.github/ISSUE_TEMPLATE/bug_report_java.yml @@ -36,6 +36,7 @@ body: - sentry-quartz - sentry-openfeign - sentry-openfeature + - sentry-micrometer - sentry-launchdarkly-server - sentry-apache-http-client-5 - sentry-okhttp diff --git a/README.md b/README.md index 849aaf74457..4f3ed1eb426 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Sentry SDK for Java and Android | sentry-jul | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-jul?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-jul) | | sentry-jdbc | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-jdbc?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-jdbc) | | sentry-kafka | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-kafka?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-kafka) | +| sentry-micrometer | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-micrometer?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-micrometer) | | sentry-apollo | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-apollo?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-apollo) | 21 | | sentry-apollo-3 | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-apollo-3?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-apollo-3) | 21 | | sentry-apollo-4 | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-apollo-4?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-apollo-4) | 21 | diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 09d2869988b..63a6072f9b8 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -76,6 +76,7 @@ object Config { val SENTRY_QUARTZ_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.quartz" val SENTRY_JDBC_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.jdbc" val SENTRY_KAFKA_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.kafka" + val SENTRY_MICROMETER_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.micrometer" val SENTRY_OPENFEATURE_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.openfeature" val SENTRY_LAUNCHDARKLY_SERVER_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.launchdarkly-server" val SENTRY_LAUNCHDARKLY_ANDROID_SDK_NAME = "$SENTRY_ANDROID_SDK_NAME.launchdarkly" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fbe9ef0177a..2a759aeb2c9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,6 +21,7 @@ ksp = "2.3.9" ktorClient = "3.0.0" logback = "1.2.9" log4j2 = "2.20.0" +micrometer = "1.9.17" nopen = "1.0.1" # see https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html#kotlin-compatibility # see https://developer.android.com/jetpack/androidx/releases/compose-kotlin @@ -144,6 +145,7 @@ log4j-core = { module = "org.apache.logging.log4j:log4j-core", version.ref = "lo leakcanary = { module = "com.squareup.leakcanary:leakcanary-android", version = "2.14" } lottie-compose = { module = "com.airbnb.android:lottie-compose", version = "6.7.1" } logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } +micrometer-core = { module = "io.micrometer:micrometer-core", version.ref = "micrometer" } nopen-annotations = { module = "com.jakewharton.nopen:nopen-annotations", version.ref = "nopen" } nopen-checker = { module = "com.jakewharton.nopen:nopen-checker", version.ref = "nopen" } nullaway = { module = "com.uber.nullaway:nullaway", version = "0.9.5" } diff --git a/sentry-micrometer/README.md b/sentry-micrometer/README.md new file mode 100644 index 00000000000..a4e13d29b84 --- /dev/null +++ b/sentry-micrometer/README.md @@ -0,0 +1,21 @@ +# sentry-micrometer + +This module forwards Micrometer metrics to Sentry. + +## Install + +Add the Sentry Micrometer module and Micrometer Core: + +```kotlin +dependencies { + implementation("io.sentry:sentry-micrometer:") + implementation("io.micrometer:micrometer-core:") +} +``` + +Create a `SentryMeterRegistry` and add it to Micrometer: + +```java +SentryMeterRegistry sentryRegistry = new SentryMeterRegistry(); +Metrics.addRegistry(sentryRegistry); +``` diff --git a/sentry-micrometer/api/sentry-micrometer.api b/sentry-micrometer/api/sentry-micrometer.api new file mode 100644 index 00000000000..f8c4d1328ea --- /dev/null +++ b/sentry-micrometer/api/sentry-micrometer.api @@ -0,0 +1,9 @@ +public final class io/sentry/micrometer/BuildConfig { + public static final field SENTRY_MICROMETER_SDK_NAME Ljava/lang/String; + public static final field VERSION_NAME Ljava/lang/String; +} + +public final class io/sentry/micrometer/SentryMeterRegistry : io/micrometer/core/instrument/MeterRegistry { + public fun ()V +} + diff --git a/sentry-micrometer/build.gradle.kts b/sentry-micrometer/build.gradle.kts new file mode 100644 index 00000000000..e13a03f696d --- /dev/null +++ b/sentry-micrometer/build.gradle.kts @@ -0,0 +1,66 @@ +import net.ltgt.gradle.errorprone.errorprone +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + `java-library` + id("io.sentry.javadoc") + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.errorprone) + alias(libs.plugins.gradle.versions) + alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") +} + +tasks.withType().configureEach { + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 +} + +dependencies { + api(projects.sentry) + compileOnly(libs.micrometer.core) + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.nopen.annotations) + + errorprone(libs.errorprone.core) + errorprone(libs.nopen.checker) + errorprone(libs.nullaway) + + // tests + testImplementation(projects.sentryTestSupport) + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.google.truth) + testImplementation(libs.mockito.kotlin) + testImplementation(libs.micrometer.core) +} + +tasks.withType().configureEach { + options.errorprone { + check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) + option("NullAway:AnnotatedPackages", "io.sentry") + } +} + +buildConfig { + useJavaOutput() + packageName("io.sentry.micrometer") + buildConfigField( + "String", + "SENTRY_MICROMETER_SDK_NAME", + "\"${Config.Sentry.SENTRY_MICROMETER_SDK_NAME}\"", + ) + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") +} + +tasks.jar { + manifest { + attributes( + "Sentry-Version-Name" to project.version, + "Sentry-SDK-Name" to Config.Sentry.SENTRY_MICROMETER_SDK_NAME, + "Sentry-SDK-Package-Name" to "maven:io.sentry:sentry-micrometer", + "Implementation-Vendor" to "Sentry", + "Implementation-Title" to project.name, + "Implementation-Version" to project.version, + ) + } +} diff --git a/sentry-micrometer/src/main/java/io/sentry/micrometer/SentryCounter.java b/sentry-micrometer/src/main/java/io/sentry/micrometer/SentryCounter.java new file mode 100644 index 00000000000..591bd863bc3 --- /dev/null +++ b/sentry-micrometer/src/main/java/io/sentry/micrometer/SentryCounter.java @@ -0,0 +1,27 @@ +package io.sentry.micrometer; + +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.cumulative.CumulativeCounter; +import org.jetbrains.annotations.NotNull; + +final class SentryCounter extends CumulativeCounter { + private final @NotNull SentryMeterRegistry registry; + private final @NotNull SentryMetricInfo metricInfo; + + SentryCounter( + final @NotNull Meter.Id id, + final @NotNull SentryMeterRegistry registry, + final @NotNull SentryMetricInfo metricInfo) { + super(id); + this.registry = registry; + this.metricInfo = metricInfo; + } + + @Override + public void increment(final double amount) { + super.increment(amount); + if (amount > 0.0 && Double.isFinite(amount)) { + registry.captureCounter(metricInfo, amount); + } + } +} diff --git a/sentry-micrometer/src/main/java/io/sentry/micrometer/SentryDistributionSummary.java b/sentry-micrometer/src/main/java/io/sentry/micrometer/SentryDistributionSummary.java new file mode 100644 index 00000000000..b618db832fa --- /dev/null +++ b/sentry-micrometer/src/main/java/io/sentry/micrometer/SentryDistributionSummary.java @@ -0,0 +1,32 @@ +package io.sentry.micrometer; + +import io.micrometer.core.instrument.Clock; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.cumulative.CumulativeDistributionSummary; +import io.micrometer.core.instrument.distribution.DistributionStatisticConfig; +import org.jetbrains.annotations.NotNull; + +final class SentryDistributionSummary extends CumulativeDistributionSummary { + private final @NotNull SentryMeterRegistry registry; + private final @NotNull SentryMetricInfo metricInfo; + + SentryDistributionSummary( + final @NotNull Meter.Id id, + final @NotNull Clock clock, + final @NotNull DistributionStatisticConfig distributionStatisticConfig, + final double scale, + final @NotNull SentryMeterRegistry registry, + final @NotNull SentryMetricInfo metricInfo) { + super(id, clock, distributionStatisticConfig, scale, false); + this.registry = registry; + this.metricInfo = metricInfo; + } + + @Override + protected void recordNonNegative(final double amount) { + super.recordNonNegative(amount); + if (Double.isFinite(amount)) { + registry.captureDistribution(metricInfo, amount); + } + } +} diff --git a/sentry-micrometer/src/main/java/io/sentry/micrometer/SentryMeterRegistry.java b/sentry-micrometer/src/main/java/io/sentry/micrometer/SentryMeterRegistry.java new file mode 100644 index 00000000000..a8f3d0898b7 --- /dev/null +++ b/sentry-micrometer/src/main/java/io/sentry/micrometer/SentryMeterRegistry.java @@ -0,0 +1,172 @@ +package io.sentry.micrometer; + +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; + +import io.micrometer.core.instrument.Clock; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.DistributionSummary; +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.Measurement; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; +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.sentry.Sentry; +import io.sentry.SentryAttributes; +import io.sentry.SentryIntegrationPackageStorage; +import io.sentry.SentryLevel; +import io.sentry.metrics.MetricsUnit; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.function.ToDoubleFunction; +import java.util.function.ToLongFunction; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** A Micrometer registry that forwards metrics to Sentry. */ +public final class SentryMeterRegistry extends MeterRegistry { + private static final @NotNull String INTEGRATION_NAME = "Micrometer"; + + static { + SentryIntegrationPackageStorage.getInstance() + .addPackage("maven:io.sentry:sentry-micrometer", BuildConfig.VERSION_NAME); + } + + /** Creates a registry that forwards active meter observations to Sentry. */ + public SentryMeterRegistry() { + super(Clock.SYSTEM); + addIntegrationToSdkVersion(INTEGRATION_NAME); + } + + @Override + protected @NotNull Counter newCounter(final @NotNull Meter.Id id) { + return new SentryCounter(id, this, createMetricInfo(id)); + } + + @Override + protected @NotNull Timer newTimer( + final @NotNull Meter.Id id, + final @NotNull DistributionStatisticConfig distributionStatisticConfig, + final @NotNull PauseDetector pauseDetector) { + return new SentryTimer( + id, + clock, + distributionStatisticConfig, + pauseDetector, + getBaseTimeUnit(), + this, + createMetricInfo(id, MetricsUnit.Duration.MILLISECOND)); + } + + @Override + protected @NotNull DistributionSummary newDistributionSummary( + final @NotNull Meter.Id id, + final @NotNull DistributionStatisticConfig distributionStatisticConfig, + final double scale) { + return new SentryDistributionSummary( + id, clock, distributionStatisticConfig, scale, this, createMetricInfo(id)); + } + + @Override + protected @NotNull Gauge newGauge( + final @NotNull Meter.Id id, + final @Nullable T obj, + final @NotNull ToDoubleFunction valueFunction) { + return new DefaultGauge<>(id, obj, valueFunction); + } + + @Override + protected @NotNull LongTaskTimer newLongTaskTimer( + final @NotNull Meter.Id id, + final @NotNull DistributionStatisticConfig distributionStatisticConfig) { + return new DefaultLongTaskTimer( + id, clock, getBaseTimeUnit(), distributionStatisticConfig, false); + } + + @Override + protected @NotNull FunctionTimer newFunctionTimer( + final @NotNull Meter.Id id, + final @NotNull T obj, + final @NotNull ToLongFunction countFunction, + final @NotNull ToDoubleFunction totalTimeFunction, + final @NotNull TimeUnit totalTimeFunctionUnit) { + return new CumulativeFunctionTimer<>( + id, obj, countFunction, totalTimeFunction, totalTimeFunctionUnit, getBaseTimeUnit()); + } + + @Override + protected @NotNull FunctionCounter newFunctionCounter( + final @NotNull Meter.Id id, + final @NotNull T obj, + final @NotNull ToDoubleFunction countFunction) { + return new CumulativeFunctionCounter<>(id, obj, countFunction); + } + + @Override + protected @NotNull Meter newMeter( + final @NotNull Meter.Id id, + final @NotNull Meter.Type type, + final @NotNull Iterable measurements) { + Sentry.getCurrentScopes() + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "Micrometer meter type %s is not supported for Sentry export.", + type); + return new DefaultMeter(id, type, measurements); + } + + @Override + protected @NotNull TimeUnit getBaseTimeUnit() { + return TimeUnit.MILLISECONDS; + } + + @Override + protected @NotNull DistributionStatisticConfig defaultHistogramConfig() { + return DistributionStatisticConfig.DEFAULT; + } + + void captureCounter(final @NotNull SentryMetricInfo metricInfo, final double value) { + if (isClosed()) { + return; + } + Sentry.getCurrentScopes() + .metrics() + .count(metricInfo.getName(), value, metricInfo.getUnit(), metricInfo.createParameters()); + } + + void captureDistribution(final @NotNull SentryMetricInfo metricInfo, final double value) { + if (isClosed()) { + return; + } + Sentry.getCurrentScopes() + .metrics() + .distribution( + metricInfo.getName(), value, metricInfo.getUnit(), metricInfo.createParameters()); + } + + 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) { + 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)); + } +} diff --git a/sentry-micrometer/src/main/java/io/sentry/micrometer/SentryMetricInfo.java b/sentry-micrometer/src/main/java/io/sentry/micrometer/SentryMetricInfo.java new file mode 100644 index 00000000000..dcc1a29b2fb --- /dev/null +++ b/sentry-micrometer/src/main/java/io/sentry/micrometer/SentryMetricInfo.java @@ -0,0 +1,40 @@ +package io.sentry.micrometer; + +import io.sentry.SentryAttributes; +import io.sentry.metrics.SentryMetricsParameters; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +final class SentryMetricInfo { + private static final @NotNull String ORIGIN = "auto.metrics.micrometer"; + + private final @NotNull String name; + private final @Nullable String unit; + private final @NotNull SentryAttributes attributes; + + SentryMetricInfo( + final @NotNull String name, + final @Nullable String unit, + final @NotNull SentryAttributes attributes) { + this.name = name; + this.unit = unit; + this.attributes = attributes; + } + + @NotNull + String getName() { + return name; + } + + @Nullable + String getUnit() { + return unit; + } + + @NotNull + SentryMetricsParameters createParameters() { + final @NotNull SentryMetricsParameters parameters = SentryMetricsParameters.create(attributes); + parameters.setOrigin(ORIGIN); + return parameters; + } +} diff --git a/sentry-micrometer/src/main/java/io/sentry/micrometer/SentryMetricUnit.java b/sentry-micrometer/src/main/java/io/sentry/micrometer/SentryMetricUnit.java new file mode 100644 index 00000000000..1f76e67a10d --- /dev/null +++ b/sentry-micrometer/src/main/java/io/sentry/micrometer/SentryMetricUnit.java @@ -0,0 +1,115 @@ +package io.sentry.micrometer; + +import io.sentry.metrics.MetricsUnit; +import java.util.Locale; +import org.jetbrains.annotations.Nullable; + +final class SentryMetricUnit { + private SentryMetricUnit() {} + + static @Nullable String normalize(final @Nullable String unit) { + if (unit == null) { + return null; + } + + switch (unit.toLowerCase(Locale.ROOT)) { + case "ns": + case "nanosecond": + case "nanoseconds": + return MetricsUnit.Duration.NANOSECOND; + case "us": + case "µs": + case "microsecond": + case "microseconds": + return MetricsUnit.Duration.MICROSECOND; + case "ms": + case "millisecond": + case "milliseconds": + return MetricsUnit.Duration.MILLISECOND; + case "s": + case "sec": + case "second": + case "seconds": + return MetricsUnit.Duration.SECOND; + case "min": + case "minute": + case "minutes": + return MetricsUnit.Duration.MINUTE; + case "h": + case "hour": + case "hours": + return MetricsUnit.Duration.HOUR; + case "d": + case "day": + case "days": + return MetricsUnit.Duration.DAY; + case "w": + case "week": + case "weeks": + return MetricsUnit.Duration.WEEK; + case "bit": + case "bits": + return MetricsUnit.Information.BIT; + case "b": + case "byte": + case "bytes": + return MetricsUnit.Information.BYTE; + case "kb": + case "kilobyte": + case "kilobytes": + return MetricsUnit.Information.KILOBYTE; + case "kib": + case "kibibyte": + case "kibibytes": + return MetricsUnit.Information.KIBIBYTE; + case "mb": + case "megabyte": + case "megabytes": + return MetricsUnit.Information.MEGABYTE; + case "mib": + case "mebibyte": + case "mebibytes": + return MetricsUnit.Information.MEBIBYTE; + case "gb": + case "gigabyte": + case "gigabytes": + return MetricsUnit.Information.GIGABYTE; + case "gib": + case "gibibyte": + case "gibibytes": + return MetricsUnit.Information.GIBIBYTE; + case "tb": + case "terabyte": + case "terabytes": + return MetricsUnit.Information.TERABYTE; + case "tib": + case "tebibyte": + case "tebibytes": + return MetricsUnit.Information.TEBIBYTE; + case "pb": + case "petabyte": + case "petabytes": + return MetricsUnit.Information.PETABYTE; + case "pib": + case "pebibyte": + case "pebibytes": + return MetricsUnit.Information.PEBIBYTE; + case "eb": + case "exabyte": + case "exabytes": + return MetricsUnit.Information.EXABYTE; + case "eib": + case "exbibyte": + case "exbibytes": + return MetricsUnit.Information.EXBIBYTE; + case "ratio": + return MetricsUnit.Fraction.RATIO; + case "%": + case "percent": + case "percentage": + return MetricsUnit.Fraction.PERCENT; + default: + return unit; + } + } +} diff --git a/sentry-micrometer/src/main/java/io/sentry/micrometer/SentryTimer.java b/sentry-micrometer/src/main/java/io/sentry/micrometer/SentryTimer.java new file mode 100644 index 00000000000..614f7690cda --- /dev/null +++ b/sentry-micrometer/src/main/java/io/sentry/micrometer/SentryTimer.java @@ -0,0 +1,54 @@ +package io.sentry.micrometer; + +import io.micrometer.core.instrument.Clock; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.cumulative.CumulativeTimer; +import io.micrometer.core.instrument.distribution.DistributionStatisticConfig; +import io.micrometer.core.instrument.distribution.pause.PauseDetector; +import java.util.concurrent.TimeUnit; +import org.jetbrains.annotations.NotNull; + +final class SentryTimer extends CumulativeTimer { + private final @NotNull SentryMeterRegistry registry; + private final @NotNull SentryMetricInfo metricInfo; + + SentryTimer( + final @NotNull Meter.Id id, + final @NotNull Clock clock, + final @NotNull DistributionStatisticConfig distributionStatisticConfig, + final @NotNull PauseDetector pauseDetector, + final @NotNull TimeUnit baseTimeUnit, + final @NotNull SentryMeterRegistry registry, + final @NotNull SentryMetricInfo metricInfo) { + super(id, clock, distributionStatisticConfig, pauseDetector, baseTimeUnit, false); + this.registry = registry; + this.metricInfo = metricInfo; + } + + @Override + protected void recordNonNegative(final long amount, final @NotNull TimeUnit unit) { + super.recordNonNegative(amount, unit); + registry.captureDistribution(metricInfo, toMilliseconds(amount, unit)); + } + + private static double toMilliseconds(final long amount, final @NotNull TimeUnit unit) { + switch (unit) { + case NANOSECONDS: + return amount / 1_000_000.0; + case MICROSECONDS: + return amount / 1_000.0; + case MILLISECONDS: + return amount; + case SECONDS: + return amount * 1_000.0; + case MINUTES: + return amount * 60_000.0; + case HOURS: + return amount * 3_600_000.0; + case DAYS: + return amount * 86_400_000.0; + default: + throw new IllegalArgumentException("Unsupported time unit: " + unit); + } + } +} diff --git a/sentry-micrometer/src/test/kotlin/io/sentry/micrometer/SentryMeterRegistryTest.kt b/sentry-micrometer/src/test/kotlin/io/sentry/micrometer/SentryMeterRegistryTest.kt new file mode 100644 index 00000000000..44df9825528 --- /dev/null +++ b/sentry-micrometer/src/test/kotlin/io/sentry/micrometer/SentryMeterRegistryTest.kt @@ -0,0 +1,289 @@ +package io.sentry.micrometer + +import com.google.common.truth.Truth.assertThat +import io.micrometer.core.instrument.Counter +import io.micrometer.core.instrument.DistributionSummary +import io.micrometer.core.instrument.Measurement +import io.micrometer.core.instrument.Meter +import io.micrometer.core.instrument.Statistic +import io.micrometer.core.instrument.Timer +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.Hint +import io.sentry.IScopes +import io.sentry.ISentryClient +import io.sentry.Sentry +import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryMetricsEvent +import io.sentry.SentryOptions +import io.sentry.metrics.IMetricsApi +import io.sentry.metrics.MetricsUnit +import io.sentry.metrics.SentryMetricsParameters +import io.sentry.test.createTestScopes +import io.sentry.test.initForTest +import java.util.concurrent.TimeUnit +import java.util.function.Supplier +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.doThrow +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 SentryMeterRegistryTest { + @BeforeTest + fun setUp() { + initForTest { it.dsn = "https://key@sentry.io/proj" } + } + + @AfterTest + fun tearDown() { + Sentry.close() + } + + @Test + fun `counter forwards positive finite increments with converted metadata`() { + val metrics = installMetricsApi() + val registry = SentryMeterRegistry() + val counter = + Counter.builder("request.count") + .tags("http.method", "GET") + .baseUnit("requests") + .register(registry) + + counter.increment(2.5) + + assertThat(counter.count()).isEqualTo(2.5) + val parameters = argumentCaptor() + verify(metrics).count(eq("request_count"), eq(2.5), eq("requests"), parameters.capture()) + assertThat(parameters.firstValue.origin).isEqualTo("auto.metrics.micrometer") + assertThat(parameters.firstValue.attributes!!.attributes["http_method"]!!.value) + .isEqualTo("GET") + } + + @Test + fun `uses the configured naming convention for names tags and values`() { + val metrics = installMetricsApi() + val registry = SentryMeterRegistry() + registry + .config() + .namingConvention( + object : NamingConvention { + override fun name(name: String, type: Meter.Type, baseUnit: String?) = "metric_$name" + + override fun tagKey(key: String) = "tag_$key" + + override fun tagValue(value: String) = value.lowercase() + } + ) + val counter = Counter.builder("requests").tag("METHOD", "GET").register(registry) + + counter.increment() + + val parameters = argumentCaptor() + verify(metrics).count(eq("metric_requests"), eq(1.0), anyOrNull(), parameters.capture()) + assertThat(parameters.firstValue.attributes!!.attributes["tag_METHOD"]!!.value).isEqualTo("get") + } + + @Test + fun `counter does not forward zero negative or non-finite increments`() { + val metrics = installMetricsApi() + val registry = SentryMeterRegistry() + val counter = registry.counter("counter") + + counter.increment(0.0) + counter.increment(-1.0) + counter.increment(Double.NaN) + counter.increment(Double.POSITIVE_INFINITY) + + verifyNoInteractions(metrics) + } + + @Test + fun `timer forwards accepted durations as millisecond distributions`() { + val metrics = installMetricsApi() + val registry = SentryMeterRegistry() + val timer = Timer.builder("request.duration").register(registry) + + timer.record(1500, TimeUnit.MICROSECONDS) + timer.record(-1, TimeUnit.MILLISECONDS) + + assertThat(timer.count()).isEqualTo(1) + assertThat(timer.totalTime(TimeUnit.MILLISECONDS)).isWithin(0.0001).of(1.5) + verify(metrics) + .distribution( + eq("request_duration"), + eq(1.5), + eq(MetricsUnit.Duration.MILLISECOND), + any(), + ) + verify(metrics, never()).count(any(), anyOrNull(), anyOrNull(), any()) + } + + @Test + fun `distribution summary forwards scaled finite observations and remains readable`() { + val metrics = installMetricsApi() + val registry = SentryMeterRegistry() + val summary = + DistributionSummary.builder("payload.size").baseUnit("bytes").scale(2.0).register(registry) + + summary.record(3.0) + summary.record(Double.POSITIVE_INFINITY) + + assertThat(summary.count()).isEqualTo(2) + assertThat(summary.totalAmount()).isPositiveInfinity() + verify(metrics) + .distribution(eq("payload_size"), eq(6.0), eq(MetricsUnit.Information.BYTE), any()) + verify(metrics, times(1)).distribution(any(), anyOrNull(), anyOrNull(), any()) + } + + @Test + fun `each recording resolves the current scopes metrics API`() { + val first = mock() + val second = mock() + val registry = SentryMeterRegistry() + val counter = registry.counter("counter") + + installMetricsApi(first) + counter.increment() + installMetricsApi(second) + counter.increment(2.0) + + verify(first).count(eq("counter"), eq(1.0), anyOrNull(), any()) + verify(second).count(eq("counter"), eq(2.0), anyOrNull(), any()) + } + + @Test + fun `registry created before Sentry init forwards after initialization`() { + Sentry.close() + val registry = SentryMeterRegistry() + val counter = registry.counter("counter") + + counter.increment() + val metrics = mock() + initForTest { it.dsn = "https://key@sentry.io/proj" } + installMetricsApi(metrics) + counter.increment(2.0) + + verify(metrics).count(eq("counter"), eq(2.0), anyOrNull(), any()) + } + + @Test + fun `active meters stop forwarding after registry close but retain local behavior`() { + val metrics = installMetricsApi() + val registry = SentryMeterRegistry() + val counter = registry.counter("counter") + val timer = registry.timer("timer") + val summary = registry.summary("summary") + + registry.close() + counter.increment() + timer.record(1, TimeUnit.MILLISECONDS) + summary.record(1.0) + + assertThat(counter.count()).isEqualTo(1.0) + assertThat(timer.count()).isEqualTo(1) + assertThat(summary.count()).isEqualTo(1) + verifyNoInteractions(metrics) + } + + @Test + fun `global Sentry metrics option disables forwarding`() { + val client = mock() + whenever(client.isEnabled).thenReturn(true) + val options = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + metrics.isEnabled = false + } + val scopes = createTestScopes(options) + scopes.bindClient(client) + Sentry.setCurrentScopes(scopes) + val registry = SentryMeterRegistry() + + registry.counter("counter").increment() + + verify(client, never()).captureMetric(any(), any(), anyOrNull()) + } + + @Test + fun `capture failures do not affect local meter state`() { + val client = mock() + whenever(client.isEnabled).thenReturn(true) + doThrow(IllegalStateException("capture failed")) + .whenever(client) + .captureMetric(any(), any(), anyOrNull()) + val scopes = createTestScopes(SentryOptions().apply { dsn = "https://key@sentry.io/proj" }) + scopes.bindClient(client) + Sentry.setCurrentScopes(scopes) + val registry = SentryMeterRegistry() + val counter = registry.counter("counter") + + counter.increment(3.0) + + assertThat(counter.count()).isEqualTo(3.0) + } + + @Test + fun `registry-local filters do not affect another composite registry`() { + val metrics = installMetricsApi() + val sentryRegistry = SentryMeterRegistry() + sentryRegistry.config().meterFilter(MeterFilter.denyNameStartsWith("denied")) + val otherRegistry = SimpleMeterRegistry() + val composite = CompositeMeterRegistry() + composite.add(sentryRegistry) + composite.add(otherRegistry) + + composite.counter("denied.counter").increment(4.0) + composite.counter("allowed.counter").increment(2.0) + + assertThat(otherRegistry.counter("denied.counter").count()).isEqualTo(4.0) + assertThat(otherRegistry.counter("allowed.counter").count()).isEqualTo(2.0) + assertThat(sentryRegistry.find("denied.counter").counter()).isNull() + verify(metrics).count(eq("allowed_counter"), eq(2.0), anyOrNull(), any()) + } + + @Test + fun `custom meters remain readable and are not forwarded`() { + val metrics = installMetricsApi() + val registry = SentryMeterRegistry() + val meter = + Meter.builder( + "custom", + Meter.Type.OTHER, + listOf(Measurement(Supplier { 7.0 }, Statistic.VALUE)), + ) + .register(registry) + + assertThat(meter.measure().single().value).isEqualTo(7.0) + assertThat(registry.get("custom").meter()).isSameInstanceAs(meter) + verifyNoInteractions(metrics) + } + + @Test + fun `registry construction records integration and package metadata`() { + SentryMeterRegistry() + + val storage = SentryIntegrationPackageStorage.getInstance() + assertThat(storage.integrations).contains("Micrometer") + assertThat(storage.packages.map { it.name }).contains("maven:io.sentry:sentry-micrometer") + } + + 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/SentryMetricUnitTest.kt b/sentry-micrometer/src/test/kotlin/io/sentry/micrometer/SentryMetricUnitTest.kt new file mode 100644 index 00000000000..0acd15049c8 --- /dev/null +++ b/sentry-micrometer/src/test/kotlin/io/sentry/micrometer/SentryMetricUnitTest.kt @@ -0,0 +1,22 @@ +package io.sentry.micrometer + +import com.google.common.truth.Truth.assertThat +import io.sentry.metrics.MetricsUnit +import kotlin.test.Test + +class SentryMetricUnitTest { + @Test + fun `normalizes known aliases`() { + assertThat(SentryMetricUnit.normalize("ms")).isEqualTo(MetricsUnit.Duration.MILLISECOND) + assertThat(SentryMetricUnit.normalize("seconds")).isEqualTo(MetricsUnit.Duration.SECOND) + assertThat(SentryMetricUnit.normalize("bytes")).isEqualTo(MetricsUnit.Information.BYTE) + assertThat(SentryMetricUnit.normalize("KiB")).isEqualTo(MetricsUnit.Information.KIBIBYTE) + assertThat(SentryMetricUnit.normalize("percentage")).isEqualTo(MetricsUnit.Fraction.PERCENT) + } + + @Test + fun `preserves unknown and absent units`() { + assertThat(SentryMetricUnit.normalize("widgets")).isEqualTo("widgets") + assertThat(SentryMetricUnit.normalize(null)).isNull() + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 665e85477df..471d9ac8b15 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -81,6 +81,7 @@ include( "sentry-graphql-core", "sentry-jdbc", "sentry-kafka", + "sentry-micrometer", "sentry-opentelemetry:sentry-opentelemetry-bootstrap", "sentry-opentelemetry:sentry-opentelemetry-core", "sentry-opentelemetry:sentry-opentelemetry-agentcustomization",