Repository: helidon-io/helidon (java)

Issue:
Unable to disable application scope metrics: "allGets" and "personalisedGets"

## Environment Details
* Helidon Version: 4.0.10
* Helidon SE or Helidon MP: Helidon MP
* JDK version: 22.0.1
* OS: Darwin
* Docker version (if applicable):

----------

## Problem Description
In a Helidon MP Quickstart application, there are two application scope metrics **personalisedGets** and **allGets** defined in the **SimpleGreetResource.java** file. I tried to disable these two metrics by adding the following two lines in the **META-INF/microprofile-config.properties** :
metrics.scoping.scopes.0.name=application
metrics.scoping.scopes.0.filter.exclude=.*Gets

Current behaviour: 
- The application throws an exception on startup.

Expected behaviour: 
- The application should start, i.e, no errors/exceptions on startup
- On accessing the application metrics endpoint using **curl -H "Accept: application/json" http://localhost:8080/metrics**, it should either display **Metrics are disabled** if all application metrics end with "Gets" or should not display any application metric ending with "Gets".

The problem is easily reproducible.

The exception thrown and stack trace:

Exception in thread "main" java.lang.IllegalArgumentException: Attempt to look up or register a metric for annotation org.eclipse.microprofile.metrics.annotation.Counted at public labs.helidon.mp.Message labs.helidon.mp.SimpleGreetResource.getMessage(java.lang.String) failed; the metadata implied by the annotation DefaultMetadata{name='personalizedGets', unit='null', description='Counts personalized GET operations'} does not match the pre-existing metadata DefaultMetadata{name='personalizedGets', unit='', description=null} for [description]
	at io.helidon.microprofile.metrics.MetricAnnotationInfo$RegistrationPrep.validateMetadata(MetricAnnotationInfo.java:166)
	at io.helidon.microprofile.metrics.MetricAnnotationInfo$RegistrationPrep.register(MetricAnnotationInfo.java:134)
	at io.helidon.microprofile.metrics.MetricsCdiExtension.lambda$registerMetricsForAnnotatedSites$14(MetricsCdiExtension.java:564)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1597)
	at io.helidon.microprofile.metrics.MetricsCdiExtension.registerMetricsForAnnotatedSites(MetricsCdiExtension.java:561)
	at io.helidon.microprofile.metrics.MetricsCdiExtension.registerService(MetricsCdiExtension.java:323)
	at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
	at java.base/java.lang.reflect.Method.invoke(Method.java:580)
	at org.jboss.weld.injection.StaticMethodInjectionPoint.invoke(StaticMethodInjectionPoint.java:95)
	at org.jboss.weld.injection.StaticMethodInjectionPoint.invoke(StaticMethodInjectionPoint.java:85)
	at org.jboss.weld.injection.MethodInvocationStrategy$DefaultMethodInvocationStrategy.invoke(MethodInvocationStrategy.java:144)
	at org.jboss.weld.event.ObserverMethodImpl.sendEvent(ObserverMethodImpl.java:330)
	at org.jboss.weld.event.ExtensionObserverMethodImpl.sendEvent(ExtensionObserverMethodImpl.java:126)
	at org.jboss.weld.event.ObserverMethodImpl.sendEvent(ObserverMethodImpl.java:308)
	at org.jboss.weld.event.ObserverMethodImpl.notify(ObserverMethodImpl.java:286)
	at jakarta.enterprise.inject.spi.ObserverMethod.notify(ObserverMethod.java:142)
	at org.jboss.weld.util.Observers.notify(Observers.java:166)
	at org.jboss.weld.event.ObserverNotifier.notifySyncObservers(ObserverNotifier.java:285)
	at org.jboss.weld.event.ObserverNotifier.notify(ObserverNotifier.java:273)
	at org.jboss.weld.event.EventImpl.fire(EventImpl.java:96)
	at io.helidon.microprofile.cdi.HelidonContainerImpl.doStart(HelidonContainerImpl.java:311)
	at io.helidon.common.context.Contexts.runInContext(Contexts.java:137)
	at io.helidon.microprofile.cdi.HelidonContainerImpl.start(HelidonContainerImpl.java:258)
	at io.helidon.microprofile.cdi.Main.main(Main.java:80)
	at io.helidon.microprofile.cdi.CdiStartupProvider.start(CdiStartupProvider.java:39)
	at io.helidon.Main.main(Main.java:85)

