Repository: helidon-io/helidon (java)

Issue:
Metrics are in in camelCase
[//]: # "Please remove these comments"
[//]: # "Provide a general summary of the issue in the Title above"

## Environment Details
* Helidon Version: 4.0.5
* Helidon MP
* JDK version: GraalVM 21
* OS: Ubuntu
* Docker version (if applicable): NA

----------

## Problem Description 1
Metrics created by Helidon are in camelCase. Prometheus community prefers snake_case. To reproduce:

```
 curl http://localhost:8080/metrics | ./promtool check metrics
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 20365  100 20365    0     0  1385k      0 --:--:-- --:--:-- --:--:-- 1420k
REST_request_unmappedException_total metric names should be written in 'snake_case' not 'camelCase'
classloader_loadedClasses_count metric names should be written in 'snake_case' not 'camelCase'
classloader_loadedClasses_count non-histogram and non-summary metrics should not have "_count" suffix
classloader_loadedClasses_total metric names should be written in 'snake_case' not 'camelCase'
classloader_unloadedClasses_total metric names should be written in 'snake_case' not 'camelCase'
cpu_availableProcessors metric names should be written in 'snake_case' not 'camelCase'
cpu_systemLoadAverage metric names should be written in 'snake_case' not 'camelCase'
createCustomer_total metric names should be written in 'snake_case' not 'camelCase'
createCustomer_total no help text
deleteCustomer_total metric names should be written in 'snake_case' not 'camelCase'
deleteCustomer_total no help text
getCustomerByEmail_total metric names should be written in 'snake_case' not 'camelCase'
getCustomerByEmail_total no help text
getCustomerById_total metric names should be written in 'snake_case' not 'camelCase'
getCustomerById_total no help text
getCustomers_total metric names should be written in 'snake_case' not 'camelCase'
getCustomers_total no help text
helidon_cloudbank_customer_totalCustomers_CustomerResource_total metric names should be written in 'snake_case' not 'camelCase'
helidon_cloudbank_customer_totalCustomers_CustomerResource_total no help text
hikaricp_connections_creation_milliseconds use base unit "seconds" instead of "milliseconds"
hikaricp_connections_creation_milliseconds_max use base unit "seconds" instead of "milliseconds"
hikaricp_connections_usage_milliseconds use base unit "seconds" instead of "milliseconds"
hikaricp_connections_usage_milliseconds_max use base unit "seconds" instead of "milliseconds"
hikaricp_connections_wait_nanoseconds use base unit "seconds" instead of "nanoseconds"
hikaricp_connections_wait_nanoseconds_max use base unit "seconds" instead of "nanoseconds"
memory_committedHeap_bytes metric names should be written in 'snake_case' not 'camelCase'
memory_maxHeap_bytes metric names should be written in 'snake_case' not 'camelCase'
memory_usedHeap_bytes metric names should be written in 'snake_case' not 'camelCase'
thread_count non-histogram and non-summary metrics should not have "_count" suffix
thread_daemon_count non-histogram and non-summary metrics should not have "_count" suffix
thread_max_count non-histogram and non-summary metrics should not have "_count" suffix
updateCustomer_total metric names should be written in 'snake_case' not 'camelCase'
updateCustomer_total no help text
```

## Problem description 2
No help text is generated. See above.


Pull request description:
Allow config to set camel (default) or snake case for built-in meter names
### Description
Resolves #8422 

1. Adds the metrics config  setting `built-in-meter-name-format` with choices `CAMEL` (default)` and `SNAKE`. The JavaDoc (and therefore the generated config documentation) notes that selecting `SNAKE` does not conform to the MP Metrics spec; the built-in meter names are specified there.
2. The existing system meters provider now looks at config to decide which naming scheme to use for the built-in meters (`memory.usedHeap` or `memory.used_heap` for example).
3. MP metrics now checks config to decide whether to use `REST.request.unmappedException.total` or `REST.request.unmapped_exception.total`.

The sets of names are hard-coded; the new code _does not_ try to convert "camelCase" names automatically to their "snake_case" counterparts.

### Documentation
The Javadoc for the new metrics config setting should be a good enough explanation without requiring changes to the doc page itself.

Patch:
diff --git a/metrics/api/src/main/java/io/helidon/metrics/api/BuiltInMeterNameFormat.java b/metrics/api/src/main/java/io/helidon/metrics/api/BuiltInMeterNameFormat.java
new file mode 100644
index 0000000000..e038adcd2e
--- /dev/null
+++ b/metrics/api/src/main/java/io/helidon/metrics/api/BuiltInMeterNameFormat.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2024 Oracle and/or its affiliates.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.helidon.metrics.api;
+
+/**
+ * Choices for the output format for built-in meter names.
+ */
+public enum BuiltInMeterNameFormat {
+    /**
+     * Snake-case.
+     */
+    SNAKE,
+
+    /**
+     * Camel-case (which is compatible with the MicroProfile Metrics spec).
+     */
+    CAMEL;
+
+    static final String DEFAULT = "CAMEL";
+}
diff --git a/metrics/api/src/main/java/io/helidon/metrics/api/MetricsConfigBlueprint.java b/metrics/api/src/main/java/io/helidon/metrics/api/MetricsConfigBlueprint.java
index dd0dd03578..4b4531b616 100644
--- a/metrics/api/src/main/java/io/helidon/metrics/api/MetricsConfigBlueprint.java
+++ b/metrics/api/src/main/java/io/helidon/metrics/api/MetricsConfigBlueprint.java
@@ -219,6 +219,18 @@ interface MetricsConfigBlueprint {
     @Option.Default("COUNTER")
     GcTimeType gcTimeType();
 
+    /**
+     * Output format for built-in meter names.
+     * <p>
+     *     {@link BuiltInMeterNameFormat#SNAKE} selects "snake_case" which does not conform to the MicroProfile
+     *     Metrics specification.
+     *
+     * @return the output format for built-in meter names
+     */
+    @Option.Configured
+    @Option.Default(BuiltInMeterNameFormat.DEFAULT)
+    BuiltInMeterNameFormat builtInMeterNameFormat();
+
     /**
      * Reports whether the specified scope is enabled, according to any scope configuration that
      * is part of this metrics configuration.
diff --git a/metrics/system-meters/pom.xml b/metrics/system-meters/pom.xml
index bd17ebc3da..fbf7463370 100644
--- a/metrics/system-meters/pom.xml
+++ b/metrics/system-meters/pom.xml
@@ -46,6 +46,17 @@
             <groupId>io.helidon.common</groupId>
             <artifactId>helidon-common</artifactId>
         </dependency>
+        <dependency>
+            <groupId>io.helidon.config</groupId>
+            <artifactId>helidon-config</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <!-- We need a full metrics implementation to test that config affects system meter names correctly. -->
+        <dependency>
+            <groupId>io.helidon.metrics.providers</groupId>
+            <artifactId>helidon-metrics-providers-micrometer</artifactId>
+            <scope>test</scope>
+        </dependency>
         <dependency>
             <groupId>io.helidon.common.testing</groupId>
             <artifactId>helidon-common-testing-junit5</artifactId>
diff --git a/metrics/system-meters/src/main/java/io/helidon/metrics/systemmeters/SystemMetersProvider.java b/metrics/system-meters/src/main/java/io/helidon/metrics/systemmeters/SystemMetersProvider.java
index 9f70e7bfc0..a90c79efe0 100644
--- a/metrics/system-meters/src/main/java/io/helidon/metrics/systemmeters/SystemMetersProvider.java
+++ b/metrics/system-meters/src/main/java/io/helidon/metrics/systemmeters/SystemMetersProvider.java
@@ -26,9 +26,12 @@ import java.lang.management.ThreadMXBean;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.function.Function;
 
+import io.helidon.metrics.api.BuiltInMeterNameFormat;
 import io.helidon.metrics.api.Meter;
 import io.helidon.metrics.api.MetricsFactory;
 import io.helidon.metrics.api.Tag;
@@ -42,12 +45,15 @@ public class SystemMetersProvider implements MetersProvider {
     private static final String BYTES = "bytes";
     private static final String SECONDS = "seconds";
     private static final String SCOPE = Meter.Scope.BASE;
-    private static final Metadata MEMORY_USED_HEAP = Metadata.builder()
+
+    // Maps camelCase names to snake_case, but only for those names that are actually different in the two cases.
+    private static final Map<String, String> CAMEL_TO_SNAKE_CASE_METER_NAMES = initMeterNames();
+
+    private static final Metadata.Builder MEMORY_USED_HEAP = Metadata.builder()
             .withName("memory.usedHeap")
             .withDescription("Displays the amount of used heap memory in bytes.")
-            .withUnit(BYTES)
-            .build();
-    private static final Metadata MEMORY_COMMITTED_HEAP = Metadata.builder()
+            .withUnit(BYTES);
+    private static final Metadata.Builder MEMORY_COMMITTED_HEAP = Metadata.builder()
             .withName("memory.committedHeap")
             .withDescription(
                     "Displays the amount of memory in bytes that is "
@@ -55,9 +61,8 @@ public class SystemMetersProvider implements MetersProvider {
                             + "machine to use. This amount of memory is "
                             + "guaranteed for the Java virtual "
                             + "machine to use.")
-            .withUnit(BYTES)
-            .build();
-    private static final Metadata MEMORY_MAX_HEAP = Metadata.builder()
+            .withUnit(BYTES);
+    private static final Metadata.Builder MEMORY_MAX_HEAP = Metadata.builder()
             .withName("memory.maxHeap")
             .withDescription(
                     "Displays the maximum amount of heap memory in bytes that can"
@@ -72,9 +77,8 @@ public class SystemMetersProvider implements MetersProvider {
                             + " to allocate memory "
                             + "even if the amount of used memory does not exceed "
                             + "this maximum size.")
-            .withUnit(BYTES)
-            .build();
-    private static final Metadata JVM_UPTIME = Metadata.builder()
+            .withUnit(BYTES);
+    private static final Metadata.Builder JVM_UPTIME = Metadata.builder()
             .withName("jvm.uptime")
             .withDescription(
                     "Displays the start time of the Java virtual machine in "
@@ -82,47 +86,39 @@ public class SystemMetersProvider implements MetersProvider {
                             + "attribute displays the approximate time when the Java "
                             + "virtual machine "
                             + "started.")
-            .withUnit(SECONDS)
-            .build();
-    private static final Metadata THREAD_COUNT = Metadata.builder()
+            .withUnit(SECONDS);
+    private static final Metadata.Builder THREAD_COUNT = Metadata.builder()
             .withName("thread.count")
             .withDescription("Displays the current number of live threads including both "
-                                     + "daemon and nondaemon threads")
-            .build();
-    private static final Metadata THREAD_DAEMON_COUNT = Metadata.builder()
+                                     + "daemon and nondaemon threads");
+    private static final Metadata.Builder THREAD_DAEMON_COUNT = Metadata.builder()
             .withName("thread.daemon.count")
-            .withDescription("Displays the current number of live daemon threads.")
-            .build();
-    private static final Metadata THREAD_MAX_COUNT = Metadata.builder()
+            .withDescription("Displays the current number of live daemon threads.");
+    private static final Metadata.Builder THREAD_MAX_COUNT = Metadata.builder()
             .withName("thread.max.count")
             .withDescription("Displays the peak live thread count since the Java "
                                      + "virtual machine started or "
                                      + "peak was reset. This includes daemon and "
-                                     + "non-daemon threads.")
-            .build();
-    private static final Metadata CL_LOADED_COUNT = Metadata.builder()
+                                     + "non-daemon threads.");
+    private static final Metadata.Builder CL_LOADED_COUNT = Metadata.builder()
             .withName("classloader.loadedClasses.count")
             .withDescription("Displays the number of classes that are currently loaded in "
-                                     + "the Java virtual machine.")
-            .build();
-    private static final Metadata CL_LOADED_TOTAL = Metadata.builder()
+                                     + "the Java virtual machine.");
+    private static final Metadata.Builder CL_LOADED_TOTAL = Metadata.builder()
             .withName("classloader.loadedClasses.total")
             .withDescription("Displays the total number of classes that have been loaded "
-                                     + "since the Java virtual machine has started execution.")
-            .build();
-    private static final Metadata CL_UNLOADED_COUNT = Metadata.builder()
+                                     + "since the Java virtual machine has started execution.");
+    private static final Metadata.Builder CL_UNLOADED_COUNT = Metadata.builder()
             .withName("classloader.unloadedClasses.total")
             .withDescription("Displays the total number of classes unloaded since the Java "
-                                     + "virtual machine has started execution.")
-            .build();
-    private static final Metadata OS_AVAILABLE_CPU = Metadata.builder()
+                                     + "virtual machine has started execution.");
+    private static final Metadata.Builder OS_AVAILABLE_CPU = Metadata.builder()
             .withName("cpu.availableProcessors")
             .withDescription("Displays the number of processors available to the Java "
                                      + "virtual machine. This "
                                      + "value may change during a particular invocation of"
-                                     + " the virtual machine.")
-            .build();
-    private static final Metadata OS_LOAD_AVERAGE = Metadata.builder()
+                                     + " the virtual machine.");
+    private static final Metadata.Builder OS_LOAD_AVERAGE = Metadata.builder()
             .withName("cpu.systemLoadAverage")
             .withDescription("Displays the system load average for the last minute. The "
                                      + "system load average "
@@ -141,9 +137,8 @@ public class SystemMetersProvider implements MetersProvider {
                                      + "and may be queried frequently. The load average may"
                                      + " be unavailable on some "
                                      + "platforms where it is expensive to implement this "
-                                     + "method.")
-            .build();
-    private static final Metadata GC_TIME = Metadata.builder()
+                                     + "method.");
+    private static final Metadata.Builder GC_TIME = Metadata.builder()
             .withName("gc.time")
             .withDescription(
                     "Displays the approximate accumulated collection elapsed time in seconds. "
@@ -152,14 +147,12 @@ public class SystemMetersProvider implements MetersProvider {
                             + "timer to measure the elapsed time. This attribute may display the same value "
                             + "even if the collection count has been incremented if the collection elapsed "
                             + "time is very short.")
-            .withUnit("seconds")
-            .build();
-    private static final Metadata GC_COUNT = Metadata.builder()
+            .withUnit("seconds");
+    private static final Metadata.Builder GC_COUNT = Metadata.builder()
             .withName("gc.total")
             .withDescription(
                     "Displays the total number of collections that have occurred. This attribute lists "
-                            + "-1 if the collection count is undefined for this collector.")
-            .build();
+                            + "-1 if the collection count is undefined for this collector.");
 
     private MetricsFactory metricsFactory;
 
@@ -172,6 +165,19 @@ public class SystemMetersProvider implements MetersProvider {
     public SystemMetersProvider() {
     }
 
+    private static Map<String, String> initMeterNames() {
+        Map<String, String> result = new HashMap<>();
+        result.put("memory.usedHeap", "memory.used_heap");
+        result.put("memory.committedHeap", "memory.committed_heap");
+        result.put("memory.maxHeap", "memory.max_heap");
+        result.put("classloader.loadedClasses.count", "classloader.loaded_classes.count");
+        result.put("classloader.loadedClasses.total", "classloader.loaded_classes.total");
+        result.put("classloader.unloadedClasses.total", "classloader.unloaded_classes.total");
+        result.put("cpu.availableProcessors", "cpu.available_processors");
+        result.put("cpu.systemLoadAverage", "cpu.system_load_average");
+        return result;
+    }
+
     @Override
     public Collection<Meter.Builder<?, ?>> meterBuilders(MetricsFactory metricsFactory) {
         this.metricsFactory = metricsFactory; // save at the instance level for ease of access
@@ -193,6 +199,18 @@ public class SystemMetersProvider implements MetersProvider {
         return main -> valueFn.apply(getSubBeanFn.apply(main));
     }
 
+    private Metadata metadata(Metadata.Builder metadataBuilderWithCamelCaseName) {
+        String camelCaseName = metadataBuilderWithCamelCaseName.name;
+        Metadata.Builder builder = metadataBuilderWithCamelCaseName;
+        if (metricsFactory.metricsConfig().builtInMeterNameFormat() == BuiltInMeterNameFormat.SNAKE) {
+            builder = Metadata.builder()
+                    .withName(CAMEL_TO_SNAKE_CASE_METER_NAMES.getOrDefault(camelCaseName, camelCaseName))
+                    .withDescription(metadataBuilderWithCamelCaseName.description)
+                    .withUnit(metadataBuilderWithCamelCaseName.baseUnit);
+        }
+        return builder.build();
+    }
+
     private <B extends Meter.Builder<B, M>,
             M extends Meter>
     Collection<Meter.Builder<?, ?>> prepareMeterBuilders() {
@@ -202,40 +220,43 @@ public class SystemMetersProvider implements MetersProvider {
 
         // load all base metrics
         registerGauge(result,
-                      MEMORY_USED_HEAP,
+                      metadata(MEMORY_USED_HEAP),
                       memoryBean,
                       typedFn(MemoryMXBean::getHeapMemoryUsage, MemoryUsage::getUsed));
         registerGauge(result,
-                      MEMORY_COMMITTED_HEAP,
+                      metadata(MEMORY_COMMITTED_HEAP),
                       memoryBean,
                       typedFn(MemoryMXBean::getHeapMemoryUsage, MemoryUsage::getCommitted));
         registerGauge(result,
-                      MEMORY_MAX_HEAP,
+                      metadata(MEMORY_MAX_HEAP),
                       memoryBean,
                       typedFn(MemoryMXBean::getHeapMemoryUsage, MemoryUsage::getMax));
 
         RuntimeMXBean runtimeBean = ManagementFactory.getRuntimeMXBean();
-        registerGauge(result, JVM_UPTIME, runtimeBean, rtBean -> rtBean.getUptime() / 1000.0D);
+        registerGauge(result,
+                      metadata(JVM_UPTIME),
+                      runtimeBean,
+                      rtBean -> rtBean.getUptime() / 1000.0D);
 
         ThreadMXBean threadBean = ManagementFactory.getThreadMXBean();
-        registerGauge(result, THREAD_COUNT, threadBean, ThreadMXBean::getThreadCount);
-        registerGauge(result, THREAD_DAEMON_COUNT, threadBean, ThreadMXBean::getDaemonThreadCount);
-        registerGauge(result, THREAD_MAX_COUNT, threadBean, ThreadMXBean::getPeakThreadCount);
+        registerGauge(result, metadata(THREAD_COUNT), threadBean, ThreadMXBean::getThreadCount);
+        registerGauge(result, metadata(THREAD_DAEMON_COUNT), threadBean, ThreadMXBean::getDaemonThreadCount);
+        registerGauge(result, metadata(THREAD_MAX_COUNT), threadBean, ThreadMXBean::getPeakThreadCount);
 
         ClassLoadingMXBean clBean = ManagementFactory.getClassLoadingMXBean();
-        registerGauge(result, CL_LOADED_COUNT, clBean, ClassLoadingMXBean::getLoadedClassCount);
-        registerFunctionalCounter(result, CL_LOADED_TOTAL, clBean, ClassLoadingMXBean::getTotalLoadedClassCount);
-        registerFunctionalCounter(result, CL_UNLOADED_COUNT, clBean, ClassLoadingMXBean::getUnloadedClassCount);
+        registerGauge(result, metadata(CL_LOADED_COUNT), clBean, ClassLoadingMXBean::getLoadedClassCount);
+        registerFunctionalCounter(result, metadata(CL_LOADED_TOTAL), clBean, ClassLoadingMXBean::getTotalLoadedClassCount);
+        registerFunctionalCounter(result, metadata(CL_UNLOADED_COUNT), clBean, ClassLoadingMXBean::getUnloadedClassCount);
 
         OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean();
-        registerGauge(result, OS_AVAILABLE_CPU, osBean, OperatingSystemMXBean::getAvailableProcessors);
-        registerGauge(result, OS_LOAD_AVERAGE, osBean, OperatingSystemMXBean::getSystemLoadAverage);
+        registerGauge(result, metadata(OS_AVAILABLE_CPU), osBean, OperatingSystemMXBean::getAvailableProcessors);
+        registerGauge(result, metadata(OS_LOAD_AVERAGE), osBean, OperatingSystemMXBean::getSystemLoadAverage);
 
         List<GarbageCollectorMXBean> gcBeans = ManagementFactory.getGarbageCollectorMXBeans();
         for (GarbageCollectorMXBean gcBean : gcBeans) {
             String poolName = gcBean.getName();
             registerFunctionalCounter(result,
-                          GC_COUNT,
+                          metadata(GC_COUNT),
                           gcBean,
                           GarbageCollectorMXBean::getCollectionCount,
                           Tag.create("name", poolName));
@@ -244,13 +265,13 @@ public class SystemMetersProvider implements MetersProvider {
             // type of meter to register.
             if (isGcTimeGauge()) {
                 registerGauge(result,
-                              GC_TIME,
+                              metadata(GC_TIME),
                               gcBean,
                               bean -> (long) (bean.getCollectionTime() / 1000.0D),
                               Tag.create("name", poolName));
             } else {
                 registerFunctionalCounter(result,
-                                          GC_TIME,
+                                          metadata(GC_TIME),
                                           gcBean,
                                           bean -> (long) (bean.getCollectionTime() / 1000.0D),
                                           Tag.create("name", poolName));
diff --git a/microprofile/metrics/src/main/java/io/helidon/microprofile/metrics/MetricsCdiExtension.java b/microprofile/metrics/src/main/java/io/helidon/microprofile/metrics/MetricsCdiExtension.java
index 64ab36bae8..d598f31240 100644
--- a/microprofile/metrics/src/main/java/io/helidon/microprofile/metrics/MetricsCdiExtension.java
+++ b/microprofile/metrics/src/main/java/io/helidon/microprofile/metrics/MetricsCdiExtension.java
@@ -42,6 +42,7 @@ import io.helidon.common.context.Contexts;
 import io.helidon.config.Config;
 import io.helidon.config.ConfigSources;
 import io.helidon.config.ConfigValue;
+import io.helidon.metrics.api.BuiltInMeterNameFormat;
 import io.helidon.metrics.api.MeterRegistry;
 import io.helidon.metrics.api.MetricsConfig;
 import io.helidon.metrics.api.MetricsFactory;
@@ -125,13 +126,6 @@ public class MetricsCdiExtension extends HelidonRestCdiExtension {
     static final String SYNTHETIC_TIMER_METRIC_NAME = "REST.request";
     static final String SYNTHETIC_TIMER_METRIC_UNMAPPED_EXCEPTION_NAME =
             SYNTHETIC_TIMER_METRIC_NAME + ".unmappedException.total";
-    static final Metadata SYNTHETIC_TIMER_UNMAPPED_EXCEPTION_METADATA = Metadata.builder()
-            .withName(SYNTHETIC_TIMER_METRIC_UNMAPPED_EXCEPTION_NAME)
-            .withDescription("""
-                                     The total number of unmapped exceptions that occur from this RESTful resouce method since \
-                                     the start of the server.""")
-            .withUnit(MetricUnits.NONE)
-            .build();
     static final Metadata SYNTHETIC_TIMER_METADATA = Metadata.builder()
             .withName(SYNTHETIC_TIMER_METRIC_NAME)
             .withDescription("""
@@ -155,6 +149,7 @@ public class MetricsCdiExtension extends HelidonRestCdiExtension {
                 }
             };
     private static final boolean REST_ENDPOINTS_METRIC_ENABLED_DEFAULT_VALUE = false;
+    private static Metadata syntheticTimerUnmappedExceptionMetadata;
     private final Map<MetricID, AnnotatedMethod<?>> annotatedGaugeSites = new HashMap<>();
     private final List<RegistrationPrep> annotatedSites = new ArrayList<>();
     private final Map<Class<?>, Set<Method>> methodsWithRestRequestMetrics = new HashMap<>();
@@ -170,6 +165,7 @@ public class MetricsCdiExtension extends HelidonRestCdiExtension {
     private final Map<Class<?>, StereotypeMetricsInfo> stereotypeMetricsInfo = new HashMap<>();
     private boolean restEndpointsMetricsEnabled = REST_ENDPOINTS_METRIC_ENABLED_DEFAULT_VALUE;
     private Errors.Collector errors = Errors.collector();
+    private String syntheticTimerMetricUnmappedExceptionName;
 
     /**
      * Creates a new extension instance.
@@ -226,7 +222,7 @@ public class MetricsCdiExtension extends HelidonRestCdiExtension {
                    () -> String.format("Registering synthetic Counter for %s#%s", method.getDeclaringClass().getName(),
                                        method.getName()));
         return getRegistryForSyntheticRestRequestMetrics()
-                .counter(SYNTHETIC_TIMER_UNMAPPED_EXCEPTION_METADATA, syntheticRestRequestMetricTags(method));
+                .counter(syntheticTimerUnmappedExceptionMetadata, syntheticRestRequestMetricTags(method));
     }
 
     /**
@@ -245,8 +241,8 @@ public class MetricsCdiExtension extends HelidonRestCdiExtension {
      * @param method Java method of interest
      * @return {@code MetricID} for the counter for this Java method
      */
-    static MetricID restEndpointCounterMetricID(Method method) {
-        return new MetricID(SYNTHETIC_TIMER_METRIC_UNMAPPED_EXCEPTION_NAME, syntheticRestRequestMetricTags(method));
+    MetricID restEndpointCounterMetricID(Method method) {
+        return new MetricID(syntheticTimerMetricUnmappedExceptionName, syntheticRestRequestMetricTags(method));
     }
 
     /**
@@ -559,6 +555,17 @@ public class MetricsCdiExtension extends HelidonRestCdiExtension {
 
         Contexts.globalContext().register(metricsFactory);
         MetricsConfig metricsConfig = metricsFactory.metricsConfig();
+        syntheticTimerMetricUnmappedExceptionName =
+                metricsConfig.builtInMeterNameFormat() == BuiltInMeterNameFormat.CAMEL
+                ? SYNTHETIC_TIMER_METRIC_UNMAPPED_EXCEPTION_NAME
+                        : SYNTHETIC_TIMER_METRIC_NAME + ".unmapped_exception.total";
+        syntheticTimerUnmappedExceptionMetadata = Metadata.builder()
+                .withName(syntheticTimerMetricUnmappedExceptionName)
+                .withDescription("""
+                                     The total number of unmapped exceptions that occur from this RESTful resouce method since \
+                                     the start of the server.""")
+                .withUnit(MetricUnits.NONE)
+                .build();
         MeterRegistry meterRegistry = metricsFactory.globalRegistry(metricsConfig);
         return builder.metricsConfig(metricsConfig)
                 .meterRegistry(meterRegistry)


Test patch:
diff --git a/metrics/system-meters/src/test/java/io/helidon/metrics/systemmeters/MeterBuilderMatcher.java b/metrics/system-meters/src/test/java/io/helidon/metrics/systemmeters/MeterBuilderMatcher.java
new file mode 100644
index 0000000000..04709b742b
--- /dev/null
+++ b/metrics/system-meters/src/test/java/io/helidon/metrics/systemmeters/MeterBuilderMatcher.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2024 Oracle and/or its affiliates.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.helidon.metrics.systemmeters;
+
+import io.helidon.metrics.api.Meter;
+
+import org.hamcrest.Description;
+import org.hamcrest.Matcher;
+import org.hamcrest.TypeSafeMatcher;
+
+/**
+ * Hamcrest matcher for unit tests involving {@link Meter.Builder} objects.
+ */
+class MeterBuilderMatcher {
+
+    /**
+     * A matcher that checks the meter builder's name using the supplied string matcher.
+     *
+     * @param matcher string matcher to apply to the meter builder's name
+     * @return matcher applicable to a meter builder for checking the name
+     */
+    static Matcher<Meter.Builder<?, ?>> withName(Matcher<String> matcher) {
+        return new WithName(matcher);
+    }
+
+    private static class WithName extends TypeSafeMatcher<Meter.Builder<?, ?>> {
+
+        private final Matcher<? super String> matcher;
+
+        private WithName(Matcher<? super String> matcher) {
+            this.matcher = matcher;
+        }
+
+        @Override
+        protected boolean matchesSafely(Meter.Builder<?, ?> item) {
+            return matcher.matches(item.name());
+        }
+
+        @Override
+        public void describeTo(Description description) {
+            description.appendText("with name ");
+            description.appendDescriptionOf(matcher);
+        }
+    }
+}
diff --git a/metrics/system-meters/src/test/java/io/helidon/metrics/systemmeters/TestBuiltInMeterCaseSelection.java b/metrics/system-meters/src/test/java/io/helidon/metrics/systemmeters/TestBuiltInMeterCaseSelection.java
new file mode 100644
index 0000000000..3d0ba2e1d9
--- /dev/null
+++ b/metrics/system-meters/src/test/java/io/helidon/metrics/systemmeters/TestBuiltInMeterCaseSelection.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2024 Oracle and/or its affiliates.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.helidon.metrics.systemmeters;
+
+import java.util.Collection;
+import java.util.Map;
+
+import io.helidon.common.config.Config;
+import io.helidon.config.ConfigSources;
+import io.helidon.metrics.api.Meter;
+import io.helidon.metrics.api.MetricsFactory;
+
+import org.junit.jupiter.api.Test;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.allOf;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasItem;
+import static org.hamcrest.Matchers.not;
+
+class TestBuiltInMeterCaseSelection {
+
+    @Test
+    void testDefaults() {
+        SystemMetersProvider provider = new SystemMetersProvider();
+        MetricsFactory metricsFactory = MetricsFactory.getInstance(Config.empty());
+        Collection<Meter.Builder<?, ?>> meterBuilders = provider.meterBuilders(metricsFactory);
+
+        assertThat("Default used heap name", meterBuilders, allOf(
+                hasItem(MeterBuilderMatcher.withName(equalTo("memory.usedHeap"))),
+                not(hasItem(MeterBuilderMatcher.withName(equalTo("memory.used_heap"))))));
+    }
+
+    @Test
+    void testWithSnakeConfigured() {
+        SystemMetersProvider provider = new SystemMetersProvider();
+        Config config = io.helidon.config.Config.just(ConfigSources.create(Map.of("metrics.built-in-meter-name-format", "SNAKE")));
+        MetricsFactory metricsFactory = MetricsFactory.getInstance(config.get("metrics"));
+        Collection<Meter.Builder<?, ?>> meterBuilders = provider.meterBuilders(metricsFactory);
+
+        assertThat("Configured used heap name", meterBuilders, allOf(
+                hasItem(MeterBuilderMatcher.withName(equalTo("memory.used_heap"))),
+                not(hasItem(MeterBuilderMatcher.withName(equalTo("memory.usedHeap"))))));
+    }
+}
diff --git a/microprofile/metrics/src/test/java/io/helidon/microprofile/metrics/MetricIDMatcher.java b/microprofile/metrics/src/test/java/io/helidon/microprofile/metrics/MetricIDMatcher.java
new file mode 100644
index 0000000000..34018ed375
--- /dev/null
+++ b/microprofile/metrics/src/test/java/io/helidon/microprofile/metrics/MetricIDMatcher.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2024 Oracle and/or its affiliates.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.helidon.microprofile.metrics;
+
+import org.eclipse.microprofile.metrics.MetricID;
+import org.hamcrest.Description;
+import org.hamcrest.Matcher;
+import org.hamcrest.TypeSafeMatcher;
+
+class MetricIDMatcher {
+
+    static WithName withName(Matcher<? super String> matcher) {
+        return new WithName(matcher);
+    }
+
+    private static class WithName extends TypeSafeMatcher<MetricID> {
+
+        private final Matcher<? super String> matcher;
+
+        private WithName(Matcher<? super String> matcher) {
+            this.matcher = matcher;
+        }
+
+        @Override
+        protected boolean matchesSafely(MetricID item) {
+            return matcher.matches(item.getName());
+        }
+
+        @Override
+        public void describeTo(Description description) {
+            description.appendText("metric ID name");
+            description.appendDescriptionOf(matcher);
+        }
+    }
+
+}
diff --git a/microprofile/metrics/src/test/java/io/helidon/microprofile/metrics/TestConfiguredBuiltInMeterNameFormat.java b/microprofile/metrics/src/test/java/io/helidon/microprofile/metrics/TestConfiguredBuiltInMeterNameFormat.java
new file mode 100644
index 0000000000..6f8cf0d819
--- /dev/null
+++ b/microprofile/metrics/src/test/java/io/helidon/microprofile/metrics/TestConfiguredBuiltInMeterNameFormat.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright (c) 2024 Oracle and/or its affiliates.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.helidon.microprofile.metrics;
+
+import java.util.Map;
+
+import io.helidon.microprofile.testing.junit5.AddBean;
+import io.helidon.microprofile.testing.junit5.AddConfig;
+import io.helidon.microprofile.testing.junit5.HelidonTest;
+
+import jakarta.inject.Inject;
+import org.eclipse.microprofile.metrics.Metric;
+import org.eclipse.microprofile.metrics.MetricID;
+import org.eclipse.microprofile.metrics.MetricRegistry;
+import org.eclipse.microprofile.metrics.annotation.RegistryScope;
+import org.junit.jupiter.api.Test;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.allOf;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasItem;
+import static org.hamcrest.Matchers.not;
+
+@HelidonTest
+@AddConfig(key = "metrics." + MetricsCdiExtension.REST_ENDPOINTS_METRIC_ENABLED_PROPERTY_NAME, value = "true")
+@AddConfig(key = "metrics.built-in-meter-name-format", value = "SNAKE")
+@AddBean(HelloWorldResource.class)
+class TestConfiguredBuiltInMeterNameFormat {
+
+    @Inject
+    @RegistryScope(scope = MetricRegistry.BASE_SCOPE)
+    private MetricRegistry baseRegistry;
+
+    @Test
+    void checkDefaultNames() {
+        Map<MetricID, Metric> baseMetrics = baseRegistry.getMetrics();
+
+        assertThat("Built-in metrics", baseMetrics.keySet(), allOf(
+                not(hasItem(MetricIDMatcher.withName(equalTo("memory.usedHeap")))),
+                hasItem(MetricIDMatcher.withName(equalTo("memory.used_heap")))));
+
+        Map<MetricID, Metric> metrics = baseRegistry.getMetrics();
+        assertThat("REST.request unmapped exception metric", metrics.keySet(), allOf(
+                not(hasItem(MetricIDMatcher.withName(equalTo(MetricsCdiExtension.SYNTHETIC_TIMER_METRIC_UNMAPPED_EXCEPTION_NAME)))),
+                hasItem(MetricIDMatcher.withName(equalTo(MetricsCdiExtension.SYNTHETIC_TIMER_METRIC_NAME +
+                                                                 ".unmapped_exception.total")))));
+
+    }
+}
diff --git a/microprofile/metrics/src/test/java/io/helidon/microprofile/metrics/TestDefaultBuiltInMeterNameFormat.java b/microprofile/metrics/src/test/java/io/helidon/microprofile/metrics/TestDefaultBuiltInMeterNameFormat.java
new file mode 100644
index 0000000000..a5adfeee1c
--- /dev/null
+++ b/microprofile/metrics/src/test/java/io/helidon/microprofile/metrics/TestDefaultBuiltInMeterNameFormat.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright (c) 2024 Oracle and/or its affiliates.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.helidon.microprofile.metrics;
+
+import java.util.Map;
+
+import io.helidon.microprofile.testing.junit5.AddBean;
+import io.helidon.microprofile.testing.junit5.AddConfig;
+import io.helidon.microprofile.testing.junit5.HelidonTest;
+
+import jakarta.inject.Inject;
+import org.eclipse.microprofile.metrics.Metric;
+import org.eclipse.microprofile.metrics.MetricID;
+import org.eclipse.microprofile.metrics.MetricRegistry;
+import org.eclipse.microprofile.metrics.annotation.RegistryScope;
+import org.junit.jupiter.api.Test;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.allOf;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasItem;
+import static org.hamcrest.Matchers.not;
+
+@HelidonTest
+@AddConfig(key = "metrics." + MetricsCdiExtension.REST_ENDPOINTS_METRIC_ENABLED_PROPERTY_NAME, value = "true")
+@AddBean(HelloWorldResource.class)
+class TestDefaultBuiltInMeterNameFormat {
+
+    @Inject
+    @RegistryScope(scope = MetricRegistry.BASE_SCOPE)
+    private MetricRegistry baseRegistry;
+
+    @Test
+    void checkDefaultNames() {
+        Map<MetricID, Metric> baseMetrics = baseRegistry.getMetrics();
+
+        assertThat("Built-in metrics", baseMetrics.keySet(), allOf(
+                hasItem(MetricIDMatcher.withName(equalTo("memory.usedHeap"))),
+                not(hasItem(MetricIDMatcher.withName(equalTo("memory.used_heap"))))));
+
+        Map<MetricID, Metric> metrics = baseRegistry.getMetrics();
+        assertThat("REST.request unmapped exception metric", metrics.keySet(), allOf(
+                hasItem(MetricIDMatcher.withName(equalTo(MetricsCdiExtension.SYNTHETIC_TIMER_METRIC_UNMAPPED_EXCEPTION_NAME))),
+                not(hasItem(MetricIDMatcher.withName(equalTo(MetricsCdiExtension.SYNTHETIC_TIMER_METRIC_NAME +
+                                                                     ".unmapped_exception.total"))))));
+
+    }
+
+}