This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 084816b0fc00 CAMEL-24561: Add Micrometer Observation for GenAI
084816b0fc00 is described below

commit 084816b0fc00a25baf27ec218a7c5c45d7a395dd
Author: Ramu <[email protected]>
AuthorDate: Sun Aug 30 13:33:35 2026 +0530

    CAMEL-24561: Add Micrometer Observation for GenAI
    
    Adds optional Micrometer Observation support for Camel GenAI observability. 
When a non-NOOP ObservationRegistry is available, the GenAI client operation is 
wrapped in a Micrometer Observation named gen_ai.client.operation, reusing 
Camel's existing spanName() for the contextual name and recording 
low-cardinality GenAI attributes with proper Observation.Scope handling for 
correct parent/child correlation. Falls back to existing OTel span + timer 
behavior when Observation support is unav [...]
    
    Closes #25882
---
 .../camel/catalog/docs/ai-observability.adoc       |  31 ++
 components/camel-ai/camel-ai-observability/pom.xml |   5 +
 .../src/main/docs/ai-observability.adoc            |  31 ++
 .../ai/observability/GenAiMetricsBackend.java      |   5 +
 ...java => GenAiMicrometerObservationBackend.java} |  15 +-
 .../GenAiMicrometerObservationSupport.java         | 104 ++++++
 .../ai/observability/GenAiMicrometerSupport.java   |  11 +-
 .../ai/observability/GenAiObservabilityImpl.java   |  72 +++-
 .../GenAiMicrometerObservationTest.java            | 367 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc    |   7 +
 10 files changed, 634 insertions(+), 14 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-observability.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-observability.adoc
index 2fe8750d7714..603055aa03a6 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-observability.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-observability.adoc
@@ -46,6 +46,37 @@ Metrics recorded (when Micrometer is available):
 * `gen_ai.client.operation` — operation duration timer
 * `gen_ai.client.token.usage` — token usage counter (tags: 
`gen_ai.token.type=input|output`)
 
+== Micrometer Observation
+
+When a non-NOOP `io.micrometer.observation.ObservationRegistry` is in the 
Camel registry
+(for example a Spring Boot Actuator bean), each GenAI client call is recorded 
as a Micrometer
+`io.micrometer.observation.Observation` named `gen_ai.client.operation`.
+The contextual name uses the same `\{operation} \{model}` form as GenAI 
telemetry spans
+(for example `chat test-model`).
+
+That is not the same type as Camel's 
`org.apache.camel.component.ai.observability.GenAiObservation`
+handle used by AI producers.
+
+`camel-micrometer-observability` is not required. Bind the 
`ObservationRegistry` in the Camel
+registry; Spring Boot does this automatically when observation support is 
enabled.
+
+When Observation is used:
+
+* Camel does not create the GenAI CLIENT span through `camel-telemetry` / 
`camel-opentelemetry2`.
+* Camel does not record the direct `gen_ai.client.operation` timer.
+* Tracing is emitted only if the registry has a tracing handler. Without one, 
the Observation
+path produces no GenAI trace.
+* The operation timer is emitted only if the registry has a meter handler. 
Without one, the
+Observation path produces no operation timer.
+* Token usage counters (`gen_ai.client.token.usage`) still use `MeterRegistry` 
when one is present.
+
+When no `ObservationRegistry` is available, existing OpenTelemetry spans and 
Micrometer timers
+continue to work as before.
+
+Low-cardinality keys: `gen_ai.operation.name`, `gen_ai.system`, 
`gen_ai.request.model`,
+`camel.component`, and `error.type` on failure. Prompts, completions, and 
token counts are not used
+as Observation keys.
+
 == Camel TUI integration (Phase 2)
 
 When monitoring a running integration with `camel tui` and observability 
enabled, the AI panel usage view
diff --git a/components/camel-ai/camel-ai-observability/pom.xml 
b/components/camel-ai/camel-ai-observability/pom.xml
index a2f80f442e6e..508bf3e7c2a7 100644
--- a/components/camel-ai/camel-ai-observability/pom.xml
+++ b/components/camel-ai/camel-ai-observability/pom.xml
@@ -59,6 +59,11 @@
             <artifactId>micrometer-core</artifactId>
             <optional>true</optional>
         </dependency>
+        <dependency>
+            <groupId>io.micrometer</groupId>
+            <artifactId>micrometer-observation</artifactId>
+            <optional>true</optional>
+        </dependency>
         <dependency>
             <groupId>dev.langchain4j</groupId>
             <artifactId>langchain4j-core</artifactId>
