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.