Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 78 additions & 1 deletion sentry-micrometer/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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.
2 changes: 2 additions & 0 deletions sentry-micrometer/api/sentry-micrometer.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 <init> ()V
public fun <init> (J)V
public fun close ()V
}

Original file line number Diff line number Diff line change
@@ -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<T> extends CumulativeFunctionCounter<T> {
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<T> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
*
* <p>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
Expand Down Expand Up @@ -110,7 +149,7 @@ public SentryMeterRegistry() {
final @NotNull Meter.Id id,
final @NotNull T obj,
final @NotNull ToDoubleFunction<T> countFunction) {
return new CumulativeFunctionCounter<>(id, obj, countFunction);
return new SentryFunctionCounter<>(id, obj, countFunction, this, createMetricInfo(id));
}

@Override
Expand Down Expand Up @@ -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<String, Object> 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.");
}
}
}
Loading
Loading