## Steps to reproduce

1. Create a Helidon MP Quickstart application using helidon init: version 4.0.10 and cd into the application directory.
2. Add the following two lines in the **src/main/java/resources/META-INF/microprofile-config.properties**:
metrics.scoping.scopes.0.name=application
metrics.scoping.scopes.0.filter.exclude=.*Gets
3. Run using "helidon dev" or "mvn clean package -DskipTests && java -jar target/quickstart-mp.jar".


I am attaching my zipped test folder here.

[quickstart-mp.zip](https://github.com/user-attachments/files/15932064/quickstart-mp.zip)



Pull request:
4.0.11: Properly handle disabled metrics in MP
### Description

Backport of #8908 to 4.0.11

Resolves #8906

In Helidon 3.x, if the metrics configuration disabled a metric then Helidon created a no-op metric and registered it in the registry.

For Helidon 4.x, after discussion and in conformance with common practice, Helidon SE no longer registers a disabled meter in the meter registry or stores it in the related data structures. It simply returns a no-op meter when one is looked up or registered.

Helidon MP metrics needs additional data structures beyond the SE meter registry. When code invokes the MP metrics API to get a metric, MP metrics consults those MP data structures. If the requested metric does not exist, the MP code delegates to the SE code to register a meter and then via an "on-create" callback updates its data structures and returns an MP metric wrapper around the new SE meter.

In this processing, though, the MP code did not check whether the created SE meter was a no-op or not and this led to the MP data structures being out of sync with the SE meter registry and led to the error reported in the issue.

The key change in the PR is that Helidon MP metrics is now sensitive to whether a searched-for metric is disabled or not. If enabled, it uses the old code path which always worked for that case. If disabled, it now bypasses the attempt to look-up the SE delegate and returns a Helidon MP wrapper around the disabled SE meter delegate.

To accomplish this, each Helidon MP metric type (HelidonCounter, etc.) add a new static create factory method which accepts the corresponding Helidon Meter type (Counter, etc.) as a delegate. Those methods delegate to some new common code which encapsulates the decision-making about whether the delegate is configured to be enabled or not.

One new test makes sure that selectively disabled metrics are handled correctly.

### Documentation

Bug fix; no doc impact

Patch:
diff --git a/microprofile/metrics/pom.xml b/microprofile/metrics/pom.xml
index c782d508b4..ef61d976fe 100644
--- a/microprofile/metrics/pom.xml
+++ b/microprofile/metrics/pom.xml
@@ -123,6 +123,7 @@
                                 <exclude>**/HelloWorldAsyncResponseTest.java</exclude>
                                 <exclude>**/TestMetricsOnOwnSocket.java</exclude>
                                 <exclude>**/TestDisabledMetrics.java</exclude>
+                                <exclude>**/TestSelectivelyDisabledMetrics.java</exclude>
                                 <exclude>**/TestConfigProcessing.java</exclude>
                             </excludes>
                             <systemPropertyVariables>
@@ -162,6 +163,17 @@
                             </excludes>
                         </configuration>
                     </execution>
+                    <execution>
+                        <id>test-selectively-disabled-metrics</id>
+                        <goals>
+                            <goal>test</goal>
+                        </goals>
+                        <configuration>
+                            <includes>
+                                <include>**/TestSelectivelyDisabledMetric.java</include>
+                            </includes>
+                        </configuration>
+                    </execution>
                     <execution>
                         <id>test-config-with-top-level-tags</id>
                         <goals>
diff --git a/microprofile/metrics/src/main/java/io/helidon/microprofile/metrics/HelidonCounter.java b/microprofile/metrics/src/main/java/io/helidon/microprofile/metrics/HelidonCounter.java
index cba058be2d..524d1aee97 100644
--- a/microprofile/metrics/src/main/java/io/helidon/microprofile/metrics/HelidonCounter.java
+++ b/microprofile/metrics/src/main/java/io/helidon/microprofile/metrics/HelidonCounter.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2018, 2023 Oracle and/or its affiliates.
+ * Copyright (c) 2018, 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.
@@ -54,6 +54,12 @@ class HelidonCounter extends MetricImpl<io.helidon.metrics.api.Counter> implemen
                                   delegate);
     }
 
