Error Is Not Exception: Micrometer's Silent CPU Metric on Native Image
Contents
Part 13 in the Memory Benchmark series.
The catch block that catches nothing
This code has a bug. Not a typo, not a logic error. A type hierarchy bug.
private double invoke(@Nullable Method method) {
try {
return method != null
? toDouble((Number) method.invoke(operatingSystemBean))
: Double.NaN;
}
catch (IllegalAccessException | IllegalArgumentException
| InvocationTargetException e) {
return Double.NaN;
}
}
That’s micrometer’s ProcessorMetrics.invoke() (v1.16.5). It calls JMX methods via reflection, wraps the call in three catch clauses. Standard defensive coding.
GraalVM 25 native image, when you invoke a method not registered in reachability metadata, throws MissingReflectionRegistrationError.
java.lang.Object
└── java.lang.Throwable
└── java.lang.Error
└── java.lang.LinkageError
└── MissingReflectionRegistrationError
Error, not Exception. The catch block catches three Exception subclasses. Error flies over every one of them.
The distinction exists for a reason. Error means “the JVM’s world is broken” — OutOfMemoryError, StackOverflowError, things you don’t recover from. GraalVM decided missing reflection metadata belongs in that category. Fair. But every library written before GraalVM 25 catches Exception, not Throwable.
The gap that doesn’t crash
Micrometer ships its own native image config at META-INF/native-image/io.micrometer/micrometer-core/reflect-config.json:
{
"name": "com.sun.management.OperatingSystemMXBean",
"methods": [
{ "name": "getCpuLoad", "parameterTypes": [] },
{ "name": "getProcessCpuLoad", "parameterTypes": [] },
{ "name": "getSystemCpuLoad", "parameterTypes": [] }
]
}
Three methods registered. Three CPU-related gauges that work on native image.
getProcessCpuTime is not in that list.
Here’s where it gets interesting. The type IS registered — with those three methods. When micrometer’s detectMethod calls getMethod("getProcessCpuTime"):
private @Nullable Method detectMethod(String name) {
if (operatingSystemBeanClass == null) return null;
try {
Object ignored = operatingSystemBeanClass.cast(operatingSystemBean);
return operatingSystemBeanClass.getMethod(name);
}
catch (ClassCastException | NoSuchMethodException | SecurityException e) {
return null;
}
}
GraalVM sees a registered type, checks its registered methods, doesn’t find getProcessCpuTime, throws NoSuchMethodException. Caught. Returns null.
Back in the constructor, processCpuTime is null. The if (processCpuTime != null) guard in bindTo() skips the FunctionCounter registration. No process_cpu_time_ns_total in prometheus output. No crash. No warning. No log line.
The metric isn’t there.
Three rows, three outcomes
| Type registered? | Method registered? | getMethod() | Result |
|---|---|---|---|
| No | — | MissingReflectionRegistrationError | App dies |
| Yes | No | NoSuchMethodException (caught) | Metric silently absent |
| Yes | Yes | Returns Method | Works |
Micrometer’s config puts getProcessCpuTime in row 2. If the type weren’t registered at all, the app would crash at startup — someone would notice, the fix would be obvious. Partial registration produces partial behavior. The dangerous kind. The kind that looks correct.
Weeks of zeros that looked plausible
Five benchmark scenarios. 1.66 million requests each. Prometheus scraping every 2 seconds. The process_cpu_time_ns_total counter for native scenarios: absent entirely. The process_cpu_usage gauge worked fine (uses getCpuLoad — registered).
A native binary that starts in 40ms and handles requests in microseconds. Zero CPU time looked… reasonable? Nobody questioned it.
It surfaced when regenerating metadata from scratch. The tracing agent captured getProcessCpuTime with query permission but not invoke. Now detectMethod returned a real Method object. invoke() called method.invoke(). MissingReflectionRegistrationError. Crash.
The fix that crashed the app was closer to correct than the silence that preceded it.
The fix: one test
A test that exercises the prometheus endpoint during the tracing agent run:
@MicronautTest
class ManagementEndpointsTest {
@Inject
@Client("/")
HttpClient client;
@Test
void prometheusEndpointReturnsMetrics() {
String body = client.toBlocking()
.retrieve("/prometheus");
assertThat(body).contains("process_cpu_time_ns_total");
}
}
./mvnw test -Pnative-agent
The agent watches the JVM. The test hits /prometheus. Micrometer calls getMethod("getProcessCpuTime"), gets a Method, calls invoke(). The agent captures both lookup and invocation. Regenerated metadata includes getProcessCpuTime with invoke permission. Native image works. The column has real numbers.
What this means
Error is not Exception. Every Java developer knows this in the abstract. Interviews ask about Throwable hierarchy. Nobody thinks about it when reviewing library catch blocks, because on a standard JVM it doesn’t matter — OperatingSystemMXBean.getProcessCpuTime() doesn’t throw Error.
GraalVM changed the contract. Methods that exist at compile time can be absent at runtime — not NoSuchMethodException absent, but Error absent. Libraries correct for 20 years of JVM execution have catch blocks that no longer cover their failure modes.
Library-shipped reflect-configs are partial. They register what the library’s CI tested, not what your usage exercises. The tracing agent captures what your tests execute. No test, no metadata. No metadata, no metric.
The tracing agent is not a safety net. It’s a recording device. Point the microphone at the right thing.
Sources
- Micrometer
ProcessorMetrics.java— the catch blocks and detectMethod logic - GraalVM
MissingReflectionRegistrationErrorjavadoc —extends LinkageError extends Error - GraalVM Reachability Metadata — query vs. invoke registration semantics
- GraalVM
--exact-reachability-metadatadiscussion — community discussion on registration granularity