diff --git 
a/components/camel-ai/camel-ai-observability/src/main/docs/ai-observability.adoc
 
b/components/camel-ai/camel-ai-observability/src/main/docs/ai-observability.adoc
index 2fe8750d7714..603055aa03a6 100644
--- 
a/components/camel-ai/camel-ai-observability/src/main/docs/ai-observability.adoc
+++ 
b/components/camel-ai/camel-ai-observability/src/main/docs/ai-observability.adoc
@@ -46,6 +46,37 @@ Metrics recorded (when Micrometer is available):
 * `gen_ai.client.operation` — operation duration timer
 * `gen_ai.client.token.usage` — token usage counter (tags: 
`gen_ai.token.type=input|output`)
 
+== Micrometer Observation
+
+When a non-NOOP `io.micrometer.observation.ObservationRegistry` is in the 
Camel registry
+(for example a Spring Boot Actuator bean), each GenAI client call is recorded 
as a Micrometer
+`io.micrometer.observation.Observation` named `gen_ai.client.operation`.
+The contextual name uses the same `\{operation} \{model}` form as GenAI 
telemetry spans
+(for example `chat test-model`).
+
+That is not the same type as Camel's 
`org.apache.camel.component.ai.observability.GenAiObservation`
+handle used by AI producers.
+
+`camel-micrometer-observability` is not required. Bind the 
`ObservationRegistry` in the Camel
+registry; Spring Boot does this automatically when observation support is 
enabled.
+
+When Observation is used:
+
+* Camel does not create the GenAI CLIENT span through `camel-telemetry` / 
`camel-opentelemetry2`.
+* Camel does not record the direct `gen_ai.client.operation` timer.
+* Tracing is emitted only if the registry has a tracing handler. Without one, 
the Observation
+path produces no GenAI trace.
+* The operation timer is emitted only if the registry has a meter handler. 
Without one, the
+Observation path produces no operation timer.
+* Token usage counters (`gen_ai.client.token.usage`) still use `MeterRegistry` 
when one is present.
+
+When no `ObservationRegistry` is available, existing OpenTelemetry spans and 
Micrometer timers
+continue to work as before.
+
+Low-cardinality keys: `gen_ai.operation.name`, `gen_ai.system`, 
`gen_ai.request.model`,
+`camel.component`, and `error.type` on failure. Prompts, completions, and 
token counts are not used
+as Observation keys.
+
 == Camel TUI integration (Phase 2)
 
 When monitoring a running integration with `camel tui` and observability 
enabled, the AI panel usage view
diff --git 
a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiMetricsBackend.java
 
b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiMetricsBackend.java
index e9f0e6465e63..9675d8c6c6a5 100644
--- 
a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiMetricsBackend.java
+++ 
b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiMetricsBackend.java
@@ -24,4 +24,9 @@ interface GenAiMetricsBackend {
     boolean isAvailable();
 
     void recordMetrics(GenAiObservationContext context, GenAiUsage usage, 
Throwable error, long startNanos);
+
+    /**
+     * Records token usage counters only. Used when Micrometer Observation 
already records the operation timer.
+     */
+    void recordTokenUsage(GenAiObservationContext context, GenAiUsage usage, 
Throwable error);
 }
diff --git 
a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiMetricsBackend.java
 
b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiMicrometerObservationBackend.java
similarity index 61%
copy from 
components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiMetricsBackend.java
copy to 
components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiMicrometerObservationBackend.java
index e9f0e6465e63..4229ecc9e942 100644
--- 
a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiMetricsBackend.java
+++ 
b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiMicrometerObservationBackend.java
@@ -17,11 +17,20 @@
 package org.apache.camel.component.ai.observability;
 
 /**
- * Optional Micrometer metrics backend for GenAI observations.
+ * Optional Micrometer Observation backend for GenAI operations. 
Implementations must not expose Micrometer Observation
+ * types on this interface so {@link GenAiObservabilityImpl} can load without 
micrometer-observation on the classpath.
  */