+    static HelidonCounter create(io.helidon.metrics.api.Counter delegate) {
+        return new HelidonCounter(resolvedScope(delegate),
+                                  Registry.metadata(delegate),
+                                  delegate);
+    }
+
     @Override
     public void inc() {
         delegate.increment();
diff --git a/microprofile/metrics/src/main/java/io/helidon/microprofile/metrics/HelidonGauge.java b/microprofile/metrics/src/main/java/io/helidon/microprofile/metrics/HelidonGauge.java
index 4252331faf..9d2895b433 100644
--- a/microprofile/metrics/src/main/java/io/helidon/microprofile/metrics/HelidonGauge.java
+++ b/microprofile/metrics/src/main/java/io/helidon/microprofile/metrics/HelidonGauge.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2018, 2023 Oracle and/or its affiliates.
+ * Copyright (c) 2018, 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.
@@ -85,6 +85,12 @@ abstract class HelidonGauge<N extends Number> extends MetricImpl<io.helidon.metr
                                                 delegate);
     }
 
+    static <N extends Number> HelidonGauge<N> create(io.helidon.metrics.api.Gauge<N> delegate) {
+        return new HelidonGauge.DelegateBased<>(resolvedScope(delegate),
+                                                Registry.metadata(delegate),
+                                                delegate);
+    }
+
     static HelidonGauge<Long> create(String scope,
                                      Metadata metadata,
                                      io.helidon.metrics.api.FunctionalCounter delegate) {
diff --git a/microprofile/metrics/src/main/java/io/helidon/microprofile/metrics/HelidonHistogram.java b/microprofile/metrics/src/main/java/io/helidon/microprofile/metrics/HelidonHistogram.java
index 7dc53c4329..c21b47cf9d 100644
--- a/microprofile/metrics/src/main/java/io/helidon/microprofile/metrics/HelidonHistogram.java
+++ b/microprofile/metrics/src/main/java/io/helidon/microprofile/metrics/HelidonHistogram.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2018, 2023 Oracle and/or its affiliates.
+ * Copyright (c) 2018, 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.
@@ -57,6 +57,12 @@ final class HelidonHistogram extends MetricImpl<DistributionSummary> implements
                                     delegate);
     }
 
+    static HelidonHistogram create(io.helidon.metrics.api.DistributionSummary delegate) {
+        return new HelidonHistogram(resolvedScope(delegate),
+                                    Registry.metadata(delegate),
+                                    delegate);
+    }
+
     @Override
     public long getSum() {
         return (long) delegate.totalAmount();
diff --git a/microprofile/metrics/src/main/java/io/helidon/microprofile/metrics/HelidonTimer.java b/microprofile/metrics/src/main/java/io/helidon/microprofile/metrics/HelidonTimer.java
index 1bcd554185..1693937bd1 100644
--- a/microprofile/metrics/src/main/java/io/helidon/microprofile/metrics/HelidonTimer.java
+++ b/microprofile/metrics/src/main/java/io/helidon/microprofile/metrics/HelidonTimer.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2018, 2023 Oracle and/or its affiliates.
+ * Copyright (c) 2018, 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.
@@ -67,6 +67,13 @@ final class HelidonTimer extends MetricImpl<io.helidon.metrics.api.Timer> implem
                                 delegate);
     }
 
+    static HelidonTimer create(MeterRegistry meterRegistry, io.helidon.metrics.api.Timer delegate) {
+        return new HelidonTimer(meterRegistry,
+                                resolvedScope(delegate),
+                                Registry.metadata(delegate),
+                                delegate);
+    }
+
     @Override
     public void update(Duration duration) {
         delegate.record(duration);
diff --git a/microprofile/metrics/src/main/java/io/helidon/microprofile/metrics/MetricImpl.java b/microprofile/metrics/src/main/java/io/helidon/microprofile/metrics/MetricImpl.java
index 035ab249f7..9c7b30e6e2 100644
--- a/microprofile/metrics/src/main/java/io/helidon/microprofile/metrics/MetricImpl.java
+++ b/microprofile/metrics/src/main/java/io/helidon/microprofile/metrics/MetricImpl.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2018, 2023 Oracle and/or its affiliates.
+ * Copyright (c) 2018, 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.
@@ -81,6 +81,10 @@ abstract class MetricImpl<M extends Meter> extends AbstractMetric<M> implements
         return toHelidonTags(SystemTagsManager.instance().withScopeTag(iterableEntries(tags), scope));
     }
 
