Skip to content

Commit 170c0a5

Browse files
adinauerclaude
andcommitted
feat(micrometer): [Micrometer 2] Add passive meter polling
Poll gauges, time gauges, long task timers, and function counters on a configurable interval while forwarding values through the existing Sentry metrics pipeline. Support disabled polling, isolate callback failures, and stop the polling worker when the registry closes. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 7a06b65 commit 170c0a5

6 files changed

Lines changed: 725 additions & 19 deletions

File tree

‎sentry-micrometer/README.md‎

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# sentry-micrometer
22

3-
This module forwards Micrometer metrics to Sentry.
3+
This module forwards Micrometer metrics to Sentry while preserving normal Micrometer registry behavior.
4+
It can be used alongside Prometheus, Datadog, OTLP, and other registries.
45

56
## Install
67

@@ -19,3 +20,79 @@ Create a `SentryMeterRegistry` and add it to Micrometer:
1920
SentryMeterRegistry sentryRegistry = new SentryMeterRegistry();
2021
Metrics.addRegistry(sentryRegistry);
2122
```
23+
24+
## Metric mappings
25+
26+
Active meters are forwarded when they are recorded:
27+
28+
| Micrometer meter | Sentry metric |
29+
| --- | --- |
30+
| `Counter` | Counter increment |
31+
| `Timer` | Distribution in milliseconds |
32+
| `DistributionSummary` | Distribution |
33+
34+
Passive meters are polled every 60 seconds by default:
35+
36+
| Micrometer meter | Sentry metric |
37+
| --- | --- |
38+
| `Gauge` | Gauge |
39+
| `TimeGauge` | Gauge in milliseconds |
40+
| `LongTaskTimer` active tasks | `${name}.active` gauge |
41+
| `LongTaskTimer` active duration | `${name}.duration` gauge in milliseconds |
42+
| `FunctionCounter` | Positive counter delta |
43+
44+
The first successful finite `FunctionCounter` poll establishes its baseline and emits nothing.
45+
Later positive deltas are sent. A decreasing value is treated as a reset and establishes a new
46+
baseline.
47+
48+
Unsupported custom meters remain readable through Micrometer but are not exported to Sentry.
49+
50+
## Polling
51+
52+
Pass the interval in milliseconds to configure passive polling:
53+
54+
```java
55+
SentryMeterRegistry sentryRegistry = new SentryMeterRegistry(30_000);
56+
```
57+
58+
Use zero to disable passive polling while keeping active meter forwarding enabled:
59+
60+
```java
61+
SentryMeterRegistry sentryRegistry = new SentryMeterRegistry(0);
62+
```
63+
64+
Each registry with polling enabled owns one daemon scheduler thread. A slow passive-meter callback
65+
delays other passive meters in that registry. Callback failures and non-finite values are skipped
66+
without stopping later meters from being polled.
67+
68+
Active metrics use the Sentry scope and trace context present when they are recorded. Passive
69+
metrics use the context available on the polling thread because Micrometer does not retain the
70+
context that changed a backing value.
71+
72+
## Filtering and volume
73+
74+
Each active timer or distribution-summary recording creates one Sentry metric before the existing
75+
Sentry metrics batch processor batches it for transport. Apply Micrometer `MeterFilter`s directly
76+
to the Sentry registry to control volume and cardinality without affecting other registries:
77+
78+
```java
79+
sentryRegistry.config().meterFilter(MeterFilter.denyNameStartsWith("jvm.buffer"));
80+
```
81+
82+
Micrometer tags are application-provided metric data and are forwarded as supplied. Use a
83+
registry-local filter or Sentry's metrics `beforeSend` callback to remove sensitive or
84+
high-cardinality attributes.
85+
86+
## Shutdown
87+
88+
Remove the registry from its owning global or composite registry, close it, and then close Sentry:
89+
90+
```java
91+
Metrics.removeRegistry(sentryRegistry);
92+
sentryRegistry.close();
93+
Sentry.close();
94+
```
95+
96+
Closing the registry stops future polling without invoking passive callbacks or waiting for a
97+
blocked callback to return. Metrics already accepted by Sentry remain available to the normal
98+
Sentry flush and shutdown lifecycle.

‎sentry-micrometer/api/sentry-micrometer.api‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,7 @@ public final class io/sentry/micrometer/BuildConfig {
55

66
public final class io/sentry/micrometer/SentryMeterRegistry : io/micrometer/core/instrument/MeterRegistry {
77
public fun <init> ()V
8+
public fun <init> (J)V
9+
public fun close ()V
810
}
911

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package io.sentry.micrometer;
2+
3+
import io.micrometer.core.instrument.Meter;
4+
import io.micrometer.core.instrument.cumulative.CumulativeFunctionCounter;
5+
import java.util.function.ToDoubleFunction;
6+
import org.jetbrains.annotations.NotNull;
7+
8+
final class SentryFunctionCounter<T> extends CumulativeFunctionCounter<T> {
9+
private final @NotNull SentryMeterRegistry registry;
10+
private final @NotNull SentryMetricInfo metricInfo;
11+
private volatile boolean removed;
12+
private boolean initialized;
13+
private double previousValue;
14+
15+
SentryFunctionCounter(
16+
final @NotNull Meter.Id id,
17+
final @NotNull T obj,
18+
final @NotNull ToDoubleFunction<T> countFunction,
19+
final @NotNull SentryMeterRegistry registry,
20+
final @NotNull SentryMetricInfo metricInfo) {
21+
super(id, obj, countFunction);
22+
this.registry = registry;
23+
this.metricInfo = metricInfo;
24+
}
25+
26+
void poll() {
27+
final double currentValue = count();
28+
if (!Double.isFinite(currentValue) || removed || registry.isClosed()) {
29+
return;
30+
}
31+
32+
if (!initialized || currentValue < previousValue) {
33+
initialized = true;
34+
previousValue = currentValue;
35+
return;
36+
}
37+
38+
final double delta = currentValue - previousValue;
39+
previousValue = currentValue;
40+
if (delta > 0.0 && !removed) {
41+
registry.captureCounter(metricInfo, delta);
42+
}
43+
}
44+
45+
void markRemoved() {
46+
removed = true;
47+
}
48+
}

‎sentry-micrometer/src/main/java/io/sentry/micrometer/SentryMeterRegistry.java‎

Lines changed: 155 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,26 @@
1313
import io.micrometer.core.instrument.Meter;
1414
import io.micrometer.core.instrument.MeterRegistry;
1515
import io.micrometer.core.instrument.Tag;
16+
import io.micrometer.core.instrument.TimeGauge;
1617
import io.micrometer.core.instrument.Timer;
17-
import io.micrometer.core.instrument.cumulative.CumulativeFunctionCounter;
1818
import io.micrometer.core.instrument.cumulative.CumulativeFunctionTimer;
1919
import io.micrometer.core.instrument.distribution.DistributionStatisticConfig;
2020
import io.micrometer.core.instrument.distribution.pause.PauseDetector;
2121
import io.micrometer.core.instrument.internal.DefaultGauge;
2222
import io.micrometer.core.instrument.internal.DefaultLongTaskTimer;
2323
import io.micrometer.core.instrument.internal.DefaultMeter;
24+
import io.micrometer.core.instrument.util.NamedThreadFactory;
2425
import io.sentry.Sentry;
2526
import io.sentry.SentryAttributes;
2627
import io.sentry.SentryIntegrationPackageStorage;
2728
import io.sentry.SentryLevel;
2829
import io.sentry.metrics.MetricsUnit;
30+
import io.sentry.util.ExceptionUtils;
2931
import java.util.HashMap;
3032
import java.util.Map;
33+
import java.util.concurrent.Executors;
34+
import java.util.concurrent.ScheduledExecutorService;
35+
import java.util.concurrent.ScheduledFuture;
3136
import java.util.concurrent.TimeUnit;
3237
import java.util.function.ToDoubleFunction;
3338
import java.util.function.ToLongFunction;
@@ -37,16 +42,50 @@
3742
/** A Micrometer registry that forwards metrics to Sentry. */
3843
public final class SentryMeterRegistry extends MeterRegistry {
3944
private static final @NotNull String INTEGRATION_NAME = "Micrometer";
45+
private static final long DEFAULT_POLL_INTERVAL_MILLIS = 60_000;
46+
47+
private final @Nullable ScheduledExecutorService scheduler;
48+
private final @Nullable ScheduledFuture<?> pollingTask;
4049

4150
static {
4251
SentryIntegrationPackageStorage.getInstance()
4352
.addPackage("maven:io.sentry:sentry-micrometer", BuildConfig.VERSION_NAME);
4453
}
4554

46-
/** Creates a registry that forwards active meter observations to Sentry. */
55+
/** Creates a registry that polls passive meters every 60 seconds. */
4756
public SentryMeterRegistry() {
48-
super(Clock.SYSTEM);
57+
this(DEFAULT_POLL_INTERVAL_MILLIS);
58+
}
59+
60+
/**
61+
* Creates a registry with the given passive meter polling interval in milliseconds.
62+
*
63+
* <p>A zero interval disables passive meter polling. Negative intervals are not supported.
64+
*/
65+
public SentryMeterRegistry(final long pollIntervalMillis) {
66+
this(pollIntervalMillis, Clock.SYSTEM, createScheduler(pollIntervalMillis));
67+
}
68+
69+
SentryMeterRegistry(
70+
final long pollIntervalMillis,
71+
final @NotNull Clock clock,
72+
final @Nullable ScheduledExecutorService scheduler) {
73+
super(clock);
74+
validatePollInterval(pollIntervalMillis);
75+
if (pollIntervalMillis > 0 && scheduler == null) {
76+
throw new IllegalArgumentException(
77+
"A scheduler is required when passive polling is enabled.");
78+
}
79+
this.scheduler = pollIntervalMillis == 0 ? null : scheduler;
80+
config().onMeterRemoved(this::onMeterRemoved);
4981
addIntegrationToSdkVersion(INTEGRATION_NAME);
82+
if (this.scheduler == null) {
83+
pollingTask = null;
84+
} else {
85+
pollingTask =
86+
this.scheduler.scheduleAtFixedRate(
87+
this::pollMeters, pollIntervalMillis, pollIntervalMillis, TimeUnit.MILLISECONDS);
88+
}
5089
}
5190

5291
@Override
@@ -110,7 +149,7 @@ public SentryMeterRegistry() {
110149
final @NotNull Meter.Id id,
111150
final @NotNull T obj,
112151
final @NotNull ToDoubleFunction<T> countFunction) {
113-
return new CumulativeFunctionCounter<>(id, obj, countFunction);
152+
return new SentryFunctionCounter<>(id, obj, countFunction, this, createMetricInfo(id));
114153
}
115154

116155
@Override
@@ -157,16 +196,127 @@ void captureDistribution(final @NotNull SentryMetricInfo metricInfo, final doubl
157196
metricInfo.getName(), value, metricInfo.getUnit(), metricInfo.createParameters());
158197
}
159198

199+
void captureGauge(final @NotNull SentryMetricInfo metricInfo, final double value) {
200+
if (isClosed()) {
201+
return;
202+
}
203+
Sentry.getCurrentScopes()
204+
.metrics()
205+
.gauge(metricInfo.getName(), value, metricInfo.getUnit(), metricInfo.createParameters());
206+
}
207+
208+
void pollMeters() {
209+
if (isClosed()) {
210+
return;
211+
}
212+
for (final @NotNull Meter meter : getMeters()) {
213+
if (isClosed()) {
214+
return;
215+
}
216+
try {
217+
publishPassiveMeter(meter);
218+
} catch (Throwable throwable) {
219+
ExceptionUtils.rethrowIfFatal(throwable);
220+
Sentry.getCurrentScopes()
221+
.getOptions()
222+
.getLogger()
223+
.log(
224+
SentryLevel.DEBUG,
225+
throwable,
226+
"Failed to publish Micrometer meter %s to Sentry.",
227+
meter.getId().getName());
228+
}
229+
}
230+
}
231+
232+
private void publishPassiveMeter(final @NotNull Meter meter) {
233+
if (meter instanceof TimeGauge) {
234+
publishTimeGauge((TimeGauge) meter);
235+
} else if (meter instanceof Gauge) {
236+
publishGauge((Gauge) meter);
237+
} else if (meter instanceof LongTaskTimer) {
238+
publishLongTaskTimer((LongTaskTimer) meter);
239+
} else if (meter instanceof SentryFunctionCounter) {
240+
((SentryFunctionCounter<?>) meter).poll();
241+
}
242+
}
243+
244+
private void publishGauge(final @NotNull Gauge gauge) {
245+
final double value = gauge.value();
246+
if (Double.isFinite(value)) {
247+
captureGauge(createMetricInfo(gauge.getId()), value);
248+
}
249+
}
250+
251+
private void publishTimeGauge(final @NotNull TimeGauge gauge) {
252+
final double value = gauge.value(TimeUnit.MILLISECONDS);
253+
if (Double.isFinite(value)) {
254+
captureGauge(createMetricInfo(gauge.getId(), MetricsUnit.Duration.MILLISECOND), value);
255+
}
256+
}
257+
258+
private void publishLongTaskTimer(final @NotNull LongTaskTimer timer) {
259+
final @NotNull SentryMetricInfo activeMetric = createMetricInfo(timer.getId(), ".active", null);
260+
captureGauge(activeMetric, timer.activeTasks());
261+
262+
final double duration = timer.duration(TimeUnit.MILLISECONDS);
263+
if (Double.isFinite(duration)) {
264+
captureGauge(
265+
createMetricInfo(timer.getId(), ".duration", MetricsUnit.Duration.MILLISECOND), duration);
266+
}
267+
}
268+
269+
private void onMeterRemoved(final @NotNull Meter meter) {
270+
if (meter instanceof SentryFunctionCounter) {
271+
((SentryFunctionCounter<?>) meter).markRemoved();
272+
}
273+
}
274+
160275
private @NotNull SentryMetricInfo createMetricInfo(final @NotNull Meter.Id id) {
161276
return createMetricInfo(id, SentryMetricUnit.normalize(id.getBaseUnit()));
162277
}
163278

164279
private @NotNull SentryMetricInfo createMetricInfo(
165280
final @NotNull Meter.Id id, final @Nullable String unit) {
281+
return createMetricInfo(id, "", unit);
282+
}
283+
284+
private @NotNull SentryMetricInfo createMetricInfo(
285+
final @NotNull Meter.Id id, final @NotNull String suffix, final @Nullable String unit) {
166286
final @NotNull Map<String, Object> attributes = new HashMap<>();
167287
for (final @NotNull Tag tag : getConventionTags(id)) {
168288
attributes.put(tag.getKey(), tag.getValue());
169289
}
170-
return new SentryMetricInfo(getConventionName(id), unit, SentryAttributes.fromMap(attributes));
290+
return new SentryMetricInfo(
291+
getConventionName(id) + suffix, unit, SentryAttributes.fromMap(attributes));
292+
}
293+
294+
@Override
295+
public void close() {
296+
if (isClosed()) {
297+
return;
298+
}
299+
super.close();
300+
if (pollingTask != null) {
301+
pollingTask.cancel(true);
302+
}
303+
if (scheduler != null) {
304+
scheduler.shutdownNow();
305+
}
306+
}
307+
308+
private static @Nullable ScheduledExecutorService createScheduler(final long pollIntervalMillis) {
309+
validatePollInterval(pollIntervalMillis);
310+
if (pollIntervalMillis == 0) {
311+
return null;
312+
}
313+
return Executors.newSingleThreadScheduledExecutor(
314+
new NamedThreadFactory("sentry-micrometer-poller"));
315+
}
316+
317+
private static void validatePollInterval(final long pollIntervalMillis) {
318+
if (pollIntervalMillis < 0) {
319+
throw new IllegalArgumentException("The passive meter polling interval cannot be negative.");
320+
}
171321
}
172322
}

0 commit comments

Comments
 (0)