-interface GenAiMetricsBackend {
+interface GenAiMicrometerObservationBackend {
 
     boolean isAvailable();
 
-    void recordMetrics(GenAiObservationContext context, GenAiUsage usage, 
Throwable error, long startNanos);
+    /**
+     * Starts a Micrometer Observation for the GenAI client call. Returns 
{@code null} when the registry produces a
+     * no-op Observation.
+     */
+    Handle start(GenAiObservationContext context);
+
+    interface Handle {
+        void stop(Throwable error);
+    }
 }
diff --git 
a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiMicrometerObservationSupport.java
 
b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiMicrometerObservationSupport.java
new file mode 100644
index 000000000000..9096c252953b
--- /dev/null
+++ 
b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiMicrometerObservationSupport.java
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.camel.component.ai.observability;
+
+import io.micrometer.observation.Observation;
+import io.micrometer.observation.ObservationRegistry;
+import org.apache.camel.CamelContext;
+import org.apache.camel.support.CamelContextHelper;
+import org.apache.camel.util.ObjectHelper;
+
+/**
+ * Micrometer Observation-backed instrumentation. Loaded reflectively only 
when {@link ObservationRegistry} is on the
+ * classpath.
+ */
+final class GenAiMicrometerObservationSupport implements 
GenAiMicrometerObservationBackend {
+
+    private final ObservationRegistry observationRegistry;
+
+    GenAiMicrometerObservationSupport(CamelContext camelContext) {
+        ObservationRegistry registry = 
CamelContextHelper.findSingleByType(camelContext, ObservationRegistry.class);
+        this.observationRegistry = isUsable(registry) ? registry : null;
+    }
+
+    @Override
+    public boolean isAvailable() {
+        return observationRegistry != null;
+    }
+
+    @Override
+    public Handle start(GenAiObservationContext context) {
+        if (observationRegistry == null) {
+            return null;
+        }
+        Observation observation = 
Observation.createNotStarted(GenAiMetrics.CLIENT_OPERATION, 
observationRegistry);
+        observation.contextualName(context.spanName());
+        observation.lowCardinalityKeyValue(GenAiAttributes.OPERATION_NAME, 
context.operationName().value());
+        observation.lowCardinalityKeyValue(GenAiAttributes.SYSTEM, 
nullToUnknown(context.system()));
+        observation.lowCardinalityKeyValue(GenAiAttributes.REQUEST_MODEL, 
nullToUnknown(context.requestModel()));
+        if (ObjectHelper.isNotEmpty(context.componentScheme())) {
+            
observation.lowCardinalityKeyValue(GenAiAttributes.CAMEL_COMPONENT, 
context.componentScheme());
+        }
+        observation.start();
+        if (observation.isNoop()) {
+            return null;
+        }
+        try {
+            return new ObservationHandle(observation, observation.openScope());
+        } catch (RuntimeException e) {
+            observation.stop();
+            throw e;
+        }
+    }
+
+    private static boolean isUsable(ObservationRegistry registry) {
+        return registry != null && registry != ObservationRegistry.NOOP;
+    }
+
+    private static String nullToUnknown(String value) {
+        return value == null || value.isBlank() ? "unknown" : value;
+    }
+
+    private static final class ObservationHandle implements Handle {
+
+        private final Observation observation;
+        private final Observation.Scope scope;
+
+        private ObservationHandle(Observation observation, Observation.Scope 
scope) {
+            this.observation = observation;
+            this.scope = scope;
+        }
+
+        @Override
+        public void stop(Throwable error) {
+            try {
+                if (error != null) {
+                    
observation.lowCardinalityKeyValue(GenAiAttributes.ERROR_TYPE, 
error.getClass().getSimpleName());
+                    observation.error(error);
+                }
+            } finally {
+                try {
+                    if (scope != null) {
+                        scope.close();
+                    }
+                } finally {
+                    observation.stop();
+                }
+            }
+        }
+    }
+}
diff --git 
a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiMicrometerSupport.java
 
b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiMicrometerSupport.java
index 8b020194c07d..149a4cfc20c1 100644
--- 
a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiMicrometerSupport.java
+++ 
b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiMicrometerSupport.java
@@ -58,11 +58,16 @@ final class GenAiMicrometerSupport implements 
GenAiMetricsBackend {
                 .tags(baseTags)
                 .register(meterRegistry)
                 .record(System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
+        recordTokenUsage(context, usage, error);
+    }
 
-        if (usage != null) {
-            recordTokenCounter(usage.inputTokens(), 
GenAiMetrics.TOKEN_TYPE_INPUT, context, error);
-            recordTokenCounter(usage.outputTokens(), 
GenAiMetrics.TOKEN_TYPE_OUTPUT, context, error);
+    @Override
+    public void recordTokenUsage(GenAiObservationContext context, GenAiUsage 
usage, Throwable error) {
+        if (meterRegistry == null || usage == null) {
+            return;
         }
+        recordTokenCounter(usage.inputTokens(), GenAiMetrics.TOKEN_TYPE_INPUT, 
context, error);
+        recordTokenCounter(usage.outputTokens(), 
GenAiMetrics.TOKEN_TYPE_OUTPUT, context, error);
     }
 
     private void recordTokenCounter(
diff --git 
a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservabilityImpl.java
 
b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservabilityImpl.java
index 62021eb33779..d0dbecb97a13 100644
--- 
a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservabilityImpl.java
+++ 
b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservabilityImpl.java
@@ -42,8 +42,14 @@ public final class GenAiObservabilityImpl {
     private static final String METER_REGISTRY_CLASS = 
"io.micrometer.core.instrument.MeterRegistry";
     private static final String MICROMETER_SUPPORT_CLASS
             = 
"org.apache.camel.component.ai.observability.GenAiMicrometerSupport";
+    private static final String OBSERVATION_REGISTRY_CLASS = 
"io.micrometer.observation.ObservationRegistry";
+    private static final String OBSERVATION_SUPPORT_CLASS
+            = 
"org.apache.camel.component.ai.observability.GenAiMicrometerObservationSupport";
     private static final GenAiMetricsBackend NO_METRICS_BACKEND = new 
NoMetricsBackend();
+    private static final GenAiMicrometerObservationBackend 
NO_OBSERVATION_BACKEND = new NoObservationBackend();
     private static final ConcurrentMap<CamelContext, GenAiMetricsBackend> 
METRICS_BACKENDS = new ConcurrentHashMap<>();
+    private static final ConcurrentMap<CamelContext, 
GenAiMicrometerObservationBackend> OBSERVATION_BACKENDS
+            = new ConcurrentHashMap<>();
 
     private GenAiObservabilityImpl() {
     }
@@ -52,12 +58,14 @@ public final class GenAiObservabilityImpl {
      * Starts a GenAI observation for a single LLM client call. Returns a 
no-op when no backend is available.
      */
     public static GenAiObservation start(Exchange exchange, 
GenAiObservationContext context) {
-        Tracer tracer = exchange.getContext().hasService(Tracer.class);
-        GenAiMetricsBackend metricsBackend = 
resolveMetricsBackend(exchange.getContext());
-        if (tracer == null && !metricsBackend.isAvailable()) {
+        CamelContext camelContext = exchange.getContext();
+        GenAiMicrometerObservationBackend observationBackend = 
resolveObservationBackend(camelContext);
+        Tracer tracer = camelContext.hasService(Tracer.class);
+        GenAiMetricsBackend metricsBackend = 
resolveMetricsBackend(camelContext);
+        if (!observationBackend.isAvailable() && tracer == null && 
!metricsBackend.isAvailable()) {
             return NOOP;
         }
-        return new DefaultGenAiObservation(exchange, context, tracer, 
metricsBackend);
+        return new DefaultGenAiObservation(exchange, context, tracer, 
metricsBackend, observationBackend);
     }
 
     private static GenAiMetricsBackend resolveMetricsBackend(CamelContext 
camelContext) {
@@ -78,12 +86,31 @@ public final class GenAiObservabilityImpl {
         }
     }
 
+    private static GenAiMicrometerObservationBackend 
resolveObservationBackend(CamelContext camelContext) {
+        return OBSERVATION_BACKENDS.computeIfAbsent(camelContext, 
GenAiObservabilityImpl::createObservationBackend);
+    }
+
+    private static GenAiMicrometerObservationBackend 
createObservationBackend(CamelContext camelContext) {
+        try {
+            Class.forName(OBSERVATION_REGISTRY_CLASS);
+            Class<?> supportClass = Class.forName(OBSERVATION_SUPPORT_CLASS);
+            Constructor<?> constructor = 
supportClass.getDeclaredConstructor(CamelContext.class);
+            return (GenAiMicrometerObservationBackend) 
constructor.newInstance(camelContext);
+        } catch (ReflectiveOperationException | LinkageError e) {
+            if (LOG.isDebugEnabled()) {
+                LOG.debug("Micrometer Observation backend unavailable for 
GenAI observability", e);
+            }
+            return NO_OBSERVATION_BACKEND;
+        }
+    }
+
     private static final class DefaultGenAiObservation implements 
GenAiObservation {
 
         private final Exchange exchange;
         private final GenAiObservationContext context;
         private final Tracer tracer;
         private final GenAiMetricsBackend metricsBackend;
+        private final GenAiMicrometerObservationBackend.Handle 
micrometerObservation;
         private final long startNanos;
         private Span span;
         private GenAiUsage usage;
@@ -92,13 +119,17 @@ public final class GenAiObservabilityImpl {
 
         private DefaultGenAiObservation(
                                         Exchange exchange, 
GenAiObservationContext context, Tracer tracer,
-                                        GenAiMetricsBackend metricsBackend) {
+                                        GenAiMetricsBackend metricsBackend,
+                                        GenAiMicrometerObservationBackend 
observationBackend) {
             this.exchange = exchange;
             this.context = context;
             this.tracer = tracer;
             this.metricsBackend = metricsBackend;
             this.startNanos = System.nanoTime();
-            startSpan();
+            this.micrometerObservation = observationBackend.isAvailable() ? 
observationBackend.start(context) : null;
+            if (this.micrometerObservation == null) {
+                startSpan();
+            }
         }
 
         private void startSpan() {
@@ -130,7 +161,11 @@ public final class GenAiObservabilityImpl {
                 return;
             }
             closed = true;
-            closeSpan();
+            if (micrometerObservation != null) {
+                micrometerObservation.stop(error);
+            } else {
+                closeSpan();
+            }
             recordMetrics();
         }
 
@@ -155,7 +190,11 @@ public final class GenAiObservabilityImpl {
             if (!metricsBackend.isAvailable()) {
                 return;
             }
-            metricsBackend.recordMetrics(context, usage, error, startNanos);
+            if (micrometerObservation != null) {
+                metricsBackend.recordTokenUsage(context, usage, error);
+            } else {
+                metricsBackend.recordMetrics(context, usage, error, 
startNanos);
+            }
         }
 
         private static void applyContextAttributes(
@@ -214,5 +253,22 @@ public final class GenAiObservabilityImpl {
         public void recordMetrics(GenAiObservationContext context, GenAiUsage 
usage, Throwable error, long startNanos) {
             // noop
         }
+
+        @Override
+        public void recordTokenUsage(GenAiObservationContext context, 
GenAiUsage usage, Throwable error) {
+            // noop
+        }
+    }
+
+    private static final class NoObservationBackend implements 
GenAiMicrometerObservationBackend {
+        @Override
+        public boolean isAvailable() {
+            return false;
+        }
+
+        @Override
+        public Handle start(GenAiObservationContext context) {
+            return null;
+        }
     }
 }
diff --git 
a/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiMicrometerObservationTest.java
 
b/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiMicrometerObservationTest.java
new file mode 100644
index 000000000000..72a2009ebb8a
--- /dev/null
+++ 
b/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiMicrometerObservationTest.java
@@ -0,0 +1,367 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.camel.component.ai.observability;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import io.micrometer.common.KeyValue;
+import io.micrometer.core.instrument.Timer;
+import 
io.micrometer.core.instrument.observation.DefaultMeterObservationHandler;
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+import io.micrometer.observation.Observation;
+import io.micrometer.observation.ObservationHandler;
+import io.micrometer.observation.ObservationRegistry;
+import org.apache.camel.CamelContextAware;
+import org.apache.camel.Exchange;
+import org.apache.camel.support.DefaultExchange;
+import org.apache.camel.telemetry.Span;
+import org.apache.camel.telemetry.SpanContextPropagationExtractor;
+import org.apache.camel.telemetry.SpanContextPropagationInjector;
+import org.apache.camel.telemetry.SpanLifecycleManager;
+import org.apache.camel.telemetry.Tracer;
+import org.apache.camel.test.junit6.ExchangeTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class GenAiMicrometerObservationTest extends ExchangeTestSupport {
+
+    @Test
+    void shouldStartAndStopObservation() {
+        RecordingObservationHandler handler = bindObservationRegistry();
+
+        observeSuccess();
+
+        assertThat(handler.started).hasSize(1);
+        assertThat(handler.stopped).hasSize(1);
+        assertThat(handler.errors).isEmpty();
+        
assertThat(handler.stopped.get(0).getName()).isEqualTo(GenAiMetrics.CLIENT_OPERATION);
+        assertThat(handler.stopped.get(0).getContextualName()).isEqualTo("chat 
test-model");
+    }
+
+    @Test
+    void shouldRecordLowCardinalityKeysWithoutHighCardinalityData() {
+        RecordingObservationHandler handler = bindObservationRegistry();
+
+        observeSuccess();
+
+        Map<String, String> lowCardinality = 
lowCardinalityMap(handler.stopped.get(0));
+        assertThat(lowCardinality)
+                .containsEntry(GenAiAttributes.OPERATION_NAME, "chat")
+                .containsEntry(GenAiAttributes.SYSTEM, "openai")
+                .containsEntry(GenAiAttributes.REQUEST_MODEL, "test-model")
+                .containsEntry(GenAiAttributes.CAMEL_COMPONENT, 
"langchain4j-chat")
+                .doesNotContainKeys(
+                        GenAiAttributes.INPUT_TOKENS,
+                        GenAiAttributes.OUTPUT_TOKENS,
+                        GenAiAttributes.FINISH_REASONS,
+                        GenAiAttributes.RESPONSE_MODEL,
+                        "prompt",
+                        "completion");
+        assertThat(highCardinalityMap(handler.stopped.get(0))).isEmpty();
+    }
+
+    @Test
+    void shouldRecordErrorThenStopObservation() {
+        RecordingObservationHandler handler = bindObservationRegistry();
+        ObservationRegistry registry = boundObservationRegistry();
+        IllegalStateException failure = new IllegalStateException("rate 
limited");
+
+        Exchange exchange = new DefaultExchange(context);
+        GenAiObservation observation = GenAiObservability.start(exchange, 
chatContext());
+        Observation current = registry.getCurrentObservation();
+        assertThat(current).isNotNull();
+        
assertThat(current.getContext().getName()).isEqualTo(GenAiMetrics.CLIENT_OPERATION);
+
+        observation.recordError(failure);
+        observation.close();
+
+        assertThat(handler.started).hasSize(1);
+        assertThat(handler.errors).hasSize(1);
+        assertThat(handler.stopped).hasSize(1);
+        assertThat(handler.errors.get(0).getError()).isSameAs(failure);
+        assertThat(lowCardinalityMap(handler.stopped.get(0)))
+                .containsEntry(GenAiAttributes.ERROR_TYPE, 
"IllegalStateException");
+        assertThat(registry.getCurrentObservation()).isNull();
+    }
+
+    @Test
+    void shouldMakeObservationCurrentWhileOperationIsActive() {
+        bindObservationRegistry();
+        ObservationRegistry registry = boundObservationRegistry();
+
+        Exchange exchange = new DefaultExchange(context);
+        GenAiObservation observation = GenAiObservability.start(exchange, 
chatContext());
+
+        Observation current = registry.getCurrentObservation();
+        assertThat(current).isNotNull();
+        
assertThat(current.getContext().getName()).isEqualTo(GenAiMetrics.CLIENT_OPERATION);
+        assertThat(current.getContext().getContextualName()).isEqualTo("chat 
test-model");
+
+        Observation nested = Observation.createNotStarted("nested.http", 
registry).start();
+        try (Observation.Scope nestedScope = nested.openScope()) {
+            assertThat(registry.getCurrentObservation()).isSameAs(nested);
+            
assertThat(nested.getContext().getParentObservation()).isSameAs(current);
+        } finally {
+            nested.stop();
+        }
+        assertThat(registry.getCurrentObservation()).isSameAs(current);
+
+        observation.recordSuccess(GenAiUsage.of(10, 5, "stop", "test-model"));
+        observation.close();
+
+        assertThat(registry.getCurrentObservation()).isNull();
+    }
+
+    @Test
+    void shouldPreferObservationOverTelemetrySpan() {
+        RecordingObservationHandler handler = bindObservationRegistry();
+        RecordingTracer tracer = bindRecordingTracer();
+
+        observeSuccess();
+
+        assertThat(handler.stopped).hasSize(1);
+        assertThat(tracer.closedSpans()).isEmpty();
+    }
+
+    @Test
+    void shouldNotDuplicateOperationTimerAndShouldKeepTokenCounters() {
+        SimpleMeterRegistry meters = new SimpleMeterRegistry();
+        context.getRegistry().bind("metricsRegistry", meters);
+
+        ObservationRegistry observationRegistry = ObservationRegistry.create();
+        RecordingObservationHandler handler = new 
RecordingObservationHandler();
+        observationRegistry.observationConfig()
+                .observationHandler(handler)
+                .observationHandler(new 
DefaultMeterObservationHandler(meters));
+        context.getRegistry().bind("observationRegistry", observationRegistry);
+
+        observeSuccess();
+
+        assertThat(handler.stopped).hasSize(1);
+        Timer timer = meters.find(GenAiMetrics.CLIENT_OPERATION).timer();
+        assertThat(timer).isNotNull();
+        assertThat(timer.count()).isEqualTo(1);
+        assertThat(meters.find(GenAiMetrics.CLIENT_TOKEN_USAGE)
+                .tag(GenAiMetrics.TAG_TOKEN_TYPE, 
GenAiMetrics.TOKEN_TYPE_INPUT)
+                .counter()
+                .count()).isEqualTo(10);
+        assertThat(meters.find(GenAiMetrics.CLIENT_TOKEN_USAGE)
+                .tag(GenAiMetrics.TAG_TOKEN_TYPE, 
GenAiMetrics.TOKEN_TYPE_OUTPUT)
+                .counter()
+                .count()).isEqualTo(5);
+    }
+
+    @Test
+    void shouldKeepExistingBackendsWhenObservationRegistryIsAbsent() {
+        SimpleMeterRegistry meters = new SimpleMeterRegistry();
+        context.getRegistry().bind("metricsRegistry", meters);
+        RecordingTracer tracer = bindRecordingTracer();
+
+        observeSuccess();
+
+        assertThat(tracer.closedSpans()).hasSize(1);
+        assertThat(tracer.closedSpans().get(0).tags())
+                .containsEntry(GenAiAttributes.OPERATION_NAME, "chat")
+                .containsEntry(GenAiAttributes.INPUT_TOKENS, "10");
+        
assertThat(meters.find(GenAiMetrics.CLIENT_OPERATION).timer()).isNotNull();
+        
assertThat(meters.find(GenAiMetrics.CLIENT_OPERATION).timer().count()).isEqualTo(1);
+        assertThat(meters.find(GenAiMetrics.CLIENT_TOKEN_USAGE)
+                .tag(GenAiMetrics.TAG_TOKEN_TYPE, 
GenAiMetrics.TOKEN_TYPE_INPUT)
+                .counter()
+                .count()).isEqualTo(10);
+        assertThat(meters.find(GenAiMetrics.CLIENT_TOKEN_USAGE)
+                .tag(GenAiMetrics.TAG_TOKEN_TYPE, 
GenAiMetrics.TOKEN_TYPE_OUTPUT)
+                .counter()
+                .count()).isEqualTo(5);
+    }
+
+    @Test
+    void shouldReturnNoopWhenNoBackendIsAvailable() {
+        Exchange exchange = new DefaultExchange(context);
+        GenAiObservation observation = GenAiObservability.start(exchange, 
chatContext());
+        observation.recordSuccess(GenAiUsage.of(10, 5, "stop", "test-model"));
+        observation.close();
+
+        assertThat(observation).isNotNull();
+        assertThat(context.hasService(Tracer.class)).isNull();
+        
assertThat(context.getRegistry().findByType(ObservationRegistry.class)).isEmpty();
+        
assertThat(context.getRegistry().findByType(SimpleMeterRegistry.class)).isEmpty();
+    }
+
+    @Test
+    void shouldIgnoreNoopObservationRegistry() {
+        context.getRegistry().bind("observationRegistry", 
ObservationRegistry.NOOP);
+        RecordingTracer tracer = bindRecordingTracer();
+
+        observeSuccess();
+
+        assertThat(tracer.closedSpans()).hasSize(1);
+    }
+
+    private void observeSuccess() {
+        Exchange exchange = new DefaultExchange(context);
+        GenAiObservation observation = GenAiObservability.start(exchange, 
chatContext());
+        observation.recordSuccess(GenAiUsage.of(10, 5, "stop", "test-model"));
+        observation.close();
+    }
+
+    private static GenAiObservationContext chatContext() {
+        return GenAiObservationContext.builder()
+                .operationName(GenAiOperationName.CHAT)
+                .system("openai")
+                .requestModel("test-model")
+                .componentScheme("langchain4j-chat")
+                .build();
+    }
+
+    private RecordingObservationHandler bindObservationRegistry() {
+        ObservationRegistry observationRegistry = ObservationRegistry.create();
+        RecordingObservationHandler handler = new 
RecordingObservationHandler();
+        observationRegistry.observationConfig().observationHandler(handler);
+        context.getRegistry().bind("observationRegistry", observationRegistry);
+        return handler;
+    }
+
+    private ObservationRegistry boundObservationRegistry() {
+        return 
context.getRegistry().lookupByNameAndType("observationRegistry", 
ObservationRegistry.class);
+    }
+
+    private RecordingTracer bindRecordingTracer() {
+        RecordingTracer tracer = new RecordingTracer();
+        CamelContextAware.trySetCamelContext(tracer, context);
+        tracer.init(context);
+        return tracer;
+    }
+
+    private static Map<String, String> lowCardinalityMap(Observation.Context 
observationContext) {
+        Map<String, String> values = new HashMap<>();
+        for (KeyValue keyValue : 
observationContext.getLowCardinalityKeyValues()) {
+            values.put(keyValue.getKey(), keyValue.getValue());
+        }
+        return values;
+    }
+
+    private static Map<String, String> highCardinalityMap(Observation.Context 
observationContext) {
+        Map<String, String> values = new HashMap<>();
+        for (KeyValue keyValue : 
observationContext.getHighCardinalityKeyValues()) {
+            values.put(keyValue.getKey(), keyValue.getValue());
+        }
+        return values;
+    }
+
+    private static final class RecordingObservationHandler implements 
ObservationHandler<Observation.Context> {
+
+        private final List<Observation.Context> started = new ArrayList<>();
+        private final List<Observation.Context> stopped = new ArrayList<>();
+        private final List<Observation.Context> errors = new ArrayList<>();
+
+        @Override
+        public void onStart(Observation.Context context) {
+            started.add(context);
+        }
+
+        @Override
+        public void onError(Observation.Context context) {
+            errors.add(context);
+        }
+
+        @Override
+        public void onStop(Observation.Context context) {
+            stopped.add(context);
+        }
+
+        @Override
+        public boolean supportsContext(Observation.Context context) {
+            return true;
+        }
+    }
+
+    private static final class RecordingTracer extends Tracer {
+
+        private final List<RecordingSpan> closedSpans = new ArrayList<>();
+
+        @Override
+        protected void initTracer() {
+            setSpanLifecycleManager(new RecordingSpanLifecycleManager());
+        }
+
+        List<RecordingSpan> closedSpans() {
+            return closedSpans;
+        }
+
+        private final class RecordingSpanLifecycleManager implements 
SpanLifecycleManager {
+
+            @Override
+            public Span create(String spanName, String spanKind, Span parent, 
SpanContextPropagationExtractor extractor) {
+                return new RecordingSpan();
+            }
+
+            @Override
+            public void activate(Span span) {
+                // noop
+            }
+
+            @Override
+            public void deactivate(Span span) {
+                // noop
+            }
+
+            @Override
+            public void close(Span span) {
+                closedSpans.add((RecordingSpan) span);
+            }
+
+            @Override
+            public void inject(Span span, SpanContextPropagationInjector 
injector, boolean includeTracing) {
+                // noop
+            }
+        }
+    }
+
+    private static final class RecordingSpan implements Span {
+
+        private final Map<String, String> tags = new HashMap<>();
+
+        Map<String, String> tags() {
+            return tags;
+        }
+
+        @Override
+        public void log(Map<String, String> fields) {
+            // noop
+        }
+
+        @Override
+        public void setTag(String key, String value) {
+            tags.put(key, value);
+        }
+
+        @Override
+        public void setComponent(String component) {
+            tags.put("component", component);
+        }
+
+        @Override
+        public void setError(boolean isError) {
+            // noop
+        }
+    }
+}
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index 1e59f43decfd..f40ca4dcbd43 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -247,6 +247,13 @@ exposes the same setting via 
`main.configure().aiObservability().withEnabled(fal
 
 Spring AI chat (`spring-ai-chat`) producers emit the same GenAI spans and 
metrics in Phase 3.
 
+When a non-NOOP `ObservationRegistry` is bound in the Camel registry, GenAI 
client calls are recorded
+as Micrometer Observations (`gen_ai.client.operation`). Camel then skips the 
`camel-telemetry` CLIENT
+span and the direct `gen_ai.client.operation` timer. Traces and the operation 
timer appear only if
+the registry has tracing and meter handlers respectively. Token usage counters 
still use
+`MeterRegistry`. Applications without an `ObservationRegistry` bean keep the 
previous OpenTelemetry
+and MeterRegistry behavior. `camel-micrometer-observability` is not required.
+
 OpenAI streaming chat sets `stream_options.include_usage=true` only when GenAI 
observability is enabled,
 adding a final chunk with token usage for span/metric recording.
 

Reply via email to