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",