+    static String resolvedScope(Meter delegate) {
+        return SystemTagsManager.instance().effectiveScope(delegate.scope()).orElse(Meter.Scope.DEFAULT);
+    }
+
     /**
      * Converts an iterable of map entries (representing tag names and values) into an iterable of Helidon tags.
      *
diff --git a/microprofile/metrics/src/main/java/io/helidon/microprofile/metrics/Registry.java b/microprofile/metrics/src/main/java/io/helidon/microprofile/metrics/Registry.java
index 2c0cf62248..c8ff5bb32f 100644
--- a/microprofile/metrics/src/main/java/io/helidon/microprofile/metrics/Registry.java
+++ b/microprofile/metrics/src/main/java/io/helidon/microprofile/metrics/Registry.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2023 Oracle and/or its affiliates.
+ * Copyright (c) 2023, 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.
@@ -80,6 +80,15 @@ class Registry implements MetricRegistry {
         return new Registry(scope, meterRegistry);
     }
 
+    static Metadata metadata(Meter meter) {
+
+        MetadataBuilder builder = Metadata.builder().withName(meter.id().name());
+        meter.baseUnit().ifPresent(builder::withUnit);
+        meter.description().ifPresent(builder::withDescription);
+
+        return builder.build();
+    }
+
     protected static String sanitizeUnit(String unit) {
         return unit != null && !unit.equals(MetricUnits.NONE)
                 ? unit
@@ -431,14 +440,16 @@ class Registry implements MetricRegistry {
 
             HelidonMetric<?> newMetric = metric(collector, existingInfo == null ? newMetadata : existingInfo.metadata, meter);
 
-            // Now update the data structures.
-            InfoPerName info = infoByName.computeIfAbsent(newMetricID.getName(),
-                                                          n -> InfoPerName.create(newMetadata, newMetricID));
+            // Now update the data structures if the meter is enabled.
+            if (meterRegistry.isMeterEnabled(meter.id().name(), meter.id().tagsMap(), meter.scope())) {
+                InfoPerName info = infoByName.computeIfAbsent(newMetricID.getName(),
+                                                              n -> InfoPerName.create(newMetadata, newMetricID));
 
-            // Inside info, metric IDs are stored in a set, so adding the first ID again does no harm.
-            info.add(newMetricID);
-            metricsById.put(newMetricID, newMetric);
-            metricsByDelegate.put(meter, newMetric);
+                // Inside info, metric IDs are stored in a set, so adding the first ID again does no harm.
+                info.add(newMetricID);
+                metricsById.put(newMetricID, newMetric);
+                metricsByDelegate.put(meter, newMetric);
+            }
 
             collector.collect().log(LOGGER);
 
@@ -573,15 +584,6 @@ class Registry implements MetricRegistry {
         }
     }
 
-    private static Metadata metadata(Meter meter) {
-
-        MetadataBuilder builder = Metadata.builder().withName(meter.id().name());
-        meter.baseUnit().ifPresent(builder::withUnit);
-        meter.description().ifPresent(builder::withDescription);
-
-        return builder.build();
-    }
-
     private static Map<String, String> tagsWithoutSystemOrScopeTags(Iterable<io.helidon.metrics.api.Tag> tags) {
         Map<String, String> result = new TreeMap<>();
 
@@ -590,6 +592,10 @@ class Registry implements MetricRegistry {
         return result;
     }
 
+    private boolean isMeterEnabled(Meter meter) {
+        return meterRegistry.isMeterEnabled(meter.id().name(), meter.id().tagsMap(), meter.scope());
+    }
+
     private static Tag[] tags(Map<String, String> tags) {
         var result = new ArrayList<Tag>();
         tags.forEach((key, value) -> result.add(new Tag(key, value)));
@@ -605,9 +611,7 @@ class Registry implements MetricRegistry {
     }
 
     private HelidonCounter createCounter(io.helidon.metrics.api.Counter.Builder counterBuilder) {
-        io.helidon.metrics.api.Counter delegate = meterRegistry.getOrCreate(counterBuilder);
-        HelidonCounter result = (HelidonCounter) metricsByDelegate.get(delegate);
-        return result;
+        return createMeter(counterBuilder, HelidonCounter::create);
     }
 
     @SuppressWarnings("unchecked")
@@ -634,17 +638,15 @@ class Registry implements MetricRegistry {
 
     @SuppressWarnings("unchecked")
     private <N extends Number> HelidonGauge<N> createGauge(io.helidon.metrics.api.Gauge.Builder<N> gBuilder) {
-        io.helidon.metrics.api.Gauge<?> delegate = meterRegistry.getOrCreate(gBuilder);
-        return (HelidonGauge<N>) metricsByDelegate.get(delegate);
+        return createMeter(gBuilder, HelidonGauge::create);
     }
 
     @SuppressWarnings("unchecked")
     private <T> HelidonGauge<Long> createFunctionalCounter(io.helidon.metrics.api.FunctionalCounter.Builder<T> fcBuilder) {
-        io.helidon.metrics.api.Gauge<?> delegate = meterRegistry
-                .getOrCreate(io.helidon.metrics.api.Gauge.builder(fcBuilder.name(),
-                                                                  () -> fcBuilder.fn()
-                                                                          .apply(fcBuilder.stateObject())));
-        return (HelidonGauge<Long>) metricsByDelegate.get(delegate);
+        return createMeter(io.helidon.metrics.api.Gauge.builder(fcBuilder.name(),
+                                                                () -> fcBuilder.fn()
+                                                                        .apply(fcBuilder.stateObject())),
+                           HelidonGauge::create);
     }
 
     private HelidonHistogram createHistogram(Metadata metadata, Tag... tags) {
@@ -657,8 +659,7 @@ class Registry implements MetricRegistry {
     }
 
     private HelidonHistogram createHistogram(io.helidon.metrics.api.DistributionSummary.Builder sBuilder) {
-        io.helidon.metrics.api.DistributionSummary delegate = meterRegistry.getOrCreate(sBuilder);
-        return (HelidonHistogram) metricsByDelegate.get(delegate);
+        return createMeter(sBuilder, HelidonHistogram::create);
     }
 
     private HelidonTimer createTimer(Metadata metadata, Tag... tags) {
@@ -670,8 +671,19 @@ class Registry implements MetricRegistry {
     }
 
     private HelidonTimer createTimer(io.helidon.metrics.api.Timer.Builder tBuilder) {
-        io.helidon.metrics.api.Timer delegate = meterRegistry.getOrCreate(tBuilder);
-        return (HelidonTimer) metricsByDelegate.get(delegate);
+        return createMeter(tBuilder, d -> HelidonTimer.create(meterRegistry, d));
+    }
+
+    private <HM extends HelidonMetric<M>,
+            M extends Meter,
+            B extends Meter.Builder<B, M>> HM createMeter(B builder,
+                                                          Function<M, HM> factory) {
+        M delegate = meterRegistry.getOrCreate(builder);
+        // Disabled metrics are not in the data structures supporting our registry, so we cannot find those via metricsByDelegate.
+        // Instead just create a new wrapper around the delegate.
+        return isMeterEnabled(delegate)
+                ? (HM) metricsByDelegate.get(delegate)
+                : factory.apply(delegate);
     }
 
     private boolean removeMatchingWithResult(MetricFilter filter) {
diff --git a/tracing/providers/opentelemetry/src/main/java/io/helidon/tracing/providers/opentelemetry/MutableOpenTelemetryBaggage.java b/tracing/providers/opentelemetry/src/main/java/io/helidon/tracing/providers/opentelemetry/MutableOpenTelemetryBaggage.java
index 52db8a3b13..e73286773d 100644
--- a/tracing/providers/opentelemetry/src/main/java/io/helidon/tracing/providers/opentelemetry/MutableOpenTelemetryBaggage.java
+++ b/tracing/providers/opentelemetry/src/main/java/io/helidon/tracing/providers/opentelemetry/MutableOpenTelemetryBaggage.java
@@ -96,7 +96,10 @@ class MutableOpenTelemetryBaggage implements Baggage, WritableBaggage {
 
     @Override
     public Optional<String> get(String key) {
-        return Optional.ofNullable(values.get(key).getValue());
+        BaggageEntry baggageEntry = values.get(key);
+        return baggageEntry == null
+                ? Optional.empty()
+                : Optional.ofNullable(baggageEntry.getValue());
     }
 
     @Override


Test patch:
diff --git a/microprofile/metrics/src/test/java/io/helidon/microprofile/metrics/TestSelectivelyDisabledMetric.java b/microprofile/metrics/src/test/java/io/helidon/microprofile/metrics/TestSelectivelyDisabledMetric.java
new file mode 100644
index 0000000000..dee04d8635
--- /dev/null
+++ b/microprofile/metrics/src/test/java/io/helidon/microprofile/metrics/TestSelectivelyDisabledMetric.java
@@ -0,0 +1,52 @@
+/*
+ * 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 io.helidon.metrics.api.Meter;
+import io.helidon.microprofile.testing.junit5.AddBean;
+import io.helidon.microprofile.testing.junit5.AddConfigBlock;
+import io.helidon.microprofile.testing.junit5.HelidonTest;
+
+import org.eclipse.microprofile.metrics.Counter;
+import org.eclipse.microprofile.metrics.MetricID;
+import org.junit.jupiter.api.Test;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.nullValue;
+
+@HelidonTest
+@AddBean(CountedBean.class)
+@AddConfigBlock("""
+        metrics.scoping.scopes.0.name=application
+        metrics.scoping.scopes.0.filter.exclude=.*oome.*"""
+)
+class TestSelectivelyDisabledMetric {
+
+    @Test
+    void testDisabledCounter() {
+        RegistryFactory rf = RegistryFactory.getInstance();
+        Registry reg = rf.registry(Meter.Scope.APPLICATION);
+
+        MetricID metricID = new MetricID(CountedBean.DOOMED_COUNTER);
+        Counter counter = reg.getCounter(metricID);
+        assertThat("Disabled counter looked up", counter, is(nullValue()));
+
+        counter = reg.counter(metricID);
+        counter.inc();
+        assertThat("Disabled (no-op) counter after increment", counter.getCount(), is(0L));
+    }
+}
diff --git a/tracing/provider-tests/src/main/java/io/helidon/tracing/providers/tests/BaggageOptionalTests.java b/tracing/provider-tests/src/main/java/io/helidon/tracing/providers/tests/BaggageOptionalTests.java
new file mode 100644
index 0000000000..a1038145bb
--- /dev/null
+++ b/tracing/provider-tests/src/main/java/io/helidon/tracing/providers/tests/BaggageOptionalTests.java
@@ -0,0 +1,68 @@
+/*
+ * 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.tracing.providers.tests;
+
+import java.util.Optional;
+import java.util.function.Consumer;
+
+import io.helidon.common.testing.junit5.OptionalMatcher;
+import io.helidon.tracing.Span;
+import io.helidon.tracing.Tracer;
+
+import org.junit.jupiter.api.Test;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+
+class BaggageOptionalTests {
+    // Demonstrate NPE in io.opentelemetry.api.baggage.BaggageEntry.getValue()
+    private static final String KEY = "baggageGetShouldCauseEmptyReturn_NotNPE";
+
+    @Test
+    void baggageRetrievalWhenNeverTouchedShouldBeEmptyFromSpan() {
+        runWithSpanHarness(span -> {
+            // NPE in 4.0.5 -> 4.0.10
+            final var actual = span.baggage().get(KEY);
+            assertThat("Retrieval of unset baggage", actual, OptionalMatcher.optionalEmpty());
+        });
+    }
+
+    @Test void baggageRetrievalWhenNeverTouchedShouldBeEmptyFromContext() {
+        runWithSpanHarness(span -> {
+            // NPE in 4.0.5 -> 4.0.10
+            final var actual = span.context().baggage().get(KEY);
+            assertThat("Retrieval of unset baggage", actual, OptionalMatcher.optionalEmpty());
+        });
+    }
+
+    @Test void baggageRetrievalWhenNeverTouchedShouldBeEmptyFromSpanWithWorkaround() {
+        runWithSpanHarness(span -> {
+            // Apply only known workaround (use containsKey precondition)
+            final var actual = !span.baggage().containsKey(KEY) ? Optional.empty()
+                    : span.baggage().get(KEY);
+            assertThat("Retrieval of unset baggage", actual, OptionalMatcher.optionalEmpty());
+        });
+    }
+
+    private void runWithSpanHarness(final Consumer<Span> spanConsumer) {
+        final var span = Tracer.global().spanBuilder("BaggageOptionalTest").start();
+        try {
+            spanConsumer.accept(span);
+
+        } finally {
+            span.end();
+        }
+    }
+}